Author SHA1 Message Date
TylerandClaude Fable 5 07e6191cc1 Reconcile the license to CC BY-NC 4.0 (owner decision)
Template tests / tests (pull_request) Failing after 32s
The repo contradicted itself — package.json/CONTRIBUTING said MPL-2.0 (permits
commercial use) while README/docs/LICENSE said Creative Commons NonCommercial
(forbids it), with no root LICENSE. The owner chose Creative Commons
Attribution-NonCommercial 4.0 International; this makes every surface agree.

- Add a root LICENSE with the full official CC BY-NC 4.0 legal text plus a
  StepForge copyright header and SPDX identifier. Packages already ship it as
  the copyright file (verified in the built .deb).
- package.json license -> CC-BY-NC-4.0; RPM spec License -> CC-BY-NC-4.0.
- docs/LICENSE becomes a short pointer to the root LICENSE (no more competing
  paraphrase); README and CONTRIBUTING state CC BY-NC 4.0 (contributions
  licensed under the same license + DCO sign-off for provenance).
- app:info now reports the license so the About view can't contradict.
- New tests/unit/license.test.js guards consistency: root LICENSE present with
  the full legal text, package.json is CC-BY-NC-4.0, no shipping surface
  mentions MPL, and the story matches across README/CONTRIBUTING/docs/spec.
  Updated the RPM-spec packaging test accordingly.

Note: the historical planning archives under ai_prompts/ still mention the old
MPL target; they are point-in-time design notes, not the license, so they are
left as-is.

Verified: 289 unit tests pass; the built .deb ships the CC BY-NC 4.0 text as
its copyright file.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-04 14:17:42 -05:00
Tyler 8ddfe1b3e1 update ai prompt
Template tests / tests (push) Failing after 32s
2026-07-04 14:06:54 -05:00
Tyler cc724f894b Merge pull request #14 from Twest2/StepForge pr/10-wayland-honesty
Template tests / tests (push) Failing after 33s
Make Wayland capture honest and replace the broad input group with least privilege (plan PR 10)
2026-07-04 11:53:46 -05:00
TylerandClaude Fable 5 4f57cfacba Enforce LF line endings for Unix artifacts via .gitattributes
Template tests / tests (pull_request) Failing after 32s
A shell script, .desktop, .rules, or .spec file checked out with CRLF (Windows
with core.autocrlf=true) breaks when run/parsed on Linux — a
`#!/usr/bin/env bash\r` shebang is a "bad interpreter" error, and the Linux
package would ship broken scripts. Pin these (and other text) to LF, and mark
binary assets (png/gz/traineddata) so they are never converted. Also harden
the packaging/wayland structural tests to strip CR defensively.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:44:45 -05:00
TylerandClaude Fable 5 8c39e8db4e Make the enable-script guard robust to CRLF line endings
Template tests / tests (pull_request) Failing after 3m22s
The wayland-honesty guard stripped comments per line to check usermod is never
run, but the comment-strip regex depended on `.`/`$` behavior that breaks on
Windows CRLF checkouts (`.` does not cross `\r`), so the comment's cautionary
usermod mention was read as a command and the test failed only on windows-
latest CI. Check command position (line start, optional sudo) in multiline
mode instead — line-ending agnostic.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:42:45 -05:00
TylerandClaude Fable 5 2c0b8b021e Make Wayland capture honest and replace the broad input group with least privilege
Template tests / tests (pull_request) Failing after 33s
Phase 3 of the improvement plan (PR 10 of the sequence): honest Wayland
behavior and a least-privilege alternative to the broad `input` group the
audit flagged as a keylogging surface.

Least-privilege input access:
- packaging/linux/common/60-stepforge-input.rules grants the ACTIVE session
  read access to MOUSE devices only (ID_INPUT_MOUSE, excluding
  ID_INPUT_KEYBOARD) via a systemd uaccess ACL — session-scoped, device-
  scoped, and never keyboards. This replaces `usermod -aG input`, which grants
  every input device (keyboards included) to the user permanently.
- scripts/linux/enable-click-capture.sh installs it opt-in: it prints the exact
  rule, requires confirmation, and documents the security tradeoff. It never
  runs the broad-group command.

Honest trigger reporting:
- New chooseCaptureTrigger() (app/platform/linux/diagnostics.js, re-exported
  from app/platform/index.js) maps a capability profile to the real trigger:
  per-click with a marker on X11+xinput; per-click WITHOUT coordinates or a
  marker on Wayland evdev (the platform exposes no pointer position); and an
  honest hotkey/interval fallback otherwise. It never promises per-click
  capture with coordinates on Wayland.
- platform:capabilities now includes the active trigger for the machine and
  the user's fallback setting, for the diagnostics UI.

Docs:
- GETTING_STARTED_WITH_LINUX.md no longer instructs every Wayland user to join
  the `input` group; it presents that as a warning, documents the least-
  privilege script, corrects the capability table (per-click is opt-in, mice
  only; hotkey/interval is the default Wayland trigger), and points at
  Settings → Diagnostics.

Tests: trigger decisions for X11/xinput, Wayland evdev (no coordinates/marker),
Wayland fallback (interval/hotkey with an honest note), the platform facade
wiring, Windows; the udev rule (mouse-only, excludes keyboards, uaccess); the
opt-in enable script (confirms, installs the rule, never runs usermod -aG
input as a command); and docs guards. 289 unit tests pass; startup smoke and
click self-test unchanged (stream, markers 3/3, burst 8/8).

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:39:59 -05:00
Tyler 7827ef3ad2 Merge pull request #13 from Twest2/StepForge pr/09-linux-dnf-rpm
Template tests / tests (push) Failing after 34s
Add production .rpm packaging and dnf setup; share runtime staging (plan PR 9)
2026-07-03 23:35:32 -05:00
TylerandClaude Fable 5 575d893f90 Add production .rpm packaging and dnf setup; share runtime staging
Template tests / tests (pull_request) Failing after 33s
Phase 3 of the improvement plan (PR 9 of the sequence): the dnf/Fedora
packaging half, in separate Linux-specific files, mirroring the apt/.deb work.

- packaging/linux/common/stage-runtime.sh: shared runtime-only payload staging
  extracted from the .deb builder so the two package formats never drift. It
  copies app code + a fixed Electron runtime + production npm deps (npm ls
  --omit=dev), guards against dev-dep leaks, and fails without node_modules.
  The .deb builder now delegates to it.
- packaging/linux/fedora/stepforge.spec: runtime Requires (nss, nspr, gtk3,
  …), Recommends (xinput, portal, pipewire), %license, and a %post that makes
  chrome-sandbox setuid and refreshes desktop/MIME/icon caches. No compilation
  — it packages the prebuilt BuildRoot.
- packaging/linux/fedora/package.sh: stages the shared payload, substitutes
  version/maintainer into the spec, and runs rpmbuild against the prebuilt
  BuildRoot with detected arch. Requires rpmbuild + node_modules; emits an
  .rpm + sha256.
- scripts/linux/dnf/install-runtime-deps.sh and install-build-deps.sh:
  separate runtime vs build dependency sets for dnf (runtime installs no build
  tools).
- docs/linux/dnf.md: Fedora/RHEL install + build guide, distinct from apt.md.

Tests: packaging-linux.test.js gains Fedora/dnf structural checks (spec
Requires + MPL-2.0 + %license + sandbox setup, builder shares staging +
requires rpmbuild, dnf build/runtime dep separation, shared staging never
copies the whole dev tree). tests/integration/linux/package-rpm.test.sh builds
a real .rpm and asserts runtime-only contents (honest skip when rpmbuild is
absent, as on this apt host).

