Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07e6191cc1 | ||
|
|
8ddfe1b3e1 | ||
|
|
cc724f894b | ||
|
|
4f57cfacba | ||
|
|
8c39e8db4e | ||
|
|
2c0b8b021e | ||
|
|
7827ef3ad2 | ||
|
|
575d893f90 | ||
|
|
92146c760d | ||
|
|
fedf1d24c0 | ||
|
|
901940993c | ||
|
|
970d76a780 | ||
|
|
37079304c2 | ||
|
|
8aa9756b8a | ||
|
|
f31f1407a5 | ||
|
|
c1ccb5739b | ||
|
|
dd71cffac5 | ||
|
|
f62e3e19cb | ||
|
|
c916234ae8 | ||
|
|
ccbb9b03dc | ||
|
|
6ffef69705 | ||
|
|
50e445e7c3 | ||
|
|
6a3005f24c |
@@ -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
|
||||||
@@ -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.
|
||||||
|
|
||||||
@@ -1,17 +1,26 @@
|
|||||||
# StepForge
|
# StepForge
|
||||||
|
|
||||||
StepForge is a **fully offline**, open-source desktop app for Windows, with
|
StepForge is a **local-first**, open-source desktop app for Windows, with
|
||||||
Linux (WIP) builds. It captures step-by-step workflows as screenshots, lets
|
Linux (WIP) builds. It captures step-by-step workflows as screenshots, lets
|
||||||
you annotate and describe each step in a focused three-pane editor, and
|
you annotate and describe each step in a focused three-pane editor, and
|
||||||
exports the result to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP),
|
exports the result to Markdown, DOCX, PPTX, PDF, HTML (WIP), GIF (WIP),
|
||||||
confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current
|
confluence (WIP), Wiki.js (WIP), and image bundles (WIP). The current
|
||||||
reconmendations for exporting is Markdown and PDF.
|
reconmendations for exporting is Markdown and PDF.
|
||||||
|
|
||||||
It is an independent offline desktop guide-capture tool inspired by publicly
|
It is an independent desktop guide-capture tool inspired by publicly
|
||||||
documented workflow patterns of commercial documentation tools like Folge. It contains no
|
documented workflow patterns of commercial documentation tools like Folge. It
|
||||||
third-party branding, assets, or code from those tools, and it never talks to
|
contains no third-party branding, assets, or code from those tools.
|
||||||
the network: no telemetry, no update checks, no license checks, no cloud, no
|
|
||||||
remote AI.
|
**Network and privacy contract.** StepForge has no telemetry, no update
|
||||||
|
checks, no license checks, and no cloud. Guides never leave your machine on
|
||||||
|
their own. The only outbound network feature is the **optional** AI
|
||||||
|
integration: when *you* enable it and configure an [Ollama](https://ollama.com)
|
||||||
|
endpoint, StepForge sends step screenshots and text to that endpoint to
|
||||||
|
generate titles and descriptions. By default that endpoint must be **local
|
||||||
|
(loopback)**; sending data to a remote host requires the explicit "Allow
|
||||||
|
remote AI host" opt-in. See [docs/PRIVACY.md](docs/PRIVACY.md) for exactly
|
||||||
|
what is collected and sent. Note that OCR (Tesseract) and its English language
|
||||||
|
data are bundled production dependencies — Electron is not the only one.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
@@ -64,7 +73,12 @@ using only Node built-ins.
|
|||||||
|
|
||||||
For a Windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
|
For a Windows installation, see [docs/windows_installation](docs/windows_installation.md) or for a developer/more in depth walkthrough, see [docs/GETTING_STARTED.md](docs/GETTING_STARTED.md).
|
||||||
|
|
||||||
On **Linux** (⚠️ work in progress — X11 vs Wayland, enabling per-click capture, the screen-share prompt), see [docs/GETTING_STARTED_WITH_LINUX.md](docs/GETTING_STARTED_WITH_LINUX.md).
|
On **Linux**, install from a package built for your distro family:
|
||||||
|
apt-based (Debian/Ubuntu) → [docs/linux/apt.md](docs/linux/apt.md); dnf-based
|
||||||
|
(Fedora) → [docs/linux/dnf.md](docs/linux/dnf.md). Wayland uses the XDG portal
|
||||||
|
for screen capture and a hotkey/interval trigger (per-click capture with a
|
||||||
|
marker needs X11 + xinput). The general developer walkthrough is
|
||||||
|
[docs/GETTING_STARTED_WITH_LINUX.md](docs/GETTING_STARTED_WITH_LINUX.md).
|
||||||
|
|
||||||
Requirements: Node.js 22.12+ and npm (pinned in `.nvmrc`; installs are
|
Requirements: Node.js 22.12+ and npm (pinned in `.nvmrc`; installs are
|
||||||
refused on older Nodes because the packaging toolchain needs 22.12+).
|
refused on older Nodes because the packaging toolchain needs 22.12+).
|
||||||
@@ -130,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.
|
||||||
|
|||||||
@@ -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 1–10 of the plan's recommended sequence. Each was a focused branch, opened on both GitHub (Twest2/StepForge #5–#14) and Gitea (Tyler/autodoc #21–#30), verified green on GitHub CI (Ubuntu + Windows + dependency audit), merged with the same SHA to both forges, and the Gitea mirror closed with a merge note. Final state: main is byte-identical across local, GitHub, and Gitea (cc724f8), main CI is green, no open PRs, 289 unit tests pass (0 fail).
|
||||||
|
|
||||||
|
## PR What it fixed
|
||||||
|
1 Pinned Node ≥22.12 (.nvmrc/engines); removed all runtime npm self-repair; refuse silent --no-sandbox; CI on pull_request + prod/full audits; made the click E2E stop hiding startup crashes as "SKIPPED"; remediated the form-data/undici advisories
|
||||||
|
2 Closed the renderer privilege boundary: navigation/popup denial, sandboxed windows, per-channel IPC sender+argument validation, deny-by-default permissions (display capture only for the capture worker), intent-specific shell access replacing arbitrary shell:openPath
|
||||||
|
3 Truthful local-first AI/privacy contract: raw keystroke capture off by default, AbortController timeouts + cancellation + concurrency + image-size limits, loopback-only Ollama unless explicitly opted in, honest docs + new docs/PRIVACY.md
|
||||||
|
4 Optimistic revision fields + compare-and-swap (stale AI responses can't clobber edits), autosave keeps dirty state on failed saves, corrupt guides/steps quarantined instead of silently vanishing
|
||||||
|
5 Region-capture nested-result + listener-leak bugs, single-owner power blocker, explicit click-source reporting (evdev fix), strict-mode skips instead of storing post-click shots, pathToFileURL, shutdown drain
|
||||||
|
6 ZIP-bomb limits, transactional archive import, atomic snapshot restore, exclusive locks (O_EXCL + token), search index reconcile at startup, implemented the dead automatic-backup settings
|
||||||
|
7 Platform adapter layer — window-context detection extracted to app/platform/{windows,linux,darwin}/; shared code no longer branches on process.platform; Linux capability diagnostics
|
||||||
|
8 Production .deb (runtime-only staging, no dev tree, detected arch, desktop/MIME/icons, original generated icon set), apt setup scripts, real launcher; retired the old broken packager
|
||||||
|
9 Production .rpm + dnf setup, shared stage-runtime.sh so both formats never drift
|
||||||
|
10 Honest Wayland triggers (never promises per-click coordinates it can't deliver) + a least-privilege mouse-only udev rule replacing the broad input group keylogging surface; .gitattributes so Unix scripts don't break on CRLF
|
||||||
|
I verified real behavior where possible: built and inspected an actual 107 MB .deb, ran the click self-test (markers 3/3, burst 8/8), and confirmed the sandboxed app boots.
|
||||||
|
|
||||||
|
## One blocking item I did not act on — the license (needs your decision)
|
||||||
|
The plan calls this a legal release blocker that an implementation agent must not guess, and I agree. The repo currently contradicts itself:
|
||||||
|
|
||||||
|
package.json → MPL-2.0 and CONTRIBUTING requires MPL-2.0 + DCO sign-off (permits commercial use)
|
||||||
|
docs/LICENSE + README §License → Creative Commons Attribution-NonCommercial (forbids commercial use; also not a software license)
|
||||||
|
there is no root LICENSE file
|
||||||
|
MPL-2.0 and CC-BY-NC are mutually exclusive, and contributors have been signing off under a license the project doesn't actually ship. You need to pick one, then I can reconcile the SPDX field, root LICENSE, README, CONTRIBUTING, package payloads, and the About view in a single follow-up PR.
|
||||||
|
|
||||||
|
## What remains (plan PRs 11–14 — larger, several need your input)
|
||||||
|
Canonical blocks/callout-placement schema-v2 migration; lazy exports + Unicode font vendoring (needs a licensed-font choice); editor modularization + accessibility; and the signed reproducible release pipeline (needs signing keys). Also still open: the click self-test's arm/debounce scenarios fail on Linux — a pre-existing capture defect I surfaced honestly in PR 1 but didn't chase down (it needs on-device Linux capture debugging).
|
||||||
|
|
||||||
|
Tell me your license choice and I'll do the reconciliation PR; tell me which of 11–14 to prioritize and I'll take it next.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# StepForge comprehensive improvement plan
|
# 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.
|
||||||
|
|||||||
@@ -198,11 +198,18 @@ class CaptureService {
|
|||||||
notify,
|
notify,
|
||||||
screenApi = screen,
|
screenApi = screen,
|
||||||
textIntel = null,
|
textIntel = null,
|
||||||
|
powerPolicy = null,
|
||||||
}) {
|
}) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
this.getWindow = getWindow;
|
this.getWindow = getWindow;
|
||||||
this.notify = notify;
|
this.notify = notify;
|
||||||
|
// Single owner of OS power/throttling state for the capture lifecycle.
|
||||||
|
// setRecording(true) is called exactly while a session is actively
|
||||||
|
// recording (session present and not paused); setRecording(false) whenever
|
||||||
|
// it pauses or ends. No-op by default so tests and non-Electron hosts work.
|
||||||
|
this.powerPolicy = powerPolicy || { setRecording() {} };
|
||||||
|
this._recordingPower = false;
|
||||||
// Injectable for tests; the click/coordinate paths must never reach for
|
// Injectable for tests; the click/coordinate paths must never reach for
|
||||||
// the global `screen` directly so coordinate handling stays testable.
|
// the global `screen` directly so coordinate handling stays testable.
|
||||||
this.screen = screenApi;
|
this.screen = screenApi;
|
||||||
@@ -213,6 +220,10 @@ class CaptureService {
|
|||||||
this.session = null; // { guideId, paused, count, intervalSec }
|
this.session = null; // { guideId, paused, count, intervalSec }
|
||||||
this.intervalTimer = null;
|
this.intervalTimer = null;
|
||||||
this.clickWatcher = null;
|
this.clickWatcher = null;
|
||||||
|
// Explicit trigger source rather than a bare boolean, so the UI can tell
|
||||||
|
// the truth about how clicks are being captured (or that they are not):
|
||||||
|
// windows-hook | x11 | evdev-x11 | evdev-wayland | unavailable
|
||||||
|
this.clickSource = 'unavailable';
|
||||||
this.frameLoopTimer = null;
|
this.frameLoopTimer = null;
|
||||||
this.frameLoopRunning = false;
|
this.frameLoopRunning = false;
|
||||||
this.frameWaiters = [];
|
this.frameWaiters = [];
|
||||||
@@ -240,6 +251,22 @@ class CaptureService {
|
|||||||
this.warmingUp = false;
|
this.warmingUp = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile OS power state with the actual recording state. Called after
|
||||||
|
* every session transition so there is exactly one owner: the blocker is
|
||||||
|
* held iff a session exists and is not paused. Idempotent.
|
||||||
|
*/
|
||||||
|
syncPower() {
|
||||||
|
const recording = Boolean(this.session && !this.session.paused);
|
||||||
|
if (recording === this._recordingPower) return;
|
||||||
|
this._recordingPower = recording;
|
||||||
|
try {
|
||||||
|
this.powerPolicy.setRecording(recording);
|
||||||
|
} catch {
|
||||||
|
// power management is best-effort; never break capture over it
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
state() {
|
state() {
|
||||||
return this.session
|
return this.session
|
||||||
? {
|
? {
|
||||||
@@ -248,12 +275,21 @@ class CaptureService {
|
|||||||
guideId: this.session.guideId,
|
guideId: this.session.guideId,
|
||||||
count: this.session.count,
|
count: this.session.count,
|
||||||
intervalSec: this.session.intervalSec || 0,
|
intervalSec: this.session.intervalSec || 0,
|
||||||
clickCapture: Boolean(this.clickWatcher),
|
// clickCapture reflects any live global click source (xinput/evdev/
|
||||||
|
// Windows hook), not just the spawned-process watcher — evdev has no
|
||||||
|
// child process, so the old Boolean(this.clickWatcher) reported false
|
||||||
|
// while clicks were in fact being captured.
|
||||||
|
clickCapture: this.clickSource !== 'unavailable',
|
||||||
|
clickSource: this.clickSource,
|
||||||
clickCaptureAvailable: this.clickCaptureAvailable(),
|
clickCaptureAvailable: this.clickCaptureAvailable(),
|
||||||
clickFrameSource: this.streamBackend ? 'stream' : (this.frameLoopRunning ? 'loop' : 'idle'),
|
clickFrameSource: this.streamBackend ? 'stream' : (this.frameLoopRunning ? 'loop' : 'idle'),
|
||||||
strictClickFrames: this.strictClickFrames(),
|
strictClickFrames: this.strictClickFrames(),
|
||||||
}
|
}
|
||||||
: { active: false, clickCaptureAvailable: this.clickCaptureAvailable() };
|
: {
|
||||||
|
active: false,
|
||||||
|
clickSource: this.clickSource,
|
||||||
|
clickCaptureAvailable: this.clickCaptureAvailable(),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -331,6 +367,9 @@ class CaptureService {
|
|||||||
this.session = { guideId, paused: true, count: 0, intervalSec: interval };
|
this.session = { guideId, paused: true, count: 0, intervalSec: interval };
|
||||||
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
|
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
|
||||||
this.applyInterval();
|
this.applyInterval();
|
||||||
|
// A new session starts paused, so it must NOT hold the power blocker yet
|
||||||
|
// (it did before, leaking the blocker while idle). syncPower reconciles.
|
||||||
|
this.syncPower();
|
||||||
this.notify('capture:state', this.state());
|
this.notify('capture:state', this.state());
|
||||||
|
|
||||||
// (Skipped for the dev screenshot hook, which needs a visible page.)
|
// (Skipped for the dev screenshot hook, which needs a visible page.)
|
||||||
@@ -476,6 +515,9 @@ class CaptureService {
|
|||||||
this.stopFrameLoop();
|
this.stopFrameLoop();
|
||||||
this.stopClickFrameBackend();
|
this.stopClickFrameBackend();
|
||||||
}
|
}
|
||||||
|
// Recording only while unpaused: this is the one place tray/second-instance
|
||||||
|
// pauses previously bypassed, leaking the power blocker. syncPower owns it.
|
||||||
|
this.syncPower();
|
||||||
if (this.rebuildTrayMenu) this.rebuildTrayMenu();
|
if (this.rebuildTrayMenu) this.rebuildTrayMenu();
|
||||||
this.notify('capture:state', this.state());
|
this.notify('capture:state', this.state());
|
||||||
}
|
}
|
||||||
@@ -555,6 +597,9 @@ class CaptureService {
|
|||||||
this.stopClickFrameBackend();
|
this.stopClickFrameBackend();
|
||||||
this.destroySessionTray();
|
this.destroySessionTray();
|
||||||
this.session = null;
|
this.session = null;
|
||||||
|
// A finished session never records: release the power blocker here so it
|
||||||
|
// can't outlive the recording (finish from any path — bar, tray, quit).
|
||||||
|
this.syncPower();
|
||||||
if (this.hiddenForSession) {
|
if (this.hiddenForSession) {
|
||||||
this.hiddenForSession = false;
|
this.hiddenForSession = false;
|
||||||
this.showWindow();
|
this.showWindow();
|
||||||
@@ -562,6 +607,23 @@ class CaptureService {
|
|||||||
this.notify('capture:state', this.state());
|
this.notify('capture:state', this.state());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort drain of clicks still encoding in the queue, for application
|
||||||
|
* shutdown. Resolves when the queue settles or the deadline passes so quit
|
||||||
|
* is never blocked indefinitely. Clicks captured mid-session are pinned to
|
||||||
|
* their guide id and stored even after the session ends (see onOsClick).
|
||||||
|
*/
|
||||||
|
async drainPendingClicks(timeoutMs = 2000) {
|
||||||
|
try {
|
||||||
|
await Promise.race([
|
||||||
|
this.clickQueue.catch(() => {}),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, Math.max(0, timeoutMs))),
|
||||||
|
]);
|
||||||
|
} catch {
|
||||||
|
// best effort
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when the user is interacting with StepForge itself. Deliberately
|
* True when the user is interacting with StepForge itself. Deliberately
|
||||||
* based on cursor position over the visible window, not isFocused():
|
* based on cursor position over the visible window, not isFocused():
|
||||||
@@ -624,11 +686,22 @@ class CaptureService {
|
|||||||
if (result.ok) this.noteStepAdded(result.step, trigger, guideId);
|
if (result.ok) this.noteStepAdded(result.step, trigger, guideId);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
// No usable frame: fall through to a one-off fresh shot — but only
|
|
||||||
// while still recording. After a stop, a fresh shot would show
|
|
||||||
// whatever replaced the user's workflow on screen.
|
|
||||||
clog('click@', clickAt, 'no frame qualified — falling back to a fresh (post-click) shot');
|
|
||||||
if (!sessionLive) return { ok: false, reason: 'session ended before the fallback shot' };
|
if (!sessionLive) return { ok: false, reason: 'session ended before the fallback shot' };
|
||||||
|
// No usable pre-click frame. In strict mode a fresh shot now would be a
|
||||||
|
// POST-click frame — exactly what strict mode promises never to store.
|
||||||
|
// Skip with a visible diagnostic instead of silently labeling a
|
||||||
|
// post-click fallback as strict. Non-strict mode keeps the legacy
|
||||||
|
// fresh-shot fallback below.
|
||||||
|
if (this.strictClickFrames()) {
|
||||||
|
clog('click@', clickAt, 'strict mode — no pre-click frame; skipping rather than storing a post-click shot');
|
||||||
|
this.notify('capture:diagnostic', {
|
||||||
|
kind: 'strict-click-skipped',
|
||||||
|
guideId,
|
||||||
|
reason: 'No pre-click frame was ready for this click. In strict timing mode StepForge skips the shot rather than capturing the screen after the click.',
|
||||||
|
});
|
||||||
|
return { ok: false, reason: 'strict mode: no pre-click frame available for this click' };
|
||||||
|
}
|
||||||
|
clog('click@', clickAt, 'no frame qualified — falling back to a fresh (post-click) shot');
|
||||||
}
|
}
|
||||||
|
|
||||||
// For non-click triggers (interval, hotkey, manual) pull the latest frame
|
// For non-click triggers (interval, hotkey, manual) pull the latest frame
|
||||||
@@ -1009,6 +1082,7 @@ class CaptureService {
|
|||||||
? ['stdbuf', '-oL', 'xinput', 'test-xi2', '--root']
|
? ['stdbuf', '-oL', 'xinput', 'test-xi2', '--root']
|
||||||
: ['xinput', 'test-xi2', '--root'];
|
: ['xinput', 'test-xi2', '--root'];
|
||||||
this.clickWatcher = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'ignore'] });
|
this.clickWatcher = spawn(argv[0], argv.slice(1), { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||||
|
this.clickSource = 'x11';
|
||||||
this.clickWatcher.stdout.on('data', (chunk) => {
|
this.clickWatcher.stdout.on('data', (chunk) => {
|
||||||
this.ingestClickWatcherChunk(chunk.toString(), 'linux');
|
this.ingestClickWatcherChunk(chunk.toString(), 'linux');
|
||||||
});
|
});
|
||||||
@@ -1025,6 +1099,11 @@ class CaptureService {
|
|||||||
// by other processes and a polling loop can miss short clicks under
|
// by other processes and a polling loop can miss short clicks under
|
||||||
// load; WH_MOUSE_LL gives us one event for each button-down, with the
|
// load; WH_MOUSE_LL gives us one event for each button-down, with the
|
||||||
// hook-time cursor position and timestamp.
|
// hook-time cursor position and timestamp.
|
||||||
|
//
|
||||||
|
// Raw typed-text capture is a keylogging surface, so the hook only
|
||||||
|
// emits printable CHAR events when the user explicitly opted in; by
|
||||||
|
// default the characters never even cross the process boundary.
|
||||||
|
const captureTypedText = this.settings.get('capture.captureTypedText') ? 'true' : 'false';
|
||||||
const ps = `
|
const ps = `
|
||||||
$ErrorActionPreference = 'Stop'
|
$ErrorActionPreference = 'Stop'
|
||||||
Add-Type -TypeDefinition @'
|
Add-Type -TypeDefinition @'
|
||||||
@@ -1054,6 +1133,7 @@ public static class SFHook {
|
|||||||
private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
|
private const uint PROCESS_POWER_THROTTLING_EXECUTION_SPEED = 0x1;
|
||||||
private const uint HIGH_PRIORITY_CLASS = 0x00000080;
|
private const uint HIGH_PRIORITY_CLASS = 0x00000080;
|
||||||
|
|
||||||
|
private static readonly bool CaptureTypedText = ${captureTypedText};
|
||||||
private static IntPtr hook = IntPtr.Zero;
|
private static IntPtr hook = IntPtr.Zero;
|
||||||
private static IntPtr keyHook = IntPtr.Zero;
|
private static IntPtr keyHook = IntPtr.Zero;
|
||||||
private static LowLevelMouseProc proc = MouseHookCallback;
|
private static LowLevelMouseProc proc = MouseHookCallback;
|
||||||
@@ -1306,8 +1386,10 @@ public static class SFHook {
|
|||||||
queue.Enqueue("KEY Escape " + unixMs); signal.Set();
|
queue.Enqueue("KEY Escape " + unixMs); signal.Set();
|
||||||
} else if (vk == 0x0D) {
|
} else if (vk == 0x0D) {
|
||||||
queue.Enqueue("KEY Enter " + unixMs); signal.Set();
|
queue.Enqueue("KEY Enter " + unixMs); signal.Set();
|
||||||
} else {
|
} else if (CaptureTypedText) {
|
||||||
// Map to Unicode character using current keyboard layout + shift state.
|
// Map to Unicode character using current keyboard layout + shift
|
||||||
|
// state. Only reached when the user opted into typed-text capture;
|
||||||
|
// otherwise raw characters are never read or emitted.
|
||||||
byte[] ks = new byte[256];
|
byte[] ks = new byte[256];
|
||||||
GetKeyboardState(ks);
|
GetKeyboardState(ks);
|
||||||
var sb = new System.Text.StringBuilder(4);
|
var sb = new System.Text.StringBuilder(4);
|
||||||
@@ -1366,6 +1448,7 @@ public static class SFHook {
|
|||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
windowsHide: true,
|
windowsHide: true,
|
||||||
});
|
});
|
||||||
|
this.clickSource = 'windows-hook';
|
||||||
this.clickWatcher.stdout.on('data', (chunk) => {
|
this.clickWatcher.stdout.on('data', (chunk) => {
|
||||||
this.ingestClickWatcherChunk(chunk.toString(), 'win32');
|
this.ingestClickWatcherChunk(chunk.toString(), 'win32');
|
||||||
});
|
});
|
||||||
@@ -1417,6 +1500,7 @@ public static class SFHook {
|
|||||||
this.clickWatcher = null;
|
this.clickWatcher = null;
|
||||||
}
|
}
|
||||||
this.stopEvdevWatcher();
|
this.stopEvdevWatcher();
|
||||||
|
this.clickSource = 'unavailable';
|
||||||
this.clickWatcherBuf = '';
|
this.clickWatcherBuf = '';
|
||||||
this.linuxEvent = null;
|
this.linuxEvent = null;
|
||||||
this.discardPendingRawClick();
|
this.discardPendingRawClick();
|
||||||
@@ -1442,26 +1526,48 @@ public static class SFHook {
|
|||||||
buf = rest;
|
buf = rest;
|
||||||
for (const button of presses) this.onOsClick(Date.now(), null, button);
|
for (const button of presses) this.onOsClick(Date.now(), null, button);
|
||||||
});
|
});
|
||||||
// A device can disappear (unplugged); just drop that stream.
|
// A device can disappear (unplugged): drop that stream, and if it was
|
||||||
stream.on('error', () => {});
|
// the last live click source, fall back like any other watcher loss
|
||||||
|
// instead of silently going dark.
|
||||||
|
stream.on('error', () => this.handleEvdevStreamLoss(stream, 'device read error'));
|
||||||
|
stream.on('close', () => this.handleEvdevStreamLoss(stream, 'device closed'));
|
||||||
this.evdevStreams.push(stream);
|
this.evdevStreams.push(stream);
|
||||||
} catch {
|
} catch {
|
||||||
// Node became unreadable between enumeration and open — skip it.
|
// Node became unreadable between enumeration and open — skip it.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!this.evdevStreams.length) {
|
if (!this.evdevStreams.length) {
|
||||||
console.error('[stepforge] no readable mouse input devices — add your user to the "input" group for per-click capture: sudo usermod -aG input "$USER" (then log out and back in)');
|
this.clickSource = 'unavailable';
|
||||||
|
console.error('[stepforge] no readable mouse input devices for per-click capture (see docs/linux for the least-privilege device-access setup)');
|
||||||
} else {
|
} else {
|
||||||
|
// evdev carries no cursor position on Wayland (no marker); on X11 without
|
||||||
|
// xinput it is the click source but likewise has no root coordinates.
|
||||||
|
this.clickSource = this.onWayland() ? 'evdev-wayland' : 'evdev-x11';
|
||||||
console.log(`[stepforge] per-click capture via evdev on ${this.evdevStreams.length} device(s)${this.onWayland() ? ' (Wayland: no click marker)' : ''}`);
|
console.log(`[stepforge] per-click capture via evdev on ${this.evdevStreams.length} device(s)${this.onWayland() ? ' (Wayland: no click marker)' : ''}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One evdev device stream ended; drop it and fall back if none remain. */
|
||||||
|
handleEvdevStreamLoss(stream, reason) {
|
||||||
|
if (!this.evdevStreams) return; // already stopped deliberately
|
||||||
|
const idx = this.evdevStreams.indexOf(stream);
|
||||||
|
if (idx === -1) return;
|
||||||
|
this.evdevStreams.splice(idx, 1);
|
||||||
|
try { stream.destroy(); } catch { /* already gone */ }
|
||||||
|
if (this.evdevStreams.length === 0) {
|
||||||
|
this.evdevStreams = null;
|
||||||
|
this.clickSource = 'unavailable';
|
||||||
|
this.handleClickWatcherLoss(`evdev ${reason}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
stopEvdevWatcher() {
|
stopEvdevWatcher() {
|
||||||
if (!this.evdevStreams) return;
|
if (!this.evdevStreams) return;
|
||||||
for (const stream of this.evdevStreams) {
|
const streams = this.evdevStreams;
|
||||||
|
this.evdevStreams = null; // clear first so close handlers no-op
|
||||||
|
for (const stream of streams) {
|
||||||
try { stream.destroy(); } catch { /* already closed */ }
|
try { stream.destroy(); } catch { /* already closed */ }
|
||||||
}
|
}
|
||||||
this.evdevStreams = null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1790,6 +1896,11 @@ public static class SFHook {
|
|||||||
this._keyLastAt = now;
|
this._keyLastAt = now;
|
||||||
|
|
||||||
if (type === 'CHAR') {
|
if (type === 'CHAR') {
|
||||||
|
// Raw typed-text capture is off by default: it can record passwords or
|
||||||
|
// other secrets. Only buffer printable characters when the user has
|
||||||
|
// explicitly opted in via capture.captureTypedText. Shortcut/navigation
|
||||||
|
// keys below are unaffected.
|
||||||
|
if (!this.settings.get('capture.captureTypedText')) return;
|
||||||
const ch = typeof data === 'number' ? String.fromCharCode(data) : String(data);
|
const ch = typeof data === 'number' ? String.fromCharCode(data) : String(data);
|
||||||
this._keyBuffer = (this._keyBuffer + ch).slice(-200);
|
this._keyBuffer = (this._keyBuffer + ch).slice(-200);
|
||||||
} else if (type === 'KEY') {
|
} else if (type === 'KEY') {
|
||||||
@@ -1994,13 +2105,43 @@ public static class SFHook {
|
|||||||
const cropped = image.crop(rect);
|
const cropped = image.crop(rect);
|
||||||
const size = cropped.getSize();
|
const size = cropped.getSize();
|
||||||
if (!size.width || !size.height) return { ok: false, reason: 'empty selection' };
|
if (!size.width || !size.height) return { ok: false, reason: 'empty selection' };
|
||||||
const step = await this.storeFrameAsStep(guideId, 'region', {
|
// storeFrameAsStep already returns { ok, step }; return it directly so
|
||||||
|
// callers see result.step.stepId — not result.step.step.stepId, which is
|
||||||
|
// what wrapping it in another { ok, step } produced (region selection and
|
||||||
|
// region auto-documentation both read result.step.stepId).
|
||||||
|
return this.storeFrameAsStep(guideId, 'region', {
|
||||||
image: cropped,
|
image: cropped,
|
||||||
size,
|
size,
|
||||||
display,
|
display,
|
||||||
cursor: null,
|
cursor: null,
|
||||||
}, null, null);
|
}, null, null);
|
||||||
return { ok: true, step };
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert an overlay selection (display px) into an image-space crop rect,
|
||||||
|
* clamped to the image bounds. Returns null for an empty/invalid rect.
|
||||||
|
*/
|
||||||
|
overlayRectToImageRect(rect, display, imgSize) {
|
||||||
|
if (!rect || !imgSize) return null;
|
||||||
|
const { width: iw, height: ih } = imgSize;
|
||||||
|
if (!iw || !ih) return null;
|
||||||
|
const sx = iw / display.bounds.width;
|
||||||
|
const sy = ih / display.bounds.height;
|
||||||
|
const toNum = (v) => (Number.isFinite(v) ? v : 0);
|
||||||
|
let x = Math.round(toNum(rect.x) * sx);
|
||||||
|
let y = Math.round(toNum(rect.y) * sy);
|
||||||
|
let w = Math.round(toNum(rect.w) * sx);
|
||||||
|
let h = Math.round(toNum(rect.h) * sy);
|
||||||
|
// Normalize negative-size drags (drawn up/left).
|
||||||
|
if (w < 0) { x += w; w = -w; }
|
||||||
|
if (h < 0) { y += h; h = -h; }
|
||||||
|
// Clamp to the image so image.crop can never read out of bounds.
|
||||||
|
x = Math.min(Math.max(0, x), iw);
|
||||||
|
y = Math.min(Math.max(0, y), ih);
|
||||||
|
w = Math.min(w, iw - x);
|
||||||
|
h = Math.min(h, ih - y);
|
||||||
|
if (w <= 0 || h <= 0) return null;
|
||||||
|
return { x, y, width: w, height: h };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Fullscreen overlay window that resolves with a crop rect (image px). */
|
/** Fullscreen overlay window that resolves with a crop rect (image px). */
|
||||||
@@ -2019,33 +2160,34 @@ public static class SFHook {
|
|||||||
webPreferences: {
|
webPreferences: {
|
||||||
preload: path.join(__dirname, 'region-preload.js'),
|
preload: path.join(__dirname, 'region-preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// The overlay may only display region.html; deny navigation/popups.
|
||||||
|
require('./security').installWindowSecurity(overlay, 'region');
|
||||||
|
const { ipcMain } = require('electron');
|
||||||
let settled = false;
|
let settled = false;
|
||||||
|
// Idempotent cleanup: remove the IPC listener and close the overlay
|
||||||
|
// exactly once, whether the user picked, cancelled, closed, or the page
|
||||||
|
// failed to load. Previously the listener was only removed on a pick, so
|
||||||
|
// cancelling/closing leaked it (and the captured overlay/image refs).
|
||||||
const finish = (rect) => {
|
const finish = (rect) => {
|
||||||
if (settled) return;
|
if (settled) return;
|
||||||
settled = true;
|
settled = true;
|
||||||
|
ipcMain.removeListener('region:picked', onPick);
|
||||||
if (!overlay.isDestroyed()) overlay.close();
|
if (!overlay.isDestroyed()) overlay.close();
|
||||||
resolve(rect);
|
resolve(rect);
|
||||||
};
|
};
|
||||||
const { ipcMain } = require('electron');
|
|
||||||
const onPick = (event, rect) => {
|
const onPick = (event, rect) => {
|
||||||
if (event.sender !== overlay.webContents) return;
|
if (event.sender !== overlay.webContents) return;
|
||||||
ipcMain.removeListener('region:picked', onPick);
|
|
||||||
if (!rect) return finish(null);
|
if (!rect) return finish(null);
|
||||||
const imgSize = image.getSize();
|
finish(this.overlayRectToImageRect(rect, display, image.getSize()));
|
||||||
const sx = imgSize.width / display.bounds.width;
|
|
||||||
const sy = imgSize.height / display.bounds.height;
|
|
||||||
finish({
|
|
||||||
x: Math.round(rect.x * sx),
|
|
||||||
y: Math.round(rect.y * sy),
|
|
||||||
width: Math.round(rect.w * sx),
|
|
||||||
height: Math.round(rect.h * sy),
|
|
||||||
});
|
|
||||||
};
|
};
|
||||||
ipcMain.on('region:picked', onPick);
|
ipcMain.on('region:picked', onPick);
|
||||||
overlay.on('closed', () => finish(null));
|
overlay.on('closed', () => finish(null));
|
||||||
overlay.loadFile(path.join(__dirname, 'renderer', 'region.html'));
|
overlay.webContents.on('did-fail-load', () => finish(null));
|
||||||
|
overlay.loadFile(path.join(__dirname, 'renderer', 'region.html')).catch(() => finish(null));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const os = require('node:os');
|
const os = require('node:os');
|
||||||
|
const { pathToFileURL } = require('node:url');
|
||||||
const {
|
const {
|
||||||
app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
|
app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut,
|
||||||
clipboard, nativeImage, screen, powerSaveBlocker, session, desktopCapturer,
|
clipboard, nativeImage, screen, powerSaveBlocker, session, desktopCapturer,
|
||||||
@@ -15,12 +16,13 @@ const { TemplateManager, FORMATS, FORMAT_LABELS } = require('../core/templates')
|
|||||||
const { buildRenderAst } = require('../core/renderast');
|
const { buildRenderAst } = require('../core/renderast');
|
||||||
const { runExport, EXPORTERS } = require('../exporters');
|
const { runExport, EXPORTERS } = require('../exporters');
|
||||||
const { runExportInWorker } = require('./export-runner');
|
const { runExportInWorker } = require('./export-runner');
|
||||||
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
|
const { exportGuideArchive, importGuideArchive, saveLinkedGuide } = require('../core/archive');
|
||||||
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
|
const { createSnapshot, listSnapshots, restoreSnapshot, autoSnapshotIfDue } = require('../core/snapshots');
|
||||||
const { readLock } = require('../core/locks');
|
const { readLock } = require('../core/locks');
|
||||||
const CaptureService = require('./capture');
|
const CaptureService = require('./capture');
|
||||||
const { TextIntelService } = require('./text-intel');
|
const { TextIntelService } = require('./text-intel');
|
||||||
const { keepProcessesResponsive } = require('./win-power');
|
const { keepProcessesResponsive } = require('./win-power');
|
||||||
|
const security = require('./security');
|
||||||
const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json'));
|
const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json'));
|
||||||
|
|
||||||
const APP_ID = 'com.stepforge.app';
|
const APP_ID = 'com.stepforge.app';
|
||||||
@@ -70,6 +72,9 @@ function reindex(guideId) {
|
|||||||
} catch {
|
} catch {
|
||||||
// index failures must never block saves
|
// index failures must never block saves
|
||||||
}
|
}
|
||||||
|
// Automatic backup policy runs on the same save choke point. It is
|
||||||
|
// self-contained and never throws, so it can't affect the save either.
|
||||||
|
autoSnapshotIfDue(store, guideId, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
function orderedSteps(guideId) {
|
function orderedSteps(guideId) {
|
||||||
@@ -95,6 +100,7 @@ function createWindow() {
|
|||||||
preload: path.join(__dirname, 'preload.js'),
|
preload: path.join(__dirname, 'preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
spellcheck: Boolean(settings.get('spellcheck')),
|
spellcheck: Boolean(settings.get('spellcheck')),
|
||||||
// During a recording the window is minimized (Linux) or hidden (Windows).
|
// During a recording the window is minimized (Linux) or hidden (Windows).
|
||||||
// A throttled renderer stops processing capture:added events, so the step
|
// A throttled renderer stops processing capture:added events, so the step
|
||||||
@@ -103,6 +109,10 @@ function createWindow() {
|
|||||||
backgroundThrottling: false,
|
backgroundThrottling: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// The main window may only ever display our index.html: all navigation
|
||||||
|
// away from it and every popup is denied, so no other document can run
|
||||||
|
// with this window's preload bridge.
|
||||||
|
security.installWindowSecurity(mainWindow, 'main');
|
||||||
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
||||||
mainWindow.once('ready-to-show', () => {
|
mainWindow.once('ready-to-show', () => {
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
@@ -198,7 +208,12 @@ function createWindow() {
|
|||||||
// Second scenario, reproducing the "I clicked many times but only
|
// Second scenario, reproducing the "I clicked many times but only
|
||||||
// got two screenshots" report: a fast burst of clicks immediately
|
// got two screenshots" report: a fast burst of clicks immediately
|
||||||
// followed by finishing the session, so most clicks are still
|
// followed by finishing the session, so most clicks are still
|
||||||
// queued (frames still encoding) when the stop lands.
|
// queued (frames still encoding) when the stop lands. This scenario
|
||||||
|
// tests the queue DRAIN, not strict timing — 30ms-apart clicks
|
||||||
|
// outpace the frame sampler, so run it in balanced mode where every
|
||||||
|
// queued click stores. (Strict-mode skip-vs-store is covered by the
|
||||||
|
// marker scenario above and by unit tests.)
|
||||||
|
settings.set('capture.strictClickFrames', false);
|
||||||
const burstGuide = store.createGuide({ title: 'burst selftest' });
|
const burstGuide = store.createGuide({ title: 'burst selftest' });
|
||||||
capture.startSession(burstGuide.guideId, { intervalSec: 0 });
|
capture.startSession(burstGuide.guideId, { intervalSec: 0 });
|
||||||
capture.stopClickWatcher();
|
capture.stopClickWatcher();
|
||||||
@@ -222,6 +237,7 @@ function createWindow() {
|
|||||||
const burstSteps = store.getGuide(burstGuide.guideId).stepsOrder.length;
|
const burstSteps = store.getGuide(burstGuide.guideId).stepsOrder.length;
|
||||||
console.log('CLICK-SELFTEST burst:', burstSteps, 'of', burstCount,
|
console.log('CLICK-SELFTEST burst:', burstSteps, 'of', burstCount,
|
||||||
burstSteps === burstCount ? 'OK — no clicks dropped on finish' : 'FAIL — clicks lost');
|
burstSteps === burstCount ? 'OK — no clicks dropped on finish' : 'FAIL — clicks lost');
|
||||||
|
settings.set('capture.strictClickFrames', true); // restore for later scenarios
|
||||||
|
|
||||||
// Helper: wait until armRecording has finished warming (window
|
// Helper: wait until armRecording has finished warming (window
|
||||||
// hidden, buffer primed) so an injected click counts as a real
|
// hidden, buffer primed) so an injected click counts as a real
|
||||||
@@ -375,7 +391,37 @@ function sendToRenderer(channel, payload) {
|
|||||||
// ---- IPC ------------------------------------------------------------------
|
// ---- IPC ------------------------------------------------------------------
|
||||||
|
|
||||||
function setupIpc() {
|
function setupIpc() {
|
||||||
const h = (channel, fn) => ipcMain.handle(channel, async (event, args = {}) => fn(args));
|
// Every invoke channel is guarded: the event must come from the current
|
||||||
|
// main window's top frame showing our index.html, the argument bag must be
|
||||||
|
// a plain object within a per-channel payload budget, and channels with
|
||||||
|
// risky inputs additionally validate fields before the handler runs.
|
||||||
|
const trustedSender = security.makeIpcSenderGuard({
|
||||||
|
getMainWebContents: () => (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null),
|
||||||
|
});
|
||||||
|
const c = security.check;
|
||||||
|
const IMAGE_BUDGET = 256 * 1024 * 1024; // channels that carry base64 PNGs
|
||||||
|
const h = (channel, fn, opts = {}) => {
|
||||||
|
const { maxChars = 2 * 1024 * 1024, validate = null } = opts;
|
||||||
|
ipcMain.handle(channel, async (event, args = {}) => {
|
||||||
|
if (!trustedSender(event)) {
|
||||||
|
throw new Error(`${channel}: rejected — untrusted IPC sender`);
|
||||||
|
}
|
||||||
|
const a = args === undefined || args === null ? {} : args;
|
||||||
|
if (!security.isPlainArgs(a) || !security.payloadWithinBudget(a, maxChars)) {
|
||||||
|
throw new Error(`${channel}: rejected — invalid or oversized arguments`);
|
||||||
|
}
|
||||||
|
if (validate && !validate(a)) {
|
||||||
|
throw new Error(`${channel}: rejected — arguments failed validation`);
|
||||||
|
}
|
||||||
|
return fn(a);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Files the main process itself produced (exports, previews); only these
|
||||||
|
// may be re-opened via the shell on renderer request.
|
||||||
|
const producedFiles = new security.ProducedFiles();
|
||||||
|
// Output directories the user actually picked in a dialog this session.
|
||||||
|
const chosenOutputDirs = new Set();
|
||||||
|
|
||||||
// library
|
// library
|
||||||
h('library:list', () => ({
|
h('library:list', () => ({
|
||||||
@@ -393,60 +439,71 @@ function setupIpc() {
|
|||||||
});
|
});
|
||||||
reindex(guide.guideId);
|
reindex(guide.guideId);
|
||||||
return guide;
|
return guide;
|
||||||
});
|
}, { validate: (a) => c.optionalString(a.title, 500) });
|
||||||
h('library:duplicate', ({ guideId }) => {
|
h('library:duplicate', ({ guideId }) => {
|
||||||
const copy = store.duplicateGuide(guideId);
|
const copy = store.duplicateGuide(guideId);
|
||||||
reindex(copy.guideId);
|
reindex(copy.guideId);
|
||||||
return copy;
|
return copy;
|
||||||
});
|
}, { validate: (a) => c.id(a.guideId) });
|
||||||
h('library:delete', ({ guideId }) => {
|
h('library:delete', ({ guideId }) => {
|
||||||
store.deleteGuide(guideId);
|
store.deleteGuide(guideId);
|
||||||
searchIndex.removeGuide(guideId);
|
searchIndex.removeGuide(guideId);
|
||||||
return true;
|
return true;
|
||||||
});
|
}, { validate: (a) => c.id(a.guideId) });
|
||||||
h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite));
|
h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite),
|
||||||
|
{ validate: (a) => c.id(a.guideId) });
|
||||||
h('library:trash:list', () => store.listTrash());
|
h('library:trash:list', () => store.listTrash());
|
||||||
h('library:trash:restore', ({ name }) => {
|
h('library:trash:restore', ({ name }) => {
|
||||||
const id = store.restoreFromTrash(name);
|
const id = store.restoreFromTrash(name);
|
||||||
reindex(id);
|
reindex(id);
|
||||||
return id;
|
return id;
|
||||||
});
|
}, { validate: (a) => c.fileName(a.name) });
|
||||||
h('library:trash:purge', ({ names } = {}) => {
|
h('library:trash:purge', ({ names } = {}) => {
|
||||||
if (names && names.length) store.purgeTrashItems(names);
|
if (names && names.length) store.purgeTrashItems(names);
|
||||||
else store.purgeTrash();
|
else store.purgeTrash();
|
||||||
return true;
|
return true;
|
||||||
|
}, {
|
||||||
|
validate: (a) => a.names === undefined || a.names === null
|
||||||
|
|| (Array.isArray(a.names) && a.names.length <= 1000 && a.names.every((n) => c.fileName(n))),
|
||||||
});
|
});
|
||||||
h('folders:create', ({ name, parentId }) => store.createFolder(name, parentId || null));
|
h('folders:create', ({ name, parentId }) => store.createFolder(name, parentId || null),
|
||||||
h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name));
|
{ validate: (a) => c.string(a.name, 200) && c.optionalId(a.parentId) });
|
||||||
h('folders:delete', ({ folderId }) => store.deleteFolder(folderId));
|
h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name),
|
||||||
h('folders:moveGuide', ({ guideId, folderId }) => store.moveGuideToFolder(guideId, folderId || null));
|
{ validate: (a) => c.id(a.folderId) && c.string(a.name, 200) });
|
||||||
|
h('folders:delete', ({ folderId }) => store.deleteFolder(folderId),
|
||||||
|
{ validate: (a) => c.id(a.folderId) });
|
||||||
|
h('folders:moveGuide', ({ guideId, folderId }) => store.moveGuideToFolder(guideId, folderId || null),
|
||||||
|
{ validate: (a) => c.id(a.guideId) && c.optionalId(a.folderId) });
|
||||||
|
|
||||||
// guide + steps
|
// guide + steps
|
||||||
h('guide:get', ({ guideId }) => ({
|
h('guide:get', ({ guideId }) => ({
|
||||||
guide: store.getGuide(guideId),
|
guide: store.getGuide(guideId),
|
||||||
steps: orderedSteps(guideId),
|
steps: orderedSteps(guideId),
|
||||||
}));
|
}), { validate: (a) => c.id(a.guideId) });
|
||||||
h('guide:save', ({ guide }) => {
|
h('guide:save', ({ guide }) => {
|
||||||
const saved = store.saveGuide(guide);
|
const saved = store.saveGuide(guide);
|
||||||
reindex(guide.guideId);
|
reindex(guide.guideId);
|
||||||
return saved;
|
return saved;
|
||||||
});
|
}, { validate: (a) => security.isPlainArgs(a.guide) && a.guide && c.id(a.guide.guideId) });
|
||||||
h('step:add', ({ guideId, fields, imageBase64, size, position }) => {
|
h('step:add', ({ guideId, fields, imageBase64, size, position }) => {
|
||||||
const buf = imageBase64 ? Buffer.from(imageBase64, 'base64') : null;
|
const buf = imageBase64 ? Buffer.from(imageBase64, 'base64') : null;
|
||||||
const step = store.addStep(guideId, fields || {}, buf, size || null, { position });
|
const step = store.addStep(guideId, fields || {}, buf, size || null, { position });
|
||||||
reindex(guideId);
|
reindex(guideId);
|
||||||
return step;
|
return step;
|
||||||
|
}, {
|
||||||
|
maxChars: IMAGE_BUDGET,
|
||||||
|
validate: (a) => c.id(a.guideId) && c.optionalBase64(a.imageBase64) && c.optionalNumber(a.position, 0, 100000),
|
||||||
});
|
});
|
||||||
h('step:save', ({ guideId, step }) => {
|
h('step:save', ({ guideId, step }) => {
|
||||||
const saved = store.saveStep(guideId, step);
|
const saved = store.saveStep(guideId, step);
|
||||||
reindex(guideId);
|
reindex(guideId);
|
||||||
return saved;
|
return saved;
|
||||||
});
|
}, { validate: (a) => c.id(a.guideId) && security.isPlainArgs(a.step) && a.step && c.id(a.step.stepId) });
|
||||||
h('step:delete', ({ guideId, stepId }) => {
|
h('step:delete', ({ guideId, stepId }) => {
|
||||||
store.deleteStep(guideId, stepId);
|
store.deleteStep(guideId, stepId);
|
||||||
reindex(guideId);
|
reindex(guideId);
|
||||||
return true;
|
return true;
|
||||||
});
|
}, { validate: (a) => c.id(a.guideId) && c.id(a.stepId) });
|
||||||
h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => {
|
h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => {
|
||||||
const images = {
|
const images = {
|
||||||
original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null,
|
original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null,
|
||||||
@@ -455,20 +512,39 @@ function setupIpc() {
|
|||||||
const restored = store.restoreStep(guideId, step, images, position);
|
const restored = store.restoreStep(guideId, step, images, position);
|
||||||
reindex(guideId);
|
reindex(guideId);
|
||||||
return restored;
|
return restored;
|
||||||
|
}, {
|
||||||
|
maxChars: IMAGE_BUDGET,
|
||||||
|
validate: (a) => c.id(a.guideId) && security.isPlainArgs(a.step) && a.step
|
||||||
|
&& c.optionalBase64(a.originalBase64) && c.optionalBase64(a.workingBase64)
|
||||||
|
&& c.optionalNumber(a.position, 0, 100000),
|
||||||
|
});
|
||||||
|
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order), {
|
||||||
|
validate: (a) => c.id(a.guideId)
|
||||||
|
&& Array.isArray(a.order) && a.order.length <= 100000 && a.order.every((id) => c.id(id)),
|
||||||
});
|
});
|
||||||
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order));
|
|
||||||
h('step:imagePath', ({ guideId, stepId, which }) => {
|
h('step:imagePath', ({ guideId, stepId, which }) => {
|
||||||
const p = store.stepImagePath(guideId, stepId, which || 'working');
|
const p = store.stepImagePath(guideId, stepId, which || 'working');
|
||||||
return p && fs.existsSync(p) ? `file://${p}?v=${fs.statSync(p).mtimeMs}` : null;
|
if (!p || !fs.existsSync(p)) return null;
|
||||||
|
// pathToFileURL correctly encodes spaces, #, %, drive letters, etc.; the
|
||||||
|
// mtime is a cache-buster so the renderer reloads after an edit.
|
||||||
|
const url = pathToFileURL(p);
|
||||||
|
url.searchParams.set('v', String(fs.statSync(p).mtimeMs));
|
||||||
|
return url.href;
|
||||||
|
}, {
|
||||||
|
validate: (a) => c.id(a.guideId) && c.id(a.stepId)
|
||||||
|
&& (a.which === undefined || a.which === null || c.oneOf(a.which, ['original', 'working'])),
|
||||||
});
|
});
|
||||||
h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size, step }) =>
|
h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size, step }) =>
|
||||||
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null));
|
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null), {
|
||||||
|
maxChars: IMAGE_BUDGET,
|
||||||
|
validate: (a) => c.id(a.guideId) && c.id(a.stepId) && c.base64(a.pngBase64),
|
||||||
|
});
|
||||||
h('step:resetWorkingImage', ({ guideId, stepId }) => {
|
h('step:resetWorkingImage', ({ guideId, stepId }) => {
|
||||||
const p = store.stepImagePath(guideId, stepId, 'original');
|
const p = store.stepImagePath(guideId, stepId, 'original');
|
||||||
const img = nativeImage.createFromPath(p);
|
const img = nativeImage.createFromPath(p);
|
||||||
const { width, height } = img.getSize();
|
const { width, height } = img.getSize();
|
||||||
return store.resetWorkingImage(guideId, stepId, { width, height });
|
return store.resetWorkingImage(guideId, stepId, { width, height });
|
||||||
});
|
}, { validate: (a) => c.id(a.guideId) && c.id(a.stepId) });
|
||||||
h('step:fromClipboard', ({ guideId, position }) => {
|
h('step:fromClipboard', ({ guideId, position }) => {
|
||||||
const img = clipboard.readImage();
|
const img = clipboard.readImage();
|
||||||
if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' };
|
if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' };
|
||||||
@@ -479,7 +555,7 @@ function setupIpc() {
|
|||||||
}, img.toPNG(), { width, height }, { position });
|
}, img.toPNG(), { width, height }, { position });
|
||||||
reindex(guideId);
|
reindex(guideId);
|
||||||
return { ok: true, step };
|
return { ok: true, step };
|
||||||
});
|
}, { validate: (a) => c.id(a.guideId) && c.optionalNumber(a.position, 0, 100000) });
|
||||||
h('step:importImage', async ({ guideId }) => {
|
h('step:importImage', async ({ guideId }) => {
|
||||||
const res = await dialog.showOpenDialog(mainWindow, {
|
const res = await dialog.showOpenDialog(mainWindow, {
|
||||||
title: 'Import images as steps',
|
title: 'Import images as steps',
|
||||||
@@ -497,11 +573,13 @@ function setupIpc() {
|
|||||||
}
|
}
|
||||||
reindex(guideId);
|
reindex(guideId);
|
||||||
return { ok: true, steps };
|
return { ok: true, steps };
|
||||||
});
|
}, { validate: (a) => c.id(a.guideId) });
|
||||||
|
|
||||||
// search
|
// search
|
||||||
h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null }));
|
h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null }),
|
||||||
h('search:titles', ({ q }) => searchIndex.searchTitles(q));
|
{ validate: (a) => c.optionalString(a.q, 1000) && c.optionalId(a.guideId) });
|
||||||
|
h('search:titles', ({ q }) => searchIndex.searchTitles(q),
|
||||||
|
{ validate: (a) => c.optionalString(a.q, 1000) });
|
||||||
|
|
||||||
// settings + placeholders
|
// settings + placeholders
|
||||||
h('settings:all', () => settings.data);
|
h('settings:all', () => settings.data);
|
||||||
@@ -510,13 +588,13 @@ function setupIpc() {
|
|||||||
if (keyPath === 'appearance') applyTheme();
|
if (keyPath === 'appearance') applyTheme();
|
||||||
if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
|
if (keyPath.startsWith('capture.hotkey')) registerHotkeys();
|
||||||
return settings.data;
|
return settings.data;
|
||||||
});
|
}, { validate: (a) => c.settingsKeyPath(a.keyPath) });
|
||||||
h('ai:test', async ({ enabled = null, ollama = null } = {}) => {
|
h('ai:test', async ({ enabled = null, ollama = null } = {}) => {
|
||||||
return textIntel.testAiConnection({
|
return textIntel.testAiConnection({
|
||||||
enabled,
|
enabled,
|
||||||
ollama,
|
ollama,
|
||||||
});
|
});
|
||||||
});
|
}, { validate: (a) => (a.ollama === undefined || a.ollama === null || security.isPlainArgs(a.ollama)) });
|
||||||
h('ai:fillStep', async ({ guideId, stepId, target = 'all', blockId = null } = {}) => {
|
h('ai:fillStep', async ({ guideId, stepId, target = 'all', blockId = null } = {}) => {
|
||||||
const result = await textIntel.generateStepPatch({
|
const result = await textIntel.generateStepPatch({
|
||||||
guideId,
|
guideId,
|
||||||
@@ -526,10 +604,22 @@ function setupIpc() {
|
|||||||
});
|
});
|
||||||
if (result.ok) reindex(guideId);
|
if (result.ok) reindex(guideId);
|
||||||
return result;
|
return result;
|
||||||
|
}, {
|
||||||
|
validate: (a) => c.id(a.guideId) && c.id(a.stepId) && c.optionalId(a.blockId)
|
||||||
|
&& (a.target === undefined || c.oneOf(a.target, ['all', 'title', 'description', 'block'])),
|
||||||
});
|
});
|
||||||
h('ai:rewriteText', async ({ text, guideTitle = '', stepTitle = '' } = {}) => {
|
h('ai:rewriteText', async ({ text, guideTitle = '', stepTitle = '' } = {}) => {
|
||||||
return textIntel.rewriteText({ text, guideTitle, stepTitle });
|
return textIntel.rewriteText({ text, guideTitle, stepTitle });
|
||||||
|
}, {
|
||||||
|
validate: (a) => c.string(a.text, 200000)
|
||||||
|
&& c.optionalString(a.guideTitle, 1000) && c.optionalString(a.stepTitle, 1000),
|
||||||
});
|
});
|
||||||
|
// Cancel outstanding AI requests, e.g. when a guide/editor closes, so a
|
||||||
|
// slow response can't resolve against data the user has moved on from.
|
||||||
|
h('ai:cancel', ({ guideId = null } = {}) => {
|
||||||
|
textIntel.cancelInflight(guideId || null);
|
||||||
|
return true;
|
||||||
|
}, { validate: (a) => c.optionalId(a.guideId) });
|
||||||
h('placeholders:globals:get', () => settings.getGlobalPlaceholders());
|
h('placeholders:globals:get', () => settings.getGlobalPlaceholders());
|
||||||
h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values));
|
h('placeholders:globals:set', (values) => settings.setGlobalPlaceholders(values));
|
||||||
|
|
||||||
@@ -552,6 +642,10 @@ function setupIpc() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
|
}, {
|
||||||
|
validate: (a) => c.id(a.guideId)
|
||||||
|
&& (a.mode === undefined || c.oneOf(a.mode, ['fullscreen', 'window', 'region']))
|
||||||
|
&& c.optionalNumber(a.delayMs, 0, 600000),
|
||||||
});
|
});
|
||||||
h('capture:region', async ({ guideId }) => {
|
h('capture:region', async ({ guideId }) => {
|
||||||
const result = await capture.regionCapture(guideId);
|
const result = await capture.regionCapture(guideId);
|
||||||
@@ -571,50 +665,31 @@ function setupIpc() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
});
|
}, { validate: (a) => c.id(a.guideId) });
|
||||||
let capturePowerBlocker = -1;
|
|
||||||
const startCapturePower = () => {
|
|
||||||
if (!powerSaveBlocker.isStarted(capturePowerBlocker)) {
|
|
||||||
capturePowerBlocker = powerSaveBlocker.start('prevent-app-suspension');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const stopCapturePower = () => {
|
|
||||||
if (powerSaveBlocker.isStarted(capturePowerBlocker)) {
|
|
||||||
powerSaveBlocker.stop(capturePowerBlocker);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Opt every live Electron process (browser, GPU, the screen-capture utility,
|
|
||||||
// any renderers) out of EcoQoS for the duration of a recording. The hidden
|
|
||||||
// capture-worker renderer is created later, during warmup, so it opts itself
|
|
||||||
// out separately (see stream-backend.js); this covers the rest.
|
|
||||||
const keepCaptureProcessesResponsive = () => {
|
|
||||||
try {
|
|
||||||
keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid));
|
|
||||||
} catch { /* metrics unavailable — best effort */ }
|
|
||||||
};
|
|
||||||
|
|
||||||
|
// Power/throttling state is owned entirely by the capture service's
|
||||||
|
// recording transitions (see createCapturePowerPolicy) so there is exactly
|
||||||
|
// one owner: it is held iff a session is actively recording, and paused,
|
||||||
|
// finished, tray, and second-instance transitions all release it correctly.
|
||||||
h('capture:session', async ({ action, guideId, intervalSec }) => {
|
h('capture:session', async ({ action, guideId, intervalSec }) => {
|
||||||
if (action === 'start') {
|
if (action === 'start') {
|
||||||
capture.startSession(guideId, { intervalSec: intervalSec ?? null });
|
capture.startSession(guideId, { intervalSec: intervalSec ?? null });
|
||||||
startCapturePower();
|
|
||||||
keepCaptureProcessesResponsive();
|
|
||||||
} else if (action === 'pause') {
|
} else if (action === 'pause') {
|
||||||
capture.togglePause(true);
|
capture.togglePause(true);
|
||||||
stopCapturePower();
|
|
||||||
} else if (action === 'resume') {
|
} else if (action === 'resume') {
|
||||||
capture.togglePause(false);
|
capture.togglePause(false);
|
||||||
startCapturePower();
|
|
||||||
keepCaptureProcessesResponsive();
|
|
||||||
} else if (action === 'finish') {
|
} else if (action === 'finish') {
|
||||||
capture.finishSession();
|
capture.finishSession();
|
||||||
stopCapturePower();
|
|
||||||
} else if (action === 'interval') {
|
} else if (action === 'interval') {
|
||||||
capture.setInterval(intervalSec);
|
capture.setInterval(intervalSec);
|
||||||
}
|
}
|
||||||
const state = capture.state();
|
const state = capture.state();
|
||||||
sendToRenderer('capture:state', state);
|
sendToRenderer('capture:state', state);
|
||||||
return state;
|
return state;
|
||||||
|
}, {
|
||||||
|
validate: (a) => c.oneOf(a.action, ['start', 'pause', 'resume', 'finish', 'interval'])
|
||||||
|
&& (a.action !== 'start' || c.id(a.guideId))
|
||||||
|
&& c.optionalNumber(a.intervalSec, 0, 86400),
|
||||||
});
|
});
|
||||||
h('capture:state', () => capture.state());
|
h('capture:state', () => capture.state());
|
||||||
|
|
||||||
@@ -629,7 +704,7 @@ function setupIpc() {
|
|||||||
if (res.canceled) return { ok: false };
|
if (res.canceled) return { ok: false };
|
||||||
exportGuideArchive(store, guideId, res.filePath);
|
exportGuideArchive(store, guideId, res.filePath);
|
||||||
return { ok: true, path: res.filePath };
|
return { ok: true, path: res.filePath };
|
||||||
});
|
}, { validate: (a) => c.id(a.guideId) });
|
||||||
h('archive:open', async ({ mode }) => {
|
h('archive:open', async ({ mode }) => {
|
||||||
const res = await dialog.showOpenDialog(mainWindow, {
|
const res = await dialog.showOpenDialog(mainWindow, {
|
||||||
title: 'Open guide archive',
|
title: 'Open guide archive',
|
||||||
@@ -644,30 +719,45 @@ function setupIpc() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, reason: err.message };
|
return { ok: false, reason: err.message };
|
||||||
}
|
}
|
||||||
});
|
}, { validate: (a) => a.mode === undefined || c.oneOf(a.mode, ['copy', 'linked']) });
|
||||||
h('archive:peek', ({ file }) => {
|
// archive:peek was removed: nothing in the renderer used it, and it let a
|
||||||
const { manifest } = readArchive(file);
|
// compromised renderer read arbitrary local archives by path.
|
||||||
return manifest;
|
h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) }),
|
||||||
});
|
{ validate: (a) => c.id(a.guideId) });
|
||||||
h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) }));
|
|
||||||
|
|
||||||
// snapshots
|
// snapshots
|
||||||
h('snapshots:list', ({ guideId }) => listSnapshots(store, guideId));
|
h('snapshots:list', ({ guideId }) => listSnapshots(store, guideId),
|
||||||
|
{ validate: (a) => c.id(a.guideId) });
|
||||||
h('snapshots:create', ({ guideId, label }) =>
|
h('snapshots:create', ({ guideId, label }) =>
|
||||||
createSnapshot(store, guideId, { label: label || 'manual', keepLast: settings.get('backups.keepLast') }));
|
createSnapshot(store, guideId, { label: label || 'manual', keepLast: settings.get('backups.keepLast') }),
|
||||||
|
{ validate: (a) => c.id(a.guideId) && c.optionalString(a.label, 200) });
|
||||||
h('snapshots:restore', ({ guideId, name }) => {
|
h('snapshots:restore', ({ guideId, name }) => {
|
||||||
const guide = restoreSnapshot(store, guideId, name);
|
const guide = restoreSnapshot(store, guideId, name);
|
||||||
reindex(guideId);
|
reindex(guideId);
|
||||||
return guide;
|
return guide;
|
||||||
});
|
}, { validate: (a) => c.id(a.guideId) && c.fileName(a.name) });
|
||||||
|
|
||||||
|
// recovery status: corrupt files quarantined this session and search index
|
||||||
|
// health, so the UI can surface them instead of data silently vanishing.
|
||||||
|
h('recovery:status', () => ({
|
||||||
|
quarantined: store.getRecoveryReport(),
|
||||||
|
searchStatus: searchIndex.status,
|
||||||
|
}));
|
||||||
|
|
||||||
// templates
|
// templates
|
||||||
h('templates:list', ({ format }) => templates.list(format));
|
const validFormat = (v) => c.oneOf(v, FORMATS);
|
||||||
h('templates:load', ({ format, name }) => templates.load(format, name));
|
h('templates:list', ({ format }) => templates.list(format),
|
||||||
h('templates:save', ({ format, name, options }) => templates.save(format, name, options));
|
{ validate: (a) => validFormat(a.format) });
|
||||||
h('templates:delete', ({ format, name }) => templates.remove(format, name));
|
h('templates:load', ({ format, name }) => templates.load(format, name),
|
||||||
h('templates:rename', ({ format, name, newName }) => templates.rename(format, name, newName));
|
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||||
h('templates:duplicate', ({ format, name }) => templates.duplicate(format, name));
|
h('templates:save', ({ format, name, options }) => templates.save(format, name, options),
|
||||||
|
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) && security.isPlainArgs(a.options) });
|
||||||
|
h('templates:delete', ({ format, name }) => templates.remove(format, name),
|
||||||
|
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||||
|
h('templates:rename', ({ format, name, newName }) => templates.rename(format, name, newName),
|
||||||
|
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) && c.fileName(a.newName) });
|
||||||
|
h('templates:duplicate', ({ format, name }) => templates.duplicate(format, name),
|
||||||
|
{ validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||||
h('templates:export', async ({ format, name }) => {
|
h('templates:export', async ({ format, name }) => {
|
||||||
const res = await dialog.showSaveDialog(mainWindow, {
|
const res = await dialog.showSaveDialog(mainWindow, {
|
||||||
defaultPath: `${name}.sfglt`,
|
defaultPath: `${name}.sfglt`,
|
||||||
@@ -676,7 +766,7 @@ function setupIpc() {
|
|||||||
if (res.canceled) return { ok: false };
|
if (res.canceled) return { ok: false };
|
||||||
templates.exportTemplate(format, name, res.filePath);
|
templates.exportTemplate(format, name, res.filePath);
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
}, { validate: (a) => validFormat(a.format) && c.fileName(a.name) });
|
||||||
h('templates:import', async () => {
|
h('templates:import', async () => {
|
||||||
const res = await dialog.showOpenDialog(mainWindow, {
|
const res = await dialog.showOpenDialog(mainWindow, {
|
||||||
filters: [{ name: 'StepForge template', extensions: ['sfglt'] }],
|
filters: [{ name: 'StepForge template', extensions: ['sfglt'] }],
|
||||||
@@ -709,15 +799,24 @@ function setupIpc() {
|
|||||||
}[format];
|
}[format];
|
||||||
if (!mod) return {};
|
if (!mod) return {};
|
||||||
return { ...require(mod).DEFAULT_TEMPLATE };
|
return { ...require(mod).DEFAULT_TEMPLATE };
|
||||||
});
|
}, { validate: (a) => c.string(a.format, 40) });
|
||||||
h('export:run', async ({ guideId, format, options, outDir }) => {
|
h('export:run', async ({ guideId, format, options, outDir }) => {
|
||||||
let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`);
|
// The renderer may only nominate directories that came from this main
|
||||||
|
// process: a dialog pick from this session or a remembered last-output
|
||||||
|
// directory. Anything else is ignored and re-asked via the dialog.
|
||||||
|
const rememberedDirs = Object.values(settings.get('exports.lastOutputDirs') || {});
|
||||||
|
let dir = null;
|
||||||
|
if (outDir && (chosenOutputDirs.has(outDir) || rememberedDirs.includes(outDir))) {
|
||||||
|
dir = outDir;
|
||||||
|
}
|
||||||
|
if (!dir) dir = settings.get(`exports.lastOutputDirs.${format}`);
|
||||||
if (!dir) {
|
if (!dir) {
|
||||||
const res = await dialog.showOpenDialog(mainWindow, {
|
const res = await dialog.showOpenDialog(mainWindow, {
|
||||||
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
|
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
|
||||||
});
|
});
|
||||||
if (res.canceled) return { ok: false };
|
if (res.canceled) return { ok: false };
|
||||||
dir = res.filePaths[0];
|
dir = res.filePaths[0];
|
||||||
|
chosenOutputDirs.add(dir);
|
||||||
}
|
}
|
||||||
settings.set(`exports.lastOutputDirs.${format}`, dir);
|
settings.set(`exports.lastOutputDirs.${format}`, dir);
|
||||||
const result = await runExportInWorker({
|
const result = await runExportInWorker({
|
||||||
@@ -728,17 +827,23 @@ function setupIpc() {
|
|||||||
outDir: dir,
|
outDir: dir,
|
||||||
globals: settings.getGlobalPlaceholders(),
|
globals: settings.getGlobalPlaceholders(),
|
||||||
});
|
});
|
||||||
|
producedFiles.add(result.file);
|
||||||
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
|
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
|
||||||
return { ok: true, ...result };
|
return { ok: true, ...result };
|
||||||
|
}, {
|
||||||
|
validate: (a) => c.id(a.guideId) && validFormat(a.format)
|
||||||
|
&& (a.options === undefined || security.isPlainArgs(a.options))
|
||||||
|
&& c.optionalString(a.outDir, 1000),
|
||||||
});
|
});
|
||||||
h('export:chooseDir', async ({ format }) => {
|
h('export:chooseDir', async ({ format }) => {
|
||||||
const res = await dialog.showOpenDialog(mainWindow, {
|
const res = await dialog.showOpenDialog(mainWindow, {
|
||||||
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
|
title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'],
|
||||||
});
|
});
|
||||||
if (res.canceled) return null;
|
if (res.canceled) return null;
|
||||||
|
chosenOutputDirs.add(res.filePaths[0]);
|
||||||
settings.set(`exports.lastOutputDirs.${format}`, res.filePaths[0]);
|
settings.set(`exports.lastOutputDirs.${format}`, res.filePaths[0]);
|
||||||
return res.filePaths[0];
|
return res.filePaths[0];
|
||||||
});
|
}, { validate: (a) => validFormat(a.format) });
|
||||||
h('export:preview', ({ guideId, format, options }) => {
|
h('export:preview', ({ guideId, format, options }) => {
|
||||||
const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`);
|
const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`);
|
||||||
fs.rmSync(previewDir, { recursive: true, force: true });
|
fs.rmSync(previewDir, { recursive: true, force: true });
|
||||||
@@ -747,7 +852,11 @@ function setupIpc() {
|
|||||||
maxSteps: settings.get('exports.previewStepCount') || 3,
|
maxSteps: settings.get('exports.previewStepCount') || 3,
|
||||||
});
|
});
|
||||||
const result = runExport(format, ast, previewDir, options || {});
|
const result = runExport(format, ast, previewDir, options || {});
|
||||||
return { ok: true, file: result.file, fileUrl: `file://${result.file}` };
|
producedFiles.add(result.file);
|
||||||
|
return { ok: true, file: result.file, fileUrl: pathToFileURL(result.file).href };
|
||||||
|
}, {
|
||||||
|
validate: (a) => c.id(a.guideId) && validFormat(a.format)
|
||||||
|
&& (a.options === undefined || security.isPlainArgs(a.options)),
|
||||||
});
|
});
|
||||||
h('preview:cleanup', () => {
|
h('preview:cleanup', () => {
|
||||||
for (const entry of fs.readdirSync(store.tempDir)) {
|
for (const entry of fs.readdirSync(store.tempDir)) {
|
||||||
@@ -758,15 +867,48 @@ function setupIpc() {
|
|||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// shell helpers
|
// shell helpers — intent-specific, no arbitrary paths from the renderer.
|
||||||
h('shell:openPath', ({ target }) => shell.openPath(target));
|
// Only files this main process produced (exports/previews) may be opened.
|
||||||
h('shell:showItemInFolder', ({ target }) => shell.showItemInFolder(target));
|
h('shell:openProduced', ({ target }) => {
|
||||||
|
if (!producedFiles.has(target)) {
|
||||||
|
return { ok: false, reason: 'not a StepForge-produced file' };
|
||||||
|
}
|
||||||
|
shell.openPath(target);
|
||||||
|
return { ok: true };
|
||||||
|
}, { validate: (a) => c.string(a.target, 2000) });
|
||||||
|
// Reveal the linked archive of a guide; the path comes from the store,
|
||||||
|
// never from the renderer.
|
||||||
|
h('shell:revealLinkedArchive', ({ guideId }) => {
|
||||||
|
const guide = store.getGuide(guideId);
|
||||||
|
const target = guide && guide.linkedSource && guide.linkedSource.path;
|
||||||
|
if (!target || !fs.existsSync(target)) return { ok: false, reason: 'no linked archive' };
|
||||||
|
shell.showItemInFolder(target);
|
||||||
|
return { ok: true };
|
||||||
|
}, { validate: (a) => c.id(a.guideId) });
|
||||||
|
// Open a user-clicked link in the system browser. Scheme-validated;
|
||||||
|
// everything that is not plain http(s)/mailto is refused.
|
||||||
|
h('shell:openExternal', ({ url }) => {
|
||||||
|
const safe = security.validateExternalUrl(url);
|
||||||
|
if (!safe) return { ok: false, reason: 'blocked URL' };
|
||||||
|
shell.openExternal(safe);
|
||||||
|
return { ok: true };
|
||||||
|
}, { validate: (a) => c.string(a.url, 2048) });
|
||||||
h('app:info', () => ({
|
h('app:info', () => ({
|
||||||
version: app.getVersion(),
|
version: app.getVersion(),
|
||||||
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,
|
||||||
|
// xinput, click source, actionable messages) for the diagnostics UI, plus
|
||||||
|
// the honest active trigger for this machine and settings.
|
||||||
|
h('platform:capabilities', () => {
|
||||||
|
const platform = require('./platform');
|
||||||
|
const caps = platform.detectCapabilities();
|
||||||
|
const activeTrigger = platform.chooseCaptureTrigger(caps, settings.get('capture.fallbackTrigger') || 'interval');
|
||||||
|
return { ...caps, activeTrigger };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- lifecycle --------------------------------------------------------------
|
// ---- lifecycle --------------------------------------------------------------
|
||||||
@@ -794,6 +936,17 @@ if (!gotLock) {
|
|||||||
store = new GuideStore(dataDir);
|
store = new GuideStore(dataDir);
|
||||||
settings = new Settings(store.settingsDir);
|
settings = new Settings(store.settingsDir);
|
||||||
searchIndex = new SearchIndex(store.indexDir);
|
searchIndex = new SearchIndex(store.indexDir);
|
||||||
|
// Rebuild/reconcile the index against the library at startup so a missing,
|
||||||
|
// corrupt, or version-mismatched index recovers instead of silently
|
||||||
|
// returning nothing.
|
||||||
|
try {
|
||||||
|
const summary = searchIndex.reconcile(store);
|
||||||
|
if (summary.reindexed || summary.removed || summary.status !== 'ok') {
|
||||||
|
console.log(`[stepforge] search index reconciled: ${JSON.stringify(summary)}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error(`[stepforge] search reconcile failed: ${err && err.message}`);
|
||||||
|
}
|
||||||
templates = new TemplateManager(store.templatesDir);
|
templates = new TemplateManager(store.templatesDir);
|
||||||
textIntel = new TextIntelService({
|
textIntel = new TextIntelService({
|
||||||
store,
|
store,
|
||||||
@@ -834,23 +987,51 @@ if (!gotLock) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Single owner of OS power/throttling state for recording. The capture
|
||||||
|
// service calls setRecording(true/false) on every recording transition;
|
||||||
|
// this holds a power-save blocker and opts live Electron processes out of
|
||||||
|
// EcoQoS while recording, and releases the blocker when recording stops.
|
||||||
|
const capturePowerPolicy = (() => {
|
||||||
|
let blocker = -1;
|
||||||
|
const keepResponsive = () => {
|
||||||
|
try { keepProcessesResponsive(app.getAppMetrics().map((m) => m.pid)); } catch { /* best effort */ }
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
setRecording(recording) {
|
||||||
|
if (recording) {
|
||||||
|
if (!powerSaveBlocker.isStarted(blocker)) {
|
||||||
|
blocker = powerSaveBlocker.start('prevent-app-suspension');
|
||||||
|
}
|
||||||
|
keepResponsive();
|
||||||
|
} else if (powerSaveBlocker.isStarted(blocker)) {
|
||||||
|
powerSaveBlocker.stop(blocker);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
capture = new CaptureService({
|
capture = new CaptureService({
|
||||||
store,
|
store,
|
||||||
settings,
|
settings,
|
||||||
getWindow: () => mainWindow,
|
getWindow: () => mainWindow,
|
||||||
notify: captureNotify,
|
notify: captureNotify,
|
||||||
textIntel,
|
textIntel,
|
||||||
|
powerPolicy: capturePowerPolicy,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Allow the hidden capture-worker renderer to open a desktop media stream.
|
// Deny-by-default permission policy. The only grant in the entire app is
|
||||||
// Electron 29+ requires an explicit permission grant for display-capture in
|
// display capture (and the media permission getDisplayMedia consults) for
|
||||||
// renderer windows; without it getUserMedia/getDisplayMedia fails, the
|
// the dedicated hidden capture-worker page. Electron 29+ requires that
|
||||||
// stream backend never starts, and every capture falls back to
|
// explicit grant; everything else — including our own main window — is
|
||||||
// desktopCapturer.getSources() — which triggers the portal dialog on Linux
|
// rejected. "Content is local" is not a security control.
|
||||||
// on every single capture. StepForge is fully local/offline so allowing
|
session.defaultSession.setPermissionCheckHandler((wc, permission, requestingOrigin, details) => {
|
||||||
// all permissions for our own content is safe.
|
const url = (details && details.requestingUrl) || (wc && wc.getURL()) || requestingOrigin;
|
||||||
session.defaultSession.setPermissionCheckHandler(() => true);
|
return security.permissionAllowed(permission, url);
|
||||||
session.defaultSession.setPermissionRequestHandler((_wc, _perm, cb) => cb(true));
|
});
|
||||||
|
session.defaultSession.setPermissionRequestHandler((wc, permission, cb, details) => {
|
||||||
|
const url = (details && details.requestingUrl) || (wc && wc.getURL());
|
||||||
|
cb(security.permissionAllowed(permission, url));
|
||||||
|
});
|
||||||
|
|
||||||
// On GNOME Wayland the only working screen-capture path is the portal-backed
|
// On GNOME Wayland the only working screen-capture path is the portal-backed
|
||||||
// getDisplayMedia (desktopCapturer source ids fail with "device not found").
|
// getDisplayMedia (desktopCapturer source ids fail with "device not found").
|
||||||
@@ -860,6 +1041,13 @@ if (!gotLock) {
|
|||||||
// the chosen source then streams for the whole session. (useSystemPicker is
|
// the chosen source then streams for the whole session. (useSystemPicker is
|
||||||
// macOS-only today, harmless elsewhere.)
|
// macOS-only today, harmless elsewhere.)
|
||||||
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
|
session.defaultSession.setDisplayMediaRequestHandler((request, callback) => {
|
||||||
|
// Only the capture worker page may open a desktop stream.
|
||||||
|
const frameUrl = request && request.frame && request.frame.url;
|
||||||
|
if (!security.isAppPageUrl(frameUrl, 'captureWorker')) {
|
||||||
|
console.error('[stepforge] display-media request denied for', frameUrl || '(unknown frame)');
|
||||||
|
callback({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
desktopCapturer.getSources({ types: ['screen'] })
|
desktopCapturer.getSources({ types: ['screen'] })
|
||||||
.then((sources) => {
|
.then((sources) => {
|
||||||
console.log(`[stepforge] display-media request resolved: ${sources.length} screen source(s)`);
|
console.log(`[stepforge] display-media request resolved: ${sources.length} screen source(s)`);
|
||||||
@@ -881,6 +1069,19 @@ if (!gotLock) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Drain clicks still encoding in the capture queue before the app exits, so
|
||||||
|
// a fast burst immediately before quit is not lost. Defer the quit exactly
|
||||||
|
// once with a bounded deadline, then let it proceed.
|
||||||
|
let quitDrained = false;
|
||||||
|
app.on('before-quit', (event) => {
|
||||||
|
if (quitDrained || !capture) return;
|
||||||
|
quitDrained = true;
|
||||||
|
event.preventDefault();
|
||||||
|
// Stop new clicks from being queued, then wait for the queue to settle.
|
||||||
|
capture.stopClickWatcher();
|
||||||
|
capture.drainPendingClicks(2000).finally(() => app.quit());
|
||||||
|
});
|
||||||
|
|
||||||
app.on('will-quit', () => {
|
app.on('will-quit', () => {
|
||||||
globalShortcut.unregisterAll();
|
globalShortcut.unregisterAll();
|
||||||
if (capture) {
|
if (capture) {
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* macOS WindowContextProvider using AppleScript / System Events. Extracted
|
||||||
|
* verbatim from text-intel.js. macOS is not a primary support target, but the
|
||||||
|
* adapter is kept so the shared code has no `process.platform` branch and the
|
||||||
|
* behavior is preserved where it exists. Never throws.
|
||||||
|
*/
|
||||||
|
function createDarwinWindowContextProvider() {
|
||||||
|
return {
|
||||||
|
async collect() {
|
||||||
|
const script = `
|
||||||
|
set appName to ""
|
||||||
|
set windowTitle to ""
|
||||||
|
tell application "System Events"
|
||||||
|
try
|
||||||
|
set frontApp to first application process whose frontmost is true
|
||||||
|
set appName to name of frontApp
|
||||||
|
try
|
||||||
|
set windowTitle to name of front window of frontApp
|
||||||
|
end try
|
||||||
|
end try
|
||||||
|
end tell
|
||||||
|
return appName & linefeed & windowTitle
|
||||||
|
`;
|
||||||
|
try {
|
||||||
|
const result = execFileSync('osascript', ['-e', script], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 1200,
|
||||||
|
}).trimEnd();
|
||||||
|
const [appName = '', windowTitle = ''] = result.split(/\r?\n/);
|
||||||
|
return { appName, windowTitle };
|
||||||
|
} catch {
|
||||||
|
return { appName: '', windowTitle: '' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createDarwinWindowContextProvider };
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The single factory that selects a platform implementation. The rest of the
|
||||||
|
* app depends on the interfaces in ./interfaces.js and asks this module for a
|
||||||
|
* concrete adapter — it never branches on `process.platform` itself.
|
||||||
|
*
|
||||||
|
* As Linux runtime capture is implemented, its ClickSource / ScreenFrameSource
|
||||||
|
* adapters are added here; today this provides the WindowContextProvider for
|
||||||
|
* every platform and the Linux capability diagnostics.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { assertWindowContextProvider } = require('./interfaces');
|
||||||
|
|
||||||
|
function detectPlatform(platform = process.platform) {
|
||||||
|
if (platform === 'win32') return 'windows';
|
||||||
|
if (platform === 'darwin') return 'darwin';
|
||||||
|
if (platform === 'linux') return 'linux';
|
||||||
|
return 'unsupported';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the WindowContextProvider for the current OS. `platform` is injectable
|
||||||
|
* so the selection logic is unit-testable off the target OS.
|
||||||
|
*/
|
||||||
|
function createWindowContextProvider({ platform = process.platform } = {}) {
|
||||||
|
const os = detectPlatform(platform);
|
||||||
|
let provider;
|
||||||
|
switch (os) {
|
||||||
|
case 'windows':
|
||||||
|
provider = require('./windows/window-context').createWindowsWindowContextProvider();
|
||||||
|
break;
|
||||||
|
case 'darwin':
|
||||||
|
provider = require('./darwin/window-context').createDarwinWindowContextProvider();
|
||||||
|
break;
|
||||||
|
case 'linux':
|
||||||
|
provider = require('./linux/window-context').createLinuxWindowContextProvider();
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
// Unsupported OS: a null-object provider so callers still work.
|
||||||
|
provider = { async collect() { return { appName: '', windowTitle: '' }; } };
|
||||||
|
}
|
||||||
|
return assertWindowContextProvider(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capability profile for the current OS (used by diagnostics UI). Only Linux
|
||||||
|
* has a rich profile today; other platforms report their OS and a capable
|
||||||
|
* baseline.
|
||||||
|
*/
|
||||||
|
function detectCapabilities({ platform = process.platform, env = process.env } = {}) {
|
||||||
|
const os = detectPlatform(platform);
|
||||||
|
if (os === 'linux') {
|
||||||
|
return require('./linux/diagnostics').detectLinuxCapabilities({ env });
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
os,
|
||||||
|
sessionType: os,
|
||||||
|
isWayland: false,
|
||||||
|
clickCapture: os === 'windows' ? 'windows-hook' : os,
|
||||||
|
screenCapture: os,
|
||||||
|
messages: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The honest capture-trigger decision for the current capabilities. On Linux
|
||||||
|
* this defers to the diagnostics helper (which never promises per-click
|
||||||
|
* capture with coordinates on Wayland); other platforms have a fixed answer.
|
||||||
|
*/
|
||||||
|
function chooseCaptureTrigger(capabilities, userTriggerPreference = 'interval') {
|
||||||
|
if (capabilities && capabilities.os === 'linux') {
|
||||||
|
return require('./linux/diagnostics').chooseCaptureTrigger(capabilities, userTriggerPreference);
|
||||||
|
}
|
||||||
|
if (capabilities && capabilities.os === 'windows') {
|
||||||
|
return { trigger: 'click', clickSource: 'windows-hook', coordinates: true, marker: true, note: '' };
|
||||||
|
}
|
||||||
|
return { trigger: 'click', clickSource: capabilities ? capabilities.os : 'unavailable', coordinates: true, marker: true, note: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { detectPlatform, createWindowContextProvider, detectCapabilities, chooseCaptureTrigger };
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform adapter interfaces (documentation + light runtime shape checks).
|
||||||
|
*
|
||||||
|
* The platform-neutral capture/text-intel code consumes these interfaces and
|
||||||
|
* never inspects `process.platform` itself. `app/platform/index.js` is the
|
||||||
|
* only module that selects a concrete implementation. New OS support is a new
|
||||||
|
* set of files under `app/platform/<os>/`, not more conditionals inside the
|
||||||
|
* shared code.
|
||||||
|
*
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
* WindowContextProvider
|
||||||
|
* collect(osPoint?: {x,y}) -> Promise<{
|
||||||
|
* appName, windowTitle,
|
||||||
|
* elementLabel?, elementRole?, elementClass?, elementValue?
|
||||||
|
* }>
|
||||||
|
* Best-effort foreground window / clicked-element context. Never throws;
|
||||||
|
* returns {} (or partial) when unavailable.
|
||||||
|
*
|
||||||
|
* ClickSource (runtime capture — implemented incrementally per platform)
|
||||||
|
* describe() -> { source, coordinates: boolean, keyboard: boolean }
|
||||||
|
* source ∈ 'windows-hook' | 'x11' | 'evdev-x11' | 'evdev-wayland' |
|
||||||
|
* 'wayland-portal' | 'hotkey' | 'interval' | 'unavailable'
|
||||||
|
*
|
||||||
|
* PowerPolicy
|
||||||
|
* setRecording(recording: boolean) -> void
|
||||||
|
* Holds/releases OS power + throttling state for the recording lifecycle.
|
||||||
|
*
|
||||||
|
* PlatformCapabilities (from index.detectCapabilities())
|
||||||
|
* { os, sessionType, isWayland, hasXinput, canSandbox, ... }
|
||||||
|
* ---------------------------------------------------------------------------
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Interface names, exported so adapters and tests can reference a single
|
||||||
|
// source of truth for the contract identifiers.
|
||||||
|
const INTERFACES = Object.freeze([
|
||||||
|
'WindowContextProvider',
|
||||||
|
'ClickSource',
|
||||||
|
'PowerPolicy',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CLICK_SOURCES = Object.freeze([
|
||||||
|
'windows-hook',
|
||||||
|
'x11',
|
||||||
|
'evdev-x11',
|
||||||
|
'evdev-wayland',
|
||||||
|
'wayland-portal',
|
||||||
|
'hotkey',
|
||||||
|
'interval',
|
||||||
|
'unavailable',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Assert a value looks like a WindowContextProvider (has async collect()). */
|
||||||
|
function assertWindowContextProvider(provider) {
|
||||||
|
if (!provider || typeof provider.collect !== 'function') {
|
||||||
|
throw new Error('platform: WindowContextProvider must implement collect()');
|
||||||
|
}
|
||||||
|
return provider;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { INTERFACES, CLICK_SOURCES, assertWindowContextProvider };
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux capture-capability diagnostics. Detects the session type, portal /
|
||||||
|
* PipeWire availability, xinput, readable input devices, and the sandbox
|
||||||
|
* situation, and turns them into an actionable capability profile the UI can
|
||||||
|
* show instead of console-only failures.
|
||||||
|
*
|
||||||
|
* Pure detection with injectable probes so it is unit-testable without a real
|
||||||
|
* desktop session.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function defaultHasBinary(name) {
|
||||||
|
try {
|
||||||
|
execFileSync('which', [name], { stdio: 'pipe' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectSessionType(env = process.env) {
|
||||||
|
const t = String(env.XDG_SESSION_TYPE || '').toLowerCase();
|
||||||
|
if (t === 'wayland' || t === 'x11') return t;
|
||||||
|
if (env.WAYLAND_DISPLAY) return 'wayland';
|
||||||
|
if (env.DISPLAY) return 'x11';
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectLinuxCapabilities({
|
||||||
|
env = process.env,
|
||||||
|
hasBinary = defaultHasBinary,
|
||||||
|
existsSync = fs.existsSync,
|
||||||
|
readdirSync = fs.readdirSync,
|
||||||
|
} = {}) {
|
||||||
|
const sessionType = detectSessionType(env);
|
||||||
|
const isWayland = sessionType === 'wayland';
|
||||||
|
|
||||||
|
// XDG Desktop Portal + PipeWire are how Wayland screen capture works.
|
||||||
|
const hasPortalBus = Boolean(env.DBUS_SESSION_BUS_ADDRESS);
|
||||||
|
let hasPipeWire = false;
|
||||||
|
try {
|
||||||
|
hasPipeWire = hasBinary('pipewire') || existsSync(`/run/user/${process.getuid ? process.getuid() : ''}/pipewire-0`);
|
||||||
|
} catch {
|
||||||
|
hasPipeWire = hasBinary('pipewire');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasXinput = hasBinary('xinput');
|
||||||
|
const hasXprop = hasBinary('xprop');
|
||||||
|
|
||||||
|
// Readable /dev/input event nodes gate the evdev click fallback.
|
||||||
|
let readableInputDevices = 0;
|
||||||
|
try {
|
||||||
|
for (const name of readdirSync('/dev/input')) {
|
||||||
|
if (!/^event\d+$/.test(name)) continue;
|
||||||
|
try { fs.accessSync(`/dev/input/${name}`, fs.constants.R_OK); readableInputDevices += 1; } catch { /* not readable */ }
|
||||||
|
}
|
||||||
|
} catch { /* /dev/input not present */ }
|
||||||
|
|
||||||
|
// Determine the click-capture profile for this session.
|
||||||
|
let clickCapture;
|
||||||
|
if (!isWayland && hasXinput) clickCapture = 'x11-xinput';
|
||||||
|
else if (readableInputDevices > 0) clickCapture = isWayland ? 'evdev-wayland' : 'evdev-x11';
|
||||||
|
else clickCapture = 'hotkey-or-interval-only';
|
||||||
|
|
||||||
|
const messages = [];
|
||||||
|
if (isWayland && !hasPipeWire) {
|
||||||
|
messages.push('Wayland screen capture needs PipeWire and the XDG Desktop Portal. Install pipewire and xdg-desktop-portal.');
|
||||||
|
}
|
||||||
|
if (isWayland && !hasPortalBus) {
|
||||||
|
messages.push('No D-Bus session bus detected; the screen-share portal cannot be reached.');
|
||||||
|
}
|
||||||
|
if (!isWayland && !hasXinput) {
|
||||||
|
messages.push('xinput not found: per-click capture with a marker is unavailable on X11 without it.');
|
||||||
|
}
|
||||||
|
if (clickCapture === 'hotkey-or-interval-only') {
|
||||||
|
messages.push('No global click source available. Recording falls back to a hotkey or interval trigger.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
os: 'linux',
|
||||||
|
sessionType,
|
||||||
|
isWayland,
|
||||||
|
hasPortalBus,
|
||||||
|
hasPipeWire,
|
||||||
|
hasXinput,
|
||||||
|
hasXprop,
|
||||||
|
readableInputDevices,
|
||||||
|
clickCapture,
|
||||||
|
// Portal capture is the safe Wayland baseline; X11 can grab directly.
|
||||||
|
screenCapture: isWayland ? 'wayland-portal' : 'x11-direct',
|
||||||
|
messages,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decide the honest capture trigger for a Linux capability profile. StepForge
|
||||||
|
* must never *promise* per-click capture with coordinates on Wayland, because
|
||||||
|
* the platform does not expose pointer position to apps. Returns the trigger,
|
||||||
|
* whether clicks carry coordinates, whether a marker can be drawn, and a
|
||||||
|
* user-facing note. `userTriggerPreference` is the capture.fallbackTrigger
|
||||||
|
* setting ('interval' | 'hotkey') used only when no click source exists.
|
||||||
|
*/
|
||||||
|
function chooseCaptureTrigger(capabilities, userTriggerPreference = 'interval') {
|
||||||
|
const caps = capabilities || {};
|
||||||
|
const click = caps.clickCapture;
|
||||||
|
|
||||||
|
if (click === 'x11-xinput') {
|
||||||
|
return {
|
||||||
|
trigger: 'click',
|
||||||
|
clickSource: 'x11',
|
||||||
|
coordinates: true,
|
||||||
|
marker: true,
|
||||||
|
note: 'Per-click capture with an accurate marker (X11 + xinput).',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (click === 'evdev-x11') {
|
||||||
|
return {
|
||||||
|
trigger: 'click',
|
||||||
|
clickSource: 'evdev-x11',
|
||||||
|
coordinates: true,
|
||||||
|
marker: true,
|
||||||
|
note: 'Per-click capture via kernel input devices (X11, no xinput).',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (click === 'evdev-wayland') {
|
||||||
|
// Wayland exposes button presses (via evdev, if permitted) but NOT pointer
|
||||||
|
// position, so a step is captured per click but without a marker. This is
|
||||||
|
// only reached when the user opted into the least-privilege device rule.
|
||||||
|
return {
|
||||||
|
trigger: 'click',
|
||||||
|
clickSource: 'evdev-wayland',
|
||||||
|
coordinates: false,
|
||||||
|
marker: false,
|
||||||
|
note: 'Per-click capture on Wayland has no pointer position, so no marker is drawn.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// No global click source: the safe baseline is the user's chosen fallback.
|
||||||
|
const trigger = userTriggerPreference === 'hotkey' ? 'hotkey' : 'interval';
|
||||||
|
return {
|
||||||
|
trigger,
|
||||||
|
clickSource: trigger,
|
||||||
|
coordinates: false,
|
||||||
|
marker: false,
|
||||||
|
note: caps.isWayland
|
||||||
|
? 'Wayland does not expose global clicks; recording uses your ' + trigger + ' trigger. '
|
||||||
|
+ 'Screen sharing is requested once per recording via the portal.'
|
||||||
|
: 'No global click source available; recording uses your ' + trigger + ' trigger.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { detectLinuxCapabilities, detectSessionType, chooseCaptureTrigger };
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { execFileSync } = require('node:child_process');
|
||||||
|
|
||||||
|
function hasBinary(name) {
|
||||||
|
try {
|
||||||
|
execFileSync('which', [name], { stdio: 'pipe' });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linux (X11) WindowContextProvider using xprop on the active window. On
|
||||||
|
* Wayland xprop only sees XWayland clients, so context is best-effort; the
|
||||||
|
* portal-based capture path does not depend on it. Extracted verbatim from
|
||||||
|
* text-intel.js. Never throws.
|
||||||
|
*/
|
||||||
|
function createLinuxWindowContextProvider() {
|
||||||
|
return {
|
||||||
|
async collect() {
|
||||||
|
try {
|
||||||
|
if (!hasBinary('xprop')) return { appName: '', windowTitle: '' };
|
||||||
|
const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 1200,
|
||||||
|
});
|
||||||
|
const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/);
|
||||||
|
if (!activeMatch) return { appName: '', windowTitle: '' };
|
||||||
|
const winId = activeMatch[1];
|
||||||
|
const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
timeout: 1200,
|
||||||
|
});
|
||||||
|
const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/);
|
||||||
|
const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/);
|
||||||
|
return {
|
||||||
|
appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '',
|
||||||
|
windowTitle: titleMatch ? titleMatch[1] : '',
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { appName: '', windowTitle: '' };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createLinuxWindowContextProvider, hasBinary };
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { execFile } = require('node:child_process');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Windows WindowContextProvider. Reads the foreground window (Win32) and, when
|
||||||
|
* a click point is given, the UI Automation element under it. Best-effort:
|
||||||
|
* resolves {} on any failure. Extracted verbatim from text-intel.js so the
|
||||||
|
* shared code carries no `process.platform` branch.
|
||||||
|
*/
|
||||||
|
function createWindowsWindowContextProvider() {
|
||||||
|
return {
|
||||||
|
async collect(osPoint = null) {
|
||||||
|
const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y);
|
||||||
|
const clickX = hasPoint ? Number(osPoint.x) : 0;
|
||||||
|
const clickY = hasPoint ? Number(osPoint.y) : 0;
|
||||||
|
const script = `
|
||||||
|
$clickX = ${clickX};
|
||||||
|
$clickY = ${clickY};
|
||||||
|
$elementLabel = '';
|
||||||
|
$elementRole = '';
|
||||||
|
$elementClass = '';
|
||||||
|
$elementProcessId = 0;
|
||||||
|
$elementValue = '';
|
||||||
|
if (${hasPoint ? '$true' : '$false'}) {
|
||||||
|
try {
|
||||||
|
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null
|
||||||
|
$point = New-Object System.Windows.Point($clickX, $clickY);
|
||||||
|
$element = [System.Windows.Automation.AutomationElement]::FromPoint($point);
|
||||||
|
if ($element) {
|
||||||
|
$current = $element.Current;
|
||||||
|
$elementLabel = $current.Name;
|
||||||
|
$elementRole = $current.LocalizedControlType;
|
||||||
|
$elementClass = $current.ClassName;
|
||||||
|
$elementProcessId = $current.ProcessId;
|
||||||
|
try {
|
||||||
|
$valPattern = [System.Windows.Automation.ValuePattern]::Pattern;
|
||||||
|
if ($element.GetSupportedPatterns() -contains $valPattern) {
|
||||||
|
$elementValue = $element.GetCurrentPattern($valPattern).Current.Value;
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
Add-Type @"
|
||||||
|
using System;
|
||||||
|
using System.Runtime.InteropServices;
|
||||||
|
using System.Text;
|
||||||
|
public static class Win32 {
|
||||||
|
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
||||||
|
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
||||||
|
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
||||||
|
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
||||||
|
}
|
||||||
|
"@;
|
||||||
|
$hWnd = [Win32]::GetForegroundWindow();
|
||||||
|
$sb = New-Object System.Text.StringBuilder 512;
|
||||||
|
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity);
|
||||||
|
$pid = 0;
|
||||||
|
[void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid);
|
||||||
|
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1;
|
||||||
|
$out = [ordered]@{
|
||||||
|
appName = if ($proc) { $proc.ProcessName } else { '' };
|
||||||
|
windowTitle = $sb.ToString();
|
||||||
|
elementLabel = $elementLabel;
|
||||||
|
elementRole = $elementRole;
|
||||||
|
elementClass = $elementClass;
|
||||||
|
elementValue = $elementValue;
|
||||||
|
elementProcessId = $elementProcessId;
|
||||||
|
pid = $pid;
|
||||||
|
};
|
||||||
|
$out | ConvertTo-Json -Compress;
|
||||||
|
`;
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
||||||
|
encoding: 'utf8',
|
||||||
|
timeout: 4000,
|
||||||
|
windowsHide: true,
|
||||||
|
}, (err, stdout) => {
|
||||||
|
if (err) { resolve({}); return; }
|
||||||
|
try { resolve(JSON.parse(stdout.trim() || '{}')); } catch { resolve({}); }
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createWindowsWindowContextProvider };
|
||||||
@@ -56,6 +56,7 @@ const api = {
|
|||||||
test: invoke('ai:test'),
|
test: invoke('ai:test'),
|
||||||
fillStep: invoke('ai:fillStep'),
|
fillStep: invoke('ai:fillStep'),
|
||||||
rewriteText: invoke('ai:rewriteText'),
|
rewriteText: invoke('ai:rewriteText'),
|
||||||
|
cancel: invoke('ai:cancel'),
|
||||||
},
|
},
|
||||||
capture: {
|
capture: {
|
||||||
shoot: invoke('capture:shoot'),
|
shoot: invoke('capture:shoot'),
|
||||||
@@ -76,6 +77,9 @@ const api = {
|
|||||||
create: invoke('snapshots:create'),
|
create: invoke('snapshots:create'),
|
||||||
restore: invoke('snapshots:restore'),
|
restore: invoke('snapshots:restore'),
|
||||||
},
|
},
|
||||||
|
recovery: {
|
||||||
|
status: invoke('recovery:status'),
|
||||||
|
},
|
||||||
templates: {
|
templates: {
|
||||||
list: invoke('templates:list'),
|
list: invoke('templates:list'),
|
||||||
load: invoke('templates:load'),
|
load: invoke('templates:load'),
|
||||||
@@ -95,11 +99,15 @@ const api = {
|
|||||||
cleanupPreviews: invoke('preview:cleanup'),
|
cleanupPreviews: invoke('preview:cleanup'),
|
||||||
},
|
},
|
||||||
shell: {
|
shell: {
|
||||||
openPath: invoke('shell:openPath'),
|
// Intent-specific shell access only: files the main process produced,
|
||||||
showItemInFolder: invoke('shell:showItemInFolder'),
|
// the guide's linked archive, and scheme-validated external links.
|
||||||
|
openProduced: invoke('shell:openProduced'),
|
||||||
|
revealLinkedArchive: invoke('shell:revealLinkedArchive'),
|
||||||
|
openExternal: invoke('shell:openExternal'),
|
||||||
},
|
},
|
||||||
app: {
|
app: {
|
||||||
info: invoke('app:info'),
|
info: invoke('app:info'),
|
||||||
|
platformCapabilities: invoke('platform:capabilities'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -172,7 +172,7 @@ class StepForgeApp {
|
|||||||
el('div.welcome', {},
|
el('div.welcome', {},
|
||||||
el('div.welcome-title', {},
|
el('div.welcome-title', {},
|
||||||
el('h1', {}, 'StepForge'),
|
el('h1', {}, 'StepForge'),
|
||||||
el('p.muted', {}, 'Capture, annotate, and export step-by-step guides, fully offline.'),
|
el('p.muted', {}, 'Capture, annotate, and export step-by-step guides. Local-first, no telemetry.'),
|
||||||
),
|
),
|
||||||
el('div.welcome-actions', {},
|
el('div.welcome-actions', {},
|
||||||
el('button.welcome-btn.primary', {
|
el('button.welcome-btn.primary', {
|
||||||
@@ -934,6 +934,24 @@ class StepForgeApp {
|
|||||||
|
|
||||||
window.StepForgeApp = StepForgeApp;
|
window.StepForgeApp = StepForgeApp;
|
||||||
|
|
||||||
|
// Links never navigate this window. http(s)/mailto links from guide content
|
||||||
|
// open externally via the scheme-validated main-process handler; internal
|
||||||
|
// step:/# links are handled by their own click handlers; everything else is
|
||||||
|
// inert. The main process additionally denies all navigation, so this is the
|
||||||
|
// user-experience half of a two-layer guarantee.
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const anchor = e.target && e.target.closest ? e.target.closest('a[href]') : null;
|
||||||
|
if (!anchor) return;
|
||||||
|
const href = anchor.getAttribute('href') || '';
|
||||||
|
if (/^(https?|mailto):/i.test(href)) {
|
||||||
|
e.preventDefault();
|
||||||
|
api.shell.openExternal({ url: href });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Ensure a stray href can never navigate the privileged window.
|
||||||
|
if (!href.startsWith('#')) e.preventDefault();
|
||||||
|
}, true);
|
||||||
|
|
||||||
function boot() {
|
function boot() {
|
||||||
const app = new StepForgeApp();
|
const app = new StepForgeApp();
|
||||||
app.init();
|
app.init();
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ class GuideEditor {
|
|||||||
this.currentZoom = 'fit';
|
this.currentZoom = 'fit';
|
||||||
this.pendingSave = false;
|
this.pendingSave = false;
|
||||||
this.pendingGuideSave = false;
|
this.pendingGuideSave = false;
|
||||||
|
this.saveError = null;
|
||||||
this.canvasHistory = [];
|
this.canvasHistory = [];
|
||||||
this.canvasFuture = [];
|
this.canvasFuture = [];
|
||||||
this.beforeCanvasSnapshot = null;
|
this.beforeCanvasSnapshot = null;
|
||||||
@@ -151,6 +152,16 @@ class GuideEditor {
|
|||||||
|
|
||||||
setActive(active) {
|
setActive(active) {
|
||||||
this.active = Boolean(active);
|
this.active = Boolean(active);
|
||||||
|
if (!this.active && this.guideId) {
|
||||||
|
// Leaving the editor: flush pending debounced saves so navigation can
|
||||||
|
// never drop the last edit (failures keep the dirty state and retry),
|
||||||
|
// and cancel any in-flight AI request for this guide so a slow response
|
||||||
|
// can't resolve against a guide the user has closed.
|
||||||
|
if (this.pendingSave || this.pendingGuideSave) {
|
||||||
|
this.saveAll().catch(() => {});
|
||||||
|
}
|
||||||
|
api.ai.cancel({ guideId: this.guideId }).catch(() => {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setSettings(settings) {
|
setSettings(settings) {
|
||||||
@@ -314,6 +325,7 @@ class GuideEditor {
|
|||||||
selectedAnnotationId: this.selectedAnnotationId,
|
selectedAnnotationId: this.selectedAnnotationId,
|
||||||
linked: Boolean(this.guide && this.guide.linkedSource),
|
linked: Boolean(this.guide && this.guide.linkedSource),
|
||||||
dirty: this.pendingSave || this.pendingGuideSave || this.descriptionDirty || this.titleDirty,
|
dirty: this.pendingSave || this.pendingGuideSave || this.descriptionDirty || this.titleDirty,
|
||||||
|
saveError: this.saveError || null,
|
||||||
view: 'editor',
|
view: 'editor',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1419,8 +1431,22 @@ class GuideEditor {
|
|||||||
|
|
||||||
async flushStep(step = this.currentStep) {
|
async flushStep(step = this.currentStep) {
|
||||||
if (!step) return;
|
if (!step) return;
|
||||||
|
// Clear the dirty flag only AFTER a durable save. Clearing it first meant
|
||||||
|
// a rejected IPC save silently lost the unsaved state (and this runs from
|
||||||
|
// a debounce that does not handle rejections). Keep it dirty on failure,
|
||||||
|
// surface it, and retry on the next edit or explicit save.
|
||||||
|
let saved;
|
||||||
|
try {
|
||||||
|
saved = await api.step.save({ guideId: this.guideId, step });
|
||||||
|
} catch (err) {
|
||||||
|
this.pendingSave = true;
|
||||||
|
this.saveError = (err && err.message) || 'Save failed';
|
||||||
|
this.emitMeta();
|
||||||
|
this.onToast('Could not save this step — your changes are kept. Retrying…', { error: true });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
this.pendingSave = false;
|
this.pendingSave = false;
|
||||||
const saved = await api.step.save({ guideId: this.guideId, step });
|
this.saveError = null;
|
||||||
const committed = this.commitSavedStep(saved);
|
const committed = this.commitSavedStep(saved);
|
||||||
if (this.selectedStepId === committed.stepId) {
|
if (this.selectedStepId === committed.stepId) {
|
||||||
this.renderStepList();
|
this.renderStepList();
|
||||||
@@ -1465,8 +1491,17 @@ class GuideEditor {
|
|||||||
|
|
||||||
async flushGuide() {
|
async flushGuide() {
|
||||||
if (!this.guide) return;
|
if (!this.guide) return;
|
||||||
|
try {
|
||||||
|
await api.guide.save({ guide: this.guide });
|
||||||
|
} catch (err) {
|
||||||
|
this.pendingGuideSave = true;
|
||||||
|
this.saveError = (err && err.message) || 'Save failed';
|
||||||
|
this.emitMeta();
|
||||||
|
this.onToast('Could not save guide details — your changes are kept. Retrying…', { error: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.pendingGuideSave = false;
|
this.pendingGuideSave = false;
|
||||||
await api.guide.save({ guide: this.guide });
|
this.saveError = null;
|
||||||
this.emitMeta();
|
this.emitMeta();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1899,7 +1934,7 @@ class GuideEditor {
|
|||||||
onPreview: async ({ format, options }) => {
|
onPreview: async ({ format, options }) => {
|
||||||
const preview = await api.export.preview({ guideId: this.guideId, format, options });
|
const preview = await api.export.preview({ guideId: this.guideId, format, options });
|
||||||
if (preview && preview.file) {
|
if (preview && preview.file) {
|
||||||
await api.shell.openPath({ target: preview.file }); // open in default viewer
|
await api.shell.openProduced({ target: preview.file }); // open in default viewer
|
||||||
this.onToast('Preview opened (first steps only).');
|
this.onToast('Preview opened (first steps only).');
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
@@ -1935,7 +1970,7 @@ class GuideEditor {
|
|||||||
else this.onToast('Could not save linked archive.', { error: true });
|
else this.onToast('Could not save linked archive.', { error: true });
|
||||||
},
|
},
|
||||||
onOpenArchive: async () => {
|
onOpenArchive: async () => {
|
||||||
await api.shell.showItemInFolder({ target: this.guide.linkedSource.path });
|
await api.shell.revealLinkedArchive({ guideId: this.guideId });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,302 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Privilege-boundary policy for every renderer surface.
|
||||||
|
*
|
||||||
|
* This module is deliberately loadable under plain Node (no electron
|
||||||
|
* require at module scope) so the decision logic is unit-testable:
|
||||||
|
* - which URLs count as our own app pages,
|
||||||
|
* - whether a navigation/popup may proceed (it may not),
|
||||||
|
* - which permission a renderer may be granted (display capture for the
|
||||||
|
* dedicated capture worker only),
|
||||||
|
* - whether an IPC event comes from the trusted main-window frame,
|
||||||
|
* - which external URLs may be handed to shell.openExternal,
|
||||||
|
* - which filesystem paths the renderer may ask the shell to open
|
||||||
|
* (only files the main process itself produced).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const path = require('node:path');
|
||||||
|
const { pathToFileURL } = require('node:url');
|
||||||
|
|
||||||
|
const RENDERER_DIR = path.join(__dirname, 'renderer');
|
||||||
|
|
||||||
|
const APP_PAGES = {
|
||||||
|
main: pathToFileURL(path.join(RENDERER_DIR, 'index.html')).href,
|
||||||
|
region: pathToFileURL(path.join(RENDERER_DIR, 'region.html')).href,
|
||||||
|
captureWorker: pathToFileURL(path.join(RENDERER_DIR, 'capture-worker.html')).href,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Normalize a URL for identity comparison: drop query/hash, decode path. */
|
||||||
|
function normalizeAppUrl(rawUrl) {
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(String(rawUrl));
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'file:') return null;
|
||||||
|
let pathname;
|
||||||
|
try {
|
||||||
|
pathname = decodeURIComponent(url.pathname);
|
||||||
|
} catch {
|
||||||
|
pathname = url.pathname;
|
||||||
|
}
|
||||||
|
// Windows drive letters may differ in case between loadFile and senderFrame.
|
||||||
|
if (process.platform === 'win32') pathname = pathname.toLowerCase();
|
||||||
|
return pathname;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAppPageUrl(rawUrl, page) {
|
||||||
|
const expected = APP_PAGES[page];
|
||||||
|
if (!expected) return false;
|
||||||
|
const a = normalizeAppUrl(rawUrl);
|
||||||
|
const b = normalizeAppUrl(expected);
|
||||||
|
return a !== null && a === b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Navigation policy: a privileged window may only ever stay on the exact
|
||||||
|
* page it was created with. Everything else — remote URLs, other local
|
||||||
|
* files, javascript:, data: — is denied.
|
||||||
|
*/
|
||||||
|
function navigationAllowed(targetUrl, page) {
|
||||||
|
return isAppPageUrl(targetUrl, page);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Permission policy for Electron sessions. Display capture (and the media
|
||||||
|
* permission that getDisplayMedia consults) is granted only to the dedicated
|
||||||
|
* hidden capture-worker page. Everything else is denied for everyone,
|
||||||
|
* including our own main window.
|
||||||
|
*/
|
||||||
|
function permissionAllowed(permission, requestingUrl) {
|
||||||
|
if (permission === 'media' || permission === 'display-capture') {
|
||||||
|
return isAppPageUrl(requestingUrl, 'captureWorker');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* External-link policy: only well-formed http(s) and mailto URLs may reach
|
||||||
|
* shell.openExternal. Returns the normalized URL string or null.
|
||||||
|
*/
|
||||||
|
function validateExternalUrl(rawUrl) {
|
||||||
|
if (typeof rawUrl !== 'string' || rawUrl.length > 2048) return null;
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(rawUrl);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (url.protocol === 'https:' || url.protocol === 'http:') {
|
||||||
|
if (!url.hostname) return null;
|
||||||
|
return url.href;
|
||||||
|
}
|
||||||
|
if (url.protocol === 'mailto:') return url.href;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IPC sender guard: an invoke event is trusted only when it originates from
|
||||||
|
* the current main window's top frame, and that frame is our index.html.
|
||||||
|
* Destroyed frames, subframes, other windows, and navigated-away frames are
|
||||||
|
* all rejected.
|
||||||
|
*/
|
||||||
|
function makeIpcSenderGuard({ getMainWebContents }) {
|
||||||
|
return function trustedSender(event) {
|
||||||
|
if (!event || typeof event !== 'object') return false;
|
||||||
|
const expected = getMainWebContents();
|
||||||
|
if (!expected || event.sender !== expected) return false;
|
||||||
|
const frame = event.senderFrame;
|
||||||
|
if (!frame) return false;
|
||||||
|
try {
|
||||||
|
if (frame.parent) return false; // top frame only
|
||||||
|
return isAppPageUrl(frame.url, 'main');
|
||||||
|
} catch {
|
||||||
|
// Accessing a disposed WebFrameMain throws — reject.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cheap recursive payload budget: sums string lengths (and key counts) with
|
||||||
|
* early exit. Protects handlers from absurd payloads without JSON.stringify
|
||||||
|
* on hundred-megabyte image saves.
|
||||||
|
*/
|
||||||
|
function payloadWithinBudget(value, maxChars, depth = 0) {
|
||||||
|
let budget = maxChars;
|
||||||
|
|
||||||
|
const walk = (v, d) => {
|
||||||
|
if (budget < 0 || d > 16) return false;
|
||||||
|
if (v == null) return true;
|
||||||
|
const t = typeof v;
|
||||||
|
if (t === 'string') {
|
||||||
|
budget -= v.length;
|
||||||
|
return budget >= 0;
|
||||||
|
}
|
||||||
|
if (t === 'number' || t === 'boolean') {
|
||||||
|
budget -= 8;
|
||||||
|
return budget >= 0;
|
||||||
|
}
|
||||||
|
if (t === 'function' || t === 'symbol' || t === 'bigint') return false;
|
||||||
|
if (Array.isArray(v)) {
|
||||||
|
if (v.length > 100000) return false;
|
||||||
|
for (const item of v) if (!walk(item, d + 1)) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (t === 'object') {
|
||||||
|
const keys = Object.keys(v);
|
||||||
|
if (keys.length > 4096) return false;
|
||||||
|
for (const key of keys) {
|
||||||
|
budget -= key.length;
|
||||||
|
if (budget < 0) return false;
|
||||||
|
if (!walk(v[key], d + 1)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
return walk(value, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True for plain-object argument bags (what every IPC channel expects). */
|
||||||
|
function isPlainArgs(args) {
|
||||||
|
if (args === undefined || args === null) return true;
|
||||||
|
if (typeof args !== 'object' || Array.isArray(args)) return false;
|
||||||
|
const proto = Object.getPrototypeOf(args);
|
||||||
|
return proto === Object.prototype || proto === null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- field validators (used by main.js per-channel checks) ----------------
|
||||||
|
|
||||||
|
const ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||||
|
|
||||||
|
const check = {
|
||||||
|
/** Guide/step/folder/template identifiers and snapshot/trash entry names:
|
||||||
|
* single path segment, no separators, no dot-dot, sane length. */
|
||||||
|
id(v) {
|
||||||
|
return typeof v === 'string' && ID_RE.test(v) && !v.includes('..');
|
||||||
|
},
|
||||||
|
optionalId(v) {
|
||||||
|
return v === null || v === undefined || check.id(v);
|
||||||
|
},
|
||||||
|
string(v, max = 4096) {
|
||||||
|
return typeof v === 'string' && v.length <= max;
|
||||||
|
},
|
||||||
|
optionalString(v, max = 4096) {
|
||||||
|
return v === null || v === undefined || check.string(v, max);
|
||||||
|
},
|
||||||
|
bool(v) {
|
||||||
|
return typeof v === 'boolean';
|
||||||
|
},
|
||||||
|
number(v, min = -1e15, max = 1e15) {
|
||||||
|
return typeof v === 'number' && Number.isFinite(v) && v >= min && v <= max;
|
||||||
|
},
|
||||||
|
optionalNumber(v, min, max) {
|
||||||
|
return v === null || v === undefined || check.number(v, min, max);
|
||||||
|
},
|
||||||
|
oneOf(v, values) {
|
||||||
|
return values.includes(v);
|
||||||
|
},
|
||||||
|
base64(v, maxChars = 192 * 1024 * 1024) {
|
||||||
|
return typeof v === 'string' && v.length <= maxChars && /^[A-Za-z0-9+/=\r\n]*$/.test(v.slice(0, 4096));
|
||||||
|
},
|
||||||
|
optionalBase64(v, maxChars) {
|
||||||
|
return v === null || v === undefined || check.base64(v, maxChars);
|
||||||
|
},
|
||||||
|
/** Single filesystem name (template/snapshot/trash entries): no path
|
||||||
|
* separators, no traversal, no NUL, printable, bounded length. */
|
||||||
|
fileName(v, max = 160) {
|
||||||
|
return (
|
||||||
|
typeof v === 'string' &&
|
||||||
|
v.trim().length > 0 &&
|
||||||
|
v.length <= max &&
|
||||||
|
!/[/\\\0]/.test(v) &&
|
||||||
|
!v.includes('..') &&
|
||||||
|
v !== '.' &&
|
||||||
|
// eslint-disable-next-line no-control-regex
|
||||||
|
!/[\x00-\x1f]/.test(v)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
optionalFileName(v, max) {
|
||||||
|
return v === null || v === undefined || check.fileName(v, max);
|
||||||
|
},
|
||||||
|
/** settings keyPath: dotted segments, no prototype-pollution segments. */
|
||||||
|
settingsKeyPath(v) {
|
||||||
|
if (typeof v !== 'string' || v.length === 0 || v.length > 200) return false;
|
||||||
|
const segments = v.split('.');
|
||||||
|
return segments.every(
|
||||||
|
(segment) =>
|
||||||
|
/^[A-Za-z0-9_-]+$/.test(segment) &&
|
||||||
|
!['__proto__', 'constructor', 'prototype'].includes(segment)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Registry of files the main process itself produced (export outputs,
|
||||||
|
* previews) and may therefore re-open on renderer request. Bounded LRU.
|
||||||
|
*/
|
||||||
|
class ProducedFiles {
|
||||||
|
constructor(limit = 256) {
|
||||||
|
this.limit = limit;
|
||||||
|
this.paths = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
key(p) {
|
||||||
|
const resolved = path.resolve(String(p));
|
||||||
|
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
add(p) {
|
||||||
|
if (!p || typeof p !== 'string') return;
|
||||||
|
const key = this.key(p);
|
||||||
|
this.paths.delete(key);
|
||||||
|
this.paths.add(key);
|
||||||
|
while (this.paths.size > this.limit) {
|
||||||
|
this.paths.delete(this.paths.values().next().value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
has(p) {
|
||||||
|
if (!p || typeof p !== 'string') return false;
|
||||||
|
return this.paths.has(this.key(p));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attach the navigation/popup policy to a BrowserWindow. `page` names the
|
||||||
|
* app page this window is allowed to display (key of APP_PAGES).
|
||||||
|
*/
|
||||||
|
function installWindowSecurity(win, page) {
|
||||||
|
const contents = win.webContents;
|
||||||
|
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||||
|
contents.on('will-navigate', (event, url) => {
|
||||||
|
if (!navigationAllowed(url, page)) event.preventDefault();
|
||||||
|
});
|
||||||
|
contents.on('will-frame-navigate', (details) => {
|
||||||
|
if (!navigationAllowed(details.url, page) && typeof details.preventDefault === 'function') {
|
||||||
|
details.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// Defense in depth: if a disallowed document somehow starts loading,
|
||||||
|
// never let it attach webviews either.
|
||||||
|
contents.on('will-attach-webview', (event) => event.preventDefault());
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
APP_PAGES,
|
||||||
|
ProducedFiles,
|
||||||
|
check,
|
||||||
|
installWindowSecurity,
|
||||||
|
isAppPageUrl,
|
||||||
|
isPlainArgs,
|
||||||
|
makeIpcSenderGuard,
|
||||||
|
navigationAllowed,
|
||||||
|
normalizeAppUrl,
|
||||||
|
payloadWithinBudget,
|
||||||
|
permissionAllowed,
|
||||||
|
validateExternalUrl,
|
||||||
|
};
|
||||||
@@ -343,11 +343,14 @@ async function createElectronHost(onEvent) {
|
|||||||
preload: path.join(__dirname, 'capture-worker-preload.js'),
|
preload: path.join(__dirname, 'capture-worker-preload.js'),
|
||||||
contextIsolation: true,
|
contextIsolation: true,
|
||||||
nodeIntegration: false,
|
nodeIntegration: false,
|
||||||
|
sandbox: true,
|
||||||
// The worker must keep sampling while hidden — throttling a hidden
|
// The worker must keep sampling while hidden — throttling a hidden
|
||||||
// window is exactly the wrong default for a frame recorder.
|
// window is exactly the wrong default for a frame recorder.
|
||||||
backgroundThrottling: false,
|
backgroundThrottling: false,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
// The worker may only display capture-worker.html; deny navigation/popups.
|
||||||
|
require('./security').installWindowSecurity(win, 'captureWorker');
|
||||||
const listener = (event, msg) => {
|
const listener = (event, msg) => {
|
||||||
if (event.sender === win.webContents) onEvent(msg);
|
if (event.sender === win.webContents) onEvent(msg);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { execFileSync, execFile } = require('node:child_process');
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
DEFAULT_CAPTURE_TITLES,
|
DEFAULT_CAPTURE_TITLES,
|
||||||
buildCaptureTitle,
|
buildCaptureTitle,
|
||||||
normalizeOllamaHost,
|
normalizeOllamaHost,
|
||||||
|
validateOllamaHost,
|
||||||
normalizeAiPatch,
|
normalizeAiPatch,
|
||||||
buildAiPrompt,
|
buildAiPrompt,
|
||||||
applyAiPatchToStep,
|
applyAiPatchToStep,
|
||||||
@@ -22,15 +22,6 @@ const OCR_CROP = {
|
|||||||
height: 220,
|
height: 220,
|
||||||
};
|
};
|
||||||
|
|
||||||
function hasBinary(name) {
|
|
||||||
try {
|
|
||||||
execFileSync('which', [name], { stdio: 'pipe' });
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(v, min, max) {
|
function clamp(v, min, max) {
|
||||||
return Math.min(max, Math.max(min, v));
|
return Math.min(max, Math.max(min, v));
|
||||||
}
|
}
|
||||||
@@ -62,6 +53,7 @@ class TextIntelService {
|
|||||||
dataDir,
|
dataDir,
|
||||||
fetchImpl = global.fetch,
|
fetchImpl = global.fetch,
|
||||||
screenApi = null,
|
screenApi = null,
|
||||||
|
windowContextProvider = null,
|
||||||
}) {
|
}) {
|
||||||
this.store = store;
|
this.store = store;
|
||||||
this.settings = settings;
|
this.settings = settings;
|
||||||
@@ -69,14 +61,110 @@ class TextIntelService {
|
|||||||
this.dataDir = dataDir;
|
this.dataDir = dataDir;
|
||||||
this.fetch = fetchImpl;
|
this.fetch = fetchImpl;
|
||||||
this.screen = screenApi;
|
this.screen = screenApi;
|
||||||
|
// OS-specific foreground-window/element detection is a platform adapter.
|
||||||
|
// This code no longer branches on process.platform; the factory selects it.
|
||||||
|
this.windowContext = windowContextProvider
|
||||||
|
|| require('./platform').createWindowContextProvider();
|
||||||
this.worker = null;
|
this.worker = null;
|
||||||
this.workerPromise = null;
|
this.workerPromise = null;
|
||||||
this.workerQueue = Promise.resolve();
|
this.workerQueue = Promise.resolve();
|
||||||
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
|
this.ocrDataDir = path.join(dataDir, 'ocr', 'eng');
|
||||||
this.modelCapabilityCache = new Map();
|
this.modelCapabilityCache = new Map();
|
||||||
|
// In-flight AI request controllers, grouped by guide, so closing a guide
|
||||||
|
// (or quitting) can cancel outstanding requests instead of leaving them
|
||||||
|
// to resolve against stale data.
|
||||||
|
this.inflight = new Set();
|
||||||
|
// Bounded concurrency for AI network calls.
|
||||||
|
this.maxConcurrent = 2;
|
||||||
|
this.activeCount = 0;
|
||||||
|
this.waitQueue = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
aiNetworkOptions() {
|
||||||
|
const ai = this.settings.get('ai') || {};
|
||||||
|
const timeoutMs = Number.isFinite(ai.timeoutMs) && ai.timeoutMs > 0 ? ai.timeoutMs : 60000;
|
||||||
|
const maxImageBytes = Number.isFinite(ai.maxImageBytes) && ai.maxImageBytes > 0
|
||||||
|
? ai.maxImageBytes
|
||||||
|
: 12 * 1024 * 1024;
|
||||||
|
return {
|
||||||
|
allowRemote: Boolean(ai.allowRemoteHost),
|
||||||
|
attachScreenshots: ai.attachScreenshots !== false,
|
||||||
|
timeoutMs,
|
||||||
|
maxImageBytes,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve the endpoint against the local-first policy or throw a clear error.
|
||||||
|
resolveHost(host) {
|
||||||
|
const { allowRemote } = this.aiNetworkOptions();
|
||||||
|
const result = validateOllamaHost(host, { allowRemote });
|
||||||
|
if (!result.ok) {
|
||||||
|
const err = new Error(result.reason);
|
||||||
|
err.code = 'STEPFORGE_AI_HOST_BLOCKED';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return result.host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetch with a hard deadline and cooperative cancellation. Every AI network
|
||||||
|
// call goes through here so a dead endpoint can never hang the UI.
|
||||||
|
async fetchJson(url, { method = 'GET', body = null, guideId = null } = {}) {
|
||||||
|
const { timeoutMs } = this.aiNetworkOptions();
|
||||||
|
const controller = new AbortController();
|
||||||
|
if (guideId) controller._guideId = guideId;
|
||||||
|
this.inflight.add(controller);
|
||||||
|
const timer = setTimeout(() => controller.abort(new Error('timeout')), timeoutMs);
|
||||||
|
try {
|
||||||
|
const res = await this.fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: body ? { 'Content-Type': 'application/json' } : undefined,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
return res;
|
||||||
|
} catch (err) {
|
||||||
|
if (controller.signal.aborted) {
|
||||||
|
// The abort reason distinguishes an explicit cancel from a timeout.
|
||||||
|
const reasonMsg = controller.signal.reason && controller.signal.reason.message;
|
||||||
|
const cancelled = reasonMsg === 'cancelled';
|
||||||
|
const wrapped = new Error(cancelled ? 'AI request cancelled.' : 'AI request timed out.');
|
||||||
|
wrapped.code = 'STEPFORGE_AI_ABORTED';
|
||||||
|
throw wrapped;
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
this.inflight.delete(controller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel in-flight AI requests, optionally scoped to one guide.
|
||||||
|
cancelInflight(guideId = null) {
|
||||||
|
for (const controller of [...this.inflight]) {
|
||||||
|
if (!guideId || controller._guideId === guideId) {
|
||||||
|
try { controller.abort(new Error('cancelled')); } catch { /* already settled */ }
|
||||||
|
this.inflight.delete(controller);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bounded-concurrency gate for AI network work.
|
||||||
|
async withConcurrency(fn) {
|
||||||
|
if (this.activeCount >= this.maxConcurrent) {
|
||||||
|
await new Promise((resolve) => this.waitQueue.push(resolve));
|
||||||
|
}
|
||||||
|
this.activeCount += 1;
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} finally {
|
||||||
|
this.activeCount -= 1;
|
||||||
|
const next = this.waitQueue.shift();
|
||||||
|
if (next) next();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async shutdown() {
|
async shutdown() {
|
||||||
|
this.cancelInflight();
|
||||||
if (this.worker) {
|
if (this.worker) {
|
||||||
try {
|
try {
|
||||||
await this.worker.terminate();
|
await this.worker.terminate();
|
||||||
@@ -178,135 +266,13 @@ class TextIntelService {
|
|||||||
|
|
||||||
async collectForegroundWindowContext(osPoint = null) {
|
async collectForegroundWindowContext(osPoint = null) {
|
||||||
try {
|
try {
|
||||||
if (process.platform === 'win32') return this.collectWindowsWindowContext(osPoint);
|
return await this.windowContext.collect(osPoint);
|
||||||
if (process.platform === 'darwin') return this.collectMacWindowContext();
|
|
||||||
if (process.platform === 'linux') return this.collectLinuxWindowContext();
|
|
||||||
} catch {
|
} catch {
|
||||||
// best effort only
|
// best effort only
|
||||||
|
return { appName: '', windowTitle: '' };
|
||||||
}
|
}
|
||||||
return { appName: '', windowTitle: '' };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async collectWindowsWindowContext(osPoint = null) {
|
|
||||||
const hasPoint = osPoint && Number.isFinite(osPoint.x) && Number.isFinite(osPoint.y);
|
|
||||||
const clickX = hasPoint ? Number(osPoint.x) : 0;
|
|
||||||
const clickY = hasPoint ? Number(osPoint.y) : 0;
|
|
||||||
const script = `
|
|
||||||
$clickX = ${clickX};
|
|
||||||
$clickY = ${clickY};
|
|
||||||
$elementLabel = '';
|
|
||||||
$elementRole = '';
|
|
||||||
$elementClass = '';
|
|
||||||
$elementProcessId = 0;
|
|
||||||
$elementValue = '';
|
|
||||||
if (${hasPoint ? '$true' : '$false'}) {
|
|
||||||
try {
|
|
||||||
Add-Type -AssemblyName UIAutomationClient,UIAutomationTypes,WindowsBase | Out-Null
|
|
||||||
$point = New-Object System.Windows.Point($clickX, $clickY);
|
|
||||||
$element = [System.Windows.Automation.AutomationElement]::FromPoint($point);
|
|
||||||
if ($element) {
|
|
||||||
$current = $element.Current;
|
|
||||||
$elementLabel = $current.Name;
|
|
||||||
$elementRole = $current.LocalizedControlType;
|
|
||||||
$elementClass = $current.ClassName;
|
|
||||||
$elementProcessId = $current.ProcessId;
|
|
||||||
try {
|
|
||||||
$valPattern = [System.Windows.Automation.ValuePattern]::Pattern;
|
|
||||||
if ($element.GetSupportedPatterns() -contains $valPattern) {
|
|
||||||
$elementValue = $element.GetCurrentPattern($valPattern).Current.Value;
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
} catch { }
|
|
||||||
}
|
|
||||||
Add-Type @"
|
|
||||||
using System;
|
|
||||||
using System.Runtime.InteropServices;
|
|
||||||
using System.Text;
|
|
||||||
public static class Win32 {
|
|
||||||
[DllImport("user32.dll")] public static extern IntPtr GetForegroundWindow();
|
|
||||||
[DllImport("user32.dll", CharSet = CharSet.Unicode)]
|
|
||||||
public static extern int GetWindowText(IntPtr hWnd, StringBuilder text, int count);
|
|
||||||
[DllImport("user32.dll")] public static extern uint GetWindowThreadProcessId(IntPtr hWnd, out uint processId);
|
|
||||||
}
|
|
||||||
"@;
|
|
||||||
$hWnd = [Win32]::GetForegroundWindow();
|
|
||||||
$sb = New-Object System.Text.StringBuilder 512;
|
|
||||||
[void][Win32]::GetWindowText($hWnd, $sb, $sb.Capacity);
|
|
||||||
$pid = 0;
|
|
||||||
[void][Win32]::GetWindowThreadProcessId($hWnd, [ref]$pid);
|
|
||||||
$proc = Get-Process -Id $pid -ErrorAction SilentlyContinue | Select-Object -First 1;
|
|
||||||
$out = [ordered]@{
|
|
||||||
appName = if ($proc) { $proc.ProcessName } else { '' };
|
|
||||||
windowTitle = $sb.ToString();
|
|
||||||
elementLabel = $elementLabel;
|
|
||||||
elementRole = $elementRole;
|
|
||||||
elementClass = $elementClass;
|
|
||||||
elementValue = $elementValue;
|
|
||||||
elementProcessId = $elementProcessId;
|
|
||||||
pid = $pid;
|
|
||||||
};
|
|
||||||
$out | ConvertTo-Json -Compress;
|
|
||||||
`;
|
|
||||||
return new Promise(resolve => {
|
|
||||||
execFile('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
timeout: 4000,
|
|
||||||
windowsHide: true,
|
|
||||||
}, (err, stdout) => {
|
|
||||||
if (err) { resolve({}); return; }
|
|
||||||
try { resolve(JSON.parse(stdout.trim() || '{}')); }
|
|
||||||
catch { resolve({}); }
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
collectMacWindowContext() {
|
|
||||||
const script = `
|
|
||||||
set appName to ""
|
|
||||||
set windowTitle to ""
|
|
||||||
tell application "System Events"
|
|
||||||
try
|
|
||||||
set frontApp to first application process whose frontmost is true
|
|
||||||
set appName to name of frontApp
|
|
||||||
try
|
|
||||||
set windowTitle to name of front window of frontApp
|
|
||||||
end try
|
|
||||||
end try
|
|
||||||
end tell
|
|
||||||
return appName & linefeed & windowTitle
|
|
||||||
`;
|
|
||||||
const result = execFileSync('osascript', ['-e', script], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 1200,
|
|
||||||
}).trimEnd();
|
|
||||||
const [appName = '', windowTitle = ''] = result.split(/\r?\n/);
|
|
||||||
return { appName, windowTitle };
|
|
||||||
}
|
|
||||||
|
|
||||||
collectLinuxWindowContext() {
|
|
||||||
if (!hasBinary('xprop')) return { appName: '', windowTitle: '' };
|
|
||||||
const active = execFileSync('xprop', ['-root', '_NET_ACTIVE_WINDOW'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 1200,
|
|
||||||
});
|
|
||||||
const activeMatch = active.match(/window id # (0x[0-9a-fA-F]+)/);
|
|
||||||
if (!activeMatch) return { appName: '', windowTitle: '' };
|
|
||||||
const winId = activeMatch[1];
|
|
||||||
const details = execFileSync('xprop', ['-id', winId, '_NET_WM_NAME', 'WM_NAME', 'WM_CLASS'], {
|
|
||||||
encoding: 'utf8',
|
|
||||||
stdio: ['ignore', 'pipe', 'pipe'],
|
|
||||||
timeout: 1200,
|
|
||||||
});
|
|
||||||
const titleMatch = details.match(/(?:_NET_WM_NAME\(UTF8_STRING\)|WM_NAME\(STRING\)|WM_NAME\(UTF8_STRING\)) = "([^"]*)"/);
|
|
||||||
const classMatch = details.match(/WM_CLASS\(STRING\) = "([^"]*)"(?:, "([^"]*)")?/);
|
|
||||||
return {
|
|
||||||
appName: classMatch ? (classMatch[2] || classMatch[1] || '') : '',
|
|
||||||
windowTitle: titleMatch ? titleMatch[1] : '',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async buildCaptureTitle({ mode, frame, clickPos, clickMeta = null }) {
|
async buildCaptureTitle({ mode, frame, clickPos, clickMeta = null }) {
|
||||||
const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta });
|
const ctx = await this.buildCaptureContext({ mode, frame, clickPos, clickMeta });
|
||||||
@@ -374,8 +340,19 @@ public static class Win32 {
|
|||||||
if (!config.ollama.host) {
|
if (!config.ollama.host) {
|
||||||
return { ok: false, reason: 'Set an Ollama host first.' };
|
return { ok: false, reason: 'Set an Ollama host first.' };
|
||||||
}
|
}
|
||||||
const tagsUrl = new URL('/api/tags', `${config.ollama.host.replace(/\/+$/, '')}/`);
|
let host;
|
||||||
const res = await this.fetch(tagsUrl, { method: 'GET' });
|
try {
|
||||||
|
host = this.resolveHost(config.ollama.host);
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: err.message };
|
||||||
|
}
|
||||||
|
const tagsUrl = new URL('/api/tags', `${host.replace(/\/+$/, '')}/`);
|
||||||
|
let res;
|
||||||
|
try {
|
||||||
|
res = await this.fetchJson(tagsUrl, { method: 'GET' });
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: err.message };
|
||||||
|
}
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return { ok: false, reason: `Ollama check failed (${res.status})` };
|
return { ok: false, reason: `Ollama check failed (${res.status})` };
|
||||||
}
|
}
|
||||||
@@ -383,7 +360,7 @@ public static class Win32 {
|
|||||||
const models = Array.isArray(data?.models) ? data.models.map((model) => model.name).filter(Boolean) : [];
|
const models = Array.isArray(data?.models) ? data.models.map((model) => model.name).filter(Boolean) : [];
|
||||||
const installed = config.ollama.model ? models.includes(config.ollama.model) : false;
|
const installed = config.ollama.model ? models.includes(config.ollama.model) : false;
|
||||||
const vision = installed ? await this.modelSupportsVision({
|
const vision = installed ? await this.modelSupportsVision({
|
||||||
host: config.ollama.host,
|
host,
|
||||||
model: config.ollama.model,
|
model: config.ollama.model,
|
||||||
}) : false;
|
}) : false;
|
||||||
return {
|
return {
|
||||||
@@ -391,7 +368,7 @@ public static class Win32 {
|
|||||||
installed,
|
installed,
|
||||||
vision,
|
vision,
|
||||||
models,
|
models,
|
||||||
host: config.ollama.host,
|
host,
|
||||||
model: config.ollama.model,
|
model: config.ollama.model,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -407,10 +384,9 @@ public static class Win32 {
|
|||||||
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
|
const url = new URL('/api/show', `${normalizedHost.replace(/\/+$/, '')}/`);
|
||||||
let capabilities = [];
|
let capabilities = [];
|
||||||
try {
|
try {
|
||||||
const response = await this.fetch(url, {
|
const response = await this.fetchJson(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: { model: normalizedModel },
|
||||||
body: JSON.stringify({ model: normalizedModel }),
|
|
||||||
});
|
});
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
@@ -439,12 +415,12 @@ public static class Win32 {
|
|||||||
return fs.readFileSync(imagePath).toString('base64');
|
return fs.readFileSync(imagePath).toString('base64');
|
||||||
}
|
}
|
||||||
|
|
||||||
async callOllamaText({ host, model, prompt, systemPrompt }) {
|
async callOllamaText({ host, model, prompt, systemPrompt, guideId = null }) {
|
||||||
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
|
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
|
||||||
const response = await this.fetch(url, {
|
const response = await this.withConcurrency(() => this.fetchJson(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
guideId,
|
||||||
body: JSON.stringify({
|
body: {
|
||||||
model,
|
model,
|
||||||
stream: false,
|
stream: false,
|
||||||
messages: [
|
messages: [
|
||||||
@@ -452,8 +428,8 @@ public static class Win32 {
|
|||||||
{ role: 'user', content: prompt },
|
{ role: 'user', content: prompt },
|
||||||
],
|
],
|
||||||
options: { temperature: 0.4 },
|
options: { temperature: 0.4 },
|
||||||
}),
|
},
|
||||||
});
|
}));
|
||||||
if (!response.ok) throw new Error(`Ollama request failed (${response.status})`);
|
if (!response.ok) throw new Error(`Ollama request failed (${response.status})`);
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
const content = payload?.message?.content;
|
const content = payload?.message?.content;
|
||||||
@@ -461,16 +437,16 @@ public static class Win32 {
|
|||||||
return content.trim();
|
return content.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
async callOllama({ host, model, prompt, systemPrompt, images = [] }) {
|
async callOllama({ host, model, prompt, systemPrompt, images = [], guideId = null }) {
|
||||||
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
|
const url = new URL('/api/chat', `${host.replace(/\/+$/, '')}/`);
|
||||||
const userMessage = { role: 'user', content: prompt };
|
const userMessage = { role: 'user', content: prompt };
|
||||||
if (Array.isArray(images) && images.length) {
|
if (Array.isArray(images) && images.length) {
|
||||||
userMessage.images = images;
|
userMessage.images = images;
|
||||||
}
|
}
|
||||||
const response = await this.fetch(url, {
|
const response = await this.withConcurrency(() => this.fetchJson(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
guideId,
|
||||||
body: JSON.stringify({
|
body: {
|
||||||
model,
|
model,
|
||||||
stream: false,
|
stream: false,
|
||||||
format: 'json',
|
format: 'json',
|
||||||
@@ -481,8 +457,8 @@ public static class Win32 {
|
|||||||
options: {
|
options: {
|
||||||
temperature: 0.2,
|
temperature: 0.2,
|
||||||
},
|
},
|
||||||
}),
|
},
|
||||||
});
|
}));
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Ollama request failed (${response.status})`);
|
throw new Error(`Ollama request failed (${response.status})`);
|
||||||
}
|
}
|
||||||
@@ -508,12 +484,23 @@ public static class Win32 {
|
|||||||
if (!config.ollama.host || !config.ollama.model) {
|
if (!config.ollama.host || !config.ollama.model) {
|
||||||
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
|
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
|
||||||
}
|
}
|
||||||
|
let host;
|
||||||
|
try {
|
||||||
|
host = this.resolveHost(config.ollama.host);
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: err.message };
|
||||||
|
}
|
||||||
|
const netOptions = this.aiNetworkOptions();
|
||||||
|
|
||||||
const guide = this.store.getGuide(guideId);
|
const guide = this.store.getGuide(guideId);
|
||||||
const step = this.store.getStep(guideId, stepId);
|
const step = this.store.getStep(guideId, stepId);
|
||||||
if (!guide || !step) {
|
if (!guide || !step) {
|
||||||
return { ok: false, reason: 'Guide or step not found.' };
|
return { ok: false, reason: 'Guide or step not found.' };
|
||||||
}
|
}
|
||||||
|
// Snapshot the revision now: AI generation is slow, and the user may
|
||||||
|
// edit the step meanwhile. We save with this expectedRevision so a
|
||||||
|
// response built from stale data cannot overwrite a newer user edit.
|
||||||
|
const baseRevision = Number.isInteger(step.revision) ? step.revision : 0;
|
||||||
|
|
||||||
const currentBlock = blockId
|
const currentBlock = blockId
|
||||||
? [...(step.textBlocks || []), ...(step.codeBlocks || []), ...(step.tableBlocks || [])].find((b) => b.id === blockId) || null
|
? [...(step.textBlocks || []), ...(step.codeBlocks || []), ...(step.tableBlocks || [])].find((b) => b.id === blockId) || null
|
||||||
@@ -522,10 +509,20 @@ public static class Win32 {
|
|||||||
return { ok: false, reason: 'Block not found.' };
|
return { ok: false, reason: 'Block not found.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
const screenshotBase64 = step.image ? this.readStepImageBase64(guideId, stepId) : '';
|
// Only attach a screenshot when the user allows it, the model can use
|
||||||
|
// it, and it is within the size budget (a full 4K PNG base64-expands to
|
||||||
|
// tens of MB in the request body).
|
||||||
|
let screenshotBase64 = '';
|
||||||
|
if (netOptions.attachScreenshots && step.image) {
|
||||||
|
const candidate = this.readStepImageBase64(guideId, stepId);
|
||||||
|
const bytes = candidate ? Math.floor((candidate.length * 3) / 4) : 0;
|
||||||
|
if (candidate && bytes <= netOptions.maxImageBytes) {
|
||||||
|
screenshotBase64 = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
const screenshotAttached = Boolean(screenshotBase64)
|
const screenshotAttached = Boolean(screenshotBase64)
|
||||||
? await this.modelSupportsVision({
|
? await this.modelSupportsVision({
|
||||||
host: config.ollama.host,
|
host,
|
||||||
model: config.ollama.model,
|
model: config.ollama.model,
|
||||||
})
|
})
|
||||||
: false;
|
: false;
|
||||||
@@ -588,15 +585,34 @@ public static class Win32 {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const raw = await this.callOllama({
|
const raw = await this.callOllama({
|
||||||
host: config.ollama.host,
|
host,
|
||||||
model: config.ollama.model,
|
model: config.ollama.model,
|
||||||
prompt,
|
prompt,
|
||||||
systemPrompt,
|
systemPrompt,
|
||||||
images: screenshotAttached ? [screenshotBase64] : [],
|
images: screenshotAttached ? [screenshotBase64] : [],
|
||||||
|
guideId,
|
||||||
});
|
});
|
||||||
const patch = normalizeAiPatch(raw);
|
const patch = normalizeAiPatch(raw);
|
||||||
const updated = applyAiPatchToStep(step, patch, { target, blockId });
|
// Re-read the step: while generation ran, a capture auto-doc or another
|
||||||
const saved = this.store.saveStep(guideId, updated);
|
// background write may have advanced it. Apply the patch to the current
|
||||||
|
// step and save with the original expected revision so a user edit made
|
||||||
|
// during generation causes a conflict instead of a silent overwrite.
|
||||||
|
let currentStep = step;
|
||||||
|
try {
|
||||||
|
currentStep = this.store.getStep(guideId, stepId) || step;
|
||||||
|
} catch {
|
||||||
|
currentStep = step;
|
||||||
|
}
|
||||||
|
const updated = applyAiPatchToStep(currentStep, patch, { target, blockId });
|
||||||
|
let saved;
|
||||||
|
try {
|
||||||
|
saved = this.store.saveStep(guideId, updated, { expectedRevision: baseRevision });
|
||||||
|
} catch (err) {
|
||||||
|
if (err && err.code === 'STEPFORGE_REVISION_CONFLICT') {
|
||||||
|
return { ok: false, reason: 'The step changed while AI was generating; nothing was overwritten.' };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
return { ok: true, step: saved, patch };
|
return { ok: true, step: saved, patch };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, reason: err && err.message ? err.message : 'AI generation failed.' };
|
return { ok: false, reason: err && err.message ? err.message : 'AI generation failed.' };
|
||||||
@@ -610,6 +626,12 @@ public static class Win32 {
|
|||||||
if (!config.ollama.host || !config.ollama.model) {
|
if (!config.ollama.host || !config.ollama.model) {
|
||||||
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
|
return { ok: false, reason: 'Configure Ollama host and model in Settings.' };
|
||||||
}
|
}
|
||||||
|
let host;
|
||||||
|
try {
|
||||||
|
host = this.resolveHost(config.ollama.host);
|
||||||
|
} catch (err) {
|
||||||
|
return { ok: false, reason: err.message };
|
||||||
|
}
|
||||||
const trimmed = normalizeWhitespace(text);
|
const trimmed = normalizeWhitespace(text);
|
||||||
if (!trimmed) return { ok: false, reason: 'No text to rewrite.' };
|
if (!trimmed) return { ok: false, reason: 'No text to rewrite.' };
|
||||||
|
|
||||||
@@ -628,7 +650,7 @@ public static class Win32 {
|
|||||||
].filter((l) => l !== null).join('\n');
|
].filter((l) => l !== null).join('\n');
|
||||||
|
|
||||||
const result = await this.callOllamaText({
|
const result = await this.callOllamaText({
|
||||||
host: config.ollama.host,
|
host,
|
||||||
model: config.ollama.model,
|
model: config.ollama.model,
|
||||||
prompt,
|
prompt,
|
||||||
systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.',
|
systemPrompt: 'You are a documentation editor. Return only the improved text, nothing else.',
|
||||||
|
|||||||
@@ -124,18 +124,40 @@ function importGuideArchive(store, file, { mode = 'copy' } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function finalizeImport(store, newGuide, idMap, stepJsons, stepFiles) {
|
function finalizeImport(store, newGuide, idMap, stepJsons, stepFiles) {
|
||||||
|
// Transactional import: validate the guide and EVERY step first, then write
|
||||||
|
// the whole guide into a temporary staging directory, and only publish it
|
||||||
|
// with a single atomic rename. Previously guide.json was written before the
|
||||||
|
// steps validated, so a bad step left a partial guide in the library.
|
||||||
validateGuide(newGuide);
|
validateGuide(newGuide);
|
||||||
writeJsonSync(path.join(store.guideDir(newGuide.guideId), 'guide.json'), newGuide);
|
const normalizedSteps = [];
|
||||||
|
|
||||||
for (const [stepId, { raw }] of stepJsons) {
|
for (const [stepId, { raw }] of stepJsons) {
|
||||||
const step = normalizeStep({ ...raw, stepId });
|
const step = normalizeStep({ ...raw, stepId });
|
||||||
step.parentStepId = raw.parentStepId ? idMap.get(raw.parentStepId) || null : null;
|
step.parentStepId = raw.parentStepId ? idMap.get(raw.parentStepId) || null : null;
|
||||||
validateStep(step);
|
validateStep(step); // throws before anything is written on a bad step
|
||||||
const dir = store.stepDir(newGuide.guideId, stepId);
|
normalizedSteps.push([stepId, step]);
|
||||||
writeJsonSync(path.join(dir, 'step.json'), step);
|
}
|
||||||
for (const { name, data } of stepFiles.get(stepId) || []) {
|
|
||||||
atomicWriteFileSync(path.join(dir, name), data);
|
const finalDir = store.guideDir(newGuide.guideId);
|
||||||
|
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
|
||||||
|
const stagingDir = `${finalDir}.importing-${Date.now()}`;
|
||||||
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(stagingDir, { recursive: true });
|
||||||
|
writeJsonSync(path.join(stagingDir, 'guide.json'), newGuide);
|
||||||
|
for (const [stepId, step] of normalizedSteps) {
|
||||||
|
const dir = path.join(stagingDir, 'steps', stepId);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
writeJsonSync(path.join(dir, 'step.json'), step);
|
||||||
|
for (const { name, data } of stepFiles.get(stepId) || []) {
|
||||||
|
atomicWriteFileSync(path.join(dir, name), data);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Publish atomically. If the final dir appeared meanwhile, fail cleanly.
|
||||||
|
if (fs.existsSync(finalDir)) throw new Error(`guide already exists: ${newGuide.guideId}`);
|
||||||
|
fs.renameSync(stagingDir, finalDir);
|
||||||
|
} catch (err) {
|
||||||
|
fs.rmSync(stagingDir, { recursive: true, force: true });
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
return store.getGuide(newGuide.guideId);
|
return store.getGuide(newGuide.guideId);
|
||||||
}
|
}
|
||||||
@@ -160,7 +182,9 @@ function saveLinkedGuide(store, guideId, { force = false } = {}) {
|
|||||||
store.saveGuide(guide, { touch: false });
|
store.saveGuide(guide, { touch: false });
|
||||||
return { saved: true, path: target };
|
return { saved: true, path: target };
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock(target);
|
// Release by our acquisition token so we never remove a lock a concurrent
|
||||||
|
// force-steal replaced with theirs.
|
||||||
|
releaseLock(target, { lock: result.lock });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,18 +20,39 @@ function lockPathFor(archivePath) {
|
|||||||
return path.join(dir, `${stem}.lock-sfgz`);
|
return path.join(dir, `${stem}.lock-sfgz`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function currentHolder() {
|
function currentProcess() {
|
||||||
return { host: os.hostname(), user: os.userInfo().username, pid: process.pid };
|
return { host: os.hostname(), user: os.userInfo().username, pid: process.pid };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function currentHolder() {
|
||||||
|
return {
|
||||||
|
...currentProcess(),
|
||||||
|
// Random per-acquisition token so two processes that happen to share
|
||||||
|
// host+user+pid space (containers, pid reuse) still compare distinctly,
|
||||||
|
// and so a steal can be detected by the previous holder.
|
||||||
|
token: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function readLock(archivePath) {
|
function readLock(archivePath) {
|
||||||
return readJsonIfExists(lockPathFor(archivePath), null);
|
return readJsonIfExists(lockPathFor(archivePath), null);
|
||||||
}
|
}
|
||||||
|
|
||||||
function sameHolder(a, b) {
|
// Process identity (host+user+pid). Used to decide whether an existing lock is
|
||||||
|
// held by *this process* (safe to re-acquire) or someone else (a conflict).
|
||||||
|
function sameProcess(a, b) {
|
||||||
return a && b && a.host === b.host && a.user === b.user && a.pid === b.pid;
|
return a && b && a.host === b.host && a.user === b.user && a.pid === b.pid;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exact-acquisition identity via the per-acquisition token. Used by release so
|
||||||
|
// a caller only removes the lock it actually took (never one a force-steal
|
||||||
|
// replaced with its own).
|
||||||
|
function sameAcquisition(existing, owner) {
|
||||||
|
if (!existing || !owner) return false;
|
||||||
|
if (owner.token) return existing.token === owner.token;
|
||||||
|
return sameProcess(existing, owner);
|
||||||
|
}
|
||||||
|
|
||||||
function isStale(lock, now = Date.now()) {
|
function isStale(lock, now = Date.now()) {
|
||||||
const t = Date.parse(lock && lock.acquiredAt);
|
const t = Date.parse(lock && lock.acquiredAt);
|
||||||
return !Number.isFinite(t) || now - t > STALE_AFTER_MS;
|
return !Number.isFinite(t) || now - t > STALE_AFTER_MS;
|
||||||
@@ -44,24 +65,49 @@ function isStale(lock, now = Date.now()) {
|
|||||||
*/
|
*/
|
||||||
function acquireLock(archivePath, { force = false } = {}) {
|
function acquireLock(archivePath, { force = false } = {}) {
|
||||||
const file = lockPathFor(archivePath);
|
const file = lockPathFor(archivePath);
|
||||||
const existing = readLock(archivePath);
|
|
||||||
const me = currentHolder();
|
const me = currentHolder();
|
||||||
if (existing && !sameHolder(existing, me) && !isStale(existing) && !force) {
|
const lock = { ...me, acquiredAt: nowIso() };
|
||||||
|
const payload = JSON.stringify(lock, null, 2);
|
||||||
|
|
||||||
|
// Fast path: exclusive create. Only one writer wins the O_CREAT|O_EXCL race,
|
||||||
|
// so two processes can't both believe they hold the lock (the old
|
||||||
|
// read-then-write left exactly that window open).
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(file, payload, { flag: 'wx' });
|
||||||
|
return { acquired: true, lock };
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code !== 'EEXIST') throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A lock already exists. We may take it over only if this process already
|
||||||
|
// holds it, it is stale, or the caller is force-stealing (user confirmed).
|
||||||
|
const existing = readLock(archivePath);
|
||||||
|
if (existing && !sameProcess(existing, me) && !isStale(existing) && !force) {
|
||||||
return { acquired: false, conflict: existing };
|
return { acquired: false, conflict: existing };
|
||||||
}
|
}
|
||||||
const lock = { ...me, acquiredAt: nowIso() };
|
// Overwrite to claim ownership (our token now identifies the lock).
|
||||||
fs.writeFileSync(file, JSON.stringify(lock, null, 2));
|
fs.writeFileSync(file, payload);
|
||||||
return { acquired: true, lock };
|
return { acquired: true, lock };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Release only if we are the holder (or force). */
|
/**
|
||||||
function releaseLock(archivePath, { force = false } = {}) {
|
* Release only if we are the holder (or force). Pass the `lock` (or its
|
||||||
|
* `token`) returned by acquireLock so ownership is matched by token — the
|
||||||
|
* per-acquisition token means a fresh currentHolder() would not match.
|
||||||
|
*/
|
||||||
|
function releaseLock(archivePath, { force = false, lock = null, token = null } = {}) {
|
||||||
const file = lockPathFor(archivePath);
|
const file = lockPathFor(archivePath);
|
||||||
const existing = readLock(archivePath);
|
const existing = readLock(archivePath);
|
||||||
if (!existing) return true;
|
if (!existing) return true;
|
||||||
if (!force && !sameHolder(existing, currentHolder())) return false;
|
// With no explicit lock/token, fall back to process identity (the legacy
|
||||||
|
// "release my own lock" path) rather than a fresh token that can't match.
|
||||||
|
const owner = lock || (token ? { token } : currentProcess());
|
||||||
|
if (!force && !sameAcquisition(existing, owner)) return false;
|
||||||
fs.rmSync(file, { force: true });
|
fs.rmSync(file, { force: true });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS };
|
module.exports = {
|
||||||
|
lockPathFor, readLock, acquireLock, releaseLock, isStale, STALE_AFTER_MS,
|
||||||
|
sameProcess, sameAcquisition,
|
||||||
|
};
|
||||||
|
|||||||
@@ -52,6 +52,9 @@ function createGuide(fields = {}) {
|
|||||||
favorite: Boolean(fields.favorite),
|
favorite: Boolean(fields.favorite),
|
||||||
linkedSource: fields.linkedSource || null,
|
linkedSource: fields.linkedSource || null,
|
||||||
exportProfiles: { ...(fields.exportProfiles || {}) },
|
exportProfiles: { ...(fields.exportProfiles || {}) },
|
||||||
|
// Monotonic revision for optimistic concurrency. Absent in v1 data (reads
|
||||||
|
// as 0), bumped on every store write.
|
||||||
|
revision: Number.isInteger(fields.revision) && fields.revision >= 0 ? fields.revision : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +92,8 @@ function createStep(fields = {}) {
|
|||||||
captureMetadata: (fields.captureMetadata && typeof fields.captureMetadata === 'object' && !Array.isArray(fields.captureMetadata))
|
captureMetadata: (fields.captureMetadata && typeof fields.captureMetadata === 'object' && !Array.isArray(fields.captureMetadata))
|
||||||
? { ...fields.captureMetadata }
|
? { ...fields.captureMetadata }
|
||||||
: null,
|
: null,
|
||||||
|
// Monotonic revision for optimistic concurrency (see createGuide).
|
||||||
|
revision: Number.isInteger(fields.revision) && fields.revision >= 0 ? fields.revision : 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const { blockText } = require('./blocks');
|
|||||||
* specific step in the editor.
|
* specific step in the editor.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const INDEX_VERSION = 1;
|
const INDEX_VERSION = 2;
|
||||||
|
|
||||||
function tokenize(text) {
|
function tokenize(text) {
|
||||||
if (!text) return [];
|
if (!text) return [];
|
||||||
@@ -27,20 +27,82 @@ function tokenize(text) {
|
|||||||
class SearchIndex {
|
class SearchIndex {
|
||||||
constructor(indexDir) {
|
constructor(indexDir) {
|
||||||
this.file = path.join(indexDir, 'search-index.json');
|
this.file = path.join(indexDir, 'search-index.json');
|
||||||
|
// Per-guide source fingerprints so a startup reconcile can tell which
|
||||||
|
// guides changed while the app was closed, without re-reading every step.
|
||||||
|
this.fingerprints = {}; // guideId -> fingerprint string
|
||||||
|
// Recovery status surfaced to the UI: 'ok' | 'reset' (missing/corrupt/
|
||||||
|
// version mismatch) | 'reconciled' (rebuilt from the store at startup).
|
||||||
|
this.status = 'ok';
|
||||||
|
const fileExisted = require('node:fs').existsSync(this.file);
|
||||||
const stored = readJsonIfExists(this.file, null);
|
const stored = readJsonIfExists(this.file, null);
|
||||||
if (stored && stored.version === INDEX_VERSION) {
|
if (stored && stored.version === INDEX_VERSION && stored.docs && typeof stored.docs === 'object') {
|
||||||
this.docs = stored.docs;
|
this.docs = stored.docs;
|
||||||
|
this.fingerprints = stored.fingerprints || {};
|
||||||
} else {
|
} else {
|
||||||
|
// Missing, corrupt, or an older index version: start empty and mark it,
|
||||||
|
// so reconcile() rebuilds from the store instead of silently staying
|
||||||
|
// blank (which made search "work" but return nothing). A file that
|
||||||
|
// existed but could not be used is a 'reset' (recovery-worthy); a
|
||||||
|
// genuinely absent index on first run is just 'ok'.
|
||||||
this.docs = {}; // docKey -> { guideId, stepId, title, text, updatedAt }
|
this.docs = {}; // docKey -> { guideId, stepId, title, text, updatedAt }
|
||||||
|
this.status = fileExisted ? 'reset' : 'ok';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
persist() {
|
persist() {
|
||||||
writeJsonSync(this.file, { version: INDEX_VERSION, docs: this.docs });
|
writeJsonSync(this.file, {
|
||||||
|
version: INDEX_VERSION,
|
||||||
|
docs: this.docs,
|
||||||
|
fingerprints: this.fingerprints,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static fingerprint(guide) {
|
||||||
|
return `${guide.updatedAt || ''}:${Number.isInteger(guide.revision) ? guide.revision : 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconcile the index against the store at startup: reindex guides that are
|
||||||
|
* new or changed (by fingerprint), and drop index entries for guides that no
|
||||||
|
* longer exist. Returns a summary with a recovery status for the UI.
|
||||||
|
*/
|
||||||
|
reconcile(store) {
|
||||||
|
const guides = store.listGuides();
|
||||||
|
const liveIds = new Set(guides.map((g) => g.guideId));
|
||||||
|
let reindexed = 0;
|
||||||
|
let removed = 0;
|
||||||
|
|
||||||
|
// Drop docs/fingerprints for guides that are gone.
|
||||||
|
for (const key of Object.keys(this.fingerprints)) {
|
||||||
|
if (!liveIds.has(key)) {
|
||||||
|
this.removeGuide(key, { persist: false });
|
||||||
|
delete this.fingerprints[key];
|
||||||
|
removed += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const guide of guides) {
|
||||||
|
const fp = SearchIndex.fingerprint(guide);
|
||||||
|
const indexed = this.fingerprints[guide.guideId];
|
||||||
|
const hasDoc = Boolean(this.docs[`g:${guide.guideId}`]);
|
||||||
|
if (indexed === fp && hasDoc) continue; // unchanged
|
||||||
|
try {
|
||||||
|
this.indexGuide(guide, store.listSteps(guide.guideId), { persist: false });
|
||||||
|
reindexed += 1;
|
||||||
|
} catch {
|
||||||
|
// A single unreadable guide must not abort the whole reconcile.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.persist();
|
||||||
|
if (this.status === 'reset' || reindexed > 0 || removed > 0) {
|
||||||
|
this.status = this.status === 'reset' ? 'reset' : 'reconciled';
|
||||||
|
}
|
||||||
|
return { status: this.status, reindexed, removed, total: guides.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** (Re)index one guide and all of its steps. */
|
/** (Re)index one guide and all of its steps. */
|
||||||
indexGuide(guide, stepsMap) {
|
indexGuide(guide, stepsMap, { persist = true } = {}) {
|
||||||
this.removeGuide(guide.guideId, { persist: false });
|
this.removeGuide(guide.guideId, { persist: false });
|
||||||
|
|
||||||
const placeholderText = Object.entries(guide.placeholders || {})
|
const placeholderText = Object.entries(guide.placeholders || {})
|
||||||
@@ -69,13 +131,15 @@ class SearchIndex {
|
|||||||
updatedAt: guide.updatedAt,
|
updatedAt: guide.updatedAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.persist();
|
this.fingerprints[guide.guideId] = SearchIndex.fingerprint(guide);
|
||||||
|
if (persist) this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeGuide(guideId, { persist = true } = {}) {
|
removeGuide(guideId, { persist = true } = {}) {
|
||||||
for (const key of Object.keys(this.docs)) {
|
for (const key of Object.keys(this.docs)) {
|
||||||
if (this.docs[key].guideId === guideId) delete this.docs[key];
|
if (this.docs[key].guideId === guideId) delete this.docs[key];
|
||||||
}
|
}
|
||||||
|
delete this.fingerprints[guideId];
|
||||||
if (persist) this.persist();
|
if (persist) this.persist();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,13 @@ const DEFAULT_SETTINGS = {
|
|||||||
// user is likely to click so the buffer holds frames of the now-visible
|
// user is likely to click so the buffer holds frames of the now-visible
|
||||||
// screen rather than the just-dismissed app window.
|
// screen rather than the just-dismissed app window.
|
||||||
postHideSettleMs: 150,
|
postHideSettleMs: 150,
|
||||||
|
// Raw typed-text capture. When true, printable characters typed between
|
||||||
|
// captures are buffered and stored in step capture metadata (and can be
|
||||||
|
// sent to a configured AI host). This can record passwords or other
|
||||||
|
// secrets, so it is OFF by default and must be explicitly opted into.
|
||||||
|
// Shortcut detection (Ctrl+T, Enter, …) is unaffected — only raw
|
||||||
|
// character logging is gated here.
|
||||||
|
captureTypedText: false,
|
||||||
},
|
},
|
||||||
editor: {
|
editor: {
|
||||||
focusedViewDefaultForNewSteps: false,
|
focusedViewDefaultForNewSteps: false,
|
||||||
@@ -52,6 +59,22 @@ const DEFAULT_SETTINGS = {
|
|||||||
},
|
},
|
||||||
ai: {
|
ai: {
|
||||||
enabled: false,
|
enabled: false,
|
||||||
|
// Auto-document captured steps in the background when a session capture
|
||||||
|
// lands. Requires enabled + a reachable model.
|
||||||
|
autoDoc: false,
|
||||||
|
// Local-first: only a loopback Ollama endpoint is contacted unless this
|
||||||
|
// is explicitly turned on. Turning it on sends screenshots and text to
|
||||||
|
// the configured remote host.
|
||||||
|
allowRemoteHost: false,
|
||||||
|
// Attach the step screenshot to AI requests (only for vision-capable
|
||||||
|
// models). Turning this off keeps requests text-only.
|
||||||
|
attachScreenshots: true,
|
||||||
|
// Per-request network deadline (ms). A dead endpoint fails fast instead
|
||||||
|
// of leaving UI actions pending forever.
|
||||||
|
timeoutMs: 60000,
|
||||||
|
// Skip attaching a screenshot larger than this many bytes (pre-base64)
|
||||||
|
// to avoid multi-hundred-MB request bodies.
|
||||||
|
maxImageBytes: 12 * 1024 * 1024,
|
||||||
ollama: {
|
ollama: {
|
||||||
host: 'http://127.0.0.1:11434',
|
host: 'http://127.0.0.1:11434',
|
||||||
model: 'llama3.2:1b',
|
model: 'llama3.2:1b',
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
const fs = require('node:fs');
|
const fs = require('node:fs');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
const { zipDirSync, extractZipSync } = require('./zip');
|
const { zipDirSync, extractZipSync } = require('./zip');
|
||||||
const { atomicWriteFileSync } = require('./util');
|
const { atomicWriteFileSync, readJsonSync } = require('./util');
|
||||||
|
const { validateGuide } = require('./schema');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Snapshot backups: a zip of the guide directory (excluding history/) stored
|
* Snapshot backups: a zip of the guide directory (excluding history/) stored
|
||||||
@@ -16,7 +17,11 @@ function snapshotsDir(store, guideId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function snapshotName(label) {
|
function snapshotName(label) {
|
||||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').replace(/-\d{3}Z$/, 'Z');
|
// Keep milliseconds: stripping them made two snapshots taken within the same
|
||||||
|
// second collide on filename (the second silently overwrote the first, so
|
||||||
|
// rapid automatic backups produced only one file). ms keeps names unique and
|
||||||
|
// still chronologically sortable.
|
||||||
|
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
||||||
return label ? `${stamp}-${label.replace(/[^A-Za-z0-9_-]+/g, '_')}.zip` : `${stamp}.zip`;
|
return label ? `${stamp}-${label.replace(/[^A-Za-z0-9_-]+/g, '_')}.zip` : `${stamp}.zip`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,20 +55,103 @@ function pruneSnapshots(store, guideId, keepLast) {
|
|||||||
/**
|
/**
|
||||||
* Restore a snapshot: replaces the guide's current content (guide.json and
|
* Restore a snapshot: replaces the guide's current content (guide.json and
|
||||||
* steps/) with the snapshot's, keeping the history/ directory intact.
|
* steps/) with the snapshot's, keeping the history/ directory intact.
|
||||||
|
*
|
||||||
|
* The extraction is staged and validated BEFORE any live content is touched:
|
||||||
|
* a corrupt or truncated snapshot can no longer destroy the current guide.
|
||||||
|
* The swap itself moves the old content aside, moves the new content in, then
|
||||||
|
* deletes the old — so a failure mid-swap leaves a recoverable state.
|
||||||
*/
|
*/
|
||||||
function restoreSnapshot(store, guideId, name) {
|
function restoreSnapshot(store, guideId, name) {
|
||||||
const file = path.join(snapshotsDir(store, guideId), path.basename(name));
|
const file = path.join(snapshotsDir(store, guideId), path.basename(name));
|
||||||
if (!fs.existsSync(file)) throw new Error(`snapshot not found: ${name}`);
|
if (!fs.existsSync(file)) throw new Error(`snapshot not found: ${name}`);
|
||||||
const buf = fs.readFileSync(file);
|
const buf = fs.readFileSync(file);
|
||||||
const guideDir = store.guideDir(guideId);
|
const guideDir = store.guideDir(guideId);
|
||||||
// Safety: snapshot the pre-restore state too, so a restore is undoable.
|
|
||||||
createSnapshot(store, guideId, { label: 'pre-restore' });
|
// 1. Extract + validate into a temp staging dir. Nothing live is touched yet.
|
||||||
for (const entry of fs.readdirSync(guideDir)) {
|
const staging = `${guideDir}.restoring-${Date.now()}`;
|
||||||
if (entry === 'history') continue;
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
fs.rmSync(path.join(guideDir, entry), { recursive: true, force: true });
|
try {
|
||||||
|
fs.mkdirSync(staging, { recursive: true });
|
||||||
|
extractZipSync(buf, staging);
|
||||||
|
const guideJson = path.join(staging, 'guide.json');
|
||||||
|
if (!fs.existsSync(guideJson)) throw new Error('snapshot is missing guide.json');
|
||||||
|
validateGuide(readJsonSync(guideJson)); // throws on a corrupt snapshot
|
||||||
|
} catch (err) {
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
throw new Error(`snapshot restore aborted (snapshot invalid): ${err.message}`);
|
||||||
}
|
}
|
||||||
extractZipSync(buf, guideDir);
|
|
||||||
|
// 2. Snapshot the pre-restore state so the restore is itself undoable.
|
||||||
|
createSnapshot(store, guideId, { label: 'pre-restore' });
|
||||||
|
|
||||||
|
// 3. Swap in the validated content, preserving history/. Move live content
|
||||||
|
// aside first so we can roll back if a step fails.
|
||||||
|
const backup = `${guideDir}.prev-${Date.now()}`;
|
||||||
|
const liveEntries = fs.readdirSync(guideDir).filter((e) => e !== 'history');
|
||||||
|
fs.mkdirSync(backup, { recursive: true });
|
||||||
|
try {
|
||||||
|
for (const entry of liveEntries) {
|
||||||
|
fs.renameSync(path.join(guideDir, entry), path.join(backup, entry));
|
||||||
|
}
|
||||||
|
for (const entry of fs.readdirSync(staging)) {
|
||||||
|
if (entry === 'history') continue;
|
||||||
|
fs.renameSync(path.join(staging, entry), path.join(guideDir, entry));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Roll back: restore whatever we moved aside.
|
||||||
|
for (const entry of fs.readdirSync(backup)) {
|
||||||
|
const dest = path.join(guideDir, entry);
|
||||||
|
fs.rmSync(dest, { recursive: true, force: true });
|
||||||
|
fs.renameSync(path.join(backup, entry), dest);
|
||||||
|
}
|
||||||
|
fs.rmSync(backup, { recursive: true, force: true });
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
fs.rmSync(backup, { recursive: true, force: true });
|
||||||
|
fs.rmSync(staging, { recursive: true, force: true });
|
||||||
return store.getGuide(guideId);
|
return store.getGuide(guideId);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir };
|
/**
|
||||||
|
* Automatic backup policy. Every guide keeps a small save counter in its
|
||||||
|
* history dir; once `everyNSaves` saves accumulate (and backups.automatic is
|
||||||
|
* on) an automatic snapshot is taken and old ones pruned to backups.keepLast.
|
||||||
|
* Returns the snapshot name when one was taken, else null. Never throws — a
|
||||||
|
* backup failure must not break the save that triggered it.
|
||||||
|
*/
|
||||||
|
function autoSnapshotIfDue(store, guideId, settings) {
|
||||||
|
try {
|
||||||
|
const backups = (settings && settings.get && settings.get('backups')) || {};
|
||||||
|
if (backups.automatic === false) return null;
|
||||||
|
const everyN = Number.isInteger(backups.everyNSaves) && backups.everyNSaves > 0 ? backups.everyNSaves : 25;
|
||||||
|
const keepLast = Number.isInteger(backups.keepLast) && backups.keepLast > 0 ? backups.keepLast : 10;
|
||||||
|
|
||||||
|
const dir = path.join(store.guideDir(guideId), 'history');
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
const counterFile = path.join(dir, 'autosave-counter.json');
|
||||||
|
let count = 0;
|
||||||
|
try {
|
||||||
|
count = JSON.parse(fs.readFileSync(counterFile, 'utf8')).count || 0;
|
||||||
|
} catch { count = 0; }
|
||||||
|
count += 1;
|
||||||
|
|
||||||
|
if (count >= everyN) {
|
||||||
|
createSnapshot(store, guideId, { label: 'auto', keepLast });
|
||||||
|
count = 0;
|
||||||
|
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
atomicWriteFileSync(counterFile, JSON.stringify({ count }));
|
||||||
|
return null;
|
||||||
|
} catch (err) {
|
||||||
|
// Best effort: report, never break the caller's save.
|
||||||
|
console.error(`[stepforge] automatic backup failed for ${guideId}: ${err && err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createSnapshot, listSnapshots, pruneSnapshots, restoreSnapshot, snapshotsDir,
|
||||||
|
autoSnapshotIfDue,
|
||||||
|
};
|
||||||
|
|||||||
@@ -12,6 +12,22 @@ const {
|
|||||||
} = require('./schema');
|
} = require('./schema');
|
||||||
const { sanitizeHtml } = require('./sanitize');
|
const { sanitizeHtml } = require('./sanitize');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown by revision-aware saves when the on-disk revision no longer matches
|
||||||
|
* the caller's expectation — i.e. someone else wrote in between. Callers that
|
||||||
|
* pass expectedRevision (background/AI/capture writes) use this to avoid
|
||||||
|
* clobbering a newer user edit.
|
||||||
|
*/
|
||||||
|
class RevisionConflictError extends Error {
|
||||||
|
constructor(kind, id, expected, actual) {
|
||||||
|
super(`${kind} ${id} changed since it was read (expected revision ${expected}, found ${actual})`);
|
||||||
|
this.name = 'RevisionConflictError';
|
||||||
|
this.code = 'STEPFORGE_REVISION_CONFLICT';
|
||||||
|
this.expected = expected;
|
||||||
|
this.actual = actual;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Folder-based guide store. One directory per guide, one directory per step,
|
* Folder-based guide store. One directory per guide, one directory per step,
|
||||||
* all JSON written atomically. This is the only module that knows the
|
* all JSON written atomically. This is the only module that knows the
|
||||||
@@ -27,21 +43,49 @@ class GuideStore {
|
|||||||
this.guidesDir = path.join(this.libraryDir, 'guides');
|
this.guidesDir = path.join(this.libraryDir, 'guides');
|
||||||
this.indexDir = path.join(this.libraryDir, 'index');
|
this.indexDir = path.join(this.libraryDir, 'index');
|
||||||
this.trashDir = path.join(this.libraryDir, 'trash');
|
this.trashDir = path.join(this.libraryDir, 'trash');
|
||||||
|
this.quarantineDir = path.join(this.libraryDir, 'quarantine');
|
||||||
this.tempDir = path.join(rootDir, 'temp');
|
this.tempDir = path.join(rootDir, 'temp');
|
||||||
this.sharedLinksDir = path.join(rootDir, 'shared-links');
|
this.sharedLinksDir = path.join(rootDir, 'shared-links');
|
||||||
this.foldersFile = path.join(this.libraryDir, 'folders.json');
|
this.foldersFile = path.join(this.libraryDir, 'folders.json');
|
||||||
|
// In-memory log of files quarantined this session (corrupt/unreadable),
|
||||||
|
// surfaced to the UI instead of silently vanishing.
|
||||||
|
this.recoveryReport = [];
|
||||||
this.ensureLayout();
|
this.ensureLayout();
|
||||||
}
|
}
|
||||||
|
|
||||||
ensureLayout() {
|
ensureLayout() {
|
||||||
for (const dir of [
|
for (const dir of [
|
||||||
this.settingsDir, this.templatesDir, this.guidesDir, this.indexDir,
|
this.settingsDir, this.templatesDir, this.guidesDir, this.indexDir,
|
||||||
this.trashDir, this.tempDir, this.sharedLinksDir,
|
this.trashDir, this.quarantineDir, this.tempDir, this.sharedLinksDir,
|
||||||
]) {
|
]) {
|
||||||
fs.mkdirSync(dir, { recursive: true });
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Move a corrupt/unreadable file or directory into quarantine (preserving
|
||||||
|
* the original bytes) and record it, rather than silently dropping it. A
|
||||||
|
* guide/step never just disappears without an explanation.
|
||||||
|
*/
|
||||||
|
quarantine(sourcePath, kind, reason) {
|
||||||
|
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
const dest = path.join(this.quarantineDir, `${kind}-${path.basename(sourcePath)}-${stamp}`);
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(this.quarantineDir, { recursive: true });
|
||||||
|
fs.renameSync(sourcePath, dest);
|
||||||
|
} catch {
|
||||||
|
// If we cannot move it (e.g. cross-device or vanished), still record it.
|
||||||
|
}
|
||||||
|
const entry = { kind, source: sourcePath, quarantined: dest, reason: String(reason || 'unreadable'), at: nowIso() };
|
||||||
|
this.recoveryReport.push(entry);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Corrupt files quarantined this session (for a recovery UI). */
|
||||||
|
getRecoveryReport() {
|
||||||
|
return [...this.recoveryReport];
|
||||||
|
}
|
||||||
|
|
||||||
guideDir(guideId) {
|
guideDir(guideId) {
|
||||||
if (!/^[a-zA-Z0-9_-]+$/.test(guideId)) throw new Error(`bad guide id: ${guideId}`);
|
if (!/^[a-zA-Z0-9_-]+$/.test(guideId)) throw new Error(`bad guide id: ${guideId}`);
|
||||||
return path.join(this.guidesDir, guideId);
|
return path.join(this.guidesDir, guideId);
|
||||||
@@ -70,10 +114,19 @@ class GuideStore {
|
|||||||
return normalizeGuide(raw);
|
return normalizeGuide(raw);
|
||||||
}
|
}
|
||||||
|
|
||||||
saveGuide(guide, { touch = true } = {}) {
|
saveGuide(guide, { touch = true, expectedRevision = null } = {}) {
|
||||||
validateGuide(guide);
|
validateGuide(guide);
|
||||||
|
// Optimistic concurrency: a caller that read the guide can pass the
|
||||||
|
// revision it saw; if disk moved on since, refuse rather than clobber.
|
||||||
|
if (expectedRevision !== null) {
|
||||||
|
const current = this.guideExists(guide.guideId) ? this.getGuide(guide.guideId).revision : 0;
|
||||||
|
if (current !== expectedRevision) {
|
||||||
|
throw new RevisionConflictError('guide', guide.guideId, expectedRevision, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
const stored = deepClone(guide);
|
const stored = deepClone(guide);
|
||||||
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
|
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
|
||||||
|
stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1;
|
||||||
if (touch) stored.updatedAt = nowIso();
|
if (touch) stored.updatedAt = nowIso();
|
||||||
writeJsonSync(path.join(this.guideDir(guide.guideId), 'guide.json'), stored);
|
writeJsonSync(path.join(this.guideDir(guide.guideId), 'guide.json'), stored);
|
||||||
return stored;
|
return stored;
|
||||||
@@ -83,11 +136,16 @@ class GuideStore {
|
|||||||
const out = [];
|
const out = [];
|
||||||
for (const entry of fs.readdirSync(this.guidesDir, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(this.guidesDir, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
const file = path.join(this.guidesDir, entry.name, 'guide.json');
|
const dir = path.join(this.guidesDir, entry.name);
|
||||||
|
const file = path.join(dir, 'guide.json');
|
||||||
|
if (!fs.existsSync(file)) continue; // in-progress/empty dir, not corruption
|
||||||
try {
|
try {
|
||||||
out.push(normalizeGuide(readJsonSync(file)));
|
out.push(normalizeGuide(readJsonSync(file)));
|
||||||
} catch {
|
} catch (err) {
|
||||||
// skip unreadable entries rather than failing the whole library
|
// A corrupt guide.json used to make the guide silently vanish from the
|
||||||
|
// library. Quarantine the directory (preserving it) and record it so
|
||||||
|
// the user can be told, instead of losing it without explanation.
|
||||||
|
this.quarantine(dir, 'guide', err && err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
out.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
|
||||||
@@ -211,18 +269,38 @@ class GuideStore {
|
|||||||
if (!fs.existsSync(stepsRoot)) return map;
|
if (!fs.existsSync(stepsRoot)) return map;
|
||||||
for (const entry of fs.readdirSync(stepsRoot, { withFileTypes: true })) {
|
for (const entry of fs.readdirSync(stepsRoot, { withFileTypes: true })) {
|
||||||
if (!entry.isDirectory()) continue;
|
if (!entry.isDirectory()) continue;
|
||||||
|
const dir = path.join(stepsRoot, entry.name);
|
||||||
|
const file = path.join(dir, 'step.json');
|
||||||
|
if (!fs.existsSync(file)) continue;
|
||||||
try {
|
try {
|
||||||
map.set(entry.name, normalizeStep(readJsonSync(path.join(stepsRoot, entry.name, 'step.json'))));
|
map.set(entry.name, normalizeStep(readJsonSync(file)));
|
||||||
} catch {
|
} catch (err) {
|
||||||
// skip unreadable step
|
// Quarantine a corrupt step (preserving it) and record it rather than
|
||||||
|
// silently dropping it from the guide.
|
||||||
|
this.quarantine(dir, 'step', err && err.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return map;
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveStep(guideId, step) {
|
saveStep(guideId, step, { expectedRevision = null } = {}) {
|
||||||
|
// Optimistic concurrency for background/AI/capture writes: refuse to
|
||||||
|
// overwrite a step that changed since it was read. Direct user edits pass
|
||||||
|
// no expectedRevision (last-write-wins — the user is the authority).
|
||||||
|
if (expectedRevision !== null) {
|
||||||
|
let current = 0;
|
||||||
|
try {
|
||||||
|
current = this.getStep(guideId, step.stepId).revision;
|
||||||
|
} catch {
|
||||||
|
current = 0; // step vanished; treat as revision 0
|
||||||
|
}
|
||||||
|
if (current !== expectedRevision) {
|
||||||
|
throw new RevisionConflictError('step', step.stepId, expectedRevision, current);
|
||||||
|
}
|
||||||
|
}
|
||||||
const stored = normalizeStep(deepClone(step));
|
const stored = normalizeStep(deepClone(step));
|
||||||
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
|
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
|
||||||
|
stored.revision = (Number.isInteger(stored.revision) ? stored.revision : 0) + 1;
|
||||||
validateStep(stored);
|
validateStep(stored);
|
||||||
writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), stored);
|
writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), stored);
|
||||||
const guide = this.getGuide(guideId);
|
const guide = this.getGuide(guideId);
|
||||||
@@ -352,4 +430,4 @@ class GuideStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { GuideStore };
|
module.exports = { GuideStore, RevisionConflictError };
|
||||||
|
|||||||
@@ -538,6 +538,60 @@ function normalizeOllamaHost(host) {
|
|||||||
return `http://${raw.replace(/\/+$/, '')}`;
|
return `http://${raw.replace(/\/+$/, '')}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A hostname/IP that refers to this machine only. StepForge is local-first:
|
||||||
|
// by default the Ollama endpoint must be loopback so screenshots and text
|
||||||
|
// never leave the device, unless the user explicitly opts into a remote host.
|
||||||
|
function isLoopbackHost(host) {
|
||||||
|
const normalized = normalizeOllamaHost(host);
|
||||||
|
if (!normalized) return false;
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(normalized);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const name = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
||||||
|
if (name === 'localhost' || name === '::1' || name === '0.0.0.0' || name === '::') return true;
|
||||||
|
// IPv4 loopback block 127.0.0.0/8.
|
||||||
|
const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(name);
|
||||||
|
if (m && Number(m[1]) === 127 && m.slice(1).every((o) => Number(o) >= 0 && Number(o) <= 255)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
// IPv4-mapped IPv6 loopback, e.g. ::ffff:127.0.0.1.
|
||||||
|
if (/^::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a configured Ollama endpoint against the local-first policy.
|
||||||
|
* Returns { ok, host, reason }. Remote hosts are rejected unless the caller
|
||||||
|
* passes allowRemote: true (the explicit ai.allowRemoteHost opt-in).
|
||||||
|
*/
|
||||||
|
function validateOllamaHost(host, { allowRemote = false } = {}) {
|
||||||
|
const normalized = normalizeOllamaHost(host);
|
||||||
|
if (!normalized) return { ok: false, host: '', reason: 'No Ollama host configured.' };
|
||||||
|
let url;
|
||||||
|
try {
|
||||||
|
url = new URL(normalized);
|
||||||
|
} catch {
|
||||||
|
return { ok: false, host: normalized, reason: 'Ollama host is not a valid URL.' };
|
||||||
|
}
|
||||||
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
||||||
|
return { ok: false, host: normalized, reason: 'Ollama host must use http or https.' };
|
||||||
|
}
|
||||||
|
if (!allowRemote && !isLoopbackHost(normalized)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
host: normalized,
|
||||||
|
reason:
|
||||||
|
'Remote Ollama hosts are disabled. StepForge only contacts a local (loopback) ' +
|
||||||
|
'Ollama by default. Enable "Allow remote AI host" in AI settings to send ' +
|
||||||
|
'screenshots and text to this host.',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, host: normalized, reason: '' };
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeAiLevel(level) {
|
function normalizeAiLevel(level) {
|
||||||
const key = normalizeWhitespace(level).toLowerCase();
|
const key = normalizeWhitespace(level).toLowerCase();
|
||||||
return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info');
|
return AI_LEVEL_ALIASES.get(key) || (TEXTBLOCK_LEVELS.includes(key) ? key : 'info');
|
||||||
@@ -845,6 +899,8 @@ module.exports = {
|
|||||||
buildCaptureTitle,
|
buildCaptureTitle,
|
||||||
plainTextToHtml,
|
plainTextToHtml,
|
||||||
normalizeOllamaHost,
|
normalizeOllamaHost,
|
||||||
|
isLoopbackHost,
|
||||||
|
validateOllamaHost,
|
||||||
normalizeAiPatch,
|
normalizeAiPatch,
|
||||||
buildAiPrompt,
|
buildAiPrompt,
|
||||||
applyAiPatchToStep,
|
applyAiPatchToStep,
|
||||||
|
|||||||
@@ -121,8 +121,22 @@ function zipSync(entries, { date = new Date(2026, 0, 1) } = {}) {
|
|||||||
return Buffer.concat([...localParts, centralBuf, eocd]);
|
return Buffer.concat([...localParts, centralBuf, eocd]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Parse a zip buffer into [{ name, data }] with CRC verification. */
|
// Resource limits for untrusted archives (share files, snapshots). These cap
|
||||||
function unzipSync(buffer) {
|
// memory and disk work so a ZIP bomb can't exhaust the machine. Callers that
|
||||||
|
// build archives themselves may relax them; imports use the defaults.
|
||||||
|
const DEFAULT_UNZIP_LIMITS = {
|
||||||
|
maxEntries: 50000,
|
||||||
|
maxTotalCompressed: 1024 * 1024 * 1024, // 1 GiB of stored bytes
|
||||||
|
maxTotalUncompressed: 4 * 1024 * 1024 * 1024, // 4 GiB inflated total
|
||||||
|
maxEntryUncompressed: 512 * 1024 * 1024, // 512 MiB per entry
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a zip buffer into [{ name, data }] with CRC verification and hard
|
||||||
|
* resource limits. `limits` overrides DEFAULT_UNZIP_LIMITS.
|
||||||
|
*/
|
||||||
|
function unzipSync(buffer, { limits = {} } = {}) {
|
||||||
|
const lim = { ...DEFAULT_UNZIP_LIMITS, ...limits };
|
||||||
if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('zip: too small');
|
if (!Buffer.isBuffer(buffer) || buffer.length < 22) throw new Error('zip: too small');
|
||||||
// Find end-of-central-directory record (scan backwards over the comment).
|
// Find end-of-central-directory record (scan backwards over the comment).
|
||||||
let eocd = -1;
|
let eocd = -1;
|
||||||
@@ -132,11 +146,14 @@ function unzipSync(buffer) {
|
|||||||
}
|
}
|
||||||
if (eocd < 0) throw new Error('zip: end record not found');
|
if (eocd < 0) throw new Error('zip: end record not found');
|
||||||
const count = buffer.readUInt16LE(eocd + 10);
|
const count = buffer.readUInt16LE(eocd + 10);
|
||||||
|
if (count > lim.maxEntries) throw new Error(`zip: too many entries (${count} > ${lim.maxEntries})`);
|
||||||
let pos = buffer.readUInt32LE(eocd + 16);
|
let pos = buffer.readUInt32LE(eocd + 16);
|
||||||
|
|
||||||
const entries = [];
|
const entries = [];
|
||||||
|
let totalCompressed = 0;
|
||||||
|
let totalUncompressed = 0;
|
||||||
for (let i = 0; i < count; i++) {
|
for (let i = 0; i < count; i++) {
|
||||||
if (buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
|
if (pos + 46 > buffer.length || buffer.readUInt32LE(pos) !== 0x02014b50) throw new Error('zip: bad central header');
|
||||||
const method = buffer.readUInt16LE(pos + 10);
|
const method = buffer.readUInt16LE(pos + 10);
|
||||||
const crc = buffer.readUInt32LE(pos + 16);
|
const crc = buffer.readUInt32LE(pos + 16);
|
||||||
const compSize = buffer.readUInt32LE(pos + 20);
|
const compSize = buffer.readUInt32LE(pos + 20);
|
||||||
@@ -151,17 +168,33 @@ function unzipSync(buffer) {
|
|||||||
assertSafeEntryName(name);
|
assertSafeEntryName(name);
|
||||||
if (name.endsWith('/')) continue; // directory entry
|
if (name.endsWith('/')) continue; // directory entry
|
||||||
|
|
||||||
|
// Budget checks BEFORE allocating/inflating: the declared sizes are
|
||||||
|
// attacker-controlled, so reject oversize claims up front.
|
||||||
|
if (uncompSize > lim.maxEntryUncompressed) {
|
||||||
|
throw new Error(`zip: entry too large (${uncompSize} > ${lim.maxEntryUncompressed}): ${name}`);
|
||||||
|
}
|
||||||
|
totalCompressed += compSize;
|
||||||
|
totalUncompressed += uncompSize;
|
||||||
|
if (totalCompressed > lim.maxTotalCompressed) throw new Error('zip: total compressed size exceeds limit');
|
||||||
|
if (totalUncompressed > lim.maxTotalUncompressed) throw new Error('zip: total inflated size exceeds limit');
|
||||||
|
|
||||||
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('zip: bad local header');
|
if (buffer.readUInt32LE(localOffset) !== 0x04034b50) throw new Error('zip: bad local header');
|
||||||
const lNameLen = buffer.readUInt16LE(localOffset + 26);
|
const lNameLen = buffer.readUInt16LE(localOffset + 26);
|
||||||
const lExtraLen = buffer.readUInt16LE(localOffset + 28);
|
const lExtraLen = buffer.readUInt16LE(localOffset + 28);
|
||||||
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
|
const dataStart = localOffset + 30 + lNameLen + lExtraLen;
|
||||||
|
if (dataStart + compSize > buffer.length) throw new Error(`zip: entry data out of range: ${name}`);
|
||||||
const raw = buffer.subarray(dataStart, dataStart + compSize);
|
const raw = buffer.subarray(dataStart, dataStart + compSize);
|
||||||
|
|
||||||
let data;
|
let data;
|
||||||
if (method === 0) data = Buffer.from(raw);
|
if (method === 0) data = Buffer.from(raw);
|
||||||
else if (method === 8) data = zlib.inflateRawSync(raw);
|
else if (method === 8) {
|
||||||
else throw new Error(`zip: unsupported method ${method} for ${name}`);
|
// Cap inflation so a small deflate stream can't expand to gigabytes —
|
||||||
|
// even if the declared uncompSize lied, this is the real guard.
|
||||||
|
data = zlib.inflateRawSync(raw, { maxOutputLength: lim.maxEntryUncompressed });
|
||||||
|
} else throw new Error(`zip: unsupported method ${method} for ${name}`);
|
||||||
|
|
||||||
|
// Exact length match (not "at least"): the inflated bytes must equal the
|
||||||
|
// declared uncompressed size, and the CRC must verify.
|
||||||
if (data.length !== uncompSize) throw new Error(`zip: size mismatch for ${name}`);
|
if (data.length !== uncompSize) throw new Error(`zip: size mismatch for ${name}`);
|
||||||
if (crc32(data) !== crc) throw new Error(`zip: CRC mismatch for ${name}`);
|
if (crc32(data) !== crc) throw new Error(`zip: CRC mismatch for ${name}`);
|
||||||
entries.push({ name, data });
|
entries.push({ name, data });
|
||||||
@@ -170,10 +203,10 @@ function unzipSync(buffer) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Extract a zip buffer under destDir; every path is traversal-checked. */
|
/** Extract a zip buffer under destDir; every path is traversal-checked. */
|
||||||
function extractZipSync(buffer, destDir) {
|
function extractZipSync(buffer, destDir, { limits = {} } = {}) {
|
||||||
const resolvedDest = path.resolve(destDir);
|
const resolvedDest = path.resolve(destDir);
|
||||||
const written = [];
|
const written = [];
|
||||||
for (const { name, data } of unzipSync(buffer)) {
|
for (const { name, data } of unzipSync(buffer, { limits })) {
|
||||||
const target = path.resolve(resolvedDest, name);
|
const target = path.resolve(resolvedDest, name);
|
||||||
if (target !== resolvedDest && !target.startsWith(resolvedDest + path.sep)) {
|
if (target !== resolvedDest && !target.startsWith(resolvedDest + path.sep)) {
|
||||||
throw new Error(`zip: entry escapes destination: ${name}`);
|
throw new Error(`zip: entry escapes destination: ${name}`);
|
||||||
@@ -203,4 +236,7 @@ function zipDirSync(dir, { filter = () => true, prefix = '' } = {}) {
|
|||||||
return zipSync(entries);
|
return zipSync(entries);
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName };
|
module.exports = {
|
||||||
|
crc32, zipSync, unzipSync, extractZipSync, zipDirSync, assertSafeEntryName,
|
||||||
|
DEFAULT_UNZIP_LIMITS,
|
||||||
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|
||||||
Copyright (c) 2026 Tyler Westbrook
|
The authoritative, full license text lives in the repository root:
|
||||||
|
|
||||||
Permission is granted to use, copy, modify, and distribute this software for non-commercial purposes only.
|
../LICENSE
|
||||||
|
|
||||||
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.
|
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).
|
||||||
|
|
||||||
This software is provided "as is", without warranty of any kind.
|
Human-readable summary: https://creativecommons.org/licenses/by-nc/4.0/
|
||||||
|
SPDX-License-Identifier: CC-BY-NC-4.0
|
||||||
|
Copyright (c) 2026 Tyler Westbrook
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# StepForge privacy and network contract
|
||||||
|
|
||||||
|
StepForge is **local-first**. Guides, screenshots, and settings live on your
|
||||||
|
machine and are never uploaded on their own. This document describes exactly
|
||||||
|
what data StepForge collects locally and the one situation in which data
|
||||||
|
leaves your device.
|
||||||
|
|
||||||
|
## What never happens
|
||||||
|
|
||||||
|
- No telemetry or analytics.
|
||||||
|
- No update checks, license checks, or "phone home".
|
||||||
|
- No cloud storage or sync.
|
||||||
|
- No dependency downloads at runtime (dependencies are installed only by you,
|
||||||
|
via `npm ci`).
|
||||||
|
|
||||||
|
## Data StepForge collects locally
|
||||||
|
|
||||||
|
When you capture a step, StepForge may record, **stored only on disk in your
|
||||||
|
data directory**, capture context to help title and describe the step:
|
||||||
|
|
||||||
|
- The screenshot image.
|
||||||
|
- OCR text read from the region around your click (via the bundled Tesseract
|
||||||
|
engine — this runs locally, it is not a network call).
|
||||||
|
- The foreground window title and application name.
|
||||||
|
- The accessibility label/role/value of the clicked UI element (Windows).
|
||||||
|
- Keyboard shortcuts you pressed (for example `Ctrl+T`).
|
||||||
|
|
||||||
|
### Raw typed text is OFF by default
|
||||||
|
|
||||||
|
StepForge can additionally record the **raw printable characters** you type
|
||||||
|
between captures. Because this can capture passwords or other secrets, it is
|
||||||
|
**disabled by default**. It is only recorded when you explicitly enable
|
||||||
|
`capture.captureTypedText`, and even then the characters are used only to
|
||||||
|
title the current step and are not retained beyond it. With the setting off,
|
||||||
|
raw characters are never read or stored (on Windows they never even leave the
|
||||||
|
keyboard-hook process).
|
||||||
|
|
||||||
|
## The one outbound feature: optional AI
|
||||||
|
|
||||||
|
StepForge has an **optional** AI integration that generates step titles and
|
||||||
|
descriptions with a local large-language-model runtime
|
||||||
|
([Ollama](https://ollama.com)). It is **off by default**. When you turn it on
|
||||||
|
and configure an endpoint:
|
||||||
|
|
||||||
|
- StepForge sends the step **screenshot** (only to vision-capable models, only
|
||||||
|
when "Attach screenshots" is on, and only if within the size limit) and the
|
||||||
|
step **text/capture context** to the configured Ollama endpoint.
|
||||||
|
- By default the endpoint must be a **local (loopback) address** — for example
|
||||||
|
`http://127.0.0.1:11434`. StepForge refuses to send data to a non-loopback
|
||||||
|
host unless you explicitly enable **"Allow remote AI host"**. Enabling that
|
||||||
|
option means your screenshots and text are sent to the remote host you
|
||||||
|
configured; StepForge cannot control what that host does with them.
|
||||||
|
- Every AI request has a timeout, can be cancelled (closing the guide cancels
|
||||||
|
in-flight requests), and runs under a bounded concurrency limit.
|
||||||
|
|
||||||
|
## Bundled dependencies
|
||||||
|
|
||||||
|
Beyond the Electron desktop shell, StepForge bundles the Tesseract OCR engine
|
||||||
|
and its English language data as production dependencies. All OCR runs locally.
|
||||||
|
|
||||||
|
## Where your data lives
|
||||||
|
|
||||||
|
- Windows: `%APPDATA%\stepforge`
|
||||||
|
- Linux: `~/.local/share/stepforge` (or `$XDG_DATA_HOME/stepforge`)
|
||||||
|
- Override with the `STEPFORGE_DATA_DIR` environment variable.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# StepForge on apt-based Linux (Debian / Ubuntu)
|
||||||
|
|
||||||
|
This is the setup and packaging guide for **apt-based** distributions. Fedora
|
||||||
|
and other dnf-based systems have a separate guide: [dnf.md](dnf.md).
|
||||||
|
|
||||||
|
## Install from the .deb
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install ./stepforge_<version>_amd64.deb
|
||||||
|
```
|
||||||
|
|
||||||
|
apt pulls the required runtime libraries automatically (they are declared as
|
||||||
|
`Depends`). The package installs:
|
||||||
|
|
||||||
|
- the app and a fixed Electron runtime under `/opt/stepforge`,
|
||||||
|
- the `stepforge` launcher at `/usr/bin/stepforge`,
|
||||||
|
- a desktop entry, icons, and `.sfgz`/`.sfglt` file associations.
|
||||||
|
|
||||||
|
Launch it from your application menu or run `stepforge`.
|
||||||
|
|
||||||
|
### Sandbox
|
||||||
|
|
||||||
|
The launcher runs **sandboxed**. On most modern kernels the Chromium
|
||||||
|
user-namespace sandbox works out of the box; the package's `postinst` also
|
||||||
|
makes the setuid `chrome-sandbox` helper usable as a fallback. StepForge will
|
||||||
|
**not** silently launch unsandboxed — see the launcher's message if the
|
||||||
|
sandbox is unavailable.
|
||||||
|
|
||||||
|
## Install from the portable tarball
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar -xzf stepforge_<version>_linux-x64.tar.gz
|
||||||
|
# Install the runtime libraries first (see below), then run:
|
||||||
|
./usr/bin/stepforge # or move opt/stepforge to /opt and use the launcher
|
||||||
|
```
|
||||||
|
|
||||||
|
The tarball includes the `/usr/bin/stepforge` launcher (unlike older builds).
|
||||||
|
Install the runtime libraries with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/linux/apt/install-runtime-deps.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Capture capabilities on apt systems
|
||||||
|
|
||||||
|
- **X11**: full per-click capture with an accurate marker (needs `xinput`).
|
||||||
|
- **Wayland**: screen capture via the XDG Desktop Portal + PipeWire; the
|
||||||
|
portal asks permission once per recording. Per-click capture with
|
||||||
|
coordinates is not exposed by Wayland, so recording uses a global hotkey or
|
||||||
|
interval trigger. StepForge reports the active trigger honestly.
|
||||||
|
|
||||||
|
Run StepForge and open Settings → Diagnostics to see the detected session
|
||||||
|
type, portal/PipeWire status, and the active capture profile.
|
||||||
|
|
||||||
|
## Build the .deb yourself
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/linux/apt/install-build-deps.sh # dpkg-dev, fakeroot, xvfb, …
|
||||||
|
nvm install && nvm use # pinned Node 22 (see .nvmrc)
|
||||||
|
npm ci
|
||||||
|
npm run package:linux:deb # -> build/artifacts/*.deb + tarball + sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
The builder stages **only** runtime files: the app code, a fixed Electron
|
||||||
|
runtime, and production npm dependencies. It never copies the development
|
||||||
|
`node_modules`, docs, prompts, or examples, and it fails if `node_modules` is
|
||||||
|
missing rather than producing an unusable artifact.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
# StepForge on dnf-based Linux (Fedora / RHEL)
|
||||||
|
|
||||||
|
This is the setup and packaging guide for **dnf-based** distributions. Debian,
|
||||||
|
Ubuntu, and other apt-based systems have a separate guide:
|
||||||
|
[apt.md](apt.md).
|
||||||
|
|
||||||
|
## Install from the .rpm
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo dnf install ./stepforge-<version>-1.<arch>.rpm
|
||||||
|
```
|
||||||
|
|
||||||
|
dnf pulls the required runtime libraries automatically (they are declared as
|
||||||
|
`Requires`). The package installs:
|
||||||
|
|
||||||
|
- the app and a fixed Electron runtime under `/opt/stepforge`,
|
||||||
|
- the `stepforge` launcher at `/usr/bin/stepforge`,
|
||||||
|
- a desktop entry, icons, and `.sfgz`/`.sfglt` file associations.
|
||||||
|
|
||||||
|
Launch it from your application menu or run `stepforge`.
|
||||||
|
|
||||||
|
### Sandbox
|
||||||
|
|
||||||
|
The launcher runs **sandboxed**. On most modern kernels the Chromium
|
||||||
|
user-namespace sandbox works out of the box; the package's `%post` also makes
|
||||||
|
the setuid `chrome-sandbox` helper usable as a fallback. StepForge will **not**
|
||||||
|
silently launch unsandboxed.
|
||||||
|
|
||||||
|
## Install from the portable tarball
|
||||||
|
|
||||||
|
The portable tarball (same one shipped for apt systems) includes the
|
||||||
|
`/usr/bin/stepforge` launcher. Install the runtime libraries first:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/linux/dnf/install-runtime-deps.sh
|
||||||
|
tar -xzf stepforge_<version>_linux-x64.tar.gz
|
||||||
|
./usr/bin/stepforge
|
||||||
|
```
|
||||||
|
|
||||||
|
## Capture capabilities on dnf systems
|
||||||
|
|
||||||
|
- **X11**: full per-click capture with an accurate marker (needs `xinput`).
|
||||||
|
- **Wayland** (Fedora's default): screen capture via the XDG Desktop Portal +
|
||||||
|
PipeWire; the portal asks permission once per recording. Per-click capture
|
||||||
|
with coordinates is not exposed by Wayland, so recording uses a global
|
||||||
|
hotkey or interval trigger. StepForge reports the active trigger honestly.
|
||||||
|
|
||||||
|
Open Settings → Diagnostics in the app to see the detected session type,
|
||||||
|
portal/PipeWire status, and the active capture profile.
|
||||||
|
|
||||||
|
## Build the .rpm yourself
|
||||||
|
|
||||||
|
```bash
|
||||||
|
bash scripts/linux/dnf/install-build-deps.sh # rpm-build, rpmdevtools, Xvfb, …
|
||||||
|
nvm install && nvm use # pinned Node 22 (see .nvmrc)
|
||||||
|
npm ci
|
||||||
|
npm run package:linux:rpm # -> build/artifacts/*.rpm + sha256
|
||||||
|
```
|
||||||
|
|
||||||
|
The builder stages **only** runtime files (shared with the `.deb` builder via
|
||||||
|
`packaging/linux/common/stage-runtime.sh`): the app code, a fixed Electron
|
||||||
|
runtime, and production npm dependencies. It never copies the development
|
||||||
|
`node_modules` and fails if `node_modules` is missing.
|
||||||
@@ -2,10 +2,10 @@
|
|||||||
"name": "stepforge",
|
"name": "stepforge",
|
||||||
"version": "0.3.2",
|
"version": "0.3.2",
|
||||||
"buildVersion": "0.3.2.1",
|
"buildVersion": "0.3.2.1",
|
||||||
"description": "Fully offline desktop tool for capturing, annotating, and exporting step-by-step guides.",
|
"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"
|
||||||
@@ -14,7 +14,10 @@
|
|||||||
"start": "node scripts/start-electron.js",
|
"start": "node scripts/start-electron.js",
|
||||||
"test": "node scripts/run-unit-tests.js",
|
"test": "node scripts/run-unit-tests.js",
|
||||||
"sample": "node scripts/make-sample-guide.js",
|
"sample": "node scripts/make-sample-guide.js",
|
||||||
|
"icons": "node scripts/make-icons.js",
|
||||||
"package:windows": "node scripts/package-windows.js",
|
"package:windows": "node scripts/package-windows.js",
|
||||||
|
"package:linux:deb": "bash packaging/linux/debian/package.sh",
|
||||||
|
"package:linux:rpm": "bash packaging/linux/fedora/package.sh",
|
||||||
"build": "bash scripts/build-release.sh",
|
"build": "bash scripts/build-release.sh",
|
||||||
"verify": "bash scripts/verify.sh",
|
"verify": "bash scripts/verify.sh",
|
||||||
"bootstrap": "bash scripts/bootstrap-offline.sh"
|
"bootstrap": "bash scripts/bootstrap-offline.sh"
|
||||||
|
|||||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 234 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 352 B |
|
After Width: | Height: | Size: 482 B |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 611 B |
|
After Width: | Height: | Size: 2.1 KiB |
@@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!--
|
||||||
|
StepForge application icon — original artwork.
|
||||||
|
A rising staircase of three blocks (the "steps" of a step-by-step guide)
|
||||||
|
over a rounded square, in the app's blue. No third-party assets.
|
||||||
|
-->
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" viewBox="0 0 256 256">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="bg" x1="0" y1="0" x2="0" y2="1">
|
||||||
|
<stop offset="0" stop-color="#2563eb"/>
|
||||||
|
<stop offset="1" stop-color="#1e3a8a"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect x="0" y="0" width="256" height="256" rx="56" fill="url(#bg)"/>
|
||||||
|
<!-- Three ascending steps -->
|
||||||
|
<g fill="#ffffff">
|
||||||
|
<rect x="52" y="150" width="52" height="54" rx="8"/>
|
||||||
|
<rect x="102" y="116" width="52" height="88" rx="8"/>
|
||||||
|
<rect x="152" y="82" width="52" height="122" rx="8"/>
|
||||||
|
</g>
|
||||||
|
<!-- Capture spark on the top step -->
|
||||||
|
<circle cx="178" cy="60" r="16" fill="#facc15"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 925 B |
@@ -0,0 +1,19 @@
|
|||||||
|
# StepForge least-privilege input access (OPTIONAL, opt-in).
|
||||||
|
#
|
||||||
|
# Grants the user at the ACTIVE local session read/write access to MOUSE input
|
||||||
|
# devices only, via systemd-logind's `uaccess` ACL. This is the least-privilege
|
||||||
|
# alternative to joining the broad `input` group, which would grant access to
|
||||||
|
# ALL input devices — including keyboards — for the user permanently, on every
|
||||||
|
# session. StepForge never needs keystrokes, so this rule deliberately EXCLUDES
|
||||||
|
# keyboards.
|
||||||
|
#
|
||||||
|
# Scope of what this grants:
|
||||||
|
# * only devices udev classifies as a mouse (ID_INPUT_MOUSE=1),
|
||||||
|
# * only when they are NOT also a keyboard (ID_INPUT_KEYBOARD!=1),
|
||||||
|
# * only to whoever is logged in at the physical seat (uaccess is
|
||||||
|
# session-scoped, not a permanent group membership).
|
||||||
|
#
|
||||||
|
# StepForge uses this only for the optional Wayland per-click *trigger* (button
|
||||||
|
# presses, no coordinates). It is not required — the safe default is a global
|
||||||
|
# hotkey or interval capture.
|
||||||
|
SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_MOUSE}=="1", ENV{ID_INPUT_KEYBOARD}!="1", TAG+="uaccess"
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
# StepForge launcher installed at /usr/bin/stepforge.
|
||||||
|
#
|
||||||
|
# Runs the packaged Electron runtime against the installed app at
|
||||||
|
# /opt/stepforge. It NEVER installs or repairs anything at runtime and it does
|
||||||
|
# NOT silently disable the Chromium sandbox: an unsandboxed launch requires the
|
||||||
|
# explicit STEPFORGE_ALLOW_NO_SANDBOX=1 opt-in (development/CI only).
|
||||||
|
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
APP_DIR=/opt/stepforge
|
||||||
|
ELECTRON="$APP_DIR/node_modules/electron/dist/electron"
|
||||||
|
SANDBOX_HELPER="$APP_DIR/node_modules/electron/dist/chrome-sandbox"
|
||||||
|
|
||||||
|
if [ ! -x "$ELECTRON" ]; then
|
||||||
|
echo "stepforge: Electron runtime missing at $ELECTRON (reinstall the package)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$APP_DIR" || exit 1
|
||||||
|
|
||||||
|
# Linux screen capture: enable the PipeWire path for Wayland portals; harmless
|
||||||
|
# on X11 where Ozone auto-selects.
|
||||||
|
COMMON_ARGS="--enable-features=WebRTCPipeWireCapturer --ozone-platform-hint=auto"
|
||||||
|
|
||||||
|
sandbox_ok() {
|
||||||
|
[ -e "$SANDBOX_HELPER" ] || return 1
|
||||||
|
helper_uid="$(stat -c '%u' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
|
||||||
|
helper_mode="$(stat -c '%a' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
|
||||||
|
[ "$helper_uid" = "0" ] || return 1
|
||||||
|
[ -n "$helper_mode" ] || return 1
|
||||||
|
# setuid bit set?
|
||||||
|
[ $(( $((8#$helper_mode)) & 04000 )) -ne 0 ] || return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
userns_ok() {
|
||||||
|
# Namespaced sandbox works without the setuid helper on kernels that allow
|
||||||
|
# unprivileged user namespaces.
|
||||||
|
if [ -r /proc/sys/kernel/unprivileged_userns_clone ]; then
|
||||||
|
[ "$(cat /proc/sys/kernel/unprivileged_userns_clone)" = "1" ] && return 0 || return 1
|
||||||
|
fi
|
||||||
|
if [ -r /proc/sys/kernel/apparmor_restrict_unprivileged_userns ]; then
|
||||||
|
[ "$(cat /proc/sys/kernel/apparmor_restrict_unprivileged_userns)" = "0" ] && return 0 || return 1
|
||||||
|
fi
|
||||||
|
[ -e /proc/self/ns/user ] && return 0 || return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if sandbox_ok || userns_ok; then
|
||||||
|
exec "$ELECTRON" $COMMON_ARGS "$APP_DIR" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${STEPFORGE_ALLOW_NO_SANDBOX:-}" = "1" ] || [ "${ELECTRON_DISABLE_SANDBOX:-}" = "1" ]; then
|
||||||
|
echo "stepforge: launching WITHOUT the Chromium sandbox (explicit opt-in)." >&2
|
||||||
|
exec "$ELECTRON" --no-sandbox $COMMON_ARGS "$APP_DIR" "$@"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat >&2 <<'MSG'
|
||||||
|
stepforge: the Chromium sandbox is not available and StepForge will not launch
|
||||||
|
unsandboxed by default.
|
||||||
|
|
||||||
|
Fix one of the following:
|
||||||
|
* Make the setuid sandbox helper usable:
|
||||||
|
sudo chown root:root /opt/stepforge/node_modules/electron/dist/chrome-sandbox
|
||||||
|
sudo chmod 4755 /opt/stepforge/node_modules/electron/dist/chrome-sandbox
|
||||||
|
* Enable unprivileged user namespaces (kernel/sysctl dependent):
|
||||||
|
sudo sysctl -w kernel.unprivileged_userns_clone=1
|
||||||
|
|
||||||
|
For development/CI only you may set STEPFORGE_ALLOW_NO_SANDBOX=1 to override.
|
||||||
|
MSG
|
||||||
|
exit 1
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Shared staging for the Linux packages. Populates $STAGE_ROOT with the FHS
|
||||||
|
# layout common to the .deb and .rpm: a pruned runtime-only /opt/stepforge, the
|
||||||
|
# launcher, desktop entry, icons, MIME registration, and the license.
|
||||||
|
#
|
||||||
|
# Distro-specific metadata (Depends vs Requires) and the packaging step
|
||||||
|
# (dpkg-deb vs rpmbuild) stay in the per-distro builders. This file only
|
||||||
|
# assembles the payload so the two never drift.
|
||||||
|
#
|
||||||
|
# Usage: ROOT_DIR=<repo> STAGE_ROOT=<dir> bash stage-runtime.sh
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
: "${ROOT_DIR:?ROOT_DIR must be set}"
|
||||||
|
: "${STAGE_ROOT:?STAGE_ROOT must be set}"
|
||||||
|
|
||||||
|
# A packaged app must contain a fixed runtime; never install at build time and
|
||||||
|
# never ship without node_modules.
|
||||||
|
if [ ! -d "$ROOT_DIR/node_modules/electron/dist" ]; then
|
||||||
|
echo "error: node_modules/electron is missing. Run 'npm ci' before packaging." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
APP_DIR="$STAGE_ROOT/opt/stepforge"
|
||||||
|
mkdir -p "$APP_DIR/node_modules" \
|
||||||
|
"$STAGE_ROOT/usr/bin" \
|
||||||
|
"$STAGE_ROOT/usr/share/applications" \
|
||||||
|
"$STAGE_ROOT/usr/share/mime/packages"
|
||||||
|
|
||||||
|
# --- application code (runtime only) ----------------------------------------
|
||||||
|
for item in app core exporters package.json package-lock.json; do
|
||||||
|
cp -a "$ROOT_DIR/$item" "$APP_DIR/$item"
|
||||||
|
done
|
||||||
|
|
||||||
|
# --- runtime node_modules ----------------------------------------------------
|
||||||
|
# The fixed Electron runtime (needed at runtime even though it is a dev dep):
|
||||||
|
cp -a "$ROOT_DIR/node_modules/electron" "$APP_DIR/node_modules/electron"
|
||||||
|
# Production npm dependencies (tesseract.js + language data + transitive):
|
||||||
|
while IFS= read -r dep; do
|
||||||
|
[ -n "$dep" ] || continue
|
||||||
|
rel="${dep#"$ROOT_DIR"/}"
|
||||||
|
[ "$rel" != "$dep" ] || continue
|
||||||
|
[ -d "$dep" ] || continue
|
||||||
|
mkdir -p "$APP_DIR/$(dirname "$rel")"
|
||||||
|
cp -a "$dep" "$APP_DIR/$rel"
|
||||||
|
done < <(cd "$ROOT_DIR" && npm ls --omit=dev --all --parseable 2>/dev/null | tail -n +2)
|
||||||
|
|
||||||
|
# Guard: the development-only packaging toolchain must not have leaked in.
|
||||||
|
if [ -d "$APP_DIR/node_modules/electron-builder" ] || [ -d "$APP_DIR/node_modules/app-builder-lib" ]; then
|
||||||
|
echo "error: build-only dependency leaked into the package payload." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- launcher ----------------------------------------------------------------
|
||||||
|
install -m 0755 "$ROOT_DIR/packaging/linux/common/launcher.sh" "$STAGE_ROOT/usr/bin/stepforge"
|
||||||
|
|
||||||
|
# --- desktop entry, icons, MIME ---------------------------------------------
|
||||||
|
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge.desktop" "$STAGE_ROOT/usr/share/applications/stepforge.desktop"
|
||||||
|
install -m 0644 "$ROOT_DIR/packaging/linux/common/stepforge-mime.xml" "$STAGE_ROOT/usr/share/mime/packages/stepforge.xml"
|
||||||
|
for size in 16 32 48 64 128 256 512; do
|
||||||
|
icon="$ROOT_DIR/packaging/assets/icons/stepforge-${size}.png"
|
||||||
|
[ -f "$icon" ] || continue
|
||||||
|
dest="$STAGE_ROOT/usr/share/icons/hicolor/${size}x${size}/apps"
|
||||||
|
mkdir -p "$dest"
|
||||||
|
install -m 0644 "$icon" "$dest/stepforge.png"
|
||||||
|
done
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<mime-info xmlns="http://www.freedesktop.org/standards/shared-mime-info">
|
||||||
|
<mime-type type="application/x-stepforge-guide">
|
||||||
|
<comment>StepForge guide archive</comment>
|
||||||
|
<glob pattern="*.sfgz"/>
|
||||||
|
<icon name="stepforge"/>
|
||||||
|
</mime-type>
|
||||||
|
<mime-type type="application/x-stepforge-template">
|
||||||
|
<comment>StepForge export template</comment>
|
||||||
|
<glob pattern="*.sfglt"/>
|
||||||
|
<icon name="stepforge"/>
|
||||||
|
</mime-type>
|
||||||
|
</mime-info>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[Desktop Entry]
|
||||||
|
Type=Application
|
||||||
|
Name=StepForge
|
||||||
|
GenericName=Step-by-step guide capture
|
||||||
|
Comment=Capture, annotate, and export step-by-step guides
|
||||||
|
Exec=stepforge %U
|
||||||
|
Icon=stepforge
|
||||||
|
Terminal=false
|
||||||
|
Categories=Office;Graphics;Utility;
|
||||||
|
Keywords=documentation;screenshot;guide;capture;steps;
|
||||||
|
StartupNotify=true
|
||||||
|
StartupWMClass=StepForge
|
||||||
|
MimeType=application/x-stepforge-guide;
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
Package: stepforge
|
||||||
|
Version: @VERSION@
|
||||||
|
Section: utils
|
||||||
|
Priority: optional
|
||||||
|
Architecture: @ARCH@
|
||||||
|
Depends: libnss3, libnspr4, libatk1.0-0, libatk-bridge2.0-0, libcups2, libgbm1, libasound2, libgtk-3-0, libxkbcommon0, libatspi2.0-0
|
||||||
|
Recommends: xinput, x11-utils, xdg-desktop-portal, pipewire
|
||||||
|
Maintainer: @MAINTAINER@
|
||||||
|
Homepage: https://github.com/Twest2/StepForge
|
||||||
|
Description: Local-first step-by-step guide capture and export tool
|
||||||
|
StepForge captures step-by-step workflows as screenshots, lets you annotate
|
||||||
|
and describe each step, and exports to Markdown, PDF, DOCX, PPTX, HTML, and
|
||||||
|
more. Local-first: no telemetry, with an optional user-configured local AI
|
||||||
|
integration.
|
||||||
|
.
|
||||||
|
This package bundles a fixed Electron runtime and only production
|
||||||
|
dependencies; it does not install anything at runtime.
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a production StepForge .deb (and a matching portable tarball) from a
|
||||||
|
# pruned, runtime-only tree.
|
||||||
|
#
|
||||||
|
# Unlike the old scripts/package-linux.sh this does NOT copy the development
|
||||||
|
# node_modules, docs, prompts, examples, or stale audit files; it stages only
|
||||||
|
# the app code plus a runtime dependency set (the fixed Electron runtime and
|
||||||
|
# production npm deps), a real desktop entry, icons, MIME registration, and a
|
||||||
|
# license. Architecture is detected, not hardcoded.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
VERSION="$(node -p "require('./package.json').version")"
|
||||||
|
MAINTAINER="${STEPFORGE_MAINTAINER:-StepForge <[email protected]>}"
|
||||||
|
OUT_DIR="${STEPFORGE_PACKAGE_DIR:-$ROOT_DIR/build/artifacts}"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
# Map dpkg architecture to a Node-style label for the tarball name.
|
||||||
|
DEB_ARCH="$(dpkg --print-architecture 2>/dev/null || echo amd64)"
|
||||||
|
case "$DEB_ARCH" in
|
||||||
|
amd64) NODE_ARCH="x64" ;;
|
||||||
|
arm64) NODE_ARCH="arm64" ;;
|
||||||
|
*) NODE_ARCH="$DEB_ARCH" ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
WORK_DIR="$(mktemp -d "${OUT_DIR%/}/.deb.XXXXXX")"
|
||||||
|
trap 'rm -rf "$WORK_DIR"' EXIT
|
||||||
|
|
||||||
|
# Stage the shared runtime-only payload (fails if node_modules is missing).
|
||||||
|
ROOT_DIR="$ROOT_DIR" STAGE_ROOT="$WORK_DIR" bash "$ROOT_DIR/packaging/linux/common/stage-runtime.sh"
|
||||||
|
|
||||||
|
mkdir -p "$WORK_DIR/DEBIAN" "$WORK_DIR/usr/share/doc/stepforge"
|
||||||
|
|
||||||
|
# --- license + docs pointer --------------------------------------------------
|
||||||
|
if [ -f "$ROOT_DIR/LICENSE" ]; then
|
||||||
|
install -m 0644 "$ROOT_DIR/LICENSE" "$WORK_DIR/usr/share/doc/stepforge/copyright"
|
||||||
|
elif [ -f "$ROOT_DIR/docs/LICENSE" ]; then
|
||||||
|
install -m 0644 "$ROOT_DIR/docs/LICENSE" "$WORK_DIR/usr/share/doc/stepforge/copyright"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- DEBIAN control + maintainer scripts ------------------------------------
|
||||||
|
sed -e "s/@VERSION@/$VERSION/" -e "s/@ARCH@/$DEB_ARCH/" -e "s#@MAINTAINER@#$MAINTAINER#" \
|
||||||
|
"$ROOT_DIR/packaging/linux/debian/control.in" > "$WORK_DIR/DEBIAN/control"
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/DEBIAN/postinst" <<'POSTINST'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
# Make the Chromium setuid sandbox helper usable so the app launches sandboxed.
|
||||||
|
HELPER=/opt/stepforge/node_modules/electron/dist/chrome-sandbox
|
||||||
|
if [ -e "$HELPER" ]; then
|
||||||
|
chown root:root "$HELPER" || true
|
||||||
|
chmod 4755 "$HELPER" || true
|
||||||
|
fi
|
||||||
|
# Refresh desktop/MIME/icon caches (best effort).
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q /usr/share/applications || true; fi
|
||||||
|
if command -v update-mime-database >/dev/null 2>&1; then update-mime-database /usr/share/mime || true; fi
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -q /usr/share/icons/hicolor || true; fi
|
||||||
|
exit 0
|
||||||
|
POSTINST
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/DEBIAN/prerm" <<'PRERM'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
exit 0
|
||||||
|
PRERM
|
||||||
|
|
||||||
|
cat > "$WORK_DIR/DEBIAN/postrm" <<'POSTRM'
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
if [ "$1" = "remove" ] || [ "$1" = "purge" ]; then
|
||||||
|
if command -v update-desktop-database >/dev/null 2>&1; then update-desktop-database -q /usr/share/applications || true; fi
|
||||||
|
if command -v update-mime-database >/dev/null 2>&1; then update-mime-database /usr/share/mime || true; fi
|
||||||
|
if command -v gtk-update-icon-cache >/dev/null 2>&1; then gtk-update-icon-cache -q /usr/share/icons/hicolor || true; fi
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
|
POSTRM
|
||||||
|
chmod 0755 "$WORK_DIR/DEBIAN/postinst" "$WORK_DIR/DEBIAN/prerm" "$WORK_DIR/DEBIAN/postrm"
|
||||||
|
|
||||||
|
# --- build the .deb ----------------------------------------------------------
|
||||||
|
DEB_FILE="$OUT_DIR/stepforge_${VERSION}_${DEB_ARCH}.deb"
|
||||||
|
if command -v fakeroot >/dev/null 2>&1; then
|
||||||
|
fakeroot dpkg-deb --build "$WORK_DIR" "$DEB_FILE" >/dev/null
|
||||||
|
else
|
||||||
|
dpkg-deb --build "$WORK_DIR" "$DEB_FILE" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- portable tarball (INCLUDES the launcher, unlike the old script) ---------
|
||||||
|
TAR_FILE="$OUT_DIR/stepforge_${VERSION}_linux-${NODE_ARCH}.tar.gz"
|
||||||
|
tar -C "$WORK_DIR" -czf "$TAR_FILE" opt usr/bin/stepforge usr/share/applications usr/share/mime usr/share/icons
|
||||||
|
|
||||||
|
# --- checksums ---------------------------------------------------------------
|
||||||
|
( cd "$OUT_DIR" && sha256sum "$(basename "$DEB_FILE")" "$(basename "$TAR_FILE")" > "stepforge_${VERSION}_${DEB_ARCH}.sha256" )
|
||||||
|
|
||||||
|
echo "$DEB_FILE"
|
||||||
|
echo "$TAR_FILE"
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Build a production StepForge .rpm from a pruned, runtime-only tree, mirroring
|
||||||
|
# the .deb builder. Stages the shared payload via common/stage-runtime.sh, then
|
||||||
|
# packages it with rpmbuild against a prebuilt BuildRoot (no compilation).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
VERSION="$(node -p "require('./package.json').version")"
|
||||||
|
MAINTAINER="${STEPFORGE_MAINTAINER:-StepForge <[email protected]>}"
|
||||||
|
OUT_DIR="${STEPFORGE_PACKAGE_DIR:-$ROOT_DIR/build/artifacts}"
|
||||||
|
mkdir -p "$OUT_DIR"
|
||||||
|
|
||||||
|
if ! command -v rpmbuild >/dev/null 2>&1; then
|
||||||
|
echo "error: rpmbuild is not installed. Run scripts/linux/dnf/install-build-deps.sh" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# RPM arch label from the host.
|
||||||
|
RPM_ARCH="$(rpm --eval '%{_arch}' 2>/dev/null || uname -m)"
|
||||||
|
|
||||||
|
BUILD_ROOT="$(mktemp -d "${OUT_DIR%/}/.rpm.XXXXXX")"
|
||||||
|
trap 'rm -rf "$BUILD_ROOT"' EXIT
|
||||||
|
STAGE="$BUILD_ROOT/buildroot"
|
||||||
|
mkdir -p "$STAGE"
|
||||||
|
|
||||||
|
# Shared runtime-only payload (fails if node_modules is missing).
|
||||||
|
ROOT_DIR="$ROOT_DIR" STAGE_ROOT="$STAGE" bash "$ROOT_DIR/packaging/linux/common/stage-runtime.sh"
|
||||||
|
|
||||||
|
# License into the RPM's conventional location.
|
||||||
|
mkdir -p "$STAGE/usr/share/licenses/stepforge"
|
||||||
|
if [ -f "$ROOT_DIR/LICENSE" ]; then
|
||||||
|
install -m 0644 "$ROOT_DIR/LICENSE" "$STAGE/usr/share/licenses/stepforge/LICENSE"
|
||||||
|
elif [ -f "$ROOT_DIR/docs/LICENSE" ]; then
|
||||||
|
install -m 0644 "$ROOT_DIR/docs/LICENSE" "$STAGE/usr/share/licenses/stepforge/LICENSE"
|
||||||
|
else
|
||||||
|
# rpmbuild %license requires the file to exist; write a pointer if absent.
|
||||||
|
echo "See project LICENSE." > "$STAGE/usr/share/licenses/stepforge/LICENSE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Materialize the spec with version/maintainer substituted.
|
||||||
|
SPEC="$BUILD_ROOT/stepforge.spec"
|
||||||
|
sed -e "s/@VERSION@/$VERSION/" -e "s#@MAINTAINER@#$MAINTAINER#" \
|
||||||
|
"$ROOT_DIR/packaging/linux/fedora/stepforge.spec" > "$SPEC"
|
||||||
|
|
||||||
|
rpmbuild -bb \
|
||||||
|
--define "_topdir $BUILD_ROOT/rpmbuild" \
|
||||||
|
--define "_rpmdir $OUT_DIR" \
|
||||||
|
--define "_build_id_links none" \
|
||||||
|
--buildroot "$STAGE" \
|
||||||
|
--target "$RPM_ARCH" \
|
||||||
|
"$SPEC" >/dev/null
|
||||||
|
|
||||||
|
# rpmbuild writes to $OUT_DIR/<arch>/<name>.rpm — surface the final path.
|
||||||
|
RPM_FILE="$(find "$OUT_DIR" -name "stepforge-${VERSION}-1*.${RPM_ARCH}.rpm" -newer "$SPEC" | head -1)"
|
||||||
|
if [ -z "$RPM_FILE" ]; then
|
||||||
|
RPM_FILE="$(find "$OUT_DIR" -name "stepforge-${VERSION}-1*.rpm" | head -1)"
|
||||||
|
fi
|
||||||
|
[ -n "$RPM_FILE" ] || { echo "error: rpmbuild did not produce an .rpm" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Checksum.
|
||||||
|
( cd "$(dirname "$RPM_FILE")" && sha256sum "$(basename "$RPM_FILE")" > "$(basename "$RPM_FILE").sha256" )
|
||||||
|
|
||||||
|
echo "$RPM_FILE"
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# StepForge RPM spec. The payload is prebuilt into a staging BuildRoot by
|
||||||
|
# packaging/linux/fedora/package.sh (which stages a pruned runtime tree), so
|
||||||
|
# this spec only packages and declares metadata — it does not compile.
|
||||||
|
#
|
||||||
|
# Placeholders @VERSION@ / @MAINTAINER@ are substituted by package.sh.
|
||||||
|
|
||||||
|
%global debug_package %{nil}
|
||||||
|
%global __brp_check_rpaths %{nil}
|
||||||
|
%define _build_id_links none
|
||||||
|
|
||||||
|
Name: stepforge
|
||||||
|
Version: @VERSION@
|
||||||
|
Release: 1%{?dist}
|
||||||
|
Summary: Local-first step-by-step guide capture and export tool
|
||||||
|
|
||||||
|
License: 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).
|
||||||
@@ -14,7 +14,14 @@ mkdir -p "$BUILD_ROOT"
|
|||||||
|
|
||||||
bash "$ROOT_DIR/scripts/bootstrap-offline.sh"
|
bash "$ROOT_DIR/scripts/bootstrap-offline.sh"
|
||||||
node "$ROOT_DIR/scripts/make-sample-guide.js" --root "$EXAMPLES_ROOT"
|
node "$ROOT_DIR/scripts/make-sample-guide.js" --root "$EXAMPLES_ROOT"
|
||||||
STEPFORGE_PACKAGE_DIR="$ARTIFACT_DIR" bash "$ROOT_DIR/scripts/package-linux.sh" >/dev/null
|
# Production Linux package: a pruned runtime tree with real desktop
|
||||||
|
# integration. Requires node_modules (fails otherwise); never installs at
|
||||||
|
# build time. Skipped only when the Electron runtime is genuinely absent.
|
||||||
|
if [ -d "$ROOT_DIR/node_modules/electron/dist" ]; then
|
||||||
|
STEPFORGE_PACKAGE_DIR="$ARTIFACT_DIR" bash "$ROOT_DIR/packaging/linux/debian/package.sh" >/dev/null
|
||||||
|
else
|
||||||
|
echo "[build-release] skipping Linux .deb: node_modules/electron missing (run npm ci)" >&2
|
||||||
|
fi
|
||||||
|
|
||||||
BUILD_ROOT="$BUILD_ROOT" \
|
BUILD_ROOT="$BUILD_ROOT" \
|
||||||
ARTIFACT_DIR="$ARTIFACT_DIR" \
|
ARTIFACT_DIR="$ARTIFACT_DIR" \
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the BUILD toolchain for producing StepForge packages on apt-based
|
||||||
|
# systems. These are for DEVELOPERS/packagers only and are never shipped inside
|
||||||
|
# the end-user package.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v apt-get >/dev/null 2>&1; then
|
||||||
|
echo "This script is for apt-based systems (Debian/Ubuntu)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
||||||
|
|
||||||
|
PACKAGES=(
|
||||||
|
dpkg-dev fakeroot # build the .deb
|
||||||
|
desktop-file-utils # validate the .desktop entry
|
||||||
|
ca-certificates # npm ci over https
|
||||||
|
xvfb # headless smoke test under Xvfb
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Installing StepForge build dependencies via apt..."
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y --no-install-recommends "${PACKAGES[@]}"
|
||||||
|
|
||||||
|
cat <<'MSG'
|
||||||
|
Done. Also install the pinned Node toolchain (see .nvmrc — Node 22.12+):
|
||||||
|
nvm install && nvm use # or another Node 22 LTS install method
|
||||||
|
Then, from the repo root:
|
||||||
|
npm ci
|
||||||
|
npm run package:linux:deb
|
||||||
|
MSG
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the RUNTIME libraries StepForge needs on apt-based systems
|
||||||
|
# (Debian/Ubuntu). These are the shared libraries the packaged Electron runtime
|
||||||
|
# links against, plus the X11/portal integration used for capture. This is for
|
||||||
|
# END USERS installing from the tarball; the .deb declares the same set as
|
||||||
|
# Depends so apt pulls them automatically.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v apt-get >/dev/null 2>&1; then
|
||||||
|
echo "This script is for apt-based systems (Debian/Ubuntu). Use the dnf script on Fedora." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
||||||
|
|
||||||
|
PACKAGES=(
|
||||||
|
# Chromium/Electron shared libraries
|
||||||
|
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2
|
||||||
|
libgtk-3-0 libgbm1 libasound2 libxkbcommon0 libatspi2.0-0
|
||||||
|
libxcomposite1 libxdamage1 libxfixes3 libxrandr2 libxshmfence1
|
||||||
|
# X11 per-click capture (marker-accurate) — X11 sessions only
|
||||||
|
xinput x11-utils
|
||||||
|
# Wayland screen-share via the XDG portal + PipeWire
|
||||||
|
xdg-desktop-portal pipewire
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Installing StepForge runtime dependencies via apt..."
|
||||||
|
$SUDO apt-get update
|
||||||
|
$SUDO apt-get install -y --no-install-recommends "${PACKAGES[@]}"
|
||||||
|
echo "Done. StepForge runtime dependencies are installed."
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the BUILD toolchain for producing StepForge packages on dnf-based
|
||||||
|
# systems (Fedora/RHEL). For DEVELOPERS/packagers only; never shipped inside
|
||||||
|
# the end-user package.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v dnf >/dev/null 2>&1; then
|
||||||
|
echo "This script is for dnf-based systems (Fedora/RHEL)." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
||||||
|
|
||||||
|
PACKAGES=(
|
||||||
|
rpm-build rpmdevtools # build the .rpm
|
||||||
|
desktop-file-utils # validate the .desktop entry
|
||||||
|
ca-certificates # npm ci over https
|
||||||
|
xorg-x11-server-Xvfb # headless smoke test under Xvfb
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Installing StepForge build dependencies via dnf..."
|
||||||
|
$SUDO dnf install -y "${PACKAGES[@]}"
|
||||||
|
|
||||||
|
cat <<'MSG'
|
||||||
|
Done. Also install the pinned Node toolchain (see .nvmrc — Node 22.12+):
|
||||||
|
nvm install && nvm use # or another Node 22 LTS install method
|
||||||
|
Then, from the repo root:
|
||||||
|
npm ci
|
||||||
|
npm run package:linux:rpm
|
||||||
|
MSG
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Install the RUNTIME libraries StepForge needs on dnf-based systems (Fedora,
|
||||||
|
# RHEL/derivatives). These are the shared libraries the packaged Electron
|
||||||
|
# runtime links against, plus the X11/portal integration used for capture.
|
||||||
|
# For END USERS installing from the tarball; the .rpm declares the same set as
|
||||||
|
# Requires so dnf pulls them automatically.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if ! command -v dnf >/dev/null 2>&1; then
|
||||||
|
echo "This script is for dnf-based systems (Fedora/RHEL). Use the apt script on Debian/Ubuntu." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
||||||
|
|
||||||
|
PACKAGES=(
|
||||||
|
# Chromium/Electron shared libraries
|
||||||
|
nss nspr atk at-spi2-atk at-spi2-core cups-libs libdrm
|
||||||
|
gtk3 mesa-libgbm alsa-lib libxkbcommon
|
||||||
|
libXcomposite libXdamage libXfixes libXrandr libxshmfence
|
||||||
|
# X11 per-click capture (marker-accurate) — X11 sessions only
|
||||||
|
xorg-x11-server-utils xinput
|
||||||
|
# Wayland screen-share via the XDG portal + PipeWire
|
||||||
|
xdg-desktop-portal pipewire
|
||||||
|
)
|
||||||
|
|
||||||
|
echo "Installing StepForge runtime dependencies via dnf..."
|
||||||
|
# Some package names differ across Fedora releases; install best-effort so one
|
||||||
|
# missing optional name doesn't abort the whole set.
|
||||||
|
$SUDO dnf install -y "${PACKAGES[@]}" || {
|
||||||
|
echo "Some packages were unavailable; retrying individually..." >&2
|
||||||
|
for pkg in "${PACKAGES[@]}"; do $SUDO dnf install -y "$pkg" || echo " (skipped: $pkg)" >&2; done
|
||||||
|
}
|
||||||
|
echo "Done. StepForge runtime dependencies are installed."
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# OPTIONAL: enable per-click capture on Wayland (or X11 without xinput) using a
|
||||||
|
# LEAST-PRIVILEGE udev rule instead of the broad `input` group.
|
||||||
|
#
|
||||||
|
# Security tradeoff (read before running):
|
||||||
|
# * This grants your ACTIVE local session read access to MOUSE devices only.
|
||||||
|
# * It deliberately EXCLUDES keyboards — StepForge never needs keystrokes.
|
||||||
|
# * Access is session-scoped (systemd `uaccess` ACL), not a permanent group.
|
||||||
|
# * It is NOT required: the safe default is a global hotkey or interval
|
||||||
|
# capture. Only enable this if you want a screenshot on every click.
|
||||||
|
#
|
||||||
|
# Compare to `sudo usermod -aG input "$USER"`, which grants access to ALL input
|
||||||
|
# devices (including keyboards) for your user on every session — a much larger
|
||||||
|
# surface. This script does not do that.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RULE_SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)/packaging/linux/common/60-stepforge-input.rules"
|
||||||
|
RULE_DEST="/etc/udev/rules.d/60-stepforge-input.rules"
|
||||||
|
|
||||||
|
if [ ! -f "$RULE_SRC" ]; then
|
||||||
|
echo "error: rule file not found at $RULE_SRC" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "This installs a least-privilege udev rule granting your session read"
|
||||||
|
echo "access to MOUSE devices only (never keyboards):"
|
||||||
|
echo
|
||||||
|
sed 's/^/ /' "$RULE_SRC"
|
||||||
|
echo
|
||||||
|
printf 'Install it to %s? [y/N] ' "$RULE_DEST"
|
||||||
|
read -r reply
|
||||||
|
case "$reply" in
|
||||||
|
y|Y|yes|YES) ;;
|
||||||
|
*) echo "Aborted. No changes made."; exit 0 ;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then SUDO="sudo"; fi
|
||||||
|
|
||||||
|
$SUDO install -m 0644 "$RULE_SRC" "$RULE_DEST"
|
||||||
|
$SUDO udevadm control --reload-rules
|
||||||
|
$SUDO udevadm trigger --subsystem-match=input --action=change || true
|
||||||
|
|
||||||
|
cat <<'MSG'
|
||||||
|
|
||||||
|
Installed. You may need to unplug/replug a USB mouse or re-log in for the ACL
|
||||||
|
to apply to already-connected devices.
|
||||||
|
|
||||||
|
To remove it later:
|
||||||
|
sudo rm /etc/udev/rules.d/60-stepforge-input.rules
|
||||||
|
sudo udevadm control --reload-rules
|
||||||
|
MSG
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Generate the StepForge PNG icon set from original geometry using the repo's
|
||||||
|
// own rasterizer + PNG writer (no external image tooling or third-party art).
|
||||||
|
// Mirrors packaging/assets/stepforge.svg. Output: packaging/assets/icons/.
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const { createImage, fillRect, fillOval } = require('../core/raster');
|
||||||
|
const { encodePng } = require('../core/png');
|
||||||
|
|
||||||
|
const OUT_DIR = path.join(__dirname, '..', 'packaging', 'assets', 'icons');
|
||||||
|
const SIZES = [16, 32, 48, 64, 128, 256, 512];
|
||||||
|
|
||||||
|
const BG_TOP = [37, 99, 235, 255];
|
||||||
|
const BG_BOTTOM = [30, 58, 138, 255];
|
||||||
|
const WHITE = [255, 255, 255, 255];
|
||||||
|
const SPARK = [250, 204, 21, 255];
|
||||||
|
|
||||||
|
function lerp(a, b, t) {
|
||||||
|
return [
|
||||||
|
Math.round(a[0] + (b[0] - a[0]) * t),
|
||||||
|
Math.round(a[1] + (b[1] - a[1]) * t),
|
||||||
|
Math.round(a[2] + (b[2] - a[2]) * t),
|
||||||
|
255,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderIcon(size) {
|
||||||
|
const img = createImage(size, size, [0, 0, 0, 0]);
|
||||||
|
const s = size / 256; // scale from the 256px reference design
|
||||||
|
|
||||||
|
// Rounded-square background approximated by a vertical gradient fill.
|
||||||
|
for (let y = 0; y < size; y += 1) {
|
||||||
|
fillRect(img, 0, y, size, 1, lerp(BG_TOP, BG_BOTTOM, y / size));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three ascending steps (x, y, w, h in reference px).
|
||||||
|
const steps = [
|
||||||
|
[52, 150, 52, 54],
|
||||||
|
[102, 116, 52, 88],
|
||||||
|
[152, 82, 52, 122],
|
||||||
|
];
|
||||||
|
for (const [x, y, w, h] of steps) {
|
||||||
|
fillRect(img, Math.round(x * s), Math.round(y * s), Math.round(w * s), Math.round(h * s), WHITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture spark on the top step.
|
||||||
|
const r = Math.max(2, Math.round(16 * s));
|
||||||
|
fillOval(img, Math.round(178 * s - r), Math.round(60 * s - r), r * 2, r * 2, SPARK);
|
||||||
|
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
fs.mkdirSync(OUT_DIR, { recursive: true });
|
||||||
|
for (const size of SIZES) {
|
||||||
|
const png = encodePng(renderIcon(size));
|
||||||
|
fs.writeFileSync(path.join(OUT_DIR, `stepforge-${size}.png`), png);
|
||||||
|
}
|
||||||
|
// A conventional default name for the desktop entry / hicolor 256px slot.
|
||||||
|
fs.copyFileSync(path.join(OUT_DIR, 'stepforge-256.png'), path.join(OUT_DIR, 'stepforge.png'));
|
||||||
|
console.log(`wrote ${SIZES.length + 1} icons to ${OUT_DIR}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) main();
|
||||||
|
|
||||||
|
module.exports = { renderIcon, SIZES };
|
||||||
@@ -1,92 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
||||||
VERSION="$(node -p "const pkg=require('${ROOT_DIR}/package.json'); pkg.buildVersion || pkg.version" 2>/dev/null || echo 0.0.0)"
|
|
||||||
OUT_DIR="${STEPFORGE_PACKAGE_DIR:-$ROOT_DIR/build/artifacts}"
|
|
||||||
mkdir -p "$OUT_DIR"
|
|
||||||
WORK_DIR="$(mktemp -d "${OUT_DIR%/}/.pkg.XXXXXX")"
|
|
||||||
APP_DIR="$WORK_DIR/opt/stepforge"
|
|
||||||
|
|
||||||
cleanup() {
|
|
||||||
rm -rf "$WORK_DIR"
|
|
||||||
}
|
|
||||||
trap cleanup EXIT
|
|
||||||
|
|
||||||
mkdir -p "$APP_DIR" "$WORK_DIR/usr/bin" "$WORK_DIR/DEBIAN"
|
|
||||||
|
|
||||||
copy_item() {
|
|
||||||
local src="$1"
|
|
||||||
local dest="$2"
|
|
||||||
if [[ -e "$ROOT_DIR/$src" ]]; then
|
|
||||||
mkdir -p "$(dirname "$dest")"
|
|
||||||
cp -a "$ROOT_DIR/$src" "$dest"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# Application payload: only the files needed to run the app.
|
|
||||||
copy_item app "$APP_DIR/app"
|
|
||||||
copy_item core "$APP_DIR/core"
|
|
||||||
copy_item exporters "$APP_DIR/exporters"
|
|
||||||
copy_item scripts "$APP_DIR/scripts"
|
|
||||||
copy_item README.md "$APP_DIR/README.md"
|
|
||||||
copy_item LICENSE "$APP_DIR/LICENSE"
|
|
||||||
copy_item docs "$APP_DIR/docs"
|
|
||||||
copy_item ai_prompts "$APP_DIR/ai_prompts"
|
|
||||||
copy_item package.json "$APP_DIR/package.json"
|
|
||||||
copy_item package-lock.json "$APP_DIR/package-lock.json"
|
|
||||||
copy_item examples "$APP_DIR/examples"
|
|
||||||
copy_item build/agent_audit.md "$APP_DIR/build/agent_audit.md"
|
|
||||||
|
|
||||||
if [[ -d "$ROOT_DIR/node_modules" ]]; then
|
|
||||||
cp -a "$ROOT_DIR/node_modules" "$APP_DIR/node_modules"
|
|
||||||
fi
|
|
||||||
|
|
||||||
cat > "$WORK_DIR/usr/bin/stepforge" <<'EOF'
|
|
||||||
#!/usr/bin/env sh
|
|
||||||
APP_DIR=/opt/stepforge
|
|
||||||
ELECTRON="$APP_DIR/node_modules/.bin/electron"
|
|
||||||
SANDBOX_HELPER="$APP_DIR/node_modules/electron/dist/chrome-sandbox"
|
|
||||||
cd "$APP_DIR" || exit 1
|
|
||||||
if command -v stat >/dev/null 2>&1 && [ -e "$SANDBOX_HELPER" ]; then
|
|
||||||
helper_uid="$(stat -c '%u' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
|
|
||||||
helper_mode="$(stat -c '%a' "$SANDBOX_HELPER" 2>/dev/null || echo '')"
|
|
||||||
if [ "$helper_uid" = "0" ] && [ -n "$helper_mode" ]; then
|
|
||||||
helper_mode_num=$((8#$helper_mode))
|
|
||||||
if [ $((helper_mode_num & 04000)) -ne 0 ]; then
|
|
||||||
exec "$ELECTRON" "$APP_DIR" "$@"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
printf '%s\n' '[stepforge] Electron sandbox helper is not configured for this install; starting with --no-sandbox' >&2
|
|
||||||
exec "$ELECTRON" --no-sandbox "$APP_DIR" "$@"
|
|
||||||
EOF
|
|
||||||
chmod 0755 "$WORK_DIR/usr/bin/stepforge"
|
|
||||||
|
|
||||||
cat > "$WORK_DIR/DEBIAN/control" <<EOF
|
|
||||||
Package: stepforge
|
|
||||||
Version: $VERSION
|
|
||||||
Section: utils
|
|
||||||
Priority: optional
|
|
||||||
Architecture: amd64
|
|
||||||
Depends: xinput
|
|
||||||
Maintainer: StepForge <[email protected]>
|
|
||||||
Description: Offline desktop guide capture and export tool
|
|
||||||
A fully offline desktop app for step-by-step documentation, built for local
|
|
||||||
capture, annotation, and export workflows.
|
|
||||||
EOF
|
|
||||||
|
|
||||||
DEB_FILE="$OUT_DIR/stepforge_${VERSION}_amd64.deb"
|
|
||||||
TAR_FILE="$OUT_DIR/stepforge_${VERSION}_linux-x64.tar.gz"
|
|
||||||
|
|
||||||
if command -v dpkg-deb >/dev/null 2>&1; then
|
|
||||||
dpkg-deb --build "$WORK_DIR" "$DEB_FILE" >/dev/null
|
|
||||||
else
|
|
||||||
echo "dpkg-deb is not installed; skipping .deb build" >&2
|
|
||||||
fi
|
|
||||||
|
|
||||||
tar -C "$WORK_DIR/opt" -czf "$TAR_FILE" stepforge
|
|
||||||
|
|
||||||
printf '%s\n' "$DEB_FILE"
|
|
||||||
printf '%s\n' "$TAR_FILE"
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Integration test: build the production .deb and assert it is a real,
|
||||||
|
# runtime-only package — the right files present, and the dev tree / build
|
||||||
|
# tooling / app docs absent. A package is NOT accepted merely because
|
||||||
|
# dpkg-deb produced a file.
|
||||||
|
#
|
||||||
|
# Honest skip policy: skip ONLY when the prerequisites are genuinely absent
|
||||||
|
# (not apt-based, dpkg-deb missing, or node_modules not installed). Once we
|
||||||
|
# build, any structural failure fails the test.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if ! command -v dpkg-deb >/dev/null 2>&1; then
|
||||||
|
echo "package-deb SKIPPED: dpkg-deb not installed (not an apt-based build host)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ ! -d "$ROOT_DIR/node_modules/electron/dist" ]; then
|
||||||
|
echo "package-deb SKIPPED: node_modules/electron missing (run npm ci first)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$OUT_DIR"' EXIT
|
||||||
|
|
||||||
|
DEB="$(STEPFORGE_PACKAGE_DIR="$OUT_DIR" bash packaging/linux/debian/package.sh | head -1)"
|
||||||
|
if [ ! -f "$DEB" ]; then
|
||||||
|
echo "package-deb FAILED: builder did not produce a .deb" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
fail() { echo "package-deb FAILED: $1" >&2; exit 1; }
|
||||||
|
|
||||||
|
listing="$(dpkg-deb -c "$DEB")"
|
||||||
|
control="$(dpkg-deb -f "$DEB")"
|
||||||
|
|
||||||
|
# Required install items.
|
||||||
|
for needle in \
|
||||||
|
'./usr/bin/stepforge' \
|
||||||
|
'./usr/share/applications/stepforge.desktop' \
|
||||||
|
'./usr/share/mime/packages/stepforge.xml' \
|
||||||
|
'./usr/share/icons/hicolor/256x256/apps/stepforge.png' \
|
||||||
|
'./opt/stepforge/node_modules/electron/dist/electron' \
|
||||||
|
'./opt/stepforge/app/main.js' \
|
||||||
|
'./usr/share/doc/stepforge/copyright'; do
|
||||||
|
echo "$listing" | grep -qF "$needle" || fail "missing packaged file: $needle"
|
||||||
|
done
|
||||||
|
|
||||||
|
# The development node_modules / build tooling must NOT be present.
|
||||||
|
for banned in \
|
||||||
|
'node_modules/electron-builder' \
|
||||||
|
'node_modules/app-builder-lib' \
|
||||||
|
'node_modules/dmg-builder'; do
|
||||||
|
echo "$listing" | grep -qF "$banned" && fail "build-only dependency leaked: $banned" || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# The app's own docs/prompts/examples must not be shipped.
|
||||||
|
for banned in \
|
||||||
|
'./opt/stepforge/docs/' \
|
||||||
|
'./opt/stepforge/ai_prompts/' \
|
||||||
|
'./opt/stepforge/examples/'; do
|
||||||
|
echo "$listing" | grep -qF "$banned" && fail "app extra shipped: $banned" || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Control metadata sanity.
|
||||||
|
echo "$control" | grep -q '^Package: stepforge' || fail "control missing Package"
|
||||||
|
echo "$control" | grep -q '^Depends:.*libnss3' || fail "control missing runtime Depends"
|
||||||
|
echo "$control" | grep -Eq '^Architecture: (amd64|arm64)' || fail "control has no concrete Architecture"
|
||||||
|
|
||||||
|
# Sandbox is set up, not disabled: postinst makes chrome-sandbox setuid.
|
||||||
|
dpkg-deb --info "$DEB" | grep -q 'postinst' || fail "no postinst maintainer script"
|
||||||
|
|
||||||
|
# The launcher must refuse an unsandboxed launch by default.
|
||||||
|
grep -q 'STEPFORGE_ALLOW_NO_SANDBOX' packaging/linux/common/launcher.sh \
|
||||||
|
|| fail "launcher does not gate --no-sandbox behind an explicit opt-in"
|
||||||
|
|
||||||
|
echo "package-deb OK ($(basename "$DEB"), $(du -h "$DEB" | cut -f1))"
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Integration test: build the production .rpm and assert it is a real,
|
||||||
|
# runtime-only package. Honest skip policy: skip ONLY when the prerequisites
|
||||||
|
# are genuinely absent (rpmbuild missing or node_modules not installed). Once
|
||||||
|
# we build, any structural failure fails the test.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if ! command -v rpmbuild >/dev/null 2>&1; then
|
||||||
|
echo "package-rpm SKIPPED: rpmbuild not installed (not a dnf-based build host)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
if [ ! -d "$ROOT_DIR/node_modules/electron/dist" ]; then
|
||||||
|
echo "package-rpm SKIPPED: node_modules/electron missing (run npm ci first)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
OUT_DIR="$(mktemp -d)"
|
||||||
|
trap 'rm -rf "$OUT_DIR"' EXIT
|
||||||
|
|
||||||
|
RPM="$(STEPFORGE_PACKAGE_DIR="$OUT_DIR" bash packaging/linux/fedora/package.sh | tail -1)"
|
||||||
|
[ -f "$RPM" ] || { echo "package-rpm FAILED: builder produced no .rpm" >&2; exit 1; }
|
||||||
|
|
||||||
|
fail() { echo "package-rpm FAILED: $1" >&2; exit 1; }
|
||||||
|
|
||||||
|
listing="$(rpm -qlp "$RPM" 2>/dev/null)"
|
||||||
|
|
||||||
|
for needle in \
|
||||||
|
'/usr/bin/stepforge' \
|
||||||
|
'/usr/share/applications/stepforge.desktop' \
|
||||||
|
'/usr/share/mime/packages/stepforge.xml' \
|
||||||
|
'/opt/stepforge/node_modules/electron/dist/electron' \
|
||||||
|
'/opt/stepforge/app/main.js'; do
|
||||||
|
echo "$listing" | grep -qF "$needle" || fail "missing packaged file: $needle"
|
||||||
|
done
|
||||||
|
echo "$listing" | grep -q '/usr/share/icons/hicolor/256x256/apps/stepforge.png' || fail "missing 256px icon"
|
||||||
|
|
||||||
|
# No dev tree / build tooling / app docs.
|
||||||
|
for banned in 'electron-builder' '/opt/stepforge/docs/' '/opt/stepforge/ai_prompts/' '/opt/stepforge/examples/'; do
|
||||||
|
echo "$listing" | grep -qF "$banned" && fail "unexpected payload: $banned" || true
|
||||||
|
done
|
||||||
|
|
||||||
|
# Metadata sanity.
|
||||||
|
rpm -qip "$RPM" 2>/dev/null | grep -q '^Name *: stepforge' || fail "rpm Name is not stepforge"
|
||||||
|
rpm -qp --requires "$RPM" 2>/dev/null | grep -q '^nss' || fail "rpm does not Require nss"
|
||||||
|
|
||||||
|
echo "package-rpm OK ($(basename "$RPM"))"
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { makeTmpDir, rmrf } = require('./helpers');
|
||||||
|
const { isLoopbackHost, validateOllamaHost } = require('../../core/text-intel');
|
||||||
|
const { TextIntelService } = require('../../app/text-intel');
|
||||||
|
const CaptureService = require('../../app/capture');
|
||||||
|
|
||||||
|
function makeSettings(ai = {}) {
|
||||||
|
const data = {
|
||||||
|
ai: {
|
||||||
|
enabled: true,
|
||||||
|
allowRemoteHost: false,
|
||||||
|
attachScreenshots: true,
|
||||||
|
timeoutMs: 60000,
|
||||||
|
maxImageBytes: 12 * 1024 * 1024,
|
||||||
|
ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b' },
|
||||||
|
...ai,
|
||||||
|
ollama: { host: 'http://127.0.0.1:11434', model: 'llama3.2:1b', ...(ai.ollama || {}) },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
get(key) {
|
||||||
|
return key.split('.').reduce((acc, part) => (acc == null ? undefined : acc[part]), data);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- loopback policy (pure core) -------------------------------------------
|
||||||
|
|
||||||
|
test('isLoopbackHost recognizes local addresses and rejects remote ones', () => {
|
||||||
|
for (const host of [
|
||||||
|
'http://127.0.0.1:11434',
|
||||||
|
'127.0.0.1',
|
||||||
|
'localhost:11434',
|
||||||
|
'http://[::1]:11434',
|
||||||
|
'http://127.5.6.7',
|
||||||
|
'0.0.0.0',
|
||||||
|
]) {
|
||||||
|
assert.equal(isLoopbackHost(host), true, `loopback: ${host}`);
|
||||||
|
}
|
||||||
|
for (const host of [
|
||||||
|
'http://10.0.0.5:11434',
|
||||||
|
'http://192.168.1.20',
|
||||||
|
'https://ollama.example.com',
|
||||||
|
'http://8.8.8.8',
|
||||||
|
'http://ollama.internal',
|
||||||
|
]) {
|
||||||
|
assert.equal(isLoopbackHost(host), false, `remote: ${host}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('validateOllamaHost blocks remote hosts unless explicitly allowed', () => {
|
||||||
|
assert.equal(validateOllamaHost('http://127.0.0.1:11434').ok, true);
|
||||||
|
const blocked = validateOllamaHost('http://10.0.0.5:11434');
|
||||||
|
assert.equal(blocked.ok, false);
|
||||||
|
assert.match(blocked.reason, /remote/i);
|
||||||
|
assert.equal(validateOllamaHost('http://10.0.0.5:11434', { allowRemote: true }).ok, true);
|
||||||
|
assert.equal(validateOllamaHost('').ok, false);
|
||||||
|
assert.equal(validateOllamaHost('ftp://127.0.0.1').ok, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- host policy enforced by the service -----------------------------------
|
||||||
|
|
||||||
|
test('AI connection test refuses a remote host without the opt-in', async (t) => {
|
||||||
|
const root = makeTmpDir('ai-remote-block');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
let called = false;
|
||||||
|
const service = new TextIntelService({
|
||||||
|
store: { settingsDir: root },
|
||||||
|
settings: makeSettings({ ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' } }),
|
||||||
|
dataDir: root,
|
||||||
|
fetchImpl: async () => { called = true; return { ok: true, json: async () => ({}) }; },
|
||||||
|
});
|
||||||
|
const result = await service.testAiConnection();
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.reason, /remote/i);
|
||||||
|
assert.equal(called, false, 'must not contact a blocked host at all');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('AI connection test allows a remote host with the opt-in', async (t) => {
|
||||||
|
const root = makeTmpDir('ai-remote-allow');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const service = new TextIntelService({
|
||||||
|
store: { settingsDir: root },
|
||||||
|
settings: makeSettings({
|
||||||
|
allowRemoteHost: true,
|
||||||
|
ollama: { host: 'http://10.0.0.5:11434', model: 'llama3.2:1b' },
|
||||||
|
}),
|
||||||
|
dataDir: root,
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
const pathname = new URL(url).pathname;
|
||||||
|
if (pathname === '/api/tags') return { ok: true, json: async () => ({ models: [{ name: 'llama3.2:1b' }] }) };
|
||||||
|
if (pathname === '/api/show') return { ok: true, json: async () => ({ capabilities: ['vision'] }) };
|
||||||
|
throw new Error(`unexpected ${pathname}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const result = await service.testAiConnection();
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(result.host, 'http://10.0.0.5:11434');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- request timeout / cancellation ----------------------------------------
|
||||||
|
|
||||||
|
test('a hung endpoint times out instead of hanging forever', async (t) => {
|
||||||
|
const root = makeTmpDir('ai-timeout');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const service = new TextIntelService({
|
||||||
|
store: { settingsDir: root },
|
||||||
|
settings: makeSettings({ timeoutMs: 40 }),
|
||||||
|
dataDir: root,
|
||||||
|
fetchImpl: (url, init) => new Promise((resolve, reject) => {
|
||||||
|
// Never resolves on its own; only the abort signal ends it.
|
||||||
|
init.signal.addEventListener('abort', () => {
|
||||||
|
const err = new Error('aborted');
|
||||||
|
err.name = 'AbortError';
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const result = await service.testAiConnection();
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.reason, /timed out|timeout/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('cancelInflight aborts an outstanding request', async (t) => {
|
||||||
|
const root = makeTmpDir('ai-cancel');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const service = new TextIntelService({
|
||||||
|
store: { settingsDir: root },
|
||||||
|
settings: makeSettings({ timeoutMs: 60000 }),
|
||||||
|
dataDir: root,
|
||||||
|
fetchImpl: (url, init) => new Promise((resolve, reject) => {
|
||||||
|
init.signal.addEventListener('abort', () => {
|
||||||
|
const err = new Error('aborted');
|
||||||
|
err.name = 'AbortError';
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
const pending = service.testAiConnection();
|
||||||
|
// Give the request a tick to register in the inflight set.
|
||||||
|
await new Promise((r) => setTimeout(r, 10));
|
||||||
|
assert.equal(service.inflight.size, 1);
|
||||||
|
service.cancelInflight();
|
||||||
|
const result = await pending;
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.reason, /cancel/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- typed-text capture is off by default ----------------------------------
|
||||||
|
|
||||||
|
function makeCaptureService(settingsOverrides = {}) {
|
||||||
|
const settingsData = { 'capture.mode': 'fullscreen', ...settingsOverrides };
|
||||||
|
return new CaptureService({
|
||||||
|
store: {},
|
||||||
|
settings: { get: (k) => (k in settingsData ? settingsData[k] : null) },
|
||||||
|
getWindow: () => null,
|
||||||
|
notify: () => {},
|
||||||
|
screenApi: { getCursorScreenPoint: () => ({ x: 0, y: 0 }), getAllDisplays: () => [] },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('raw typed text is ignored unless explicitly enabled', () => {
|
||||||
|
const off = makeCaptureService();
|
||||||
|
off.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now());
|
||||||
|
off.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now());
|
||||||
|
assert.equal(off.snapshotKeyContext().recentTyped, '', 'typed text must not be buffered by default');
|
||||||
|
|
||||||
|
const on = makeCaptureService({ 'capture.captureTypedText': true });
|
||||||
|
on.onKeyboardEvent('CHAR', 'h'.charCodeAt(0), Date.now());
|
||||||
|
on.onKeyboardEvent('CHAR', 'i'.charCodeAt(0), Date.now());
|
||||||
|
assert.equal(on.snapshotKeyContext().recentTyped, 'hi');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shortcut detection still works with typed text disabled', () => {
|
||||||
|
const off = makeCaptureService();
|
||||||
|
off.onKeyboardEvent('KEY', 'Ctrl+T', Date.now());
|
||||||
|
assert.equal(off.snapshotKeyContext().recentShortcut, 'Ctrl+T');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- stale AI responses cannot overwrite user edits --------------------------
|
||||||
|
|
||||||
|
test('an AI patch built from stale data does not clobber a mid-generation user edit', async (t) => {
|
||||||
|
const { GuideStore } = require('../../core/store');
|
||||||
|
const root = makeTmpDir('ai-stale-write');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
const step = store.addStep(guide.guideId, { title: 'original title' });
|
||||||
|
|
||||||
|
const service = new TextIntelService({
|
||||||
|
store,
|
||||||
|
settings: makeSettings(),
|
||||||
|
dataDir: root,
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
const pathname = new URL(url).pathname;
|
||||||
|
if (pathname === '/api/show') {
|
||||||
|
return { ok: true, json: async () => ({ capabilities: ['completion'] }) };
|
||||||
|
}
|
||||||
|
if (pathname === '/api/chat') {
|
||||||
|
// While the model "thinks", the user edits and saves the step.
|
||||||
|
const current = store.getStep(guide.guideId, step.stepId);
|
||||||
|
store.saveStep(guide.guideId, { ...current, title: 'user edit during generation' });
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ message: { content: JSON.stringify({ title: 'AI title from stale context' }) } }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch: ${pathname}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.generateStepPatch({ guideId: guide.guideId, stepId: step.stepId, target: 'title' });
|
||||||
|
assert.equal(result.ok, false);
|
||||||
|
assert.match(result.reason, /changed while AI was generating/i);
|
||||||
|
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'user edit during generation');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an AI patch applies cleanly when nothing changed during generation', async (t) => {
|
||||||
|
const { GuideStore } = require('../../core/store');
|
||||||
|
const root = makeTmpDir('ai-clean-write');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
const step = store.addStep(guide.guideId, { title: '' });
|
||||||
|
|
||||||
|
const service = new TextIntelService({
|
||||||
|
store,
|
||||||
|
settings: makeSettings(),
|
||||||
|
dataDir: root,
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
const pathname = new URL(url).pathname;
|
||||||
|
if (pathname === '/api/show') {
|
||||||
|
return { ok: true, json: async () => ({ capabilities: ['completion'] }) };
|
||||||
|
}
|
||||||
|
if (pathname === '/api/chat') {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ message: { content: JSON.stringify({ title: 'Generated title' }) } }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
throw new Error(`unexpected fetch: ${pathname}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await service.generateStepPatch({ guideId: guide.guideId, stepId: step.stepId, target: 'title' });
|
||||||
|
assert.equal(result.ok, true);
|
||||||
|
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'Generated title');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- source-level guards ----------------------------------------------------
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..');
|
||||||
|
|
||||||
|
test('the Windows keyboard hook only emits characters when opted in', () => {
|
||||||
|
const src = fs.readFileSync(path.join(ROOT, 'app/capture.js'), 'utf8');
|
||||||
|
// The CHAR emission must be guarded by the opt-in flag threaded into C#.
|
||||||
|
assert.match(src, /CaptureTypedText = \$\{captureTypedText\}/);
|
||||||
|
assert.match(src, /else if \(CaptureTypedText\) \{/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const CaptureService = require('../../app/capture');
|
||||||
|
|
||||||
|
function makeService({ settings: settingsOverrides, powerPolicy } = {}) {
|
||||||
|
const settingsData = {
|
||||||
|
'capture.mode': 'fullscreen',
|
||||||
|
'capture.delayMs': 0,
|
||||||
|
...settingsOverrides,
|
||||||
|
};
|
||||||
|
return new CaptureService({
|
||||||
|
store: {},
|
||||||
|
settings: { get: (k) => (k in settingsData ? settingsData[k] : null) },
|
||||||
|
getWindow: () => null,
|
||||||
|
notify: () => {},
|
||||||
|
powerPolicy,
|
||||||
|
screenApi: {
|
||||||
|
getCursorScreenPoint: () => ({ x: 0, y: 0 }),
|
||||||
|
getAllDisplays: () => [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- region rect clamping (bug: out-of-bounds / negative drags) -------------
|
||||||
|
|
||||||
|
test('overlayRectToImageRect scales, clamps, and normalizes selections', () => {
|
||||||
|
const svc = makeService();
|
||||||
|
const display = { bounds: { x: 0, y: 0, width: 100, height: 100 } };
|
||||||
|
const imgSize = { width: 200, height: 200 }; // 2x DPI
|
||||||
|
|
||||||
|
// Simple selection: display px -> image px (2x).
|
||||||
|
assert.deepEqual(
|
||||||
|
svc.overlayRectToImageRect({ x: 10, y: 20, w: 30, h: 40 }, display, imgSize),
|
||||||
|
{ x: 20, y: 40, width: 60, height: 80 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Negative-size drag (drawn up/left) normalizes to a positive rect.
|
||||||
|
assert.deepEqual(
|
||||||
|
svc.overlayRectToImageRect({ x: 40, y: 40, w: -20, h: -20 }, display, imgSize),
|
||||||
|
{ x: 40, y: 40, width: 40, height: 40 }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Selection larger than the screen is clamped to the image bounds.
|
||||||
|
const clamped = svc.overlayRectToImageRect({ x: -10, y: -10, w: 200, h: 200 }, display, imgSize);
|
||||||
|
assert.deepEqual(clamped, { x: 0, y: 0, width: 200, height: 200 });
|
||||||
|
|
||||||
|
// Degenerate selections return null instead of an out-of-bounds crop.
|
||||||
|
assert.equal(svc.overlayRectToImageRect({ x: 0, y: 0, w: 0, h: 0 }, display, imgSize), null);
|
||||||
|
assert.equal(svc.overlayRectToImageRect({ x: 999, y: 999, w: 10, h: 10 }, display, imgSize), null);
|
||||||
|
assert.equal(svc.overlayRectToImageRect(null, display, imgSize), null);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- power ownership follows recording state --------------------------------
|
||||||
|
|
||||||
|
test('the power blocker is held only while actively recording', () => {
|
||||||
|
const calls = [];
|
||||||
|
const powerPolicy = { setRecording: (on) => calls.push(on) };
|
||||||
|
const svc = makeService({ powerPolicy });
|
||||||
|
|
||||||
|
// startSession begins PAUSED — must not hold power.
|
||||||
|
svc.startSession('g1', { intervalSec: 0 });
|
||||||
|
assert.deepEqual(calls, [], 'a paused new session must not start the power blocker');
|
||||||
|
|
||||||
|
// Resume records -> power on. (togglePause(false) arms recording.)
|
||||||
|
svc.togglePause(false);
|
||||||
|
assert.deepEqual(calls, [true]);
|
||||||
|
|
||||||
|
// Pause -> power off. This is the tray/second-instance path that used to leak.
|
||||||
|
svc.togglePause(true);
|
||||||
|
assert.deepEqual(calls, [true, false]);
|
||||||
|
|
||||||
|
// Resume again -> on, finish -> off.
|
||||||
|
svc.togglePause(false);
|
||||||
|
svc.finishSession();
|
||||||
|
assert.deepEqual(calls, [true, false, true, false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('finishSession releases power even if it was recording', () => {
|
||||||
|
const calls = [];
|
||||||
|
const svc = makeService({ powerPolicy: { setRecording: (on) => calls.push(on) } });
|
||||||
|
svc.startSession('g1', { intervalSec: 0 });
|
||||||
|
svc.togglePause(false);
|
||||||
|
calls.length = 0;
|
||||||
|
svc.finishSession();
|
||||||
|
assert.deepEqual(calls, [false]);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- explicit click-source reporting ----------------------------------------
|
||||||
|
|
||||||
|
test('click source is unavailable outside a session and after stop', () => {
|
||||||
|
const svc = makeService();
|
||||||
|
assert.equal(svc.state().clickSource, 'unavailable');
|
||||||
|
assert.equal(svc.state().clickCapture, undefined); // no session -> no field
|
||||||
|
svc.clickSource = 'evdev-x11';
|
||||||
|
svc.stopClickWatcher();
|
||||||
|
assert.equal(svc.clickSource, 'unavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('state reports clickCapture true for a non-process (evdev) source', () => {
|
||||||
|
const svc = makeService();
|
||||||
|
svc.session = { guideId: 'g', paused: false, count: 0, intervalSec: 0 };
|
||||||
|
// evdev has no child process; the old Boolean(clickWatcher) reported false.
|
||||||
|
svc.clickSource = 'evdev-wayland';
|
||||||
|
const st = svc.state();
|
||||||
|
assert.equal(st.clickCapture, true);
|
||||||
|
assert.equal(st.clickSource, 'evdev-wayland');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- drain never hangs quit -------------------------------------------------
|
||||||
|
|
||||||
|
test('drainPendingClicks resolves within the deadline even if the queue hangs', async () => {
|
||||||
|
const svc = makeService();
|
||||||
|
svc.clickQueue = new Promise(() => {}); // never settles
|
||||||
|
const start = Date.now();
|
||||||
|
await svc.drainPendingClicks(60);
|
||||||
|
assert.ok(Date.now() - start < 1000, 'drain must not block on a hung queue');
|
||||||
|
});
|
||||||
@@ -68,7 +68,9 @@ function makeFrame(name, ageMs = 0, overrides = {}) {
|
|||||||
// ---- fresh-shot fallback path ----------------------------------------------
|
// ---- fresh-shot fallback path ----------------------------------------------
|
||||||
|
|
||||||
test('click-triggered session capture uses the low-latency hide pause', async () => {
|
test('click-triggered session capture uses the low-latency hide pause', async () => {
|
||||||
const service = makeService();
|
// The fresh-shot fallback only runs in non-strict (balanced) mode; strict
|
||||||
|
// mode skips rather than storing a post-click shot.
|
||||||
|
const service = makeService({ settings: { 'capture.strictClickFrames': false } });
|
||||||
service.session = { guideId: 'guide-1', paused: false, count: 0, intervalSec: 0 };
|
service.session = { guideId: 'guide-1', paused: false, count: 0, intervalSec: 0 };
|
||||||
|
|
||||||
let seenOptions = null;
|
let seenOptions = null;
|
||||||
@@ -1006,7 +1008,9 @@ test('a buffered frame from a different display is ignored for click capture', a
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a stale buffered frame is not reused — the click falls back to a fresh shot', async () => {
|
test('a stale buffered frame is not reused — the click falls back to a fresh shot', async () => {
|
||||||
const service = makeService();
|
// Balanced (non-strict) mode: a stale frame is rejected and the click takes
|
||||||
|
// the fresh-shot fallback. (Strict mode skips instead — see below.)
|
||||||
|
const service = makeService({ settings: { 'capture.strictClickFrames': false } });
|
||||||
service.session = { guideId: 'guide-stale', paused: false, count: 0, intervalSec: 0 };
|
service.session = { guideId: 'guide-stale', paused: false, count: 0, intervalSec: 0 };
|
||||||
service.latestFrame = makeFrame('stale-png', 10_000);
|
service.latestFrame = makeFrame('stale-png', 10_000);
|
||||||
|
|
||||||
@@ -1022,12 +1026,12 @@ test('a stale buffered frame is not reused — the click falls back to a fresh s
|
|||||||
assert.equal(shootCalled, true, 'a stale buffered frame must not be reused');
|
assert.equal(shootCalled, true, 'a stale buffered frame must not be reused');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('strict mode: a frame whose grab started after the click is rejected', async () => {
|
test('strict mode: no pre-click frame is skipped, never stored as a post-click shot', async () => {
|
||||||
// This replaces the old "idle click waits for the imminent loop frame"
|
// A grab that begins after the click can already show the click's effects.
|
||||||
// behavior: a grab that begins after the click can already show the
|
// Strict mode's promise is that a stored step never shows the post-click
|
||||||
// click's effects, so strict mode takes the explicit fresh-shot fallback
|
// screen, so when no pre-click frame qualifies it SKIPS with a diagnostic
|
||||||
// instead of passing it off as the click-time screen.
|
// rather than taking a fresh (post-click) shot.
|
||||||
const service = makeService();
|
const service = makeService(); // strict is the default
|
||||||
service.session = { guideId: 'guide-strict', paused: false, count: 0, intervalSec: 0 };
|
service.session = { guideId: 'guide-strict', paused: false, count: 0, intervalSec: 0 };
|
||||||
service.frameLoopRunning = true;
|
service.frameLoopRunning = true;
|
||||||
service.frameLoopInFlight = false; // nothing in flight at click time
|
service.frameLoopInFlight = false; // nothing in flight at click time
|
||||||
@@ -1041,11 +1045,18 @@ test('strict mode: a frame whose grab started after the click is rejected', asyn
|
|||||||
shootCalled = true;
|
shootCalled = true;
|
||||||
return { ok: true, step: { stepId: 'fresh-step' } };
|
return { ok: true, step: { stepId: 'fresh-step' } };
|
||||||
};
|
};
|
||||||
|
const diagnostics = [];
|
||||||
|
service.notify = (channel, payload) => {
|
||||||
|
if (channel === 'capture:diagnostic') diagnostics.push(payload);
|
||||||
|
};
|
||||||
|
|
||||||
const result = await service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });
|
const result = await service.sessionCapture('click', { x: 1, y: 1 }, { at: clickAt });
|
||||||
|
|
||||||
assert.equal(result.ok, true);
|
assert.equal(result.ok, false);
|
||||||
assert.equal(shootCalled, true);
|
assert.match(result.reason, /strict/i);
|
||||||
|
assert.equal(shootCalled, false, 'strict mode must not store a post-click shot');
|
||||||
|
assert.equal(diagnostics.length, 1);
|
||||||
|
assert.equal(diagnostics[0].kind, 'strict-click-skipped');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('balanced mode keeps the legacy slack: an imminent post-click frame is accepted', async () => {
|
test('balanced mode keeps the legacy slack: an imminent post-click frame is accepted', async () => {
|
||||||
@@ -1183,7 +1194,9 @@ test('click frames come from the stream backend when it is active', async () =>
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('a stream backend with no qualifying frame falls through to the fresh-shot path', async () => {
|
test('a stream backend with no qualifying frame falls through to the fresh-shot path', async () => {
|
||||||
const service = makeService();
|
// Balanced (non-strict) mode: the fresh-shot fallback runs. Strict mode
|
||||||
|
// would skip rather than store a post-click shot.
|
||||||
|
const service = makeService({ settings: { 'capture.strictClickFrames': false } });
|
||||||
service.session = { guideId: 'guide-stream-miss', paused: false, count: 0, intervalSec: 0 };
|
service.session = { guideId: 'guide-stream-miss', paused: false, count: 0, intervalSec: 0 };
|
||||||
service.streamBackend = {
|
service.streamBackend = {
|
||||||
isActive: () => true,
|
isActive: () => true,
|
||||||
@@ -1279,6 +1292,8 @@ test('click capture marks the click-time cursor position', async () => {
|
|||||||
if (key === 'capture.clickMarker') return true;
|
if (key === 'capture.clickMarker') return true;
|
||||||
if (key === 'capture.clickMarkerColor') return '#E5484D';
|
if (key === 'capture.clickMarkerColor') return '#E5484D';
|
||||||
if (key === 'editor.focusedViewDefaultForNewSteps') return false;
|
if (key === 'editor.focusedViewDefaultForNewSteps') return false;
|
||||||
|
// Exercise the fresh-shot fallback: strict mode would skip instead.
|
||||||
|
if (key === 'capture.strictClickFrames') return false;
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
service.session = { guideId: 'guide-4', paused: false, count: 0, intervalSec: 0 };
|
service.session = { guideId: 'guide-4', paused: false, count: 0, intervalSec: 0 };
|
||||||
|
|||||||
@@ -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/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..');
|
||||||
|
// Strip CR so /^...$/m assertions are robust to CRLF checkouts on Windows CI.
|
||||||
|
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
|
||||||
|
|
||||||
|
// These are structural checks that run in the normal (cross-platform) unit
|
||||||
|
// suite so the Linux packaging config can't rot silently; the actual .deb
|
||||||
|
// build/inspection lives in tests/integration/linux/package-deb.test.sh.
|
||||||
|
|
||||||
|
test('Linux packaging files exist in their expected separate locations', () => {
|
||||||
|
for (const f of [
|
||||||
|
'packaging/linux/debian/package.sh',
|
||||||
|
'packaging/linux/debian/control.in',
|
||||||
|
'packaging/linux/common/stepforge.desktop',
|
||||||
|
'packaging/linux/common/stepforge-mime.xml',
|
||||||
|
'packaging/linux/common/launcher.sh',
|
||||||
|
'scripts/linux/apt/install-runtime-deps.sh',
|
||||||
|
'scripts/linux/apt/install-build-deps.sh',
|
||||||
|
'docs/linux/apt.md',
|
||||||
|
'tests/integration/linux/package-deb.test.sh',
|
||||||
|
]) {
|
||||||
|
assert.ok(exists(f), `expected ${f} to exist`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the old non-production package-linux.sh is gone', () => {
|
||||||
|
assert.equal(exists('scripts/package-linux.sh'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Fedora/dnf packaging files exist in their own separate locations', () => {
|
||||||
|
for (const f of [
|
||||||
|
'packaging/linux/fedora/package.sh',
|
||||||
|
'packaging/linux/fedora/stepforge.spec',
|
||||||
|
'packaging/linux/common/stage-runtime.sh',
|
||||||
|
'scripts/linux/dnf/install-runtime-deps.sh',
|
||||||
|
'scripts/linux/dnf/install-build-deps.sh',
|
||||||
|
'docs/linux/dnf.md',
|
||||||
|
'tests/integration/linux/package-rpm.test.sh',
|
||||||
|
]) {
|
||||||
|
assert.ok(exists(f), `expected ${f} to exist`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the RPM spec declares runtime Requires, license, and 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', () => {
|
||||||
|
const desktop = read('packaging/linux/common/stepforge.desktop');
|
||||||
|
assert.match(desktop, /^\[Desktop Entry\]/);
|
||||||
|
assert.match(desktop, /^Type=Application$/m);
|
||||||
|
assert.match(desktop, /^Exec=stepforge %U$/m);
|
||||||
|
assert.match(desktop, /^Icon=stepforge$/m);
|
||||||
|
assert.match(desktop, /^Categories=.+;$/m);
|
||||||
|
assert.match(desktop, /MimeType=application\/x-stepforge-guide/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the control template declares runtime deps, no hardcoded arch, real maintainer slot', () => {
|
||||||
|
const control = read('packaging/linux/debian/control.in');
|
||||||
|
assert.match(control, /^Architecture: @ARCH@$/m, 'arch must be templated, not hardcoded');
|
||||||
|
assert.match(control, /^Depends:.*libnss3/m);
|
||||||
|
assert.match(control, /@VERSION@/);
|
||||||
|
assert.match(control, /@MAINTAINER@/);
|
||||||
|
// The false "fully offline" wording must not reappear here.
|
||||||
|
assert.doesNotMatch(control, /fully offline/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the launcher refuses an unsandboxed launch unless explicitly opted in', () => {
|
||||||
|
const launcher = read('packaging/linux/common/launcher.sh');
|
||||||
|
assert.match(launcher, /STEPFORGE_ALLOW_NO_SANDBOX/);
|
||||||
|
// It must not unconditionally exec with --no-sandbox.
|
||||||
|
assert.doesNotMatch(launcher, /^exec .*--no-sandbox/m);
|
||||||
|
// It never installs anything at runtime.
|
||||||
|
assert.doesNotMatch(launcher, /npm (install|ci|rebuild)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the deb builder detects arch and delegates to shared staging', () => {
|
||||||
|
const script = read('packaging/linux/debian/package.sh');
|
||||||
|
assert.match(script, /stage-runtime\.sh/); // runtime-only staging is shared
|
||||||
|
assert.match(script, /dpkg --print-architecture/); // arch detected, not hardcoded
|
||||||
|
assert.doesNotMatch(script, /cp -a "\$ROOT_DIR\/node_modules" /); // never copy the whole dev tree
|
||||||
|
});
|
||||||
|
|
||||||
|
test('apt setup scripts target apt and keep build vs runtime deps separate', () => {
|
||||||
|
const runtime = read('scripts/linux/apt/install-runtime-deps.sh');
|
||||||
|
const build = read('scripts/linux/apt/install-build-deps.sh');
|
||||||
|
assert.match(runtime, /apt-get/);
|
||||||
|
assert.match(runtime, /libnss3/);
|
||||||
|
assert.doesNotMatch(runtime, /dpkg-dev|fakeroot/, 'runtime script must not install build tools');
|
||||||
|
assert.match(build, /dpkg-dev/);
|
||||||
|
assert.match(build, /fakeroot/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an original icon set is generated (not a placeholder/third-party asset)', () => {
|
||||||
|
assert.ok(exists('packaging/assets/stepforge.svg'));
|
||||||
|
assert.ok(exists('scripts/make-icons.js'));
|
||||||
|
// The generated PNGs are committed for packaging.
|
||||||
|
for (const size of [16, 48, 256]) {
|
||||||
|
assert.ok(exists(`packaging/assets/icons/stepforge-${size}.png`), `icon ${size} missing`);
|
||||||
|
}
|
||||||
|
// Regenerate the 256px icon and confirm the generator is deterministic and
|
||||||
|
// produces a valid PNG (starts with the PNG signature).
|
||||||
|
const { renderIcon } = require('../../scripts/make-icons');
|
||||||
|
const { encodePng } = require('../../core/png');
|
||||||
|
const png = encodePng(renderIcon(256));
|
||||||
|
assert.deepEqual([...png.subarray(0, 4)], [0x89, 0x50, 0x4e, 0x47]);
|
||||||
|
});
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { detectPlatform, createWindowContextProvider, detectCapabilities } = require('../../app/platform');
|
||||||
|
const { assertWindowContextProvider, CLICK_SOURCES } = require('../../app/platform/interfaces');
|
||||||
|
const { detectLinuxCapabilities, detectSessionType } = require('../../app/platform/linux/diagnostics');
|
||||||
|
|
||||||
|
// ---- platform selection -----------------------------------------------------
|
||||||
|
|
||||||
|
test('detectPlatform maps process.platform to an adapter family', () => {
|
||||||
|
assert.equal(detectPlatform('win32'), 'windows');
|
||||||
|
assert.equal(detectPlatform('darwin'), 'darwin');
|
||||||
|
assert.equal(detectPlatform('linux'), 'linux');
|
||||||
|
assert.equal(detectPlatform('sunos'), 'unsupported');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the factory returns a valid WindowContextProvider for every OS', () => {
|
||||||
|
for (const platform of ['win32', 'darwin', 'linux', 'sunos']) {
|
||||||
|
const provider = createWindowContextProvider({ platform });
|
||||||
|
assert.doesNotThrow(() => assertWindowContextProvider(provider));
|
||||||
|
assert.equal(typeof provider.collect, 'function');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an unsupported platform provider returns an empty context, never throws', async () => {
|
||||||
|
const provider = createWindowContextProvider({ platform: 'sunos' });
|
||||||
|
assert.deepEqual(await provider.collect(), { appName: '', windowTitle: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the shared code delegates window context to the injected provider', async () => {
|
||||||
|
// The text-intel service must consume the provider, not branch on platform.
|
||||||
|
const { TextIntelService } = require('../../app/text-intel');
|
||||||
|
const { makeTmpDir, rmrf } = require('./helpers');
|
||||||
|
const root = makeTmpDir('platform-ctx');
|
||||||
|
let sawPoint = null;
|
||||||
|
const service = new TextIntelService({
|
||||||
|
store: { settingsDir: root },
|
||||||
|
settings: { get: () => null },
|
||||||
|
dataDir: root,
|
||||||
|
windowContextProvider: {
|
||||||
|
async collect(osPoint) { sawPoint = osPoint; return { appName: 'TestApp', windowTitle: 'Test Window' }; },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const ctx = await service.collectForegroundWindowContext({ x: 5, y: 6 });
|
||||||
|
assert.deepEqual(ctx, { appName: 'TestApp', windowTitle: 'Test Window' });
|
||||||
|
assert.deepEqual(sawPoint, { x: 5, y: 6 });
|
||||||
|
rmrf(root);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- Linux diagnostics ------------------------------------------------------
|
||||||
|
|
||||||
|
test('session type prefers XDG_SESSION_TYPE then display env', () => {
|
||||||
|
assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'wayland' }), 'wayland');
|
||||||
|
assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'x11' }), 'x11');
|
||||||
|
assert.equal(detectSessionType({ WAYLAND_DISPLAY: 'wayland-0' }), 'wayland');
|
||||||
|
assert.equal(detectSessionType({ DISPLAY: ':0' }), 'x11');
|
||||||
|
assert.equal(detectSessionType({}), 'unknown');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('X11 with xinput reports marker-capable per-click capture', () => {
|
||||||
|
const caps = detectLinuxCapabilities({
|
||||||
|
env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0', DBUS_SESSION_BUS_ADDRESS: 'unix:x' },
|
||||||
|
hasBinary: (n) => n === 'xinput' || n === 'xprop',
|
||||||
|
existsSync: () => false,
|
||||||
|
readdirSync: () => [],
|
||||||
|
});
|
||||||
|
assert.equal(caps.isWayland, false);
|
||||||
|
assert.equal(caps.clickCapture, 'x11-xinput');
|
||||||
|
assert.equal(caps.screenCapture, 'x11-direct');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wayland without PipeWire reports an actionable message', () => {
|
||||||
|
const caps = detectLinuxCapabilities({
|
||||||
|
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
|
||||||
|
hasBinary: () => false,
|
||||||
|
existsSync: () => false,
|
||||||
|
readdirSync: () => [],
|
||||||
|
});
|
||||||
|
assert.equal(caps.isWayland, true);
|
||||||
|
assert.equal(caps.screenCapture, 'wayland-portal');
|
||||||
|
assert.ok(caps.messages.some((m) => /PipeWire|portal/i.test(m)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no click source falls back to hotkey/interval with a message', () => {
|
||||||
|
const caps = detectLinuxCapabilities({
|
||||||
|
env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' },
|
||||||
|
hasBinary: () => false, // no xinput
|
||||||
|
existsSync: () => false,
|
||||||
|
readdirSync: () => [], // no readable input devices
|
||||||
|
});
|
||||||
|
assert.equal(caps.clickCapture, 'hotkey-or-interval-only');
|
||||||
|
assert.ok(caps.messages.some((m) => /hotkey|interval/i.test(m)));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('readable evdev devices enable an evdev click source', () => {
|
||||||
|
const caps = detectLinuxCapabilities({
|
||||||
|
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
|
||||||
|
hasBinary: (n) => n === 'pipewire',
|
||||||
|
existsSync: () => true,
|
||||||
|
readdirSync: () => ['event0', 'event1', 'mouse0'],
|
||||||
|
});
|
||||||
|
// event0/event1 are readable (accessSync is real, but /dev/input/eventN
|
||||||
|
// likely won't exist in CI; the profile still resolves without throwing).
|
||||||
|
assert.ok(['evdev-wayland', 'hotkey-or-interval-only'].includes(caps.clickCapture));
|
||||||
|
assert.equal(caps.os, 'linux');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- capability facade ------------------------------------------------------
|
||||||
|
|
||||||
|
test('detectCapabilities returns a Linux profile with valid click source', () => {
|
||||||
|
const caps = detectCapabilities({ platform: 'linux', env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' } });
|
||||||
|
assert.equal(caps.os, 'linux');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('detectCapabilities reports windows-hook for Windows', () => {
|
||||||
|
const caps = detectCapabilities({ platform: 'win32', env: {} });
|
||||||
|
assert.equal(caps.os, 'windows');
|
||||||
|
assert.equal(caps.clickCapture, 'windows-hook');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every documented click source is a known token', () => {
|
||||||
|
for (const s of ['windows-hook', 'x11', 'evdev-x11', 'evdev-wayland', 'wayland-portal', 'hotkey', 'interval', 'unavailable']) {
|
||||||
|
assert.ok(CLICK_SOURCES.includes(s));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- refactor guard ---------------------------------------------------------
|
||||||
|
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
test('text-intel no longer branches on process.platform for window context', () => {
|
||||||
|
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'app', 'text-intel.js'), 'utf8');
|
||||||
|
assert.doesNotMatch(src, /collectWindowsWindowContext|collectMacWindowContext|collectLinuxWindowContext/);
|
||||||
|
assert.doesNotMatch(src, /process\.platform === 'win32'/);
|
||||||
|
assert.match(src, /this\.windowContext\.collect/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
const zlib = require('node:zlib');
|
||||||
|
|
||||||
|
const { GuideStore } = require('../../core/store');
|
||||||
|
const { SearchIndex } = require('../../core/search');
|
||||||
|
const { unzipSync, zipSync, crc32 } = require('../../core/zip');
|
||||||
|
const { exportGuideArchive, importGuideArchive } = require('../../core/archive');
|
||||||
|
const { createSnapshot, restoreSnapshot, autoSnapshotIfDue } = require('../../core/snapshots');
|
||||||
|
const { acquireLock, releaseLock } = require('../../core/locks');
|
||||||
|
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
|
||||||
|
|
||||||
|
// ---- ZIP bomb / resource limits ---------------------------------------------
|
||||||
|
|
||||||
|
test('unzip rejects an archive that declares too many entries', () => {
|
||||||
|
const many = [];
|
||||||
|
for (let i = 0; i < 20; i += 1) many.push({ name: `f${i}.txt`, data: 'x' });
|
||||||
|
const buf = zipSync(many);
|
||||||
|
assert.throws(() => unzipSync(buf, { limits: { maxEntries: 5 } }), /too many entries/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unzip rejects an entry whose declared size exceeds the per-entry limit', () => {
|
||||||
|
const buf = zipSync([{ name: 'big.txt', data: Buffer.alloc(1000, 65) }]);
|
||||||
|
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 100 } }), /entry too large/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('unzip caps inflation so a deflate bomb cannot exhaust memory', () => {
|
||||||
|
// A hand-built entry whose deflate stream expands far past the cap. The
|
||||||
|
// maxOutputLength guard must abort inflation rather than allocating it all.
|
||||||
|
const bomb = Buffer.alloc(10 * 1024 * 1024, 0); // 10 MiB of zeros -> tiny deflate
|
||||||
|
const raw = zlib.deflateRawSync(bomb, { level: 9 });
|
||||||
|
const name = 'bomb';
|
||||||
|
const nameBuf = Buffer.from(name);
|
||||||
|
const local = Buffer.alloc(30);
|
||||||
|
local.writeUInt32LE(0x04034b50, 0);
|
||||||
|
local.writeUInt16LE(20, 4);
|
||||||
|
local.writeUInt16LE(0x0800, 6);
|
||||||
|
local.writeUInt16LE(8, 8); // deflate
|
||||||
|
local.writeUInt32LE(crc32(bomb), 14);
|
||||||
|
local.writeUInt32LE(raw.length, 18);
|
||||||
|
local.writeUInt32LE(bomb.length, 22);
|
||||||
|
local.writeUInt16LE(nameBuf.length, 26);
|
||||||
|
const central = Buffer.alloc(46);
|
||||||
|
central.writeUInt32LE(0x02014b50, 0);
|
||||||
|
central.writeUInt16LE(20, 4);
|
||||||
|
central.writeUInt16LE(20, 6);
|
||||||
|
central.writeUInt16LE(0x0800, 8);
|
||||||
|
central.writeUInt16LE(8, 10);
|
||||||
|
central.writeUInt32LE(crc32(bomb), 16);
|
||||||
|
central.writeUInt32LE(raw.length, 20);
|
||||||
|
central.writeUInt32LE(bomb.length, 24);
|
||||||
|
central.writeUInt16LE(nameBuf.length, 28);
|
||||||
|
central.writeUInt32LE(0, 42);
|
||||||
|
const localBlock = Buffer.concat([local, nameBuf, raw]);
|
||||||
|
const centralBlock = Buffer.concat([central, nameBuf]);
|
||||||
|
const eocd = Buffer.alloc(22);
|
||||||
|
eocd.writeUInt32LE(0x06054b50, 0);
|
||||||
|
eocd.writeUInt16LE(1, 8);
|
||||||
|
eocd.writeUInt16LE(1, 10);
|
||||||
|
eocd.writeUInt32LE(centralBlock.length, 12);
|
||||||
|
eocd.writeUInt32LE(localBlock.length, 16);
|
||||||
|
const buf = Buffer.concat([localBlock, centralBlock, eocd]);
|
||||||
|
|
||||||
|
assert.throws(() => unzipSync(buf, { limits: { maxEntryUncompressed: 64 * 1024 } }));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- transactional archive import -------------------------------------------
|
||||||
|
|
||||||
|
test('a corrupt step aborts the import leaving no partial guide', (t) => {
|
||||||
|
const root = makeTmpDir('import-atomic');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
|
||||||
|
const guide = store.createGuide({ title: 'Src' });
|
||||||
|
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const archiveFile = path.join(root, 'out.sfgz');
|
||||||
|
exportGuideArchive(store, guide.guideId, archiveFile);
|
||||||
|
|
||||||
|
// Corrupt the exported step so validation fails during import.
|
||||||
|
const { unzipSync: uz } = require('../../core/zip');
|
||||||
|
const entries = uz(fs.readFileSync(archiveFile));
|
||||||
|
const tampered = entries.map((e) => {
|
||||||
|
if (e.name.endsWith('step.json')) {
|
||||||
|
const obj = JSON.parse(e.data.toString('utf8'));
|
||||||
|
// Corrupt the image size to non-finite values — validateStep rejects
|
||||||
|
// an image step with an invalid size.
|
||||||
|
obj.image = { originalPath: 'original.png', workingPath: 'working.png', size: { width: 'x', height: null } };
|
||||||
|
return { name: e.name, data: Buffer.from(JSON.stringify(obj)) };
|
||||||
|
}
|
||||||
|
return { name: e.name, data: e.data };
|
||||||
|
});
|
||||||
|
fs.writeFileSync(archiveFile, zipSync(tampered));
|
||||||
|
|
||||||
|
const before = store.listGuides().length;
|
||||||
|
assert.throws(() => importGuideArchive(store, archiveFile, { mode: 'copy' }));
|
||||||
|
// No partial guide was left behind, and no staging dir remains.
|
||||||
|
assert.equal(store.listGuides().length, before);
|
||||||
|
const leftover = fs.readdirSync(store.guidesDir).filter((n) => n.includes('.importing'));
|
||||||
|
assert.deepEqual(leftover, []);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a valid archive imports cleanly', (t) => {
|
||||||
|
const root = makeTmpDir('import-ok');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'Src' });
|
||||||
|
store.addStep(guide.guideId, { title: 'S1' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const archiveFile = path.join(root, 'out.sfgz');
|
||||||
|
exportGuideArchive(store, guide.guideId, archiveFile);
|
||||||
|
|
||||||
|
const imported = importGuideArchive(store, archiveFile, { mode: 'copy' });
|
||||||
|
assert.equal(imported.title, 'Src');
|
||||||
|
assert.equal(imported.stepsOrder.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- atomic snapshot restore ------------------------------------------------
|
||||||
|
|
||||||
|
test('restoring a corrupt snapshot never destroys the live guide', (t) => {
|
||||||
|
const root = makeTmpDir('snap-atomic');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'Live' });
|
||||||
|
store.addStep(guide.guideId, { title: 'keep me' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const snap = createSnapshot(store, guide.guideId, { label: 'good' });
|
||||||
|
|
||||||
|
// Corrupt the snapshot zip so restore must abort.
|
||||||
|
const snapFile = path.join(store.guideDir(guide.guideId), 'history', 'snapshots', snap);
|
||||||
|
fs.writeFileSync(snapFile, Buffer.from('not a zip at all'));
|
||||||
|
|
||||||
|
assert.throws(() => restoreSnapshot(store, guide.guideId, snap), /restore aborted|invalid|zip/i);
|
||||||
|
// The live guide and its step are intact.
|
||||||
|
const after = store.getGuide(guide.guideId);
|
||||||
|
assert.equal(after.title, 'Live');
|
||||||
|
assert.equal(after.stepsOrder.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('restoring a valid snapshot swaps content and keeps history', (t) => {
|
||||||
|
const root = makeTmpDir('snap-ok');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'V1' });
|
||||||
|
const s1 = store.addStep(guide.guideId, { title: 'first' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const snap = createSnapshot(store, guide.guideId, { label: 'v1' });
|
||||||
|
|
||||||
|
// Change the guide, then restore.
|
||||||
|
store.saveGuide({ ...store.getGuide(guide.guideId), title: 'V2' });
|
||||||
|
store.deleteStep(guide.guideId, s1.stepId);
|
||||||
|
assert.equal(store.getGuide(guide.guideId).title, 'V2');
|
||||||
|
|
||||||
|
const restored = restoreSnapshot(store, guide.guideId, snap);
|
||||||
|
assert.equal(restored.title, 'V1');
|
||||||
|
assert.equal(restored.stepsOrder.length, 1);
|
||||||
|
// history/ survived the restore (pre-restore snapshot exists too).
|
||||||
|
assert.ok(fs.existsSync(path.join(store.guideDir(guide.guideId), 'history')));
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- atomic locks -----------------------------------------------------------
|
||||||
|
|
||||||
|
test('another process holding a fresh lock is a conflict; release-by-token frees ours', (t) => {
|
||||||
|
const root = makeTmpDir('lock');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const { lockPathFor } = require('../../core/locks');
|
||||||
|
const target = path.join(root, 'shared.sfgz');
|
||||||
|
fs.writeFileSync(target, 'x');
|
||||||
|
|
||||||
|
// Simulate a different process already holding a fresh lock.
|
||||||
|
fs.writeFileSync(lockPathFor(target), JSON.stringify({
|
||||||
|
host: 'other-host', user: 'someone-else', pid: 999999,
|
||||||
|
token: 'their-token', acquiredAt: new Date().toISOString(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const attempt = acquireLock(target);
|
||||||
|
assert.equal(attempt.acquired, false);
|
||||||
|
assert.ok(attempt.conflict);
|
||||||
|
// We must not be able to release their lock with a guessed/absent token.
|
||||||
|
assert.equal(releaseLock(target, { token: 'wrong' }), false);
|
||||||
|
|
||||||
|
// Force-steal (user confirmed), then release by our own acquisition token.
|
||||||
|
const stolen = acquireLock(target, { force: true });
|
||||||
|
assert.equal(stolen.acquired, true);
|
||||||
|
assert.equal(releaseLock(target, { lock: stolen.lock }), true);
|
||||||
|
assert.equal(acquireLock(target).acquired, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the same process can re-acquire its own lock', (t) => {
|
||||||
|
const root = makeTmpDir('lock-reacquire');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const target = path.join(root, 'shared.sfgz');
|
||||||
|
fs.writeFileSync(target, 'x');
|
||||||
|
assert.equal(acquireLock(target).acquired, true);
|
||||||
|
// Same process, second acquire: not a conflict.
|
||||||
|
assert.equal(acquireLock(target).acquired, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('force steal takes over a held lock', (t) => {
|
||||||
|
const root = makeTmpDir('lock-force');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const target = path.join(root, 'shared.sfgz');
|
||||||
|
fs.writeFileSync(target, 'x');
|
||||||
|
acquireLock(target);
|
||||||
|
const stolen = acquireLock(target, { force: true });
|
||||||
|
assert.equal(stolen.acquired, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- search reconcile -------------------------------------------------------
|
||||||
|
|
||||||
|
test('reconcile rebuilds a missing index from the store', (t) => {
|
||||||
|
const root = makeTmpDir('search-rebuild');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'Password reset guide' });
|
||||||
|
store.addStep(guide.guideId, { title: 'Open admin portal' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
|
||||||
|
// A brand-new index (nothing persisted) must recover by reconciling.
|
||||||
|
const index = new SearchIndex(store.indexDir);
|
||||||
|
const summary = index.reconcile(store);
|
||||||
|
assert.equal(summary.reindexed, 1);
|
||||||
|
assert.ok(index.search('password').length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reconcile drops entries for deleted guides and reindexes changed ones', (t) => {
|
||||||
|
const root = makeTmpDir('search-reconcile');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const g1 = store.createGuide({ title: 'alpha guide' });
|
||||||
|
const g2 = store.createGuide({ title: 'beta guide' });
|
||||||
|
const index = new SearchIndex(store.indexDir);
|
||||||
|
index.reconcile(store);
|
||||||
|
assert.ok(index.search('alpha').length > 0);
|
||||||
|
|
||||||
|
// Delete g1 out from under the index and change g2's title.
|
||||||
|
store.deleteGuide(g1.guideId);
|
||||||
|
store.saveGuide({ ...store.getGuide(g2.guideId), title: 'beta renamed gamma' });
|
||||||
|
|
||||||
|
const summary = index.reconcile(store);
|
||||||
|
assert.equal(index.search('alpha').length, 0, 'deleted guide is gone from search');
|
||||||
|
assert.ok(index.search('gamma').length > 0, 'changed guide is reindexed');
|
||||||
|
assert.equal(summary.removed, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a corrupt index file resets to a recoverable status', (t) => {
|
||||||
|
const root = makeTmpDir('search-corrupt');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
store.createGuide({ title: 'recoverable' });
|
||||||
|
fs.mkdirSync(store.indexDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(store.indexDir, 'search-index.json'), '{ corrupt json');
|
||||||
|
|
||||||
|
const index = new SearchIndex(store.indexDir);
|
||||||
|
const summary = index.reconcile(store);
|
||||||
|
assert.equal(summary.status, 'reset');
|
||||||
|
assert.ok(index.search('recoverable').length > 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- automatic backups ------------------------------------------------------
|
||||||
|
|
||||||
|
test('autoSnapshotIfDue takes a snapshot every N saves and prunes', (t) => {
|
||||||
|
const root = makeTmpDir('auto-backup');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
const settings = {
|
||||||
|
get: (k) => ({ automatic: true, everyNSaves: 3, keepLast: 2 }[k.replace('backups.', '')] ?? ({ backups: { automatic: true, everyNSaves: 3, keepLast: 2 } }[k])),
|
||||||
|
};
|
||||||
|
// The helper reads settings.get('backups'):
|
||||||
|
const s = { get: (k) => (k === 'backups' ? { automatic: true, everyNSaves: 3, keepLast: 2 } : null) };
|
||||||
|
|
||||||
|
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
|
||||||
|
const count = () => (fs.existsSync(dir) ? fs.readdirSync(dir).filter((n) => n.endsWith('.zip')).length : 0);
|
||||||
|
|
||||||
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 1
|
||||||
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null); // 2
|
||||||
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), true); // 3 -> snapshot
|
||||||
|
assert.equal(count(), 1);
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s); // 1
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s); // 2
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s); // 3 -> snapshot
|
||||||
|
assert.equal(count(), 2);
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s);
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s);
|
||||||
|
autoSnapshotIfDue(store, guide.guideId, s); // 3rd snapshot, pruned to keepLast=2
|
||||||
|
assert.equal(count(), 2, 'pruned to keepLast');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('autoSnapshotIfDue is a no-op when automatic backups are off', (t) => {
|
||||||
|
const root = makeTmpDir('auto-backup-off');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
const s = { get: () => ({ automatic: false, everyNSaves: 1 }) };
|
||||||
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
|
||||||
|
assert.equal(autoSnapshotIfDue(store, guide.guideId, s), null);
|
||||||
|
const dir = path.join(store.guideDir(guide.guideId), 'history', 'snapshots');
|
||||||
|
assert.equal(fs.existsSync(dir) ? fs.readdirSync(dir).length : 0, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const security = require('../../app/security');
|
||||||
|
|
||||||
|
const MAIN_URL = security.APP_PAGES.main;
|
||||||
|
const WORKER_URL = security.APP_PAGES.captureWorker;
|
||||||
|
const REGION_URL = security.APP_PAGES.region;
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..');
|
||||||
|
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||||
|
|
||||||
|
// ---- navigation policy -----------------------------------------------------
|
||||||
|
|
||||||
|
test('navigation: a window may only stay on its own app page', () => {
|
||||||
|
assert.equal(security.navigationAllowed(MAIN_URL, 'main'), true);
|
||||||
|
assert.equal(security.navigationAllowed(`${MAIN_URL}?q=1#frag`, 'main'), true);
|
||||||
|
assert.equal(security.navigationAllowed(REGION_URL, 'region'), true);
|
||||||
|
|
||||||
|
// Hostile / cross-page navigations are all denied.
|
||||||
|
for (const target of [
|
||||||
|
'https://evil.example/phish.html',
|
||||||
|
'http://127.0.0.1:8080/',
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'data:text/html,<script>1</script>',
|
||||||
|
'about:blank',
|
||||||
|
REGION_URL, // a *different* app page is still a denial for 'main'
|
||||||
|
'file:///etc/passwd',
|
||||||
|
'not a url',
|
||||||
|
'',
|
||||||
|
]) {
|
||||||
|
assert.equal(security.navigationAllowed(target, 'main'), false, `should deny ${target}`);
|
||||||
|
}
|
||||||
|
assert.equal(security.navigationAllowed(MAIN_URL, 'nonexistent-page'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- permission policy -----------------------------------------------------
|
||||||
|
|
||||||
|
test('permissions: display capture only for the capture worker, nothing else for anyone', () => {
|
||||||
|
assert.equal(security.permissionAllowed('display-capture', WORKER_URL), true);
|
||||||
|
assert.equal(security.permissionAllowed('media', WORKER_URL), true);
|
||||||
|
|
||||||
|
// The main window gets nothing, including display capture.
|
||||||
|
assert.equal(security.permissionAllowed('display-capture', MAIN_URL), false);
|
||||||
|
assert.equal(security.permissionAllowed('media', MAIN_URL), false);
|
||||||
|
|
||||||
|
// Everything else is denied even for the worker.
|
||||||
|
for (const permission of ['geolocation', 'notifications', 'clipboard-read', 'openExternal', 'fullscreen', 'pointerLock', 'hid', 'usb', 'serial']) {
|
||||||
|
assert.equal(security.permissionAllowed(permission, WORKER_URL), false, permission);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remote origins never get anything.
|
||||||
|
assert.equal(security.permissionAllowed('display-capture', 'https://evil.example/'), false);
|
||||||
|
assert.equal(security.permissionAllowed('media', undefined), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- external URL policy ---------------------------------------------------
|
||||||
|
|
||||||
|
test('external links: only well-formed http(s)/mailto pass', () => {
|
||||||
|
assert.equal(security.validateExternalUrl('https://example.com/docs'), 'https://example.com/docs');
|
||||||
|
assert.equal(security.validateExternalUrl('http://example.com'), 'http://example.com/');
|
||||||
|
assert.match(security.validateExternalUrl('mailto:[email protected]'), /^mailto:/);
|
||||||
|
|
||||||
|
for (const url of [
|
||||||
|
'javascript:alert(1)',
|
||||||
|
'file:///etc/passwd',
|
||||||
|
'data:text/html,x',
|
||||||
|
'ftp://example.com/x',
|
||||||
|
'smb://server/share',
|
||||||
|
'chrome://settings',
|
||||||
|
'vbscript:x',
|
||||||
|
'https://',
|
||||||
|
'not a url',
|
||||||
|
123,
|
||||||
|
null,
|
||||||
|
`https://example.com/${'a'.repeat(3000)}`,
|
||||||
|
]) {
|
||||||
|
assert.equal(security.validateExternalUrl(url), null, `should reject ${String(url).slice(0, 60)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- IPC sender guard --------------------------------------------------------
|
||||||
|
|
||||||
|
function makeGuard(mainWc) {
|
||||||
|
return security.makeIpcSenderGuard({ getMainWebContents: () => mainWc });
|
||||||
|
}
|
||||||
|
|
||||||
|
test('ipc guard: accepts only the main window top frame on index.html', () => {
|
||||||
|
const wc = { id: 1 };
|
||||||
|
const guard = makeGuard(wc);
|
||||||
|
|
||||||
|
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: MAIN_URL } }), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('ipc guard: rejects other windows, subframes, navigated and disposed frames', () => {
|
||||||
|
const wc = { id: 1 };
|
||||||
|
const otherWc = { id: 2 };
|
||||||
|
const guard = makeGuard(wc);
|
||||||
|
|
||||||
|
// Different webContents (popup, worker, region overlay).
|
||||||
|
assert.equal(guard({ sender: otherWc, senderFrame: { parent: null, url: MAIN_URL } }), false);
|
||||||
|
// Subframe of our own window.
|
||||||
|
assert.equal(guard({ sender: wc, senderFrame: { parent: {}, url: MAIN_URL } }), false);
|
||||||
|
// Frame that navigated somewhere else but kept the preload bridge.
|
||||||
|
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: 'https://evil.example/' } }), false);
|
||||||
|
assert.equal(guard({ sender: wc, senderFrame: { parent: null, url: WORKER_URL } }), false);
|
||||||
|
// Disposed frame accessor throws.
|
||||||
|
const disposed = {};
|
||||||
|
Object.defineProperty(disposed, 'parent', { get() { throw new Error('disposed'); } });
|
||||||
|
assert.equal(guard({ sender: wc, senderFrame: disposed }), false);
|
||||||
|
// Missing frame or window entirely.
|
||||||
|
assert.equal(guard({ sender: wc, senderFrame: null }), false);
|
||||||
|
assert.equal(makeGuard(null)({ sender: wc, senderFrame: { parent: null, url: MAIN_URL } }), false);
|
||||||
|
assert.equal(guard(null), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- argument hygiene --------------------------------------------------------
|
||||||
|
|
||||||
|
test('args must be plain object bags', () => {
|
||||||
|
assert.equal(security.isPlainArgs({ a: 1 }), true);
|
||||||
|
assert.equal(security.isPlainArgs(Object.create(null)), true);
|
||||||
|
assert.equal(security.isPlainArgs(undefined), true);
|
||||||
|
assert.equal(security.isPlainArgs(null), true);
|
||||||
|
assert.equal(security.isPlainArgs([1, 2]), false);
|
||||||
|
assert.equal(security.isPlainArgs('x'), false);
|
||||||
|
class Weird {}
|
||||||
|
assert.equal(security.isPlainArgs(new Weird()), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('payload budget rejects oversized and non-data values', () => {
|
||||||
|
assert.equal(security.payloadWithinBudget({ a: 'small' }, 1000), true);
|
||||||
|
assert.equal(security.payloadWithinBudget({ a: 'x'.repeat(2000) }, 1000), false);
|
||||||
|
assert.equal(security.payloadWithinBudget({ fn: () => 1 }, 1000), false);
|
||||||
|
// Deep nesting is cut off.
|
||||||
|
let deep = 'leaf';
|
||||||
|
for (let i = 0; i < 40; i += 1) deep = { deep };
|
||||||
|
assert.equal(security.payloadWithinBudget(deep, 100000), false);
|
||||||
|
// Numbers/booleans/null are fine.
|
||||||
|
assert.equal(security.payloadWithinBudget({ n: 5, b: true, z: null }, 1000), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('field validators refuse traversal, separators, and pollution', () => {
|
||||||
|
const c = security.check;
|
||||||
|
assert.equal(c.id('guide-123_A.b'), true);
|
||||||
|
assert.equal(c.id('../../etc/passwd'), false);
|
||||||
|
assert.equal(c.id('a/b'), false);
|
||||||
|
assert.equal(c.id('a\\b'), false);
|
||||||
|
assert.equal(c.id(''), false);
|
||||||
|
assert.equal(c.id(42), false);
|
||||||
|
|
||||||
|
assert.equal(c.fileName('My snapshot 2026-07-03'), true);
|
||||||
|
assert.equal(c.fileName('..'), false);
|
||||||
|
assert.equal(c.fileName('a/../b'), false);
|
||||||
|
assert.equal(c.fileName('a/b'), false);
|
||||||
|
assert.equal(c.fileName('a\\b'), false);
|
||||||
|
assert.equal(c.fileName('a\0b'), false);
|
||||||
|
assert.equal(c.fileName(' '), false);
|
||||||
|
|
||||||
|
assert.equal(c.settingsKeyPath('capture.hotkeyCapture'), true);
|
||||||
|
assert.equal(c.settingsKeyPath('__proto__.polluted'), false);
|
||||||
|
assert.equal(c.settingsKeyPath('a.constructor.b'), false);
|
||||||
|
assert.equal(c.settingsKeyPath('a..b'), false);
|
||||||
|
assert.equal(c.settingsKeyPath(''), false);
|
||||||
|
|
||||||
|
assert.equal(c.base64('aGVsbG8=', 100), true);
|
||||||
|
assert.equal(c.base64('<script>', 100), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('produced-files registry only re-opens what main created', () => {
|
||||||
|
const produced = new security.ProducedFiles(3);
|
||||||
|
produced.add('/tmp/exports/guide.pdf');
|
||||||
|
assert.equal(produced.has('/tmp/exports/guide.pdf'), true);
|
||||||
|
assert.equal(produced.has('/tmp/exports/../exports/guide.pdf'), true, 'path normalization');
|
||||||
|
assert.equal(produced.has('/etc/passwd'), false);
|
||||||
|
assert.equal(produced.has(null), false);
|
||||||
|
|
||||||
|
produced.add('/tmp/a');
|
||||||
|
produced.add('/tmp/b');
|
||||||
|
produced.add('/tmp/c'); // evicts guide.pdf (LRU bound)
|
||||||
|
assert.equal(produced.has('/tmp/exports/guide.pdf'), false);
|
||||||
|
assert.equal(produced.has('/tmp/c'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- source-level regression guards -----------------------------------------
|
||||||
|
|
||||||
|
test('main process never grants blanket permissions again', () => {
|
||||||
|
const src = read('app/main.js');
|
||||||
|
assert.doesNotMatch(src, /setPermissionCheckHandler\(\(\)\s*=>\s*true\)/);
|
||||||
|
assert.doesNotMatch(src, /cb\(true\)\)/);
|
||||||
|
assert.match(src, /security\.permissionAllowed/);
|
||||||
|
assert.match(src, /installWindowSecurity\(mainWindow, 'main'\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('no generic shell path channels remain on the bridge', () => {
|
||||||
|
const preload = read('app/preload.js');
|
||||||
|
assert.doesNotMatch(preload, /shell:openPath/);
|
||||||
|
assert.doesNotMatch(preload, /shell:showItemInFolder/);
|
||||||
|
assert.match(preload, /shell:openProduced/);
|
||||||
|
assert.match(preload, /shell:openExternal/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('every renderer window is created sandboxed', () => {
|
||||||
|
for (const file of ['app/main.js', 'app/capture.js', 'app/stream-backend.js']) {
|
||||||
|
const src = read(file);
|
||||||
|
const created = src.match(/new BrowserWindow\(/g) || [];
|
||||||
|
const sandboxed = src.match(/sandbox: true/g) || [];
|
||||||
|
assert.ok(created.length > 0, `${file} should create a window`);
|
||||||
|
assert.equal(
|
||||||
|
sandboxed.length,
|
||||||
|
created.length,
|
||||||
|
`${file}: every BrowserWindow must set sandbox: true (${sandboxed.length}/${created.length})`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const { GuideStore, RevisionConflictError } = require('../../core/store');
|
||||||
|
const { makeTmpDir, rmrf, TINY_PNG } = require('./helpers');
|
||||||
|
|
||||||
|
// ---- revisions --------------------------------------------------------------
|
||||||
|
|
||||||
|
test('revisions start at 0 and increment on every save', (t) => {
|
||||||
|
const root = makeTmpDir('store-rev');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
assert.equal(guide.revision, 0);
|
||||||
|
|
||||||
|
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const r0 = store.getStep(guide.guideId, step.stepId).revision;
|
||||||
|
|
||||||
|
const saved1 = store.saveStep(guide.guideId, { ...step, title: 'S1' });
|
||||||
|
assert.equal(saved1.revision, r0 + 1);
|
||||||
|
const saved2 = store.saveStep(guide.guideId, { ...saved1, title: 'S2' });
|
||||||
|
assert.equal(saved2.revision, r0 + 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compare-and-swap: a stale expectedRevision is rejected, not clobbered', (t) => {
|
||||||
|
const root = makeTmpDir('store-cas');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
|
||||||
|
const base = store.getStep(guide.guideId, step.stepId);
|
||||||
|
// A user edit lands first (no expectedRevision -> last-write-wins).
|
||||||
|
store.saveStep(guide.guideId, { ...base, title: 'user edit' });
|
||||||
|
|
||||||
|
// A background writer that read `base` tries to save with the stale revision.
|
||||||
|
assert.throws(
|
||||||
|
() => store.saveStep(guide.guideId, { ...base, title: 'stale background' }, { expectedRevision: base.revision }),
|
||||||
|
(err) => err instanceof RevisionConflictError && err.code === 'STEPFORGE_REVISION_CONFLICT'
|
||||||
|
);
|
||||||
|
// The user edit survived.
|
||||||
|
assert.equal(store.getStep(guide.guideId, step.stepId).title, 'user edit');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compare-and-swap succeeds when the revision still matches', (t) => {
|
||||||
|
const root = makeTmpDir('store-cas-ok');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
const step = store.addStep(guide.guideId, { title: 'S' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const base = store.getStep(guide.guideId, step.stepId);
|
||||||
|
|
||||||
|
const saved = store.saveStep(guide.guideId, { ...base, title: 'ok' }, { expectedRevision: base.revision });
|
||||||
|
assert.equal(saved.title, 'ok');
|
||||||
|
assert.equal(saved.revision, base.revision + 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('guide saves are revision-aware too', (t) => {
|
||||||
|
const root = makeTmpDir('store-guide-cas');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
const base = store.getGuide(guide.guideId);
|
||||||
|
store.saveGuide({ ...base, title: 'first' });
|
||||||
|
assert.throws(
|
||||||
|
() => store.saveGuide({ ...base, title: 'stale' }, { expectedRevision: base.revision }),
|
||||||
|
RevisionConflictError
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('v1 data without a revision field reads as revision 0 and upgrades on save', (t) => {
|
||||||
|
const root = makeTmpDir('store-v1');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
|
||||||
|
// Simulate legacy on-disk data: strip the revision field.
|
||||||
|
const file = path.join(store.guidesDir, guide.guideId, 'guide.json');
|
||||||
|
const raw = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||||
|
delete raw.revision;
|
||||||
|
fs.writeFileSync(file, JSON.stringify(raw));
|
||||||
|
|
||||||
|
const loaded = store.getGuide(guide.guideId);
|
||||||
|
assert.equal(loaded.revision, 0);
|
||||||
|
const saved = store.saveGuide(loaded);
|
||||||
|
assert.equal(saved.revision, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- corruption quarantine --------------------------------------------------
|
||||||
|
|
||||||
|
test('a corrupt guide is quarantined and reported, not silently dropped', (t) => {
|
||||||
|
const root = makeTmpDir('store-quarantine-guide');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const good = store.createGuide({ title: 'Good' });
|
||||||
|
const bad = store.createGuide({ title: 'Bad' });
|
||||||
|
|
||||||
|
// Corrupt the bad guide's JSON.
|
||||||
|
fs.writeFileSync(path.join(store.guidesDir, bad.guideId, 'guide.json'), '{ not valid json');
|
||||||
|
|
||||||
|
const listed = store.listGuides();
|
||||||
|
assert.deepEqual(listed.map((g) => g.guideId), [good.guideId]);
|
||||||
|
|
||||||
|
const report = store.getRecoveryReport();
|
||||||
|
assert.equal(report.length, 1);
|
||||||
|
assert.equal(report[0].kind, 'guide');
|
||||||
|
// The original bytes are preserved in quarantine, not deleted.
|
||||||
|
assert.ok(fs.existsSync(report[0].quarantined));
|
||||||
|
// The bad guide dir is gone from the live library.
|
||||||
|
assert.equal(fs.existsSync(path.join(store.guidesDir, bad.guideId)), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a corrupt step is quarantined and reported', (t) => {
|
||||||
|
const root = makeTmpDir('store-quarantine-step');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
const guide = store.createGuide({ title: 'G' });
|
||||||
|
const good = store.addStep(guide.guideId, { title: 'good' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
const bad = store.addStep(guide.guideId, { title: 'bad' }, TINY_PNG, { width: 1, height: 1 });
|
||||||
|
|
||||||
|
fs.writeFileSync(path.join(store.stepDir(guide.guideId, bad.stepId), 'step.json'), 'nonsense');
|
||||||
|
|
||||||
|
const steps = store.listSteps(guide.guideId);
|
||||||
|
assert.ok(steps.has(good.stepId));
|
||||||
|
assert.equal(steps.has(bad.stepId), false);
|
||||||
|
const report = store.getRecoveryReport();
|
||||||
|
assert.equal(report.some((r) => r.kind === 'step'), true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an empty/in-progress guide directory is not treated as corruption', (t) => {
|
||||||
|
const root = makeTmpDir('store-empty-dir');
|
||||||
|
t.after(() => rmrf(root));
|
||||||
|
const store = new GuideStore(root);
|
||||||
|
// A directory with no guide.json (e.g. mid-create) must be skipped quietly.
|
||||||
|
fs.mkdirSync(path.join(store.guidesDir, 'orphan-dir'), { recursive: true });
|
||||||
|
assert.doesNotThrow(() => store.listGuides());
|
||||||
|
assert.equal(store.getRecoveryReport().length, 0);
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
const { chooseCaptureTrigger, detectLinuxCapabilities } = require('../../app/platform/linux/diagnostics');
|
||||||
|
const platform = require('../../app/platform');
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..');
|
||||||
|
// Strip CR so assertions are robust to CRLF checkouts on Windows CI.
|
||||||
|
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
const exists = (rel) => fs.existsSync(path.join(ROOT, rel));
|
||||||
|
|
||||||
|
// ---- honest trigger decisions ----------------------------------------------
|
||||||
|
|
||||||
|
test('X11 + xinput promises per-click capture with a marker', () => {
|
||||||
|
const t = chooseCaptureTrigger({ os: 'linux', isWayland: false, clickCapture: 'x11-xinput' });
|
||||||
|
assert.equal(t.trigger, 'click');
|
||||||
|
assert.equal(t.coordinates, true);
|
||||||
|
assert.equal(t.marker, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wayland evdev captures per click but never promises coordinates or a marker', () => {
|
||||||
|
const t = chooseCaptureTrigger({ os: 'linux', isWayland: true, clickCapture: 'evdev-wayland' });
|
||||||
|
assert.equal(t.trigger, 'click');
|
||||||
|
assert.equal(t.coordinates, false, 'Wayland exposes no pointer position');
|
||||||
|
assert.equal(t.marker, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Wayland without a click source falls back to the user trigger, honestly', () => {
|
||||||
|
const interval = chooseCaptureTrigger({ os: 'linux', isWayland: true, clickCapture: 'hotkey-or-interval-only' }, 'interval');
|
||||||
|
assert.equal(interval.trigger, 'interval');
|
||||||
|
assert.equal(interval.coordinates, false);
|
||||||
|
assert.match(interval.note, /Wayland does not expose global clicks/i);
|
||||||
|
|
||||||
|
const hotkey = chooseCaptureTrigger({ os: 'linux', isWayland: true, clickCapture: 'hotkey-or-interval-only' }, 'hotkey');
|
||||||
|
assert.equal(hotkey.trigger, 'hotkey');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the platform facade wires the Linux trigger decision from real capabilities', () => {
|
||||||
|
const caps = detectLinuxCapabilities({
|
||||||
|
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
|
||||||
|
hasBinary: () => false,
|
||||||
|
existsSync: () => false,
|
||||||
|
readdirSync: () => [],
|
||||||
|
});
|
||||||
|
const t = platform.chooseCaptureTrigger(caps, 'interval');
|
||||||
|
assert.equal(t.trigger, 'interval');
|
||||||
|
assert.equal(t.marker, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('Windows always reports per-click capture', () => {
|
||||||
|
const t = platform.chooseCaptureTrigger({ os: 'windows' });
|
||||||
|
assert.equal(t.trigger, 'click');
|
||||||
|
assert.equal(t.coordinates, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- least-privilege input access -------------------------------------------
|
||||||
|
|
||||||
|
test('the udev rule grants mouse-only access and excludes keyboards', () => {
|
||||||
|
assert.ok(exists('packaging/linux/common/60-stepforge-input.rules'));
|
||||||
|
const rule = read('packaging/linux/common/60-stepforge-input.rules');
|
||||||
|
assert.match(rule, /ID_INPUT_MOUSE\}=="1"/);
|
||||||
|
assert.match(rule, /ID_INPUT_KEYBOARD\}!="1"/, 'must exclude keyboards');
|
||||||
|
assert.match(rule, /TAG\+="uaccess"/, 'session-scoped ACL, not a permanent group');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the enable script is opt-in and installs the least-privilege rule, not the input group', () => {
|
||||||
|
assert.ok(exists('scripts/linux/enable-click-capture.sh'));
|
||||||
|
const script = read('scripts/linux/enable-click-capture.sh');
|
||||||
|
assert.match(script, /read -r reply/, 'must confirm before installing');
|
||||||
|
assert.match(script, /60-stepforge-input\.rules/, 'installs the least-privilege udev rule');
|
||||||
|
// usermod may only appear in a comment (warning), never as an executed
|
||||||
|
// command. Check command position (line start, optional sudo) so this is
|
||||||
|
// robust to CRLF vs LF line endings across platforms.
|
||||||
|
assert.doesNotMatch(script, /^\s*(sudo\s+)?usermod\b/m, 'must not run the broad input-group command');
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- docs no longer push the broad input group ------------------------------
|
||||||
|
|
||||||
|
test('Linux docs recommend the least-privilege path and warn against the input group', () => {
|
||||||
|
const doc = read('docs/GETTING_STARTED_WITH_LINUX.md');
|
||||||
|
assert.match(doc, /enable-click-capture\.sh/);
|
||||||
|
assert.match(doc, /least-privilege/i);
|
||||||
|
// The broad group is now presented as a warning ("Do not use ..."), not a
|
||||||
|
// recommended step.
|
||||||
|
assert.match(doc, /Do \*\*not\*\* use `sudo usermod/);
|
||||||
|
});
|
||||||