A shell script, .desktop, .rules, or .spec file checked out with CRLF (Windows
with core.autocrlf=true) breaks when run/parsed on Linux — a
`#!/usr/bin/env bash\r` shebang is a "bad interpreter" error, and the Linux
package would ship broken scripts. Pin these (and other text) to LF, and mark
binary assets (png/gz/traineddata) so they are never converted. Also harden
the packaging/wayland structural tests to strip CR defensively.
Co-Authored-By: Claude Fable 5 <[email protected]>
The wayland-honesty guard stripped comments per line to check usermod is never
run, but the comment-strip regex depended on `.`/`$` behavior that breaks on
Windows CRLF checkouts (`.` does not cross `\r`), so the comment's cautionary
usermod mention was read as a command and the test failed only on windows-
latest CI. Check command position (line start, optional sudo) in multiline
mode instead — line-ending agnostic.
Co-Authored-By: Claude Fable 5 <[email protected]>
Phase 3 of the improvement plan (PR 10 of the sequence): honest Wayland
behavior and a least-privilege alternative to the broad `input` group the
audit flagged as a keylogging surface.
Least-privilege input access:
- packaging/linux/common/60-stepforge-input.rules grants the ACTIVE session
read access to MOUSE devices only (ID_INPUT_MOUSE, excluding
ID_INPUT_KEYBOARD) via a systemd uaccess ACL — session-scoped, device-
scoped, and never keyboards. This replaces `usermod -aG input`, which grants
every input device (keyboards included) to the user permanently.
- scripts/linux/enable-click-capture.sh installs it opt-in: it prints the exact
rule, requires confirmation, and documents the security tradeoff. It never
runs the broad-group command.
Honest trigger reporting:
- New chooseCaptureTrigger() (app/platform/linux/diagnostics.js, re-exported
from app/platform/index.js) maps a capability profile to the real trigger:
per-click with a marker on X11+xinput; per-click WITHOUT coordinates or a
marker on Wayland evdev (the platform exposes no pointer position); and an
honest hotkey/interval fallback otherwise. It never promises per-click
capture with coordinates on Wayland.
- platform:capabilities now includes the active trigger for the machine and
the user's fallback setting, for the diagnostics UI.
Docs:
- GETTING_STARTED_WITH_LINUX.md no longer instructs every Wayland user to join
the `input` group; it presents that as a warning, documents the least-
privilege script, corrects the capability table (per-click is opt-in, mice
only; hotkey/interval is the default Wayland trigger), and points at
Settings → Diagnostics.
Tests: trigger decisions for X11/xinput, Wayland evdev (no coordinates/marker),
Wayland fallback (interval/hotkey with an honest note), the platform facade
wiring, Windows; the udev rule (mouse-only, excludes keyboards, uaccess); the
opt-in enable script (confirms, installs the rule, never runs usermod -aG
input as a command); and docs guards. 289 unit tests pass; startup smoke and
click self-test unchanged (stream, markers 3/3, burst 8/8).
Co-Authored-By: Claude Fable 5 <[email protected]>
Phase 3 of the improvement plan (PR 9 of the sequence): the dnf/Fedora
packaging half, in separate Linux-specific files, mirroring the apt/.deb work.
- packaging/linux/common/stage-runtime.sh: shared runtime-only payload staging
extracted from the .deb builder so the two package formats never drift. It
copies app code + a fixed Electron runtime + production npm deps (npm ls
--omit=dev), guards against dev-dep leaks, and fails without node_modules.
The .deb builder now delegates to it.
- packaging/linux/fedora/stepforge.spec: runtime Requires (nss, nspr, gtk3,
…), Recommends (xinput, portal, pipewire), %license, and a %post that makes
chrome-sandbox setuid and refreshes desktop/MIME/icon caches. No compilation
— it packages the prebuilt BuildRoot.
- packaging/linux/fedora/package.sh: stages the shared payload, substitutes
version/maintainer into the spec, and runs rpmbuild against the prebuilt
BuildRoot with detected arch. Requires rpmbuild + node_modules; emits an
.rpm + sha256.
- scripts/linux/dnf/install-runtime-deps.sh and install-build-deps.sh:
separate runtime vs build dependency sets for dnf (runtime installs no build
tools).
- docs/linux/dnf.md: Fedora/RHEL install + build guide, distinct from apt.md.
Tests: packaging-linux.test.js gains Fedora/dnf structural checks (spec
Requires + MPL-2.0 + %license + sandbox setup, builder shares staging +
requires rpmbuild, dnf build/runtime dep separation, shared staging never
copies the whole dev tree). tests/integration/linux/package-rpm.test.sh builds
a real .rpm and asserts runtime-only contents (honest skip when rpmbuild is
absent, as on this apt host).
Verified: 13 packaging unit tests pass; the refactored .deb builder still
produces a valid runtime-only package (integration test green); the .rpm
integration test skips cleanly where rpmbuild is unavailable.
Co-Authored-By: Claude Fable 5 <[email protected]>
Phase 3 of the improvement plan (PR 8 of the sequence): the apt/X11 packaging
half of Linux support, in separate Linux-specific files. Replaces the old
scripts/package-linux.sh, which the audit flagged as "not production
packaging" (it copied the whole dev node_modules — including vulnerable build
deps — plus docs/prompts/examples/audit files, hardcoded amd64, declared only
xinput, lacked desktop/icon/MIME integration, and could build without
node_modules).
Production builder (packaging/linux/debian/package.sh):
- Stages ONLY runtime files: app code, a fixed Electron runtime, and the
production npm deps (enumerated via npm ls --omit=dev). Never copies the
development node_modules; guards against electron-builder/app-builder-lib
leaking in. Fails if node_modules is absent instead of shipping an unusable
artifact.
- Detects architecture (dpkg --print-architecture, x64/arm64) rather than
hardcoding amd64. Generates DEBIAN/control from control.in with proper
runtime Depends, real maintainer, and homepage.
- Installs a desktop entry, hicolor icons (16–512), .sfgz/.sfglt MIME
registration, the launcher, and the license. postinst makes chrome-sandbox
setuid and refreshes desktop/MIME/icon caches; postrm cleans them.
- Emits a .deb, a portable tarball that now INCLUDES /usr/bin/stepforge (the
old tarball omitted it), and a sha256 sums file.
Launcher (packaging/linux/common/launcher.sh):
- Runs sandboxed; prefers the user-namespace sandbox, accepts a root-owned
setuid helper, and otherwise refuses to launch with an actionable message.
--no-sandbox requires an explicit STEPFORGE_ALLOW_NO_SANDBOX opt-in. Never
installs anything at runtime.
Setup (separate build vs runtime, apt only):
- scripts/linux/apt/install-runtime-deps.sh (Chromium/Electron libs, X11
tools, portal/PipeWire) and install-build-deps.sh (dpkg-dev, fakeroot,
xvfb). Runtime script installs no build tools.
Assets: original StepForge icon — packaging/assets/stepforge.svg plus a
generator (scripts/make-icons.js) that renders the PNG set with the repo's own
rasterizer/PNG writer (no third-party art). npm run icons regenerates them.
Wiring: package.json gains package:linux:deb / package:linux:rpm / icons;
build-release.sh uses the production builder and requires node_modules; README
points at the apt/dnf guides.
Tests: tests/unit/packaging-linux.test.js (structural: files present in their
separate locations, old script gone, valid desktop entry, templated arch +
runtime Depends, launcher gates --no-sandbox, builder requires node_modules
and guards dev-dep leaks, apt build/runtime dep separation, original icon set
generates a valid PNG) runs in the normal suite;
tests/integration/linux/package-deb.test.sh builds a real .deb and asserts the
right files present and the dev tree / build tooling / app docs absent
(honest skip only when dpkg-deb/node_modules are genuinely missing).
Verified locally: 276 unit tests pass; the integration test builds and
validates stepforge_0.3.2_amd64.deb; build-release E2E passes with the new
production package.
Co-Authored-By: Claude Fable 5 <[email protected]>
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]>
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]>
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]>
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]>
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]>
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]>
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]>
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.
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]>
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]>
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]>
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]>
**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]>
**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]>
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]>
**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]>