Author SHA1 Message Date
TylerandClaude Fable 5 970d76a780 Introduce the platform adapter layer; extract window context behind it
Template tests / tests (pull_request) Failing after 33s
Phase 3 groundwork of the improvement plan (PR 7 of the sequence). The Linux
work is a platform rewrite, so this first establishes the interface boundary
and moves an OS-specific piece behind it with Windows behavior preserved — no
new process.platform branches in shared code.

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:10:21 -05:00
Tyler f31f1407a5 Merge pull request #9 from Twest2/StepForge pr/04-autosave-storage
Template tests / tests (push) Failing after 5m6s
Add optimistic revisions, keep dirty state on failed saves, quarantine corrupt data (plan PR 4)
2026-07-03 22:59:51 -05:00
TylerandClaude Fable 5 c1ccb5739b Add optimistic revisions, keep dirty state on failed saves, quarantine corrupt data
Template tests / tests (pull_request) Failing after 33s
Phase 1 of the improvement plan (PR 4 of the sequence): stop concurrent
whole-object saves from losing edits, stop failed saves from reporting clean,
and stop corrupt user data from silently vanishing.

Revisions (core/schema.js, core/store.js):
- Every guide and step carries a monotonic `revision`, bumped on each store
  write. Legacy v1 data without the field reads as revision 0 and upgrades on
  its next save — no migration pass, no data rewrite.
- saveGuide/saveStep accept { expectedRevision } for compare-and-swap saves;
  a mismatch throws RevisionConflictError instead of clobbering. Direct user
  edits pass no expectation (the user is the authority); background writers
  must pass one.

Stale AI responses (app/text-intel.js):
- generateStepPatch snapshots the step revision before the (slow) model call,
  re-reads the step after it, and saves with the original expectedRevision. A
  user edit made during generation now surfaces as "the step changed while AI
  was generating; nothing was overwritten" — previously the AI response
  silently overwrote the newer edit.

Autosave truthfulness (app/renderer/editor.js):
- flushStep/flushGuide cleared the dirty flag BEFORE awaiting the IPC save,
  so a rejected save (invoked via a debounce that never handled rejections)
  lost the visible dirty state. The flag is now cleared only after a durable
  save; failures keep it dirty, surface a persistent saveError in editor
  meta, toast the user, and retry on the next edit or explicit save.
- Navigating away from the editor flushes pending debounced saves so the
  last edit can never be dropped by a view switch.

Corruption quarantine (core/store.js):
- listGuides/listSteps used to silently skip unreadable entries — a corrupt
  guide just vanished from the library. Corrupt guide/step directories are
  now moved to library/quarantine (original bytes preserved) and recorded in
  a recovery report (store.getRecoveryReport()) for the UI. Empty in-progress
  directories are still skipped quietly — absence of guide.json is not
  corruption.

Tests: revision increments, stale-CAS rejection with user edit surviving,
CAS success path, guide CAS, v1 no-revision upgrade, guide/step quarantine
with preserved bytes + recovery report, empty-dir non-quarantine, AI
stale-write rejection end-to-end (user edit mid-generation survives) and the
clean-apply path. 240 unit tests pass; startup smoke and sample-artifact
E2E pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 22:57:50 -05:00
Tyler dd71cffac5 Merge pull request #8 from Twest2/StepForge pr/05-capture-fixes
Template tests / tests (push) Failing after 33s
Fix region capture, power ownership, click-source reporting, strict timing (plan PR 5)
2026-07-03 11:52:31 -07:00
TylerandClaude Fable 5 f62e3e19cb Fix region capture, power ownership, click-source reporting, strict timing
Template tests / tests (pull_request) Failing after 33s
Phase 2 of the improvement plan (PR 5 of the sequence). Several confirmed
capture defects from the audit.

Region capture:
- regionCapture returned { ok, step } wrapping storeFrameAsStep's own
  { ok, step }, so the real step was at result.step.step — region selection
  and region auto-doc (which read result.step.stepId) broke. Return the
  storeFrameAsStep result directly.
- pickRegion leaked the region:picked IPC listener (and the overlay/image
  refs) whenever the overlay was cancelled/closed rather than picked: cleanup
  only ran on a pick. Cleanup is now idempotent and runs on pick, close, load
  failure, and settle. The received rectangle is validated and clamped to the
  image (new overlayRectToImageRect handles negative-size drags and
  out-of-bounds selections) so image.crop can never read out of bounds.

Power blocker ownership:
- New single owner: CaptureService.syncPower() holds the blocker iff a session
  is actively recording, called on start/pause/resume/finish. A new session
  starts paused and no longer holds the blocker while idle; tray and
  second-instance pauses that previously bypassed main.js's stop closure now
  release it correctly. main.js provides the power policy (blocker + EcoQoS
  opt-out) via dependency injection.

Explicit click-trigger source:
- startEvdevWatcher never set clickWatcher, so state().clickCapture read false
  while evdev was actively capturing clicks. Replaced the boolean with an
  explicit clickSource (windows-hook | x11 | evdev-x11 | evdev-wayland |
  unavailable); clickCapture is now derived from it. evdev device-stream
  errors/closes now fall back via handleClickWatcherLoss instead of being
  swallowed.

Strict click timing:
- When no pre-click frame qualifies, strict mode previously fell through to a
  fresh (post-click) shot and stored it — contradicting the strict promise.
  It now skips with a capture:diagnostic instead. Non-strict (balanced) mode
  keeps the fresh-shot fallback. Existing tests that exercised the fallback
  now run in balanced mode; the strict test asserts the skip.

Other:
- pathToFileURL replaces file://${p} concatenation for step image URLs and
  export previews (correct for spaces, #, %, drive letters).
- Best-effort click-queue drain on app shutdown (before-quit) so a burst just
  before quit is not lost; bounded so quit never hangs.

Tests: region rect clamping/normalization, power-held-only-while-recording
(incl. finish releases), click-source reporting incl. evdev, drain deadline.
230 unit tests pass. Click self-test: markers 3/3 (strict) and burst 8/8
deterministic across runs; the burst scenario runs in balanced mode because
it tests the drain, not strict timing. (arm/debounce remain the pre-existing
Linux capture failures untouched by this PR.)

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 11:50:44 -07:00
Tyler c916234ae8 Merge pull request #7 from Twest2/StepForge pr/03-privacy-ai-contract
Template tests / tests (push) Failing after 32s
Enforce a truthful local-first AI/privacy contract (plan PR 3)
2026-07-03 11:39:08 -07:00
TylerandClaude Fable 5 ccbb9b03dc Enforce a truthful local-first AI/privacy contract
Template tests / tests (pull_request) Failing after 34s
Phase 1 of the improvement plan (PR 3 of the sequence). The docs claimed
"fully offline"/"never talks to the network," but text-intel makes HTTP
requests to a configurable Ollama host that could be remote, with no timeout,
no cancellation, and no size limit; the Windows hook logged raw keystrokes
into capture metadata that could then be sent to that host.

Privacy — raw keystroke capture:
- New capture.captureTypedText setting, default false. With it off, printable
  characters are never buffered in JS and the Windows keyboard hook never even
  emits them across the process boundary (the flag is threaded into the C#).
  Shortcut/navigation detection (Ctrl+T, Enter, …) is unaffected.

AI network hardening (app/text-intel.js):
- Every Ollama call goes through fetchJson with an AbortController deadline
  (ai.timeoutMs, default 60s): a dead endpoint fails fast instead of leaving
  UI actions pending forever.
- Cancellation: in-flight requests are tracked and cancelInflight(guideId)
  aborts them; new ai:cancel IPC + api.ai.cancel are called when the editor
  closes, and shutdown cancels everything.
- Bounded concurrency (2) for AI network work.
- Screenshots are only attached when allowed (ai.attachScreenshots), the model
  is vision-capable, and the image is within ai.maxImageBytes — no more
  unbounded base64-expanded 4K bodies.

Local-first host policy (core/text-intel.js):
- New isLoopbackHost + validateOllamaHost. By default only a loopback Ollama
  endpoint is contacted; a remote host is refused with a clear message unless
  ai.allowRemoteHost is explicitly enabled. Blocked hosts are never contacted.

Honest documentation:
- README, package.json, and the welcome screen drop "fully offline"/"never
  talks to the network"/"Electron is the only dependency" for an accurate
  local-first contract that discloses the optional AI path and the bundled
  Tesseract OCR dependency.
- New docs/PRIVACY.md details exactly what is collected locally and the one
  outbound (opt-in, loopback-by-default) AI feature.

Tests: loopback/remote host matrix, remote-blocked-without-opt-in (and never
contacted), remote-allowed-with-opt-in, request timeout, explicit cancel vs
timeout, typed-text off-by-default vs opted-in, shortcut detection still works,
and a source guard that the C# CHAR emission stays behind the opt-in. 224 unit
tests pass; startup smoke and workflow E2E pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 11:36:01 -07:00
Tyler 6ffef69705 Merge pull request #6 from Twest2/StepForge pr/02-security-boundary
Template tests / tests (push) Failing after 2m37s
Close the renderer privilege boundary: navigation, IPC, permissions, shell (plan PR 2)
2026-07-03 11:27:28 -07:00
TylerandClaude Fable 5 50e445e7c3 Close the renderer privilege boundary: navigation, IPC, permissions, shell
Template tests / tests (pull_request) Failing after 33s
Phase 1 of the improvement plan (PR 2 of the sequence). A remote page could
previously inherit the privileged preload bridge: the main window had no
navigation guard or popup handler, IPC handlers accepted any sender, every
Electron permission was granted to everyone, and shell:openPath accepted an
arbitrary renderer-supplied target.

- New app/security.js (plain-Node testable) centralizes the policy:
  app-page identity, navigation/popup denial, deny-by-default permissions,
  external-URL validation, IPC sender guard, payload budgets, field
  validators, and a produced-files registry.
- Every window (main, region overlay, capture worker) is sandboxed, denies
  all navigation away from its own page, denies every popup, and refuses
  webview attachment.
- Every IPC channel now rejects events that are not from the main window's
  top frame on index.html, rejects non-plain/oversized argument bags, and
  channels with risky inputs validate ids, enums, names, and sizes
  (path-traversal and prototype-pollution guards included).
- Permissions are deny-by-default; the only grant in the app is display
  capture (+media) for the dedicated capture-worker page. The display-media
  handler also verifies the requesting frame.
- shell:openPath/showItemInFolder are gone. Replacements are intent-specific:
  openProduced (only files the main process produced this session),
  revealLinkedArchive (path read from the store, not the renderer), and
  openExternal (parsed, scheme-checked http(s)/mailto only).
- archive:peek removed: unused, and it let the renderer read arbitrary
  archives by path.
- export:run only accepts output directories that came from a dialog pick or
  remembered settings; anything else re-prompts.
- Renderer: description links never navigate; http(s)/mailto open externally
  via the validated handler.
- 12 new security regression tests: hostile navigation targets, permission
  matrix, sender spoofing (wrong window/subframe/navigated/disposed frames),
  oversized payloads, traversal/pollution attempts, produced-file registry,
  and source-level guards (no blanket grants, no generic shell channels,
  sandbox on every window).

Verified: 215 unit tests pass; startup smoke, unit-workflows, sample
artifacts, and build-release E2E pass; click self-test still reaches
source: stream with markers 3/3 and burst 8/8 under the deny-by-default
policy (arm/debounce remain the known pre-existing failures, untouched
here); UI screenshot confirms the sandboxed renderer boots.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 11:25:36 -07:00
Tyler 6a3005f24c Merge pull request #5 from Twest2/StepForge pr/01-toolchain-ci
Template tests / tests (push) Failing after 33s
Pin Node toolchain, remove runtime npm repair, make CI and E2E truthful (plan PR 1)
2026-07-03 11:12:29 -07:00
TylerandClaude Fable 5 0f966a5fd0 Pin the Node toolchain, remove runtime npm repair, make CI and E2E truthful
Template tests / tests (pull_request) Failing after 1m26s
Phase 0 of the improvement plan (ai_prompts/prompt4.md): make the baseline
reproducible and stop the test runner from masking real failures.

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

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

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 13:09:51 -07:00
Tyler 534a28ece8 Merge GitHub main and keep Linux WIP
Template tests / tests (push) Failing after 33s
2026-06-29 08:22:40 -05:00
Tyler c2e05c900f Remove linux support
Template tests / tests (push) Successful in 2m0s
2026-06-29 08:11:43 -05:00
Tyler 7c006a7bb7 Fix Windows installer and build versioning 2026-06-26 22:37:13 -05:00
Tyler 999f4a13b8 Minor fixes 2026-06-26 22:21:53 -05:00
Tyler 3356b935fc Minor fixes 2026-06-26 22:10:54 -05:00
Tyler 749f8d2d0d Add back red circle to windows 2026-06-26 21:38:22 -05:00
Tyler dd49e42290 Fix npm test glob on CI 2026-06-26 18:41:12 -05:00
Tyler 266a92fedb Fix windows breakage from linux dev 2026-06-26 18:38:03 -05:00
Tyler 0325b6efbc ubuntu not working. idk I'm just gonna use windows anyway 2026-06-26 18:00:22 -05:00
Tyler 3c5c520799 work on linux release
Linux release not fully working as well as windows, will investigate later
2026-06-26 17:02:35 -05:00
Tyler 962f929de2 fixed recording workflow on ubuntu
ai gen description: Root cause 1 — The mandatory constraint removal (most critical)

In Electron 29+ / Chromium 116+, the old getUserMedia format with a mandatory: {} wrapper was removed. The capture worker was still using it:

video: { mandatory: { chromeMediaSource: 'desktop', ... } }  // broke in Electron 29+
This caused getUserMedia to throw in the worker, which sent a stream-error back to the main process, which fell back to the frame loop. Fix: flat constraint format with no mandatory wrapper.

Root cause 2 — Frame loop spammed getSources() every 200ms

Once the stream backend failed, the fallback frame loop called desktopCapturer.getSources() 5× per second. On Linux via the XDG portal, each getSources() call is a new permission request → dialog every few seconds. Fix: on Linux without click capture, don't fall back to the frame loop at all — just let interval/hotkey captures take individual fresh shots.

Root cause 3 — Interval/hotkey captures bypassed the stream backend

Even when the stream backend is running, sessionCapture('interval') went straight to shoot() → grab() → getSources(). Fix: non-click triggers now pull a buffered frame from the stream backend's ring buffer (sampled every 100ms), completely avoiding getSources().

Root cause 4 — Stream backend never started on Wayland

The earlier fix made recorderWanted = false on Wayland, so the stream backend never even started. Fix: recorderWanted is now true whenever stream capture is enabled (the default), regardless of whether click detection is available.

Safety net — setPermissionCheckHandler

Electron 29+ requires an explicit permission grant for display-capture in renderer windows. Added a handler that grants all permissions (safe for a fully local/offline app like StepForge).

Windows is completely unaffected — the constraint fix is Electron-version level (affects both platforms the same), and all the Linux-specific frame-loop guards only fire when clickCaptureAvailable() returns false, which only happens on Wayland.
2026-06-26 16:11:16 -05:00
Tyler a5a8498e71 Keep recording bar in ubuntu 2026-06-26 15:49:49 -05:00
Tyler 32788fafdc fixed ubuntu (Wayland) recording workflow 2026-06-26 15:48:09 -05:00
TylerandGitHub 412a4f4820 Merge pull request #4 from Twest2/feature/ai-vision-hybrid
Feature/ai vision hybrid
2026-06-26 08:52:52 -05:00
Tyler 3915865029 Keep ai model saved when app closes. 2026-06-26 08:48:38 -05:00
Tyler 79edb6bb56 Change model
Ran into an issue with the previous model for having mllama archetecture so I changed it
2026-06-26 08:36:45 -05:00
Tyler a971ff870f Added feature to where local ai models can see the image in each step. 2026-06-26 08:05:18 -05:00
TylerandGitHub 9bd4a959c4 Semantic capture titles, AI documentation assist, and keyboard tracking (#3)
Template tests / tests (push) Successful in 1m58s
Semantic capture titles, AI documentation assist, and keyboard tracking
2026-06-24 12:11:11 -05:00
TylerandClaude Sonnet 4.6 ebeb6ac578 label Enable AI setting as experimental
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 12:04:40 -05:00
TylerandClaude Sonnet 4.6 f60cf1a688 label generate-all-steps menu item as experimental
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 12:02:59 -05:00
TylerandClaude Sonnet 4.6 2d1b474931 fix generate-all-steps: never overwrite existing content, right target per step
The previous version called target:'all' on every step unconditionally,
which caused the AI to rewrite steps that already had good titles and
descriptions — often making them worse or inaccurate.

New behaviour:
- Build a queue of only the steps that are actually missing content
  (placeholder title and/or empty description).
- Determine target per step: 'title', 'description', or 'all' — only
  fill what is blank; leave existing user-written text completely alone.
- If all steps already have titles and descriptions, show a toast and stop.
- Call reload() once at the end instead of patching this.steps mid-loop,
  so the editor and step list both update atomically from the store.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 11:27:24 -05:00
TylerandClaude Sonnet 4.6 c60b7247af generate all text fields with AI now fills every step, not just current
Previously the menu action only ran AI generation on the selected step.
Now it loops through all steps sequentially, showing progress ("AI:
filling step N of M…"), then reloads the visible step when done.
Partial failures are reported in the final toast.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 11:22:27 -05:00
TylerandClaude Sonnet 4.6 cf056d2b97 stop AI from generating text/code/table blocks on generate-all
The model was producing Text and Code blocks on every 'generate all
fields' action — uninstructed, generic, and never useful for the user.

Blocks are only appropriate when the user explicitly edits an existing
block (target === 'block'). For title/description/all generation:
- Schema collapses to { "title", "description" } — no blocks key
- targetText no longer mentions blocks
- allowedBlockNote is null (omitted from prompt)
- Rule explicitly says "Do NOT add any blocks array"

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 11:18:41 -05:00
TylerandClaude Sonnet 4.6 3fa366a0d0 revert UIAutomation background thread — it stalled click detection
TryLoadUia() blocked for several seconds loading reflection assemblies at
ClickProcessorLoop startup, during which no CLICK events were emitted.
Restored MouseHookCallback to emit CTX + CLICK synchronously (fast Win32
only) as before — no startup cost, clicks are never delayed.

The wider OCR crop and smarter search-results title fallback are kept.
UIAutomation element lookup can be revisited with a pre-warmed approach.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 11:14:49 -05:00
TylerandClaude Sonnet 4.6 d0b1816175 fix step-behind titles: UIAutomation element label from click watcher + smarter search fallback
**Root cause**
When OCR fails, the title fell back to the browser window title.  For a
click on a search-results page, the window title reflects the *previous*
search query ("oracle - Google Search"), producing "Search for Oracle"
even though the user is clicking a link *on* that page.

**Fix 1: UIAutomation element label from the click watcher**
The C# click-watcher hook now enriches each click in a background thread
(ClickProcessorLoop) rather than in the hook callback:
- MouseHookCallback captures window title synchronously (fast Win32),
  then queues a PendingClick and returns immediately.
- ClickProcessorLoop calls AutomationElement.FromPoint() via reflection
  (no compile-time assembly reference → no startup failure if UIA is
  absent).  Wrapped in a 300ms timeout thread so slow UIA calls don't
  delay the click event past the frame buffer window.
- Emits CTX + ELEM (label/role/value) + CLICK as an atomic batch.

Node.js:
- Parses ELEM events, merges element info into _pendingWindowContext.
- clickMeta.windowContext now carries elementLabel/elementRole/elementValue
  in addition to windowTitle/appName.
- buildCaptureTitle priority-5 (element label) now fires from click-watcher
  data, giving "Select Oracle | Cloud Applications…" without OCR.

**Fix 2: Wider OCR crop**
ocrAroundClick now uses a full-display-width × 100px horizontal strip at
the click height.  The previous 420 px crop cropped through long link text
(e.g. "Oracle | Cloud Applications and Cloud Platform"), causing fragments
to be scored lower than the complete text.

**Fix 3: Search-results window title fallback**
extractSearchQuery now only produces "Search for Oracle" when recentTyped
is non-empty (the user was actually typing a query).  For a pure click on
the search-results page (no recent typing), the fallback is "Select a
Oracle result in Chrome" — honest about what we know without implying the
user performed the search in this step.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 11:05:34 -05:00
TylerandClaude Sonnet 4.6 dfa65c894b ci: only run tests on push to main, not on PR commits
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 10:53:30 -05:00
TylerandClaude Sonnet 4.6 162ae0ae05 improve AI rewrite UX and title quality
**AI button: dynamic tooltip hints**
- titleAiBtn and descAiBtn now show "Rewrite with AI" when the field
  already has user content, "Generate with AI" when empty.
- updateAiButtonHints() fires on every title/description input event
  and whenever syncStepFields() runs to keep hints current.

**Stronger rewrite prompt**
- When the user has typed a draft title or description, the prompt now
  shows it explicitly: "User's draft title (rewrite this): '...'"
- Rules changed from "improve its wording" to "Your only job is to
  polish its grammar and phrasing. Do NOT replace it with something
  different." — prevents the model from ignoring the user's text and
  generating fresh content from capture context.
- The suggested-title hint is suppressed when a draft title exists so
  the model doesn't silently swap the user's text for the auto-title.

**Title quality: generic window title filter**
- GENERIC_WINDOW_TITLES Set filters "New Tab", "Untitled", "Loading"
  etc. from the window-title path so they no longer produce titles
  like "Open New Tab in Chrome".

**Title quality: app name stripping for non-browser apps**
- stripBrowserNameSuffix now accepts an optional appName; it strips
  the app's display name and process name from the window title suffix
  using the same pattern as browser names.
- "Document1.docx - Word" with appName "winword" → "Document1.docx".
- buildCaptureTitle passes metadata.appName into the strip call.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 10:42:56 -05:00
TylerandClaude Sonnet 4.6 879434f14f fix: capture window title from click watcher instead of spawning PowerShell
Root cause of all-"Screen capture" titles: collectWindowsWindowContext
called execFileSync('powershell.exe') with a 1200 ms timeout, but
PowerShell cold-start on typical Windows systems takes 1-3 seconds.
Every capture timed out silently, returned empty metadata, and fell
back to "Screen capture".

Fix:
- The click watcher C# process (already running, already compiled)
  now emits a CTX event immediately before each CLICK event using
  GetForegroundWindow → GetWindowText + QueryFullProcessImageName.
  These are synchronous Win32 calls, sub-millisecond, no startup cost.
- CTX payload is base64-encoded to survive the line protocol safely:
    CTX <b64title> <b64app> <unixMs>
- processClickWatcherData parses CTX lines and stores the decoded
  strings in this._lastWindowContext.
- enqueueClickCapture attaches it as clickMeta.windowContext.
- buildCaptureContext uses it directly (Promise.resolve) when present,
  bypassing the PowerShell spawn entirely.

Fallback (manual captures without a session click watcher running):
- collectWindowsWindowContext is now async and uses execFile with a
  4 s timeout instead of execFileSync at 1200 ms, so it no longer
  blocks the event loop and has room to succeed on slower machines.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 10:16:11 -05:00
TylerandClaude Sonnet 4.6 13b5f67de8 add keyboard tracking and app-qualified smart titles
**Keyboard hook (Windows)**
- Extends the existing C# WH_MOUSE_LL process to also install
  WH_KEYBOARD_LL alongside it (keyboard hook is optional — failure
  does not break mouse capture).
- Emits CHAR <code> <ts> for printable keystrokes, KEY <name> <ts>
  for modifier combos (Ctrl+T) and special keys (Backspace, Enter).

**Text accumulation in capture session**
- CaptureService tracks _keyBuffer (typed chars since last step) and
  _lastShortcut (last modifier combo) using the new onKeyboardEvent()
  method.
- snapshotKeyContext() is called at enqueueClickCapture time so each
  step's clickMeta.keyContext carries { recentTyped, recentShortcut }.
- Buffer resets after each snapshot; stale input (>8s gap) is dropped.

**UIAutomation element value**
- collectWindowsWindowContext now reads ValuePattern.Current.Value
  from the clicked element — giving us what's actually typed in a
  search box or text field without needing the keyboard buffer.

**Smart title generation (core/text-intel.js)**
- Priority chain: keyboard shortcut → element value → typed text
  → OCR → element label → page title → app name.
- SHORTCUT_TITLES maps 50+ common shortcuts (Ctrl+T, Ctrl+S, F5 …)
  to natural language descriptions: "Open new tab", "Save", etc.
- qualifyTitleWithApp() appends "in Chrome / VS Code / Terminal / …"
  to every title when the app is known: "Click Save in VS Code",
  "Search for oracle in Chrome", "Open new tab in Chrome".
- APP_DISPLAY_NAMES covers browsers, editors, terminals, office apps.

Six new unit tests cover shortcuts, typed-text search, element value,
and app-qualified OCR titles. Capture test updated for keyContext.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 10:04:34 -05:00
TylerandClaude Sonnet 4.6 8d645c0aca fix smart title generation from browser window context
The title engine was falling back to "Screen capture" for all browser
captures because it treated the entire browser window title as noise.

Changes:
- stripBrowserNameSuffix: removes "- Google Chrome" / "| Firefox" etc.
  from the end of window titles, leaving just the page title.
  "oracle - Google Search - Google Chrome" → "oracle - Google Search"
  "Oracle | Cloud Applications - Google Chrome" → page title only

- extractSearchQuery: detects "[query] - Google Search" / Bing / etc.
  patterns after stripping the browser suffix, and formats the result
  as "Search for oracle".

- buildCaptureTitle: uses stripped page title + search detection before
  falling through to the "Screen capture" fallback.

- pickBestOcrPhrase: considers the full OCR line (≤80 chars) as a
  candidate with a +35 completeness bonus before splitting on | or ·.
  This preserves "Oracle | Cloud Applications and Cloud Platform" as a
  single phrase instead of breaking it into fragments.

- candidateWords: filters out standalone punctuation tokens (|, ·, •)
  so they don't inflate word-count penalties for compound brand names.

- verbForElementRole: hyperlinks and links now produce "Select" instead
  of "Click"; search box / search field produces "Search for".

Five new unit tests cover: browser title stripping, search query
extraction, full pipe-separated link text, link and search box verbs.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 09:44:36 -05:00
TylerandClaude Sonnet 4.6 68ceee83ef fix ai title generation and remove separate rewrite section
- Remove the standalone "AI Rewrite" section from the editor panel.
  The existing title and description AI buttons already flush the step
  first, so they rewrite whatever the user has typed in those fields.

- Never pass a generic fallback title ("Screen capture", "Window
  capture", "Region capture", "Capture") as the AI title candidate or
  as step content. It is now treated as "(not set — generate a
  specific action title)" so the AI always produces something real.

- hasRichCaptureContext now counts any non-trivial app name or window
  title as sufficient context, instead of requiring non-browser noise.

- Prompt rules updated: "NEVER output Screen/Window/Region capture",
  separate paths for improving a user draft vs generating from context,
  and clearer guidance when context is limited (use app/window name).

- isPlaceholderTitle helper guards summarizeStepForAi so a
  default-titled step presents itself as empty to the AI.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 09:27:23 -05:00
TylerandClaude Sonnet 4.6 d244626583 ai smart auto-documentation and text rewrite
- Store captureMetadata (OCR text, window/app/element info) with each
  step at capture time so AI always has the original rich context.
- Add buildCaptureContext() to TextIntelService; capture.js uses it
  instead of buildCaptureTitle() so both title and metadata come from
  one pass.
- generateStepPatch() prefers stored captureMetadata over re-running
  OCR, giving the AI the best possible context when the user clicks
  an AI button later.
- Add autoDoc setting: when enabled every capture (shoot, region, and
  session hotkey/click) is automatically documented by AI. Manual
  captures await AI before returning; session captures fire-and-forget
  and push a step:updated event so the renderer reloads seamlessly.
- Add ai:rewriteText IPC and rewriteText() method for plain-text
  polishing via a separate callOllamaText() that skips JSON mode.
- Add "AI Rewrite" section in the editor right panel: textarea + AI
  button that rewrites whatever the user types in place.
- Improve buildAiPrompt() rules: action-focused title instructions,
  explicit anti-junk rules (no "Capture the screen / OCR" blocks),
  and a context-quality gate that suppresses blocks when context is
  thin.
- Add autoDoc checkbox to AI settings dialog.
- Renderer handles step:updated to reload the selected step after
  background auto-doc finishes.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-24 09:17:31 -05:00
Tyler 375c14b028 prefer OCR for capture titles 2026-06-24 08:55:19 -05:00
Tyler 1a1dbb846b fix settings AI test connection wiring 2026-06-23 18:14:12 -05:00
Tyler 0bd326d8b2 harden Electron startup repair 2026-06-23 18:10:35 -05:00
Tyler bd10f7d147 lazy load ocr dependency 2026-06-23 18:02:24 -05:00
Tyler 33eb0f745e semantic titles ai assist 2026-06-23 17:46:33 -05:00
TylerandGitHub cb5e0c6837 Merge pull request #2 from Twest2/text_locations
Honor text block positions in exports
2026-06-23 16:57:06 -05:00
Tyler ffaa123893 Honor text block positions in exports 2026-06-23 16:44:28 -05:00
Tyler 2b27428849 minor changes
Template tests / tests (push) Successful in 1m44s
2026-06-23 16:26:13 -05:00
Tyler 4cbab15429 Update README.md to point to new getting started documents
Template tests / tests (push) Successful in 1m42s
2026-06-23 12:18:40 -05:00
Tyler 063624ea57 Add and update TODO 2026-06-23 12:16:51 -05:00
Tyler 7fe30cf8ef Windows getting started docs with stepforge example
Template tests / tests (push) Successful in 1m44s
2026-06-23 12:10:42 -05:00
Tyler d607905b58 Disable electron-builder publish for Windows installer
Template tests / tests (push) Successful in 1m42s
2026-06-23 11:15:56 -05:00
Tyler 0f25d327c0 Disable electron-builder publish for Windows installer
Template tests / tests (push) Successful in 1m42s
2026-06-23 11:10:22 -05:00
Tyler 058582d7af Add Windows installer packaging
Template tests / tests (push) Successful in 1m40s
2026-06-23 11:01:43 -05:00
Tyler f4278d118c Add optional Windows code signing env handling
Template tests / tests (push) Successful in 1m48s
Signed-off-by: Twest2 <[email protected]>
2026-06-23 10:00:28 -05:00
Tyler c2247a57c7 Bulk documentation edits
Template tests / tests (push) Successful in 1m44s
2026-06-22 09:50:29 -05:00
Tyler d89abf5b78 Arm capture when creating a guide from the library
createGuide() opened the new guide with plain openGuide(), so no capture
session was armed — the "Start recording" bar never bound to the new guide,
and clicking it did nothing (or resumed a stale prior session). Every other
open path (guide cards, search results, New Capture) arms a paused session;
this aligns createGuide() with them via openGuideAndArmCapture().
2026-06-16 13:13:32 -05:00
Tyler 845b3ed205 Add GitHub Actions: CI and a manual release workflow
- ci.yml: runs the unit test suite (npm test → node --test tests/unit) on
  push and PR to main, across ubuntu/windows/macos with Node 20. Electron is
  installed normally because the capture unit tests require app/capture.js,
  which require()s the electron module at load.

- release.yml: manual workflow_dispatch. Enter a version tag (e.g. v0.1.1);
  it builds the Windows portable .exe (npm run package:windows) and the Linux
  tarball + .deb (scripts/package-linux.sh), stamps the requested version onto
  each artifact, then publishes a GitHub Release at the current commit with
  the artifacts attached and auto-generated notes. Supports a prerelease flag.
2026-06-16 12:39:19 -05:00
Tyler 3def598528 Tighten battery capture: cover lazily-spawned capture processes, widen warmup
Follow-up polish after the EcoQoS fix landed and recordings improved:

- Re-apply the EcoQoS opt-out when the stream backend reports it is active.
  Chromium spawns/upgrades its GPU and screen-capture utility processes only
  once the desktop stream actually starts — after the session-start sweep —
  so they could still be born throttled. Hooking the capture:state transition
  to 'stream' catches them the moment they appear.

- Raise the warmup cap from 1500ms to 3000ms. The recorder window stays
  visible during warmup (the user hasn't started their workflow yet) and the
  common path still proceeds the instant the stream is ready, so the extra
  headroom only matters when stream startup is slow on battery — where it
  buys a real pre-click frame for the very first click instead of a
  post-click fresh shot.
2026-06-16 08:15:46 -05:00
Tyler f580759337 Stop battery EcoQoS from starving frame capture during recording
Template tests / tests (push) Successful in 1m41s
Confirmed cause: on battery in a power-saving plan, Windows applies Power
Throttling (EcoQoS) to background work. StepForge records with its window
hidden, so the frame-capture worker renderer (plus the GPU and screen-capture
utility processes feeding it) get CPU-throttled. The throttled worker can't
sample the screen fast enough, so every click finds no fresh pre-click frame
and falls back to a slow post-click fresh shot — producing out-of-order,
dropped steps. It only reproduced on battery; plugged in (where EcoQoS stays
off even in eco mode) it worked. The capture log showed every click detected
but every one reporting "no frame qualified", with a ~4.6s stream startup.

Fixes:
- Chromium switches (disable-background-timer-throttling,
  disable-renderer-backgrounding, disable-backgrounding-occluded-windows) so
  Chromium stops de-prioritising and timer-throttling the hidden worker.
- New app/win-power.js opts the OS processes out of EcoQoS via
  SetProcessInformation(ProcessPowerThrottling, EXECUTION_SPEED off) and
  raises them to high priority. Applied to all live Electron processes when a
  session starts/resumes, and to the worker renderer the moment it is created
  (before it begins streaming). Best-effort, no-op off Windows.

The earlier mouse-hook EcoQoS opt-out already fixed click *detection*; this
fixes the frame-capture side that the same throttling was breaking.
2026-06-15 18:45:50 -05:00
Tyler 94f14851fa Keep mouse-hook process out of EcoQoS so clicks aren't missed in power-saving mode
Template tests / tests (push) Successful in 1m41s
The real cause of "only the first couple of clicks were captured while on
power saving mode": Windows Power Throttling (EcoQoS) CPU-starves background
processes under a power-saving plan. The low-level WH_MOUSE_LL hook lives in
the spawned PowerShell watcher; when its callback is starved past the system
LowLevelHooksTimeout, Windows silently stops delivering mouse events to it —
the process keeps running (so no exit/crash, no fallback to interval capture
fires), it just misses clicks, and those clicks never become steps.

The previous powerSaveBlocker('prevent-app-suspension') only maps to
SetThreadExecutionState, which blocks system sleep but does NOT opt a process
out of EcoQoS, so it couldn't fix this.

Have the watcher process opt itself out of execution-speed throttling via
SetProcessInformation(ProcessPowerThrottling, ...) and raise itself to
HIGH_PRIORITY_CLASS at startup, so the hook callback always runs at full
speed and every click is delivered regardless of the laptop's power mode.
2026-06-15 18:29:12 -05:00
Tyler 5edf740fda Fix capture degrading in eco/power-saving mode; decouple from display refresh rate
Template tests / tests (push) Successful in 1m47s
Two changes:
- Hold powerSaveBlocker.start('prevent-app-suspension') for the duration of
  each recording session. Windows Power Throttling (EcoQoS) can CPU-starve
  the capture-worker renderer mid-session, causing stream frame requests to
  time out and degrade to the slower in-process legacy loop. The blocker
  signals to the OS that this is an active workload and keeps the stream
  worker and main-process event loop running at full speed. Released on
  pause and finish.
- Remove maxFrameRate: 30 from the getUserMedia constraint in the capture
  worker. The actual sampling rate is driven entirely by the setInterval
  timer, so the stream's native frame rate is irrelevant to capture
  correctness. Capping at 30 could silently reduce stream delivery on some
  drivers when the display itself runs at ≤30 Hz (common in power-saving
  mode), leaving the ring buffer with fewer fresh frames at click time.
2026-06-15 18:20:57 -05:00
Tyler c39262c3fa Fix global placeholders save crashing with non-serializable error
Template tests / tests (push) Successful in 1m40s
The placeholders:globals:set IPC handler destructured { values } from
its args, but the renderer sends the placeholders object directly,
so values was always undefined and JSON.stringify(undefined) threw.
2026-06-15 18:08:16 -05:00
Tyler 46d0d622ca Redesign hotkey settings as press-to-record keycap widgets
Template tests / tests (push) Successful in 1m44s
Replace the raw accelerator-string text inputs in Settings > Capture
with a polished "click and press a key combination" control that
renders the shortcut as keycap chips with a clear button.
2026-06-15 17:59:08 -05:00
TylerandClaude Sonnet 4.6 204be178a3 Fix cramped spacing of the Quick Actions search hint
Template tests / tests (push) Successful in 1m42s
The "Type to search, arrows to move, Enter to open." hint had no
margin, so it sat flush against the search input above while the
results list below it had a 10px margin + 8px padding before its
divider - lopsided spacing. Make .quick-actions a flex column with a
uniform 8px gap so the hint sits evenly between the input and the
results divider.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 17:39:39 -05:00
TylerandClaude Sonnet 4.6 f37a2ec141 Fix DOCX table of contents to render real entries instead of a placeholder
Template tests / tests (push) Successful in 1m44s
Previously the "Contents" page just emitted a TOC field whose cached
result was the literal text "Update contents in Word" - so unless a
user manually ran Update Field in Word, the TOC showed nothing useful
(and many viewers never run that update at all).

Now each step heading is wrapped in a bookmark, and the TOC is built
as real TOC1/2/3-styled paragraphs with hyperlinks to those bookmarks
and PAGEREF fields for page numbers, matching the entry list other
exporters already produce via tocEntries(). The whole thing stays
wrapped in the original `TOC \o "1-3" \h \z \u` field so Word can still
refresh page numbers, but the document is correct on first open even
without that step.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 17:26:50 -05:00
Tyler a201b05c9e Merge pull request 'Add guide metadata, redesign PDF cover, paginate steps, and run exports in a helper process' (#19) from export_changes into main
Template tests / tests (push) Successful in 1m40s
Reviewed-on: Tyler/autodoc#19
2026-06-15 22:10:32 +00:00
TylerandClaude Sonnet 4.6 5b54305b9f Show a loading spinner on the Export/Preview buttons while exporting
The export dialog's Export and Preview buttons now disable and show a
spinner with status text while the corresponding async operation is
in flight, so the user knows the (sometimes slow) export is running
and the app isn't stuck.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 17:07:35 -05:00
Tyler 3de8ec978b Render PPTX callouts and normalize markdown text 2026-06-15 14:58:16 -05:00
Tyler 4f74b03324 fix docx toc and callouts 2026-06-15 14:46:39 -05:00
Tyler 7da39831ab remove markdown callout fill 2026-06-15 14:41:55 -05:00
Tyler ad3348f721 paginate pptx toc slides 2026-06-15 14:40:16 -05:00
Tyler d67afd2bc9 docx headings for toc 2026-06-15 14:37:02 -05:00
Tyler f79bbfed9f Style markdown callouts with HTML 2026-06-15 14:32:14 -05:00
Tyler 61e3c812a0 Polish exported documents and TOCs 2026-06-15 14:22:31 -05:00
Tyler b84b7883fe Fix export dialog format handling 2026-06-15 14:03:32 -05:00
Tyler b85eaad0cb Indent nested guide editor steps 2026-06-15 14:02:34 -05:00
Tyler 8cc1ba3532 Add Wiki.js markdown export option 2026-06-15 13:59:04 -05:00
Tyler ab280daf63 Style PDF cover and TOC links 2026-06-15 13:49:31 -05:00
Tyler b51a4c46ce Make PDF Contents entries clickable links to each step's page
Adds PdfBuilder.linkRect() for /Subtype /Link annotations with a
/Dest pointing at another page's destination. TOC entries record a
target placeholder that the per-step loop fills in once it knows
which page the step landed on, so clicking a Contents line jumps the
reader straight to that step.
2026-06-15 13:22:51 -05:00
Tyler 7229b154c9 Merge branch 'main' into export_changes 2026-06-15 13:11:35 -05:00
TylerandClaude Sonnet 4.6 722c5d2eee Add a Description field to the Guide information dialog
Reuses the guide's existing descriptionHtml/descriptionText, which
already renders on the PDF cover and at the top of every other export
format, so it surfaces alongside author/co-authors/organization with
no exporter changes needed.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 13:04:35 -05:00
TylerandClaude Sonnet 4.6 0b490e2aa9 Add guide metadata, redesign PDF cover, paginate steps per-page, and run exports in a helper process
Lets users record author/co-authors/organization for a guide via a new
"Guide information…" dialog; this metadata renders below the title on
the PDF cover (title now sits above the accent rule). PDF export also
paginates so each step fits its own page where possible, keeps a
step's title/image/lead-in together, and forces the next step onto a
fresh page after an oversized step overflows. Exports now run in a
forked helper process so large guides no longer freeze the UI.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-15 12:54:31 -05:00
Tyler e588f93a19 Merge pull request 'Mass guide editor changes' (#18) from guide_editor_changes into main
Template tests / tests (push) Successful in 1m39s
2026-06-15 15:37:48 +00:00
Tyler 4732c5daf3 Limit CI test run to main and manual triggers
Pushes to main (direct commits and merged PRs) and manual
workflow_dispatch now trigger the test job; feature-branch pushes and
PR open/sync no longer do.
2026-06-15 10:29:26 -05:00
Tyler db2382294e Document the Undo/Redo shortcut in the keyboard shortcuts help
Template tests / tests (push) Successful in 1m41s
Template tests / tests (pull_request) Successful in 1m40s
Now that undo also restores deleted steps, it's worth surfacing the
existing Ctrl+Z / Ctrl+Shift+Z shortcut to users.
2026-06-15 10:26:56 -05:00
Tyler 58b4182c54 Add global undo for step deletion; simplify capture session UI
Template tests / tests (push) Successful in 1m41s
Template tests / tests (pull_request) Successful in 1m39s
- The canvas Undo/Redo buttons now also undo/redo step deletion
  (single or multi-select), restoring the step's data, images, and
  position in the order. Backed by a new step:restore IPC call and
  GuideStore.restoreStep().
- Remove "Finish capture session" from the capture menu. The
  top-right recording bar in the guide editor is now the only place
  to start/stop recording, with its toggle relabeled
  "Start recording" / "Stop recording".
2026-06-15 10:15:15 -05:00
Tyler 88d1fc3efb Spell out "Highlight" in the annotation toolbar instead of "Hi"
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m44s
2026-06-15 09:50:34 -05:00
Tyler 2bb17b170e Use display labels for annotation types in the Annotations list and copy-style text
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m40s
The per-annotation list cards and the "Copy this style to every other
... annotation" text/tooltips were still showing raw type ids (rect,
arrow, etc). Use ANNOTATION_TYPE_LABELS for these too.
2026-06-15 09:46:31 -05:00
Tyler 6408040623 Show proper display names in the annotation Type dropdown
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m39s
The annotation editor's "Type" select used the raw lowercase type id
(e.g. "rect") as its label. Add an ANNOTATION_TYPE_LABELS map so it
shows "Rectangle", "Tooltip", "Number", etc. instead.
2026-06-15 09:43:09 -05:00
Tyler a3425c36b4 Spell out "Rectangle" in tool labels instead of "Rect"
Template tests / tests (push) Successful in 1m41s
Template tests / tests (pull_request) Successful in 1m39s
Keeps the internal 'rect' annotation type id (canvas/raster/schema)
unchanged; only updates the user-facing toolbar button and shortcuts
help text.
2026-06-15 09:38:24 -05:00
Tyler 5d4925dee4 Fix export block ordering, stale per-step UI, and cross-step block loss
Template tests / tests (push) Successful in 1m42s
Template tests / tests (pull_request) Successful in 1m41s
Exporters now interleave text/code/table blocks in the same order they
appear in the editor's Blocks panel (via a shared stepContentGroups
helper) instead of grouping by kind, so exported docs match the guide
editor's ordering.

selectStep() now also refreshes the Focused View controls and Blocks
panel (previously only done by renderAll), so switching steps no longer
leaves the previous step's blocks/focused-view sliders on screen. It
also flushes any pending edits on the outgoing step before switching, so
a later guide-wide reload (e.g. applying an annotation style to the
whole guide) can't discard unsaved text-block edits on other steps.
2026-06-14 09:11:00 -05:00
Tyler 06fba24645 Use GitHub-Flavored Markdown alert syntax for Note/Tip/Warning/Important blocks
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m40s
Markdown export now emits > [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] for
text-block callouts, giving them the same colored/icon-labeled treatment
as the PDF/HTML/DOCX exports on renderers that support GFM alerts
(GitHub, Azure DevOps wikis, etc.), while degrading gracefully to a
plain blockquote elsewhere.
2026-06-13 23:11:02 -05:00
TylerandClaude Sonnet 4.6 fee394f6b7 Add quote indicator and distinct callout styling for Note/Tip/Warning/Important
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m42s
- Description editor: blockquotes get a left accent line and muted color so
  it's clear when typing in "quote mode" (matches the existing active Quote
  toolbar button highlight).
- Editor block cards: each text block's left border is colored by its level
  (info/success/warn/error) so Note/Tip/Warning/Important are distinguishable
  at a glance.
- PDF/HTML/DOCX exports: callouts now get a level-specific accent color and
  tinted background/shading (blue/green/amber/red), instead of all looking
  identical except for the label text.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 23:02:03 -05:00
TylerandClaude Sonnet 4.6 f094bbbd73 Fix inflated word spacing in PDF description text
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
writeDescription/emitBlocks rendered each word as a separately positioned
Tj, advancing by our approximate average-glyph-width estimate — which is
much wider than real space/character widths, producing "This  is  bold"
style gaps. Render each line as one continuous text object instead
(PdfBuilder.textRun), so consecutive Tj operators advance using the
viewer's real font metrics.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 21:42:26 -05:00
TylerandClaude Sonnet 4.6 0ead121327 Render rich text, links, and special characters correctly in PDF exports
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m39s
PDF exports now preserve bold/italic/links/lists/blockquotes from the
description editor (via a new HTML block/run parser) instead of flattening
to plain text. Literal "[text](url)" links inserted by the editor's Link
button are converted to real <a> tags before rendering. Tabs and common
"smart typography" characters (smart quotes, dashes, ellipsis, bullet) are
mapped to their WinAnsiEncoding glyphs instead of rendering as "?".

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 21:35:55 -05:00
Tyler 420b32c0f8 Description editor: insert link placeholder inline instead of prompting
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
Clicking Link no longer pops up "Link text"/"Link URL" dialogs; it
inserts [Text](Link) (or [selectedText](Link)) directly so the
placeholders can be edited in place.
2026-06-13 21:11:58 -05:00
Tyler bb959a4bb9 Description editor: preserve newlines on export, toggle Quote, highlight active toolbar buttons
Template tests / tests (push) Successful in 1m49s
Template tests / tests (pull_request) Successful in 1m38s
- Enter now starts a new <p> (defaultParagraphSeparator) so line breaks
  in the description box show up as line breaks in HTML/Markdown/Confluence
  exports; htmlToMarkdown also handles <div>-separated paragraphs.
- Clicking Quote while already in a blockquote toggles back to a paragraph.
- Toolbar buttons (Bold, Italic, Bullet, Number, Quote) turn blue to
  reflect the formatting state at the current cursor/selection.
2026-06-13 21:08:31 -05:00
Tyler 46d12cc829 Insert markdown-style [text](url) links from the Link toolbar button
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m40s
Previously this wrapped the selection in a real <a> tag via
createLink. Now it inserts literal [text](url) markdown syntax,
using the selection as the link text or prompting for it if nothing
is selected.
2026-06-13 20:47:16 -05:00
Tyler 8e2e1f41fb Show "Make substep of…" as a hover submenu listing all steps
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
Replaces the number-entry prompt with a scrollable side panel (so
large guides don't overflow the screen) listing every eligible step;
clicking one immediately re-parents the step. Context menus now
support submenu items in general via contextMenu().
2026-06-13 20:33:29 -05:00
Tyler a58607f029 Add a "Make substep of…" option to the step context menu
Template tests / tests (push) Successful in 1m40s
Template tests / tests (pull_request) Successful in 1m39s
Lets a step be re-parented under another step by entering that step's
displayed number, moving it (with its own substeps) to the end of the
target's substeps and updating the numbering accordingly.
2026-06-13 20:23:31 -05:00
Tyler be88434e44 Prompt for a name and save before creating a manual snapshot
Template tests / tests (push) Successful in 1m38s
Template tests / tests (pull_request) Successful in 1m42s
Clicking Snapshot now opens a named-prompt dialog (matching the app's
modal styling) and flushes the current step/guide first, so the
snapshot reflects unsaved edits and is easy to identify later.
2026-06-13 20:07:55 -05:00
Tyler 1776089c08 Remove the guide-link panel button; Save now syncs linked archives
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m39s
For linked guides, "Save now" now also writes back to the linked
.sfgz archive (reporting a lock conflict if another session holds it),
making the dedicated link/share button in the Guide panel redundant.
The toolbar Share button and More > Linked guide… menu still cover
one-off exports and archive management.
2026-06-13 19:50:22 -05:00
Tyler 31bdbea7b5 Replace the disabled "Local guide" label with a "Share as file" action
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m39s
The guide panel's link button was a dead disabled control for local
guides. It now shares the guide as a .sfgz archive instead, while
linked guides keep the existing archive-management dialog.
2026-06-13 19:38:05 -05:00
Tyler 9e88991f46 Clarify the style-copy buttons' scope with a heading and richer tooltips
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
Renamed "Style → step/guide" to "This step"/"Entire guide" under a
heading naming the annotation type, with hover text spelling out that
it overwrites every matching annotation's style in that scope.
2026-06-13 19:14:30 -05:00
Tyler 014b92675d Show only relevant settings per annotation type, drop redundant delete button
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
The annotation editor always rendered every style field regardless of
type and a "Delete annotation" button duplicating Delete/Backspace.
2026-06-13 18:37:24 -05:00
TylerandClaude Sonnet 4.6 b39fe553b2 Allow Backspace to delete the selected annotation
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m39s
Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 18:24:33 -05:00
TylerandClaude Sonnet 4.6 602e70a7e1 Linearize focused-view pan to the available range and flip Pan Y
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m39s
panX/panY now represent 0..1 fractions of the pannable range rather
than absolute image positions, so the slider's full travel always
covers edge-to-edge regardless of zoom level. Pan Y is inverted so
sliding right moves the view up. Updated both the editor canvas and
core/raster.js export so they stay in sync.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 18:21:00 -05:00
TylerandClaude Sonnet 4.6 f23b49c0d1 Make the focused-view crop live in the editor canvas
Template tests / tests (push) Successful in 1m39s
Template tests / tests (pull_request) Successful in 1m40s
The annotation canvas now renders as a viewport into the focused-view
crop region, matching what core/raster.js produces on export. Sliders
update the canvas live; annotation data stays in full-image-normalized
coordinates and remains correctly positioned, sized, and editable.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
2026-06-13 18:09:17 -05:00
Tyler 570173cf4f small text changes
Template tests / tests (push) Successful in 1m39s
2026-06-13 17:20:55 -05:00
Tyler 91495a3614 small text changes
Template tests / tests (push) Successful in 1m40s
2026-06-13 16:16:56 -05:00
Tyler ce9bd41e72 Merge branch 'fix/issue-9-block-reorder'
Template tests / tests (push) Successful in 1m45s
2026-06-13 16:01:35 -05:00
Tyler 20d69619c2 fix: preserve block editor state on save
Template tests / tests (push) Successful in 1m41s
2026-06-13 15:51:23 -05:00
Tyler 2274544ed8 Keep block reorder in place 2026-06-13 15:29:44 -05:00
Tyler 05b7713efb Sync tests workflow with main
Template tests / tests (push) Successful in 1m45s
2026-06-13 14:31:11 -05:00
Iisyourdad 50fb9927ca Preserve block contents when adding blocks
Template tests / tests (push) Has been cancelled
2026-06-12 14:12:33 -05:00
117 changed files with 11854 additions and 1635 deletions
+8 -4
View File
@@ -2,11 +2,10 @@ name: Template tests
on:
push:
branches:
- main
pull_request:
types:
- opened
- synchronize
- reopened
workflow_dispatch:
jobs:
tests:
@@ -16,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
-6
View File
@@ -7,12 +7,6 @@
- [ ] Git / workflow
- [ ] Other
## Issue Type
- [ ] Bug
- [ ] Improvement
- [ ] Task
- [ ] Documentation
## Summary
+7 -1
View File
@@ -1,15 +1,21 @@
<!-- please delete the unused check box's on the creation of your pr -->
## Improvement Area
- [ ] Guide Editor
- [ ] Library
- [ ] Settings
- [ ] Exports
- [ ] Documentation
- [ ] Tests
- [ ] Git / workflow
- [ ] Other
## Issue
<!-- Replace the example with the issue number this PR resolves. -->
- Closes #
<!-- Replace the example with the issue number this PR resolves. -->
## Summary
+58
View File
@@ -0,0 +1,58 @@
name: CI
on:
push:
branches: [main]
pull_request:
# Cancel an in-progress run when newer commits are pushed to the same ref.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Unit tests (${{ matrix.os }})
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]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
# The capture unit tests require app/capture.js, which require()s the
# electron module at load — so the Electron binary must actually be
# installed (don't set ELECTRON_SKIP_BINARY_DOWNLOAD here).
- name: Install dependencies
run: npm ci
- 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
+101
View File
@@ -0,0 +1,101 @@
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, then publishes a GitHub Release with that artifact attached and
# auto-generated notes.
on:
workflow_dispatch:
inputs:
version:
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:
description: 'Mark this release as a pre-release'
required: false
default: false
type: boolean
# Needed for the release job to create the tag and the GitHub Release.
permissions:
contents: write
jobs:
build-windows:
name: Build Windows installer
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install dependencies
run: npm ci
# Stamp the requested version onto package.json so the produced artifact
# carries it. Not committed back — it only affects this build.
- name: Set version from input
shell: bash
env:
VERSION: ${{ inputs.version }}
run: node scripts/stamp-version.js "${VERSION#v}"
- name: Configure Windows code signing
shell: bash
env:
WIN_CSC_LINK: ${{ secrets.WIN_CSC_LINK }}
WIN_CSC_KEY_PASSWORD: ${{ secrets.WIN_CSC_KEY_PASSWORD }}
run: |
if [ -n "$WIN_CSC_LINK" ] && [ -n "$WIN_CSC_KEY_PASSWORD" ]; then
{
printf 'WIN_CSC_LINK=%s\n' "$WIN_CSC_LINK"
printf 'WIN_CSC_KEY_PASSWORD=%s\n' "$WIN_CSC_KEY_PASSWORD"
} >> "$GITHUB_ENV"
else
echo "Windows code signing secrets not configured; building unsigned."
fi
- name: Package Windows installer .exe
run: npm run package:windows
- uses: actions/upload-artifact@v4
with:
name: windows-installer
path: releases/*.exe
if-no-files-found: error
release:
name: Publish GitHub Release
needs: [build-windows]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: dist
- name: List downloaded artifacts
run: find dist -type f -printf '%s\t%p\n'
- name: Create GitHub Release
env:
GH_TOKEN: ${{ github.token }}
TAG: ${{ inputs.version }}
PRERELEASE: ${{ inputs.prerelease }}
run: |
flags=()
if [ "$PRERELEASE" = "true" ]; then
flags+=(--prerelease)
fi
gh release create "$TAG" \
dist/windows-installer/*.exe \
--title "StepForge $TAG" \
--target "${{ github.sha }}" \
--generate-notes \
"${flags[@]}"
+2
View File
@@ -6,3 +6,5 @@ releases/
.tmp/
tests/.tmp/
examples/.tmp/
build/build_report.md
build/artifacts_manifest.json
+3
View File
@@ -0,0 +1,3 @@
# Refuse installs on Node versions outside package.json "engines".
# The locked dependency graph (electron-builder toolchain) needs Node >= 22.12.
engine-strict=true
+1
View File
@@ -0,0 +1 @@
22.17.0
+40 -37
View File
@@ -1,16 +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 JSON, Markdown, HTML (simple and rich), PDF, animated GIF, image bundles,
DOCX, and PPTX.
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. 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
@@ -27,21 +37,6 @@ The core workflow:
4. **Export** — every exporter renders from the same normalized Render AST,
so output is deterministic across formats.
```text
┌────────────────────────────────────────────────────────────────────────────┐
│ StepForge > Reset Password SOP [Capture] [Export] [Save] │
├──────────────────┬──────────────────────────────────┬──────────────────────┤
│ Steps │ Canvas │ Properties │
│ 1 Open Users │ ┌────────────────────────────┐ │ Title │
│ 2 Search account │ │ [tooltip] │ │ [Reset password] │
│ 3 Click Reset │ │ ↘ ┌────────────┐ │ │ Description │
│ 3.1 Warning │ │ │ Reset btn │ │ │ [rich text editor] │
│ 4 Done │ │ └────────────┘ │ │ Text blocks │
│ [Add Step] │ └────────────────────────────┘ │ Step settings │
├──────────────────┴──────────────────────────────────┴──────────────────────┤
│ Tools: [Select][Rect][Oval][Line][Arrow][Text][Tooltip][#][Blur][Hi][Crop] │
└────────────────────────────────────────────────────────────────────────────┘
```
## What's Included
@@ -76,17 +71,24 @@ using only Node built-ins.
## Getting Started
For a shorter 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** (⚠️ work in progress — X11 vs Wayland, enabling per-click capture, the screen-share prompt), see [docs/GETTING_STARTED_WITH_LINUX.md](docs/GETTING_STARTED_WITH_LINUX.md).
Requirements: Node.js 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
@@ -99,8 +101,8 @@ bash tests/run_test.sh
The runner executes every `tests/checks/test_*.sh` script; those scripts run
the workflow test suites under `tests/unit/` with `node --test`. The tests
exercise real workflows creating guides, round-tripping archives, exporting
documents, and validating the bytes of the output not string matching.
exercise real workflows like creating guides, round-tripping archives, exporting
documents, and validating the bytes of the output, not string matching.
## Building & Packaging
@@ -108,9 +110,9 @@ 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)
npm run package:windows # portable Windows .exe in releases/
pwsh scripts/package-windows.ps1 # same Windows portable build via PowerShell
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
```
See [build/build_report.md](build/build_report.md) for what was produced on
@@ -131,11 +133,12 @@ clean-room rules.
## Repository Layout
Project docs live in `docs/`, and prompt handoffs live in `ai_prompts/`.
Project docs live in `docs/` and prompt handoffs live in `ai_prompts/`.
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the repo layout.
## License
Application code is licensed under [MPL-2.0](LICENSE). Bundled example
guides, templates, and screenshots are CC-BY-4.0 unless noted otherwise.
Creative Commons Attribution-NonCommercial
Basically, do whatever you want with it just don't make money off it or sell it.
+1
View File
@@ -0,0 +1 @@
In the future, planned updates include a re-vamp of the settings panel, text box's (important, tip, warning, and note) are not properly moving to the intended areas and I will look into locking the size (or being able to control the size) of images throughout varias export formats. OCR or local ai would be pretty cool....
+329
View File
@@ -0,0 +1,329 @@
# StepForge comprehensive improvement plan
This document is an implementation handoff for another coding agent. It is based on a repository-wide audit of commit `534a28e` on 2026-07-03. It is a plan, not authorization to make all changes in one unreviewable patch.
## Objective
Turn StepForge into a reliable, secure, maintainable Windows and Linux desktop application while preserving user data and the core capture/edit/export workflow. Work in small phases, add tests before or with each fix, and keep each pull request focused. Do not claim a capability until its acceptance test passes on the relevant operating system.
Linux is a platform rewrite, not a collection of `process.platform === "linux"` branches in Windows-oriented files. All Linux-only runtime, setup, packaging, and tests must live in separate Linux-specific files. Apt- and dnf-based distributions must also have separate setup/package files.
## Audit baseline
- Repository: Electron app with a dependency-light Node core.
- Approximate size: 10,949 lines in `app/`, 4,134 in `core/`, 2,658 in `exporters/`, 1,119 in `scripts/`, and 5,119 in tests.
- Largest production files are already too broad: `app/renderer/editor.js` (2,336 lines), `app/capture.js` (2,055), `app/renderer/dialogs.js` (1,039), `app/renderer/app.js` (944), and `app/main.js` (905).
- `node --check` succeeds for all checked JavaScript files.
- The full `bash tests/run_test.sh` run fails in `test_startup_smoke.sh`: Electron cannot load `libnspr4.so` on this Linux host. The earlier click self-test reports “SKIPPED,” masking that startup failure as a missing capture environment.
- Direct unit run: 205 tests, 200 passed, 1 failed, 4 skipped. The failure is `tests/unit/package-windows.test.js`, caused by the packaging dependency graph being loaded under Node 18 (`ERR_REQUIRE_ESM`). The four skipped tests are external renderer/codec validations.
- The host has Node 18.19.1. Documentation says Node 20+, but the lockfile contains packages requiring at least Node 20.19 and some packaging packages requiring Node 22.12. There is no `engines` field or hard prerequisite check.
- Sample generation and the current build-release workflow tests pass. They do not prove that the produced Linux package launches or that it has all system libraries.
- `npm audit --package-lock-only` reports two high-severity issues in build/dev dependencies (`form-data` and `undici`). `npm audit --omit=dev --package-lock-only` reports no production issue. This distinction disappears in the current Linux package because it copies all of `node_modules`, including dev/build dependencies.
- The launcher silently ran `npm install --package-lock=false` when dependencies were missing. That changed the ignored `node_modules` tree to versions different from `package-lock.json`. A desktop launcher must not repair itself by accessing npm at runtime.
- The worktree was clean before this plan; diagnostics only created ignored `node_modules` content.
## Confirmed high-priority findings
### Security and privacy
1. **A remote page can inherit the privileged preload API.** The main window has no `will-navigate` guard and no `setWindowOpenHandler`. Stored descriptions allow `https:` links. If a link navigates the main window, the preload still runs and exposes `window.stepforge` to the new page. IPC handlers do not validate sender URL/origin, and `shell:openPath` accepts an arbitrary renderer-provided target. This is a release-blocking privilege-boundary defect.
2. **The default session grants every Electron permission.** `app/main.js` uses permission handlers that return `true` for every permission and every requester. The comment that this is safe because content is local is not a security control. Grant only display capture, only to the dedicated capture worker, and reject everything else.
3. **Windows recording behaves like a keylogger.** The PowerShell/C# hook embedded in `app/capture.js` installs a global keyboard hook, reconstructs printable characters, buffers up to 200 characters, persists them in `captureMetadata`, and can send them with screenshots to an Ollama host. This can capture passwords or other sensitive text. It is not adequately disclosed or consented to. Disable raw character capture by default; ideally remove it. If retained, make it an explicit, separately consented feature with sensitive-field suppression, short in-memory lifetime, redaction, no persistence by default, and tests.
4. **“Fully offline” and “zero HTTP requests” are factually false.** `app/text-intel.js` performs configurable HTTP requests to an Ollama host and accepts a host that can be remote. The launcher can contact npm. The docs also say Electron is the only dependency, while Tesseract and its language data are production dependencies. Choose and document an accurate contract such as “local-first, no telemetry, optional user-configured Ollama,” then enforce it. If remote hosts are prohibited, validate loopback/unix-socket targets instead of accepting arbitrary HTTP endpoints.
5. **AI requests have no timeout, cancellation, size limit, or concurrency policy.** A dead Ollama endpoint can leave UI actions pending indefinitely. Full screenshots are base64-expanded into requests. Add `AbortController` deadlines, cancellation when a guide/step closes, request-size limits, bounded concurrency, and explicit data-disclosure UI.
6. **Archive import lacks resource limits and transactional extraction.** ZIP entry paths and CRCs are checked, but entry count, compressed size, inflated size, compression ratio, manifest size, and total extracted bytes are not bounded. A ZIP bomb can exhaust memory because the archive and inflated entries are handled synchronously in memory. Import writes `guide.json` before all steps validate, leaving partial guides after an error.
7. **Imported export templates can inject active HTML.** `customCss`, accent values, and other template values are interpolated into generated HTML without a typed schema. A malicious `.sfglt` can close the style element and add script. Validate every formats options, restrict colors/numbers/enums, and either remove arbitrary CSS or safely encode it with a clearly documented trusted-template boundary.
8. **The HTML sanitizer is regex-based and link navigation is not separated from rendering.** Replace ad hoc URL checks with URL parsing and a strict scheme/host policy. Add hostile HTML fixtures and ensure renderer links are intercepted rather than navigating the privileged window.
### Broken or misleading behavior
1. **Region capture returns the wrong shape.** `CaptureService.regionCapture()` stores the result of `storeFrameAsStep()` in `step` and then returns `{ ok: true, step }`. The actual step is therefore at `result.step.step`. Region capture selection and region auto-documentation expect `result.step.stepId`, so they break. Return the `storeFrameAsStep()` result directly and add an IPC-to-renderer workflow test.
2. **Cancelled region capture leaks an IPC listener.** `pickRegion()` removes `region:picked` only when an event arrives. Closing/cancelling the overlay leaves the listener and captured window references behind. Cleanup must be idempotent on pick, close, load failure, and app shutdown. Validate/clamp the received rectangle before cropping.
3. **Autosave can report clean after a failed write.** `flushStep()` and `flushGuide()` clear dirty flags before awaiting IPC. A rejected save can lose the visible dirty state and is often invoked through a debounce that does not handle rejected promises. Use a serialized save queue with states (`dirty`, `saving`, `saved`, `error`), only clear the matching revision after success, retry safely, show persistent failure UI, and flush on navigation/close.
4. **Concurrent whole-object saves can overwrite newer edits.** Editor saves, capture auto-documentation, AI generation, and background step updates all read and write full step objects with no revision check. An AI response based on stale data can overwrite user edits; `step:updated` can reload the editor while local changes are pending. Add per-guide/per-step revision numbers and compare-and-swap saves or field-level patches. Resolve conflicts explicitly.
5. **Configured automatic backups do not exist.** `backups.automatic` and `backups.everyNSaves` are defined but never used. Implement a save-count/time policy with pruning and failure reporting, or remove the settings and the documentation claim. Snapshot restore must extract into a temporary directory, validate fully, and atomically swap; the current restore deletes live content before extraction succeeds.
6. **Strict click timing still falls back to a post-click shot.** The selection logic rejects post-click frames, but `sessionCapture()` then takes a fresh shot after the click and stores it. That contradicts the strict-mode product promise. In strict mode, either keep a sufficiently healthy pre-click buffer or skip the capture with a visible diagnostic; never label a post-click fallback as strict.
7. **Linux evdev state is reported incorrectly.** `startEvdevWatcher()` does not set `clickWatcher`, so `state().clickCapture` is false even when evdev is active. The UI can say “hotkey only” while clicks are being watched. Device stream errors are swallowed and do not trigger fallback. Represent trigger sources as explicit states (`windows-hook`, `x11`, `wayland-helper`, `hotkey`, `interval`, `unavailable`) rather than a boolean.
8. **Power blocker ownership is wrong.** The IPC `start` action starts a power-save blocker even though new sessions begin paused. Tray/second-instance pauses bypass the main-process closure that stops it. Move power management behind capture state transitions so there is exactly one owner and assert that paused/finished sessions release it.
9. **File URLs are assembled by string concatenation.** `file://${p}` breaks on spaces, `#`, `%`, Windows drive letters, and other characters. Use `pathToFileURL()` and remove renderer control of arbitrary filesystem paths.
10. **Search can silently remain empty.** If the index is missing, corrupt, or version-mismatched, the constructor starts empty and does not reconcile all existing guides. Rebuild incrementally at startup, store a source revision/fingerprint, and expose recovery status instead of silently swallowing failures.
11. **Corrupt user data is silently hidden.** `listGuides()` and `listSteps()` skip unreadable entries. Corrupt settings are silently replaced in memory. Quarantine corrupt files, preserve originals, surface a recovery UI/report, and never make a guide disappear without explanation.
12. **Several public settings/schema fields are dead or incomplete.** `language`, `capture.includeCursor`, `editor.autoTitleTemplate`, `library.sortBy`, automatic backup settings, `themeOverride`, `exportProfiles`, `extraImages`, and parts of `links` are unused or only partially used. Implement them end-to-end or remove/migrate them; do not keep misleading UI/data contracts.
13. **Linked-guide locking is racy and barely observable.** Lock acquisition is read-then-write rather than exclusive creation, locks exist only for the short save operation, and two writers can race. Define whether the lock covers the editing session or just a write transaction. Use atomic exclusive creation plus ownership token, heartbeat/stale recovery if session-scoped, and conflict detection based on archive hash/revision before overwrite.
### Export correctness and scalability
1. `renderAllImages()` retains every decoded/rendered RGBA image. A single 4K image is roughly 32 MiB before copies; a large guide can consume gigabytes and terminate the export worker. Render one step at a time, write/consume it, release buffers, and report progress/cancellation.
2. PNG and ZIP decoding are synchronous and memory-heavy. Dimension-only limits still permit enormous allocations (up to 32,768 squared). Set total pixel/byte budgets, validate exact inflated length rather than only “at least,” and move heavy work off the main process.
3. The PDF writer replaces unsupported Unicode with `?`; raster annotation text uses an ASCII 8x8 font; the editor uses system fonts. Therefore the documented WYSIWYG and international-text claims are false. Vendor a properly licensed Unicode font or adopt a vetted renderer, embed/subset fonts, and add multilingual fixtures.
4. Editor and export rendering differ for blur, typography, antialiasing, tooltip layout, and potentially focused-view geometry. Build shared geometry/style calculations and golden-image comparisons with explicit tolerances.
5. Text-block position behavior needs a complete format matrix. Current grouping supports six positions for text blocks, while code/table blocks always fall into `rest`. Existing tests do not prove every position in PDF, DOCX, PPTX, HTML, Markdown, Confluence, and Wiki.js. Fix the reported callout movement issue by defining one canonical ordered content stream and testing every exporter against it.
6. Image sizing is format-specific and inconsistent. Introduce a canonical image layout policy (`natural`, `fit-content-width`, explicit max width/height, preserve aspect ratio, no-upscale) and map physical units correctly for HTML/CSS pixels, PDF points, DOCX twips, and PPTX EMUs. Make it configurable in export profiles and test portrait, landscape, ultrawide, small, and 4K images.
7. Markdown output does not robustly escape table pipes/newlines or choose a safe code-fence length when code contains backticks. Add escaping/conformance tests. Validate Office packages by opening/rendering with LibreOffice in Linux CI, not just by checking ZIP/XML structure. Validate PDF with Ghostscript/Poppler and images/GIF with external tools in a dedicated integration job.
8. Export writes directly into the selected output directory and can leave partial/stale files. Export into a temporary sibling directory, validate, then atomically publish. Define overwrite behavior and clean obsolete sidecar images.
### Build, packaging, and release
1. The current Linux package script is not production packaging. It copies all `node_modules` (including dev tools and vulnerable build dependencies), docs, prompts, examples, and stale audit files; hardcodes `amd64`; declares only `xinput`; lacks desktop entry/icons/MIME integration; and copies a nonexistent root `LICENSE`. The portable tarball excludes the generated `/usr/bin/stepforge` launcher because it archives only `opt/stepforge`.
2. A clean run can build a package without `node_modules`, producing an unusable artifact while tests still pass. Package tests only inspect file existence, not launch/install behavior.
3. Linux startup currently falls back to `--no-sandbox` whenever `chrome-sandbox` is not setuid-root. Do not normalize an unsandboxed production launch. Use a packaging method/configuration that supports Chromium sandboxing (or user namespaces where supported), fail with actionable diagnostics, and reserve `--no-sandbox` for explicitly marked development/CI environments.
4. The launcher auto-repairs/reinstalls Electron with npm and ignores the lockfile. Remove all runtime installation. Development setup uses `npm ci`; packaged applications contain a fixed Electron runtime.
5. The Windows artifact finder returns the first `.exe` encountered and can select the unpacked app executable instead of the NSIS installer. Select the expected artifact by exact pattern/metadata and fail on zero or multiple matches.
6. There is no real app icon/assets directory even though architecture docs claim one, and the Windows test explicitly asserts assets are not packaged. Add licensed original assets and verify Windows/Linux metadata.
7. Versioning is inconsistent: package version, four-part build version, tags, changelog, and committed build reports disagree. Use SemVer for releases, a separate platform file/build version where needed, and generate all metadata from one source. Do not commit stale machine-specific build reports/manifests as if current.
8. The license is contradictory. `package.json` says `MPL-2.0`, contribution docs require MPL-2.0/DCO, while `docs/LICENSE` and README impose a noncommercial license. There is no root `LICENSE`. The owner must choose one license before the next release; then make the SPDX field, root license text, README, contribution policy, package contents, and generated About view agree. This is a legal release blocker and cannot be guessed by an implementation agent.
9. GitHub CI runs only `npm test`, only on pushes to `main`; docs claim full checks on pull requests. Release builds only Windows. Add `pull_request`, run the same authoritative commands everywhere, and add Linux package jobs. Do not allow the click E2E test to convert arbitrary startup failures into skips.
## Target architecture
Refactor incrementally toward these boundaries; do not perform a blind rewrite of the whole app.
```text
app/
main/ lifecycle, window policy, IPC composition
renderer/ views/components with no filesystem privilege
capture/ platform-neutral session state machine and frame pairing
platform/
windows/ Windows hooks, context, power behavior
linux/ Linux session detection, portal/X11 input, window policy
services/ export, AI/OCR, search coordination
core/
domain/ guide/step/block model and migrations
storage/ transactional repository, recovery, snapshots, locks
render/ canonical document layout and annotation geometry
exporters/ thin format adapters consuming canonical layout
packaging/
windows/
linux/
debian/
fedora/
scripts/
linux/apt/
linux/dnf/
tests/
unit/
integration/
e2e/
fixtures/
```
Use dependency injection for OS adapters. The platform-neutral capture coordinator should consume interfaces such as `ClickSource`, `ScreenFrameSource`, `WindowContextProvider`, `WindowVisibilityPolicy`, and `PowerPolicy`. It should never inspect `process.platform` itself. `app/platform/index.js` is the only factory that selects a platform implementation.
Introduce a schema-v2 migration with a single ordered `blocks[]` collection instead of three arrays plus synthesized order. Keep a tested v1 reader/migrator and never rewrite user data without a pre-migration snapshot. Add `revision` fields for optimistic concurrency.
## Phased implementation plan
### Phase 0 — freeze the contract and make the baseline reproducible
- Resolve the license decision and the offline/local-AI product wording with the owner.
- Choose one supported Node LTS that satisfies the entire locked dependency graph (the current graph requires at least Node 22.12 for packaging), add `engines`, `.nvmrc` or `.node-version`, and a hard version check in setup/CI.
- Make `npm ci` the only dependency installation path. Remove auto-install/repair from `scripts/electron-launcher.js` and keep clear diagnostics.
- Refresh and pin the lockfile on the chosen Node/npm version; remediate the two audited build dependency issues. Add production and full dependency audits as separate CI signals with an explicit policy.
- Split tests into deterministic unit, desktop smoke, platform capture E2E, export integration, and package install/launch suites. A missing display may skip only a capture scenario after the app has demonstrably started; a missing shared library or crash must fail.
- Add `pull_request` CI. Run syntax/lint/type checks, unit tests, and artifact checks on Linux and Windows. Keep macOS core tests only if macOS is an intended support target; otherwise stop implying app support.
- Record baseline performance fixtures: 100-step 1080p guide, 25-step 4K guide, large archive, and rapid-click session. Track peak RSS, export time, save latency, and dropped-click count.
### Phase 1 — close privilege boundaries and data-loss paths
- In the main window, reject all navigation away from the exact local app entry URL. Add `setWindowOpenHandler(() => ({ action: "deny" }))`. Route safe external links through a narrow `openExternal` handler after scheme validation and optional confirmation.
- Validate IPC sender/webContents for every handler. Add per-channel input schemas, length/size limits, enum checks, and ownership/path checks. Remove generic `shell:openPath`/`showItemInFolder` from the renderer; replace them with intent-specific commands for known export, preview, data, and linked-archive paths.
- Set `sandbox: true` explicitly for every renderer. Deny all permissions by default and grant display capture only to the capture worker and only for the apps local URL. Add security regression tests for remote navigation, popup attempts, permission requests, malicious stored HTML, and hostile template archives.
- Remove or default-disable global printable-key capture. Add a privacy disclosure for screenshot/OCR/window-title/AI data. Never persist raw typed text unless the user explicitly opts in.
- Add AI timeouts, cancellation, concurrency limits, loopback policy if required, payload limits, and error states. Ensure stale AI responses cannot overwrite edited revisions.
- Build the serialized revision-aware autosave queue. Keep dirty state on failure, show last successful save time, flush before navigation/quit, and block destructive close only when a flush genuinely fails.
- Make guide/step save operations transactional at the guide level where multiple files must change. Add recovery journals or temp-directory swaps for add/delete/reorder/import/restore. Quarantine and report corrupt data.
- Add archive/template limits and preflight validation. Extract/import into temp storage, validate manifest/schema/all referenced files, then publish atomically.
### Phase 2 — fix known workflows before larger refactors
- Fix region captures nested result and listener cleanup. Add tests covering capture service → IPC → renderer selection → optional AI.
- Implement automatic snapshots or remove the dead settings. Make restore atomic and verify rollback after injected failures.
- Rebuild/reconcile the search index at startup and test deletion/corruption/version upgrade.
- Replace file URL concatenation with `pathToFileURL()` and test Windows, spaces, Unicode, `#`, and `%` paths.
- Fix power blocker transitions, evdev trigger reporting, watcher-loss fallback, pending click drain on application shutdown, and strict-mode post-click behavior.
- Validate and clamp all persisted geometry and settings. Reject NaN/infinite/negative image sizes, cyclic parent relationships, invalid block IDs/orders, unsafe image paths, out-of-range focused views, and oversized strings/arrays.
- Inventory every schema/settings field and either implement, migrate, or delete it. Update UI and docs in the same PR.
### Phase 3 — Linux rewrite with separate files (apt and dnf)
This phase must not add more Linux conditionals to `app/capture.js`, `app/text-intel.js`, `app/main.js`, or the Windows hook. First introduce platform interfaces, preserve the tested Windows adapter, and then write Linux implementations in new files.
Create at minimum:
```text
app/platform/index.js
app/platform/windows/capture-adapter.js
app/platform/windows/click-hook.cs
app/platform/windows/window-context.ps1
app/platform/windows/power-policy.js
app/platform/linux/capture-adapter.js
app/platform/linux/session-detection.js
app/platform/linux/portal-frame-source.js
app/platform/linux/x11-click-source.js
app/platform/linux/wayland-click-source.js
app/platform/linux/window-context-x11.js
app/platform/linux/window-policy.js
app/platform/linux/diagnostics.js
scripts/linux/apt/install-build-deps.sh
scripts/linux/apt/install-runtime-deps.sh
scripts/linux/dnf/install-build-deps.sh
scripts/linux/dnf/install-runtime-deps.sh
packaging/linux/debian/package.sh
packaging/linux/debian/control.in
packaging/linux/fedora/package.sh
packaging/linux/fedora/stepforge.spec
packaging/linux/common/stepforge.desktop
packaging/linux/common/stepforge-mime.xml
packaging/linux/common/launcher.sh
docs/linux/apt.md
docs/linux/dnf.md
tests/integration/linux/x11-capture.test.js
tests/integration/linux/wayland-capture.test.js
tests/integration/linux/package-deb.test.sh
tests/integration/linux/package-rpm.test.sh
```
Requirements:
- Support both X11 and Wayland as different capability profiles. X11 may use a separately implemented `xinput` adapter with event-time coordinates. Wayland must use XDG Desktop Portal/PipeWire for screen selection and capture.
- Do not promise global per-click capture with coordinates on Wayland when the platform does not expose it. The safe baseline is portal screen capture plus user-triggered global hotkey or interval capture. Treat direct `/dev/input` access as an optional, explicitly consented privileged mode, not default setup.
- Remove documentation that casually tells every user to join the broad `input` group. If a privileged helper is retained, perform a threat review, use least-privilege device rules, never read keyboard devices, package it separately, and show the security tradeoff before enabling it.
- Detect portal, PipeWire, compositor/session, sandbox, required shared libraries, xinput availability, and permission state in Linux diagnostics. Return actionable UI messages instead of console-only failures.
- On Wayland, map the portal-selected monitor to actual frame metadata. Do not assume `displays[0]` represents the selected screen. Test single/multiple monitors, mixed DPI, negative origins on X11, portal cancellation, stream revocation, suspend/resume, and monitor hotplug.
- Keep Linux minimize/restore/tray behavior in `window-policy.js`; do not branch inside the capture coordinator.
- Apt and dnf runtime dependency lists must be maintained in their separate files and verified in clean Debian/Ubuntu and Fedora containers/VMs. Include Chromium/Electron shared libraries, portal/PipeWire integration, and X11 tools only where needed. Do not install build tools in end-user packages.
- Produce a real `.deb` and `.rpm` from a pruned packaged Electron application. Never copy the development `node_modules` tree. Include architecture mapping (`x64`, `arm64` if supported), icons, desktop entry, categories, MIME registration, license, uninstall behavior, and sandbox-compatible permissions.
- Add Linux artifacts to release CI with checksums/SBOM. Install each artifact in a clean VM/container where possible, launch a smoke screen under Xvfb for X11, and run a real Wayland compositor test job for portal behavior. A package is not accepted merely because `dpkg-deb` or `rpmbuild` produced a file.
Linux acceptance criteria:
- Fresh apt-based and dnf-based systems can follow separate documented setup paths and launch StepForge without `--no-sandbox` or manual npm commands.
- Fullscreen, region, clipboard/import, edit, save, reopen, and export workflows pass on both distro families.
- X11 click capture preserves event time and marker position across DPI/monitors.
- Wayland asks for screen sharing once per recording, handles cancel/revoke, never loops portal prompts, and accurately reports whether the active trigger is hotkey, interval, or an approved click source.
- `.deb` and `.rpm` contain only runtime files and pass install, upgrade, uninstall, dependency, license, desktop-entry, and launch tests.
### Phase 4 — canonical editor/document model
- Migrate to a single ordered block list with text/code/table discriminated types and explicit anchors (`before-title`, `after-title`, `before-description`, `after-description`, `before-image`, `after-image`). Decide whether code/table can use anchors; enforce the decision consistently.
- Refactor the editor into bounded modules: guide state/autosave, step tree, properties form, block editor, annotation controls, capture controls, export dialog, and command history. Avoid framework migration unless it has a measured benefit and an approved dependency cost.
- Replace deprecated `document.execCommand` with an explicit editor model or a small audited implementation. Preserve selection safely, sanitize paste, and implement real link editing instead of inserting `[Text](Link)` placeholders.
- Unify undo/redo around commands and revisions. Include block edits, step metadata, crop/reset, reorder, delete/restore, and AI changes. Do not keep full base64 image copies in renderer history without a bounded disk-backed strategy.
- Make annotation geometry bounded and reusable. Share style/geometry calculations between canvas and raster export. Add rotation/layering only after parity is tested.
- Fix callout placement with exporter matrix fixtures. Add export image sizing controls and saved per-format profiles.
- Add accessibility: semantic buttons, modal roles/names, focus trap and restoration, keyboard traversal, visible focus, screen-reader labels, reduced-motion support, high contrast, and a non-canvas representation of annotations. Run automated accessibility checks plus manual keyboard testing.
- Improve responsive behavior below the current 880px minimum and at 125200% UI scale. Preserve pane sizes and window bounds per platform.
### Phase 5 — storage, performance, and export hardening
- Add explicit schema migration functions and fixture coverage for every historical schema. Back up before migration and make migrations idempotent.
- Add storage integrity scanning: guide/order references, orphan steps/images, duplicate IDs, missing originals/workings, invalid parents, and recoverable temp files. Provide repair/dry-run output.
- Replace repeated synchronous whole-index writes with an incremental, crash-safe index and background reconciliation. Measure search on large libraries.
- Stream archives and exports where practical. At minimum enforce byte/pixel budgets and render/release one step at a time. Add export progress, cancellation, and worker termination cleanup.
- Introduce a canonical layout layer that computes content order and image constraints once. Keep exporters thin.
- Add Unicode-capable text rendering and licensed embedded fonts. Test CJK, RTL, emoji policy, combining marks, smart punctuation, and long unbroken tokens. If a format cannot support a case, fail or document it rather than substituting silently.
- Add reproducible/golden output tests. Normalize timestamps/IDs where required, render PDF/DOCX/PPTX to images in integration CI, and compare meaningful layout rather than only container structure.
- Add stress/fault tests: disk full, permission denied, interrupted atomic rename, corrupted JSON, ZIP bomb, huge PNG, export worker crash, Ollama timeout, capture worker death, rapid app quit, and concurrent saves.
### Phase 6 — packaging, release, and documentation completion
- Use one packaging system/config source for Windows and Linux where possible, with platform-specific files under `packaging/`. Prune production dependencies and generate an SBOM/license notice.
- Add original icons at required resolutions. Sign Windows artifacts before recommending users bypass SmartScreen; sign/package Linux repositories if repository distribution is introduced.
- Build release artifacts from a clean checkout with `npm ci`, fixed toolchain versions, no dirty files, and no network during the packaging stage. Generate checksums and provenance.
- Test upgrade compatibility using real prior-version user data and installed packages.
- Rewrite README, architecture, security, getting-started, Linux apt/dnf, privacy/AI, file format, and troubleshooting docs to match tested behavior. Remove stale “WIP”/“fixed” claims and stale machine-specific build reports.
- Correct spelling/grammar and links, compress oversized documentation screenshots, and keep generated sample outputs either reproducible and CI-verified or out of version control.
## File-specific work map
- `app/main.js`: split lifecycle/IPC/security policy; navigation guards; permission allowlist; sender/input validation; path intents; capture power ownership.
- `app/capture.js`: reduce to platform-neutral session coordinator, then move every OS branch to adapters; fix region result/listener, strict fallback, shutdown drain, explicit trigger state.
- `app/text-intel.js`: split OCR, platform window context, and Ollama client; remove embedded OS scripts; add privacy controls, timeout/cancel/limits.
- `app/stream-backend.js` and worker: authenticated worker-only IPC, selected-display metadata, cancellation, bounded frames/encodes, lifecycle tests.
- `app/renderer/editor.js`: save state machine, revision conflicts, module split, canonical blocks, reliable undo, modern rich text, accessibility.
- `app/renderer/dialogs.js`: typed settings forms, validation, modal focus/ARIA, safe template options, AI disclosure.
- `core/schema.js`: schema v2, strict validation, bounds, migrations, revisions, unified blocks.
- `core/store.js`: transactions, corruption quarantine, async/heavy-operation strategy, integrity scan, conflict-aware patches.
- `core/archive.js`, `core/zip.js`, `core/snapshots.js`, `core/locks.js`: resource limits, temp validation/atomic swap, exclusive locks/revisions, rollback tests.
- `core/search.js`: startup reconciliation, incremental persistence, visible recovery.
- `core/renderast.js`, `core/raster.js`, `core/pdf.js`: canonical layout, lazy image rendering, WYSIWYG parity, Unicode/font work, resource limits.
- `exporters/*`: typed option schemas, safe escaping, streaming/lazy images, consistent anchors/image sizing, external conformance tests.
- `scripts/electron-launcher.js`: diagnostics only; never install or weaken production sandbox.
- `scripts/package-windows.js`: exact installer selection, assets, signing hooks, clean artifact verification.
- `.github/workflows/*` and `.gitea/workflows/*`: PR triggers, authoritative test commands, Linux distro/package matrix, non-masking E2E behavior, release artifacts/provenance.
- `README.md`, `docs/*`, `package.json`, root `LICENSE`: reconcile support, dependencies, AI/network/privacy, version, license, and build instructions.
## Required test layers
1. **Pure unit tests:** schema/migrations, storage transactions, sanitizer/URLs, archive limits, frame selection, platform parsers, layout calculations, exporter escaping.
2. **IPC contract tests:** instantiate handlers with fake senders and prove invalid origins, paths, sizes, and payloads are rejected. Do not rely on regex extraction alone.
3. **Renderer tests:** save failures/retries, navigation with dirty state, capture-added and AI races, block placement, modals/focus, keyboard and accessibility.
4. **Desktop E2E:** launch packaged/unpackaged app, create/capture/import/edit/save/restart/export. Separate Windows, Linux X11, and Linux Wayland scenarios with explicit capability expectations.
5. **Artifact tests:** install/launch/uninstall `.exe`, `.deb`, and `.rpm`; inspect file lists and dependencies; verify sandbox, icons, desktop integration, version, license, and clean upgrades.
6. **External output tests:** open/render PDF, DOCX, PPTX, HTML, GIF, and images with independent tools; include visual fixtures and multilingual content.
7. **Security/fault tests:** hostile navigation, malicious HTML/template/archive, ZIP bomb budgets, arbitrary IPC paths, permission denial, disk failures, worker crashes, stale AI responses, and captured-secret prevention.
## Definition of done
- No release-blocking security or license contradiction remains.
- No production launch path downloads dependencies or uses `--no-sandbox` by default.
- User edits remain visibly dirty until durably saved; injected failures and concurrent AI/capture updates do not lose data.
- Automatic backups, restore, archive import, and linked saves are transactional and tested.
- Windows, apt-based Linux, and dnf-based Linux use separate platform/setup/package files and pass their documented capability matrices.
- Linux `.deb` and `.rpm` install and launch from clean systems with only runtime dependencies.
- Region capture, callout placement, image sizing, Unicode, large-guide exports, and click-session shutdown have regression tests.
- CI runs on pull requests, cannot hide startup crashes as skips, and tests the same commands documented for contributors.
- README, Security, Architecture, Privacy/AI, support matrix, package metadata, changelog, and license all describe the shipping application accurately.
## Recommended PR sequence
1. Reproducible toolchain/CI and test-runner truthfulness.
2. Navigation/IPC/permission security boundary.
3. Privacy and AI/network contract.
4. Revision-aware autosave and transactional storage.
5. Region capture, power/session state, and shutdown fixes.
6. Archive/snapshot/lock/search recovery hardening.
7. Platform interface extraction with Windows behavior preserved.
8. Linux apt/X11 implementation and `.deb` packaging.
9. Linux dnf/X11 implementation and `.rpm` packaging.
10. Linux Wayland portal implementation and honest fallback behavior.
11. Canonical blocks/callout placement and image sizing.
12. Lazy exports, Unicode rendering, and external conformance tests.
13. Editor modularization/accessibility/UX polish.
14. Signed, reproducible release pipeline and final documentation reconciliation.
Do not combine these into one PR. Each PR must include migration/rollback notes where user data or package layout changes, automated tests proportional to risk, and a short manual verification matrix for the affected operating systems.
+818 -79
View File
File diff suppressed because it is too large Load Diff
+42
View File
@@ -0,0 +1,42 @@
'use strict';
const path = require('node:path');
const { fork } = require('node:child_process');
const WORKER_PATH = path.join(__dirname, '..', 'core', 'export-worker.js');
/**
* Run an export job in a forked helper process, so building the render AST
* and rendering the output (e.g. a multi-step PDF) never blocks the main
* process — and therefore never freezes the editor window — no matter how
* large the guide is.
*/
function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, maxSteps }) {
return new Promise((resolve, reject) => {
const worker = fork(WORKER_PATH, [], {
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
});
let settled = false;
const finish = (fn, value) => {
if (settled) return;
settled = true;
worker.removeAllListeners();
worker.kill();
fn(value);
};
worker.once('message', (msg) => {
if (msg && msg.ok) finish(resolve, msg.result);
else finish(reject, new Error((msg && msg.error) || 'Export worker failed.'));
});
worker.once('error', (err) => finish(reject, err));
worker.once('exit', (code) => {
finish(reject, new Error(`Export worker exited unexpectedly (code ${code}).`));
});
worker.send({ dataDir, guideId, format, options, outDir, globals, maxSteps });
});
}
module.exports = { runExportInWorker };
+458 -63
View File
@@ -3,21 +3,45 @@
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,
clipboard, nativeImage, screen, powerSaveBlocker, session, desktopCapturer,
} = require('electron');
const { GuideStore } = require('../core/store');
const { Settings } = require('../core/settings');
const { SearchIndex } = require('../core/search');
const { TemplateManager, FORMATS } = require('../core/templates');
const { TemplateManager, FORMATS, FORMAT_LABELS } = require('../core/templates');
const { buildRenderAst } = require('../core/renderast');
const { runExport, EXPORTERS } = require('../exporters');
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
const { runExportInWorker } = require('./export-runner');
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 security = require('./security');
const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json'));
const APP_ID = 'com.stepforge.app';
if (process.platform === 'win32') {
app.setAppUserModelId(APP_ID);
}
// Keep capture working on battery. In a power-saving plan on DC power, Windows
// applies Power Throttling (EcoQoS) to background work — and StepForge records
// with its window hidden, so the frame-capture worker renderer is exactly the
// kind of "background" process the OS slows down. A throttled worker can't
// sample the screen fast enough, so every click finds no fresh frame and the
// recording falls apart (the bug only ever reproduced on battery). These
// switches stop Chromium from de-prioritising and timer-throttling the hidden
// worker; win-power.js additionally opts the OS processes out of EcoQoS.
app.commandLine.appendSwitch('disable-background-timer-throttling');
app.commandLine.appendSwitch('disable-renderer-backgrounding');
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows');
/**
* StepForge main process. Zero network code: no telemetry, no updates, no
@@ -39,6 +63,7 @@ let settings;
let searchIndex;
let templates;
let capture;
let textIntel;
let mainWindow;
function reindex(guideId) {
@@ -47,6 +72,9 @@ function reindex(guideId) {
} catch {
// index failures must never block saves
}
// Automatic backup policy runs on the same save choke point. It is
// self-contained and never throws, so it can't affect the save either.
autoSnapshotIfDue(store, guideId, settings);
}
function orderedSteps(guideId) {
@@ -72,9 +100,19 @@ 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.loadFile(path.join(__dirname, 'renderer', 'index.html'));
mainWindow.once('ready-to-show', () => {
mainWindow.show();
@@ -170,7 +208,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();
@@ -194,6 +237,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
@@ -347,7 +391,37 @@ 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;
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', () => ({
@@ -365,73 +439,112 @@ 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,
working: workingBase64 ? Buffer.from(workingBase64, 'base64') : null,
};
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' };
@@ -442,7 +555,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',
@@ -460,11 +573,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);
@@ -473,30 +588,108 @@ 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,
stepId,
target,
blockId,
});
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));
h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values));
// capture
h('capture:shoot', async ({ guideId, mode, delayMs }) => {
const result = await capture.shoot({ guideId, mode, delayMs });
if (result.ok) reindex(guideId);
if (result.ok) {
reindex(guideId);
const aiConf = settings.get('ai') || {};
if (aiConf.enabled && aiConf.autoDoc && result.step) {
const aiResult = await textIntel.generateStepPatch({
guideId,
stepId: result.step.stepId,
target: 'all',
}).catch(() => null);
if (aiResult?.ok) {
reindex(guideId);
result.step = aiResult.step;
}
}
}
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);
if (result.ok) reindex(guideId);
if (result.ok) {
reindex(guideId);
const aiConf = settings.get('ai') || {};
if (aiConf.enabled && aiConf.autoDoc && result.step) {
const aiResult = await textIntel.generateStepPatch({
guideId,
stepId: result.step.stepId,
target: 'all',
}).catch(() => null);
if (aiResult?.ok) {
reindex(guideId);
result.step = aiResult.step;
}
}
}
return result;
});
}, { 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 });
else if (action === 'pause') capture.togglePause(true);
else if (action === 'resume') capture.togglePause(false);
else if (action === 'finish') capture.finishSession();
else if (action === 'interval') capture.setInterval(intervalSec);
if (action === 'start') {
capture.startSession(guideId, { intervalSec: intervalSec ?? null });
} else if (action === 'pause') {
capture.togglePause(true);
} else if (action === 'resume') {
capture.togglePause(false);
} else if (action === 'finish') {
capture.finishSession();
} 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());
@@ -511,7 +704,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',
@@ -526,30 +719,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`,
@@ -558,7 +766,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'] }],
@@ -569,13 +777,17 @@ function setupIpc() {
});
// export + preview
h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]));
h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]).map((format) => ({
id: format,
label: FORMAT_LABELS[format] || format,
})));
h('export:defaults', ({ format }) => {
// Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable
// options from it (booleans -> checkbox, numbers -> number, strings -> text).
const mod = {
json: '../exporters/json',
markdown: '../exporters/markdown',
wikijs: '../exporters/wikijs',
'html-simple': '../exporters/html',
'html-rich': '../exporters/html',
confluence: '../exporters/confluence',
@@ -587,30 +799,51 @@ 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 ast = buildRenderAst(store, guideId, { globals: settings.getGlobalPlaceholders() });
const result = runExport(format, ast, dir, options || {});
const result = await runExportInWorker({
dataDir: store.root,
guideId,
format,
options: options || {},
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 });
@@ -619,7 +852,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)) {
@@ -630,14 +867,41 @@ 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.
h('platform:capabilities', () => require('./platform').detectCapabilities());
}
// ---- lifecycle --------------------------------------------------------------
@@ -665,14 +929,129 @@ 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,
settings,
getWindow: () => mainWindow,
dataDir,
screenApi: screen,
});
// Bringing up the desktop-capture stream spawns/upgrades Chromium's GPU
// and screen-capture utility processes — which can be born after a session
// already started, so the start-time EcoQoS opt-out misses them. Re-apply
// it the moment the backend reports it is streaming.
let lastClickFrameSource = null;
const captureNotify = (channel, payload) => {
sendToRenderer(channel, payload);
if (channel === 'capture:state' && payload && payload.clickFrameSource !== lastClickFrameSource) {
lastClickFrameSource = payload.clickFrameSource;
if (payload.clickFrameSource === 'stream') {
try { keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid)); } catch { /* best effort */ }
}
}
// Auto-document session captures in the background when autoDoc is enabled.
// Single-shot captures (capture:shoot) are handled synchronously in the IPC handler.
if (channel === 'capture:added' && payload?.step && payload?.guideId) {
const aiConf = settings.get('ai') || {};
if (aiConf.enabled && aiConf.autoDoc) {
textIntel.generateStepPatch({
guideId: payload.guideId,
stepId: payload.step.stepId,
target: 'all',
}).then((result) => {
if (result.ok) {
reindex(payload.guideId);
sendToRenderer('step:updated', { guideId: payload.guideId, step: result.step });
}
}).catch(() => {});
}
}
};
// 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: sendToRenderer,
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();
@@ -683,6 +1062,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) {
@@ -690,6 +1082,9 @@ if (!gotLock) {
capture.stopClickWatcher();
capture.destroySessionTray();
}
if (textIntel) {
textIntel.shutdown().catch(() => {});
}
// clean preview temp files on close
try {
for (const entry of fs.readdirSync(store.tempDir)) {
+43
View File
@@ -0,0 +1,43 @@
'use strict';
const { execFileSync } = require('node:child_process');
/**
* macOS WindowContextProvider using AppleScript / System Events. Extracted
* verbatim from text-intel.js. macOS is not a primary support target, but the
* adapter is kept so the shared code has no `process.platform` branch and the
* behavior is preserved where it exists. Never throws.
*/
function createDarwinWindowContextProvider() {
return {
async collect() {
const script = `
set appName to ""
set windowTitle to ""
tell application "System Events"
try
set frontApp to first application process whose frontmost is true
set appName to name of frontApp
try
set windowTitle to name of front window of frontApp
end try
end try
end tell
return appName & linefeed & windowTitle
`;
try {
const result = execFileSync('osascript', ['-e', script], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
}).trimEnd();
const [appName = '', windowTitle = ''] = result.split(/\r?\n/);
return { appName, windowTitle };
} catch {
return { appName: '', windowTitle: '' };
}
},
};
}
module.exports = { createDarwinWindowContextProvider };
+66
View File
@@ -0,0 +1,66 @@
'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: [],
};
}
module.exports = { detectPlatform, createWindowContextProvider, detectCapabilities };
+62
View File
@@ -0,0 +1,62 @@
'use strict';
/**
* Platform adapter interfaces (documentation + light runtime shape checks).
*
* The platform-neutral capture/text-intel code consumes these interfaces and
* never inspects `process.platform` itself. `app/platform/index.js` is the
* only module that selects a concrete implementation. New OS support is a new
* set of files under `app/platform/<os>/`, not more conditionals inside the
* shared code.
*
* ---------------------------------------------------------------------------
* WindowContextProvider
* collect(osPoint?: {x,y}) -> Promise<{
* appName, windowTitle,
* elementLabel?, elementRole?, elementClass?, elementValue?
* }>
* Best-effort foreground window / clicked-element context. Never throws;
* returns {} (or partial) when unavailable.
*
* ClickSource (runtime capture — implemented incrementally per platform)
* describe() -> { source, coordinates: boolean, keyboard: boolean }
* source ∈ 'windows-hook' | 'x11' | 'evdev-x11' | 'evdev-wayland' |
* 'wayland-portal' | 'hotkey' | 'interval' | 'unavailable'
*
* PowerPolicy
* setRecording(recording: boolean) -> void
* Holds/releases OS power + throttling state for the recording lifecycle.
*
* PlatformCapabilities (from index.detectCapabilities())
* { os, sessionType, isWayland, hasXinput, canSandbox, ... }
* ---------------------------------------------------------------------------
*/
// Interface names, exported so adapters and tests can reference a single
// source of truth for the contract identifiers.
const INTERFACES = Object.freeze([
'WindowContextProvider',
'ClickSource',
'PowerPolicy',
]);
const CLICK_SOURCES = Object.freeze([
'windows-hook',
'x11',
'evdev-x11',
'evdev-wayland',
'wayland-portal',
'hotkey',
'interval',
'unavailable',
]);
/** Assert a value looks like a WindowContextProvider (has async collect()). */
function assertWindowContextProvider(provider) {
if (!provider || typeof provider.collect !== 'function') {
throw new Error('platform: WindowContextProvider must implement collect()');
}
return provider;
}
module.exports = { INTERFACES, CLICK_SOURCES, assertWindowContextProvider };
+99
View File
@@ -0,0 +1,99 @@
'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,
};
}
module.exports = { detectLinuxCapabilities, detectSessionType };
+51
View File
@@ -0,0 +1,51 @@
'use strict';
const { execFileSync } = require('node:child_process');
function hasBinary(name) {
try {
execFileSync('which', [name], { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
/**
* Linux (X11) WindowContextProvider using xprop on the active window. On
* Wayland xprop only sees XWayland clients, so context is best-effort; the
* portal-based capture path does not depend on it. Extracted verbatim from
* text-intel.js. Never throws.
*/
function createLinuxWindowContextProvider() {
return {
async collect() {
try {
if (!hasBinary('xprop')) return { appName: '', windowTitle: '' };
const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
});
const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/);
if (!activeMatch) return { appName: '', windowTitle: '' };
const winId = activeMatch[1];
const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
timeout: 1200,
});
const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/);
const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/);
return {
appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '',
windowTitle: titleMatch ? titleMatch[1] : '',
};
} catch {
return { appName: '', windowTitle: '' };
}
},
};
}
module.exports = { createLinuxWindowContextProvider, hasBinary };
+88
View File
@@ -0,0 +1,88 @@
'use strict';
const { execFile } = require('node:child_process');
/**
* Windows WindowContextProvider. Reads the foreground window (Win32) and, when
* a click point is given, the UI Automation element under it. Best-effort:
* resolves {} on any failure. Extracted verbatim from text-intel.js so the
* shared code carries no `process.platform` branch.
*/
function createWindowsWindowContextProvider() {
return {
async collect(osPoint = null) {
const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y);
const clickX = hasPoint ? Number(osPoint.x) : 0;
const clickY = hasPoint ? Number(osPoint.y) : 0;
const script = `
$clickX = ${clickX};
$clickY = ${clickY};
$elementLabel = '';
$elementRole = '';
$elementClass = '';
$elementProcessId = 0;
$elementValue = '';
if (${hasPoint ? '$true' : '$false'}) {
try {
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null
$point = New-Object System.Windows.Point($clickX, $clickY);
$element = [System.Windows.Automation.AutomationElement]::FromPoint($point);
if ($element) {
$current = $element.Current;
$elementLabel = $current.Name;
$elementRole = $current.LocalizedControlType;
$elementClass = $current.ClassName;
$elementProcessId = $current.ProcessId;
try {
$valPattern = [System.Windows.Automation.ValuePattern]::Pattern;
if ($element.GetSupportedPatterns() -contains $valPattern) {
$elementValue = $element.GetCurrentPattern($valPattern).Current.Value;
}
} catch { }
}
} catch { }
}
Add-Type @"
using System;
using System.Runtime.InteropServices;
using System.Text;
public static class Win32 {
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
}
"@;
$hWnd = [Win32]::GetForegroundWindow();
$sb = New-Object System.Text.StringBuilder 512;
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity);
$pid = 0;
[void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid);
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1;
$out = [ordered]@{
appName = if ($proc) { $proc.ProcessName } else { '' };
windowTitle = $sb.ToString();
elementLabel = $elementLabel;
elementRole = $elementRole;
elementClass = $elementClass;
elementValue = $elementValue;
elementProcessId = $elementProcessId;
pid = $pid;
};
$out | ConvertTo-Json -Compress;
`;
return new Promise((resolve) => {
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
encoding: 'utf8',
timeout: 4000,
windowsHide: true,
}, (err, stdout) => {
if (err) { resolve({}); return; }
try { resolve(JSON.parse(stdout.trim() || '{}')); } catch { resolve({}); }
});
});
},
};
}
module.exports = { createWindowsWindowContextProvider };
+17 -2
View File
@@ -34,6 +34,7 @@ const api = {
add: invoke('step:add'),
save: invoke('step:save'),
delete: invoke('step:delete'),
restore: invoke('step:restore'),
reorder: invoke('steps:reorder'),
imagePath: invoke('step:imagePath'),
setWorkingImage: invoke('step:setWorkingImage'),
@@ -51,6 +52,12 @@ const api = {
globalPlaceholders: invoke('placeholders:globals:get'),
setGlobalPlaceholders: invoke('placeholders:globals:set'),
},
ai: {
test: invoke('ai:test'),
fillStep: invoke('ai:fillStep'),
rewriteText: invoke('ai:rewriteText'),
cancel: invoke('ai:cancel'),
},
capture: {
shoot: invoke('capture:shoot'),
region: invoke('capture:region'),
@@ -58,6 +65,7 @@ const api = {
state: invoke('capture:state'),
onAdded: (fn) => ipcRenderer.on('capture:added', (e, payload) => fn(payload)),
onState: (fn) => ipcRenderer.on('capture:state', (e, payload) => fn(payload)),
onStepUpdated: (fn) => ipcRenderer.on('step:updated', (e, payload) => fn(payload)),
},
archive: {
export: invoke('archive:export'),
@@ -69,6 +77,9 @@ const api = {
create: invoke('snapshots:create'),
restore: invoke('snapshots:restore'),
},
recovery: {
status: invoke('recovery:status'),
},
templates: {
list: invoke('templates:list'),
load: invoke('templates:load'),
@@ -88,11 +99,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'),
},
};
+60 -9
View File
@@ -80,6 +80,18 @@ class StepForgeApp {
api.capture.onAdded((payload) => this.onCaptureAdded(payload));
api.capture.onState((payload) => this.updateCaptureState(payload));
api.capture.onStepUpdated((payload) => this.onStepUpdated(payload));
}
async onStepUpdated(payload) {
if (!payload || !payload.guideId || !payload.step) return;
if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) {
const currentStep = this.editor.currentStep;
if (currentStep && currentStep.stepId === payload.step.stepId) {
await this.editor.reload(payload.step.stepId);
toast('Documentation generated.');
}
}
}
async onCaptureAdded(payload) {
@@ -110,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 || [],
@@ -117,6 +130,7 @@ class StepForgeApp {
guideFolders: library.folders?.guideFolders || {},
};
this.state.trash = trash;
this.editor.setSettings(settings);
}
async refreshLibrary({ keepFilter = true } = {}) {
@@ -158,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', {
@@ -250,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.
@@ -279,10 +304,10 @@ class StepForgeApp {
}
send({ action: s.paused ? 'resume' : 'pause' });
},
}, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause');
}, s.paused ? 'Start recording' : 'Stop recording');
this.captureStatus.append(
el('span', { title: `Capture session — ${trigger}` }, `Rec - ${trigger}`),
el('span', { title: `Capture session — ${trigger}` }, `Recording - ${trigger}`),
pauseBtn,
);
}
@@ -317,8 +342,10 @@ class StepForgeApp {
const rect = e.target.getBoundingClientRect();
contextMenu(rect.left, rect.bottom + 4, [
{ label: 'Rename guide…', action: () => this.renameGuide() },
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
{ label: 'Generate all text fields with AI (experimental)', action: () => this.editor.generateAllTextFieldsWithAi() },
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
'sep',
{ label: 'Keyboard shortcuts…', action: () => this.editor.openShortcutsHelp() },
@@ -374,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', {}),
@@ -756,7 +783,11 @@ class StepForgeApp {
if (title == null) return;
const guide = await api.library.create({ title: title.trim() || 'Untitled guide' });
await this.refreshLibrary();
await this.openGuide(guide.guideId);
// Arm a (paused) capture session like every other open path, so the
// "Start recording" bar appears and actually controls this new guide.
// Without this, a guide created from the library opened with no session,
// so Start recording had nothing to resume (or resumed a stale one).
await this.openGuideAndArmCapture(guide.guideId);
}
async createFolder() {
@@ -854,6 +885,7 @@ class StepForgeApp {
const settings = await api.settings.all();
const placeholders = await api.settings.globalPlaceholders();
await dialogs.showSettingsDialog({
api,
settings,
placeholders,
onSave: async (next) => {
@@ -861,6 +893,7 @@ class StepForgeApp {
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
await api.settings.set({ keyPath: 'capture', value: next.capture });
await api.settings.set({ keyPath: 'editor', value: next.editor });
await api.settings.set({ keyPath: 'ai', value: next.ai });
await api.settings.set({ keyPath: 'exports', value: next.exports });
await api.settings.set({ keyPath: 'backups', value: next.backups });
await api.settings.setGlobalPlaceholders(next.placeholders || {});
@@ -901,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();
+70 -17
View File
@@ -28,6 +28,7 @@ class AnnotationCanvas {
this.selectedId = null;
this.drag = null;
this.cropRect = null;
this.focusedView = null;
canvasEl.addEventListener('pointerdown', (e) => this.onDown(e));
canvasEl.addEventListener('pointermove', (e) => this.onMove(e));
@@ -55,6 +56,14 @@ class AnnotationCanvas {
this.render();
}
// The focused view crops and zooms the canvas itself to match what
// exports produce. Annotation data stays in full-image-normalized
// coordinates; only the on-screen viewport changes.
setFocusedView(focusedView) {
this.focusedView = focusedView || null;
this.render();
}
setTool(tool) {
this.tool = tool;
this.cropRect = null;
@@ -98,30 +107,64 @@ class AnnotationCanvas {
}
// ---- coordinate helpers ----
// The focused-view crop region, in coordinates normalized to the full
// image (0..1). Identity rect when focused view is off, matching
// core/raster.js applyFocusedView. panX/panY are 0..1 fractions of the
// available pan range (0 = left/bottom edge, 1 = right/top edge), so the
// slider's full range always pans the crop across the whole image
// regardless of zoom. Y is inverted so panY increases upward.
viewRect() {
const fv = this.focusedView;
if (!fv || !fv.enabled || !(fv.zoom > 1)) return { x: 0, y: 0, w: 1, h: 1 };
const w = 1 / fv.zoom, h = 1 / fv.zoom;
const panX = fv.panX ?? 0.5, panY = fv.panY ?? 0.5;
return { x: panX * (1 - w), y: (1 - panY) * (1 - h), w, h };
}
toNorm(e) {
const rect = this.canvas.getBoundingClientRect();
const r = this.viewRect();
return {
x: (e.clientX - rect.left) / rect.width,
y: (e.clientY - rect.top) / rect.height,
x: r.x + ((e.clientX - rect.left) / rect.width) * r.w,
y: r.y + ((e.clientY - rect.top) / rect.height) * r.h,
};
}
px(ann) {
const r = this.viewRect();
return {
x: ann.x * this.canvas.width,
y: ann.y * this.canvas.height,
w: ann.w * this.canvas.width,
h: ann.h * this.canvas.height,
x: (ann.x - r.x) / r.w * this.canvas.width,
y: (ann.y - r.y) / r.h * this.canvas.height,
w: ann.w / r.w * this.canvas.width,
h: ann.h / r.h * this.canvas.height,
};
}
// Converts a canvas-pixel point to image-pixel coordinates in the
// full (uncropped) source image, for sampling `this.image` directly.
toImagePx(x, y) {
const r = this.viewRect();
return {
x: (x / this.canvas.width * r.w + r.x) * this.imgW,
y: (y / this.canvas.height * r.h + r.y) * this.imgH,
};
}
toImageLen(len, axis) {
const r = this.viewRect();
return axis === 'y' ? len / this.canvas.height * r.h * this.imgH : len / this.canvas.width * r.w * this.imgW;
}
// ---- rendering ----
render() {
const { ctx, canvas } = this;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!this.image) return;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height);
const r = this.viewRect();
ctx.drawImage(this.image,
r.x * this.imgW, r.y * this.imgH, r.w * this.imgW, r.h * this.imgH,
0, 0, canvas.width, canvas.height);
const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3));
for (const ann of ordered) this.drawAnnotation(ann);
@@ -131,12 +174,17 @@ class AnnotationCanvas {
if (this.cropRect) this.drawCropOverlay();
}
// Annotation strokes/fonts are sized relative to the full image, then
// magnified by the focused-view crop on export (core/raster.js) — divide
// by the view rect so the canvas preview matches that magnification.
strokePx(ann) {
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000);
const r = this.viewRect();
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000 / r.w);
}
fontPx(ann) {
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height);
const r = this.viewRect();
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height / r.h);
}
drawAnnotation(ann) {
@@ -202,10 +250,13 @@ class AnnotationCanvas {
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
ctx.clip();
const sw = w / zoom, sh = h / zoom;
const center = this.toImagePx(x + w / 2, y + h / 2);
const srcW = this.toImageLen(sw, 'x');
const srcH = this.toImageLen(sh, 'y');
ctx.drawImage(
this.image,
(x + w / 2 - sw / 2) / this.scale, (y + h / 2 - sh / 2) / this.scale,
sw / this.scale, sh / this.scale,
center.x - srcW / 2, center.y - srcH / 2,
srcW, srcH,
x, y, w, h
);
ctx.restore();
@@ -310,10 +361,11 @@ class AnnotationCanvas {
drawCropOverlay() {
const { ctx, canvas } = this;
const r = this.cropRect;
const x = Math.min(r.x0, r.x1) * canvas.width;
const y = Math.min(r.y0, r.y1) * canvas.height;
const w = Math.abs(r.x1 - r.x0) * canvas.width;
const h = Math.abs(r.y1 - r.y0) * canvas.height;
const view = this.viewRect();
const x = (Math.min(r.x0, r.x1) - view.x) / view.w * canvas.width;
const y = (Math.min(r.y0, r.y1) - view.y) / view.h * canvas.height;
const w = Math.abs(r.x1 - r.x0) / view.w * canvas.width;
const h = Math.abs(r.y1 - r.y0) / view.h * canvas.height;
ctx.save();
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.beginPath();
@@ -486,8 +538,9 @@ class AnnotationCanvas {
nudgeSelected(dx, dy) {
const sel = this.selected();
if (!sel) return false;
sel.x += dx / this.canvas.width;
sel.y += dy / this.canvas.height;
const r = this.viewRect();
sel.x += dx / this.canvas.width * r.w;
sel.y += dy / this.canvas.height * r.h;
this.changed();
return true;
}
+32 -15
View File
@@ -68,22 +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,
maxFrameRate: 30,
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;
+328 -22
View File
@@ -24,6 +24,151 @@ function makeSelect(value, options) {
);
}
const HOTKEY_LABELS = {
CommandOrControl: 'Ctrl',
Control: 'Ctrl',
Command: 'Cmd',
Alt: 'Alt',
Shift: 'Shift',
Super: 'Super',
Up: '↑',
Down: '↓',
Left: '←',
Right: '→',
Space: 'Space',
Escape: 'Esc',
Return: 'Enter',
};
function hotkeyLabel(part) {
return HOTKEY_LABELS[part] || part;
}
/** Turn a keydown event into accelerator parts, or null if it's a bare modifier. */
function hotkeyFromEvent(e) {
const modifiers = [];
if (e.ctrlKey || e.metaKey) modifiers.push('CommandOrControl');
if (e.altKey) modifiers.push('Alt');
if (e.shiftKey) modifiers.push('Shift');
const { key } = e;
if (key === 'Control' || key === 'Meta' || key === 'Alt' || key === 'Shift') {
return { modifiers, key: null };
}
const SPECIAL_KEYS = {
' ': 'Space',
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
Escape: 'Escape',
Enter: 'Return',
Tab: 'Tab',
Backspace: 'Backspace',
Delete: 'Delete',
};
let keyName = SPECIAL_KEYS[key];
if (!keyName) {
if (/^[a-zA-Z0-9]$/.test(key)) keyName = key.toUpperCase();
else if (/^F([1-9]|1[0-9]|2[0-4])$/.test(key)) keyName = key;
else return { modifiers, key: null };
}
return { modifiers, key: keyName };
}
/**
* A "press to record" shortcut field, styled like a row of keycaps. Exposes
* a `.value` getter/setter holding an Electron accelerator string (e.g.
* "CommandOrControl+Shift+1"), so it slots in like a text input.
*/
function makeHotkeyInput(value = '') {
let current = value || '';
const keys = el('div.hotkey-keys');
const clearBtn = el('button.hotkey-clear', {
type: 'button',
title: 'Clear shortcut',
onClick: (e) => {
e.stopPropagation();
current = '';
render();
},
}, '×');
const wrap = el('div.hotkey-input', { tabindex: '0', role: 'button', title: 'Click, then press a key combination' },
keys, clearBtn);
function renderKeys(parts) {
keys.replaceChildren();
parts.forEach((part, i) => {
if (i > 0) keys.append(el('span.hotkey-sep', {}, '+'));
keys.append(el('kbd', {}, hotkeyLabel(part)));
});
}
function render() {
if (wrap.classList.contains('recording')) {
keys.replaceChildren(el('span.hotkey-placeholder', {}, 'Press a key combination…'));
clearBtn.hidden = true;
return;
}
if (!current) {
keys.replaceChildren(el('span.hotkey-placeholder', {}, 'Click to set shortcut'));
clearBtn.hidden = true;
return;
}
renderKeys(current.split('+').filter(Boolean));
clearBtn.hidden = false;
}
// While recording, a window-level capturing listener intercepts keydown
// before the modal's own Escape handler can see it (capture order is
// window -> document -> ... -> target), so Escape cancels recording
// instead of closing the whole dialog.
let recordingKeyHandler = null;
wrap.addEventListener('focus', () => {
wrap.classList.add('recording');
render();
recordingKeyHandler = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'Escape') {
wrap.blur();
return;
}
const { modifiers, key } = hotkeyFromEvent(e);
if (key) {
current = [...modifiers, key].join('+');
wrap.blur();
} else if (modifiers.length) {
renderKeys([...modifiers, '…']);
} else {
render();
}
};
window.addEventListener('keydown', recordingKeyHandler, true);
});
wrap.addEventListener('blur', () => {
wrap.classList.remove('recording');
if (recordingKeyHandler) {
window.removeEventListener('keydown', recordingKeyHandler, true);
recordingKeyHandler = null;
}
render();
});
Object.defineProperty(wrap, 'value', {
get() { return current; },
set(v) { current = v || ''; render(); },
});
render();
return wrap;
}
async function promptText({ title, label = 'Value', value = '', placeholder = '', multiline = false } = {}) {
return new Promise((resolve) => {
const field = multiline
@@ -140,6 +285,7 @@ function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onC
}
function showSettingsDialog({
api,
settings,
placeholders = {},
onSave,
@@ -160,14 +306,71 @@ function showSettingsDialog({
{ value: 'region', label: 'Region' },
]);
const clickMarker = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.clickMarker) });
const captureHotkey = makeInput(settings.capture?.hotkeyCapture || '', 'text');
const pauseHotkey = makeInput(settings.capture?.hotkeyPauseResume || '', 'text');
const captureHotkey = makeHotkeyInput(settings.capture?.hotkeyCapture || '');
const pauseHotkey = makeHotkeyInput(settings.capture?.hotkeyPauseResume || '');
const focusedDefault = el('input', { type: 'checkbox', checked: Boolean(settings.editor?.focusedViewDefaultForNewSteps) });
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) });
const aiAutoDoc = el('input', { type: 'checkbox', checked: Boolean(settings.ai?.autoDoc) });
const ollamaHost = makeInput(settings.ai?.ollama?.host || 'http://127.0.0.1:11434');
const ollamaModel = makeInput(settings.ai?.ollama?.model || 'llama3.2:1b');
const aiStatus = el('div', { className: 'muted ai-status' }, 'AI stays local through Ollama. Vision-capable models can also inspect the screenshot attached to each step.');
const testAiBtn = el('button', { type: 'button' }, 'Test connection');
const persistOllamaModel = debounce(() => {
const model = ollamaModel.value.trim();
// Keep the last model choice even if the dialog is dismissed without a full save.
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));
};
const testAiConnection = async () => {
setButtonLoading(testAiBtn, true, 'Testing…');
updateAiStatus('Checking Ollama at the configured host…');
try {
const result = await api.ai.test({
ollama: {
host: ollamaHost.value.trim(),
model: ollamaModel.value.trim(),
},
});
if (!result.ok) {
updateAiStatus(result.reason || 'Could not connect to Ollama.', { error: true });
return;
}
if (result.installed) {
updateAiStatus(result.vision
? `Connected to ${result.host} with ${result.model}. It can inspect screenshots.`
: `Connected to ${result.host} with ${result.model}. This model is text-only, so StepForge will use OCR and metadata only.`);
} else {
updateAiStatus(`Connected to ${result.host}. Model ${result.model} is not installed yet.`, { error: true });
}
} catch (err) {
updateAiStatus(err.message || 'Could not connect to Ollama.', { error: true });
} finally {
setButtonLoading(testAiBtn, false);
}
};
ollamaModel.addEventListener('input', () => persistOllamaModel());
ollamaModel.addEventListener('blur', () => persistOllamaModel.flush());
const placeholderRows = el('div', { className: 'placeholder-rows' });
const rows = [];
@@ -211,9 +414,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'),
@@ -224,6 +432,20 @@ function showSettingsDialog({
el('legend', {}, 'Backups'),
labeledRow('Keep last snapshots', keepLast),
),
el('fieldset', {},
el('legend', {}, 'AI'),
labeledRow('Enable AI (experimental)', aiEnabled),
labeledRow('Auto-document captures', aiAutoDoc),
labeledRow('Ollama host', ollamaHost),
labeledRow('Ollama model', ollamaModel),
el('div.row', { style: { justifyContent: 'space-between' } },
aiStatus,
testAiBtn,
),
el('div.muted', {},
'When auto-document is on, each capture is automatically documented by AI. Turn it off to use AI manually only.',
),
),
el('fieldset', {},
el('legend', {}, 'Global placeholders'),
placeholderRows,
@@ -249,6 +471,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,
@@ -267,6 +491,16 @@ function showSettingsDialog({
...settings.backups,
keepLast: Number(keepLast.value || 0),
},
ai: {
...settings.ai,
enabled: aiEnabled.checked,
autoDoc: aiAutoDoc.checked,
ollama: {
...(settings.ai?.ollama || {}),
host: ollamaHost.value.trim(),
model: ollamaModel.value.trim(),
},
},
placeholders: rows.reduce((acc, row) => {
const inputs = row.querySelectorAll('input');
const key = inputs[0].value.trim();
@@ -285,6 +519,14 @@ function showSettingsDialog({
});
form.addEventListener('submit', (e) => e.preventDefault());
testAiBtn.addEventListener('click', testAiConnection);
aiEnabled.addEventListener('change', () => {
updateAiStatus(
aiEnabled.checked
? 'AI generation will be available once Ollama is reachable.'
: 'AI generation is disabled. The settings are still saved for later.',
);
});
});
}
@@ -413,28 +655,43 @@ function showExportDialog({
outDir: outDirInput.value.trim() || null,
});
const cancelBtn = el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel');
const previewBtn = el('button', {
onClick: async () => {
if (typeof onPreview !== 'function') return;
setButtonLoading(previewBtn, true, 'Preview…');
try {
await onPreview(payload()); // keep dialog open so settings can be tweaked
} finally {
setButtonLoading(previewBtn, false);
}
},
}, 'Preview');
const exportBtn = el('button.primary', {
onClick: async () => {
if (typeof onExport !== 'function') return;
cancelBtn.disabled = true;
previewBtn.disabled = true;
setButtonLoading(exportBtn, true, 'Exporting…');
try {
const ok = await onExport(payload());
if (ok !== false) {
close();
resolve(true);
return;
}
} finally {
cancelBtn.disabled = false;
previewBtn.disabled = false;
setButtonLoading(exportBtn, false);
}
},
}, 'Export');
const { close } = openModal({
title: 'Export',
body,
footer: [
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
el('button', {
onClick: async () => {
if (typeof onPreview !== 'function') return;
await onPreview(payload()); // keep dialog open so settings can be tweaked
},
}, 'Preview'),
el('button.primary', {
onClick: async () => {
if (typeof onExport !== 'function') return;
const ok = await onExport(payload());
if (ok !== false) {
close();
resolve(true);
}
},
}, 'Export'),
],
footer: [cancelBtn, previewBtn, exportBtn],
wide: true,
onClose: () => resolve(false),
});
@@ -639,6 +896,53 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {}
});
}
/**
* Optional document metadata (author, co-authors, organization, description)
* shown at the top of the guide, below the title, and surfaced on the PDF
* cover page and the top of other export formats.
*/
function showGuideInfoDialog({ values = {}, onSave } = {}) {
return new Promise((resolve) => {
const authorInput = makeInput(values.author || '', 'text', { placeholder: 'e.g. Jane Doe' });
const coAuthorsInput = makeInput(values.coAuthors || '', 'text', { placeholder: 'e.g. Alex Lee, Sam Patel' });
const organizationInput = makeInput(values.organization || '', 'text', { placeholder: 'e.g. Acme Corp' });
const descriptionInput = el('textarea', {
rows: 4,
placeholder: 'A short summary of this guide.',
}, values.description || '');
const { close } = openModal({
title: 'Guide information',
body: el('div', {},
el('div.muted', { style: { marginBottom: '10px' } },
'All fields are optional. They appear below the title on the guide and on the PDF cover page.'),
labeledRow('Author', authorInput),
labeledRow('Co-authors', coAuthorsInput),
labeledRow('Organization', organizationInput),
labeledRow('Description', descriptionInput, { stacked: true }),
el('div.muted', { style: { marginTop: '-4px' } },
'Shown on the first page of the PDF and at the top of other export formats.'),
),
footer: [
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
el('button.primary', {
onClick: async () => {
await onSave?.({
author: authorInput.value.trim(),
coAuthors: coAuthorsInput.value.trim(),
organization: organizationInput.value.trim(),
description: descriptionInput.value.trim(),
});
close();
resolve(true);
},
}, 'Save'),
],
onClose: () => resolve(false),
});
});
}
const SHORTCUTS = [
['Capture & steps', [
['Ctrl+S', 'Save (writes linked archive when guide is linked)'],
@@ -646,10 +950,11 @@ const SHORTCUTS = [
['PageUp / PageDown', 'Previous / next step'],
['Alt+↑ / Alt+↓', 'Move step up / down'],
['Ctrl+Delete', 'Delete current step'],
['Ctrl+Z / Ctrl+Shift+Z', 'Undo / redo (including step deletion)'],
['Ctrl+V', 'Paste annotation, or clipboard image as new step'],
]],
['Canvas tools', [
['S R O L A T', 'Select · Rect · Oval · Line · Arrow · Text'],
['S R O L A T', 'Select · Rectangle · Oval · Line · Arrow · Text'],
['G N B H M U C', 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop'],
['Ctrl+C', 'Copy selected annotation'],
['Delete', 'Delete selected annotation'],
@@ -726,6 +1031,7 @@ window.StepForgeDialogs = {
showInfoDialog,
showBackupsDialog,
showPlaceholdersDialog,
showGuideInfoDialog,
showShortcutsDialog,
showTemplateManager,
showRecordingReminder,
+697 -110
View File
File diff suppressed because it is too large Load Diff
+164 -1
View File
@@ -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; }
@@ -90,6 +100,35 @@ button.tool.active {
border-color: var(--accent);
color: var(--accent-fg);
}
button.ai {
padding: 5px 10px;
border-color: color-mix(in srgb, var(--accent) 38%, var(--border));
background: color-mix(in srgb, var(--accent) 10%, var(--panel-solid));
color: var(--accent-strong);
font-weight: 650;
letter-spacing: 0.02em;
}
button.ai:hover { background: color-mix(in srgb, var(--accent) 16%, var(--panel-2)); }
button.ai:disabled { opacity: 0.6; }
button:disabled { opacity: 0.6; cursor: default; }
button.loading {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 7px;
}
.spinner {
display: inline-block;
width: 13px;
height: 13px;
border: 2px solid currentColor;
border-top-color: transparent;
border-radius: 50%;
opacity: 0.85;
animation: spin 0.7s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
input, select, textarea {
font: inherit;
@@ -154,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 {
@@ -487,6 +541,11 @@ kbd {
margin-bottom: 8px;
}
.rich-toolbar button { padding: 4px 8px; font-size: 12px; }
.rich-toolbar button.active {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
}
.rich-editor {
min-height: 110px;
max-height: 220px;
@@ -509,6 +568,12 @@ kbd {
border-radius: 10px;
background: var(--panel-2);
}
.rich-editor blockquote {
margin: 6px 0;
padding: 2px 0 2px 12px;
border-left: 3px solid var(--accent);
color: var(--muted);
}
.block-card,
.annotation-editor-inner {
@@ -518,12 +583,17 @@ kbd {
}
.block-card {
border: 1px solid var(--border);
border-left: 4px solid var(--border);
border-radius: 12px;
padding: 10px;
background: var(--panel-solid);
}
.block-card[draggable="true"] { cursor: grab; }
.block-card[draggable="true"]:active { cursor: grabbing; }
.block-card[data-level="info"] { border-left-color: #3b82f6; }
.block-card[data-level="success"] { border-left-color: #10b981; }
.block-card[data-level="warn"] { border-left-color: #f59e0b; }
.block-card[data-level="error"] { border-left-color: #ef4444; }
.annotation-list {
display: flex;
flex-direction: column;
@@ -564,8 +634,82 @@ fieldset legend {
}
.placeholder-row input { width: 100%; }
.hotkey-input {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 190px;
padding: 5px 8px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--panel-2);
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
}
.hotkey-input:hover {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.hotkey-input:focus-visible,
.hotkey-input.recording {
outline: none;
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 8%, var(--panel-2));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent);
}
.hotkey-keys {
display: inline-flex;
align-items: center;
gap: 4px;
min-height: 22px;
flex: 1;
}
.hotkey-keys kbd {
min-width: 22px;
justify-content: center;
padding: 3px 7px;
font-size: 11px;
font-weight: 650;
background: var(--panel-solid);
box-shadow: 0 1px 0 0 color-mix(in srgb, var(--text) 8%, transparent);
}
.hotkey-sep {
color: var(--muted);
font-size: 11px;
}
.hotkey-placeholder {
color: var(--muted);
font-size: 12px;
}
.hotkey-clear {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 20px;
height: 20px;
padding: 0;
border: none;
border-radius: 999px;
background: transparent;
color: var(--muted);
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.hotkey-clear:hover {
background: var(--panel-solid);
color: var(--text);
}
.hotkey-clear[hidden] {
display: none;
}
.quick-actions {
width: min(760px, 92vw);
display: flex;
flex-direction: column;
gap: 8px;
}
.quick-actions input {
width: 100%;
@@ -573,7 +717,6 @@ fieldset legend {
padding: 11px 12px;
}
.qa-results {
margin-top: 10px;
max-height: 360px;
overflow-y: auto;
display: flex;
@@ -643,6 +786,7 @@ fieldset legend {
color: var(--muted);
line-height: 1.5;
}
.ai-status.error { color: var(--danger); }
#modal-root:not(:empty) {
position: fixed;
@@ -731,6 +875,25 @@ fieldset legend {
}
.ctx-menu .mi:hover { background: var(--panel-2); }
.ctx-menu .mi.danger { color: var(--danger); }
.ctx-menu .mi.disabled {
color: var(--muted);
cursor: default;
}
.ctx-menu .mi.disabled:hover { background: none; }
.ctx-menu .mi.has-submenu {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.ctx-menu .mi .submenu-arrow {
color: var(--muted);
font-size: 12px;
}
.ctx-submenu {
max-height: min(360px, calc(100vh - 24px));
overflow-y: auto;
}
.ctx-menu hr {
margin: 6px 0;
border: 0;
+83 -4
View File
@@ -34,6 +34,27 @@ function clearNode(node) {
while (node.firstChild) node.removeChild(node.firstChild);
}
/**
* Toggle a button into/out of a busy state: disables it and swaps its label
* for a spinner + `label` (e.g. "Exporting…"), restoring the original label
* afterwards. Used for actions that block on a slow main-process call.
*/
function setButtonLoading(btn, loading, label) {
if (loading) {
if (btn.dataset.origLabel === undefined) btn.dataset.origLabel = btn.textContent;
btn.disabled = true;
btn.classList.add('loading');
clearNode(btn);
btn.append(el('span.spinner'), label || btn.dataset.origLabel);
} else {
btn.disabled = false;
btn.classList.remove('loading');
clearNode(btn);
btn.append(btn.dataset.origLabel ?? '');
delete btn.dataset.origLabel;
}
}
function debounce(fn, ms) {
let t = null;
const wrapped = (...args) => {
@@ -119,15 +140,55 @@ function promptDialog(title, { value = '', label = 'Name' } = {}) {
});
}
/** Context menu at (x, y); items: [{label, danger, action}] or 'sep'. */
/**
* Context menu at (x, y).
* items: [{label, danger, action}] | [{label, submenu}] | 'sep'.
* `submenu` is an array (or a function returning one) of the same item
* shapes, shown in a scrollable panel beside the item on hover.
*/
function contextMenu(x, y, items) {
document.querySelectorAll('.ctx-menu').forEach((n) => n.remove());
const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } });
let openSubmenu = null;
const closeSubmenu = () => {
if (openSubmenu) { openSubmenu.remove(); openSubmenu = null; }
};
const closeAll = () => { closeSubmenu(); menu.remove(); };
for (const item of items) {
if (item === 'sep') { menu.append(el('hr')); continue; }
if (item === 'sep') { menu.append(el('hr', { onMouseEnter: closeSubmenu })); continue; }
if (item.submenu) {
const mi = el('div.mi.has-submenu', {
onMouseEnter: () => {
closeSubmenu();
const subItems = typeof item.submenu === 'function' ? item.submenu() : item.submenu;
const sub = el('div.ctx-menu.ctx-submenu');
if (!subItems.length) {
sub.append(el('div.mi.disabled', {}, 'Nothing else to choose'));
}
for (const subItem of subItems) {
if (subItem === 'sep') { sub.append(el('hr')); continue; }
sub.append(el('div.mi', {
className: `mi${subItem.danger ? ' danger' : ''}`,
onClick: () => { closeAll(); subItem.action(); },
}, subItem.label));
}
document.body.append(sub);
const miRect = mi.getBoundingClientRect();
sub.style.left = `${miRect.right + 2}px`;
sub.style.top = `${miRect.top}px`;
const subRect = sub.getBoundingClientRect();
if (subRect.right > innerWidth) sub.style.left = `${Math.max(6, miRect.left - subRect.width - 2)}px`;
if (subRect.bottom > innerHeight) sub.style.top = `${Math.max(6, innerHeight - subRect.height - 6)}px`;
openSubmenu = sub;
},
}, el('span', {}, item.label), el('span.submenu-arrow', {}, ''));
menu.append(mi);
continue;
}
menu.append(el('div.mi', {
className: `mi${item.danger ? ' danger' : ''}`,
onClick: () => { menu.remove(); item.action(); },
onMouseEnter: closeSubmenu,
onClick: () => { closeAll(); item.action(); },
}, item.label));
}
document.body.append(menu);
@@ -135,7 +196,7 @@ function contextMenu(x, y, items) {
if (rect.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`;
if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`;
setTimeout(() => {
document.addEventListener('click', () => menu.remove(), { once: true });
document.addEventListener('click', () => closeAll(), { once: true });
}, 0);
}
@@ -147,3 +208,21 @@ function fmtDate(iso) {
const escapeHtml = (s) => String(s ?? '')
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
/** Inverse of textToHtml, for loading sanitized description HTML into a plain textarea. */
function htmlToPlainText(html) {
return String(html || '')
.replace(/<(br|\/p|\/div|\/li|\/h[1-6])\s*\/?>/gi, '\n')
.replace(/<[^>]*>/g, '')
.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&quot;/g, '"').replace(/&#39;/g, '\'').replace(/&amp;/g, '&')
.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim();
}
/** Plain textarea text -> sanitizer-allowed paragraph HTML (blank line = new paragraph). */
function textToHtml(text) {
const trimmed = String(text || '').trim();
if (!trimmed) return '';
return trimmed.split(/\n{2,}/)
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br>')}</p>`)
.join('');
}
+302
View File
@@ -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,
};
+36 -6
View File
@@ -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);
};
@@ -340,6 +362,14 @@ async function createElectronHost(onEvent) {
if (!win.isDestroyed()) win.destroy();
throw err;
}
// The worker is a hidden renderer, so on battery Windows would EcoQoS-throttle
// it and starve frame sampling. Clear that on its own OS process before it
// starts streaming. Best-effort; no-op off Windows.
try {
// eslint-disable-next-line global-require
const { keepProcessesResponsive } = require('./win-power');
if (!win.isDestroyed()) keepProcessesResponsive([win.webContents.getOSProcessId()]);
} catch { /* throttling tweak is optional */ }
return {
send(msg) {
if (!win.isDestroyed()) win.webContents.send('capture-worker:command', msg);
+676
View File
@@ -0,0 +1,676 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const {
DEFAULT_CAPTURE_TITLES,
buildCaptureTitle,
normalizeOllamaHost,
validateOllamaHost,
normalizeAiPatch,
buildAiPrompt,
applyAiPatchToStep,
displayText,
normalizeWhitespace,
} = require('../core/text-intel');
const DEFAULT_TITLE_VALUES = new Set(Object.values(DEFAULT_CAPTURE_TITLES).concat(['Capture']));
const OCR_CROP = {
width: 420,
height: 220,
};
function clamp(v, min, max) {
return Math.min(max, Math.max(min, v));
}
function modelLooksVisionCapable(model) {
const clean = normalizeWhitespace(model).toLowerCase();
if (!clean) return false;
return clean.includes('vision')
|| clean.includes('llava')
|| clean.includes('gemma4')
|| /(^|[^a-z0-9])qwen[23](?:\.[0-9]+)?vl([^a-z0-9]|$)/.test(clean);
}
let createWorkerImpl = null;
function loadCreateWorker() {
if (createWorkerImpl) return createWorkerImpl;
// OCR is optional at startup; lazy-load it so the app can still boot when
// the dependency has not been installed yet.
// eslint-disable-next-line global-require
({ createWorker: createWorkerImpl } = require('tesseract.js'));
return createWorkerImpl;
}
class TextIntelService {
constructor({
store,
settings,
getWindow = () => null,
dataDir,
fetchImpl = global.fetch,
screenApi = null,
windowContextProvider = null,
}) {
this.store = store;
this.settings = settings;
this.getWindow = getWindow;
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();
} catch {
// best effort
}
this.worker = null;
this.workerPromise = null;
}
}
ensureLangData() {
const packageDir = path.dirname(require.resolve('@tesseract.js-data/eng/package.json'));
const source = path.join(packageDir, '4.0.0_best_int', 'eng.traineddata.gz');
const targetDir = this.ocrDataDir;
const target = path.join(targetDir, 'eng.traineddata.gz');
if (!fs.existsSync(target)) {
fs.mkdirSync(targetDir, { recursive: true });
fs.copyFileSync(source, target);
}
return targetDir;
}
async getWorker() {
if (this.workerPromise) return this.workerPromise;
this.workerPromise = (async () => {
const workerFactory = loadCreateWorker();
const langPath = this.ensureLangData();
const worker = await workerFactory('eng', 1, {
langPath,
});
await worker.setParameters({
preserve_interword_spaces: '1',
});
this.worker = worker;
return worker;
})();
this.workerPromise.catch(() => {
this.workerPromise = null;
});
return this.workerPromise;
}
async recognizeCrop(image, rect = null) {
const worker = await this.getWorker();
const cropped = rect ? image.crop(rect) : image;
const buffer = cropped.toPNG();
const result = await worker.recognize(buffer);
const text = String(result?.data?.text || '').trim();
return {
text,
confidence: Number.isFinite(result?.data?.confidence) ? result.data.confidence : null,
raw: result,
};
}
cropRectForPoint(frame, clickPos, { width = OCR_CROP.width, height = OCR_CROP.height } = {}) {
if (!frame || !frame.size) return null;
const bounds = frame.display.bounds || { x: 0, y: 0, width: frame.size.width, height: frame.size.height };
const scaleX = frame.size.width / bounds.width;
const scaleY = frame.size.height / bounds.height;
const point = clickPos || {
x: bounds.x + bounds.width / 2,
y: bounds.y + bounds.height / 2,
};
const centerX = (point.x - bounds.x) * scaleX;
const centerY = (point.y - bounds.y) * scaleY;
const rectW = Math.max(1, Math.round(width * scaleX));
const rectH = Math.max(1, Math.round(height * scaleY));
const rect = {
x: Math.round(centerX - rectW / 2),
y: Math.round(centerY - rectH / 2),
width: rectW,
height: rectH,
};
rect.x = clamp(rect.x, 0, Math.max(0, frame.size.width - rect.width));
rect.y = clamp(rect.y, 0, Math.max(0, frame.size.height - rect.height));
rect.width = clamp(rect.width, 1, frame.size.width);
rect.height = clamp(rect.height, 1, frame.size.height);
return rect;
}
async ocrAroundClick(frame, clickPos) {
if (!frame || !frame.image) return { text: '', confidence: null };
// Use a full-width horizontal strip at the click height. This preserves complete
// link text (e.g. "Oracle | Cloud Applications and Cloud Platform") rather than
// cropping through it when the element spans more than the 420 px default width.
const bounds = frame.display?.bounds || { x: 0, y: 0, width: frame.size.width, height: frame.size.height };
const rect = this.cropRectForPoint(frame, clickPos, {
width: bounds.width, // full display width → full image width after DPI scaling
height: 100, // ~2 lines tall, enough context without too much noise
});
try {
return await this.recognizeCrop(frame.image, rect);
} catch {
return { text: '', confidence: null };
}
}
async collectForegroundWindowContext(osPoint = null) {
try {
return await this.windowContext.collect(osPoint);
} catch {
// best effort only
return { appName: '', windowTitle: '' };
}
}
async buildCaptureTitle({ mode, frame, clickPos, clickMeta = null }) {
const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta });
return ctx.title;
}
async buildCaptureContext({ mode, frame, clickPos, clickMeta = null }) {
const keyContext = clickMeta?.keyContext || {};
const recentTyped = keyContext.recentTyped || '';
const recentShortcut = keyContext.recentShortcut || '';
// Use window context pre-captured by the click watcher when available.
// This avoids a costly PowerShell cold-start (13 s) on every capture.
const fastContext = clickMeta?.windowContext || null;
const [metadata, ocr] = await Promise.all([
fastContext
? Promise.resolve(fastContext)
: this.collectForegroundWindowContext(clickMeta?.osPoint || null),
this.ocrAroundClick(frame, clickPos),
]);
const title = buildCaptureTitle({ mode, metadata, ocrText: ocr.text, recentTyped, recentShortcut });
return {
title,
captureMetadata: {
ocrText: ocr.text || '',
windowTitle: metadata.windowTitle || '',
appName: metadata.appName || '',
elementLabel: metadata.elementLabel || '',
elementRole: metadata.elementRole || '',
elementValue: metadata.elementValue || '',
recentTyped,
recentShortcut,
mode,
},
};
}
aiEnabled() {
return Boolean(this.settings.get('ai.enabled'));
}
aiConfig(override = null) {
const stored = this.settings.get('ai') || {};
const merged = override ? {
...stored,
...override,
ollama: {
...(stored.ollama || {}),
...(override.ollama || {}),
},
} : stored;
return {
...merged,
enabled: override && Object.prototype.hasOwnProperty.call(override, 'enabled')
? Boolean(override.enabled)
: Boolean(stored.enabled),
ollama: {
host: normalizeOllamaHost(merged.ollama?.host || ''),
model: normalizeWhitespace(merged.ollama?.model || ''),
},
};
}
async testAiConnection(override = null) {
const config = this.aiConfig(override);
if (!config.ollama.host) {
return { ok: false, reason: 'Set an Ollama host first.' };
}
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})` };
}
const data = await res.json();
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,
model: config.ollama.model,
}) : false;
return {
ok: true,
installed,
vision,
models,
host,
model: config.ollama.model,
};
}
async modelCapabilities({ host, model }) {
const normalizedHost = normalizeOllamaHost(host);
const normalizedModel = normalizeWhitespace(model);
if (!normalizedHost || !normalizedModel) return [];
const cacheKey = `${normalizedHost}::${normalizedModel}`;
if (this.modelCapabilityCache.has(cacheKey)) {
return this.modelCapabilityCache.get(cacheKey);
}
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
let capabilities = [];
try {
const response = await this.fetchJson(url, {
method: 'POST',
body: { model: normalizedModel },
});
if (response.ok) {
const payload = await response.json();
capabilities = Array.isArray(payload?.capabilities)
? payload.capabilities.map((cap) => normalizeWhitespace(cap).toLowerCase()).filter(Boolean)
: [];
}
} catch {
capabilities = [];
}
if (!capabilities.includes('vision') && modelLooksVisionCapable(normalizedModel)) {
capabilities = [...capabilities, 'vision'];
}
this.modelCapabilityCache.set(cacheKey, capabilities);
return capabilities;
}
async modelSupportsVision({ host, model }) {
const capabilities = await this.modelCapabilities({ host, model });
return capabilities.includes('vision');
}
readStepImageBase64(guideId, stepId) {
const imagePath = this.store.stepImagePath(guideId, stepId, 'working') || this.store.stepImagePath(guideId, stepId, 'original');
if (!imagePath || !fs.existsSync(imagePath)) return '';
return fs.readFileSync(imagePath).toString('base64');
}
async callOllamaText({ host, model, prompt, systemPrompt, guideId = null }) {
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
const response = await this.withConcurrency(() => this.fetchJson(url, {
method: 'POST',
guideId,
body: {
model,
stream: false,
messages: [
{ role: 'system', content: systemPrompt },
{ 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;
if (typeof content !== 'string' || !content.trim()) throw new Error('Ollama returned an empty response');
return content.trim();
}
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.withConcurrency(() => this.fetchJson(url, {
method: 'POST',
guideId,
body: {
model,
stream: false,
format: 'json',
messages: [
{ role: 'system', content: systemPrompt },
userMessage,
],
options: {
temperature: 0.2,
},
},
}));
if (!response.ok) {
throw new Error(`Ollama request failed (${response.status})`);
}
const payload = await response.json();
const content = payload?.message?.content;
if (typeof content !== 'string' || !content.trim()) {
throw new Error('Ollama returned an empty response');
}
return content;
}
async generateStepPatch({
guideId,
stepId,
target = 'all',
blockId = null,
}) {
try {
const config = this.aiConfig();
if (!config.enabled) {
return { ok: false, reason: 'Enable AI in settings first.' };
}
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
: null;
if (blockId && target === 'block' && !currentBlock) {
return { ok: false, reason: 'Block not found.' };
}
// 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,
model: config.ollama.model,
})
: false;
let captureContext = null;
// Use stored capture metadata when available (best context, from capture time).
// Fall back to re-running OCR on the stored image only when metadata is absent.
if (step.captureMetadata) {
const rawCandidate = buildCaptureTitle({
mode: step.captureMetadata.mode || 'fullscreen',
metadata: {
windowTitle: step.captureMetadata.windowTitle,
appName: step.captureMetadata.appName,
elementLabel: step.captureMetadata.elementLabel,
elementRole: step.captureMetadata.elementRole,
elementValue: step.captureMetadata.elementValue,
},
ocrText: step.captureMetadata.ocrText,
recentTyped: step.captureMetadata.recentTyped,
recentShortcut: step.captureMetadata.recentShortcut,
});
captureContext = {
...step.captureMetadata,
// Don't suggest a generic fallback title — leave it blank so AI generates from context.
titleCandidate: DEFAULT_TITLE_VALUES.has(rawCandidate) ? '' : rawCandidate,
};
} else if (step.image) {
const imagePath = this.store.stepImagePath(guideId, stepId, 'working') || this.store.stepImagePath(guideId, stepId, 'original');
if (imagePath && fs.existsSync(imagePath)) {
const { nativeImage } = require('electron');
const image = nativeImage.createFromPath(imagePath);
if (!image.isEmpty()) {
const clickPoint = this.clickPointFromStep(step, image);
const [metadata, ocr] = await Promise.all([
this.collectForegroundWindowContext(),
this.ocrAroundClick({ image, size: image.getSize(), display: { bounds: { x: 0, y: 0, width: image.getSize().width, height: image.getSize().height } } }, clickPoint),
]);
const rawCandidate2 = buildCaptureTitle({
mode: step.kind === 'image' ? 'fullscreen' : 'window',
metadata,
ocrText: ocr.text,
});
captureContext = {
...metadata,
ocrText: ocr.text,
titleCandidate: DEFAULT_TITLE_VALUES.has(rawCandidate2) ? '' : rawCandidate2,
mode: step.kind === 'image' ? 'fullscreen' : 'content',
};
}
}
}
const { systemPrompt, prompt } = buildAiPrompt({
target,
guide,
step,
captureContext,
block: currentBlock,
screenshotAttached,
});
const raw = await this.callOllama({
host,
model: config.ollama.model,
prompt,
systemPrompt,
images: screenshotAttached ? [screenshotBase64] : [],
guideId,
});
const patch = normalizeAiPatch(raw);
// 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.' };
}
}
async rewriteText({ text, guideTitle = '', stepTitle = '' }) {
try {
const config = this.aiConfig();
if (!config.enabled) return { ok: false, reason: 'Enable AI in settings first.' };
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.' };
const contextHint = [
guideTitle ? `Guide: ${guideTitle}` : '',
stepTitle ? `Step: ${stepTitle}` : '',
].filter(Boolean).join('\n');
const prompt = [
contextHint,
contextHint ? '' : null,
'Rewrite the following text to sound professional and clear as step-by-step documentation.',
'Keep it concise. Do not add extra information. Return only the rewritten text.',
'',
trimmed,
].filter((l) => l !== null).join('\n');
const result = await this.callOllamaText({
host,
model: config.ollama.model,
prompt,
systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.',
});
return { ok: true, text: result };
} catch (err) {
return { ok: false, reason: err?.message || 'Rewrite failed.' };
}
}
clickPointFromStep(step, image = null) {
const marker = (step.annotations || []).find((ann) => ann.type === 'oval' && Number.isFinite(ann.x) && Number.isFinite(ann.y) && Number.isFinite(ann.w) && Number.isFinite(ann.h));
if (!marker) return null;
const size = image ? image.getSize() : step.image?.size || { width: 0, height: 0 };
if (!size.width || !size.height) return null;
return {
x: Math.round((marker.x + marker.w / 2) * size.width),
y: Math.round((marker.y + marker.h / 2) * size.height),
};
}
}
module.exports = { TextIntelService };
+92
View File
@@ -0,0 +1,92 @@
'use strict';
const { spawn } = require('node:child_process');
/**
* Opt a set of OS processes out of Windows Power Throttling (EcoQoS) and raise
* them to high priority, so the capture pipeline keeps running at full CPU
* speed while the laptop is on battery in a power-saving plan.
*
* Why this exists: during a recording StepForge hides its window, so Windows
* treats the frame-capture worker renderer (and the GPU / screen-capture
* utility processes feeding it) as background work and CPU-throttles them on
* DC power. Throttled, the worker can't sample frames fast enough — every
* click then finds no fresh pre-click frame and falls back to a slow
* post-click shot, which is what broke recordings on battery only.
*
* The Chromium command-line switches in main.js stop Chromium's own
* backgrounding; this goes one level lower and clears the OS EcoQoS flag via
* SetProcessInformation(ProcessPowerThrottling, EXECUTION_SPEED → off), which
* has no Node binding, so we drive it through a short-lived PowerShell.
*
* Best-effort and fire-and-forget: any failure (older Windows without the API,
* PowerShell blocked by policy, a process that already exited) is swallowed —
* the Chromium switches still apply and capture degrades gracefully.
*
* No-op on every non-Windows platform.
*
* @param {number[]} pids OS process ids to keep responsive.
*/
function keepProcessesResponsive(pids) {
if (process.platform !== 'win32') return;
// Only integers reach the script, so the interpolation below can't inject.
const list = [...new Set((pids || []).filter((p) => Number.isInteger(p) && p > 0))];
if (!list.length) return;
const ps = `
$ErrorActionPreference = 'SilentlyContinue'
Add-Type -TypeDefinition @'
using System;
using System.Runtime.InteropServices;
public static class SFPower {
[StructLayout(LayoutKind.Sequential)]
struct PROCESS_POWER_THROTTLING_STATE { public uint Version; public uint ControlMask; public uint StateMask; }
const int ProcessPowerThrottling = 4;
const uint PROCESS_POWER_THROTTLING_CURRENT_VERSION = 1;
const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
const uint HIGH_PRIORITY_CLASS = 0x00000080;
const uint PROCESS_SET_INFORMATION = 0x0200;
const uint PROCESS_QUERY_INFORMATION = 0x0400;
[DllImport("kernel32.dll", SetLastError=true)]
static extern IntPtr OpenProcess(uint access, bool inherit, uint pid);
[DllImport("kernel32.dll", SetLastError=true)]
static extern bool SetProcessInformation(IntPtr h, int cls, ref PROCESS_POWER_THROTTLING_STATE info, uint size);
[DllImport("kernel32.dll")]
static extern bool SetPriorityClass(IntPtr h, uint cls);
[DllImport("kernel32.dll")]
static extern bool CloseHandle(IntPtr h);
public static void Apply(uint pid) {
IntPtr h = OpenProcess(PROCESS_SET_INFORMATION | PROCESS_QUERY_INFORMATION, false, pid);
if (h == IntPtr.Zero) return;
try {
PROCESS_POWER_THROTTLING_STATE s = new PROCESS_POWER_THROTTLING_STATE();
s.Version = PROCESS_POWER_THROTTLING_CURRENT_VERSION;
s.ControlMask = PROCESS_POWER_THROTTLING_EXECUTION_SPEED;
s.StateMask = 0; // 0 => throttling off => opt out of EcoQoS
SetProcessInformation(h, ProcessPowerThrottling, ref s, (uint)Marshal.SizeOf(s));
SetPriorityClass(h, HIGH_PRIORITY_CLASS);
} finally { CloseHandle(h); }
}
}
'@
${list.map((p) => `[SFPower]::Apply(${p})`).join('\n')}
`;
try {
const child = spawn(
'powershell.exe',
['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', ps],
{ stdio: 'ignore', windowsHide: true },
);
child.on('error', () => { /* PowerShell missing/blocked — best effort */ });
child.unref();
} catch {
// spawn itself failed; the Chromium switches remain in effect.
}
}
module.exports = { keepProcessesResponsive };
-218
View File
@@ -1,218 +0,0 @@
{
"format": "stepforge-artifacts-manifest",
"version": 1,
"generatedAt": "2026-06-11T21:54:13.294Z",
"packageVersion": "0.1.0",
"files": [
{
"kind": "artifact",
"path": "artifacts/stepforge_0.1.0_amd64.deb",
"size": 103691640,
"sha256": "320faa345f5997905fdc831c045dbe243490a7b4328680d105934f2aec1b4ffb"
},
{
"kind": "artifact",
"path": "artifacts/stepforge_0.1.0_linux-x64.tar.gz",
"size": 139378628,
"sha256": "32971595d4df40b429cb41ba644e57b84351ec1239b4bbe8d5b82fe3b01a4cc9"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/guide.json",
"size": 843,
"sha256": "0d7760246d96a85a4d79c15c1ab1a7e481bd79e1d8ff29d177ac6d35ffe6cde6"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-01-open-users/original.png",
"size": 13643,
"sha256": "09e12f935511bb6fabb5637501aa7743516b96d990adfab62ccfa311a7b60606"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-01-open-users/step.json",
"size": 1593,
"sha256": "36deb8a8b51952a668e871a03b1c7fba2aaf72b1722fd2d88c7e5d5cbbd8da91"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-01-open-users/working.png",
"size": 13643,
"sha256": "09e12f935511bb6fabb5637501aa7743516b96d990adfab62ccfa311a7b60606"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-02-enable-policy/original.png",
"size": 14031,
"sha256": "b5e93a0ee74e2bdbbdf0871e901726dfbdc8b45dd648c959743520f92b02e7a2"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-02-enable-policy/step.json",
"size": 1873,
"sha256": "4b0b3b74f851d034a2769c69df2e9d0428373ae15009fb3e2100afb5c10f7ebb"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-02-enable-policy/working.png",
"size": 14031,
"sha256": "b5e93a0ee74e2bdbbdf0871e901726dfbdc8b45dd648c959743520f92b02e7a2"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-02a-permission-prompt/step.json",
"size": 763,
"sha256": "e8539addbe730e3a1baaa6898bf9779d2ce80564aecb3f196619d8c7ff67cbae"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-03-review-confirmation/original.png",
"size": 13602,
"sha256": "c96eedffdc5fd2eb9b63942cc00f1c8a91d01a0c5c2316c1d12d750b9b49e3d0"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-03-review-confirmation/step.json",
"size": 1968,
"sha256": "6f1cbb9d2ed32b89dec3d221822c6e973dbba42c11e93c2b35499179e8481532"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-03-review-confirmation/working.png",
"size": 13602,
"sha256": "c96eedffdc5fd2eb9b63942cc00f1c8a91d01a0c5c2316c1d12d750b9b49e3d0"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-04-legacy-note/step.json",
"size": 506,
"sha256": "c6f78405f86f4183612f5865820b2454471cbc47975e9c15e055e7bf742b1eba"
},
{
"kind": "sample",
"path": "../examples/sample-data/library/guides/guide-sample-reset-password/steps/step-sample-05-deprecated-flow/step.json",
"size": 536,
"sha256": "709887c0a5debddc851216920ee3f6f2766e986059b21ac399e2533611ab5aed"
},
{
"kind": "sample",
"path": "../examples/sample-exports/docx/reset-a-password-in-admin-portal.docx",
"size": 110463,
"sha256": "be9e2b550b732fc6cd4a171b72749b49fe67730ee9969f69c23175688b0bea14"
},
{
"kind": "sample",
"path": "../examples/sample-exports/gif/reset-a-password-in-admin-portal.gif",
"size": 32090,
"sha256": "70a789c6ce1aa6154c65d0ee88a286d888fdfd9fd5c3045a14242e13df5ca263"
},
{
"kind": "sample",
"path": "../examples/sample-exports/html-rich/reset-a-password-in-admin-portal-rich.html",
"size": 149884,
"sha256": "70695d55c37d69abb191e65675f726eaa7aea6686a15a2e7d387195441e3ca8c"
},
{
"kind": "sample",
"path": "../examples/sample-exports/html-simple/reset-a-password-in-admin-portal.html",
"size": 146646,
"sha256": "860c774b9b8bfd821b22deadc21f8021dc66bc9cacf43702eecbf55e944c6a9a"
},
{
"kind": "sample",
"path": "../examples/sample-exports/image-bundle/reset-a-password-in-admin-portal-bundle.json",
"size": 779,
"sha256": "0108a63331925ea1fdd03326dd37dce1af3642a02d167e583d0d67b50fce27fa"
},
{
"kind": "sample",
"path": "../examples/sample-exports/image-bundle/steps-reset-a-password-in-admin-portal/001-open-admin-portal-users.png",
"size": 31424,
"sha256": "892a3174e9876ebfab4c879d6d579cf8ad6eba2f299ad95feff33adaf205e042"
},
{
"kind": "sample",
"path": "../examples/sample-exports/image-bundle/steps-reset-a-password-in-admin-portal/002-enable-the-reset-policy.png",
"size": 38991,
"sha256": "f1c79fb2baa1ef41dd85b04be59cc796f3822858654612044a22e27f20d0696a"
},
{
"kind": "sample",
"path": "../examples/sample-exports/image-bundle/steps-reset-a-password-in-admin-portal/004-review-the-confirmation.png",
"size": 37274,
"sha256": "32c5e15f8a04d6af01b7d139cff732872d5ce7e3e519be6ece99cca4167856ed"
},
{
"kind": "sample",
"path": "../examples/sample-exports/json/reset-a-password-in-admin-portal.json",
"size": 6740,
"sha256": "4a27376e75c3bb33fa8e1c4117c3314a04ec4766d67a9b5c729180d116d384d1"
},
{
"kind": "sample",
"path": "../examples/sample-exports/json/steps-reset-a-password-in-admin-portal/001-open-admin-portal-users.png",
"size": 31424,
"sha256": "892a3174e9876ebfab4c879d6d579cf8ad6eba2f299ad95feff33adaf205e042"
},
{
"kind": "sample",
"path": "../examples/sample-exports/json/steps-reset-a-password-in-admin-portal/002-enable-the-reset-policy.png",
"size": 38991,
"sha256": "f1c79fb2baa1ef41dd85b04be59cc796f3822858654612044a22e27f20d0696a"
},
{
"kind": "sample",
"path": "../examples/sample-exports/json/steps-reset-a-password-in-admin-portal/004-review-the-confirmation.png",
"size": 37274,
"sha256": "32c5e15f8a04d6af01b7d139cff732872d5ce7e3e519be6ece99cca4167856ed"
},
{
"kind": "sample",
"path": "../examples/sample-exports/markdown/reset-a-password-in-admin-portal.md",
"size": 1186,
"sha256": "16bbd2eb8850d8a55914abe9ece4d2aee465820fcd6c5ffde8925f37dd2b115b"
},
{
"kind": "sample",
"path": "../examples/sample-exports/markdown/steps-reset-a-password-in-admin-portal/001-open-admin-portal-users.png",
"size": 31424,
"sha256": "892a3174e9876ebfab4c879d6d579cf8ad6eba2f299ad95feff33adaf205e042"
},
{
"kind": "sample",
"path": "../examples/sample-exports/markdown/steps-reset-a-password-in-admin-portal/002-enable-the-reset-policy.png",
"size": 38991,
"sha256": "f1c79fb2baa1ef41dd85b04be59cc796f3822858654612044a22e27f20d0696a"
},
{
"kind": "sample",
"path": "../examples/sample-exports/markdown/steps-reset-a-password-in-admin-portal/004-review-the-confirmation.png",
"size": 37274,
"sha256": "32c5e15f8a04d6af01b7d139cff732872d5ce7e3e519be6ece99cca4167856ed"
},
{
"kind": "sample",
"path": "../examples/sample-exports/pdf/reset-a-password-in-admin-portal.pdf",
"size": 103120,
"sha256": "58a952a2f95653a91d4e662e4bc2ddcb0177de33e51a8f9e454cdf158ba3a795"
},
{
"kind": "sample",
"path": "../examples/sample-exports/pptx/reset-a-password-in-admin-portal.pptx",
"size": 117643,
"sha256": "4ce1e82903b726c549e53235e3fd4c70cf647123ccd823052030e2e1b9449864"
},
{
"kind": "sample",
"path": "../examples/sample-guide.sfgz",
"size": 88427,
"sha256": "313b88f48e53e5ad7fb4e0a8189700ba0f6be642ec2311b1a2fe7ea4f3dd0481"
},
{
"kind": "sample",
"path": "../examples/sample-manifest.json",
"size": 1163,
"sha256": "cb2920e7500758074f1f54867db1ed187d3304c8badbceb06fd611057bd6fe5d"
}
]
}
-43
View File
@@ -1,43 +0,0 @@
# StepForge Build Report
Version: 0.1.0
Generated: 2026-06-11T21:54:13.292Z
Host: linux x64 (node v20.20.2)
## Outputs
- Portable tarball: artifacts/stepforge_0.1.0_linux-x64.tar.gz
- Debian package: artifacts/stepforge_0.1.0_amd64.deb
- Sample guide archive: ../examples/sample-guide.sfgz
- Sample exports (9 formats): see examples/sample-exports/
- Full artifact list with sha256 checksums: artifacts_manifest.json
## Packaging tool availability
| Tool | Status |
|---|---|
| dpkg-deb (Linux .deb) | available |
| rpmbuild (Linux .rpm) | **missing** |
| appimagetool (Linux AppImage) | **missing** |
| makensis (Windows installer .exe) | **missing** |
| wixl / WiX (Windows .msi) | **missing** |
Fallback policy: when a packaging tool is missing the build still produces
the runnable app (portable tarball with launcher) plus whatever package
formats the available tools allow. Windows artifacts are produced by
`npm run package:windows` (electron-builder, portable .exe); .msi/.rpm/
AppImage require the tools listed above and are skipped on this host.
## Offline guarantee
- The shipped app opens no sockets: no telemetry, update checks, license
checks, cloud sync, or remote AI. See docs/SECURITY.md.
- All exporters (PNG/GIF/PDF/DOCX/PPTX/ZIP) are implemented in-repo with
Node built-ins; Electron is the only third-party dependency
(dev-time fetch recorded in build/agent_audit.md).
## Verification
- `bash tests/run_test.sh` runs the workflow suites (node --test), a
startup smoke test of the Electron launcher, the sample-artifact
pipeline, and this release build.
+110
View File
@@ -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
+32 -8
View File
@@ -124,18 +124,40 @@ function importGuideArchive(store, file, { mode = 'copy' } = {}) {
}
function finalizeImport(store, newGuide, idMap, stepJsons, stepFiles) {
// Transactional import: validate the guide and EVERY step first, then write
// the whole guide into a temporary staging directory, and only publish it
// with a single atomic rename. Previously guide.json was written before the
// steps validated, so a bad step left a partial guide in the library.
validateGuide(newGuide);
writeJsonSync(path.join(store.guideDir(newGuide.guideId), 'guide.json'), newGuide);
const normalizedSteps = [];
for (const [stepId, { raw }] of stepJsons) {
const step = normalizeStep({ ...raw, stepId });
step.parentStepId = raw.parentStepId ? idMap.get(raw.parentStepId) || null : null;
validateStep(step);
const dir = store.stepDir(newGuide.guideId, stepId);
writeJsonSync(path.join(dir, 'step.json'), step);
for (const { name, data } of stepFiles.get(stepId) || []) {
atomicWriteFileSync(path.join(dir, name), data);
validateStep(step); // throws before anything is written on a bad step
normalizedSteps.push([stepId, step]);
}
const finalDir = store.guideDir(newGuide.guideId);
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
const stagingDir = `${finalDir}.importing-${Date.now()}`;
fs.rmSync(stagingDir, { recursive: true, force: true });
try {
fs.mkdirSync(stagingDir, { recursive: true });
writeJsonSync(path.join(stagingDir, 'guide.json'), newGuide);
for (const [stepId, step] of normalizedSteps) {
const dir = path.join(stagingDir, 'steps', stepId);
fs.mkdirSync(dir, { recursive: true });
writeJsonSync(path.join(dir, 'step.json'), step);
for (const { name, data } of stepFiles.get(stepId) || []) {
atomicWriteFileSync(path.join(dir, name), data);
}
}
// Publish atomically. If the final dir appeared meanwhile, fail cleanly.
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
fs.renameSync(stagingDir, finalDir);
} catch (err) {
fs.rmSync(stagingDir, { recursive: true, force: true });
throw err;
}
return store.getGuide(newGuide.guideId);
}
@@ -160,7 +182,9 @@ function saveLinkedGuide(store, guideId, { force = false } = {}) {
store.saveGuide(guide, { touch: false });
return { saved: true, path: target };
} finally {
releaseLock(target);
// Release by our acquisition token so we never remove a lock a concurrent
// force-steal replaced with theirs.
releaseLock(target, { lock: result.lock });
}
}
+26
View File
@@ -0,0 +1,26 @@
'use strict';
const { GuideStore } = require('./store');
const { buildRenderAst } = require('./renderast');
const { runExport } = require('../exporters');
/**
* Entry point for the export helper process (forked via
* app/export-runner.js). Runs one export job — building the render AST and
* invoking the exporter — off the main process so a large guide's PDF/DOCX/
* etc. rendering never blocks the UI.
*/
process.on('message', (job) => {
let payload;
try {
const { dataDir, guideId, format, options, outDir, globals, maxSteps } = job;
const store = new GuideStore(dataDir);
const ast = buildRenderAst(store, guideId, { globals, maxSteps });
const result = runExport(format, ast, outDir, options || {});
payload = { ok: true, result };
} catch (err) {
payload = { ok: false, error: err && err.message ? err.message : String(err) };
}
process.send(payload, () => process.exit(payload.ok ? 0 : 1));
});
+125
View File
@@ -0,0 +1,125 @@
'use strict';
const { decodeEntities } = require('./util');
/**
* Parse sanitized description HTML (see core/sanitize.js) into a flat list
* of blocks with inline formatting runs, for renderers (PDF) that need to
* preserve bold/italic/links/lists rather than flattening to plain text.
*
* Each block: { type, runs, indent, n? }
* type: 'p' | 'h1'..'h4' | 'blockquote' | 'li' | 'oli' | 'hr'
* runs: [{ text, bold, italic, code, href }] (absent for 'hr')
* indent: list/quote nesting depth (0 = top level)
* n: list item number, only for 'oli'
*/
const TAG_RE = /<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
const HREF_RE = /href\s*=\s*"([^"]*)"|href\s*=\s*'([^']*)'/i;
function htmlToBlocks(html) {
const blocks = [];
let current = null;
const styleStack = [{}];
const context = []; // nesting of <ul>/<ol>/<blockquote>
const indentLevel = () => context.length;
const currentStyle = () => styleStack[styleStack.length - 1];
const flushBlock = () => {
if (!current) return;
if (current.type === 'hr' || current.runs.some((r) => r.text.trim() !== '')) blocks.push(current);
current = null;
};
const startBlock = (type, extra = {}) => {
flushBlock();
current = { type, runs: [], indent: indentLevel(), ...extra };
};
const pushText = (text) => {
if (!text) return;
if (!current) current = { type: 'p', runs: [], indent: indentLevel() };
current.runs.push({ text, ...currentStyle() });
};
let last = 0;
let m;
while ((m = TAG_RE.exec(html)) !== null) {
if (m.index > last) pushText(decodeEntities(html.slice(last, m.index)));
last = TAG_RE.lastIndex;
const closing = Boolean(m[1]);
const tag = m[2].toLowerCase();
const rawAttrs = m[3];
switch (tag) {
case 'p':
case 'div':
if (closing) flushBlock();
else startBlock('p');
break;
case 'h1':
case 'h2':
case 'h3':
case 'h4':
if (closing) flushBlock();
else startBlock(tag);
break;
case 'blockquote':
if (closing) { flushBlock(); context.pop(); }
else { context.push({ kind: 'blockquote' }); startBlock('blockquote'); }
break;
case 'ul':
case 'ol':
if (closing) context.pop();
else context.push({ kind: tag, counter: 0 });
break;
case 'li':
if (closing) flushBlock();
else {
const ctx = context[context.length - 1];
const depth = Math.max(0, indentLevel() - 1);
if (ctx && ctx.kind === 'ol') { ctx.counter += 1; startBlock('oli', { n: ctx.counter, indent: depth }); }
else startBlock('li', { indent: depth });
}
break;
case 'br':
flushBlock();
break;
case 'hr':
flushBlock();
blocks.push({ type: 'hr' });
break;
case 'b':
case 'strong':
if (closing) styleStack.pop();
else styleStack.push({ ...currentStyle(), bold: true });
break;
case 'i':
case 'em':
if (closing) styleStack.pop();
else styleStack.push({ ...currentStyle(), italic: true });
break;
case 'code':
case 'pre':
if (closing) styleStack.pop();
else styleStack.push({ ...currentStyle(), code: true });
break;
case 'a':
if (closing) styleStack.pop();
else {
const hrefM = HREF_RE.exec(rawAttrs);
styleStack.push({ ...currentStyle(), href: hrefM ? (hrefM[1] ?? hrefM[2]) : undefined });
}
break;
default:
// Unrecognized/transparent allowed tags (table*, span, u, s, sub, sup):
// no block or style change.
break;
}
}
pushText(decodeEntities(html.slice(last)));
flushBlock();
return blocks;
}
module.exports = { htmlToBlocks };
+56 -10
View File
@@ -20,18 +20,39 @@ function lockPathFor(archivePath) {
return path.join(dir, `${stem}.lock-sfgz`);
}
function currentHolder() {
function currentProcess() {
return { host: os.hostname(), user: os.userInfo().username, pid: process.pid };
}
function currentHolder() {
return {
...currentProcess(),
// Random per-acquisition token so two processes that happen to share
// host+user+pid space (containers, pid reuse) still compare distinctly,
// and so a steal can be detected by the previous holder.
token: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
};
}
function readLock(archivePath) {
return readJsonIfExists(lockPathFor(archivePath), null);
}
function sameHolder(a, b) {
// Process identity (host+user+pid). Used to decide whether an existing lock is
// held by *this process* (safe to re-acquire) or someone else (a conflict).
function sameProcess(a, b) {
return a && b && a.host === b.host && a.user === b.user && a.pid === b.pid;
}
// Exact-acquisition identity via the per-acquisition token. Used by release so
// a caller only removes the lock it actually took (never one a force-steal
// replaced with its own).
function sameAcquisition(existing, owner) {
if (!existing || !owner) return false;
if (owner.token) return existing.token === owner.token;
return sameProcess(existing, owner);
}
function isStale(lock, now = Date.now()) {
const t = Date.parse(lock && lock.acquiredAt);
return !Number.isFinite(t) || now - t > STALE_AFTER_MS;
@@ -44,24 +65,49 @@ function isStale(lock, now = Date.now()) {
*/
function acquireLock(archivePath, { force = false } = {}) {
const file = lockPathFor(archivePath);
const existing = readLock(archivePath);
const me = currentHolder();
if (existing && !sameHolder(existing, me) && !isStale(existing) && !force) {
const lock = { ...me, acquiredAt: nowIso() };
const payload = JSON.stringify(lock, null, 2);
// Fast path: exclusive create. Only one writer wins the O_CREAT|O_EXCL race,
// so two processes can't both believe they hold the lock (the old
// read-then-write left exactly that window open).
try {
fs.writeFileSync(file, payload, { flag: 'wx' });
return { acquired: true, lock };
} catch (err) {
if (err.code !== 'EEXIST') throw err;
}
// A lock already exists. We may take it over only if this process already
// holds it, it is stale, or the caller is force-stealing (user confirmed).
const existing = readLock(archivePath);
if (existing && !sameProcess(existing, me) && !isStale(existing) && !force) {
return { acquired: false, conflict: existing };
}
const lock = { ...me, acquiredAt: nowIso() };
fs.writeFileSync(file, JSON.stringify(lock, null, 2));
// Overwrite to claim ownership (our token now identifies the lock).
fs.writeFileSync(file, payload);
return { acquired: true, lock };
}
/** Release only if we are the holder (or force). */
function releaseLock(archivePath, { force = false } = {}) {
/**
* Release only if we are the holder (or force). Pass the `lock` (or its
* `token`) returned by acquireLock so ownership is matched by token — the
* per-acquisition token means a fresh currentHolder() would not match.
*/
function releaseLock(archivePath, { force = false, lock = null, token = null } = {}) {
const file = lockPathFor(archivePath);
const existing = readLock(archivePath);
if (!existing) return true;
if (!force && !sameHolder(existing, currentHolder())) return false;
// With no explicit lock/token, fall back to process identity (the legacy
// "release my own lock" path) rather than a fresh token that can't match.
const owner = lock || (token ? { token } : currentProcess());
if (!force && !sameAcquisition(existing, owner)) return false;
fs.rmSync(file, { force: true });
return true;
}
module.exports = { lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS };
module.exports = {
lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS,
sameProcess, sameAcquisition,
};
+89 -4
View File
@@ -9,19 +9,41 @@ const zlib = require('node:zlib');
* points; converted to PDF's bottom-left space internally.
*/
const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier' };
const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier', F4: 'Helvetica-Oblique', F5: 'Helvetica-BoldOblique' };
// Approximate average glyph width factors (per 1pt font size) for wrapping.
const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6 };
const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6, F4: 0.51, F5: 0.55 };
function esc(text) {
return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
}
// "Smart typography" characters commonly produced by rich-text editors and
// pasted content (e.g. from Word/Google Docs) that have a printable glyph in
// WinAnsiEncoding (cp1252) outside the Latin-1 range; map to that byte
// instead of falling back to "?".
const WINANSI_EXTRA = {
0x20ac: 0x80, // €
0x2026: 0x85, // … ellipsis
0x2030: 0x89, // ‰
0x2039: 0x8b, //
0x203a: 0x9b, //
0x2018: 0x91, // ' left single quote
0x2019: 0x92, // ' right single quote
0x201c: 0x93, // " left double quote
0x201d: 0x94, // " right double quote
0x2022: 0x95, // • bullet
0x2013: 0x96, // en dash
0x2014: 0x97, // — em dash
0x2122: 0x99, // ™
};
function toLatin1(text) {
let out = '';
for (const ch of String(text)) {
const code = ch.codePointAt(0);
out += code <= 0xff ? ch : '?';
if (code <= 0xff) out += ch;
else if (WINANSI_EXTRA[code] !== undefined) out += String.fromCharCode(WINANSI_EXTRA[code]);
else out += '?';
}
return out;
}
@@ -38,6 +60,7 @@ class PdfBuilder {
this.images = []; // { name, width, height, data (deflated RGB), smask? }
this.imageCache = new Map();
this.bookmarks = []; // { title, pageIndex, y }
this.links = []; // { pageIndex, x, yTop, w, h, target }
}
addPage() {
@@ -73,11 +96,42 @@ class PdfBuilder {
text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) {
const y = this.pageHeight - yTop - size;
// Tabs have no glyph in WinAnsiEncoding and render as "?"; expand to spaces.
const clean = String(str).replace(/\t/g, ' ');
this.currentPage.ops.push(
`BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(str))}) Tj ET`
`BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(clean))}) Tj ET`
);
}
/**
* Render a line made of mixed-font/color segments (e.g. bold/italic runs)
* as one continuous text object: consecutive Tj operators without an
* intervening Tm advance the cursor by each font's real glyph widths, so
* spacing matches the viewer's metrics exactly (unlike our approximate
* textWidth(), which is only used for word-wrap decisions).
*/
textRun(parts, x, yTop, size) {
const y = this.pageHeight - yTop - size;
const ops = [`BT 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm`];
let curFont = null;
let curColor = null;
for (const part of parts) {
if (!part.text) continue;
if (part.font !== curFont) {
ops.push(`/${part.font} ${size} Tf`);
curFont = part.font;
}
if (!curColor || part.color[0] !== curColor[0] || part.color[1] !== curColor[1] || part.color[2] !== curColor[2]) {
ops.push(`${col(part.color)} rg`);
curColor = part.color;
}
const clean = String(part.text).replace(/\t/g, ' ');
ops.push(`(${esc(toLatin1(clean))}) Tj`);
}
ops.push('ET');
this.currentPage.ops.push(ops.join(' '));
}
rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) {
const y = this.pageHeight - yTop - h;
const ops = [];
@@ -120,6 +174,16 @@ class PdfBuilder {
this.bookmarks.push({ title: toLatin1(title), pageIndex });
}
/**
* Add a clickable region that jumps to another page (e.g. a Contents
* entry linking to a step). `target` is either a page index, or an
* object whose `pageIndex` is filled in later — once the destination
* page is known — and read when `build()` runs.
*/
linkRect(x, yTop, w, h, target, pageIndex = this.pages.length - 1) {
this.links.push({ pageIndex, x, yTop, w, h, target });
}
build() {
if (!this.pages.length) this.addPage();
const objects = []; // 1-based; objects[i] = body string|Buffer after header
@@ -159,6 +223,27 @@ class PdfBuilder {
));
}
// Link annotations (e.g. clickable Contents entries that jump to a
// step's page). Targets resolved late, after every step has claimed a
// page, so `target` may be an object whose `pageIndex` was only just set.
const pageAnnots = new Map(); // page index -> [annotation object id, ...]
for (const link of this.links) {
const targetIndex = typeof link.target === 'number' ? link.target : link.target?.pageIndex;
if (targetIndex == null || !pageIds[targetIndex]) continue;
const y1 = this.pageHeight - link.yTop - link.h;
const y2 = this.pageHeight - link.yTop;
const annotId = addObj(
`<< /Type /Annot /Subtype /Link /Rect [${link.x.toFixed(2)} ${y1.toFixed(2)} ${(link.x + link.w).toFixed(2)} ${y2.toFixed(2)}] ` +
`/Border [0 0 0] /Dest [${pageIds[targetIndex]} 0 R /Fit] >>`
);
if (!pageAnnots.has(link.pageIndex)) pageAnnots.set(link.pageIndex, []);
pageAnnots.get(link.pageIndex).push(annotId);
}
for (const [pageIndex, annotIds] of pageAnnots) {
const id = pageIds[pageIndex];
objects[id - 1] = objects[id - 1].replace(/ >>$/, ` /Annots [${annotIds.map((a) => `${a} 0 R`).join(' ')}] >>`);
}
let outlinesRef = '';
if (this.bookmarks.length) {
const outlineRootId = objects.length + 1;
+10 -4
View File
@@ -392,13 +392,19 @@ function renderAnnotations(baseImg, annotations = []) {
return img;
}
/** Apply focused view (zoom/pan crop, then scale back to original size). */
/**
* Apply focused view (zoom/pan crop, then scale back to original size).
* panX/panY are 0..1 fractions of the available pan range, not absolute
* image positions: 0 puts the crop at the left/bottom edge, 1 at the
* right/top edge, 0.5 centers it. Y is inverted so panY increases upward.
*/
function applyFocusedView(img, fv) {
if (!fv || !fv.enabled || !(fv.zoom > 1)) return img;
const vw = img.width / fv.zoom, vh = img.height / fv.zoom;
const cx = Math.min(Math.max(fv.panX * img.width, vw / 2), img.width - vw / 2);
const cy = Math.min(Math.max(fv.panY * img.height, vh / 2), img.height - vh / 2);
const region = crop(img, cx - vw / 2, cy - vh / 2, vw, vh);
const panX = fv.panX ?? 0.5, panY = fv.panY ?? 0.5;
const cropX = panX * (img.width - vw);
const cropY = (1 - panY) * (img.height - vh);
const region = crop(img, cropX, cropY, vw, vh);
return resize(region, img.width, img.height);
}
+18 -9
View File
@@ -3,7 +3,7 @@
const fs = require('node:fs');
const path = require('node:path');
const { sanitizeHtml } = require('./sanitize');
const { htmlToText, deepClone } = require('./util');
const { htmlToText, linkifyMarkdownLinks, deepClone } = require('./util');
const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders');
const { decodePng } = require('./png');
const { renderAnnotations, applyFocusedView } = require('./raster');
@@ -32,6 +32,10 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
system: systemPlaceholders(guide, { now, stepCount: includedIds.length }),
});
const expand = (text) => expandPlaceholders(text, values);
// Description fields additionally turn literal "[text](url)" markdown
// link syntax (as inserted by the editor's Link button) into real <a>
// tags before sanitizing, so exporters render an actual link.
const expandDesc = (html) => linkifyMarkdownLinks(expand(html || ''));
const steps = [];
const topCounter = { n: 0 };
@@ -66,15 +70,15 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
skipped: step.skipped,
forceNewPage: Boolean(step.forceNewPage),
title: expand(step.title || ''),
descriptionHtml: sanitizeHtml(expand(step.descriptionHtml || '')),
descriptionText: htmlToText(expand(step.descriptionHtml || '')),
descriptionHtml: sanitizeHtml(expandDesc(step.descriptionHtml)),
descriptionText: htmlToText(expandDesc(step.descriptionHtml)),
focusedView: step.focusedView,
annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })),
textBlocks: (step.textBlocks || []).map((tb) => ({
...tb,
title: expand(tb.title || ''),
descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')),
descriptionText: htmlToText(expand(tb.descriptionHtml || '')),
descriptionHtml: sanitizeHtml(expandDesc(tb.descriptionHtml)),
descriptionText: htmlToText(expandDesc(tb.descriptionHtml)),
})),
codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })),
tableBlocks: (step.tableBlocks || []).map((tb) => ({
@@ -86,8 +90,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
return {
...block,
title: expand(block.title || ''),
descriptionHtml: sanitizeHtml(expand(block.descriptionHtml || '')),
descriptionText: htmlToText(expand(block.descriptionHtml || '')),
descriptionHtml: sanitizeHtml(expandDesc(block.descriptionHtml)),
descriptionText: htmlToText(expandDesc(block.descriptionHtml)),
};
}
if (block.kind === 'code') return { ...block };
@@ -116,11 +120,16 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
guide: {
id: guide.guideId,
title: expand(guide.title),
descriptionHtml: sanitizeHtml(expand(guide.descriptionHtml || '')),
descriptionText: htmlToText(expand(guide.descriptionHtml || '')),
descriptionHtml: sanitizeHtml(expandDesc(guide.descriptionHtml)),
descriptionText: htmlToText(expandDesc(guide.descriptionHtml)),
createdAt: guide.createdAt,
updatedAt: guide.updatedAt,
flags: guide.flags,
metadata: {
author: expand(guide.metadata?.author || ''),
coAuthors: expand(guide.metadata?.coAuthors || ''),
organization: expand(guide.metadata?.organization || ''),
},
},
steps: limited,
};
+15
View File
@@ -34,6 +34,12 @@ function createGuide(fields = {}) {
title: fields.title || 'Untitled guide',
descriptionHtml: sanitizeHtml(fields.descriptionHtml || ''),
placeholders: { ...(fields.placeholders || {}) },
metadata: {
author: '',
coAuthors: '',
organization: '',
...(fields.metadata || {}),
},
flags: {
focusedViewDefault: false,
hideSkippedStepsInExports: true,
@@ -46,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,
};
}
@@ -80,6 +89,11 @@ function createStep(fields = {}) {
codeBlocks: (fields.codeBlocks || []).map((cb) => normalizeCodeBlock(cb, takeOrder(cb))),
tableBlocks: (fields.tableBlocks || []).map((tb) => normalizeTableBlock(tb, takeOrder(tb))),
links: fields.links || [], // { id, label, targetStepId }
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,
};
}
@@ -149,6 +163,7 @@ function validateGuide(guide) {
if (!Array.isArray(guide.stepsOrder)) errors.push('stepsOrder must be an array');
else if (new Set(guide.stepsOrder).size !== guide.stepsOrder.length) errors.push('stepsOrder has duplicates');
if (guide.placeholders && typeof guide.placeholders !== 'object') errors.push('placeholders must be an object');
if (guide.metadata && typeof guide.metadata !== 'object') errors.push('metadata must be an object');
if (errors.length) throw new Error(`invalid guide: ${errors.join('; ')}`);
return guide;
}
+69 -5
View File
@@ -14,7 +14,7 @@ const { blockText } = require('./blocks');
* specific step in the editor.
*/
const INDEX_VERSION = 1;
const INDEX_VERSION = 2;
function tokenize(text) {
if (!text) return [];
@@ -27,20 +27,82 @@ function tokenize(text) {
class SearchIndex {
constructor(indexDir) {
this.file = path.join(indexDir, 'search-index.json');
// Per-guide source fingerprints so a startup reconcile can tell which
// guides changed while the app was closed, without re-reading every step.
this.fingerprints = {}; // guideId -> fingerprint string
// Recovery status surfaced to the UI: 'ok' | 'reset' (missing/corrupt/
// version mismatch) | 'reconciled' (rebuilt from the store at startup).
this.status = 'ok';
const fileExisted = require('node:fs').existsSync(this.file);
const stored = readJsonIfExists(this.file, null);
if (stored && stored.version === INDEX_VERSION) {
if (stored && stored.version === INDEX_VERSION && stored.docs && typeof stored.docs === 'object') {
this.docs = stored.docs;
this.fingerprints = stored.fingerprints || {};
} else {
// Missing, corrupt, or an older index version: start empty and mark it,
// so reconcile() rebuilds from the store instead of silently staying
// blank (which made search "work" but return nothing). A file that
// existed but could not be used is a 'reset' (recovery-worthy); a
// genuinely absent index on first run is just 'ok'.
this.docs = {}; // docKey -> { guideId, stepId, title, text, updatedAt }
this.status = fileExisted ? 'reset' : 'ok';
}
}
persist() {
writeJsonSync(this.file, { version: INDEX_VERSION, docs: this.docs });
writeJsonSync(this.file, {
version: INDEX_VERSION,
docs: this.docs,
fingerprints: this.fingerprints,
});
}
static fingerprint(guide) {
return `${guide.updatedAt || ''}:${Number.isInteger(guide.revision) ? guide.revision : 0}`;
}
/**
* Reconcile the index against the store at startup: reindex guides that are
* new or changed (by fingerprint), and drop index entries for guides that no
* longer exist. Returns a summary with a recovery status for the UI.
*/
reconcile(store) {
const guides = store.listGuides();
const liveIds = new Set(guides.map((g) => g.guideId));
let reindexed = 0;
let removed = 0;
// Drop docs/fingerprints for guides that are gone.
for (const key of Object.keys(this.fingerprints)) {
if (!liveIds.has(key)) {
this.removeGuide(key, { persist: false });
delete this.fingerprints[key];
removed += 1;
}
}
for (const guide of guides) {
const fp = SearchIndex.fingerprint(guide);
const indexed = this.fingerprints[guide.guideId];
const hasDoc = Boolean(this.docs[`g:${guide.guideId}`]);
if (indexed === fp && hasDoc) continue; // unchanged
try {
this.indexGuide(guide, store.listSteps(guide.guideId), { persist: false });
reindexed += 1;
} catch {
// A single unreadable guide must not abort the whole reconcile.
}
}
this.persist();
if (this.status === 'reset' || reindexed > 0 || removed > 0) {
this.status = this.status === 'reset' ? 'reset' : 'reconciled';
}
return { status: this.status, reindexed, removed, total: guides.length };
}
/** (Re)index one guide and all of its steps. */
indexGuide(guide, stepsMap) {
indexGuide(guide, stepsMap, { persist = true } = {}) {
this.removeGuide(guide.guideId, { persist: false });
const placeholderText = Object.entries(guide.placeholders || {})
@@ -69,13 +131,15 @@ class SearchIndex {
updatedAt: guide.updatedAt,
};
}
this.persist();
this.fingerprints[guide.guideId] = SearchIndex.fingerprint(guide);
if (persist) this.persist();
}
removeGuide(guideId, { persist = true } = {}) {
for (const key of Object.keys(this.docs)) {
if (this.docs[key].guideId === guideId) delete this.docs[key];
}
delete this.fingerprints[guideId];
if (persist) this.persist();
}
+33
View File
@@ -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,11 +45,41 @@ 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,
autoTitleTemplate: '[[Mode]] capture [[Time]]',
},
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',
},
},
exports: {
previewStepCount: 3,
openFolderAfterExport: true,
+97 -9
View File
@@ -3,7 +3,8 @@
const fs = require('node:fs');
const path = require('node:path');
const { zipDirSync, extractZipSync } = require('./zip');
const { atomicWriteFileSync } = require('./util');
const { atomicWriteFileSync, readJsonSync } = require('./util');
const { validateGuide } = require('./schema');
/**
* Snapshot backups: a zip of the guide directory (excluding history/) stored
@@ -16,7 +17,11 @@ function snapshotsDir(store, guideId) {
}
function snapshotName(label) {
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
// Keep milliseconds: stripping them made two snapshots taken within the same
// second collide on filename (the second silently overwrote the first, so
// rapid automatic backups produced only one file). ms keeps names unique and
// still chronologically sortable.
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
return label ? `${stamp}-${label.replace(/[^A-Za-z0-9_-]+/g, '_')}.zip` : `${stamp}.zip`;
}
@@ -50,20 +55,103 @@ function pruneSnapshots(store, guideId, keepLast) {
/**
* Restore a snapshot: replaces the guide's current content (guide.json and
* steps/) with the snapshot's, keeping the history/ directory intact.
*
* The extraction is staged and validated BEFORE any live content is touched:
* a corrupt or truncated snapshot can no longer destroy the current guide.
* The swap itself moves the old content aside, moves the new content in, then
* deletes the old so a failure mid-swap leaves a recoverable state.
*/
function restoreSnapshot(store, guideId, name) {
const file = path.join(snapshotsDir(store, guideId), path.basename(name));
if (!fs.existsSync(file)) throw new Error(`snapshot not found: ${name}`);
const buf = fs.readFileSync(file);
const guideDir = store.guideDir(guideId);
// Safety: snapshot the pre-restore state too, so a restore is undoable.
createSnapshot(store, guideId, { label: 'pre-restore' });
for (const entry of fs.readdirSync(guideDir)) {
if (entry === 'history') continue;
fs.rmSync(path.join(guideDir, entry), { recursive: true, force: true });
// 1. Extract + validate into a temp staging dir. Nothing live is touched yet.
const staging = `${guideDir}.restoring-${Date.now()}`;
fs.rmSync(staging, { recursive: true, force: true });
try {
fs.mkdirSync(staging, { recursive: true });
extractZipSync(buf, staging);
const guideJson = path.join(staging, 'guide.json');
if (!fs.existsSync(guideJson)) throw new Error('snapshot is missing guide.json');
validateGuide(readJsonSync(guideJson)); // throws on a corrupt snapshot
} catch (err) {
fs.rmSync(staging, { recursive: true, force: true });
throw new Error(`snapshot restore aborted (snapshot invalid): ${err.message}`);
}
extractZipSync(buf, guideDir);
// 2. Snapshot the pre-restore state so the restore is itself undoable.
createSnapshot(store, guideId, { label: 'pre-restore' });
// 3. Swap in the validated content, preserving history/. Move live content
// aside first so we can roll back if a step fails.
const backup = `${guideDir}.prev-${Date.now()}`;
const liveEntries = fs.readdirSync(guideDir).filter((e) => e !== 'history');
fs.mkdirSync(backup, { recursive: true });
try {
for (const entry of liveEntries) {
fs.renameSync(path.join(guideDir, entry), path.join(backup, entry));
}
for (const entry of fs.readdirSync(staging)) {
if (entry === 'history') continue;
fs.renameSync(path.join(staging, entry), path.join(guideDir, entry));
}
} catch (err) {
// Roll back: restore whatever we moved aside.
for (const entry of fs.readdirSync(backup)) {
const dest = path.join(guideDir, entry);
fs.rmSync(dest, { recursive: true, force: true });
fs.renameSync(path.join(backup, entry), dest);
}
fs.rmSync(backup, { recursive: true, force: true });
fs.rmSync(staging, { recursive: true, force: true });
throw err;
}
fs.rmSync(backup, { recursive: true, force: true });
fs.rmSync(staging, { recursive: true, force: true });
return store.getGuide(guideId);
}
module.exports = { createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir };
/**
* Automatic backup policy. Every guide keeps a small save counter in its
* history dir; once `everyNSaves` saves accumulate (and backups.automatic is
* on) an automatic snapshot is taken and old ones pruned to backups.keepLast.
* Returns the snapshot name when one was taken, else null. Never throws a
* backup failure must not break the save that triggered it.
*/
function autoSnapshotIfDue(store, guideId, settings) {
try {
const backups = (settings && settings.get && settings.get('backups')) || {};
if (backups.automatic === false) return null;
const everyN = Number.isInteger(backups.everyNSaves) && backups.everyNSaves > 0 ? backups.everyNSaves : 25;
const keepLast = Number.isInteger(backups.keepLast) && backups.keepLast > 0 ? backups.keepLast : 10;
const dir = path.join(store.guideDir(guideId), 'history');
fs.mkdirSync(dir, { recursive: true });
const counterFile = path.join(dir, 'autosave-counter.json');
let count = 0;
try {
count = JSON.parse(fs.readFileSync(counterFile, 'utf8')).count || 0;
} catch { count = 0; }
count += 1;
if (count >= everyN) {
createSnapshot(store, guideId, { label: 'auto', keepLast });
count = 0;
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
return true;
}
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
return null;
} catch (err) {
// Best effort: report, never break the caller's save.
console.error(`[stepforge] automatic backup failed for ${guideId}: ${err && err.message}`);
return null;
}
}
module.exports = {
createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir,
autoSnapshotIfDue,
};
+109 -10
View File
@@ -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);
@@ -244,6 +322,27 @@ class GuideStore {
this.saveGuide(guide);
}
/**
* Recreate a previously-deleted step, preserving its stepId, and splice it
* back into the guide's order. Used to undo step deletion.
*/
restoreStep(guideId, step, images, position) {
const guide = this.getGuide(guideId);
const restored = normalizeStep(deepClone(step));
validateStep(restored);
const dir = this.stepDir(guideId, restored.stepId);
fs.mkdirSync(dir, { recursive: true });
if (restored.image && images) {
if (images.original) atomicWriteFileSync(path.join(dir, restored.image.originalPath), images.original);
if (images.working) atomicWriteFileSync(path.join(dir, restored.image.workingPath), images.working);
}
writeJsonSync(path.join(dir, 'step.json'), restored);
const at = Number.isInteger(position) ? Math.min(position, guide.stepsOrder.length) : guide.stepsOrder.length;
guide.stepsOrder.splice(at, 0, restored.stepId);
this.saveGuide(guide);
return restored;
}
reorderSteps(guideId, newOrder) {
const guide = this.getGuide(guideId);
const current = new Set(guide.stepsOrder);
@@ -331,4 +430,4 @@ class GuideStore {
}
}
module.exports = { GuideStore };
module.exports = { GuideStore, RevisionConflictError };
+16 -2
View File
@@ -11,7 +11,21 @@ const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./
* defaults, shareable as .sfglt zip files.
*/
const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
const FORMATS = ['json', 'markdown', 'wikijs', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
const FORMAT_LABELS = {
json: 'JSON',
markdown: 'Markdown',
wikijs: 'Wiki.js',
'html-simple': 'HTML (simple)',
'html-rich': 'HTML (rich)',
confluence: 'Confluence',
pdf: 'PDF',
gif: 'GIF',
'image-bundle': 'Image bundle',
docx: 'DOCX',
pptx: 'PPTX',
};
class TemplateManager {
constructor(templatesDir) {
@@ -96,4 +110,4 @@ class TemplateManager {
}
}
module.exports = { TemplateManager, FORMATS };
module.exports = { TemplateManager, FORMATS, FORMAT_LABELS };
+913
View File
@@ -0,0 +1,913 @@
'use strict';
const { deepClone, htmlToText, escapeHtml } = require('./util');
const { sanitizeHtml } = require('./sanitize');
const {
TEXTBLOCK_LEVELS,
TEXTBLOCK_POSITIONS,
normalizeTextBlock,
normalizeCodeBlock,
normalizeTableBlock,
} = require('./schema');
const DEFAULT_CAPTURE_TITLES = {
fullscreen: 'Screen capture',
window: 'Window capture',
region: 'Region capture',
};
const AI_LEVEL_ALIASES = new Map([
['note', 'info'],
['info', 'info'],
['tip', 'success'],
['success', 'success'],
['warning', 'warn'],
['warn', 'warn'],
['important', 'error'],
['error', 'error'],
]);
const GENERIC_OCR_PHRASES = new Set([
'button',
'click',
'double click',
'menu',
'item',
'field',
'text field',
'search',
'submit',
'cancel',
'ok',
'open',
'select',
'enter',
'type',
]);
// Generic OS/browser chrome titles that tell us nothing about what the user did.
const GENERIC_WINDOW_TITLES = new Set([
'new tab', 'new window', 'new incognito window', 'new incognito tab',
'new document', 'untitled', 'blank page', 'home page', 'homepage',
'start page', 'speed dial', 'loading', 'loading…', 'loading...',
]);
const BROWSER_NAME_PHRASES = new Set([
'google chrome',
'chrome',
'chromium',
'microsoft edge',
'edge',
'brave',
'firefox',
'safari',
'opera',
'vivaldi',
]);
// Known search engine page title suffixes (what appears after the query in the window title).
const SEARCH_ENGINE_PAGE_NAMES = new Set([
'google search',
'google',
'bing',
'duckduckgo',
'yahoo search',
'yahoo',
'startpage',
'ecosia',
'brave search',
]);
// Common keyboard shortcuts → short action descriptions used as step titles.
const SHORTCUT_TITLES = {
'Ctrl+T': 'Open new tab',
'Ctrl+N': 'Open new window',
'Ctrl+W': 'Close tab',
'Ctrl+Shift+T': 'Reopen closed tab',
'Ctrl+Shift+N': 'Open incognito window',
'Ctrl+S': 'Save',
'Ctrl+Shift+S': 'Save as',
'Ctrl+Z': 'Undo',
'Ctrl+Y': 'Redo',
'Ctrl+Shift+Z': 'Redo',
'Ctrl+C': 'Copy selection',
'Ctrl+V': 'Paste',
'Ctrl+X': 'Cut selection',
'Ctrl+A': 'Select all',
'Ctrl+F': 'Open Find',
'Ctrl+H': 'Open Find and Replace',
'Ctrl+R': 'Reload page',
'Ctrl+Shift+R': 'Hard reload page',
'Ctrl+L': 'Focus address bar',
'Ctrl+D': 'Bookmark page',
'Ctrl+Tab': 'Switch to next tab',
'Ctrl+Shift+Tab': 'Switch to previous tab',
'Ctrl+Plus': 'Zoom in',
'Ctrl+Minus': 'Zoom out',
'Ctrl+0': 'Reset zoom',
'Ctrl+P': 'Print',
'Ctrl+O': 'Open file',
'Ctrl+E': 'Focus search bar',
'Ctrl+K': 'Focus search bar',
'Ctrl+G': 'Go to line',
'Ctrl+B': 'Toggle sidebar',
'Ctrl+Shift+P': 'Open command palette',
'Ctrl+Shift+E': 'Show file explorer',
'Ctrl+Shift+G': 'Show source control',
'Ctrl+Shift+D': 'Show debug panel',
'Ctrl+Shift+X': 'Show extensions',
'Alt+F4': 'Close window',
'Alt+Left': 'Go back',
'Alt+Right': 'Go forward',
'Alt+Tab': 'Switch application',
'F2': 'Rename',
'F3': 'Find next',
'F4': 'Open address bar',
'F5': 'Reload page',
'F11': 'Toggle fullscreen',
'F12': 'Open developer tools',
};
// Process name → human-readable display name (used to append "in Chrome" etc. to titles).
const APP_DISPLAY_NAMES = {
chrome: 'Chrome',
msedge: 'Edge',
firefox: 'Firefox',
safari: 'Safari',
opera: 'Opera',
brave: 'Brave',
vivaldi: 'Vivaldi',
code: 'VS Code',
cursor: 'Cursor',
'sublime_text': 'Sublime Text',
atom: 'Atom',
notepad: 'Notepad',
'notepad++': 'Notepad++',
winword: 'Word',
excel: 'Excel',
powerpnt: 'PowerPoint',
outlook: 'Outlook',
teams: 'Teams',
slack: 'Slack',
discord: 'Discord',
zoom: 'Zoom',
figma: 'Figma',
postman: 'Postman',
insomnia: 'Insomnia',
notion: 'Notion',
obsidian: 'Obsidian',
spotify: 'Spotify',
terminal: 'Terminal',
cmd: 'Command Prompt',
powershell: 'PowerShell',
windowsterminal: 'Windows Terminal',
wt: 'Windows Terminal',
iterm2: 'iTerm',
wezterm: 'WezTerm',
alacritty: 'Alacritty',
kitty: 'Kitty',
'gnome-terminal': 'Terminal',
konsole: 'Konsole',
xterm: 'Terminal',
xfce4terminal: 'Terminal',
bash: 'Terminal',
zsh: 'Terminal',
fish: 'Terminal',
finder: 'Finder',
explorer: 'File Explorer',
'files-uwp': 'File Explorer',
steam: 'Steam',
'steamwebhelper': 'Steam',
};
function cleanAppName(rawName) {
if (!rawName) return '';
const key = normalizeWhitespace(rawName).toLowerCase().replace(/\.exe$/i, '');
return APP_DISPLAY_NAMES[key] || sentenceCase(rawName.replace(/\.exe$/i, ''));
}
function qualifyTitleWithApp(title, appName) {
const app = cleanAppName(appName);
if (!app) return title;
if (new RegExp(`\\b${app.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i').test(title)) return title;
return `${title} in ${app}`;
}
const ACTION_PREFIXES = [
'click',
'select',
'open',
'choose',
'enter',
'type',
'search',
'switch to',
'go to',
'navigate to',
'toggle',
'turn on',
'turn off',
'enable',
'disable',
'pick',
'focus',
'launch',
'activate',
];
function normalizeWhitespace(text) {
return String(text == null ? '' : text)
.replace(/\s+/g, ' ')
.trim();
}
function titleCaseWord(word) {
if (!word) return word;
if (/^[A-Z0-9]{2,}$/.test(word)) return word;
if (/^\d+$/.test(word)) return word;
return word[0].toUpperCase() + word.slice(1).toLowerCase();
}
function displayText(text) {
const clean = normalizeWhitespace(text)
.replace(/^[\s"'`([{<]+|[\s"'`)}\]>.,;:!?]+$/g, '')
.trim();
if (!clean) return '';
if (clean === clean.toUpperCase()) {
return clean.split(/\s+/).map(titleCaseWord).join(' ');
}
return clean.replace(/\s+/g, ' ');
}
function sentenceCase(text) {
const clean = displayText(text);
if (!clean) return '';
return clean.charAt(0).toUpperCase() + clean.slice(1);
}
function isPathOrUrlLike(text) {
return /^(?:https?:\/\/|file:\/\/|about:blank|chrome:\/\/|edge:\/\/|moz-extension:\/\/|view-source:|localhost(?:[:/]|$)|www\.)/i.test(text) ||
/[A-Za-z]:\\/.test(text) ||
/\/(?:[^/\s]+\/){2,}/.test(text) ||
/\\/.test(text);
}
function isBrowserNoise(text) {
const clean = normalizeWhitespace(text).toLowerCase();
if (!clean) return true;
if (BROWSER_NAME_PHRASES.has(clean)) return true;
if (isPathOrUrlLike(clean)) return true;
let foundBrowserName = false;
for (const name of BROWSER_NAME_PHRASES) {
if (clean.includes(name)) {
foundBrowserName = true;
break;
}
}
return foundBrowserName && /[\s|•·*]{2,}|[-–—]|\/|\\/.test(clean);
}
function isUsefulTitleCandidate(text, { source = 'ocr' } = {}) {
const clean = displayText(text);
if (!clean) return false;
const lower = clean.toLowerCase();
if (GENERIC_OCR_PHRASES.has(lower)) return false;
if (BROWSER_NAME_PHRASES.has(lower)) return false;
if (isPathOrUrlLike(clean)) return false;
if ((source === 'window' || source === 'app') && isBrowserNoise(clean)) return false;
if (source === 'window' && GENERIC_WINDOW_TITLES.has(lower)) return false;
if (/^[\p{P}\p{S}0-9]+$/u.test(clean)) return false;
return true;
}
function splitTitleFragments(text) {
const clean = normalizeWhitespace(text);
if (!clean) return [];
return clean
.split(/\s*(?:\*\*+|[|•·]+|::|\/+|\\+|\s[-–—]\s|\s{2,})\s*/g)
.map((part) => displayText(part))
.filter(Boolean);
}
function candidateWords(text) {
const clean = normalizeWhitespace(text);
if (!clean) return [];
// Exclude standalone punctuation tokens (e.g. "|" in "Oracle | Cloud...") from word count.
return clean.split(/\s+/).filter((w) => /[a-zA-Z0-9]/.test(w));
}
// Remove trailing "- Google Chrome", "| Firefox", etc. from a window title.
// When appName is supplied, also strips the specific app's display name suffix:
// "Document1 - Word" → "Document1" when appName is "winword".
function stripBrowserNameSuffix(text, appName) {
let clean = normalizeWhitespace(text);
// Always strip known browser names first.
for (const name of BROWSER_NAME_PHRASES) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
clean = clean.replace(new RegExp(`\\s*[-–—·|•]\\s*${escaped}\\s*$`, 'i'), '').trim();
}
// Also strip the specific app's display name when provided.
if (appName) {
const display = cleanAppName(appName);
const raw = normalizeWhitespace(appName).replace(/\.exe$/i, '');
for (const name of [display, raw].filter(Boolean)) {
const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
clean = clean.replace(new RegExp(`\\s*[-–—·|•]\\s*${escaped}\\s*$`, 'i'), '').trim();
}
}
return clean;
}
// Detect "[query] - Google Search" or "[query] - Bing" patterns in a (already-stripped) page title.
// Returns the query word(s) if found, otherwise ''.
function extractSearchQuery(pageTitle) {
const frags = splitTitleFragments(pageTitle);
if (frags.length < 2) return '';
const last = frags[frags.length - 1].toLowerCase();
if (SEARCH_ENGINE_PAGE_NAMES.has(last)) {
const query = frags[0];
if (query && isUsefulTitleCandidate(query, { source: 'ocr' })) return query;
}
return '';
}
function scoreCandidate(text, { source = 'ocr' } = {}) {
const clean = displayText(text);
if (!clean) return -Infinity;
const words = candidateWords(clean);
if (!words.length) return -Infinity;
let score = 0;
score += source === 'ocr' ? 140 : source === 'element' ? 95 : source === 'window' ? 35 : source === 'app' ? 25 : 90;
score += Math.min(words.length, 5) * 10;
score -= Math.max(0, words.length - 5) * 11;
score -= Math.max(0, clean.length - 42) * 0.8;
if (GENERIC_OCR_PHRASES.has(clean.toLowerCase())) score -= 50;
if (BROWSER_NAME_PHRASES.has(clean.toLowerCase())) score -= 80;
if (isBrowserNoise(clean)) score -= 60;
if (clean.length <= 24) score += 10;
if (/^(click|select|open|choose|enter|type|search|switch to|go to|navigate to|toggle|turn on|turn off|enable|disable|pick|focus|launch|activate)\b/i.test(clean)) score += 12;
if (/^[\p{P}\p{S}0-9]+$/u.test(clean)) score -= 100;
return score;
}
function pickBestOcrPhrase(ocrText) {
const text = normalizeWhitespace(ocrText);
if (!text) return '';
let best = '';
let bestScore = -Infinity;
for (const rawLine of text.split(/\n+/)) {
const line = normalizeWhitespace(rawLine);
if (!line) continue;
// For short lines (link text, button labels) try the FULL line first before splitting.
// This preserves "Oracle | Cloud Applications and Cloud Platform" instead of splitting on |.
// Full-line bonus (+35) nudges it ahead of its own fragments.
const candidates = line.length <= 80
? [[line, 35], ...splitTitleFragments(line).map((f) => [f, 0])]
: splitTitleFragments(line).map((f) => [f, 0]);
for (const [part, bonus] of candidates) {
if (!isUsefulTitleCandidate(part, { source: 'ocr' })) continue;
const score = scoreCandidate(part, { source: 'ocr' }) + bonus;
if (score > bestScore) {
best = part;
bestScore = score;
}
}
}
return best;
}
function isShortUiLabel(text) {
const words = candidateWords(text);
return words.length > 0 && words.length <= 2 && text.length <= 24;
}
function isDirectiveTitle(text) {
const clean = displayText(text);
if (!clean) return false;
const lower = clean.toLowerCase();
return ACTION_PREFIXES.some((prefix) => lower.startsWith(prefix));
}
function verbForElementRole(role) {
const clean = normalizeWhitespace(role).toLowerCase();
if (!clean) return null;
if (/(tab|menu item|menuitem|option|list item|tree item|radio button|dropdown list|combo box option|hyperlink|link)/.test(clean)) {
return 'Select';
}
if (/(search box|searchbox|search field|search bar|search input)/.test(clean)) {
return 'Search for';
}
if (/(button|check box|checkbox|toggle button|switch|item|command)/.test(clean)) {
return 'Click';
}
if (/(text field|edit|combo box|textbox|text box|input|field)/.test(clean)) {
return 'Click';
}
return null;
}
function formatCaptureTitle(text, { source = 'ocr', metadata = {} } = {}) {
const clean = displayText(text);
if (!clean) return '';
if (isDirectiveTitle(clean)) {
return sentenceCase(clean);
}
const roleVerb = (source === 'ocr' || source === 'element') ? verbForElementRole(metadata.elementRole) : null;
if (roleVerb) {
return `${roleVerb} ${sentenceCase(clean)}`;
}
if (source === 'window' || source === 'app') {
return `Open ${sentenceCase(clean)}`;
}
if (source === 'ocr' || source === 'element') {
return isShortUiLabel(clean) ? `Click ${sentenceCase(clean)}` : sentenceCase(clean);
}
return sentenceCase(clean);
}
function pickBestTitleFragment(text, { source = 'window', metadata = {} } = {}) {
const fragments = splitTitleFragments(text).filter((line) => isUsefulTitleCandidate(line, { source }));
if (!fragments.length) return '';
let best = '';
let bestScore = -Infinity;
for (const part of fragments) {
const score = scoreCandidate(part, { source });
if (score > bestScore) {
best = part;
bestScore = score;
}
}
return best ? formatCaptureTitle(best, { source, metadata }) : '';
}
function buildCaptureTitle({ mode = 'fullscreen', metadata = {}, ocrText = '', recentTyped = '', recentShortcut = '' } = {}) {
const app = cleanAppName(metadata.appName);
// 1. Keyboard shortcut → most reliable signal for "what action did the user take".
if (recentShortcut && SHORTCUT_TITLES[recentShortcut]) {
const base = SHORTCUT_TITLES[recentShortcut];
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
// 2. UIAutomation element value — what's actually typed inside the clicked field.
const elementValue = normalizeWhitespace(metadata.elementValue || '');
if (elementValue) {
const roleLower = normalizeWhitespace(metadata.elementRole || '').toLowerCase();
const labelLower = normalizeWhitespace(metadata.elementLabel || '').toLowerCase();
const looksLikeSearch = /(search|find|query|omnibox|address bar)/.test(roleLower + ' ' + labelLower);
const action = looksLikeSearch ? 'Search for' : 'Type';
const base = `${action} "${elementValue}"`;
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
// 3. Keyboard-buffer text (typed between captures) + input role context.
const typed = normalizeWhitespace(recentTyped || '');
if (typed) {
const roleLower = normalizeWhitespace(metadata.elementRole || '').toLowerCase();
const labelLower = normalizeWhitespace(metadata.elementLabel || '').toLowerCase();
const isSearchRole = /(search box|searchbox|search field|search bar|search input)/.test(roleLower);
const looksLikeSearch = isSearchRole || /(search|find|query|omnibox|address bar)/.test(roleLower + ' ' + labelLower);
const isAnyInput = /(text field|edit|input|field|combo box|textbox|text box)/.test(roleLower);
if (looksLikeSearch) {
const base = `Search for "${typed}"`;
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
if (isAnyInput) {
const base = `Type "${typed}"`;
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
}
// 4. OCR text around the click — link text, button labels, menu items.
const ocrPhrase = pickBestOcrPhrase(ocrText);
if (ocrPhrase) {
const title = formatCaptureTitle(ocrPhrase, { source: 'ocr', metadata });
return app ? qualifyTitleWithApp(title, metadata.appName) : title;
}
// 5. UIAutomation element label.
const elementPhrase = pickBestTitleFragment(metadata.elementLabel, { source: 'element', metadata });
if (elementPhrase) {
return app ? qualifyTitleWithApp(elementPhrase, metadata.appName) : elementPhrase;
}
// 6. Window title (browser suffix + app name stripped) → page title or search query.
const strippedWindowTitle = stripBrowserNameSuffix(metadata.windowTitle || '', metadata.appName);
if (strippedWindowTitle) {
const searchQuery = extractSearchQuery(strippedWindowTitle);
if (searchQuery) {
// Only claim this step IS the search action when the user was actually typing
// (recentTyped). Without typing context, the search page title is from the
// PREVIOUS step — the current step is a click ON the search results page.
if (recentTyped) {
const base = `Search for ${sentenceCase(searchQuery)}`;
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
// User is clicking something on the search results page — don't claim they searched.
const base = `Select a ${sentenceCase(searchQuery)} result`;
return app ? qualifyTitleWithApp(base, metadata.appName) : base;
}
const windowPhrase = pickBestTitleFragment(strippedWindowTitle, { source: 'window', metadata });
if (windowPhrase) return windowPhrase;
}
// 7. App name alone as last resort.
const appPhrase = pickBestTitleFragment(metadata.appName, { source: 'app', metadata });
if (appPhrase) return appPhrase;
return DEFAULT_CAPTURE_TITLES[mode] || 'Capture';
}
function plainTextToHtml(text) {
const trimmed = normalizeWhitespace(text);
if (!trimmed) return '';
return trimmed
.split(/\n{2,}/)
.map((para) => `<p>${escapeHtml(para).replace(/\n/g, '<br>')}</p>`)
.join('');
}
function normalizeOllamaHost(host) {
const raw = normalizeWhitespace(host);
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw.replace(/\/+$/, '');
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');
}
function normalizeAiPosition(position) {
const key = normalizeWhitespace(position).toLowerCase();
return TEXTBLOCK_POSITIONS.includes(key) ? key : 'after-description';
}
function normalizeAiBlock(block) {
if (!block || typeof block !== 'object') return null;
const kind = normalizeWhitespace(block.kind).toLowerCase();
if (kind === 'text') {
const normalized = normalizeTextBlock({
id: block.id,
order: Number.isFinite(block.order) ? block.order : null,
position: normalizeAiPosition(block.position),
level: normalizeAiLevel(block.level),
title: displayText(block.title),
descriptionHtml: plainTextToHtml(block.body ?? block.description ?? block.text ?? ''),
}, Number.isFinite(block.order) ? block.order : null);
return { ...normalized, kind: 'text' };
}
if (kind === 'code') {
return {
...normalizeCodeBlock({
id: block.id,
order: Number.isFinite(block.order) ? block.order : null,
language: displayText(block.language).toLowerCase(),
code: String(block.code ?? ''),
}, Number.isFinite(block.order) ? block.order : null),
kind: 'code',
};
}
if (kind === 'table') {
const rows = Array.isArray(block.rows)
? block.rows.map((row) => (Array.isArray(row) ? row.map((cell) => displayText(cell)) : []))
: [];
return {
...normalizeTableBlock({
id: block.id,
order: Number.isFinite(block.order) ? block.order : null,
rows,
}, Number.isFinite(block.order) ? block.order : null),
kind: 'table',
};
}
return null;
}
function normalizeAiPatch(raw) {
let data = raw;
if (typeof raw === 'string') {
const trimmed = raw.trim().replace(/^```(?:json)?\s*|\s*```$/g, '');
const start = trimmed.indexOf('{');
const end = trimmed.lastIndexOf('}');
const jsonText = start >= 0 && end > start ? trimmed.slice(start, end + 1) : trimmed;
data = JSON.parse(jsonText);
}
if (!data || typeof data !== 'object' || Array.isArray(data)) {
throw new Error('AI response must be a JSON object');
}
const out = {
title: displayText(data.title),
descriptionHtml: plainTextToHtml(data.description ?? data.descriptionText ?? ''),
blocks: Array.isArray(data.blocks)
? data.blocks.map((block) => normalizeAiBlock(block)).filter(Boolean)
: [],
};
return out;
}
function summarizeBlocks(step = {}) {
const parts = [];
for (const block of step.textBlocks || []) {
const body = htmlToText(block.descriptionHtml || '');
parts.push(`- Text (${block.level || 'info'}, ${block.position || 'after-description'}): ${block.title || ''}${body ? `${body}` : ''}`.trim());
}
for (const block of step.codeBlocks || []) {
const code = String(block.code || '').trim();
parts.push(`- Code (${block.language || 'plain'}):\n${code || '(empty)'}`);
}
for (const block of step.tableBlocks || []) {
const rows = Array.isArray(block.rows) ? block.rows.length : 0;
const cols = rows > 0 && Array.isArray(block.rows[0]) ? block.rows[0].length : 0;
parts.push(`- Table (${rows}x${cols})`);
}
return parts.length ? parts.join('\n') : '(none)';
}
const DEFAULT_PLACEHOLDER_TITLES = new Set(
Object.values(DEFAULT_CAPTURE_TITLES).concat(['Capture', 'Untitled step']),
);
function isPlaceholderTitle(title) {
return !title || DEFAULT_PLACEHOLDER_TITLES.has(title);
}
function summarizeStepForAi(step = {}) {
const titleLine = isPlaceholderTitle(step.title)
? 'Step title: (not set — generate a specific action title from the capture context)'
: `Step title: ${step.title}`;
const descText = htmlToText(step.descriptionHtml || '');
return [
titleLine,
`Step description: ${descText || '(empty)'}`,
`Step status: ${step.status || 'todo'}`,
`Blocks:\n${summarizeBlocks(step)}`,
].join('\n');
}
function summarizeGuideForAi(guide = {}) {
return [
`Guide title: ${guide.title || '(untitled)'}`,
`Guide description: ${htmlToText(guide.descriptionHtml || '') || '(empty)'}`,
].join('\n');
}
function hasRichCaptureContext(captureContext) {
if (!captureContext) return false;
const ocr = normalizeWhitespace(captureContext.ocrText || '');
const win = normalizeWhitespace(captureContext.windowTitle || '');
const app = normalizeWhitespace(captureContext.appName || '');
const element = normalizeWhitespace(captureContext.elementLabel || '');
// Any non-trivial context signal is enough — even just an app name.
return ocr.length > 3 || win.length > 2 || app.length > 1 || element.length > 1;
}
function buildAiPrompt({
target = 'all',
guide = null,
step = null,
captureContext = null,
block = null,
screenshotAttached = false,
} = {}) {
const hasDraftTitle = step && !isPlaceholderTitle(step.title);
const hasDraftDesc = step && Boolean(htmlToText(step.descriptionHtml || ''));
const targetText = {
title: hasDraftTitle
? 'improve the user\'s draft step title — keep their intent, make it read like professional documentation'
: 'write a specific action title for this step using the capture context',
description: hasDraftDesc
? 'improve the user\'s draft description — keep their intent, make it read like professional documentation'
: 'write a 12 sentence description of what the user does in this step, using the capture context',
block: 'rewrite only the target block',
all: 'write the step title and description from the capture context',
}[target] || 'rewrite the step';
const richContext = hasRichCaptureContext(captureContext);
const allowedBlockNote = target === 'block' ? [
'Use block.kind = "text" with level in [info, warn, error, success] for note / warning / important / tip blocks.',
'Use block.kind = "code" for code snippets.',
'Use block.kind = "table" for tables, with rows as arrays of strings.',
'Use block.position values from [before-title, after-title, before-image, after-image, before-description, after-description].',
].join(' ') : null;
// When the user already has a draft, surface it prominently so the model
// knows exactly what text to polish rather than generating from scratch.
const descText = htmlToText(step?.descriptionHtml || '');
const draftTitleLine = hasDraftTitle && (target === 'title' || target === 'all')
? `User's draft title (rewrite this): "${step.title}"` : null;
const draftDescLine = hasDraftDesc && (target === 'description' || target === 'all')
? `User's draft description (rewrite this): "${descText}"` : null;
const contextLines = [
...(captureContext ? [
captureContext.windowTitle ? `Active window: ${captureContext.windowTitle}` : null,
captureContext.appName ? `App: ${captureContext.appName}` : null,
captureContext.elementLabel ? `UI element: ${captureContext.elementLabel}${captureContext.elementRole ? ` (${captureContext.elementRole})` : ''}` : null,
captureContext.elementValue ? `Element content (what was typed): ${captureContext.elementValue}` : null,
captureContext.recentTyped ? `Keyboard input before this step: ${captureContext.recentTyped}` : null,
captureContext.recentShortcut ? `Keyboard shortcut used: ${captureContext.recentShortcut}` : null,
captureContext.ocrText ? `OCR text near click:\n${captureContext.ocrText}` : null,
(!hasDraftTitle || target === 'description') && captureContext.titleCandidate
? `Suggested title: ${captureContext.titleCandidate}` : null,
] : []),
screenshotAttached ? 'Screenshot: attached to this request.' : null,
draftTitleLine,
draftDescLine,
].filter(Boolean);
const prompt = [
'You write concise, action-focused step-by-step documentation for a desktop application guide.',
'Return JSON only. No markdown fences, no commentary, no extra keys outside the schema below.',
'Schema:',
target === 'block' ? [
'{',
' "title": string,',
' "description": string,',
' "blocks": [{',
' "kind": "text" | "code" | "table",',
' "position"?: "before-title" | "after-title" | "before-image" | "after-image" | "before-description" | "after-description",',
' "level"?: "info" | "warn" | "error" | "success",',
' "title"?: string,',
' "body"?: string,',
' "language"?: string,',
' "code"?: string,',
' "rows"?: string[][]',
' }]',
'}',
].join('\n') : '{ "title": string, "description": string }',
'',
`Target: ${targetText}.`,
allowedBlockNote,
'',
guide ? summarizeGuideForAi(guide) : 'Guide: (not provided)',
'',
step ? summarizeStepForAi(step) : 'Step: (not provided)',
'',
contextLines.length
? `Capture context:\n${contextLines.join('\n')}`
: 'Capture context: (not available)',
'',
block ? `Target block:\n${JSON.stringify(block, null, 2)}` : null,
'',
'Rules:',
'- Titles must be short imperative actions: "Click Save", "Select New document", "Open Settings".',
'- NEVER output "Screen capture", "Window capture", "Region capture", or "Capture" as a title — always produce something specific.',
hasDraftTitle && (target === 'title' || target === 'all')
? '- The user wrote their own title (shown above). Your only job is to polish its grammar and phrasing. Do NOT replace it with something different. Do NOT change what action or subject it describes.'
: '- No title yet. Use the capture context (OCR text, window, app) to write a specific action title.',
hasDraftDesc && (target === 'description' || target === 'all')
? '- The user wrote their own description (shown above). Polish the wording to sound professional but preserve every fact and intent they stated.'
: '- No description yet. Write 12 sentences describing exactly what the user does.',
target === 'block'
? '- Only include blocks that provide genuinely useful supplemental information (warnings, tips, code).'
: '- Do NOT add any blocks array. Only output "title" and "description".',
richContext
? '- Use the OCR text, window title, app name, and element info to make the documentation specific.'
: '- Context is limited. Use the app name or window title if available; generate a reasonable action title.',
screenshotAttached
? '- A screenshot is attached. Use it together with the OCR and metadata to resolve visual details, but do not mention the screenshot in the output.'
: '- No screenshot is attached. Rely on OCR, the window title, app name, and element info.',
'- Do NOT generate blocks that describe the technical capture process or mention OCR.',
'- Do NOT invent details not supported by the capture context.',
'- If the target is one block, only rewrite that block.',
].filter((l) => l !== null).join('\n');
return {
systemPrompt: 'You are a technical documentation writer. Emit only valid JSON matching the schema. Never add commentary or markdown.',
prompt,
};
}
function applyAiPatchToStep(step, patch, { target = 'all', blockId = null } = {}) {
const next = deepClone(step);
if ((target === 'all' || target === 'title') && patch.title) {
next.title = displayText(patch.title);
}
if ((target === 'all' || target === 'description') && patch.descriptionHtml) {
next.descriptionHtml = sanitizeHtml(patch.descriptionHtml);
}
if (target === 'all' && Array.isArray(patch.blocks) && patch.blocks.length) {
const textBlocks = [];
const codeBlocks = [];
const tableBlocks = [];
let nextOrder = 1;
for (const block of patch.blocks) {
const clone = deepClone(block);
clone.order = nextOrder++;
if (clone.kind === 'text') textBlocks.push(clone);
else if (clone.kind === 'code') codeBlocks.push(clone);
else if (clone.kind === 'table') tableBlocks.push(clone);
}
next.textBlocks = textBlocks;
next.codeBlocks = codeBlocks;
next.tableBlocks = tableBlocks;
} else if (target === 'block' && blockId && Array.isArray(patch.blocks) && patch.blocks.length) {
const replacement = patch.blocks[0];
const textBlock = (next.textBlocks || []).find((block) => block.id === blockId);
const codeBlock = (next.codeBlocks || []).find((block) => block.id === blockId);
const tableBlock = (next.tableBlocks || []).find((block) => block.id === blockId);
if (textBlock && replacement.kind === 'text') {
if (replacement.position) textBlock.position = replacement.position;
if (replacement.level) textBlock.level = replacement.level;
if (replacement.title) textBlock.title = replacement.title;
if (replacement.descriptionHtml) textBlock.descriptionHtml = sanitizeHtml(replacement.descriptionHtml);
} else if (codeBlock && replacement.kind === 'code') {
if (replacement.language) codeBlock.language = replacement.language;
if (replacement.code) codeBlock.code = replacement.code;
} else if (tableBlock && replacement.kind === 'table') {
if (replacement.rows) tableBlock.rows = replacement.rows;
}
}
if (!next.image) {
const hasBody = Boolean(
next.title ||
htmlToText(next.descriptionHtml || '') ||
(next.textBlocks || []).length ||
(next.codeBlocks || []).length ||
(next.tableBlocks || []).length,
);
if (hasBody) next.kind = 'content';
}
return next;
}
module.exports = {
DEFAULT_CAPTURE_TITLES,
buildCaptureTitle,
plainTextToHtml,
normalizeOllamaHost,
isLoopbackHost,
validateOllamaHost,
normalizeAiPatch,
buildAiPrompt,
applyAiPatchToStep,
summarizeStepForAi,
summarizeGuideForAi,
displayText,
normalizeWhitespace,
scoreCandidate,
pickBestOcrPhrase,
};
+15
View File
@@ -95,6 +95,20 @@ function clamp(v, min, max) {
return Math.min(max, Math.max(min, v));
}
// Matches literal markdown-style links typed in the description editor,
// e.g. "[Settings](https://example.com)". Excludes < > so it never spans
// across an existing HTML tag boundary.
const MD_LINK_RE = /\[([^[\]<>]*)\]\(([^()<>]+)\)/g;
/**
* Turn literal "[text](url)" markdown link syntax (as inserted by the
* description editor's Link button) into real <a href> tags, so exporters
* that consume HTML render an actual link instead of the raw brackets.
*/
function linkifyMarkdownLinks(html) {
return String(html || '').replace(MD_LINK_RE, (m, label, href) => `<a href="${escapeHtml(href)}">${label}</a>`);
}
/** Filesystem-safe slug for export folder names like steps-<title>. */
function slugify(text, fallback = 'untitled') {
const slug = String(text || '')
@@ -116,6 +130,7 @@ module.exports = {
readJsonSync,
readJsonIfExists,
htmlToText,
linkifyMarkdownLinks,
decodeEntities,
escapeHtml,
escapeXml,
+44 -8
View File
@@ -121,8 +121,22 @@ function zipSync(entries, { date = new Date(2026, 0, 1) } = {}) {
return Buffer.concat([...localParts, centralBuf, eocd]);
}
/** Parse a zip buffer into [{ name, data }] with CRC verification. */
function unzipSync(buffer) {
// Resource limits for untrusted archives (share files, snapshots). These cap
// memory and disk work so a ZIP bomb can't exhaust the machine. Callers that
// build archives themselves may relax them; imports use the defaults.
const DEFAULT_UNZIP_LIMITS = {
maxEntries: 50000,
maxTotalCompressed: 1024 * 1024 * 1024, // 1 GiB of stored bytes
maxTotalUncompressed: 4 * 1024 * 1024 * 1024, // 4 GiB inflated total
maxEntryUncompressed: 512 * 1024 * 1024, // 512 MiB per entry
};
/**
* Parse a zip buffer into [{ name, data }] with CRC verification and hard
* resource limits. `limits` overrides DEFAULT_UNZIP_LIMITS.
*/
function unzipSync(buffer, { limits = {} } = {}) {
const lim = { ...DEFAULT_UNZIP_LIMITS, ...limits };
if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('zip: too small');
// Find end-of-central-directory record (scan backwards over the comment).
let eocd = -1;
@@ -132,11 +146,14 @@ function unzipSync(buffer) {
}
if (eocd < 0) throw new Error('zip: end record not found');
const count = buffer.readUInt16LE(eocd + 10);
if (count > lim.maxEntries) throw new Error(`zip: too many entries (${count} > ${lim.maxEntries})`);
let pos = buffer.readUInt32LE(eocd + 16);
const entries = [];
let totalCompressed = 0;
let totalUncompressed = 0;
for (let i = 0; i < count; i++) {
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
if (pos + 46 > buffer.length || buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
const method = buffer.readUInt16LE(pos + 10);
const crc = buffer.readUInt32LE(pos + 16);
const compSize = buffer.readUInt32LE(pos + 20);
@@ -151,17 +168,33 @@ function unzipSync(buffer) {
assertSafeEntryName(name);
if (name.endsWith('/')) continue; // directory entry
// Budget checks BEFORE allocating/inflating: the declared sizes are
// attacker-controlled, so reject oversize claims up front.
if (uncompSize > lim.maxEntryUncompressed) {
throw new Error(`zip: entry too large (${uncompSize} > ${lim.maxEntryUncompressed}): ${name}`);
}
totalCompressed += compSize;
totalUncompressed += uncompSize;
if (totalCompressed > lim.maxTotalCompressed) throw new Error('zip: total compressed size exceeds limit');
if (totalUncompressed > lim.maxTotalUncompressed) throw new Error('zip: total inflated size exceeds limit');
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('zip: bad local header');
const lNameLen = buffer.readUInt16LE(localOffset + 26);
const lExtraLen = buffer.readUInt16LE(localOffset + 28);
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
if (dataStart + compSize > buffer.length) throw new Error(`zip: entry data out of range: ${name}`);
const raw = buffer.subarray(dataStart, dataStart + compSize);
let data;
if (method === 0) data = Buffer.from(raw);
else if (method === 8) data = zlib.inflateRawSync(raw);
else throw new Error(`zip: unsupported method ${method} for ${name}`);
else if (method === 8) {
// Cap inflation so a small deflate stream can't expand to gigabytes —
// even if the declared uncompSize lied, this is the real guard.
data = zlib.inflateRawSync(raw, { maxOutputLength: lim.maxEntryUncompressed });
} else throw new Error(`zip: unsupported method ${method} for ${name}`);
// Exact length match (not "at least"): the inflated bytes must equal the
// declared uncompressed size, and the CRC must verify.
if (data.length !== uncompSize) throw new Error(`zip: size mismatch for ${name}`);
if (crc32(data) !== crc) throw new Error(`zip: CRC mismatch for ${name}`);
entries.push({ name, data });
@@ -170,10 +203,10 @@ function unzipSync(buffer) {
}
/** Extract a zip buffer under destDir; every path is traversal-checked. */
function extractZipSync(buffer, destDir) {
function extractZipSync(buffer, destDir, { limits = {} } = {}) {
const resolvedDest = path.resolve(destDir);
const written = [];
for (const { name, data } of unzipSync(buffer)) {
for (const { name, data } of unzipSync(buffer, { limits })) {
const target = path.resolve(resolvedDest, name);
if (target !== resolvedDest && !target.startsWith(resolvedDest + path.sep)) {
throw new Error(`zip: entry escapes destination: ${name}`);
@@ -203,4 +236,7 @@ function zipDirSync(dir, { filter = () => true, prefix = '' } = {}) {
return zipSync(entries);
}
module.exports = { crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName };
module.exports = {
crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName,
DEFAULT_UNZIP_LIMITS,
};
+2 -1
View File
@@ -81,6 +81,7 @@ guide.json + step.json + settings
▼ hidden/skipped, focused-view geometry)
Render AST ──► exporters/json.js .json + steps-<title>/ images
──► exporters/markdown.js .md + steps-<title>/ images
──► exporters/wikijs.js .md + steps-<title>/ images
──► exporters/html-simple.js single self-contained .html
──► exporters/html-rich.js checkboxes + floating TOC
──► exporters/pdf.js native PDF writer (core/pdf.js)
@@ -110,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
+2 -3
View File
@@ -16,9 +16,8 @@ filing issues — is expected to:
Maintainers may edit, hide, or remove comments, commits, issues, and PRs that
violate this standard, and may temporarily or permanently ban contributors
for repeated or egregious behavior.
for egregious behavior. We may do this at any time without warning and without chance for appeal.
## Reporting
Report unacceptable behavior privately to the maintainers via the contact
listed on the project page. Reports are handled confidentially.
Report unacceptable behavior privately to the maintainers via `[email protected]`. Reports are handled confidentially.
+6 -7
View File
@@ -29,9 +29,10 @@ Origin sign-off (`git commit -s`).
## Offline Rules
- No network code paths in the application. No telemetry, update checks,
license checks, remote fonts, or remote APIs — ever.
license checks, remote fonts, or remote APIs — **ever**.
- No new runtime dependencies without prior maintainer agreement; prefer
internal implementations using Node built-ins.
internal implementations using Node built-ins. This is due to all the
security issues that have arrose lately with NPM dependencies.
## Branching
@@ -46,8 +47,7 @@ Origin sign-off (`git commit -s`).
`Closes #123`, `Fixes #123`, or `Relates to #123`.
- Summarize the change clearly and call out anything a reviewer should
verify manually.
- Update docs when behavior changes, and add a CHANGELOG entry for every
user-visible change.
- Update docs when behavior changes.
- Every exporter or storage change **requires tests**; output changes
require updated snapshot fixtures under `tests/fixtures/`.
@@ -64,10 +64,9 @@ automatically. The shell checks invoke the `node --test` workflow suites in
`tests/unit/`.
Write tests that exercise **real workflows and verify actual output**
create a guide, export it, parse the bytes that came out — rather than
grepping for magic strings.
create a guide, export it, parse the bytes that came out. DO NOT WRITE A TEST THAT GREPS FOR CODE.
The Gitea workflow in `.gitea/workflows/tests.yaml` runs the same command
The Gitea workflow in `.gitea/workflows/tests.yaml` and `.github/workflows/ci.yaml` runs the same command
automatically on pushes and pull requests.
Please add lots of tests to each of your PR's and be descriptive with the
+26 -12
View File
@@ -3,15 +3,27 @@
StepForge is a fully offline desktop app. Nothing is uploaded or synced, and
all guides stay on your machine.
# Windows installation
For the windows installation, please see [windows_installation](windows_installation.md)
# Developer install
## 1. Install
Install the pinned Node toolchain first — Node 22.12 or newer (see
`.nvmrc`; with nvm: `nvm install && nvm use`). Installs are refused on
older Nodes.
From the repository root:
```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
@@ -19,16 +31,16 @@ 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
In the library view:
1. Click `New guide`.
2. Give the guide a clear title.
2. Give the guide a clear title (more -> rename guide).
3. Open the guide to enter the editor.
You can also import a guide archive with `Import archive` if you already have
@@ -36,10 +48,11 @@ one.
## 4. Add content
There are two simple ways to start:
There are three simple ways to start:
1. Import screenshots with the `Import` button in the editor.
2. Paste an image from the clipboard if you already copied one.
1. Record your workflow by clicking on the record button (reconmmended).
2. Import screenshots with the `Import` button in the editor.
3. Paste an image from the clipboard if you already copied one.
If you want to capture new screenshots, open `Quick` actions and start a
capture session. Use `Settings` to set the capture hotkey and other capture
@@ -50,7 +63,7 @@ options.
The editor is split into three panes:
1. Steps on the left
2. Canvas in the center
2. Editing canvas in the center
3. Properties on the right
Use the canvas tools to add shapes, arrows, text, blur, highlight, numbers,
@@ -86,6 +99,7 @@ If you want to find commands quickly, press `Ctrl+/` for Quick Actions.
## Optional builds
1. `bash scripts/build-release.sh` assembles the offline release layout.
2. `npm run package:windows` creates the portable Windows `.exe` in
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).
+156
View File
@@ -0,0 +1,156 @@
# 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 | ✅ Yes (needs `input` group) |
| Red circle on the click | ✅ Yes | ❌ No (Wayland hides the cursor position) |
| "Share your screen" prompt | Never | Once per recording session |
| Setup needed | None | Add yourself to the `input` group |
**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 (requires the `input` group)
By default on Wayland, StepForge cannot see your clicks, so it falls back to
**capturing a screenshot every few seconds** (timed capture).
To get a screenshot **on every click** instead, give your user read access to
the mouse devices by joining the `input` group:
```bash
sudo usermod -aG input "$USER"
```
Then **log out and log back in** (group membership only applies to new sessions).
Verify it took effect:
```bash
groups | tr ' ' '\n' | grep input # should print: input
```
Now StepForge reads mouse buttons directly from the kernel (`/dev/input`) and
captures a screenshot on each click.
> **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), if
you're in the `input` group.
4. **Timed capture** — the always-works fallback (a screenshot every N seconds)
when no click source is available.
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 …` — you need the `input` group (see above).
**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.
+5 -369
View File
@@ -1,373 +1,9 @@
Mozilla Public License Version 2.0
==================================
Creative Commons Attribution-NonCommercial
1. Definitions
--------------
Copyright (c) 2026 Tyler Westbrook
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
Permission is granted to use, copy, modify, and distribute this software for non-commercial purposes only.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
You may not sell this software, sell modified versions of it, or use it as part of a commercial product or service without written permission from the copyright holder.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.
This software is provided "as is", without warranty of any kind.
+65
View File
@@ -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.
+2 -3
View File
@@ -64,6 +64,5 @@ URLs) before storage and again before rendering or export.
## Reporting
Report vulnerabilities by opening an issue marked `security` (this is a
local-only tool, so coordinated disclosure pressure is low; do not include
exploit archives directly — describe the structure instead).
Report vulnerabilities by sending an email to `[email protected]`
+84
View File
@@ -0,0 +1,84 @@
# Getting Started With AI
StepForge keeps AI local. It talks to your own Ollama server on your machine and does not send guide content to the cloud.
## 1. Install Ollama
Install Ollama from https://ollama.com and make sure the service is running.
On most systems you can verify it with:
```bash
ollama --version
```
## 2. Pull a lightweight model
The recommended default is:
```bash
ollama pull llama3.2:1b
```
That model is small enough to feel responsive on modest hardware, but still good enough for human-sounding titles and short text blocks.
If you want StepForge to send the screenshot itself to the model, pull a vision-capable model instead:
```bash
ollama pull gemma3
```
That model can inspect pictures as well as text, so it is better when you want the AI to read the UI directly from the screenshot.
If you need something even smaller, try:
```bash
ollama pull qwen3:0.6b
```
or:
```bash
ollama pull gemma3:270m
```
Those are lighter, but they are usually weaker at writing polished step text.
## 3. Open StepForge settings
In StepForge, open `Settings` and find the `AI` section.
Set:
* `Enable AI text filling` to on
* `Ollama host` to your local Ollama server
* `Ollama model` to `llama3.2:1b` for text-only mode, or `llama3.2-vision` if you want screenshot-aware AI
The default host is:
```text
http://127.0.0.1:11434
```
## 4. Test the connection
Use the `Test connection` button in the AI settings section.
If the model is installed, StepForge should confirm the host and model.
## 5. Use AI manually
AI is never automatic. After capture, use the `AI` button next to:
* the step title
* the step description
* each text, code, and table block
You can also use `More -> Generate all text fields with AI` to fill the whole step in one pass.
## Notes
* Capture titles are still generated automatically without AI.
* AI generation only works when `Enable AI text filling` is turned on.
* The app always uses local OCR around the click area first, then local AI only when you ask for it.
* When the selected Ollama model supports vision, StepForge also sends the screenshot to the model so it can cross-check OCR and visual context.
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 603 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 714 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 794 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 939 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 867 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 997 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 KiB

+100
View File
@@ -0,0 +1,100 @@
# Windows_installation
<div style="height:4px;background:#2563eb;border-radius:999px;margin:12px 0 18px;"></div>
Author: Tyler Westbrook
*10 steps · generated 2026-06-23*
Welcome to StepForge the easy documentation writer! This guide shows you how to setup StepForge on your windows computer step by step. As a demo of this project, this guide was 100% made with StepForge with no outside editing. Enjoy!
## Contents
- [1. Navigate to the git repo.](#step-1)
- [1.1. Click on releases](#step-1-1)
- [1.1.1. Select the latest release for StepForge](#step-1-1-1)
- [2. Run the installer.](#step-2)
- [2.1. Click More Info](#step-2-1)
- [2.1.1. Select Run anyway](#step-2-1-1)
- [2.2. Select install for me or install for all users](#step-2-2)
- [2.3. Select next](#step-2-3)
- [2.4. Select install](#step-2-4)
- [2.5. Select Finish](#step-2-5)
<a id="step-1"></a>
## 1. Navigate to the git repo.
Navigate to the [Github Repository](https://github.com/Twest2/StepForge) located in the link.
![Step 1](steps-windows_installation/001-navigate-to-the-git-repo..png)
<a id="step-1-1"></a>
### 1.1. Click on releases
![Step 1.1](steps-windows_installation/002-click-on-releases.png)
<a id="step-1-1-1"></a>
### 1.1.1. Select the latest release for StepForge
![Step 1.1.1](steps-windows_installation/003-select-the-latest-release-for-stepforge.png)
<div class="sf-callout sf-callout-note" style="border-left: 4px solid #2563eb; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;">
<div style="font-weight: 700; color: #1d4ed8; margin-bottom: 6px;">Note: Newest version</div>
<div style="color: inherit;"><p>Please make sure you select the newest version of StepForge, not v0.2.0. The .exe is an installer that will install the program for you.</p></div>
</div>
<a id="step-2"></a>
## 2. Run the installer.
![Step 2](steps-windows_installation/004-run-the-installer..png)
<a id="step-2-1"></a>
### 2.1. Click More Info
![Step 2.1](steps-windows_installation/005-click-more-info.png)
<div class="sf-callout sf-callout-important" style="border-left: 4px solid #ef4444; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;">
<div style="font-weight: 700; color: #b91c1c; margin-bottom: 6px;">Important: Select More Info</div>
<div style="color: inherit;"><p>Windows is warning you because this installer is new and hasnt built up enough Microsoft SmartScreen reputation yet.</p></div>
</div>
<a id="step-2-1-1"></a>
### 2.1.1. Select Run anyway
![Step 2.1.1](steps-windows_installation/006-select-run-anyway.png)
<a id="step-2-2"></a>
### 2.2. Select install for me or install for all users
![Step 2.2](steps-windows_installation/007-select-install-for-me-or-install-for-all-users.png)
<a id="step-2-3"></a>
### 2.3. Select next
![Step 2.3](steps-windows_installation/008-select-next.png)
<a id="step-2-4"></a>
### 2.4. Select install
![Step 2.4](steps-windows_installation/009-select-install.png)
<a id="step-2-5"></a>
### 2.5. Select Finish
![Step 2.5](steps-windows_installation/010-select-finish.png)
<div class="sf-callout sf-callout-tip" style="border-left: 4px solid #10b981; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;">
<div style="font-weight: 700; color: #047857; margin-bottom: 6px;">Tip: You're finished!</div>
<div style="color: inherit;"><p>Go ahead and play around with StepForge and make some docs!</p></div>
</div>
+50
View File
@@ -55,6 +55,55 @@ function stepBlocks(step) {
return step.blocks || orderedBlocks(step);
}
/**
* Split a step's blocks into the layout buckets exporters use around the
* step title, description, and image. Text blocks keep their block-list
* ordering inside each bucket; code/table blocks stay in `rest`.
*/
function stepContentGroups(step) {
const all = stepBlocks(step);
const groups = {
beforeTitle: [],
afterTitle: [],
beforeDescription: [],
afterDescription: [],
beforeImage: [],
afterImage: [],
rest: [],
};
for (const block of all) {
if (block.kind !== 'text') {
groups.rest.push(block);
continue;
}
switch (block.position) {
case 'before-title':
groups.beforeTitle.push(block);
break;
case 'after-title':
groups.afterTitle.push(block);
break;
case 'before-image':
groups.beforeImage.push(block);
break;
case 'after-image':
groups.afterImage.push(block);
break;
case 'before-description':
groups.beforeDescription.push(block);
break;
case 'after-description':
default:
groups.afterDescription.push(block);
break;
}
}
return groups;
}
function codeBlockText(block) {
return blockText(block);
}
@@ -67,6 +116,7 @@ module.exports = {
writeStepImages,
renderAllImages,
stepBlocks,
stepContentGroups,
codeBlockText,
LEVEL_LABEL,
};
+40 -17
View File
@@ -4,7 +4,8 @@ const fs = require('node:fs');
const path = require('node:path');
const { slugify, escapeXml } = require('../core/util');
const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, stepBlocks, codeBlockText } = require('./common');
const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require('./common');
const { anchorFor, guideMetaLines, guideSummary } = require('./document-layout');
/**
* Confluence storage-format export. Writes a single XHTML document plus a
@@ -14,6 +15,7 @@ const { guideSlug, renderAllImages, stepBlocks, codeBlockText } = require('./com
const DEFAULT_TEMPLATE = {
includeImages: true,
toc: true,
};
const MACRO_FOR_LEVEL = {
@@ -23,10 +25,6 @@ const MACRO_FOR_LEVEL = {
success: 'tip',
};
function anchorFor(step) {
return `step-${step.number.replace(/\./g, '-')}`;
}
function stepLinkRewrite(html, ast) {
return String(html || '').replace(/href="step:([^"]+)"/g, (m, id) => {
const target = ast.steps.find((s) => s.stepId === id);
@@ -64,10 +62,25 @@ function exportConfluence(ast, outDir, template = {}) {
}
const stepXml = ast.steps.map((step) => {
const parts = [`<a id="${anchorFor(step)}"></a>`, `<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`];
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
const parts = [`<a id="${anchorFor(step)}"></a>`];
for (const tb of beforeTitle) {
parts.push(blockMacro(tb, ast));
}
parts.push(`<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`);
if (step.skipped) parts.push('<p><em>(skipped)</em></p>');
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'before-description')) {
for (const tb of afterTitle) {
parts.push(blockMacro(tb, ast));
}
for (const tb of beforeDescription) {
parts.push(blockMacro(tb, ast));
}
@@ -75,13 +88,26 @@ function exportConfluence(ast, outDir, template = {}) {
parts.push(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
}
for (const tb of afterDescription) {
parts.push(blockMacro(tb, ast));
}
for (const tb of beforeImage) {
parts.push(blockMacro(tb, ast));
}
const attachment = attachmentNames.get(step.stepId);
if (attachment) {
parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`);
}
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
if (block.kind === 'code') {
for (const tb of afterImage) {
parts.push(blockMacro(tb, ast));
}
for (const block of rest) {
if (block.kind === 'text') {
parts.push(blockMacro(block, ast));
} else if (block.kind === 'code') {
const lang = block.language ? `<ac:parameter ac:name="language">${escapeXml(block.language)}</ac:parameter>` : '';
parts.push(`<ac:structured-macro ac:name="code">${lang}<ac:plain-text-body>${cdata(codeBlockText(block))}</ac:plain-text-body></ac:structured-macro>`);
} else if (block.kind === 'table') {
@@ -97,13 +123,6 @@ function exportConfluence(ast, outDir, template = {}) {
}
}
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-description')) {
parts.push(blockMacro(tb, ast));
}
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-image')) {
parts.push(blockMacro(tb, ast));
}
return `<div class="step">${parts.join('\n')}</div>`;
}).join('\n');
@@ -116,7 +135,11 @@ function exportConfluence(ast, outDir, template = {}) {
</head>
<body>
<h1>${escapeXml(ast.guide.title)}</h1>
<div style="border-bottom: 4px solid #2563eb; margin: 14px 0 18px;"></div>
${guideMetaLines(ast).map((line) => `<p><strong>${escapeXml(line.split(': ')[0])}:</strong> ${escapeXml(line.slice(line.indexOf(': ') + 2))}</p>`).join('\n')}
<p><em>${escapeXml(guideSummary(ast))}</em></p>
${ast.guide.descriptionHtml ? `<div>${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
${tpl.toc && ast.steps.length > 1 ? '<ac:structured-macro ac:name="toc"></ac:structured-macro>' : ''}
${stepXml}
</body>
</html>
+40
View File
@@ -0,0 +1,40 @@
'use strict';
function anchorFor(stepOrNumber) {
const number = typeof stepOrNumber === 'string'
? stepOrNumber
: stepOrNumber && stepOrNumber.number;
return `step-${String(number || '').replace(/\./g, '-')}`;
}
function tocEntries(ast, { maxDepth = Infinity } = {}) {
return (ast.steps || []).map((step) => ({
step,
anchor: anchorFor(step),
number: step.number,
title: step.title || 'Untitled step',
depth: Math.min(Number.isFinite(step.depth) ? step.depth : 0, maxDepth),
}));
}
function guideMetaLines(ast) {
const meta = ast?.guide?.metadata || {};
return [
meta.author && `Author: ${meta.author}`,
meta.coAuthors && `Co-authors: ${meta.coAuthors}`,
meta.organization && `Organization: ${meta.organization}`,
].filter(Boolean);
}
function guideSummary(ast) {
const count = ast?.steps?.length || 0;
const generated = String(ast?.generatedAt || '').slice(0, 10);
return `${count} step${count === 1 ? '' : 's'} · generated ${generated}`;
}
module.exports = {
anchorFor,
tocEntries,
guideMetaLines,
guideSummary,
};
+235 -29
View File
@@ -5,19 +5,30 @@ const path = require('node:path');
const { zipSync } = require('../core/zip');
const { escapeXml } = require('../core/util');
const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { guideMetaLines, guideSummary, tocEntries } = require('./document-layout');
/**
* DOCX exporter: WordprocessingML built directly (no dependency), one
* heading + description + screenshot per step, text blocks, code blocks
* (Courier), and tables.
* heading per step with positioned text blocks around the description and
* screenshot, plus code blocks (Courier) and tables.
*/
const DEFAULT_TEMPLATE = {
includeImages: true,
includeToc: true,
imageWidthTwips: 9000, // ~15.9cm inside A4 margins
};
// Callout styling per text-block level, matching the colors used in the
// HTML/PDF exports so a "Tip" looks distinct from a "Warning" at a glance.
const LEVEL_STYLE = {
info: { fill: 'EFF6FF', color: '1D4ED8' }, // blue — Note
success: { fill: 'ECFDF5', color: '047857' }, // green — Tip
warn: { fill: 'FFFBEB', color: 'B45309' }, // amber — Warning
error: { fill: 'FEF2F2', color: 'B91C1C' }, // red — Important
};
const EMU_PER_PX = 9525; // 96 dpi
function p(children, props = '') {
@@ -55,6 +66,74 @@ function drawing(relId, widthPx, heightPx, maxWidthTwips) {
`</pic:pic></a:graphicData></a:graphic></wp:inline></w:drawing></w:r>`;
}
function pageBreak() {
return p('<w:r><w:br w:type="page"/></w:r>');
}
// Width (in twips) of the text column inside the A4 page margins used by
// <w:sectPr> below (11906 - 1134*2), i.e. where TOC page-number tabs land.
const TOC_TAB_POS = 9638;
/** Bookmark name anchoring a step's heading, referenced by its TOC entry. */
function bookmarkName(step) {
return `toc_${String(step.number).replace(/\./g, '_')}`;
}
/** A `PAGEREF <anchor>` field, cached as "1" until Word recalculates it. */
function pageRefField(anchor) {
return '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' +
`<w:r><w:instrText xml:space="preserve"> PAGEREF ${anchor} \\h </w:instrText></w:r>` +
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>' +
'<w:r><w:t>1</w:t></w:r>' +
'<w:r><w:fldChar w:fldCharType="end"/></w:r>';
}
/** One TOC line: hyperlink to the step's heading, dot leader, page number. */
function tocEntryContent(entry) {
const anchor = bookmarkName(entry.step);
return `<w:hyperlink w:anchor="${anchor}">${run(`${entry.number}. ${entry.title}`, { size: 20 })}</w:hyperlink>` +
'<w:r><w:tab/></w:r>' +
pageRefField(anchor);
}
/**
* The TOC as real, navigable entries (one per step) rather than a bare
* "Update contents in Word" placeholder, so the table is correct on first
* open. Still wrapped in a `TOC` field (spanning the first..last paragraph)
* so Word can refresh page numbers via Update Field / the updateFields prompt.
*/
function tocFieldParagraphs(ast) {
const entries = tocEntries(ast);
const beginField = '<w:r><w:fldChar w:fldCharType="begin" w:dirty="true"/></w:r>' +
'<w:r><w:instrText xml:space="preserve"> TOC \\o "1-3" \\h \\z \\u </w:instrText></w:r>' +
'<w:r><w:fldChar w:fldCharType="separate"/></w:r>';
const endField = '<w:r><w:fldChar w:fldCharType="end"/></w:r>';
return entries.map((entry, i) => {
const pPr = `<w:pStyle w:val="TOC${Math.min(3, Math.max(1, entry.depth + 1))}"/>` +
`<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="${TOC_TAB_POS}"/></w:tabs>`;
const lead = i === 0 ? beginField : '';
const trail = i === entries.length - 1 ? endField : '';
return `<w:p><w:pPr>${pPr}</w:pPr>${lead}${tocEntryContent(entry)}${trail}</w:p>`;
});
}
function headingStyleForDepth(depth) {
return `Heading${Math.min(3, depth + 1)}`;
}
function headingOutlineLevelForDepth(depth) {
return Math.min(2, Math.max(0, depth));
}
function headingParagraphProps(depth, forceNewPage = false) {
const parts = [];
if (forceNewPage) parts.push('<w:pageBreakBefore/>');
parts.push(`<w:pStyle w:val="${headingStyleForDepth(depth)}"/>`);
parts.push(`<w:outlineLvl w:val="${headingOutlineLevelForDepth(depth)}"/>`);
return parts.join('');
}
function table(rows) {
const cols = Math.max(...rows.map((r) => r.length));
const grid = `<w:tblGrid>${'<w:gridCol w:w="2400"/>'.repeat(cols)}</w:tblGrid>`;
@@ -72,57 +151,179 @@ function table(rows) {
return `<w:tbl><w:tblPr><w:tblW w:w="0" w:type="auto"/>${borders}</w:tblPr>${grid}${body}</w:tbl>`;
}
function stylesXml() {
const headingStyle = (styleId, name, outlineLvl, size, color) => `
<w:style w:type="paragraph" w:styleId="${styleId}">
<w:name w:val="${name}"/>
<w:basedOn w:val="Normal"/>
<w:next w:val="Normal"/>
<w:uiPriority w:val="${9 + outlineLvl}"/>
<w:qFormat/>
<w:unhideWhenUsed/>
<w:pPr>
<w:keepNext/>
<w:keepLines/>
<w:spacing w:before="${outlineLvl === 0 ? 360 : outlineLvl === 1 ? 240 : 180}" w:after="120"/>
<w:outlineLvl w:val="${outlineLvl}"/>
</w:pPr>
<w:rPr>
<w:b/>
<w:sz w:val="${size}"/>
<w:szCs w:val="${size}"/>
<w:color w:val="${color}"/>
</w:rPr>
</w:style>`;
const tocStyle = (level) => `
<w:style w:type="paragraph" w:styleId="TOC${level}">
<w:name w:val="toc ${level}"/>
<w:basedOn w:val="Normal"/>
<w:next w:val="Normal"/>
<w:autoRedefine/>
<w:uiPriority w:val="39"/>
<w:unhideWhenUsed/>
<w:pPr>
<w:spacing w:after="60"/>
<w:ind w:left="${(level - 1) * 360}"/>
<w:tabs><w:tab w:val="right" w:leader="dot" w:pos="${TOC_TAB_POS}"/></w:tabs>
</w:pPr>
<w:rPr>
<w:sz w:val="20"/>
<w:szCs w:val="20"/>
</w:rPr>
</w:style>`;
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:docDefaults>
<w:rPrDefault>
<w:rPr>
<w:sz w:val="22"/>
<w:szCs w:val="22"/>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>
</w:rPr>
</w:rPrDefault>
<w:pPrDefault>
<w:pPr>
<w:spacing w:after="160"/>
</w:pPr>
</w:pPrDefault>
</w:docDefaults>
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
<w:name w:val="Normal"/>
<w:qFormat/>
<w:uiPriority w:val="1"/>
<w:rPr>
<w:sz w:val="22"/>
<w:szCs w:val="22"/>
<w:rFonts w:ascii="Calibri" w:hAnsi="Calibri"/>
</w:rPr>
</w:style>
${headingStyle('Heading1', 'Heading 1', 0, 30, '2563EB')}
${headingStyle('Heading2', 'Heading 2', 1, 26, '1D4ED8')}
${headingStyle('Heading3', 'Heading 3', 2, 22, '1E40AF')}
${tocStyle(1)}
${tocStyle(2)}
${tocStyle(3)}
</w:styles>`;
}
function exportDocx(ast, outDir, template = {}) {
const tpl = { ...DEFAULT_TEMPLATE, ...template };
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const media = []; // { name, data }
const rels = []; // relationship XML strings
let relCounter = 0;
let relCounter = 1; // rId1 reserved for settings.xml; rId2 for styles.xml
let bookmarkCounter = 0;
let stepImageCount = 0;
rels.push(`<Relationship Id="rId${++relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>`);
const body = [];
body.push(p(run(ast.guide.title, { bold: true, size: 48 })));
body.push(p(
run(ast.guide.title, { bold: true, size: 48 }),
'<w:pBdr><w:bottom w:val="single" w:sz="24" w:space="12" w:color="2563EB"/></w:pBdr>'
));
if (ast.guide.descriptionText) body.push(p(run(ast.guide.descriptionText, { size: 22, color: '444444' })));
body.push(p(run(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 18, color: '888888' })));
for (const line of guideMetaLines(ast)) body.push(p(run(line, { size: 20, color: '6B7280' })));
body.push(p(run(guideSummary(ast), { size: 18, color: '888888' })));
body.push(pageBreak());
if (tpl.includeToc && ast.steps.length > 1) {
body.push(p(
run('Contents', { bold: true, size: 28 }),
'<w:pBdr><w:bottom w:val="single" w:sz="20" w:space="8" w:color="2563EB"/></w:pBdr>'
));
body.push(...tocFieldParagraphs(ast));
body.push(pageBreak());
}
const emitTextBlock = (tb) => {
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
body.push(p(
`${run(label, { bold: true, size: 20, color: style.color })}${tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20, color: '1F2937' }) : ''}`,
`<w:shd w:val="clear" w:fill="${style.fill}"/><w:pBdr><w:left w:val="single" w:sz="24" w:space="4" w:color="${style.color}"/></w:pBdr>`
));
};
for (const step of ast.steps) {
const headSize = step.depth > 0 ? 26 : 30;
body.push(p(run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }),
step.forceNewPage ? '<w:pageBreakBefore/>' : ''));
emitTextBlocks(step, 'before-description');
if (step.descriptionText) body.push(p(run(step.descriptionText)));
const headingLevel = Math.min(3, Math.max(1, step.depth + 1));
const headSize = headingLevel === 1 ? 30 : headingLevel === 2 ? 26 : 22;
const bookmarkId = ++bookmarkCounter;
const anchor = bookmarkName(step);
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of beforeTitle) emitTextBlock(tb);
body.push(p(
`<w:bookmarkStart w:id="${bookmarkId}" w:name="${anchor}"/>` +
run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }) +
`<w:bookmarkEnd w:id="${bookmarkId}"/>`,
headingParagraphProps(step.depth, step.forceNewPage)
));
for (const tb of afterTitle) emitTextBlock(tb);
for (const tb of beforeDescription) emitTextBlock(tb);
if (step.descriptionText) body.push(p(run(step.descriptionText, { size: 20, color: '1F2937' })));
for (const tb of afterDescription) emitTextBlock(tb);
for (const tb of beforeImage) emitTextBlock(tb);
const img = images.get(step.stepId);
if (img) {
relCounter += 1;
const relId = ++relCounter;
const name = `image${relCounter}.png`;
media.push({ name, data: encodePng(img) });
rels.push(`<Relationship Id="rId${relCounter}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips)));
rels.push(`<Relationship Id="rId${relId}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/image" Target="media/${name}"/>`);
body.push(p(drawing(relId, img.width, img.height, tpl.imageWidthTwips)));
stepImageCount += 1;
}
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
if (block.kind === 'code') {
for (const tb of afterImage) emitTextBlock(tb);
for (const block of rest) {
if (block.kind === 'text') {
emitTextBlock(block);
} else if (block.kind === 'code') {
body.push(p(run(codeBlockText(block), { size: 18, font: 'Courier New', color: '1F2937' }),
'<w:shd w:val="clear" w:fill="F3F4F6"/>'));
} else if (block.kind === 'table') {
if (block.rows && block.rows.length) body.push(table(block.rows), p(''));
}
}
emitTextBlocks(step, 'after-description');
emitTextBlocks(step, 'after-image');
}
function emitTextBlocks(step, position) {
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
body.push(p(
run(label, { bold: true, size: 20 }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''),
'<w:shd w:val="clear" w:fill="F9FAFB"/>'
));
}
}
const settingsXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:settings xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
<w:updateFields w:val="true"/>
</w:settings>`;
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
@@ -143,6 +344,8 @@ ${body.join('\n')}
<Default Extension="xml" ContentType="application/xml"/>
<Default Extension="png" ContentType="image/png"/>
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
<Override PartName="/word/settings.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml"/>
</Types>`,
},
{
@@ -157,16 +360,19 @@ ${body.join('\n')}
name: 'word/_rels/document.xml.rels',
data: `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/settings" Target="settings.xml"/>
${rels.join('\n')}
</Relationships>`,
},
{ name: 'word/settings.xml', data: settingsXml },
{ name: 'word/styles.xml', data: stylesXml() },
...media.map((m) => ({ name: `word/media/${m.name}`, data: m.data, store: true })),
];
fs.mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, `${guideSlug(ast)}.docx`);
fs.writeFileSync(file, zipSync(entries));
return { file, imageCount: media.length };
return { file, imageCount: stepImageCount };
}
module.exports = { exportDocx, DEFAULT_TEMPLATE };
+487 -82
View File
@@ -4,7 +4,8 @@ const fs = require('node:fs');
const path = require('node:path');
const { escapeHtml } = require('../core/util');
const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { anchorFor, tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
/**
* HTML exporters. Both variants are fully self-contained single files:
@@ -18,14 +19,11 @@ const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = r
const DEFAULT_TEMPLATE = {
includeImages: true,
toc: true,
accentColor: '#2563eb',
customCss: '',
};
function anchorFor(step) {
return `step-${step.number.replace(/\./g, '-')}`;
}
function dataUri(img) {
return `data:image/png;base64,${encodePng(img).toString('base64')}`;
}
@@ -38,56 +36,477 @@ function stepLinkRewrite(html, ast) {
});
}
function blocksHtml(step, position) {
return stepBlocks(step)
.filter((tb) => tb.position === position)
.map((tb) => `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`)
.join('\n');
function blockHtml(tb) {
return `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`;
}
function stepBodyHtml(step, ast, images, tpl) {
function renderBlockListHtml(blocks) {
return blocks.map((block) => blockHtml(block)).join('\n');
}
function renderMetaChips(ast) {
return [
...guideMetaLines(ast).map((line) => `<span class="chip">${escapeHtml(line)}</span>`),
`<span class="chip muted">${escapeHtml(guideSummary(ast))}</span>`,
].join('');
}
function renderTocList(ast) {
return tocEntries(ast).map((entry) => `
<li class="d${entry.depth}">
<a href="#${entry.anchor}">
<span class="num">${escapeHtml(entry.number)}</span>
<span class="label">${escapeHtml(entry.title)}</span>
</a>
</li>
`).join('');
}
function renderCover(ast, tpl) {
return `
<section class="cover">
<div class="eyebrow">StepForge export</div>
<h1>${escapeHtml(ast.guide.title)}</h1>
<div class="rule" style="background:${tpl.accentColor}"></div>
${ast.guide.descriptionHtml ? `<div class="desc">${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
<div class="meta">${renderMetaChips(ast)}</div>
</section>`;
}
function renderStepCard(step, ast, images, tpl, { rich = false, selected = false } = {}) {
const title = `${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}`;
const statusText = step.skipped
? 'Skipped'
: step.status === 'in-progress'
? 'In progress'
: step.status === 'done'
? 'Done'
: 'Todo';
const head = rich ? `
<h2>
<label class="check"><input type="checkbox" class="step-done" data-step="${escapeHtml(step.stepId)}"></label>
<span class="step-num">${escapeHtml(step.number)}</span>
<span class="step-title">${escapeHtml(step.title || 'Untitled step')}</span>
<span class="status-chip status-${step.status || 'todo'}${step.skipped ? ' skipped' : ''}">${escapeHtml(statusText)}</span>
</h2>` : `<h2>${title}</h2>`;
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
return `
<section class="step-card${step.skipped ? ' skipped' : ''}${rich && selected ? ' selected' : ''}" id="${anchorFor(step)}">
${renderBlockListHtml(beforeTitle)}
${head}
${renderBlockListHtml(afterTitle)}
<div class="step-body">
${renderStepBodyHtml({ beforeDescription, afterDescription, beforeImage, afterImage, rest }, step, ast, images, tpl)}
</div>
</section>`;
}
function renderRichToc(ast) {
return tocEntries(ast).map((entry) => `
<li class="d${entry.depth}">
<a href="#${entry.anchor}">
<span class="num">${escapeHtml(entry.number)}</span>
<span class="label">${escapeHtml(entry.title)}</span>
</a>
</li>
`).join('');
}
function hexToRgb(hex) {
const m = /^#?([0-9a-f]{6})$/i.exec(String(hex || '').trim());
if (!m) return [37, 99, 235];
const n = Number.parseInt(m[1], 16);
return [(n >> 16) & 0xff, (n >> 8) & 0xff, n & 0xff];
}
function bodyStyle(tpl) {
const [r, g, b] = hexToRgb(tpl.accentColor);
return `--accent:${tpl.accentColor};--accent-rgb:${r},${g},${b};`;
}
function renderStepBodyHtml(groups, step, ast, images, tpl) {
const parts = [];
parts.push(blocksHtml(step, 'before-description'));
const {
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = groups;
for (const tb of beforeDescription) parts.push(blockHtml(tb));
if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
for (const tb of afterDescription) parts.push(blockHtml(tb));
for (const tb of beforeImage) parts.push(blockHtml(tb));
const img = images.get(step.stepId);
if (img && tpl.includeImages) {
parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`);
}
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
if (block.kind === 'code') {
for (const tb of afterImage) parts.push(blockHtml(tb));
for (const block of rest) {
if (block.kind === 'text') {
parts.push(blockHtml(block));
} else if (block.kind === 'code') {
parts.push(`<pre class="code"><code>${escapeHtml(codeBlockText(block))}</code></pre>`);
} else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue;
const [head, ...rest] = block.rows;
const [head, ...bodyRows] = block.rows;
parts.push('<table><thead><tr>' + head.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr></thead><tbody>'
+ rest.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('')
+ bodyRows.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('')
+ '</tbody></table>');
}
}
parts.push(blocksHtml(step, 'after-description'));
parts.push(blocksHtml(step, 'after-image'));
return parts.filter(Boolean).join('\n');
}
const BASE_CSS = `
body { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; margin: 0 auto; max-width: 860px;
padding: 24px; color: #1f2937; background: #ffffff; line-height: 1.55; }
h1 { font-size: 1.7em; margin-bottom: .2em; }
h2 { font-size: 1.2em; margin-top: 1.6em; border-bottom: 1px solid #e5e7eb; padding-bottom: .25em; }
img.shot { max-width: 100%; height: auto; border: 1px solid #e5e7eb; border-radius: 6px; margin: .6em 0; }
pre.code { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; }
table { border-collapse: collapse; margin: .6em 0; }
th, td { border: 1px solid #d1d5db; padding: 4px 10px; text-align: left; }
.block { border-left: 4px solid #9ca3af; background: #f9fafb; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; }
:root {
--bg: #f4f7fb;
--bg-2: #eef3f9;
--panel: rgba(255, 255, 255, 0.92);
--panel-strong: #ffffff;
--panel-soft: #f8fbff;
--text: #152033;
--muted: #637084;
--border: rgba(119, 134, 156, 0.22);
--shadow: 0 18px 50px rgba(15, 23, 42, 0.08);
}
html { scroll-behavior: smooth; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
background:
radial-gradient(circle at top right, rgba(var(--accent-rgb), 0.16), transparent 26%),
radial-gradient(circle at bottom left, rgba(14, 165, 233, 0.11), transparent 22%),
linear-gradient(180deg, var(--bg), var(--bg-2));
line-height: 1.6;
font-family: "Aptos", "Segoe UI", "Helvetica Neue", Arial, sans-serif;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
img.shot {
max-width: 100%;
height: auto;
border: 1px solid var(--border);
border-radius: 18px;
margin: 16px 0;
box-shadow: var(--shadow);
}
pre.code {
background: #0f172a;
color: #e2e8f0;
padding: 16px 18px;
border-radius: 18px;
overflow-x: auto;
box-shadow: var(--shadow);
}
pre.code code { font-family: "SFMono-Regular", Consolas, "Liberation Mono", monospace; }
table {
width: 100%;
border-collapse: collapse;
margin: 14px 0;
background: var(--panel-strong);
border: 1px solid var(--border);
border-radius: 16px;
overflow: hidden;
box-shadow: var(--shadow);
}
th, td {
border-bottom: 1px solid rgba(119, 134, 156, 0.16);
padding: 10px 12px;
text-align: left;
}
thead th {
background: var(--panel-soft);
color: var(--text);
font-size: .88rem;
letter-spacing: .02em;
text-transform: uppercase;
}
tbody tr:last-child td { border-bottom: 0; }
.doc { width: min(1120px, calc(100% - 32px)); margin: 0 auto; padding: 24px 0 40px; }
.cover, .toc-card, .step-card, .progress-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 24px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.cover {
padding: 34px 36px 28px;
margin-bottom: 22px;
}
.cover .eyebrow {
color: var(--accent);
text-transform: uppercase;
letter-spacing: .16em;
font-size: .74rem;
font-weight: 800;
margin-bottom: 14px;
}
.cover h1 {
font-size: clamp(2rem, 4vw, 3.6rem);
line-height: 1.02;
margin: 0;
letter-spacing: -0.04em;
}
.cover .rule {
width: 132px;
height: 6px;
margin: 18px 0 16px;
border-radius: 999px;
box-shadow: 0 10px 24px rgba(var(--accent-rgb), 0.22);
}
.cover .desc {
max-width: 76ch;
color: var(--muted);
font-size: 1.02rem;
}
.cover .meta { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 11px;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.10);
color: var(--accent);
border: 1px solid rgba(var(--accent-rgb), 0.14);
font-size: .86rem;
font-weight: 650;
}
.chip.muted {
background: rgba(255, 255, 255, 0.55);
color: var(--muted);
border-color: rgba(119, 134, 156, 0.16);
}
.toc-card {
padding: 20px 22px;
margin-bottom: 24px;
}
.toc-card h2 {
margin: 0 0 16px;
font-size: 1.08rem;
letter-spacing: .02em;
}
.toc-list {
list-style: none;
margin: 0;
padding: 0;
}
.toc-list li { margin: 0; }
.toc-list a {
display: flex;
gap: 12px;
align-items: flex-start;
padding: 9px 12px;
border-radius: 14px;
color: inherit;
}
.toc-list a:hover {
background: rgba(var(--accent-rgb), 0.08);
text-decoration: none;
}
.toc-list .num {
min-width: 44px;
color: var(--accent);
font-weight: 800;
}
.toc-list .label { flex: 1; }
.toc-list li.d1 a { padding-left: 24px; }
.toc-list li.d2 a { padding-left: 44px; }
.toc-list li.d3 a { padding-left: 64px; }
.step-card {
margin: 20px 0;
padding: 22px 24px 18px;
border-left: 6px solid var(--accent);
}
.step-card h2 {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 0 0 16px;
font-size: 1.28rem;
line-height: 1.15;
}
.step-card h2 .step-num {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 2.1rem;
height: 2.1rem;
padding: 0 10px;
border-radius: 999px;
background: rgba(var(--accent-rgb), 0.11);
color: var(--accent);
font-size: .9rem;
font-weight: 800;
letter-spacing: .02em;
flex: none;
}
.step-card h2 .step-title { flex: 1; }
.status-chip {
display: inline-flex;
align-items: center;
height: 1.65rem;
padding: 0 9px;
border-radius: 999px;
font-size: .72rem;
letter-spacing: .08em;
text-transform: uppercase;
font-weight: 800;
color: #334155;
background: var(--panel-soft);
border: 1px solid rgba(119, 134, 156, 0.18);
}
.status-chip.status-done {
color: #047857;
background: #ecfdf5;
border-color: rgba(16, 185, 129, 0.22);
}
.status-chip.status-in-progress {
color: #b45309;
background: #fffbeb;
border-color: rgba(245, 158, 11, 0.22);
}
.status-chip.status-todo {
color: #475569;
}
.status-chip.skipped {
color: #92400e;
background: #fffbeb;
border-color: rgba(245, 158, 11, 0.24);
}
.step-card.skipped { opacity: .76; }
.step-body > .desc {
color: #243044;
}
.block {
position: relative;
border-left: 4px solid var(--accent);
background: rgba(var(--accent-rgb), 0.08);
padding: 14px 16px 14px 54px;
margin: 14px 0;
border-radius: 18px;
overflow: hidden;
}
.block::before {
content: 'i';
position: absolute;
left: 16px;
top: 14px;
width: 22px;
height: 22px;
border-radius: 999px;
display: inline-flex;
align-items: center;
justify-content: center;
background: rgba(var(--accent-rgb), 0.15);
color: var(--accent);
font-weight: 900;
font-size: 13px;
}
.block strong { color: var(--text); }
.block-warn { border-color: #f59e0b; background: #fffbeb; }
.block-warn::before { content: '!'; background: rgba(245, 158, 11, 0.16); color: #b45309; }
.block-warn strong { color: #92400e; }
.block-error { border-color: #ef4444; background: #fef2f2; }
.block-error::before { content: '!'; background: rgba(239, 68, 68, 0.14); color: #b91c1c; }
.block-error strong { color: #b91c1c; }
.block-success { border-color: #10b981; background: #ecfdf5; }
.block-success::before { content: '✓'; background: rgba(16, 185, 129, 0.14); color: #047857; }
.block-success strong { color: #047857; }
.footer-note {
margin-top: 20px;
color: var(--muted);
font-size: .84rem;
}
.skipped { opacity: .55; }
@media (prefers-color-scheme: dark) {
body { background: #111827; color: #e5e7eb; }
h2 { border-color: #374151; }
pre.code, .block { background: #1f2937; }
th, td { border-color: #4b5563; }
:root {
--bg: #0b1220;
--bg-2: #0f172a;
--panel: rgba(17, 24, 39, 0.9);
--panel-strong: #111827;
--panel-soft: #162033;
--text: #e5e7eb;
--muted: #98a2b3;
--border: rgba(148, 163, 184, 0.18);
--shadow: 0 16px 40px rgba(0, 0, 0, 0.25);
}
.status-chip { color: #d1d5db; }
.status-chip.skipped { color: #fbbf24; background: rgba(245, 158, 11, 0.12); }
.cover .desc, .step-body > .desc { color: #cbd5e1; }
.chip.muted { background: rgba(15, 23, 42, 0.75); color: #cbd5e1; }
.block { background: rgba(37, 99, 235, 0.12); }
.block-warn { background: rgba(245, 158, 11, 0.12); }
.block-error { background: rgba(239, 68, 68, 0.12); }
.block-success { background: rgba(16, 185, 129, 0.12); }
table { background: var(--panel-strong); }
th, td { border-bottom-color: rgba(148, 163, 184, 0.12); }
}
`;
const RICH_CSS = `
.layout-rich {
display: grid;
grid-template-columns: 300px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.toc-panel {
position: sticky;
top: 20px;
align-self: start;
}
.toc-card {
margin: 0;
max-height: calc(100vh - 40px);
overflow: auto;
}
.progress-card {
margin-bottom: 22px;
padding: 14px 18px;
}
.progress {
position: sticky;
top: 0;
z-index: 2;
background: transparent;
}
.progress .label {
font-size: .86rem;
font-weight: 700;
color: var(--muted);
margin-bottom: 8px;
}
.progress .bar {
height: 8px;
background: rgba(148, 163, 184, 0.18);
border-radius: 999px;
overflow: hidden;
}
.progress .fill {
height: 100%;
width: 0;
background: var(--accent);
transition: width .2s ease;
}
label.check { margin-right: 0; }
label.check input { transform: translateY(1px); }
.step-card h2 .step-title { min-width: 0; }
@media (max-width: 960px) {
.layout-rich { grid-template-columns: 1fr; }
.toc-panel { position: static; }
.toc-card { max-height: none; }
}
`;
@@ -95,12 +514,16 @@ function exportHtmlSimple(ast, outDir, template = {}) {
const tpl = { ...DEFAULT_TEMPLATE, ...template };
fs.mkdirSync(outDir, { recursive: true });
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const stepsHtml = ast.steps.map((step) => `
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}">
<h2>${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</h2>
${stepBodyHtml(step, ast, images, tpl)}
</section>`).join('\n');
const toc = tpl.toc && ast.steps.length > 1
? `
<section class="toc-card">
<h2>Contents</h2>
<ul class="toc-list">
${renderTocList(ast)}
</ul>
</section>`
: '';
const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl)).join('\n');
const html = `<!DOCTYPE html>
<html lang="en">
@@ -110,11 +533,13 @@ function exportHtmlSimple(ast, outDir, template = {}) {
<title>${escapeHtml(ast.guide.title)}</title>
<style>${BASE_CSS}${tpl.customCss}</style>
</head>
<body>
<h1>${escapeHtml(ast.guide.title)}</h1>
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''}
<body style="${bodyStyle(tpl)}">
<div class="doc doc-simple">
${renderCover(ast, tpl)}
${toc}
${stepsHtml}
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} ${ast.steps.length} steps</small></footer>
<footer class="footer-note">Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}</footer>
</div>
</body>
</html>
`;
@@ -128,39 +553,19 @@ function exportHtmlRich(ast, outDir, template = {}) {
fs.mkdirSync(outDir, { recursive: true });
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const storageKey = `stepforge-progress-${ast.guide.id}`;
const tocHtml = tpl.toc && ast.steps.length > 1
? `
<aside class="toc-panel">
<section class="toc-card">
<h2>Contents</h2>
<ul class="toc-list">
${renderRichToc(ast)}
</ul>
</section>
</aside>`
: '';
const tocHtml = ast.steps.map((step) =>
`<li class="d${step.depth}"><a href="#${anchorFor(step)}">${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}</a></li>`
).join('\n');
const stepsHtml = ast.steps.map((step) => `
<section class="step${step.skipped ? ' skipped' : ''}" id="${anchorFor(step)}">
<h2>
<label class="check"><input type="checkbox" class="step-done" data-step="${escapeHtml(step.stepId)}"></label>
${escapeHtml(step.number)}. ${escapeHtml(step.title || 'Untitled step')}
</h2>
${stepBodyHtml(step, ast, images, tpl)}
</section>`).join('\n');
const richCss = `
.layout { display: flex; gap: 28px; max-width: 1180px; margin: 0 auto; }
nav.toc { position: sticky; top: 16px; align-self: flex-start; min-width: 220px; max-width: 280px;
max-height: calc(100vh - 32px); overflow-y: auto; font-size: .92em;
border: 1px solid #e5e7eb; border-radius: 8px; padding: 14px; }
nav.toc ul { list-style: none; margin: 0; padding: 0; }
nav.toc li { margin: .25em 0; }
nav.toc li.d1 { padding-left: 14px; } nav.toc li.d2 { padding-left: 28px; }
nav.toc a { color: inherit; text-decoration: none; }
nav.toc a:hover { color: ${tpl.accentColor}; }
main { flex: 1; min-width: 0; }
.progress { position: sticky; top: 0; background: inherit; padding: 8px 0; z-index: 2; }
.progress .bar { height: 6px; background: #e5e7eb; border-radius: 3px; overflow: hidden; }
.progress .fill { height: 100%; width: 0; background: ${tpl.accentColor}; transition: width .2s; }
label.check { margin-right: 8px; }
section.step.done h2 { text-decoration: line-through; opacity: .6; }
@media (max-width: 900px) { .layout { flex-direction: column; } nav.toc { position: static; max-width: none; } }
@media (prefers-color-scheme: dark) { nav.toc { border-color: #374151; } .progress .bar { background: #374151; } }
`;
const stepsHtml = ast.steps.map((step) => renderStepCard(step, ast, images, tpl, { rich: true })).join('\n');
const script = `
(function () {
@@ -197,19 +602,19 @@ function exportHtmlRich(ast, outDir, template = {}) {
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${escapeHtml(ast.guide.title)}</title>
<style>${BASE_CSS}${richCss}${tpl.customCss}</style>
<style>${BASE_CSS}${RICH_CSS}${tpl.customCss}</style>
</head>
<body>
<div class="layout">
<nav class="toc"><strong>Contents</strong><ul>
<body style="${bodyStyle(tpl)}">
<div class="doc layout-rich">
${tocHtml}
</ul></nav>
<main>
<h1>${escapeHtml(ast.guide.title)}</h1>
${ast.guide.descriptionHtml ? `<div class="desc">${ast.guide.descriptionHtml}</div>` : ''}
<div class="progress"><div class="label"></div><div class="bar"><div class="fill"></div></div></div>
${renderCover(ast, tpl)}
<div class="progress progress-card">
<div class="label"></div>
<div class="bar"><div class="fill"></div></div>
</div>
${stepsHtml}
<footer><small>Generated by StepForge on ${escapeHtml(ast.generatedAt)} ${ast.steps.length} steps</small></footer>
<footer class="footer-note">Generated by StepForge on ${escapeHtml(ast.generatedAt)} · ${ast.steps.length} step${ast.steps.length === 1 ? '' : 's'}</footer>
</main>
</div>
<script>${script}</script>
+2
View File
@@ -42,6 +42,8 @@ function htmlToMarkdown(html) {
.replace(/<hr\s*\/?>/gi, '\n---\n')
.replace(/<p>/gi, '\n')
.replace(/<\/p>/gi, '\n')
.replace(/<div>/gi, '\n')
.replace(/<\/div>/gi, '\n')
.replace(/<[^>]+>/g, '');
return decodeEntities(out).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
+3 -1
View File
@@ -3,6 +3,7 @@
const fs = require('node:fs');
const path = require('node:path');
const { guideSlug, writeStepImages } = require('./common');
const { tocEntries, guideSummary } = require('./document-layout');
const raster = require('../core/raster');
const { decodePng, encodePng } = require('../core/png');
@@ -39,7 +40,8 @@ function exportImageBundle(ast, outDir, template = {}) {
const meta = {
format: 'stepforge-image-bundle',
version: 1,
guide: { title: ast.guide.title, generatedAt: ast.generatedAt },
guide: { title: ast.guide.title, generatedAt: ast.generatedAt, summary: guideSummary(ast) },
toc: tocEntries(ast).map(({ number, title, depth, anchor }) => ({ number, title, depth, anchor })),
steps: ast.steps.map((step) => ({
number: step.number,
title: step.title,
+2
View File
@@ -2,6 +2,7 @@
const { exportJson } = require('./json');
const { exportMarkdown } = require('./markdown');
const { exportWikiJs } = require('./wikijs');
const { exportHtmlSimple, exportHtmlRich } = require('./html');
const { exportConfluence } = require('./confluence');
const { exportPdf } = require('./pdf');
@@ -14,6 +15,7 @@ const { exportPptx } = require('./pptx');
const EXPORTERS = {
json: exportJson,
markdown: exportMarkdown,
wikijs: exportWikiJs,
'html-simple': exportHtmlSimple,
'html-rich': exportHtmlRich,
confluence: exportConfluence,
+3
View File
@@ -3,6 +3,7 @@
const fs = require('node:fs');
const path = require('node:path');
const { guideSlug, writeStepImages, stepBlocks, codeBlockText } = require('./common');
const { tocEntries, guideSummary } = require('./document-layout');
/**
* JSON exporter: structured guide + steps, annotated screenshots written to
@@ -29,7 +30,9 @@ function exportJson(ast, outDir, template = {}) {
descriptionHtml: ast.guide.descriptionHtml,
createdAt: ast.guide.createdAt,
updatedAt: ast.guide.updatedAt,
summary: guideSummary(ast),
},
toc: tocEntries(ast).map(({ number, title, depth, anchor }) => ({ number, title, depth, anchor })),
steps: ast.steps.map((step) => ({
number: step.number,
kind: step.kind,
+154
View File
@@ -0,0 +1,154 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { escapeHtml } = require('../core/util');
const { guideSlug, writeStepImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { htmlToMarkdown } = require('./htmlmd');
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
const DEFAULT_TEMPLATE = {
toc: true,
includeImages: true,
azureWiki: false,
imageMaxWidth: 0, // 0 = natural size
};
const WIKIJS_CALLOUT_CLASS = {
info: 'is-info',
success: 'is-success',
warn: 'is-warning',
error: 'is-danger',
};
const HTML_CALLOUT_THEME = {
info: { label: 'Note', border: '#2563eb', fg: '#1d4ed8', kind: 'note' },
success: { label: 'Tip', border: '#10b981', fg: '#047857', kind: 'tip' },
warn: { label: 'Warning', border: '#f59e0b', fg: '#b45309', kind: 'warning' },
error: { label: 'Important', border: '#ef4444', fg: '#b91c1c', kind: 'important' },
};
function anchorFor(step) {
return `step-${step.number.replace(/\./g, '-')}`;
}
function quoteBody(text) {
return text ? text.replace(/\n/g, '\n> ') : '';
}
function emitBlock(lines, tb, { alertStyle = 'gfm' } = {}) {
const body = htmlToMarkdown(tb.descriptionHtml);
if (alertStyle === 'html') {
const theme = HTML_CALLOUT_THEME[tb.level] || HTML_CALLOUT_THEME.info;
const label = theme.label;
const title = tb.title ? `${label}: ${tb.title}` : label;
const style = `border-left: 4px solid ${theme.border}; padding: 14px 16px; margin: 14px 0; border-radius: 0 16px 16px 0;`;
lines.push(
`<div class="sf-callout sf-callout-${theme.kind}" style="${style}">`,
`<div style="font-weight: 700; color: ${theme.fg}; margin-bottom: ${body ? '6px' : '0'};">${escapeHtml(title)}</div>`,
);
if (body) lines.push(`<div style="color: inherit;">${tb.descriptionHtml || ''}</div>`);
lines.push('</div>', '');
return;
}
if (alertStyle === 'wikijs') {
const label = tb.title || LEVEL_LABEL[tb.level] || 'Note';
const className = WIKIJS_CALLOUT_CLASS[tb.level] || 'is-info';
lines.push(`> **${label}**`);
if (body) lines.push(`> ${quoteBody(body)}`);
lines.push(`{.${className}}`, '');
return;
}
const label = LEVEL_LABEL[tb.level] || 'Note';
lines.push(`> [!${label.toUpperCase()}]`);
if (tb.title) lines.push(`> **${tb.title}**`);
if (body) lines.push(`> ${quoteBody(body)}`);
lines.push('');
}
function renderMarkdownGuide(ast, outDir, template = {}, {
defaults = DEFAULT_TEMPLATE,
alertStyle = 'gfm',
tocTitle = 'Contents',
fileExt = '.md',
} = {}) {
const tpl = { ...defaults, ...template };
fs.mkdirSync(outDir, { recursive: true });
const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map();
const lines = [];
lines.push(`# ${ast.guide.title}`, '');
lines.push('<div style="height:4px;background:#2563eb;border-radius:999px;margin:12px 0 18px;"></div>', '');
const metaLines = guideMetaLines(ast);
if (metaLines.length) lines.push(metaLines.join(' · '), '');
lines.push(`*${guideSummary(ast)}*`, '');
if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), '');
if (tpl.toc && ast.steps.length > 1) {
lines.push(`## ${tocTitle}`, '');
for (const entry of tocEntries(ast)) {
const indent = ' '.repeat(entry.depth);
lines.push(`${indent}- [${entry.number}. ${entry.title}](#${entry.anchor})`);
}
lines.push('');
}
for (const step of ast.steps) {
const heading = step.depth > 0 ? '###' : '##';
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
lines.push(`<a id="${anchorFor(step)}"></a>`, '');
for (const tb of beforeTitle) emitBlock(lines, tb, { alertStyle });
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
if (step.skipped) lines.push('*(skipped)*', '');
for (const tb of afterTitle) emitBlock(lines, tb, { alertStyle });
for (const tb of beforeDescription) emitBlock(lines, tb, { alertStyle });
if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
for (const tb of afterDescription) emitBlock(lines, tb, { alertStyle });
for (const tb of beforeImage) emitBlock(lines, tb, { alertStyle });
const img = images.get(step.stepId);
if (img) {
if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
lines.push(`![Step ${step.number}](${img.relPath} =${tpl.imageMaxWidth}x)`, '');
} else {
lines.push(`![Step ${step.number}](${img.relPath})`, '');
}
}
for (const tb of afterImage) emitBlock(lines, tb, { alertStyle });
for (const block of rest) {
if (block.kind === 'text') {
emitBlock(lines, block, { alertStyle });
} else if (block.kind === 'code') {
lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
} else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue;
const width = Math.max(...block.rows.map((r) => r.length));
const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
lines.push(`|${' --- |'.repeat(width)}`);
for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
lines.push('');
}
}
}
const file = path.join(outDir, `${guideSlug(ast)}${fileExt}`);
fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n');
return { file, imageCount: images.size };
}
module.exports = {
DEFAULT_TEMPLATE,
anchorFor,
renderMarkdownGuide,
};
+7 -82
View File
@@ -1,94 +1,19 @@
'use strict';
const fs = require('node:fs');
const path = require('node:path');
const { guideSlug, writeStepImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
const { htmlToMarkdown } = require('./htmlmd');
const { DEFAULT_TEMPLATE, anchorFor, renderMarkdownGuide } = require('./markdown-guide');
/**
* Markdown exporter. Writes <slug>.md plus a steps-<slug>/ image folder.
* azureWiki mode emits resized image syntax (=WxH) Azure DevOps wikis accept.
*/
const DEFAULT_TEMPLATE = {
toc: true,
includeImages: true,
azureWiki: false,
imageMaxWidth: 0, // 0 = natural size
};
function anchorFor(step) {
return `step-${step.number.replace(/\./g, '-')}`;
}
function exportMarkdown(ast, outDir, template = {}) {
const tpl = { ...DEFAULT_TEMPLATE, ...template };
fs.mkdirSync(outDir, { recursive: true });
const images = tpl.includeImages ? writeStepImages(ast, outDir) : new Map();
const lines = [];
lines.push(`# ${ast.guide.title}`, '');
if (ast.guide.descriptionHtml) lines.push(htmlToMarkdown(ast.guide.descriptionHtml), '');
if (tpl.toc && ast.steps.length > 1) {
lines.push('## Contents', '');
for (const step of ast.steps) {
const indent = ' '.repeat(step.depth);
lines.push(`${indent}- [${step.number}. ${step.title || 'Untitled step'}](#${anchorFor(step)})`);
}
lines.push('');
}
for (const step of ast.steps) {
const heading = step.depth > 0 ? '###' : '##';
lines.push(`<a id="${anchorFor(step)}"></a>`, '');
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
if (step.skipped) lines.push('*(skipped)*', '');
emitBlocks(lines, step, 'before-description');
if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
const img = images.get(step.stepId);
if (img) {
if (tpl.azureWiki && tpl.imageMaxWidth > 0) {
lines.push(`![Step ${step.number}](${img.relPath} =${tpl.imageMaxWidth}x)`, '');
} else {
lines.push(`![Step ${step.number}](${img.relPath})`, '');
}
}
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
if (block.kind === 'code') {
lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
} else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue;
const width = Math.max(...block.rows.map((r) => r.length));
const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
lines.push(`|${' --- |'.repeat(width)}`);
for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
lines.push('');
}
}
emitBlocks(lines, step, 'after-description');
emitBlocks(lines, step, 'after-image');
}
const file = path.join(outDir, `${guideSlug(ast)}.md`);
fs.writeFileSync(file, lines.join('\n').replace(/\n{3,}/g, '\n\n') + '\n');
return { file, imageCount: images.size };
}
function emitBlocks(lines, step, position) {
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
const label = LEVEL_LABEL[tb.level] || 'Note';
lines.push(`> **${label}${tb.title ? `: ${tb.title}` : ''}**`);
const body = htmlToMarkdown(tb.descriptionHtml);
if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`);
lines.push('');
}
return renderMarkdownGuide(ast, outDir, template, {
defaults: DEFAULT_TEMPLATE,
alertStyle: 'html',
tocTitle: 'Contents',
fileExt: '.md',
});
}
module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor };
+287 -32
View File
@@ -3,8 +3,100 @@
const fs = require('node:fs');
const path = require('node:path');
const { PdfBuilder } = require('../core/pdf');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { htmlToText } = require('../core/util');
const { htmlToBlocks } = require('../core/htmlblocks');
const LIST_INDENT = 14;
const QUOTE_INDENT = 10;
const HEADING_BUMP = { h1: 2.5, h2: 2, h3: 1.5, h4: 1 };
// Callout styling per text-block level, matching the colors used in the
// HTML/editor UI so a "Tip" looks distinct from a "Warning" at a glance.
const LEVEL_STYLE = {
info: { accent: [59, 130, 246], tint: [239, 246, 255] }, // blue — Note
success: { accent: [16, 185, 129], tint: [236, 253, 245] }, // green — Tip
warn: { accent: [245, 158, 11], tint: [255, 251, 235] }, // amber — Warning
error: { accent: [239, 68, 68], tint: [254, 242, 242] }, // red — Important
};
function fontForRun(run) {
if (run.code) return 'F3';
if (run.bold && run.italic) return 'F5';
if (run.bold) return 'F2';
if (run.italic) return 'F4';
return 'F1';
}
/** Split formatted runs into words (font/link tagged) and greedily wrap to maxWidth. */
function wrapRuns(pdf, runs, size, maxWidth) {
const words = [];
for (const run of runs) {
const font = fontForRun(run);
for (const part of run.text.split(/(\s+)/)) {
if (part === '') continue;
words.push({ text: part, font, href: run.href });
}
}
const lines = [];
let line = [];
let w = 0;
for (const word of words) {
const isSpace = /^\s+$/.test(word.text);
const ww = pdf.textWidth(word.text, size, word.font);
if (!isSpace && line.length && w + ww > maxWidth) {
lines.push(line);
line = [];
w = 0;
}
if (isSpace && !line.length) continue;
line.push(word);
w += ww;
}
if (line.length) lines.push(line);
return lines;
}
/**
* Lay out description HTML into render-ready items, preserving bold,
* italic, links, lists, blockquotes and headings.
* Returns { items, height }; items: { kind: 'hr', width } | { kind: 'text',
* lines, size, lineHeight, indent, prefix, muted }.
*/
function layoutDescription(pdf, html, maxWidth, baseSize) {
const items = [];
let height = 0;
for (const block of htmlToBlocks(html || '')) {
if (block.type === 'hr') {
items.push({ kind: 'hr', width: maxWidth });
height += 12;
continue;
}
let size = baseSize;
let runs = block.runs;
let indent = (block.indent || 0) * LIST_INDENT;
let prefix = null;
let muted = false;
if (HEADING_BUMP[block.type]) {
size = baseSize + HEADING_BUMP[block.type];
runs = runs.map((r) => ({ ...r, bold: true }));
} else if (block.type === 'li') {
prefix = '•';
indent += LIST_INDENT;
} else if (block.type === 'oli') {
prefix = `${block.n}.`;
indent += LIST_INDENT;
} else if (block.type === 'blockquote') {
indent += QUOTE_INDENT;
muted = true;
}
const lines = wrapRuns(pdf, runs, size, maxWidth - indent);
const lineHeight = size * 1.35;
items.push({ kind: 'text', lines, size, lineHeight, indent, prefix, muted });
height += lines.length * lineHeight + 4;
}
return { items, height };
}
/**
* PDF exporter: cover block, optional TOC, one section per step with the
@@ -38,8 +130,11 @@ function exportPdf(ast, outDir, template = {}) {
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
let y = M;
// Never start a page break when already at the top of a page — an item
// taller than a full page (e.g. a step's combined head height) must
// still render and overflow naturally rather than push out a blank page.
const ensure = (needed) => {
if (y + needed > size.height - M) {
if (y > M && y + needed > size.height - M) {
pdf.addPage();
y = M;
}
@@ -51,47 +146,188 @@ function exportPdf(ast, outDir, template = {}) {
y += fs_ * leading;
}
};
/** Render laid-out description items, preserving bold/italic/links/lists. */
const writeDescription = (html, { size: baseSize = 10.5, color = [0, 0, 0], indent: indentBase = 0 } = {}) => {
const { items } = layoutDescription(pdf, html, usableW - indentBase, baseSize);
for (const item of items) {
if (item.kind === 'hr') {
ensure(12);
pdf.rect(M + indentBase, y + 5, item.width, 0.8, { fill: [225, 228, 232] });
y += 12;
continue;
}
item.lines.forEach((line, idx) => {
ensure(item.lineHeight);
const textX = M + indentBase + item.indent;
if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, y, { size: item.size, font: 'F1', color });
const parts = line.map((word) => ({
text: word.text,
font: word.font,
color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color),
}));
pdf.textRun(parts, textX, y, item.size);
y += item.lineHeight;
});
y += 4;
}
};
/** Height a callout block (emitBlock) will occupy, including its trailing gap. */
const measureBlock = (tb) => {
const { height: bodyH } = tb.descriptionHtml
? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
: { height: 0 };
return 16 + bodyH + 6;
};
/** Height an image will occupy on the page, including its trailing gap. */
const measureImage = (stepId) => {
const img = images.get(stepId);
if (!img) return 0;
const maxH = usableH * tpl.imageMaxHeightRatio;
let h = (img.height / img.width) * usableW;
if (h > maxH) h = maxH;
return h + 10;
};
const measureCode = (block) => {
const lines = String(codeBlockText(block) || '').split('\n');
const lineH = 9 * 1.3;
return lines.length * lineH + 16;
};
const measureTable = (block) => {
if (!block.rows || !block.rows.length) return 0;
return block.rows.length * 16 + 8;
};
/**
* Compute the vertical space a step will need: `head` is the title, the
* accent rule, positioned text blocks, the description, and the image
* kept together on one page and `total` adds the remaining blocks plus
* the trailing gap, used to decide whether the whole step fits on a
* fresh page.
*/
const measureStep = (step) => {
const headSize = step.depth > 0 ? 12 : 14;
const titleText = `${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`;
const titleLines = pdf.wrapText(titleText, headSize, usableW, 'F2');
let head = Math.max(40, titleLines.length * headSize * 1.35 + 8);
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of [...beforeTitle, ...afterTitle, ...beforeDescription, ...afterDescription, ...beforeImage, ...afterImage]) {
head += measureBlock(tb);
}
if (step.descriptionHtml) head += layoutDescription(pdf, step.descriptionHtml, usableW, 10.5).height;
head += measureImage(step.stepId);
let restHeight = 0;
for (const block of rest) {
if (block.kind === 'text') restHeight += measureBlock(block);
else if (block.kind === 'code') restHeight += measureCode(block);
else if (block.kind === 'table') restHeight += measureTable(block);
}
return { head, total: head + restHeight + 10 };
};
pdf.addPage();
if (tpl.includeCover) {
y = M + usableH * 0.18;
pdf.rect(M, y - 18, usableW, 3, { fill: tpl.accentColor });
y += 6;
writeLines(ast.guide.title, { size: 26, font: 'F2' });
y += 8;
if (ast.guide.descriptionText) writeLines(ast.guide.descriptionText, { size: 12, color: [70, 70, 70] });
// Keep the cover title near the top edge instead of vertically centering it.
y = M;
writeLines(ast.guide.title, { size: 28, font: 'F2' });
y += 10;
pdf.rect(M, y, usableW, 3, { fill: tpl.accentColor });
y += 16;
const meta = ast.guide.metadata || {};
const metaLines = [
meta.author && `Author: ${meta.author}`,
meta.coAuthors && `Co-authors: ${meta.coAuthors}`,
meta.organization && `Organization: ${meta.organization}`,
].filter(Boolean);
for (const line of metaLines) writeLines(line, { size: 11, color: [90, 90, 90] });
if (metaLines.length) y += 8;
if (ast.guide.descriptionHtml) writeDescription(ast.guide.descriptionHtml, { size: 12, color: [70, 70, 70] });
y += 14;
writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] });
pdf.addPage();
y = M;
}
// Filled in below as each step claims its page, so the Contents entries
// above (already laid out before pagination starts) can link to it.
const tocTargets = new Map(); // stepId -> { pageIndex }
if (tpl.includeToc && ast.steps.length > 1) {
writeLines('Contents', { size: 16, font: 'F2' });
y += 4;
const tocSize = 10.5;
const lineH = tocSize * 1.35;
for (const step of ast.steps) {
writeLines(`${step.number}. ${step.title || 'Untitled step'}`, {
size: 10.5, indent: 14 * step.depth,
});
const indent = 14 * step.depth;
const target = {};
tocTargets.set(step.stepId, target);
for (const line of pdf.wrapText(`${step.number}. ${step.title || 'Untitled step'}`, tocSize, usableW - indent, 'F1')) {
ensure(lineH);
pdf.text(line, M + indent, y, { size: tocSize, color: tpl.accentColor });
pdf.linkRect(M + indent, y, usableW - indent, lineH, target);
y += lineH;
}
}
pdf.addPage();
y = M;
}
let first = true;
let forcedFresh = false;
const pageBottom = size.height - M;
for (const step of ast.steps) {
if (step.forceNewPage && !first) { pdf.addPage(); y = M; }
const { head: headHeight, total: totalHeight } = measureStep(step);
// Start a fresh page when: the step's "New page" toggle is set; the
// previous step overflowed past one page (so this step shouldn't share
// the spillover page); or this step doesn't fit in what's left of the
// current page. The last check is skipped when already at the top of a
// page, so an overlong step doesn't push out a blank page first.
const needsFreshPage = !first && (
step.forceNewPage || forcedFresh || (y > M && y + totalHeight > pageBottom)
);
if (needsFreshPage) { pdf.addPage(); y = M; }
first = false;
ensure(40);
// Keep the title, accent rule, lead-in blocks, description, and image
// together — never split across a page boundary.
ensure(headHeight);
pdf.bookmark(`${step.number}. ${step.title || 'Untitled step'}`);
const tocTarget = tocTargets.get(step.stepId);
if (tocTarget) tocTarget.pageIndex = pdf.pages.length - 1;
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
rest,
} = stepContentGroups(step);
for (const tb of beforeTitle) emitBlock(tb);
const headSize = step.depth > 0 ? 12 : 14;
writeLines(`${step.number}. ${step.title || 'Untitled step'}${step.skipped ? ' (skipped)' : ''}`, { size: headSize, font: 'F2' });
pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] });
y += 8;
emitBlocks(step, 'before-description');
if (step.descriptionText) { writeLines(step.descriptionText); y += 4; }
for (const tb of afterTitle) emitBlock(tb);
for (const tb of beforeDescription) emitBlock(tb);
if (step.descriptionHtml) writeDescription(step.descriptionHtml);
for (const tb of afterDescription) emitBlock(tb);
for (const tb of beforeImage) emitBlock(tb);
const img = images.get(step.stepId);
if (img) {
@@ -103,9 +339,12 @@ function exportPdf(ast, outDir, template = {}) {
pdf.image(img, M, y, w, h);
y += h + 10;
}
for (const tb of afterImage) emitBlock(tb);
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
if (block.kind === 'code') {
for (const block of rest) {
if (block.kind === 'text') {
emitBlock(block);
} else if (block.kind === 'code') {
const lines = String(codeBlockText(block) || '').split('\n');
const lineH = 9 * 1.3;
ensure(Math.min(lines.length, 4) * lineH + 12);
@@ -138,26 +377,42 @@ function exportPdf(ast, outDir, template = {}) {
}
}
emitBlocks(step, 'after-description');
emitBlocks(step, 'after-image');
y += 10;
forcedFresh = totalHeight > usableH;
}
function emitBlocks(step, position) {
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
const bodyLines = tb.descriptionText ? pdf.wrapText(tb.descriptionText, 9.5, usableW - 18) : [];
const blockH = 16 + bodyLines.length * 13;
ensure(blockH + 4);
pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor });
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' });
let by = y + 16;
for (const line of bodyLines) {
pdf.text(line, M + 10, by, { size: 9.5 });
by += 13;
function emitBlock(tb) {
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
const { items, height: bodyH } = tb.descriptionHtml
? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
: { items: [], height: 0 };
const blockH = 16 + bodyH;
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
ensure(blockH + 4);
pdf.rect(M, y, usableW, blockH, { fill: style.tint });
pdf.rect(M, y, 3, blockH, { fill: style.accent });
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2', color: style.accent });
let by = y + 16;
for (const item of items) {
if (item.kind === 'hr') {
pdf.rect(M + 10, by + 5, item.width, 0.8, { fill: [225, 228, 232] });
by += 12;
continue;
}
y += blockH + 6;
item.lines.forEach((line, idx) => {
const textX = M + 10 + item.indent;
if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, by, { size: item.size, font: 'F1' });
const parts = line.map((word) => ({
text: word.text,
font: word.font,
color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]),
}));
pdf.textRun(parts, textX, by, item.size);
by += item.lineHeight;
});
by += 4;
}
y += blockH + 6;
}
fs.mkdirSync(outDir, { recursive: true });
+185 -16
View File
@@ -5,21 +5,41 @@ const path = require('node:path');
const { zipSync } = require('../core/zip');
const { escapeXml } = require('../core/util');
const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages } = require('./common');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups } = require('./common');
const { tocEntries, guideMetaLines, guideSummary } = require('./document-layout');
/**
* PPTX exporter: a title slide plus one 16:9 slide per step (title bar +
* screenshot + description). PresentationML written directly.
* PPTX exporter: a title slide plus one 16:9 slide per step, with
* positioned text blocks laid out around the step title, description, and
* screenshot. PresentationML written directly.
*/
const DEFAULT_TEMPLATE = {
includeImages: true,
titleSlide: true,
includeToc: true,
};
const SLIDE_W = 12192000; // EMU, 16:9
const SLIDE_H = 6858000;
const EMU_PER_PX = 9525;
const SLIDE_MARGIN = 914400;
const TITLE_Y = 420000;
const TITLE_H = 620000;
const TITLE_RULE_Y = 1120000;
const CONTENT_Y = 1500000;
const CONTENT_FOOTER_Y = 720000;
const CALL_OUT_HEIGHT = 620000;
const CALL_OUT_GAP = 90000;
const CALL_OUT_BAR_W = 24000;
const TOC_ENTRY_START_Y = 2300000;
const TOC_ENTRY_SPACING = 255000;
const TOC_ENTRY_HEIGHT = 220000;
const TOC_BOTTOM_MARGIN = 500000;
const TOC_MAX_ENTRIES_PER_SLIDE = Math.max(
1,
Math.floor((SLIDE_H - TOC_BOTTOM_MARGIN - TOC_ENTRY_START_Y - TOC_ENTRY_HEIGHT) / TOC_ENTRY_SPACING) + 1,
);
let shapeIdCounter = 10; // reset per export for deterministic output
@@ -29,6 +49,12 @@ function textBox(x, y, w, h, runsXml) {
`<p:txBody><a:bodyPr wrap="square"><a:normAutofit/></a:bodyPr><a:lstStyle/>${runsXml}</p:txBody></p:sp>`;
}
function rectShape(x, y, w, h, fill) {
return `<p:sp><p:nvSpPr><p:cNvPr id="${shapeIdCounter++}" name="Rect"/><p:cNvSpPr/><p:nvPr/></p:nvSpPr>` +
`<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm>` +
`<a:prstGeom prst="rect"><a:avLst/></a:prstGeom><a:solidFill><a:srgbClr val="${fill}"/></a:solidFill><a:ln><a:noFill/></a:ln></p:spPr></p:sp>`;
}
function para(text, { size = 1800, bold = false, color = '111827' } = {}) {
return `<a:p><a:r><a:rPr lang="en-US" sz="${size}" b="${bold ? 1 : 0}" dirty="0"><a:solidFill><a:srgbClr val="${color}"/></a:solidFill></a:rPr><a:t>${escapeXml(text)}</a:t></a:r></a:p>`;
}
@@ -39,6 +65,49 @@ function picture(relId, x, y, w, h) {
`<p:spPr><a:xfrm><a:off x="${x}" y="${y}"/><a:ext cx="${w}" cy="${h}"/></a:xfrm><a:prstGeom prst="rect"><a:avLst/></a:prstGeom></p:spPr></p:pic>`;
}
const CALLOUT_STYLE = {
info: { fill: 'EFF6FF', accent: '2563EB', label: 'Note', color: '1D4ED8' },
success: { fill: 'ECFDF5', accent: '10B981', label: 'Tip', color: '047857' },
warn: { fill: 'FFFBEB', accent: 'F59E0B', label: 'Warning', color: 'B45309' },
error: { fill: 'FEF2F2', accent: 'EF4444', label: 'Important', color: 'B91C1C' },
};
function estimateWrappedLines(text, charsPerLine) {
const raw = String(text || '').trim();
if (!raw) return 0;
return raw.split(/\n+/).reduce((sum, line) => sum + Math.max(1, Math.ceil(line.length / charsPerLine)), 0);
}
function calloutHeight(tb) {
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
const lines = estimateWrappedLines(label, 46) + estimateWrappedLines(tb.descriptionText, 72);
return Math.max(CALL_OUT_HEIGHT, 360000 + (lines * 150000));
}
function calloutXml(tb, x, y, w) {
const style = CALLOUT_STYLE[tb.level] || CALLOUT_STYLE.info;
const height = calloutHeight(tb);
const label = `${style.label}${tb.title ? `: ${tb.title}` : ''}`;
const titlePara = para(label, { size: 1400, bold: true, color: style.color });
const bodyPara = tb.descriptionText ? para(tb.descriptionText.slice(0, 400), { size: 1250, color: '374151' }) : '';
const innerX = x + CALL_OUT_BAR_W + 24000;
const innerW = Math.max(0, w - CALL_OUT_BAR_W - 72000);
return {
height,
xml: [
rectShape(x, y, w, height, style.fill),
rectShape(x, y, CALL_OUT_BAR_W, height, style.accent),
textBox(innerX, y + 12000, innerW, Math.max(0, height - 24000), `${titlePara}${bodyPara}`),
].join(''),
};
}
function descriptionHeight(text) {
const lines = estimateWrappedLines(text, 95);
if (!lines) return 0;
return Math.max(360000, 260000 + (lines * 130000));
}
function slideXml(content) {
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<p:sld xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main"
@@ -50,6 +119,33 @@ ${content}
</p:spTree></p:cSld><p:clrMapOvr><a:overrideClrMapping bg1="lt1" tx1="dk1" bg2="lt2" tx2="dk2" accent1="accent1" accent2="accent2" accent3="accent3" accent4="accent4" accent5="accent5" accent6="accent6" hlink="hlink" folHlink="folHlink"/></p:clrMapOvr></p:sld>`;
}
function chunkArray(items, size) {
const chunks = [];
for (let i = 0; i < items.length; i += size) {
chunks.push(items.slice(i, i + size));
}
return chunks;
}
function tocSlideXml(ast, entries, { continued = false } = {}) {
let tocContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
tocContent += textBox(914400, 760000, SLIDE_W - 1828800, 700000,
para(continued ? 'Contents (continued)' : 'Contents', { size: 3000, bold: true }));
tocContent += rectShape(914400, 1500000, 1600000, 14000, '2563EB');
tocContent += textBox(914400, 1680000, SLIDE_W - 1828800, 450000,
para(guideSummary(ast), { size: 1500, color: '6B7280' }));
entries.forEach((entry, index) => {
const x = 914400 + (entry.depth * 220000);
const y = TOC_ENTRY_START_Y + (index * TOC_ENTRY_SPACING);
tocContent += rectShape(x, y + 78000, 24000, 90000, '2563EB');
tocContent += textBox(x + 48000, y, SLIDE_W - x - 1200000, TOC_ENTRY_HEIGHT,
para(`${entry.number}. ${entry.title}`, { size: entry.depth === 0 ? 1550 : 1450, bold: entry.depth === 0 }));
});
return slideXml(tocContent);
}
const THEME_XML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<a:theme xmlns:a="http://schemas.openxmlformats.org/drawingml/2006/main" name="StepForge">
<a:themeElements>
@@ -85,22 +181,91 @@ function exportPptx(ast, outDir, template = {}) {
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
const slides = []; // { xml, rels: [{id, target}], media: [{name, data}] }
const toc = tpl.includeToc && ast.steps.length > 1 ? tocEntries(ast) : [];
if (tpl.titleSlide) {
const metaLines = guideMetaLines(ast);
let titleContent = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
titleContent += textBox(SLIDE_MARGIN, 2050000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true }));
titleContent += rectShape(SLIDE_MARGIN, 3300000, 2200000, 14000, '2563EB');
titleContent += textBox(SLIDE_MARGIN, 3500000, SLIDE_W - 1828800, 1100000,
[para(guideSummary(ast), { size: 1800, color: '6B7280' }),
...metaLines.map((line) => para(line, { size: 1500, color: '6B7280' }))].join(''));
slides.push({
xml: slideXml(
textBox(914400, 2300000, SLIDE_W - 1828800, 1200000, para(ast.guide.title, { size: 4000, bold: true })) +
textBox(914400, 3600000, SLIDE_W - 1828800, 800000,
para(`${ast.steps.length} steps — ${ast.generatedAt.slice(0, 10)}`, { size: 1800, color: '6B7280' }))
),
xml: slideXml(titleContent),
rels: [], media: [],
});
}
if (toc.length) {
const tocPages = chunkArray(toc, TOC_MAX_ENTRIES_PER_SLIDE);
tocPages.forEach((page, index) => {
slides.push({
xml: tocSlideXml(ast, page, { continued: index > 0 }),
rels: [], media: [],
});
});
}
let mediaCounter = 0;
for (const step of ast.steps) {
let content = textBox(457200, 274638, SLIDE_W - 914400, 700000,
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2400, bold: true }));
const {
beforeTitle,
afterTitle,
beforeDescription,
afterDescription,
beforeImage,
afterImage,
} = stepContentGroups(step);
let content = rectShape(0, 0, SLIDE_W, 18000, '2563EB');
const beforeTitleReserve = beforeTitle.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
const titleY = beforeTitle.length ? 220000 + beforeTitleReserve + 120000 : TITLE_Y;
const titleRuleY = beforeTitle.length ? titleY + TITLE_H + 120000 : TITLE_RULE_Y;
const contentY = beforeTitle.length ? Math.max(CONTENT_Y, titleRuleY + 180000) : CONTENT_Y;
const beforeTitleY = beforeTitle.length ? 220000 : CONTENT_Y;
let y = beforeTitleY;
for (const tb of beforeTitle) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
content += textBox(457200, titleY, SLIDE_W - 914400, TITLE_H,
para(`${step.number}. ${step.title || 'Untitled step'}`, { size: 2600, bold: true }));
content += rectShape(457200, titleRuleY, 2400000, 12000, '2563EB');
y = contentY;
for (const tb of afterTitle) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
for (const tb of beforeDescription) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
if (step.descriptionText) {
const descH = descriptionHeight(step.descriptionText);
content += textBox(457200, y, SLIDE_W - 914400, Math.max(360000, descH || 420000),
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' }));
y += Math.max(360000, descH || 420000) + 90000;
}
for (const tb of afterDescription) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
for (const tb of beforeImage) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
const postImageReserve = afterImage.reduce((sum, tb) => sum + calloutHeight(tb) + CALL_OUT_GAP, 0);
const rels = [];
const media = [];
@@ -111,16 +276,20 @@ function exportPptx(ast, outDir, template = {}) {
media.push({ name, data: encodePng(img) });
const relId = 2; // rId1 = layout, rId2 = image
rels.push({ id: relId, name });
// Fit image into a centered region below the title.
const maxW = SLIDE_W - 1219200, maxH = SLIDE_H - 1554638 - (step.descriptionText ? 700000 : 200000);
// Fit image into the remaining centered region before the trailing blocks.
const maxW = SLIDE_W - 1219200;
const maxH = Math.max(0, SLIDE_H - y - postImageReserve - 100000);
let w = img.width * EMU_PER_PX, h = img.height * EMU_PER_PX;
const scale = Math.min(maxW / w, maxH / h, 1);
w = Math.round(w * scale); h = Math.round(h * scale);
content += picture(relId, Math.round((SLIDE_W - w) / 2), 1054638, w, h);
content += picture(relId, Math.round((SLIDE_W - w) / 2), y, w, h);
y += h + 100000;
}
if (step.descriptionText) {
content += textBox(457200, SLIDE_H - 850000, SLIDE_W - 914400, 700000,
para(step.descriptionText.slice(0, 300), { size: 1400, color: '374151' }));
for (const tb of afterImage) {
const block = calloutXml(tb, 457200, y, SLIDE_W - 914400);
content += block.xml;
y += block.height + CALL_OUT_GAP;
}
slides.push({ xml: slideXml(content), rels, media });
}
+25
View File
@@ -0,0 +1,25 @@
'use strict';
const { DEFAULT_TEMPLATE, renderMarkdownGuide } = require('./markdown-guide');
/**
* Wiki.js markdown exporter. Same step/body structure as the generic
* Markdown exporter, but uses Wiki.js-friendly callout blocks.
*/
const WIKIJS_TEMPLATE = {
toc: true,
includeImages: true,
imageMaxWidth: 0,
};
function exportWikiJs(ast, outDir, template = {}) {
return renderMarkdownGuide(ast, outDir, template, {
defaults: WIKIJS_TEMPLATE,
alertStyle: 'wikijs',
tocTitle: 'Contents',
fileExt: '.md',
});
}
module.exports = { exportWikiJs, DEFAULT_TEMPLATE: WIKIJS_TEMPLATE };
+137 -10
View File
@@ -1,16 +1,23 @@
{
"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",
"tesseract.js": "^7.0.0"
},
"devDependencies": {
"electron": "^41.7.1",
"electron-builder": "^26.15.2"
},
"engines": {
"node": ">=22.12.0"
}
},
"node_modules/@electron/asar": {
@@ -624,6 +631,12 @@
"node": ">=10"
}
},
"node_modules/@tesseract.js-data/eng": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@tesseract.js-data/eng/-/eng-1.0.0.tgz",
"integrity": "sha512-mbTumm6KQPUHyzTPQaF3ObXYnx0SqqfV2nabqFVQBwD6Kl7PhGSLSzOlfFTWy0P3BjghaSKA2W9GB19Jk+ZcTg==",
"license": "MIT"
},
"node_modules/@types/cacheable-request": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/@types/cacheable-request/-/cacheable-request-6.0.3.tgz",
@@ -1057,6 +1070,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/boolean": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/boolean/-/boolean-3.2.0.tgz",
@@ -2121,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"
@@ -2512,6 +2531,12 @@
"node": ">= 14"
}
},
"node_modules/idb-keyval": {
"version": "6.2.5",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.5.tgz",
"integrity": "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==",
"license": "Apache-2.0"
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
@@ -2541,6 +2566,12 @@
"node": ">=8"
}
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@@ -2903,6 +2934,26 @@
"node": ">=10"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp": {
"version": "12.4.0",
"resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz",
@@ -3024,6 +3075,15 @@
"wrappy": "1"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/p-cancelable": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
@@ -3314,6 +3374,12 @@
"util-deprecate": "~1.0.1"
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.11",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
"license": "MIT"
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -3728,6 +3794,30 @@
"node": ">= 10.0.0"
}
},
"node_modules/tesseract.js": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz",
"integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^7.0.0",
"wasm-feature-detect": "^1.8.0",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz",
"integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==",
"license": "Apache-2.0"
},
"node_modules/tiny-async-pool": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tiny-async-pool/-/tiny-async-pool-1.3.0.tgz",
@@ -3785,6 +3875,12 @@
"tmp": "^0.2.0"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/truncate-utf8-bytes": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/truncate-utf8-bytes/-/truncate-utf8-bytes-1.0.2.tgz",
@@ -3817,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": {
@@ -3909,6 +4005,12 @@
"dev": true,
"license": "MIT"
},
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/webcrypto-core": {
"version": "1.9.2",
"resolved": "https://registry.npmjs.org/webcrypto-core/-/webcrypto-core-1.9.2.tgz",
@@ -3923,6 +4025,22 @@
"tslib": "^2.8.1"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz",
@@ -4043,6 +4161,15 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
}
}
}
+11 -3
View File
@@ -1,20 +1,28 @@
{
"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",
"package:windows": "node scripts/package-windows.js",
"build": "bash scripts/build-release.sh",
"verify": "bash scripts/verify.sh",
"bootstrap": "bash scripts/bootstrap-offline.sh"
},
"dependencies": {
"@tesseract.js-data/eng": "^1.0.0",
"tesseract.js": "^7.0.0"
},
"devDependencies": {
"electron": "^41.7.1",
"electron-builder": "^26.15.2"
+1 -1
View File
@@ -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
+6 -3
View File
@@ -70,6 +70,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 +89,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})
@@ -97,7 +99,7 @@ Host: ${process.platform} ${process.arch} (node ${process.version})
- Portable tarball: ${files.find((f) => f.path.endsWith('.tar.gz'))?.path || 'not generated'}
- Debian package: ${files.find((f) => f.path.endsWith('.deb'))?.path || 'not generated'}
- Sample guide archive: ${files.find((f) => f.path.endsWith('sample-guide.sfgz'))?.path || 'not generated'}
- Sample exports (9 formats): see examples/sample-exports/
- Sample exports (10 formats): see examples/sample-exports/
- Full artifact list with sha256 checksums: artifacts_manifest.json
## Packaging tool availability
@@ -109,7 +111,7 @@ ${toolRows}
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/
\`npm run package:windows\` (electron-builder, installer .exe); .msi/.rpm/
AppImage require the tools listed above and are skipped on this host.
## Offline guarantee
@@ -133,6 +135,7 @@ fs.writeFileSync(manifestFile, JSON.stringify({
version: 1,
generatedAt: new Date().toISOString(),
packageVersion: pkg.version,
buildVersion,
files,
}, null, 2) + '\n');
NODE
+79
View File
@@ -0,0 +1,79 @@
'use strict';
// Hard prerequisite check for the supported Node toolchain.
//
// The locked dependency graph (notably the electron-builder packaging
// toolchain) requires Node >= 22.12. Older Nodes fail late with confusing
// errors (ERR_REQUIRE_ESM deep inside dependencies) instead of a clear
// message, so every entry point calls this first.
const fs = require('node:fs');
const path = require('node:path');
function parseVersion(version) {
const match = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(version).trim());
if (!match) return null;
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
function compareVersions(a, b) {
for (let i = 0; i < 3; i += 1) {
if (a[i] !== b[i]) return a[i] < b[i] ? -1 : 1;
}
return 0;
}
function requiredNodeVersion(projectRoot = path.join(__dirname, '..')) {
const pkg = JSON.parse(fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8'));
const range = pkg.engines && pkg.engines.node ? String(pkg.engines.node) : null;
if (!range) return null;
const match = /(\d+\.\d+\.\d+)/.exec(range);
return match ? match[1] : null;
}
function checkNodeVersion({
currentVersion = process.versions.node,
projectRoot = path.join(__dirname, '..'),
} = {}) {
const required = requiredNodeVersion(projectRoot);
if (!required) return { ok: true, required: null, current: currentVersion };
const current = parseVersion(currentVersion);
const minimum = parseVersion(required);
if (!current || !minimum) return { ok: true, required, current: currentVersion };
return {
ok: compareVersions(current, minimum) >= 0,
required,
current: currentVersion,
};
}
function assertSupportedNode(options = {}) {
const result = checkNodeVersion(options);
if (result.ok) return result;
const message = [
`StepForge requires Node ${result.required} or newer; this is Node ${result.current}.`,
'',
'Install the pinned toolchain (see .nvmrc) and reinstall dependencies:',
'',
' nvm install && nvm use # or install Node 22 LTS another way',
' npm ci',
'',
'Older Nodes fail unpredictably inside the packaging dependency graph,',
'so this check stops early instead.',
].join('\n');
const error = new Error(message);
error.code = 'STEPFORGE_UNSUPPORTED_NODE';
throw error;
}
module.exports = {
assertSupportedNode,
checkNodeVersion,
compareVersions,
parseVersion,
requiredNodeVersion,
};
+119 -99
View File
@@ -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');
@@ -10,6 +18,11 @@ const ELECTRON_SKIP_ENV_KEYS = [
'NPM_CONFIG_ELECTRON_SKIP_BINARY_DOWNLOAD',
];
const NPM_IGNORE_SCRIPTS_ENV_KEYS = [
'npm_config_ignore_scripts',
'NPM_CONFIG_IGNORE_SCRIPTS',
];
function resolveElectronPackageRoot() {
try {
return path.dirname(require.resolve('electron/package.json'));
@@ -48,10 +61,92 @@ function sanitizeElectronEnv(baseEnv = process.env) {
for (const key of ELECTRON_SKIP_ENV_KEYS) {
delete env[key];
}
for (const key of NPM_IGNORE_SCRIPTS_ENV_KEYS) {
delete env[key];
}
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;
@@ -67,84 +162,21 @@ function electronBinaryCandidates({ packageRoot, distDir, platform }) {
return candidatePaths;
}
function runNpmRebuild({
packageRoot,
npmExecPath = process.env.npm_execpath || null,
npmNodeExecPath = process.env.npm_node_execpath || process.execPath,
}) {
if (!npmExecPath) {
return false;
}
const result = spawnSync(
npmNodeExecPath,
[npmExecPath, 'rebuild', 'electron', '--force', '--foreground-scripts'],
{
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 rebuild failed with exit code ${result.status ?? 1}`);
}
return true;
}
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,
@@ -153,54 +185,42 @@ function buildMissingElectronError({ packageRoot, distDir, candidatePaths }) {
function resolveElectronBinary({
packageRoot = resolveElectronPackageRoot(),
projectRoot = process.cwd(),
platform = process.platform,
overrideDistPath = process.env.ELECTRON_OVERRIDE_DIST_PATH || null,
} = {}) {
if (!packageRoot) {
const conventionalRoot = path.join(projectRoot, 'node_modules', 'electron');
if (fs.existsSync(path.join(conventionalRoot, 'package.json'))) {
packageRoot = conventionalRoot;
}
}
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(packageRoot, 'dist');
const candidatePaths = electronBinaryCandidates({ packageRoot, distDir, platform });
const resolved = candidatePaths.find((candidate) => fs.existsSync(candidate));
if (!resolved) {
if (packageRoot) {
if (runNpmRebuild({ packageRoot })) {
const rebuilt = electronBinaryCandidates({ packageRoot, distDir, platform }).find((candidate) =>
fs.existsSync(candidate)
);
if (rebuilt) {
return rebuilt;
}
}
if (repairElectronInstall({ packageRoot })) {
const repaired = electronBinaryCandidates({ packageRoot, distDir, platform }).find((candidate) =>
fs.existsSync(candidate)
);
if (repaired) {
return repaired;
}
}
}
throw new Error(buildMissingElectronError({ packageRoot, distDir, candidatePaths }));
if (resolved) {
return resolved;
}
return resolved;
throw new Error(buildMissingElectronError({ packageRoot, distDir, candidatePaths }));
}
module.exports = {
buildMissingElectronError,
electronBinaryCandidates,
readElectronPathHint,
repairElectronInstall,
runNpmRebuild,
sanitizeElectronEnv,
noSandboxExplicitlyAllowed,
linuxSandboxLaunchArgs,
resolveElectronBinary,
resolveElectronPackageRoot,
platformBinaryCandidates,
+1 -1
View File
@@ -223,7 +223,7 @@ function createGuide(store) {
function exportOutputs(store, guideId, root, manifest) {
const ast = buildRenderAst(store, guideId);
const formats = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
const formats = ['json', 'markdown', 'wikijs', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
const outputs = {};
for (const format of formats) {
const outDir = path.join(root, 'sample-exports', format);
+15 -2
View File
@@ -3,7 +3,7 @@
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)"
VERSION="$(node -p "const pkg=require('${ROOT_DIR}/package.json'); pkg.buildVersion || pkg.version" 2>/dev/null || echo 0.0.0)"
OUT_DIR="${STEPFORGE_PACKAGE_DIR:-$ROOT_DIR/build/artifacts}"
mkdir -p "$OUT_DIR"
WORK_DIR="$(mktemp -d "${OUT_DIR%/}/.pkg.XXXXXX")"
@@ -46,8 +46,21 @@ fi
cat > "$WORK_DIR/usr/bin/stepforge" <<'EOF'
#!/usr/bin/env sh
APP_DIR=/opt/stepforge
ELECTRON="$APP_DIR/node_modules/.bin/electron"
SANDBOX_HELPER="$APP_DIR/node_modules/electron/dist/chrome-sandbox"
cd "$APP_DIR" || exit 1
exec "$APP_DIR/node_modules/.bin/electron" "$APP_DIR" "$@"
if command -v stat >/dev/null 2>&1 && [ -e "$SANDBOX_HELPER" ]; then
helper_uid="$(stat -c '%u' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
helper_mode="$(stat -c '%a' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
if [ "$helper_uid" = "0" ] && [ -n "$helper_mode" ]; then
helper_mode_num=$((8#$helper_mode))
if [ $((helper_mode_num & 04000)) -ne 0 ]; then
exec "$ELECTRON" "$APP_DIR" "$@"
fi
fi
fi
printf '%s\n' '[stepforge] Electron sandbox helper is not configured for this install; starting with --no-sandbox' >&2
exec "$ELECTRON" --no-sandbox "$APP_DIR" "$@"
EOF
chmod 0755 "$WORK_DIR/usr/bin/stepforge"
+65 -37
View File
@@ -9,12 +9,10 @@ const { build, Platform } = require('electron-builder');
const ROOT_DIR = path.resolve(__dirname, '..');
const PACKAGE_JSON = require(path.join(ROOT_DIR, 'package.json'));
const RELEASE_DIR = path.resolve(process.env.STEPFORGE_RELEASE_DIR || path.join(ROOT_DIR, 'releases'));
const WORK_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'stepforge-win-'));
const OUTPUT_DIR = path.join(WORK_DIR, 'output');
const ARTIFACT_NAME = 'stepforge-windows-x64-portable.exe';
const APP_ID = 'com.stepforge.app';
const BUILD_VERSION = PACKAGE_JSON.buildVersion || PACKAGE_JSON.version;
function findPortableExe(dir) {
function findInstallerExe(dir) {
if (!fs.existsSync(dir)) return null;
const stack = [dir];
while (stack.length) {
@@ -31,15 +29,13 @@ function findPortableExe(dir) {
return null;
}
async function main() {
fs.mkdirSync(RELEASE_DIR, { recursive: true });
fs.rmSync(OUTPUT_DIR, { recursive: true, force: true });
const config = {
appId: 'com.stepforge.app',
function createWindowsInstallerConfig(outputDir) {
return {
appId: APP_ID,
productName: 'StepForge',
buildVersion: BUILD_VERSION,
directories: {
output: OUTPUT_DIR,
output: outputDir,
},
files: [
'app/**/*',
@@ -49,33 +45,65 @@ async function main() {
],
asar: true,
compression: 'normal',
artifactName: ARTIFACT_NAME,
win: {
target: ['portable'],
target: ['nsis'],
},
nsis: {
oneClick: false,
allowToChangeInstallationDirectory: true,
createDesktopShortcut: true,
createStartMenuShortcut: true,
shortcutName: 'StepForge',
artifactName: '${productName} Setup ${buildVersion}.${ext}',
include: 'build/installer.nsh',
},
};
try {
await build({
targets: Platform.WINDOWS.createTarget('portable'),
config,
});
} catch (err) {
throw new Error(`Windows portable build failed: ${err.message}`);
}
const builtExe = findPortableExe(OUTPUT_DIR);
if (!builtExe) {
throw new Error(`No .exe artifact was produced in ${OUTPUT_DIR}`);
}
const releaseExe = path.join(RELEASE_DIR, path.basename(builtExe));
fs.copyFileSync(builtExe, releaseExe);
console.log(`StepForge ${PACKAGE_JSON.version} Windows portable build written to ${releaseExe}`);
}
main().catch((err) => {
console.error(err.message || err);
process.exitCode = 1;
});
function createWindowsInstallerBuildOptions(outputDir) {
return {
targets: Platform.WINDOWS.createTarget('nsis'),
config: createWindowsInstallerConfig(outputDir),
publish: 'never',
};
}
async function buildWindowsInstaller() {
const releaseDir = path.resolve(process.env.STEPFORGE_RELEASE_DIR || path.join(ROOT_DIR, 'releases'));
const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'stepforge-win-'));
const outputDir = path.join(workDir, 'output');
fs.mkdirSync(releaseDir, { recursive: true });
fs.rmSync(outputDir, { recursive: true, force: true });
try {
await build(createWindowsInstallerBuildOptions(outputDir));
} catch (err) {
throw new Error(`Windows installer build failed: ${err.message}`);
}
const builtInstaller = findInstallerExe(outputDir);
if (!builtInstaller) {
throw new Error(`No installer .exe artifact was produced in ${outputDir}`);
}
const releaseInstaller = path.join(releaseDir, path.basename(builtInstaller));
fs.copyFileSync(builtInstaller, releaseInstaller);
console.log(`StepForge ${BUILD_VERSION} Windows installer written to ${releaseInstaller}`);
}
if (require.main === module) {
buildWindowsInstaller().catch((err) => {
console.error(err.message || err);
process.exitCode = 1;
});
}
module.exports = {
APP_ID,
createWindowsInstallerConfig,
createWindowsInstallerBuildOptions,
findInstallerExe,
buildWindowsInstaller,
};
+51
View File
@@ -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);
+52
View File
@@ -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 };
+23 -2
View File
@@ -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,
+20 -6
View File
@@ -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
View File
@@ -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
@@ -21,7 +21,7 @@ for f in sample-manifest.json sample-guide.sfgz; do
done
for dir in sample-data sample-exports/json sample-exports/markdown sample-exports/html-simple \
sample-exports/html-rich sample-exports/pdf sample-exports/gif \
sample-exports/wikijs sample-exports/html-rich sample-exports/pdf sample-exports/gif \
sample-exports/image-bundle sample-exports/docx sample-exports/pptx; do
if ! find "$SAMPLE_ROOT/$dir" -type f -print -quit | grep -q .; then
echo "Sample export directory is empty: $dir" >&2
@@ -34,7 +34,7 @@ const fs = require('node:fs');
const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_FILE, 'utf8'));
if (manifest.format !== 'stepforge-sample-manifest') throw new Error('unexpected sample manifest format');
if (!manifest.guideId) throw new Error('missing guideId');
if (!manifest.exports || Object.keys(manifest.exports).length < 9) throw new Error('missing sample exports');
if (!manifest.exports || Object.keys(manifest.exports).length < 10) throw new Error('missing sample exports');
NODE
echo "sample artifacts OK"

Some files were not shown because too many files have changed in this diff Show More