Verified: 13 packaging unit tests pass; the refactored .deb builder still
produces a valid runtime-only package (integration test green); the .rpm
integration test skips cleanly where rpmbuild is unavailable.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-03 23:33:11 -05:00
Tyler 92146c760d Merge pull request #12 from Twest2/StepForge pr/08-linux-apt-deb
Template tests / tests (push) Failing after 32s
Add production .deb packaging, apt setup, desktop integration, and icons (plan PR 8)
2026-07-03 23:32:54 -05:00
24 changed files with 1272 additions and 90 deletions
+24
View File
@@ -0,0 +1,24 @@
# Normalize line endings. Unix artifacts MUST stay LF: a shell script or
# desktop/udev/spec file checked out with CRLF (e.g. on Windows with
# core.autocrlf=true) fails to run on Linux — `#!/usr/bin/env bash\r` is a
# "bad interpreter" error, and .desktop/.rules parsers choke on trailing \r.
* text=auto eol=lf
*.sh text eol=lf
*.desktop text eol=lf
*.rules text eol=lf
*.spec text eol=lf
*.xml text eol=lf
control.in text eol=lf
*.js text eol=lf
*.mjs text eol=lf
*.cjs text eol=lf
*.json text eol=lf
*.md text eol=lf
launcher.sh text eol=lf
# Binary assets must never be line-ending converted.
*.png binary
*.ico binary
*.gz binary
*.traineddata binary
+426
View File
@@ -0,0 +1,426 @@
StepForge
Copyright (c) 2026 Tyler Westbrook
This work is licensed under the Creative Commons
Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0).
You are free to use, copy, modify, and share this software for
NON-COMMERCIAL purposes, with attribution. You may NOT use it for
commercial purposes without separate written permission from the
copyright holder.
License summary: https://creativecommons.org/licenses/by-nc/4.0/
SPDX-License-Identifier: CC-BY-NC-4.0
The full legal text of the license follows.
=======================================================================
Attribution-NonCommercial 4.0 International
=======================================================================
Creative Commons Corporation ("Creative Commons") is not a law firm and
does not provide legal services or legal advice. Distribution of
Creative Commons public licenses does not create a lawyer-client or
other relationship. Creative Commons makes its licenses and related
information available on an "as-is" basis. Creative Commons gives no
warranties regarding its licenses, any material licensed under their
terms and conditions, or any related information. Creative Commons
disclaims all liability for damages resulting from their use to the
fullest extent possible.
Using Creative Commons Public Licenses
Creative Commons public licenses provide a standard set of terms and
conditions that creators and other rights holders may use to share
original works of authorship and other material subject to copyright
and certain other rights specified in the public license below. The
following considerations are for informational purposes only, are not
exhaustive, and do not form part of our licenses.
Considerations for licensors: Our public licenses are
intended for use by those authorized to give the public
permission to use material in ways otherwise restricted by
copyright and certain other rights. Our licenses are
irrevocable. Licensors should read and understand the terms
and conditions of the license they choose before applying it.
Licensors should also secure all rights necessary before
applying our licenses so that the public can reuse the
material as expected. Licensors should clearly mark any
material not subject to the license. This includes other CC-
licensed material, or material used under an exception or
limitation to copyright. More considerations for licensors:
wiki.creativecommons.org/Considerations_for_licensors
Considerations for the public: By using one of our public
licenses, a licensor grants the public permission to use the
licensed material under specified terms and conditions. If
the licensor's permission is not necessary for any reason--for
example, because of any applicable exception or limitation to
copyright--then that use is not regulated by the license. Our
licenses grant only permissions under copyright and certain
other rights that a licensor has authority to grant. Use of
the licensed material may still be restricted for other
reasons, including because others have copyright or other
rights in the material. A licensor may make special requests,
such as asking that all changes be marked or described.
Although not required by our licenses, you are encouraged to
respect those requests where reasonable. More considerations
for the public:
wiki.creativecommons.org/Considerations_for_licensees
=======================================================================
Creative Commons Attribution-NonCommercial 4.0 International Public
License
By exercising the Licensed Rights (defined below), You accept and agree
to be bound by the terms and conditions of this Creative Commons
Attribution-NonCommercial 4.0 International Public License ("Public
License"). To the extent this Public License may be interpreted as a
contract, You are granted the Licensed Rights in consideration of Your
acceptance of these terms and conditions, and the Licensor grants You
such rights in consideration of benefits the Licensor receives from
making the Licensed Material available under these terms and
conditions.
Section 1 -- Definitions.
a. Adapted Material means material subject to Copyright and Similar
Rights that is derived from or based upon the Licensed Material
and in which the Licensed Material is translated, altered,
arranged, transformed, or otherwise modified in a manner requiring
permission under the Copyright and Similar Rights held by the
Licensor. For purposes of this Public License, where the Licensed
Material is a musical work, performance, or sound recording,
Adapted Material is always produced where the Licensed Material is
synched in timed relation with a moving image.
b. Adapter's License means the license You apply to Your Copyright
and Similar Rights in Your contributions to Adapted Material in
accordance with the terms and conditions of this Public License.
c. Copyright and Similar Rights means copyright and/or similar rights
closely related to copyright including, without limitation,
performance, broadcast, sound recording, and Sui Generis Database
Rights, without regard to how the rights are labeled or
categorized. For purposes of this Public License, the rights
specified in Section 2(b)(1)-(2) are not Copyright and Similar
Rights.
d. Effective Technological Measures means those measures that, in the
absence of proper authority, may not be circumvented under laws
fulfilling obligations under Article 11 of the WIPO Copyright
Treaty adopted on December 20, 1996, and/or similar international
agreements.
e. Exceptions and Limitations means fair use, fair dealing, and/or
any other exception or limitation to Copyright and Similar Rights
that applies to Your use of the Licensed Material.
f. Licensed Material means the artistic or literary work, database,
or other material to which the Licensor applied this Public
License.
g. Licensed Rights means the rights granted to You subject to the
terms and conditions of this Public License, which are limited to
all Copyright and Similar Rights that apply to Your use of the
Licensed Material and that the Licensor has authority to license.
h. Licensor means the individual(s) or entity(ies) granting rights
under this Public License.
i. NonCommercial means not primarily intended for or directed towards
commercial advantage or monetary compensation. For purposes of
this Public License, the exchange of the Licensed Material for
other material subject to Copyright and Similar Rights by digital
file-sharing or similar means is NonCommercial provided there is
no payment of monetary compensation in connection with the
exchange.
j. Share means to provide material to the public by any means or
process that requires permission under the Licensed Rights, such
as reproduction, public display, public performance, distribution,
dissemination, communication, or importation, and to make material
available to the public including in ways that members of the
public may access the material from a place and at a time
individually chosen by them.
k. Sui Generis Database Rights means rights other than copyright
resulting from Directive 96/9/EC of the European Parliament and of
the Council of 11 March 1996 on the legal protection of databases,
as amended and/or succeeded, as well as other essentially
equivalent rights anywhere in the world.
l. You means the individual or entity exercising the Licensed Rights
under this Public License. Your has a corresponding meaning.
Section 2 -- Scope.
a. License grant.
1. Subject to the terms and conditions of this Public License,
the Licensor hereby grants You a worldwide, royalty-free,
non-sublicensable, non-exclusive, irrevocable license to
exercise the Licensed Rights in the Licensed Material to:
a. reproduce and Share the Licensed Material, in whole or
in part, for NonCommercial purposes only; and
b. produce, reproduce, and Share Adapted Material for
NonCommercial purposes only.
2. Exceptions and Limitations. For the avoidance of doubt, where
Exceptions and Limitations apply to Your use, this Public
License does not apply, and You do not need to comply with
its terms and conditions.
3. Term. The term of this Public License is specified in Section
6(a).
4. Media and formats; technical modifications allowed. The
Licensor authorizes You to exercise the Licensed Rights in
all media and formats whether now known or hereafter created,
and to make technical modifications necessary to do so. The
Licensor waives and/or agrees not to assert any right or
authority to forbid You from making technical modifications
necessary to exercise the Licensed Rights, including
technical modifications necessary to circumvent Effective
Technological Measures. For purposes of this Public License,
simply making modifications authorized by this Section 2(a)
(4) never produces Adapted Material.
5. Downstream recipients.
a. Offer from the Licensor -- Licensed Material. Every
recipient of the Licensed Material automatically
receives an offer from the Licensor to exercise the
Licensed Rights under the terms and conditions of this
Public License.
b. No downstream restrictions. You may not offer or impose
any additional or different terms or conditions on, or
apply any Effective Technological Measures to, the
Licensed Material if doing so restricts exercise of the
Licensed Rights by any recipient of the Licensed
Material.
6. No endorsement. Nothing in this Public License constitutes or
may be construed as permission to assert or imply that You
are, or that Your use of the Licensed Material is, connected
with, or sponsored, endorsed, or granted official status by,
the Licensor or others designated to receive attribution as
provided in Section 3(a)(1)(A)(i).
b. Other rights.
1. Moral rights, such as the right of integrity, are not
licensed under this Public License, nor are publicity,
privacy, and/or other similar personality rights; however, to
the extent possible, the Licensor waives and/or agrees not to
assert any such rights held by the Licensor to the limited
extent necessary to allow You to exercise the Licensed
Rights, but not otherwise.
2. Patent and trademark rights are not licensed under this
Public License.
3. To the extent possible, the Licensor waives any right to
collect royalties from You for the exercise of the Licensed
Rights, whether directly or through a collecting society
under any voluntary or waivable statutory or compulsory
licensing scheme. In all other cases the Licensor expressly
reserves any right to collect such royalties, including when
the Licensed Material is used other than for NonCommercial
purposes.
Section 3 -- License Conditions.
Your exercise of the Licensed Rights is expressly made subject to the
following conditions.
a. Attribution.
1. If You Share the Licensed Material (including in modified
form), You must:
a. retain the following if it is supplied by the Licensor
with the Licensed Material:
i. identification of the creator(s) of the Licensed
Material and any others designated to receive
attribution, in any reasonable manner requested by
the Licensor (including by pseudonym if
designated);
ii. a copyright notice;
iii. a notice that refers to this Public License;
iv. a notice that refers to the disclaimer of
warranties;
v. a URI or hyperlink to the Licensed Material to the
extent reasonably practicable;
b. indicate if You modified the Licensed Material and
retain an indication of any previous modifications; and
c. indicate the Licensed Material is licensed under this
Public License, and include the text of, or the URI or
hyperlink to, this Public License.
2. You may satisfy the conditions in Section 3(a)(1) in any
reasonable manner based on the medium, means, and context in
which You Share the Licensed Material. For example, it may be
reasonable to satisfy the conditions by providing a URI or
hyperlink to a resource that includes the required
information.
3. If requested by the Licensor, You must remove any of the
information required by Section 3(a)(1)(A) to the extent
reasonably practicable.
4. If You Share Adapted Material You produce, the Adapter's
License You apply must not prevent recipients of the Adapted
Material from complying with this Public License.
Section 4 -- Sui Generis Database Rights.
Where the Licensed Rights include Sui Generis Database Rights that
apply to Your use of the Licensed Material:
a. for the avoidance of doubt, Section 2(a)(1) grants You the right
to extract, reuse, reproduce, and Share all or a substantial
portion of the contents of the database for NonCommercial purposes
only;
b. if You include all or a substantial portion of the database
contents in a database in which You have Sui Generis Database
Rights, then the database in which You have Sui Generis Database
Rights (but not its individual contents) is Adapted Material; and
c. You must comply with the conditions in Section 3(a) if You Share
all or a substantial portion of the contents of the database.
For the avoidance of doubt, this Section 4 supplements and does not
replace Your obligations under this Public License where the Licensed
Rights include other Copyright and Similar Rights.
Section 5 -- Disclaimer of Warranties and Limitation of Liability.
a. UNLESS OTHERWISE SEPARATELY UNDERTAKEN BY THE LICENSOR, TO THE
EXTENT POSSIBLE, THE LICENSOR OFFERS THE LICENSED MATERIAL AS-IS
AND AS-AVAILABLE, AND MAKES NO REPRESENTATIONS OR WARRANTIES OF
ANY KIND CONCERNING THE LICENSED MATERIAL, WHETHER EXPRESS,
IMPLIED, STATUTORY, OR OTHER. THIS INCLUDES, WITHOUT LIMITATION,
WARRANTIES OF TITLE, MERCHANTABILITY, FITNESS FOR A PARTICULAR
PURPOSE, NON-INFRINGEMENT, ABSENCE OF LATENT OR OTHER DEFECTS,
ACCURACY, OR THE PRESENCE OR ABSENCE OF ERRORS, WHETHER OR NOT
KNOWN OR DISCOVERABLE. WHERE DISCLAIMERS OF WARRANTIES ARE NOT
ALLOWED IN FULL OR IN PART, THIS DISCLAIMER MAY NOT APPLY TO YOU.
b. TO THE EXTENT POSSIBLE, IN NO EVENT WILL THE LICENSOR BE LIABLE
TO YOU ON ANY LEGAL THEORY (INCLUDING, WITHOUT LIMITATION,
NEGLIGENCE) OR OTHERWISE FOR ANY DIRECT, SPECIAL, INDIRECT,
INCIDENTAL, CONSEQUENTIAL, PUNITIVE, EXEMPLARY, OR OTHER LOSSES,
COSTS, EXPENSES, OR DAMAGES ARISING OUT OF THIS PUBLIC LICENSE OR
USE OF THE LICENSED MATERIAL, EVEN IF THE LICENSOR HAS BEEN
ADVISED OF THE POSSIBILITY OF SUCH LOSSES, COSTS, EXPENSES, OR
DAMAGES. WHERE A LIMITATION OF LIABILITY IS NOT ALLOWED IN FULL OR
IN PART, THIS LIMITATION MAY NOT APPLY TO YOU.
c. The disclaimer of warranties and limitation of liability provided
above shall be interpreted in a manner that, to the extent
possible, most closely approximates an absolute disclaimer and
waiver of all liability.
Section 6 -- Term and Termination.
a. This Public License applies for the term of the Copyright and
Similar Rights licensed here. However, if You fail to comply with
this Public License, then Your rights under this Public License
terminate automatically.
b. Where Your right to use the Licensed Material has terminated under
Section 6(a), it reinstates:
1. automatically as of the date the violation is cured, provided
it is cured within 30 days of Your discovery of the
violation; or
2. upon express reinstatement by the Licensor.
For the avoidance of doubt, this Section 6(b) does not affect any
right the Licensor may have to seek remedies for Your violations
of this Public License.
c. For the avoidance of doubt, the Licensor may also offer the
Licensed Material under separate terms or conditions or stop
distributing the Licensed Material at any time; however, doing so
will not terminate this Public License.
d. Sections 1, 5, 6, 7, and 8 survive termination of this Public
License.
Section 7 -- Other Terms and Conditions.
a. The Licensor shall not be bound by any additional or different
terms or conditions communicated by You unless expressly agreed.
b. Any arrangements, understandings, or agreements regarding the
Licensed Material not stated herein are separate from and
independent of the terms and conditions of this Public License.
Section 8 -- Interpretation.
a. For the avoidance of doubt, this Public License does not, and
shall not be interpreted to, reduce, limit, restrict, or impose
conditions on any use of the Licensed Material that could lawfully
be made without permission under this Public License.
b. To the extent possible, if any provision of this Public License is
deemed unenforceable, it shall be automatically reformed to the
minimum extent necessary to make it enforceable. If the provision
cannot be reformed, it shall be severed from this Public License
without affecting the enforceability of the remaining terms and
conditions.
c. No term or condition of this Public License will be waived and no
failure to comply consented to unless expressly agreed to by the
Licensor.
d. Nothing in this Public License constitutes or may be interpreted
as a limitation upon, or waiver of, any privileges and immunities
that apply to the Licensor or You, including from the legal
processes of any jurisdiction or authority.
=======================================================================
Creative Commons is not a party to its public
licenses. Notwithstanding, Creative Commons may elect to apply one of
its public licenses to material it publishes and in those instances
will be considered the “Licensor.” The text of the Creative Commons
public licenses is dedicated to the public domain under the CC0 Public
Domain Dedication. Except for the limited purpose of indicating that
material is shared under a Creative Commons public license or as
otherwise permitted by the Creative Commons policies published at
creativecommons.org/policies, Creative Commons does not authorize the
use of the trademark "Creative Commons" or any other trademark or logo
of Creative Commons without its prior written consent including,
without limitation, in connection with any unauthorized modifications
to any of its public licenses or any other arrangements,
understandings, or agreements concerning use of licensed material. For
the avoidance of doubt, this paragraph does not form part of the
public licenses.
Creative Commons may be contacted at creativecommons.org.
+6 -2
View File
@@ -144,6 +144,10 @@ See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the repo layout.
## License ## License
Creative Commons Attribution-NonCommercial StepForge is licensed under the **Creative Commons Attribution-NonCommercial
4.0 International License (CC BY-NC 4.0)**. See the root [LICENSE](LICENSE) for
the full terms.
Basically, do whatever you want with it just don't make money off it or sell it. In plain terms: you're free to use, modify, and share it for **non-commercial**
purposes, with attribution — but you may not sell it or use it commercially
without written permission from the copyright holder.
+33
View File
@@ -1,3 +1,36 @@
# After action report
## Done — 10 PRs implemented, merged, and CI-green on both forges
I worked through ai_prompts/prompt4.md and shipped PRs 110 of the plan's recommended sequence. Each was a focused branch, opened on both GitHub (Twest2/StepForge #5#14) and Gitea (Tyler/autodoc #21#30), verified green on GitHub CI (Ubuntu + Windows + dependency audit), merged with the same SHA to both forges, and the Gitea mirror closed with a merge note. Final state: main is byte-identical across local, GitHub, and Gitea (cc724f8), main CI is green, no open PRs, 289 unit tests pass (0 fail).
## PR What it fixed
1 Pinned Node ≥22.12 (.nvmrc/engines); removed all runtime npm self-repair; refuse silent --no-sandbox; CI on pull_request + prod/full audits; made the click E2E stop hiding startup crashes as "SKIPPED"; remediated the form-data/undici advisories
2 Closed the renderer privilege boundary: navigation/popup denial, sandboxed windows, per-channel IPC sender+argument validation, deny-by-default permissions (display capture only for the capture worker), intent-specific shell access replacing arbitrary shell:openPath
3 Truthful local-first AI/privacy contract: raw keystroke capture off by default, AbortController timeouts + cancellation + concurrency + image-size limits, loopback-only Ollama unless explicitly opted in, honest docs + new docs/PRIVACY.md
4 Optimistic revision fields + compare-and-swap (stale AI responses can't clobber edits), autosave keeps dirty state on failed saves, corrupt guides/steps quarantined instead of silently vanishing
5 Region-capture nested-result + listener-leak bugs, single-owner power blocker, explicit click-source reporting (evdev fix), strict-mode skips instead of storing post-click shots, pathToFileURL, shutdown drain
6 ZIP-bomb limits, transactional archive import, atomic snapshot restore, exclusive locks (O_EXCL + token), search index reconcile at startup, implemented the dead automatic-backup settings
7 Platform adapter layer — window-context detection extracted to app/platform/{windows,linux,darwin}/; shared code no longer branches on process.platform; Linux capability diagnostics
8 Production .deb (runtime-only staging, no dev tree, detected arch, desktop/MIME/icons, original generated icon set), apt setup scripts, real launcher; retired the old broken packager
9 Production .rpm + dnf setup, shared stage-runtime.sh so both formats never drift
10 Honest Wayland triggers (never promises per-click coordinates it can't deliver) + a least-privilege mouse-only udev rule replacing the broad input group keylogging surface; .gitattributes so Unix scripts don't break on CRLF
I verified real behavior where possible: built and inspected an actual 107 MB .deb, ran the click self-test (markers 3/3, burst 8/8), and confirmed the sandboxed app boots.
## One blocking item I did not act on — the license (needs your decision)
The plan calls this a legal release blocker that an implementation agent must not guess, and I agree. The repo currently contradicts itself:
package.json → MPL-2.0 and CONTRIBUTING requires MPL-2.0 + DCO sign-off (permits commercial use)
docs/LICENSE + README §License → Creative Commons Attribution-NonCommercial (forbids commercial use; also not a software license)
there is no root LICENSE file
MPL-2.0 and CC-BY-NC are mutually exclusive, and contributors have been signing off under a license the project doesn't actually ship. You need to pick one, then I can reconcile the SPDX field, root LICENSE, README, CONTRIBUTING, package payloads, and the About view in a single follow-up PR.
## What remains (plan PRs 1114 — larger, several need your input)
Canonical blocks/callout-placement schema-v2 migration; lazy exports + Unicode font vendoring (needs a licensed-font choice); editor modularization + accessibility; and the signed reproducible release pipeline (needs signing keys). Also still open: the click self-test's arm/debounce scenarios fail on Linux — a pre-existing capture defect I surfaced honestly in PR 1 but didn't chase down (it needs on-device Linux capture debugging).
Tell me your license choice and I'll do the reconciliation PR; tell me which of 1114 to prioritize and I'll take it next.
# StepForge comprehensive improvement plan # 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. 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.
+9 -2
View File
@@ -898,10 +898,17 @@ function setupIpc() {
buildVersion: PACKAGE_JSON.buildVersion || app.getVersion(), buildVersion: PACKAGE_JSON.buildVersion || app.getVersion(),
dataDir: store.root, dataDir: store.root,
platform: process.platform, platform: process.platform,
license: PACKAGE_JSON.license || 'CC-BY-NC-4.0',
})); }));
// Platform capture-capability profile (session type, portal/PipeWire, // Platform capture-capability profile (session type, portal/PipeWire,
// xinput, click source, actionable messages) for the diagnostics UI. // xinput, click source, actionable messages) for the diagnostics UI, plus
h('platform:capabilities', () => require('./platform').detectCapabilities()); // the honest active trigger for this machine and settings.
h('platform:capabilities', () => {
const platform = require('./platform');
const caps = platform.detectCapabilities();
const activeTrigger = platform.chooseCaptureTrigger(caps, settings.get('capture.fallbackTrigger') || 'interval');
return { ...caps, activeTrigger };
});
} }
// ---- lifecycle -------------------------------------------------------------- // ---- lifecycle --------------------------------------------------------------
+16 -1
View File
@@ -63,4 +63,19 @@ function detectCapabilities({ platform = process.platform, env = process.env } =
}; };
} }
module.exports = { detectPlatform, createWindowContextProvider, detectCapabilities }; /**
* The honest capture-trigger decision for the current capabilities. On Linux
* this defers to the diagnostics helper (which never promises per-click
* capture with coordinates on Wayland); other platforms have a fixed answer.
*/
function chooseCaptureTrigger(capabilities, userTriggerPreference = 'interval') {
if (capabilities && capabilities.os === 'linux') {
return require('./linux/diagnostics').chooseCaptureTrigger(capabilities, userTriggerPreference);
}
if (capabilities && capabilities.os === 'windows') {
return { trigger: 'click', clickSource: 'windows-hook', coordinates: true, marker: true, note: '' };
}
return { trigger: 'click', clickSource: capabilities ? capabilities.os : 'unavailable', coordinates: true, marker: true, note: '' };
}
module.exports = { detectPlatform, createWindowContextProvider, detectCapabilities, chooseCaptureTrigger };
+58 -1
View File
@@ -96,4 +96,61 @@ function detectLinuxCapabilities({
}; };
} }
module.exports = { detectLinuxCapabilities, detectSessionType }; /**
* Decide the honest capture trigger for a Linux capability profile. StepForge
* must never *promise* per-click capture with coordinates on Wayland, because
* the platform does not expose pointer position to apps. Returns the trigger,
* whether clicks carry coordinates, whether a marker can be drawn, and a
* user-facing note. `userTriggerPreference` is the capture.fallbackTrigger
* setting ('interval' | 'hotkey') used only when no click source exists.
*/
function chooseCaptureTrigger(capabilities, userTriggerPreference = 'interval') {
const caps = capabilities || {};
const click = caps.clickCapture;
if (click === 'x11-xinput') {
return {
trigger: 'click',
clickSource: 'x11',
coordinates: true,
marker: true,
note: 'Per-click capture with an accurate marker (X11 + xinput).',
};
}
if (click === 'evdev-x11') {
return {
trigger: 'click',
clickSource: 'evdev-x11',
coordinates: true,
marker: true,
note: 'Per-click capture via kernel input devices (X11, no xinput).',
};
}
if (click === 'evdev-wayland') {
// Wayland exposes button presses (via evdev, if permitted) but NOT pointer
// position, so a step is captured per click but without a marker. This is
// only reached when the user opted into the least-privilege device rule.
return {
trigger: 'click',
clickSource: 'evdev-wayland',
coordinates: false,
marker: false,
note: 'Per-click capture on Wayland has no pointer position, so no marker is drawn.',
};
}
// No global click source: the safe baseline is the user's chosen fallback.
const trigger = userTriggerPreference === 'hotkey' ? 'hotkey' : 'interval';
return {
trigger,
clickSource: trigger,
coordinates: false,
marker: false,
note: caps.isWayland
? 'Wayland does not expose global clicks; recording uses your ' + trigger + ' trigger. '
+ 'Screen sharing is requested once per recording via the portal.'
: 'No global click source available; recording uses your ' + trigger + ' trigger.',
};
}
module.exports = { detectLinuxCapabilities, detectSessionType, chooseCaptureTrigger };
+5 -2
View File
@@ -23,8 +23,11 @@ guide-capture workflow patterns. To keep it legally clean:
- Keep file formats (`.sfgz`, `.sfglt`, guide/step JSON) documented and - Keep file formats (`.sfgz`, `.sfglt`, guide/step JSON) documented and
versioned in `docs/` and `ARCHITECTURE.md`. versioned in `docs/` and `ARCHITECTURE.md`.
Contributions are accepted under MPL-2.0 with a Developer Certificate of StepForge is licensed under **Creative Commons Attribution-NonCommercial 4.0
Origin sign-off (`git commit -s`). International (CC BY-NC 4.0)** — see the root [LICENSE](../LICENSE). By
contributing you agree that your contributions are licensed under that same
license, and you add a Developer Certificate of Origin sign-off to each commit
(`git commit -s`) to certify you have the right to submit them.
## Offline Rules ## Offline Rules
+34 -22
View File
@@ -13,10 +13,11 @@ difference, how to get the best experience, and how to enable per-click capture.
| | **X11 / "Ubuntu on Xorg"** | **Wayland (default on Ubuntu)** | | | **X11 / "Ubuntu on Xorg"** | **Wayland (default on Ubuntu)** |
|---|---|---| |---|---|---|
| Screenshot per click | ✅ Yes | ✅ Yes (needs `input` group) | | Screenshot per click | ✅ Yes | ⚙️ Optional (least-privilege mouse rule) |
| Red circle on the click | ✅ Yes | ❌ No (Wayland hides the cursor position) | | Red circle on the click | ✅ Yes | ❌ No (Wayland hides the cursor position) |
| "Share your screen" prompt | Never | Once per recording session | | "Share your screen" prompt | Never | Once per recording session |
| Setup needed | None | Add yourself to the `input` group | | Default trigger | Per click | Global hotkey or timed interval |
| Setup needed | None | None (per-click is opt-in, mice only) |
**If you want the full Windows-like experience (click capture *with* the red **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).** marker), use an Xorg session — see [Option A](#option-a-best-experience--use-xorg).**
@@ -67,27 +68,30 @@ stream stays open until you stop recording.
> If you never see steps appear, make sure you actually picked a screen and > If you never see steps appear, make sure you actually picked a screen and
> clicked **Share** in that dialog. > clicked **Share** in that dialog.
### Per-click capture (requires the `input` group) ### Per-click capture (optional, least-privilege)
By default on Wayland, StepForge cannot see your clicks, so it falls back to By default on Wayland, StepForge cannot see your clicks, so it uses a **global
**capturing a screenshot every few seconds** (timed capture). hotkey or a timed interval** to capture (see below). This is the recommended,
no-extra-permissions path.
To get a screenshot **on every click** instead, give your user read access to If you want a screenshot **on every click**, you can grant StepForge read
the mouse devices by joining the `input` group: access to your **mouse** devices. Do **not** use `sudo usermod -aG input`:
joining the `input` group grants your user access to *all* input devices —
**including keyboards** — permanently, on every session. That is a keylogging
surface StepForge does not need.
Instead, install the least-privilege udev rule, which grants your active
session read access to **mouse devices only** (never keyboards), scoped to
whoever is physically logged in:
```bash ```bash
sudo usermod -aG input "$USER" bash scripts/linux/enable-click-capture.sh
``` ```
Then **log out and log back in** (group membership only applies to new sessions). It shows you the exact rule and asks for confirmation before installing. Under
Verify it took effect: the hood it uses a systemd `uaccess` ACL restricted to `ID_INPUT_MOUSE`
devices — see [packaging/linux/common/60-stepforge-input.rules](../packaging/linux/common/60-stepforge-input.rules).
```bash Re-log in (or replug a USB mouse) for it to apply.
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 > **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 > does not tell apps *where* the pointer is, so StepForge cannot draw the circle
@@ -114,10 +118,16 @@ On launch StepForge chooses the best available click source:
1. **Windows** — low-level mouse hook (position + timing). 1. **Windows** — low-level mouse hook (position + timing).
2. **X11**`xinput` (position + timing → full red marker). 2. **X11**`xinput` (position + timing → full red marker).
3. **Linux evdev** (`/dev/input`) — button presses on X11 *and* Wayland, no 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 position on Wayland. Used when `xinput` can't see clicks (i.e. Wayland) and
you're in the `input` group. only if you opted into the least-privilege mouse rule
4. **Timed capture** — the always-works fallback (a screenshot every N seconds) (`scripts/linux/enable-click-capture.sh`).
when no click source is available. 4. **Hotkey / timed capture** — the always-works fallback (the Capture hotkey,
or a screenshot every N seconds) when no click source is available. On
Wayland this is the default, and StepForge reports it honestly instead of
pretending clicks are captured.
Open **Settings → Diagnostics** to see the detected session type, portal/
PipeWire status, and the active capture trigger for your machine.
Screen frames come from a single long-lived capture stream per recording, so Screen frames come from a single long-lived capture stream per recording, so
clicks/timer ticks never re-open the screen-share dialog. clicks/timer ticks never re-open the screen-share dialog.
@@ -149,7 +159,9 @@ STEPFORGE_CAPTURE_LOG=1 npm start
- `[stepforge] screen-capture stream active …` — the stream is up. - `[stepforge] screen-capture stream active …` — the stream is up.
- `[stepforge] per-click capture via evdev on N device(s) …` — clicks are wired up. - `[stepforge] per-click capture via evdev on N device(s) …` — clicks are wired up.
- `[stepforge] no readable mouse input devices …`you need the `input` group (see above). - `[stepforge] no readable mouse input devices …`per-click capture is not
enabled; run `scripts/linux/enable-click-capture.sh` for the least-privilege
mouse rule, or just use the hotkey/interval trigger.
**Harmless console noise.** Lines like `vaInitialize failed`, `Frame latency is **Harmless console noise.** Lines like `vaInitialize failed`, `Frame latency is
negative`, and `StatusNotifierItem … already exported` come from Chromium/GNOME, negative`, and `StatusNotifierItem … already exported` come from Chromium/GNOME,
+12 -7
View File
@@ -1,9 +1,14 @@
Creative Commons Attribution-NonCommercial StepForge is licensed under the Creative Commons
Attribution-NonCommercial 4.0 International License (CC BY-NC 4.0).
The authoritative, full license text lives in the repository root:
../LICENSE
Summary: you may use, copy, modify, and share this software for
NON-COMMERCIAL purposes, with attribution. Commercial use requires
separate written permission from the copyright holder (Tyler Westbrook).
Human-readable summary: https://creativecommons.org/licenses/by-nc/4.0/
SPDX-License-Identifier: CC-BY-NC-4.0
Copyright (c) 2026 Tyler Westbrook Copyright (c) 2026 Tyler Westbrook
Permission is granted to use, copy, modify, and distribute this software for non-commercial purposes only.
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.
This software is provided "as is", without warranty of any kind.
+63
View File
@@ -0,0 +1,63 @@
# StepForge on dnf-based Linux (Fedora / RHEL)
This is the setup and packaging guide for **dnf-based** distributions. Debian,
Ubuntu, and other apt-based systems have a separate guide:
[apt.md](apt.md).
## Install from the .rpm
```bash
sudo dnf install ./stepforge-<version>-1.<arch>.rpm
```
dnf pulls the required runtime libraries automatically (they are declared as
`Requires`). The package installs:
- the app and a fixed Electron runtime under `/opt/stepforge`,
- the `stepforge` launcher at `/usr/bin/stepforge`,
- a desktop entry, icons, and `.sfgz`/`.sfglt` file associations.
Launch it from your application menu or run `stepforge`.
### Sandbox
The launcher runs **sandboxed**. On most modern kernels the Chromium
user-namespace sandbox works out of the box; the package's `%post` also makes
the setuid `chrome-sandbox` helper usable as a fallback. StepForge will **not**
silently launch unsandboxed.
## Install from the portable tarball
The portable tarball (same one shipped for apt systems) includes the
`/usr/bin/stepforge` launcher. Install the runtime libraries first:
```bash
bash scripts/linux/dnf/install-runtime-deps.sh
tar -xzf stepforge_<version>_linux-x64.tar.gz
./usr/bin/stepforge
```
## Capture capabilities on dnf systems
- **X11**: full per-click capture with an accurate marker (needs `xinput`).
- **Wayland** (Fedora's default): screen capture via the XDG Desktop Portal +
PipeWire; the portal asks permission once per recording. Per-click capture
with coordinates is not exposed by Wayland, so recording uses a global
hotkey or interval trigger. StepForge reports the active trigger honestly.
Open Settings → Diagnostics in the app to see the detected session type,
portal/PipeWire status, and the active capture profile.
## Build the .rpm yourself
```bash
bash scripts/linux/dnf/install-build-deps.sh # rpm-build, rpmdevtools, Xvfb, …
nvm install && nvm use # pinned Node 22 (see .nvmrc)
npm ci
npm run package:linux:rpm # -> build/artifacts/*.rpm + sha256
```
The builder stages **only** runtime files (shared with the `.deb` builder via
`packaging/linux/common/stage-runtime.sh`): the app code, a fixed Electron
runtime, and production npm dependencies. It never copies the development
`node_modules` and fails if `node_modules` is missing.
+1 -1
View File
@@ -5,7 +5,7 @@
"description": "Local-first desktop tool for capturing, annotating, and exporting step-by-step guides, with an optional user-configured local AI integration.", "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", "main": "app/main.js",
"author": "StepForge [email protected]", "author": "StepForge [email protected]",
"license": "MPL-2.0", "license": "CC-BY-NC-4.0",
"private": true, "private": true,
"engines": { "engines": {
"node": ">=22.12.0" "node": ">=22.12.0"
@@ -0,0 +1,19 @@
# StepForge least-privilege input access (OPTIONAL, opt-in).
#
# Grants the user at the ACTIVE local session read/write access to MOUSE input
# devices only, via systemd-logind's `uaccess` ACL. This is the least-privilege
# alternative to joining the broad `input` group, which would grant access to
# ALL input devices — including keyboards — for the user permanently, on every
# session. StepForge never needs keystrokes, so this rule deliberately EXCLUDES
# keyboards.
#
# Scope of what this grants:
# * only devices udev classifies as a mouse (ID_INPUT_MOUSE=1),
# * only when they are NOT also a keyboard (ID_INPUT_KEYBOARD!=1),
# * only to whoever is logged in at the physical seat (uaccess is
# session-scoped, not a permanent group membership).
#
# StepForge uses this only for the optional Wayland per-click *trigger* (button
# presses, no coordinates). It is not required — the safe default is a global
# hotkey or interval capture.
SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_MOUSE}=="1", ENV{ID_INPUT_KEYBOARD}!="1", TAG+="uaccess"
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Shared staging for the Linux packages. Populates $STAGE_ROOT with the FHS
# layout common to the .deb and .rpm: a pruned runtime-only /opt/stepforge, the
# launcher, desktop entry, icons, MIME registration, and the license.
#
# Distro-specific metadata (Depends vs Requires) and the packaging step
# (dpkg-deb vs rpmbuild) stay in the per-distro builders. This file only
# assembles the payload so the two never drift.
#
# Usage: ROOT_DIR=<repo> STAGE_ROOT=<dir> bash stage-runtime.sh
set -euo pipefail
: "${ROOT_DIR:?ROOT_DIR must be set}"
: "${STAGE_ROOT:?STAGE_ROOT must be set}"
# A packaged app must contain a fixed runtime; never install at build time and
# never ship without node_modules.
if [ ! -d "$ROOT_DIR/node_modules/electron/dist" ]; then
echo "error: node_modules/electron is missing. Run 'npm ci' before packaging." >&2
exit 1
fi
APP_DIR="$STAGE_ROOT/opt/stepforge"
mkdir -p "$APP_DIR/node_modules" \
"$STAGE_ROOT/usr/bin" \
"$STAGE_ROOT/usr/share/applications" \
"$STAGE_ROOT/usr/share/mime/packages"
# --- application code (runtime only) ----------------------------------------
for item in app core exporters package.json package-lock.json; do
cp -a "$ROOT_DIR/$item" "$APP_DIR/$item"
done
# --- runtime node_modules ----------------------------------------------------
# The fixed Electron runtime (needed at runtime even though it is a dev dep):
cp -a "$ROOT_DIR/node_modules/electron" "$APP_DIR/node_modules/electron"
# Production npm dependencies (tesseract.js + language data + transitive):
while IFS= read -r dep; do
[ -n "$dep" ] || continue
rel="${dep#"$ROOT_DIR"/}"
[ "$rel" != "$dep" ] || continue
[ -d "$dep" ] || continue
mkdir -p "$APP_DIR/$(dirname "$rel")"
cp -a "$dep" "$APP_DIR/$rel"
done < <(cd "$ROOT_DIR" && npm ls --omit=dev --all --parseable 2>/dev/null | tail -n +2)
# Guard: the development-only packaging toolchain must not have leaked in.
if [ -d "$APP_DIR/node_modules/electron-builder" ] || [ -d "$APP_DIR/node_modules/app-builder-lib" ]; then
echo "error: build-only dependency leaked into the package payload." >&2
exit 1
fi
# --- launcher ----------------------------------------------------------------
install -m 0755 "$ROOT_DIR/packaging/linux/common/launcher.sh" "$STAGE_ROOT/usr/bin/stepforge"
# --- desktop entry, icons, MIME ---------------------------------------------
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge.desktop" "$STAGE_ROOT/usr/share/applications/stepforge.desktop"
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge-mime.xml" "$STAGE_ROOT/usr/share/mime/packages/stepforge.xml"
for size in 16 32 48 64 128 256 512; do
icon="$ROOT_DIR/packaging/assets/icons/stepforge-${size}.png"
[ -f "$icon" ] || continue
dest="$STAGE_ROOT/usr/share/icons/hicolor/${size}x${size}/apps"
mkdir -p "$dest"
install -m 0644 "$icon" "$dest/stepforge.png"
done
+3 -49
View File
@@ -25,59 +25,13 @@ case "$DEB_ARCH" in
*) NODE_ARCH="$DEB_ARCH" ;; *) NODE_ARCH="$DEB_ARCH" ;;
esac esac
# A packaged app must contain a fixed runtime; never install at build time from
# within the package step, and never ship without node_modules.
if [ ! -d "$ROOT_DIR/node_modules/electron/dist" ]; then
echo "error: node_modules/electron is missing. Run 'npm ci' before packaging." >&2
exit 1
fi
WORK_DIR="$(mktemp -d "${OUT_DIR%/}/.deb.XXXXXX")" WORK_DIR="$(mktemp -d "${OUT_DIR%/}/.deb.XXXXXX")"
trap 'rm -rf "$WORK_DIR"' EXIT trap 'rm -rf "$WORK_DIR"' EXIT
APP_DIR="$WORK_DIR/opt/stepforge"
mkdir -p "$APP_DIR" "$WORK_DIR/usr/bin" "$WORK_DIR/DEBIAN"
mkdir -p "$WORK_DIR/usr/share/applications"
mkdir -p "$WORK_DIR/usr/share/mime/packages"
mkdir -p "$WORK_DIR/usr/share/doc/stepforge"
# --- application code (runtime only) ---------------------------------------- # Stage the shared runtime-only payload (fails if node_modules is missing).
for item in app core exporters package.json package-lock.json; do ROOT_DIR="$ROOT_DIR" STAGE_ROOT="$WORK_DIR" bash "$ROOT_DIR/packaging/linux/common/stage-runtime.sh"
cp -a "$ROOT_DIR/$item" "$APP_DIR/$item"
done
# --- runtime node_modules ---------------------------------------------------- mkdir -p "$WORK_DIR/DEBIAN" "$WORK_DIR/usr/share/doc/stepforge"
# The fixed Electron runtime (needed at runtime even though it is a dev dep):
mkdir -p "$APP_DIR/node_modules"
cp -a "$ROOT_DIR/node_modules/electron" "$APP_DIR/node_modules/electron"
# Production npm dependencies (tesseract.js + language data + transitive):
while IFS= read -r dep; do
[ -n "$dep" ] || continue
rel="${dep#"$ROOT_DIR"/}"
[ "$rel" != "$dep" ] || continue # only paths under the repo
[ -d "$dep" ] || continue
mkdir -p "$APP_DIR/$(dirname "$rel")"
cp -a "$dep" "$APP_DIR/$rel"
done < <(npm ls --omit=dev --all --parseable 2>/dev/null | tail -n +2)
# Guard: the development-only packaging toolchain must not have leaked in.
if [ -d "$APP_DIR/node_modules/electron-builder" ] || [ -d "$APP_DIR/node_modules/app-builder-lib" ]; then
echo "error: build-only dependency leaked into the package payload." >&2
exit 1
fi
# --- launcher ----------------------------------------------------------------
install -m 0755 "$ROOT_DIR/packaging/linux/common/launcher.sh" "$WORK_DIR/usr/bin/stepforge"
# --- desktop entry, icons, MIME ---------------------------------------------
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge.desktop" "$WORK_DIR/usr/share/applications/stepforge.desktop"
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge-mime.xml" "$WORK_DIR/usr/share/mime/packages/stepforge.xml"
for size in 16 32 48 64 128 256 512; do
icon="$ROOT_DIR/packaging/assets/icons/stepforge-${size}.png"
[ -f "$icon" ] || continue
dest="$WORK_DIR/usr/share/icons/hicolor/${size}x${size}/apps"
mkdir -p "$dest"
install -m 0644 "$icon" "$dest/stepforge.png"
done
# --- license + docs pointer -------------------------------------------------- # --- license + docs pointer --------------------------------------------------
if [ -f "$ROOT_DIR/LICENSE" ]; then if [ -f "$ROOT_DIR/LICENSE" ]; then
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Build a production StepForge .rpm from a pruned, runtime-only tree, mirroring
# the .deb builder. Stages the shared payload via common/stage-runtime.sh, then
# packages it with rpmbuild against a prebuilt BuildRoot (no compilation).
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT_DIR"
VERSION="$(node -p "require('./package.json').version")"
MAINTAINER="${STEPFORGE_MAINTAINER:-StepForge <[email protected]>}"
OUT_DIR="${STEPFORGE_PACKAGE_DIR:-$ROOT_DIR/build/artifacts}"
mkdir -p "$OUT_DIR"
if ! command -v rpmbuild >/dev/null 2>&1; then
echo "error: rpmbuild is not installed. Run scripts/linux/dnf/install-build-deps.sh" >&2
exit 1
fi
# RPM arch label from the host.
RPM_ARCH="$(rpm --eval '%{_arch}' 2>/dev/null || uname -m)"
BUILD_ROOT="$(mktemp -d "${OUT_DIR%/}/.rpm.XXXXXX")"
trap 'rm -rf "$BUILD_ROOT"' EXIT
STAGE="$BUILD_ROOT/buildroot"
mkdir -p "$STAGE"
# Shared runtime-only payload (fails if node_modules is missing).
ROOT_DIR="$ROOT_DIR" STAGE_ROOT="$STAGE" bash "$ROOT_DIR/packaging/linux/common/stage-runtime.sh"
# License into the RPM's conventional location.
mkdir -p "$STAGE/usr/share/licenses/stepforge"
if [ -f "$ROOT_DIR/LICENSE" ]; then
install -m 0644 "$ROOT_DIR/LICENSE" "$STAGE/usr/share/licenses/stepforge/LICENSE"
elif [ -f "$ROOT_DIR/docs/LICENSE" ]; then
install -m 0644 "$ROOT_DIR/docs/LICENSE" "$STAGE/usr/share/licenses/stepforge/LICENSE"
else
# rpmbuild %license requires the file to exist; write a pointer if absent.
echo "See project LICENSE." > "$STAGE/usr/share/licenses/stepforge/LICENSE"
fi
# Materialize the spec with version/maintainer substituted.
SPEC="$BUILD_ROOT/stepforge.spec"
sed -e "s/@VERSION@/$VERSION/" -e "s#@MAINTAINER@#$MAINTAINER#" \
"$ROOT_DIR/packaging/linux/fedora/stepforge.spec" > "$SPEC"
rpmbuild -bb \
--define "_topdir $BUILD_ROOT/rpmbuild" \
--define "_rpmdir $OUT_DIR" \
--define "_build_id_links none" \
--buildroot "$STAGE" \
--target "$RPM_ARCH" \
"$SPEC" >/dev/null
# rpmbuild writes to $OUT_DIR/<arch>/<name>.rpm — surface the final path.
RPM_FILE="$(find "$OUT_DIR" -name "stepforge-${VERSION}-1*.${RPM_ARCH}.rpm" -newer "$SPEC" | head -1)"
if [ -z "$RPM_FILE" ]; then
RPM_FILE="$(find "$OUT_DIR" -name "stepforge-${VERSION}-1*.rpm" | head -1)"
fi
[ -n "$RPM_FILE" ] || { echo "error: rpmbuild did not produce an .rpm" >&2; exit 1; }
# Checksum.
( cd "$(dirname "$RPM_FILE")" && sha256sum "$(basename "$RPM_FILE")" > "$(basename "$RPM_FILE").sha256" )
echo "$RPM_FILE"
+69
View File
@@ -0,0 +1,69 @@
# StepForge RPM spec. The payload is prebuilt into a staging BuildRoot by
# packaging/linux/fedora/package.sh (which stages a pruned runtime tree), so
# this spec only packages and declares metadata — it does not compile.
#
# Placeholders @VERSION@ / @MAINTAINER@ are substituted by package.sh.
%global debug_package %{nil}
%global __brp_check_rpaths %{nil}
%define _build_id_links none
Name: stepforge
Version: @VERSION@
Release: 1%{?dist}
Summary: Local-first step-by-step guide capture and export tool
License: CC-BY-NC-4.0
URL: https://github.com/Twest2/StepForge
# Runtime shared libraries (Chromium/Electron) + capture integration.
Requires: nss
Requires: nspr
Requires: atk
Requires: at-spi2-atk
Requires: cups-libs
Requires: gtk3
Requires: mesa-libgbm
Requires: alsa-lib
Requires: libxkbcommon
Recommends: xinput
Recommends: xdg-desktop-portal
Recommends: pipewire
# The payload is architecture-specific (bundles the Electron binary).
%description
StepForge captures step-by-step workflows as screenshots, lets you annotate
and describe each step, and exports to Markdown, PDF, DOCX, PPTX, HTML, and
more. Local-first: no telemetry, with an optional user-configured local AI
integration. This package bundles a fixed Electron runtime and only
production dependencies; it does not install anything at runtime.
%files
/opt/stepforge
/usr/bin/stepforge
/usr/share/applications/stepforge.desktop
/usr/share/mime/packages/stepforge.xml
/usr/share/icons/hicolor/*/apps/stepforge.png
%license /usr/share/licenses/stepforge/LICENSE
%post
# Make the Chromium setuid sandbox helper usable so the app launches sandboxed.
HELPER=/opt/stepforge/node_modules/electron/dist/chrome-sandbox
if [ -e "$HELPER" ]; then
chown root:root "$HELPER" || true
chmod 4755 "$HELPER" || true
fi
if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q /usr/share/applications || true; fi
if command -v update-mime-database >/dev/null 2>&1; then update-mime-database /usr/share/mime || true; fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -q /usr/share/icons/hicolor || true; fi
%postun
if [ "$1" = 0 ]; then
if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q /usr/share/applications || true; fi
if command -v update-mime-database >/dev/null 2>&1; then update-mime-database /usr/share/mime || true; fi
if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -q /usr/share/icons/hicolor || true; fi
fi
%changelog
* Fri Jul 03 2026 @MAINTAINER@ - @VERSION@-1
- Production runtime-only package (pruned tree, fixed Electron runtime).
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Install the BUILD toolchain for producing StepForge packages on dnf-based
# systems (Fedora/RHEL). For DEVELOPERS/packagers only; never shipped inside
# the end-user package.
set -euo pipefail
if ! command -v dnf >/dev/null 2>&1; then
echo "This script is for dnf-based systems (Fedora/RHEL)." >&2
exit 1
fi
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
PACKAGES=(
rpm-build rpmdevtools # build the .rpm
desktop-file-utils # validate the .desktop entry
ca-certificates # npm ci over https
xorg-x11-server-Xvfb # headless smoke test under Xvfb
)
echo "Installing StepForge build dependencies via dnf..."
$SUDO dnf install -y "${PACKAGES[@]}"
cat <<'MSG'
Done. Also install the pinned Node toolchain (see .nvmrc — Node 22.12+):
nvm install && nvm use # or another Node 22 LTS install method
Then, from the repo root:
npm ci
npm run package:linux:rpm
MSG
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# Install the RUNTIME libraries StepForge needs on dnf-based systems (Fedora,
# RHEL/derivatives). These are the shared libraries the packaged Electron
# runtime links against, plus the X11/portal integration used for capture.
# For END USERS installing from the tarball; the .rpm declares the same set as
# Requires so dnf pulls them automatically.
set -euo pipefail
if ! command -v dnf >/dev/null 2>&1; then
echo "This script is for dnf-based systems (Fedora/RHEL). Use the apt script on Debian/Ubuntu." >&2
exit 1
fi
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
PACKAGES=(
# Chromium/Electron shared libraries
nss nspr atk at-spi2-atk at-spi2-core cups-libs libdrm
gtk3 mesa-libgbm alsa-lib libxkbcommon
libXcomposite libXdamage libXfixes libXrandr libxshmfence
# X11 per-click capture (marker-accurate) — X11 sessions only
xorg-x11-server-utils xinput
# Wayland screen-share via the XDG portal + PipeWire
xdg-desktop-portal pipewire
)
echo "Installing StepForge runtime dependencies via dnf..."
# Some package names differ across Fedora releases; install best-effort so one
# missing optional name doesn't abort the whole set.
$SUDO dnf install -y "${PACKAGES[@]}" || {
echo "Some packages were unavailable; retrying individually..." >&2
for pkg in "${PACKAGES[@]}"; do $SUDO dnf install -y "$pkg" || echo " (skipped: $pkg)" >&2; done
}
echo "Done. StepForge runtime dependencies are installed."
+52
View File
@@ -0,0 +1,52 @@
#!/usr/bin/env bash
# OPTIONAL: enable per-click capture on Wayland (or X11 without xinput) using a
# LEAST-PRIVILEGE udev rule instead of the broad `input` group.
#
# Security tradeoff (read before running):
# * This grants your ACTIVE local session read access to MOUSE devices only.
# * It deliberately EXCLUDES keyboards — StepForge never needs keystrokes.
# * Access is session-scoped (systemd `uaccess` ACL), not a permanent group.
# * It is NOT required: the safe default is a global hotkey or interval
# capture. Only enable this if you want a screenshot on every click.
#
# Compare to `sudo usermod -aG input "$USER"`, which grants access to ALL input
# devices (including keyboards) for your user on every session — a much larger
# surface. This script does not do that.
set -euo pipefail
RULE_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/packaging/linux/common/60-stepforge-input.rules"
RULE_DEST="/etc/udev/rules.d/60-stepforge-input.rules"
if [ ! -f "$RULE_SRC" ]; then
echo "error: rule file not found at $RULE_SRC" >&2
exit 1
fi
echo "This installs a least-privilege udev rule granting your session read"
echo "access to MOUSE devices only (never keyboards):"
echo
sed 's/^/ /' "$RULE_SRC"
echo
printf 'Install it to %s? [y/N] ' "$RULE_DEST"
read -r reply
case "$reply" in
y|Y|yes|YES) ;;
*) echo "Aborted. No changes made."; exit 0 ;;
esac
SUDO=""
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
$SUDO install -m 0644 "$RULE_SRC" "$RULE_DEST"
$SUDO udevadm control --reload-rules
$SUDO udevadm trigger --subsystem-match=input --action=change || true
cat <<'MSG'
Installed. You may need to unplug/replug a USB mouse or re-log in for the ACL
to apply to already-connected devices.
To remove it later:
sudo rm /etc/udev/rules.d/60-stepforge-input.rules
sudo udevadm control --reload-rules
MSG
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# Integration test: build the production .rpm and assert it is a real,
# runtime-only package. Honest skip policy: skip ONLY when the prerequisites
# are genuinely absent (rpmbuild missing or node_modules not installed). Once
# we build, any structural failure fails the test.
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT_DIR"
if ! command -v rpmbuild >/dev/null 2>&1; then
echo "package-rpm SKIPPED: rpmbuild not installed (not a dnf-based build host)"
exit 0
fi
if [ ! -d "$ROOT_DIR/node_modules/electron/dist" ]; then
echo "package-rpm SKIPPED: node_modules/electron missing (run npm ci first)"
exit 0
fi
OUT_DIR="$(mktemp -d)"
trap 'rm -rf "$OUT_DIR"' EXIT
RPM="$(STEPFORGE_PACKAGE_DIR="$OUT_DIR" bash packaging/linux/fedora/package.sh | tail -1)"
[ -f "$RPM" ] || { echo "package-rpm FAILED: builder produced no .rpm" >&2; exit 1; }
fail() { echo "package-rpm FAILED: $1" >&2; exit 1; }
listing="$(rpm -qlp "$RPM" 2>/dev/null)"
for needle in \
'/usr/bin/stepforge' \
'/usr/share/applications/stepforge.desktop' \
'/usr/share/mime/packages/stepforge.xml' \
'/opt/stepforge/node_modules/electron/dist/electron' \
'/opt/stepforge/app/main.js'; do
echo "$listing" | grep -qF "$needle" || fail "missing packaged file: $needle"
done
echo "$listing" | grep -q '/usr/share/icons/hicolor/256x256/apps/stepforge.png' || fail "missing 256px icon"
# No dev tree / build tooling / app docs.
for banned in 'electron-builder' '/opt/stepforge/docs/' '/opt/stepforge/ai_prompts/' '/opt/stepforge/examples/'; do
echo "$listing" | grep -qF "$banned" && fail "unexpected payload: $banned" || true
done
# Metadata sanity.
rpm -qip "$RPM" 2>/dev/null | grep -q '^Name *: stepforge' || fail "rpm Name is not stepforge"
rpm -qp --requires "$RPM" 2>/dev/null | grep -q '^nss' || fail "rpm does not Require nss"
echo "package-rpm OK ($(basename "$RPM"))"
+56
View File
@@ -0,0 +1,56 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const ROOT = path.resolve(__dirname, '..', '..');
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
// The license was a release blocker: package.json/spec said MPL-2.0 while the
// README/docs said CC-BY-NC. The owner chose CC BY-NC 4.0. These guards keep
// every surface consistent so it can never silently drift back to a
// contradiction.
test('a root LICENSE exists with the full CC BY-NC 4.0 text', () => {
assert.ok(exists('LICENSE'), 'root LICENSE must exist');
const license = read('LICENSE');
assert.match(license, /Creative Commons/);
assert.match(license, /Attribution-NonCommercial 4\.0 International/);
assert.match(license, /SPDX-License-Identifier:\s*CC-BY-NC-4\.0/);
// It must be the real legalcode, not a one-paragraph paraphrase.
assert.ok(license.length > 8000, 'LICENSE should contain the full legal text');
assert.match(license, /Section 1 -+ Definitions/);
});
test('package.json declares CC-BY-NC-4.0 and never MPL', () => {
const pkg = JSON.parse(read('package.json'));
assert.equal(pkg.license, 'CC-BY-NC-4.0');
});
test('no shipping license surface still claims MPL-2.0', () => {
for (const rel of [
'package.json',
'README.md',
'docs/LICENSE',
'docs/CONTRIBUTING.md',
'packaging/linux/fedora/stepforge.spec',
]) {
assert.doesNotMatch(read(rel), /MPL-2\.0|Mozilla Public/i, `${rel} must not mention MPL`);
}
});
test('the license story is consistent across the shipping surfaces', () => {
assert.match(read('README.md'), /CC BY-NC 4\.0/);
assert.match(read('docs/CONTRIBUTING.md'), /CC BY-NC 4\.0/);
assert.match(read('docs/LICENSE'), /CC-BY-NC-4\.0/);
assert.match(read('packaging/linux/fedora/stepforge.spec'), /^License:\s+CC-BY-NC-4\.0$/m);
});
test('the About info surface reports the license from package.json', () => {
// main.js exposes license in app:info so the About view can never contradict.
const main = read('app/main.js');
assert.match(main, /license: PACKAGE_JSON\.license/);
});
+53 -5
View File
@@ -6,7 +6,8 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const ROOT = path.resolve(__dirname, '..', '..'); const ROOT = path.resolve(__dirname, '..', '..');
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8'); // Strip CR so /^...$/m assertions are robust to CRLF checkouts on Windows CI.
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
const exists = (rel) => fs.existsSync(path.join(ROOT, rel)); const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
// These are structural checks that run in the normal (cross-platform) unit // These are structural checks that run in the normal (cross-platform) unit
@@ -33,6 +34,55 @@ test('the old non-production package-linux.sh is gone', () => {
assert.equal(exists('scripts/package-linux.sh'), false); assert.equal(exists('scripts/package-linux.sh'), false);
}); });
test('Fedora/dnf packaging files exist in their own separate locations', () => {
for (const f of [
'packaging/linux/fedora/package.sh',
'packaging/linux/fedora/stepforge.spec',
'packaging/linux/common/stage-runtime.sh',
'scripts/linux/dnf/install-runtime-deps.sh',
'scripts/linux/dnf/install-build-deps.sh',
'docs/linux/dnf.md',
'tests/integration/linux/package-rpm.test.sh',
]) {
assert.ok(exists(f), `expected ${f} to exist`);
}
});
test('the RPM spec declares runtime Requires, license, and CC-BY-NC-4.0', () => {
const spec = read('packaging/linux/fedora/stepforge.spec');
assert.match(spec, /^License:\s+CC-BY-NC-4\.0$/m);
assert.match(spec, /^Requires:\s+nss$/m);
assert.match(spec, /%license/);
assert.match(spec, /chrome-sandbox/); // %post makes the sandbox helper setuid
assert.match(spec, /@VERSION@/);
assert.doesNotMatch(spec, /fully offline/i);
});
test('the rpm builder shares staging and requires rpmbuild + node_modules', () => {
const script = read('packaging/linux/fedora/package.sh');
assert.match(script, /stage-runtime\.sh/);
assert.match(script, /rpmbuild/);
assert.match(script, /rpm --eval '%\{_arch\}'|uname -m/); // arch detected
assert.doesNotMatch(script, /cp -a "\$ROOT_DIR\/node_modules" /);
});
test('dnf setup scripts target dnf and keep build vs runtime deps separate', () => {
const runtime = read('scripts/linux/dnf/install-runtime-deps.sh');
const build = read('scripts/linux/dnf/install-build-deps.sh');
assert.match(runtime, /dnf install/);
assert.match(runtime, /nss/);
assert.doesNotMatch(runtime, /rpm-build|rpmdevtools/, 'runtime script must not install build tools');
assert.match(build, /rpm-build/);
});
test('the shared staging never copies the whole dev node_modules', () => {
const staging = read('packaging/linux/common/stage-runtime.sh');
assert.match(staging, /npm ls --omit=dev/);
assert.match(staging, /electron-builder/); // leak guard
assert.doesNotMatch(staging, /cp -a "\$ROOT_DIR\/node_modules" "\$APP_DIR/);
assert.match(staging, /node_modules\/electron\/dist/); // requires the runtime
});
test('the desktop entry is valid and app-branded', () => { test('the desktop entry is valid and app-branded', () => {
const desktop = read('packaging/linux/common/stepforge.desktop'); const desktop = read('packaging/linux/common/stepforge.desktop');
assert.match(desktop, /^\[Desktop Entry\]/); assert.match(desktop, /^\[Desktop Entry\]/);
@@ -62,11 +112,9 @@ test('the launcher refuses an unsandboxed launch unless explicitly opted in', ()
assert.doesNotMatch(launcher, /npm (install|ci|rebuild)/); assert.doesNotMatch(launcher, /npm (install|ci|rebuild)/);
}); });
test('the package builder requires node_modules and guards against dev-dep leaks', () => { test('the deb builder detects arch and delegates to shared staging', () => {
const script = read('packaging/linux/debian/package.sh'); const script = read('packaging/linux/debian/package.sh');
assert.match(script, /node_modules\/electron\/dist/); assert.match(script, /stage-runtime\.sh/); // runtime-only staging is shared
assert.match(script, /npm ls --omit=dev/);
assert.match(script, /electron-builder/); // the leak guard references it
assert.match(script, /dpkg --print-architecture/); // arch detected, not hardcoded assert.match(script, /dpkg --print-architecture/); // arch detected, not hardcoded
assert.doesNotMatch(script, /cp -a "\$ROOT_DIR\/node_modules" /); // never copy the whole dev tree assert.doesNotMatch(script, /cp -a "\$ROOT_DIR\/node_modules" /); // never copy the whole dev tree
}); });
+90
View File
@@ -0,0 +1,90 @@
'use strict';
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { chooseCaptureTrigger, detectLinuxCapabilities } = require('../../app/platform/linux/diagnostics');
const platform = require('../../app/platform');
const ROOT = path.resolve(__dirname, '..', '..');
// Strip CR so assertions are robust to CRLF checkouts on Windows CI.
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
// ---- honest trigger decisions ----------------------------------------------
test('X11 + xinput promises per-click capture with a marker', () => {
const t = chooseCaptureTrigger({ os: 'linux', isWayland: false, clickCapture: 'x11-xinput' });
assert.equal(t.trigger, 'click');
assert.equal(t.coordinates, true);
assert.equal(t.marker, true);
});
test('Wayland evdev captures per click but never promises coordinates or a marker', () => {
const t = chooseCaptureTrigger({ os: 'linux', isWayland: true, clickCapture: 'evdev-wayland' });
assert.equal(t.trigger, 'click');
assert.equal(t.coordinates, false, 'Wayland exposes no pointer position');
assert.equal(t.marker, false);
});
test('Wayland without a click source falls back to the user trigger, honestly', () => {
const interval = chooseCaptureTrigger({ os: 'linux', isWayland: true, clickCapture: 'hotkey-or-interval-only' }, 'interval');
assert.equal(interval.trigger, 'interval');
assert.equal(interval.coordinates, false);
assert.match(interval.note, /Wayland does not expose global clicks/i);
const hotkey = chooseCaptureTrigger({ os: 'linux', isWayland: true, clickCapture: 'hotkey-or-interval-only' }, 'hotkey');
assert.equal(hotkey.trigger, 'hotkey');
});
test('the platform facade wires the Linux trigger decision from real capabilities', () => {
const caps = detectLinuxCapabilities({
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
hasBinary: () => false,
existsSync: () => false,
readdirSync: () => [],
});
const t = platform.chooseCaptureTrigger(caps, 'interval');
assert.equal(t.trigger, 'interval');
assert.equal(t.marker, false);
});
test('Windows always reports per-click capture', () => {
const t = platform.chooseCaptureTrigger({ os: 'windows' });
assert.equal(t.trigger, 'click');
assert.equal(t.coordinates, true);
});
// ---- least-privilege input access -------------------------------------------
test('the udev rule grants mouse-only access and excludes keyboards', () => {
assert.ok(exists('packaging/linux/common/60-stepforge-input.rules'));
const rule = read('packaging/linux/common/60-stepforge-input.rules');
assert.match(rule, /ID_INPUT_MOUSE\}=="1"/);
assert.match(rule, /ID_INPUT_KEYBOARD\}!="1"/, 'must exclude keyboards');
assert.match(rule, /TAG\+="uaccess"/, 'session-scoped ACL, not a permanent group');
});
test('the enable script is opt-in and installs the least-privilege rule, not the input group', () => {
assert.ok(exists('scripts/linux/enable-click-capture.sh'));
const script = read('scripts/linux/enable-click-capture.sh');
assert.match(script, /read -r reply/, 'must confirm before installing');
assert.match(script, /60-stepforge-input\.rules/, 'installs the least-privilege udev rule');
// usermod may only appear in a comment (warning), never as an executed
// command. Check command position (line start, optional sudo) so this is
// robust to CRLF vs LF line endings across platforms.
assert.doesNotMatch(script, /^\s*(sudo\s+)?usermod\b/m, 'must not run the broad input-group command');
});
// ---- docs no longer push the broad input group ------------------------------
test('Linux docs recommend the least-privilege path and warn against the input group', () => {
const doc = read('docs/GETTING_STARTED_WITH_LINUX.md');
assert.match(doc, /enable-click-capture\.sh/);
assert.match(doc, /least-privilege/i);
// The broad group is now presented as a warning ("Do not use ..."), not a
// recommended step.
assert.match(doc, /Do \*\*not\*\* use `sudo usermod/);
});