Compare commits

...

12 Commits

Author SHA1 Message Date
undergroundwires
65f121c451 Introduce new TreeView UI component
Key highlights:

- Written from scratch to cater specifically to privacy.sexy's
  needs and requirements.
- The visual look mimics the previous component with minimal changes,
  but its internal code is completely rewritten.
- Lays groundwork for future functionalities like the "expand all"
  button a flat view mode as discussed in #158.
- Facilitates the transition to Vue 3 by omitting the Vue 2.0 dependent
  `liquour-tree` as part of #230.

Improvements and features:

- Caching for quicker node queries.
- Gradual rendering of nodes that introduces a noticable boost in
  performance, particularly during search/filtering.
  - `TreeView` solely governs the check states of branch nodes.

Changes:

- Keyboard interactions now alter the background color to highlight the
  focused item. Previously, it was changing the color of the text.
- Better state management with clear separation of concerns:
  - `TreeView` exclusively manages indeterminate states.
  - `TreeView` solely governs the check states of branch nodes.
  - Introduce transaction pattern to update state in batches to minimize
    amount of events handled.
- Improve keyboard focus, style background instead of foreground. Use
  hover/touch color on keyboard focus.
- `SelectableTree` has been removed. Instead, `TreeView` is now directly
  integrated with `ScriptsTree`.
- `ScriptsTree` has been refactored to incorporate hooks for clearer
  code and separation of duties.
- Adopt Vue-idiomatic bindings instead of keeping a reference of the
  tree component.
- Simplify and change filter event management.
- Abandon global styles in favor of class-scoped styles.
- Use global mixins with descriptive names to clarify indended
  functionality.
2023-09-09 22:26:21 +02:00
undergroundwires
821cc62c4c Change license to AGPLv3 2023-09-08 16:52:41 +02:00
Snowz
4ce327eb6a win: fix disable recent apps revert #211, #248
Add missing space
2023-09-07 00:02:02 +02:00
undergroundwires
4beb1bb574 Introduce retry mechanism for npm install in CI/CD
This commit addresses occasional pipeline failures caused by transient
network errors during dependency installation with `npm ci`. It
centralizes the logic for installing npm dependencies and introduces a
retry mechanism.

The new approach will attempt `npm ci` up to 5 times with a 5-second
interval between each attempt, thereby increasing the resilience of
CI/CD pipelines.

This commit adds a new script `npm-install.js` with `npm run
install-deps` command to centralize npm dependency installation process
throughout the project. Separate testing of scripts to a separate
workflow.

It removes unused `install` dependency from `package.json`.
2023-09-05 13:39:15 +02:00
undergroundwires
0a2a1a026b Refactor build configs and improve CI/CD checks
This commit makes the build process more robust, simplifies
configurations and reduce the risk of incomplete or erroneous
deployments.

- Centralize output directory definitions by introducing
  `dist-dirs.json`.
- Add `verify-build-artifacts` utility to ensure correct build outputs
  and `print-dist-dir` to determine distribution directory.
- Add steps in CI/CD pipeline to verify build artifacts.
- Migrate Electron Builder config from YAML to CJS for capability to
  read JSON.
- Fix `release-site.yaml` failing due to pointing to wrong distribution
  directory, change it to use `print-dist-dir`.
- Improve `check-desktop-runtime-errors` to verify build artifacts for
  more reliable builds. Ensure tests fail and succeed reliably.
- Update `.gitignore` and configure ESLint to use it to define and
  ignore build artifact directories from one place, remove
  `.eslintignore` that does not add anything after this change.
- Keep `"main"` field in `package.json` as `electron-vite` depends on it
  (alex8088/electron-vite#270).
- Improve documentation
2023-09-03 14:50:31 +02:00
undergroundwires
eb096d07e2 Fix memory leaks via auto-unsubscribing and DI
This commit simplifies event handling, providing a unified and robust
way to handle event lifecycling. This way, it fixes events not being
unsubscribed when state is changed.

Introduce a new function in `EventSubscriptionCollection` to remove
existing events and adding new events. This provides an easier to use
API, which leads to code that's easier to understand. It also prevents
potential bugs that may occur due to forgetting to call both functions.
It fixes `TheScriptsMenu` not unregistering events on state change.
Other improvements include:
  - Include a getter to get total amount of registered subcriptions.
    This helps in unit testing.
  - Have nullish checks to prevent potential errors further down the
    execution.
  - Use array instead of rest parameters to increase readability and
    simplify tests.

Ensure `SliderHandler` stops resizes on unmount, unsubscribing from all
events and resetting state to default.

Update `injectionKeys` to do imports as types to avoid circular
dependencies. Simplify importing `injectionKeys` to enable and strict
typings for iterating injection keys.

Add tests covering new behavior.
2023-09-01 18:14:25 +02:00
undergroundwires
19e42c9c52 Refactor and improve external URL checks
- Move external URL checks to its own module under `tests/`. This
  separates them from integration test, addressing long runs and
  frequent failures that led to ignoring test results.
- Move `check-desktop-runtime-errors` to `tests/checks` to keep all
  test-related checks into one directory.
- Replace `ts-node` with `vite` for running
  `check-desktop-runtime-errors` to maintain a consistent execution
  environment across checks.
- Implement a timeout for each fetch call.
- Be nice to external sources, wait 5 seconds before sending another
  request to an URL under same domain. This solves rate-limiting issues.
- Instead of running test on every push/pull request, run them only
  weekly.
- Do not run tests on each commit/PR but only scheduled (weekly) to
  minimize noise.
- Fix URLs are not captured correctly inside backticks or parenthesis.
2023-09-01 00:18:47 +02:00
undergroundwires
f4d86fccfd Fix Windows artifact naming in desktop packaging
- Fix the naming convention in Electron output to align with previous
  artifact naming to not break external/internal URLs.
- In desktop execution tests, make artifact locator logic stricter to
  test regression.
2023-08-30 13:34:30 +02:00
undergroundwires
ad0576a752 Improve desktop runtime execution tests
Test improvements:

- Capture titles for all macOS windows, not just the frontmost.
- Incorporate missing application log files.
- Improve log clarity with enriched context.
- Improve application termination on macOS by reducing grace period.
- Ensure complete application termination on macOS.
- Validate Vue application loading through an initial log.
- Support ignoring environment-specific `stderr` errors.
- Do not fail the test if working directory cannot be deleted.
- Use retry pattern when installing dependencies due to network errors.

Refactorings:

- Migrate the test code to TypeScript.
- Replace deprecated `rmdir` with `rm` for error-resistant directory
  removal.
- Improve sanity checking by shifting from App.vue to Vue bootstrapper.
- Centralize environment variable management with `EnvironmentVariables`
  construct.
- Rename infrastructure/Environment to RuntimeEnvironment for clarity.
- Isolate WindowVariables and SystemOperations from RuntimeEnvironment.
- Inject logging via preloader.
- Correct mislabeled RuntimeSanity tests.

Configuration:

- Introduce `npm run check:desktop` for simplified execution.
- Omit `console.log` override due to `nodeIntegration` restrictions and
  reveal logging functionality using context-bridging.
2023-08-29 16:30:00 +02:00
tromcho
35be05df20 win: fix typo in Defender retention script #213 2023-08-28 13:15:41 +02:00
undergroundwires
dae6d114da linux: use user.js over prefs.js for Firefox #232
Manage Firefox preferences through `user.js` instead of `prefs.js`.
Because of Mozilla's recommendation against direct `prefs.js` edits to
avoid potential profile corruption. Instead, the `user.js` file, if
present, overrides the settings in `prefs.js` at application startup.

Change AddFirefoxPrefs function to update `user.js` and manage
creation/deletion of this file:

1. Handle file creation if `user.js` does not exist.
2. Deletes file if `user.js` becomes empty after reverting settings.

Other changes:

- Improve log messages
- Minimal refactorings
2023-08-27 20:14:10 +02:00
undergroundwires-bot
ecce47fdcd ⬆️ bump everywhere to 0.12.2 2023-08-25 12:46:25 +00:00
331 changed files with 8883 additions and 3273 deletions

View File

@@ -1,2 +0,0 @@
dist/
dist_electron/

View File

@@ -0,0 +1,11 @@
inputs:
working-directory:
required: false
default: '.'
runs:
using: composite
steps:
-
name: Run `npm ci` with retries
shell: bash
run: npm run install-deps -- --ci --root-directory "${{ inputs.working-directory }}"

View File

@@ -27,10 +27,13 @@ jobs:
uses: ./.github/actions/setup-node
-
name: Install dependencies
run: npm ci
uses: ./.github/actions/npm-install-dependencies
-
name: Build
name: Build web
run: npm run build -- --mode ${{ matrix.mode }}
-
name: Verify web build artifacts
run: npm run check:verify-build-artifacts -- --web
build-desktop:
strategy:
@@ -52,30 +55,16 @@ jobs:
uses: ./.github/actions/setup-node
-
name: Install dependencies
run: npm ci
uses: ./.github/actions/npm-install-dependencies
-
name: Prebuild
name: Prebuild desktop
run: npm run electron:prebuild -- --mode ${{ matrix.mode }}
-
name: Build
name: Verify unbundled desktop build artifacts
run: npm run check:verify-build-artifacts -- --electron-unbundled
-
name: Build (bundle and package) desktop application
run: npm run electron:build -- --publish never
create-icons:
strategy:
matrix:
os: [ macos, ubuntu, windows ]
fail-fast: false # Allows to see results from other combinations
runs-on: ${{ matrix.os }}-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Setup node
uses: ./.github/actions/setup-node
-
name: Install dependencies
run: npm ci
-
name: Create icons
run: npm run icons:build
name: Verify bundled desktop build artifacts
run: npm run check:verify-build-artifacts -- --electron-bundled

View File

@@ -6,7 +6,7 @@ on:
pull_request:
jobs:
build-desktop:
run-check:
strategy:
matrix:
os: [ macos, ubuntu, windows ]
@@ -19,6 +19,9 @@ jobs:
-
name: Setup node
uses: ./.github/actions/setup-node
-
name: Install dependencies
uses: ./.github/actions/npm-install-dependencies
-
name: Configure Ubuntu
if: matrix.os == 'ubuntu'
@@ -57,7 +60,9 @@ jobs:
-
name: Test
shell: bash
run: node ./scripts/check-desktop-runtime-errors --screenshot
run: |-
export SCREENSHOT=true
npm run check:desktop
-
name: Upload screenshot
if: always() # Run even if previous step fails

View File

@@ -0,0 +1,22 @@
name: checks.external-urls
on:
schedule:
- cron: '0 0 * * 0' # at 00:00 on every Sunday
jobs:
run-check:
runs-on: ubuntu-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Setup node
uses: ./.github/actions/setup-node
-
name: Install dependencies
uses: ./.github/actions/npm-install-dependencies
-
name: Test
run: npm run check:external-urls

View File

@@ -16,11 +16,15 @@ jobs:
os: [ macos, ubuntu, windows ]
fail-fast: false # Still interested to see results from other combinations
steps:
- name: Checkout
-
name: Checkout
uses: actions/checkout@v2
- name: Setup node
-
name: Setup node
uses: ./.github/actions/setup-node
- name: Install dependencies
run: npm ci
- name: Lint
-
name: Install dependencies
uses: ./.github/actions/npm-install-dependencies
-
name: Lint
run: ${{ matrix.lint-command }}

55
.github/workflows/checks.scripts.yaml vendored Normal file
View File

@@ -0,0 +1,55 @@
name: checks.scripts
on:
push:
pull_request:
jobs:
icons-build:
runs-on: ${{ matrix.os }}-latest
strategy:
matrix:
os: [ macos, ubuntu, windows ]
fail-fast: false # Still interested to see results from other combinations
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Setup node
uses: ./.github/actions/setup-node
-
name: Install dependencies
uses: ./.github/actions/npm-install-dependencies
-
name: Create icons
run: npm run icons:build
install-deps:
runs-on: ${{ matrix.os }}-latest
strategy:
matrix:
install-deps-before: [true, false]
install-command:
- npm run install-deps
- npm run install-deps -- --no-errors
- npm run install-deps -- --ci
- npm run install-deps -- --fresh --non-deterministic
- npm run install-deps -- --fresh
- npm run install-deps -- --non-deterministic
os: [ macos, ubuntu, windows ]
fail-fast: false # Still interested to see results from other combinations
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Setup node
uses: ./.github/actions/setup-node
-
name: Install dependencies
if: matrix.install-deps-before == true
uses: ./.github/actions/npm-install-dependencies
-
name: Run install-deps
run: ${{ matrix.install-command }}

View File

@@ -26,7 +26,7 @@ jobs:
uses: ./.github/actions/setup-node
-
name: Install dependencies
run: npm ci
uses: ./.github/actions/npm-install-dependencies
-
name: Run unit tests
run: npm run test:unit

View File

@@ -84,8 +84,9 @@ jobs:
uses: ./app/.github/actions/setup-node
-
name: "App: Install dependencies"
run: npm ci
working-directory: app
uses: ./.github/actions/npm-install-dependencies
with:
working-directory: app
-
name: "App: Run unit tests"
run: npm run test:unit
@@ -94,11 +95,21 @@ jobs:
name: "App: Build"
run: npm run build
working-directory: app
-
name: "App: Verify web build artifacts"
run: npm run check:verify-build-artifacts -- --web
working-directory: app
-
name: "App: Deploy to S3"
shell: bash
run: >-
declare web_output_dir
if ! web_output_dir=$(cd app && node scripts/print-dist-dir.js --web); then
echo 'Error: Could not determine distribution directory.'
exit 1
fi
bash "aws/scripts/deploy/deploy-to-s3.sh" \
--folder app/dist \
--folder "${web_output_dir}" \
--web-stack-name privacysexy-web-stack --web-stack-s3-name-output-name S3BucketName \
--storage-class ONEZONE_IA \
--role-arn ${{secrets.AWS_S3_SITE_DEPLOYMENT_ROLE_ARN}} \

View File

@@ -20,7 +20,7 @@ jobs:
uses: ./.github/actions/setup-node
-
name: Install dependencies
run: npm ci
uses: ./.github/actions/npm-install-dependencies
-
name: Run e2e tests
run: npm run test:cy:run

View File

@@ -22,7 +22,7 @@ jobs:
uses: ./.github/actions/setup-node
-
name: Install dependencies
run: npm ci
uses: ./.github/actions/npm-install-dependencies
-
name: Run integration tests
run: npm run test:integration

View File

@@ -20,7 +20,7 @@ jobs:
uses: ./.github/actions/setup-node
-
name: Install dependencies
run: npm ci
uses: ./.github/actions/npm-install-dependencies
-
name: Run unit tests
run: npm run test:unit

6
.gitignore vendored
View File

@@ -1,7 +1,5 @@
node_modules
dist/
/dist-*/
.vs
.vscode/**/*
!.vscode/extensions.json
#Electron-builder output
/dist_electron
!.vscode/extensions.json

View File

@@ -1,5 +1,19 @@
# Changelog
## 0.12.2 (2023-08-25)
* Add automated checks for desktop app runtime #233 | [04b3133](https://github.com/undergroundwires/privacy.sexy/commit/04b3133500485d0d278a81a177a1677134131405)
* win: fix automatic updates revert #234 | [0873769](https://github.com/undergroundwires/privacy.sexy/commit/08737698c2283bdf535d1611a730031ebfc7c0df)
* Migrate unit/integration tests to Vitest with Vite | [5f11c8d](https://github.com/undergroundwires/privacy.sexy/commit/5f11c8d98f782dd7c77f27649a1685fb7bd06e13)
* Remove Vue ESLint plugin for Vite compatibility | [6e40edd](https://github.com/undergroundwires/privacy.sexy/commit/6e40edd3f8a063c1b7482c27d8368e14c2fbcfbf)
* Migrate web builds from Vue CLI to Vite | [7365905](https://github.com/undergroundwires/privacy.sexy/commit/736590558be51a09435bb87e78b6655e8533bc2e)
* Migrate Cypress (E2E) tests to Vite and TypeScript | [ec98d84](https://github.com/undergroundwires/privacy.sexy/commit/ec98d8417f779fa818ccdda6bb90f521e1738002)
* Migrate to `electron-vite` and `electron-builder` | [75c9b51](https://github.com/undergroundwires/privacy.sexy/commit/75c9b51bf2d1dc7269adfd7b5ed71acfb5031299)
* Fix searching/filtering bugs #235 | [62f8bfa](https://github.com/undergroundwires/privacy.sexy/commit/62f8bfac2f481c93598fe19a51594769f522d684)
* Improve desktop security by isolating Electron | [e9e0001](https://github.com/undergroundwires/privacy.sexy/commit/e9e0001ef845fa6935c59a4e20a89aac9e71756a)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.1...0.12.2)
## 0.12.1 (2023-08-17)
* Transition to eslint-config-airbnb-with-typescript | [ff84f56](https://github.com/undergroundwires/privacy.sexy/commit/ff84f5676e496dd7ec5b3599e34ec9627d181ea2)

141
LICENSE
View File

@@ -1,5 +1,5 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
@@ -7,17 +7,15 @@
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users. We, the Free Software Foundation, use the
GNU General Public License for most of our software; it applies also to
any other work released this way by its authors. You can apply it to
your programs, too.
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
@@ -26,44 +24,34 @@ them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
To protect your rights, we need to prevent others from denying you
these rights or asking you to surrender the rights. Therefore, you have
certain responsibilities if you distribute copies of the software, or if
you modify it: responsibilities to respect the freedom of others.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
For example, if you distribute copies of such a program, whether
gratis or for a fee, you must pass on to the recipients the same
freedoms that you received. You must make sure that they, too, receive
or can get the source code. And you must show them these terms so they
know their rights.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
Developers that use the GNU GPL protect your rights with two steps:
(1) assert copyright on the software, and (2) offer you this License
giving you legal permission to copy, distribute and/or modify it.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
For the developers' and authors' protection, the GPL clearly explains
that there is no warranty for this free software. For both users' and
authors' sake, the GPL requires that modified versions be marked as
changed, so that their problems will not be attributed erroneously to
authors of previous versions.
Some devices are designed to deny users access to install or run
modified versions of the software inside them, although the manufacturer
can do so. This is fundamentally incompatible with the aim of
protecting users' freedom to change the software. The systematic
pattern of such abuse occurs in the area of products for individuals to
use, which is precisely where it is most unacceptable. Therefore, we
have designed this version of the GPL to prohibit the practice for those
products. If such problems arise substantially in other domains, we
stand ready to extend this provision to those domains in future versions
of the GPL, as needed to protect the freedom of users.
Finally, every program is threatened constantly by software patents.
States should not allow patents to restrict development and use of
software on general-purpose computers, but in those that do, we wish to
avoid the special danger that patents applied to a free program could
make it effectively proprietary. To prevent this, the GPL assures that
patents cannot be used to render the program non-free.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
@@ -72,7 +60,7 @@ modification follow.
0. Definitions.
"This License" refers to version 3 of the GNU General Public License.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
@@ -549,35 +537,45 @@ to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Use with the GNU Affero General Public License.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU Affero General Public License into a single
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the special requirements of the GNU Affero General Public License,
section 13, concerning interaction through a network will apply to the
combination as such.
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU General Public License from time to time. Such new versions will
be similar in spirit to the present version, but may differ in detail to
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU General
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU General Public License, you may choose any version ever published
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU General Public License can be used, that proxy's
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
@@ -635,40 +633,29 @@ the "copyright" line and a pointer to where the full notice is found.
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU General Public License for more details.
GNU Affero General Public License for more details.
You should have received a copy of the GNU General Public License
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
<program> Copyright (C) <year> <name of author>
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.
The hypothetical commands `show w' and `show c' should show the appropriate
parts of the General Public License. Of course, your program's commands
might be different; for a GUI interface, you would use an "about box".
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU GPL, see
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.
The GNU General Public License does not permit incorporating your program
into proprietary programs. If your program is a subroutine library, you
may consider it more useful to permit linking proprietary applications with
the library. If this is what you want to do, use the GNU Lesser General
Public License instead of this License. But first, please read
<https://www.gnu.org/licenses/why-not-lgpl.html>.

View File

@@ -76,6 +76,18 @@
src="https://github.com/undergroundwires/privacy.sexy/workflows/checks.desktop-runtime-errors/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.scripts.yaml" target="_blank" rel="noopener noreferrer">
<img
alt="Status of script checks"
src="https://github.com/undergroundwires/privacy.sexy/workflows/checks.scripts/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.external-urls.yaml" target="_blank" rel="noopener noreferrer">
<img
alt="Status of external URL checks"
src="https://github.com/undergroundwires/privacy.sexy/workflows/checks.external-urls/badge.svg"
/>
</a>
<!-- Release -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml" target="_blank" rel="noopener noreferrer">

5
dist-dirs.json Normal file
View File

@@ -0,0 +1,5 @@
{
"electronUnbundled": "dist-electron-unbundled",
"electronBundled": "dist-electron-bundled",
"web": "dist-web"
}

View File

@@ -5,14 +5,16 @@ Before your commit, a good practice is to:
1. [Run unit tests](#testing)
2. [Lint your code](#linting)
You could run other types of tests as well, but they may take longer time and overkill for your changes. Automated actions executes the tests for a pull request or change in the main branch. See [ci-cd.md](./ci-cd.md) for more information.
You could run other types of tests as well, but they may take longer time and overkill for your changes.
Automated actions are set up to execute these tests as necessary.
See [ci-cd.md](./ci-cd.md) for more information.
## Commands
### Prerequisites
- Install node >15.x.
- Install dependencies using `npm install`.
- Install Node >16.x.
- Install dependencies using `npm install` (or [`npm run install-deps`](#utility-scripts) for more options).
### Testing
@@ -21,6 +23,10 @@ You could run other types of tests as well, but they may take longer time and ov
- Run end-to-end (e2e) tests:
- `npm run test:cy:open`: Run tests interactively using the development server with hot-reloading.
- `npm run test:cy:run`: Run tests on the production build in a headless mode.
- Run checks:
- `npm run check:desktop`: Run runtime checks for packaged desktop applications ([README.md](./../tests/checks/desktop-runtime-errors/check-desktop-runtime-errors/README.md)).
- You can set environment variables active its flags such as `BUILD=true SCREENSHOT=true npm run check:desktop`
- `npm run check:external-urls`: Test whether external URLs used in applications are alive.
📖 Read more about testing in [tests](./tests.md).
@@ -61,13 +67,26 @@ You could run other types of tests as well, but they may take longer time and ov
- Build desktop application: `npm run electron:build`
- (Re)create icons (see [documentation](../img/README.md)): `npm run create-icons`
### Utility Scripts
### Scripts
- Run fresh NPM install: [`./scripts/fresh-npm-install.sh`](../scripts/fresh-npm-install.sh)
- This script provides a clean NPM install, removing existing node modules and optionally the package-lock.json (when run with -n), then installs dependencies and runs unit tests.
- Configure VSCode: [`./scripts/configure-vscode.sh`](../scripts/configure-vscode.sh)
📖 For detailed options and behavior for any of the following scripts, please refer to the script file itself.
#### Utility scripts
- [**`npm run install-deps [-- <options>]`**](../scripts/npm-install.js):
- Manages NPM dependency installation, it offers capabilities like doing a fresh install, retries on network errors, and other features.
- For example, you can run `npm run install-deps -- --fresh` to do clean installation of dependencies.
- [**`./scripts/configure-vscode.sh`**](../scripts/configure-vscode.sh):
- This script checks and sets the necessary configurations for VSCode in `settings.json` file.
#### Automation scripts
- [**`node scripts/print-dist-dir.js [-- <options>]`**](../scripts/print-dist-dir.js):
- Determines the absolute path of a distribution directory based on CLI arguments and outputs its absolute path.
- Primarily used by automation scripts.
- [**`npm run check:verify-build-artifacts [-- <options>]`**](../scripts/verify-build-artifacts.js):
- Verifies the existence and content of build artifacts. Useful for ensuring that the build process is generating the expected output.
## Recommended extensions
You should use EditorConfig to follow project style.

View File

@@ -14,12 +14,12 @@ The presentation layer uses an event-driven architecture for bidirectional react
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins.
- [**`components/`**](./../src/presentation/components/): Contains Vue components and helpers.
- [**`Shared/`**](./../src/presentation/components/Shared): Contains shared Vue components and helpers.
- [**`hooks`**](../src/presentation/components/Shared/Hooks): Hooks used by components through [dependency injection](#dependency-injections).
- [**`Hooks`**](../src/presentation/components/Shared/Hooks): Hooks used by components through [dependency injection](#dependency-injections).
- [**`/public/`**](../src/presentation/public/): Contains static assets.
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets processed by Vite.
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts.
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles.
- [**`components/`**](./../src/presentation/assets/styles/components): Contains styles for Vue components.
- [**`components/`**](./../src/presentation/assets/styles/components): Contains styles coupled to Vue components.
- [**`vendors-extensions/`**](./../src/presentation/assets/styles/third-party-extensions): Contains styles for third-party components.
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Main Sass file, imported by other components as single entrypoint.
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.
@@ -91,7 +91,14 @@ Shared components include:
Desktop builds uses `electron-vite` to bundle the code, and `electron-builder` to build and publish the packages.
## Sass naming convention
## Styles
### Style location
- **Global styles**: The [`assets/styles/`](#structure) directory is reserved for styles that have a broader scope, affecting multiple components or entire layouts. They are generic and should not be tightly coupled to a specific component's functionality.
- **Component-specific styles**: Styles closely tied to a particular component's functionality or appearance should reside near the component they are used by. This makes it easier to locate and modify styles when working on a specific component.
### Sass naming convention
- Use lowercase for variables/functions/mixins, e.g.:
- Variable: `$variable: value;`

43
electron-builder.cjs Normal file
View File

@@ -0,0 +1,43 @@
/* eslint-disable no-template-curly-in-string */
const { join } = require('path');
const { electronBundled, electronUnbundled } = require('./dist-dirs.json');
module.exports = {
// Common options
publish: {
provider: 'github',
vPrefixedTagName: false, // default: true
releaseType: 'release', // default: draft
},
directories: {
output: electronBundled,
},
extraMetadata: {
main: join(electronUnbundled, 'main/index.cjs'), // do not `path.resolve`, it expects a relative path
},
// Windows
win: {
target: 'nsis',
},
nsis: {
artifactName: '${name}-Setup-${version}.${ext}',
},
// Linux
linux: {
target: 'AppImage',
},
appImage: {
artifactName: '${name}-${version}.${ext}',
},
// macOS
mac: {
target: 'dmg',
},
dmg: {
artifactName: '${name}-${version}.${ext}',
},
};

View File

@@ -1,31 +0,0 @@
# -------
# Windows
# -------
win:
target: nsis
nsis:
artifactName: ${name}-${version}-Setup.${ext}
# -----
# Linux
# -----
linux:
target: AppImage
appImage:
artifactName: ${name}-${version}.${ext}
# -----
# macOS
# -----
mac:
target: dmg
dmg:
artifactName: ${name}-${version}.${ext}
# ----------------
# Publish options
# ----------------
publish:
provider: 'github'
vPrefixedTagName: false # default: true
releaseType: release # default: draft

View File

@@ -3,11 +3,12 @@ import { mergeConfig, UserConfig } from 'vite';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import { getAliasesFromTsConfig, getClientEnvironmentVariables } from './vite-config-helper';
import { createVueConfig } from './vite.config';
import distDirs from './dist-dirs.json' assert { type: 'json' };
const MAIN_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/main/index.ts');
const PRELOAD_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/preload/index.ts');
const WEB_INDEX_HTML_PATH = resolvePathFromProjectRoot('src/presentation/index.html');
const DIST_DIR = resolvePathFromProjectRoot('dist_electron/');
const DIST_DIR = resolvePathFromProjectRoot(distDirs.electronUnbundled);
export default defineConfig({
main: getSharedElectronConfig({

29
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "privacy.sexy",
"version": "0.12.1",
"version": "0.12.2",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "privacy.sexy",
"version": "0.12.1",
"version": "0.12.2",
"hasInstallScript": true,
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0",
@@ -19,8 +19,6 @@
"cross-fetch": "^4.0.0",
"electron-progressbar": "^2.1.0",
"file-saver": "^2.0.5",
"install": "^0.13.0",
"liquor-tree": "^0.2.70",
"markdown-it": "^13.0.1",
"npm": "^9.8.1",
"v-tooltip": "2.1.3",
@@ -9425,14 +9423,6 @@
"node": ">=10"
}
},
"node_modules/install": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/install/-/install-0.13.0.tgz",
"integrity": "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA==",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/internal-slot": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz",
@@ -10771,11 +10761,6 @@
"uc.micro": "^1.0.1"
}
},
"node_modules/liquor-tree": {
"version": "0.2.70",
"resolved": "https://registry.npmjs.org/liquor-tree/-/liquor-tree-0.2.70.tgz",
"integrity": "sha512-5CiMlDVmuveYwwc27mYe1xZ3J4aHhZBErUhIp9ov4v4wIBso+s5JAByOOit4iOCMCQ5ODd8VggbKymzZREYbBQ=="
},
"node_modules/listr2": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",
@@ -28223,11 +28208,6 @@
"integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==",
"dev": true
},
"install": {
"version": "0.13.0",
"resolved": "https://registry.npmjs.org/install/-/install-0.13.0.tgz",
"integrity": "sha512-zDml/jzr2PKU9I8J/xyZBQn8rPCAY//UOYNmR01XwNwyfhEWObo2SWfSl1+0tm1u6PhxLwDnfsT/6jB7OUxqFA=="
},
"internal-slot": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.5.tgz",
@@ -29256,11 +29236,6 @@
"uc.micro": "^1.0.1"
}
},
"liquor-tree": {
"version": "0.2.70",
"resolved": "https://registry.npmjs.org/liquor-tree/-/liquor-tree-0.2.70.tgz",
"integrity": "sha512-5CiMlDVmuveYwwc27mYe1xZ3J4aHhZBErUhIp9ov4v4wIBso+s5JAByOOit4iOCMCQ5ODd8VggbKymzZREYbBQ=="
},
"listr2": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/listr2/-/listr2-3.14.0.tgz",

View File

@@ -1,27 +1,31 @@
{
"name": "privacy.sexy",
"version": "0.12.1",
"version": "0.12.2",
"private": true,
"slogan": "Now you have the choice",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
"author": "undergroundwires",
"type": "module",
"main": "./dist-electron-unbundled/main/index.cjs",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test:unit": "vitest run --dir tests/unit",
"test:integration": "vitest run --dir tests/integration",
"test:e2e": "vue-cli-service test:e2e",
"test:cy:run": "start-server-and-test \"vite build && vite preview --port 7070\" http://localhost:7070 \"cypress run --config baseUrl=http://localhost:7070\"",
"test:cy:open": "start-server-and-test \"vite --port 7070 --mode production\" http://localhost:7070 \"cypress open --config baseUrl=http://localhost:7070\"",
"lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml",
"install-deps": "node scripts/npm-install.js",
"icons:build": "node scripts/logo-update.js",
"check:desktop": "vitest run --dir tests/checks/desktop-runtime-errors --environment node",
"check:external-urls": "vitest run --dir tests/checks/external-urls --environment node",
"check:verify-build-artifacts": "node scripts/verify-build-artifacts",
"electron:dev": "electron-vite dev",
"electron:preview": "electron-vite preview",
"electron:prebuild": "electron-vite build",
"electron:build": "electron-builder",
"lint:eslint": "eslint .",
"lint:eslint": "eslint . --ignore-path .gitignore",
"lint:md": "markdownlint **/*.md --ignore node_modules",
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
"lint:md:relative-urls": "remark . --frail --use remark-validate-links",
@@ -29,7 +33,6 @@
"postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps"
},
"main": "./dist_electron/main/index.cjs",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-brands-svg-icons": "^6.4.0",
@@ -41,8 +44,6 @@
"cross-fetch": "^4.0.0",
"electron-progressbar": "^2.1.0",
"file-saver": "^2.0.5",
"install": "^0.13.0",
"liquor-tree": "^0.2.70",
"markdown-it": "^13.0.1",
"npm": "^9.8.1",
"v-tooltip": "2.1.3",

View File

@@ -1,10 +0,0 @@
require('@rushstack/eslint-patch/modern-module-resolution.js');
module.exports = {
env: {
node: true,
},
rules: {
'import/extensions': ['error', 'always'],
},
};

View File

@@ -1,55 +0,0 @@
import { unlink, readFile } from 'fs/promises';
import { join } from 'path';
import { log, die, LOG_LEVELS } from '../utils/log.js';
import { exists } from '../utils/io.js';
import { SUPPORTED_PLATFORMS, CURRENT_PLATFORM } from '../utils/platform.js';
import { getAppName } from '../utils/npm.js';
export async function clearAppLogFile(projectDir) {
if (!projectDir) { throw new Error('missing project directory'); }
const logPath = await determineLogPath(projectDir);
if (!logPath || !await exists(logPath)) {
log(`Skipping clearing logs, log file does not exist: ${logPath}.`);
return;
}
try {
await unlink(logPath);
log(`Successfully cleared the log file at: ${logPath}.`);
} catch (error) {
die(`Failed to clear the log file at: ${logPath}. Reason: ${error}`);
}
}
export async function readAppLogFile(projectDir) {
if (!projectDir) { throw new Error('missing project directory'); }
const logPath = await determineLogPath(projectDir);
if (!logPath || !await exists(logPath)) {
log(`No log file at: ${logPath}`, LOG_LEVELS.WARN);
return undefined;
}
const logContent = await readLogFile(logPath);
return logContent;
}
async function determineLogPath(projectDir) {
if (!projectDir) { throw new Error('missing project directory'); }
const appName = await getAppName(projectDir);
if (!appName) {
die('App name not found.');
}
const logFilePaths = {
[SUPPORTED_PLATFORMS.MAC]: () => join(process.env.HOME, 'Library', 'Logs', appName, 'main.log'),
[SUPPORTED_PLATFORMS.LINUX]: () => join(process.env.HOME, '.config', appName, 'logs', 'main.log'),
[SUPPORTED_PLATFORMS.WINDOWS]: () => join(process.env.USERPROFILE, 'AppData', 'Roaming', appName, 'logs', 'main.log'),
};
const logFilePath = logFilePaths[CURRENT_PLATFORM]?.();
if (!logFilePath) {
log(`Cannot determine log path, unsupported OS: ${CURRENT_PLATFORM}`, LOG_LEVELS.WARN);
}
return logFilePath;
}
async function readLogFile(logFilePath) {
const content = await readFile(logFilePath, 'utf-8');
return content?.trim().length > 0 ? content : undefined;
}

View File

@@ -1,126 +0,0 @@
import { splitTextIntoLines, indentText } from '../utils/text.js';
import { die } from '../utils/log.js';
import { readAppLogFile } from './app-logs.js';
const ELECTRON_CRASH_TITLE = 'Error'; // Used by electron for early crashes
const LOG_ERROR_MARKER = '[error]'; // from electron-log
const EXPECTED_LOG_MARKERS = [
'[WINDOW_INIT]',
'[PRELOAD_INIT]',
'[APP_MOUNT_INIT]',
];
export async function checkForErrors(stderr, windowTitles, projectDir) {
if (!projectDir) { throw new Error('missing project directory'); }
const errors = await gatherErrors(stderr, windowTitles, projectDir);
if (errors.length) {
die(formatErrors(errors));
}
}
async function gatherErrors(stderr, windowTitles, projectDir) {
if (!projectDir) { throw new Error('missing project directory'); }
const logContent = await readAppLogFile(projectDir);
return [
verifyStdErr(stderr),
verifyApplicationLogsExist(logContent),
...EXPECTED_LOG_MARKERS.map((marker) => verifyLogMarkerExistsInLogs(logContent, marker)),
verifyWindowTitle(windowTitles),
verifyErrorsInLogs(logContent),
].filter(Boolean);
}
function formatErrors(errors) {
if (!errors || !errors.length) { throw new Error('missing errors'); }
return [
'Errors detected during execution:',
...errors.map(
(error) => formatError(error),
),
].join('\n---\n');
}
function formatError(error) {
if (!error) { throw new Error('missing error'); }
if (!error.reason) { throw new Error(`missing reason, error (${typeof error}): ${JSON.stringify(error)}`); }
let message = `Reason: ${indentText(error.reason, 1)}`;
if (error.description) {
message += `\nDescription:\n${indentText(error.description, 2)}`;
}
return message;
}
function verifyApplicationLogsExist(logContent) {
if (!logContent || !logContent.length) {
return describeError(
'Missing application logs',
'Application logs are empty not were not found.',
);
}
return undefined;
}
function verifyLogMarkerExistsInLogs(logContent, marker) {
if (!marker) {
throw new Error('missing marker');
}
if (!logContent?.includes(marker)) {
return describeError(
'Incomplete application logs',
`Missing identifier "${marker}" in application logs.`,
);
}
return undefined;
}
function verifyWindowTitle(windowTitles) {
const errorTitles = windowTitles.filter(
(title) => title.toLowerCase().includes(ELECTRON_CRASH_TITLE),
);
if (errorTitles.length) {
return describeError(
'Unexpected window title',
'One or more window titles suggest an error occurred in the application:'
+ `\nError Titles: ${errorTitles.join(', ')}`
+ `\nAll Titles: ${windowTitles.join(', ')}`,
);
}
return undefined;
}
function verifyStdErr(stderrOutput) {
if (stderrOutput && stderrOutput.length > 0) {
return describeError(
'Standard error stream (`stderr`) is not empty.',
stderrOutput,
);
}
return undefined;
}
function verifyErrorsInLogs(logContent) {
if (!logContent || !logContent.length) {
return undefined;
}
const logLines = getNonEmptyLines(logContent)
.filter((line) => line.includes(LOG_ERROR_MARKER));
if (!logLines.length) {
return undefined;
}
return describeError(
'Application log file',
logLines.join('\n'),
);
}
function describeError(reason, description) {
return {
reason,
description: `${description}\n\nThis might indicate an early crash or significant runtime issue.`,
};
}
function getNonEmptyLines(text) {
return splitTextIntoLines(text)
.filter((line) => line?.trim().length > 0);
}

View File

@@ -1,34 +0,0 @@
import { access, chmod } from 'fs/promises';
import { constants } from 'fs';
import { findSingleFileByExtension } from '../../utils/io.js';
import { log } from '../../utils/log.js';
export async function prepareLinuxApp(desktopDistPath) {
const { absolutePath: appFile } = await findSingleFileByExtension(
'AppImage',
desktopDistPath,
);
await makeExecutable(appFile);
return {
appExecutablePath: appFile,
};
}
async function makeExecutable(appFile) {
if (!appFile) { throw new Error('missing file'); }
if (await isExecutable(appFile)) {
log('AppImage is already executable.');
return;
}
log('Making it executable...');
await chmod(appFile, 0o755);
}
async function isExecutable(file) {
try {
await access(file, constants.X_OK);
return true;
} catch {
return false;
}
}

View File

@@ -1,38 +0,0 @@
import { mkdtemp, rmdir } from 'fs/promises';
import { join } from 'path';
import { tmpdir } from 'os';
import { findSingleFileByExtension, exists } from '../../utils/io.js';
import { log, die } from '../../utils/log.js';
import { runCommand } from '../../utils/run-command.js';
export async function prepareWindowsApp(desktopDistPath) {
const workdir = await mkdtemp(join(tmpdir(), 'win-nsis-installation-'));
if (await exists(workdir)) {
log(`Temporary directory ${workdir} already exists, cleaning up...`);
await rmdir(workdir, { recursive: true });
}
const { appExecutablePath } = await installNsis(workdir, desktopDistPath);
return {
appExecutablePath,
cleanup: async () => {
log(`Cleaning up working directory ${workdir}...`);
await rmdir(workdir, { recursive: true });
},
};
}
async function installNsis(installationPath, desktopDistPath) {
const { absolutePath: installerPath } = await findSingleFileByExtension('exe', desktopDistPath);
log(`Silently installing contents of ${installerPath} to ${installationPath}...`);
const { error } = await runCommand(`"${installerPath}" /S /D=${installationPath}`);
if (error) {
die(`Failed to install.\n${error}`);
}
const { absolutePath: appExecutablePath } = await findSingleFileByExtension('exe', installationPath);
return {
appExecutablePath,
};
}

View File

@@ -1,20 +0,0 @@
import { log } from './utils/log.js';
const PROCESS_ARGUMENTS = process.argv.slice(2);
export const COMMAND_LINE_FLAGS = Object.freeze({
FORCE_REBUILD: '--build',
TAKE_SCREENSHOT: '--screenshot',
});
export function logCurrentArgs() {
if (!PROCESS_ARGUMENTS.length) {
log('No additional arguments provided.');
return;
}
log(`Arguments: ${PROCESS_ARGUMENTS.join(', ')}`);
}
export function hasCommandLineFlag(flag) {
return PROCESS_ARGUMENTS.includes(flag);
}

View File

@@ -1,7 +0,0 @@
import { join } from 'path';
export const DESKTOP_BUILD_COMMAND = 'npm run electron:prebuild && npm run electron:build -- --publish never';
export const PROJECT_DIR = process.cwd();
export const DESKTOP_DIST_PATH = join(PROJECT_DIR, 'dist');
export const APP_EXECUTION_DURATION_IN_SECONDS = 60; // Long enough for CI runners
export const SCREENSHOT_PATH = join(PROJECT_DIR, 'screenshot.png');

View File

@@ -1,3 +0,0 @@
import { main } from './main.js';
await main();

View File

@@ -1,68 +0,0 @@
import { logCurrentArgs, COMMAND_LINE_FLAGS, hasCommandLineFlag } from './cli-args.js';
import { log, die } from './utils/log.js';
import { ensureNpmProjectDir, npmInstall, npmBuild } from './utils/npm.js';
import { clearAppLogFile } from './app/app-logs.js';
import { checkForErrors } from './app/check-for-errors.js';
import { runApplication } from './app/runner.js';
import { CURRENT_PLATFORM, SUPPORTED_PLATFORMS } from './utils/platform.js';
import { prepareLinuxApp } from './app/extractors/linux.js';
import { prepareWindowsApp } from './app/extractors/windows.js';
import { prepareMacOsApp } from './app/extractors/macos.js';
import {
DESKTOP_BUILD_COMMAND,
PROJECT_DIR,
DESKTOP_DIST_PATH,
APP_EXECUTION_DURATION_IN_SECONDS,
SCREENSHOT_PATH,
} from './config.js';
export async function main() {
logCurrentArgs();
await ensureNpmProjectDir(PROJECT_DIR);
await npmInstall(PROJECT_DIR);
await npmBuild(
PROJECT_DIR,
DESKTOP_BUILD_COMMAND,
DESKTOP_DIST_PATH,
hasCommandLineFlag(COMMAND_LINE_FLAGS.FORCE_REBUILD),
);
await clearAppLogFile(PROJECT_DIR);
const {
stderr, stdout, isCrashed, windowTitles,
} = await extractAndRun();
if (stdout) {
log(`Output (stdout) from application execution:\n${stdout}`);
}
if (isCrashed) {
die('The application encountered an error during its execution.');
}
await checkForErrors(stderr, windowTitles, PROJECT_DIR);
log('🥳🎈 Success! Application completed without any runtime errors.');
process.exit(0);
}
async function extractAndRun() {
const extractors = {
[SUPPORTED_PLATFORMS.MAC]: () => prepareMacOsApp(DESKTOP_DIST_PATH),
[SUPPORTED_PLATFORMS.LINUX]: () => prepareLinuxApp(DESKTOP_DIST_PATH),
[SUPPORTED_PLATFORMS.WINDOWS]: () => prepareWindowsApp(DESKTOP_DIST_PATH),
};
const extractor = extractors[CURRENT_PLATFORM];
if (!extractor) {
throw new Error(`Platform not supported: ${CURRENT_PLATFORM}`);
}
const { appExecutablePath, cleanup } = await extractor();
try {
return await runApplication(
appExecutablePath,
APP_EXECUTION_DURATION_IN_SECONDS,
hasCommandLineFlag(COMMAND_LINE_FLAGS.TAKE_SCREENSHOT),
SCREENSHOT_PATH,
);
} finally {
if (cleanup) {
log('Cleaning up post-execution resources...');
await cleanup();
}
}
}

View File

@@ -1,48 +0,0 @@
import { extname, join } from 'path';
import { readdir, access } from 'fs/promises';
import { constants } from 'fs';
import { log, die, LOG_LEVELS } from './log.js';
export async function findSingleFileByExtension(extension, directory) {
if (!directory) { throw new Error('Missing directory'); }
if (!extension) { throw new Error('Missing file extension'); }
if (!await exists(directory)) {
die(`Directory does not exist: ${directory}`);
return [];
}
const directoryContents = await readdir(directory);
const foundFileNames = directoryContents.filter((file) => extname(file) === `.${extension}`);
const withoutUninstaller = foundFileNames.filter(
(fileName) => !fileName.toLowerCase().includes('uninstall'), // NSIS build has `Uninstall {app-name}.exe`
);
if (!withoutUninstaller.length) {
die(`No ${extension} found in ${directory} directory.`);
}
if (withoutUninstaller.length > 1) {
log(`Found multiple ${extension} files: ${withoutUninstaller.join(', ')}. Using first occurrence`, LOG_LEVELS.WARN);
}
return {
absolutePath: join(directory, withoutUninstaller[0]),
};
}
export async function exists(path) {
if (!path) { throw new Error('Missing path'); }
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
export async function isDirMissingOrEmpty(dir) {
if (!dir) { throw new Error('Missing directory'); }
if (!await exists(dir)) {
return true;
}
const contents = await readdir(dir);
return contents.length === 0;
}

View File

@@ -1,39 +0,0 @@
export const LOG_LEVELS = Object.freeze({
INFO: 'INFO',
WARN: 'WARN',
ERROR: 'ERROR',
});
export function log(message, level = LOG_LEVELS.INFO) {
const timestamp = new Date().toISOString();
const config = LOG_LEVEL_CONFIG[level] || LOG_LEVEL_CONFIG[LOG_LEVELS.INFO];
const formattedMessage = `[${timestamp}][${config.color}${level}${COLOR_CODES.RESET}] ${message}`;
config.method(formattedMessage);
}
export function die(message) {
log(message, LOG_LEVELS.ERROR);
process.exit(1);
}
const COLOR_CODES = {
RESET: '\x1b[0m',
LIGHT_RED: '\x1b[91m',
YELLOW: '\x1b[33m',
LIGHT_BLUE: '\x1b[94m',
};
const LOG_LEVEL_CONFIG = {
[LOG_LEVELS.INFO]: {
color: COLOR_CODES.LIGHT_BLUE,
method: console.log,
},
[LOG_LEVELS.WARN]: {
color: COLOR_CODES.YELLOW,
method: console.warn,
},
[LOG_LEVELS.ERROR]: {
color: COLOR_CODES.LIGHT_RED,
method: console.error,
},
};

View File

@@ -1,87 +0,0 @@
import { join } from 'path';
import { rmdir, readFile } from 'fs/promises';
import { exists, isDirMissingOrEmpty } from './io.js';
import { runCommand } from './run-command.js';
import { LOG_LEVELS, die, log } from './log.js';
export async function ensureNpmProjectDir(projectDir) {
if (!projectDir) { throw new Error('missing project directory'); }
if (!await exists(join(projectDir, 'package.json'))) {
die(`'package.json' not found in project directory: ${projectDir}`);
}
}
export async function npmInstall(projectDir) {
if (!projectDir) { throw new Error('missing project directory'); }
const npmModulesPath = join(projectDir, 'node_modules');
if (!await isDirMissingOrEmpty(npmModulesPath)) {
log(`Directory "${npmModulesPath}" exists and has content. Skipping \`npm install\`.`);
return;
}
log('Starting dependency installation...');
const { error } = await runCommand('npm install --loglevel=error', {
stdio: 'inherit',
cwd: projectDir,
});
if (error) {
die(error);
}
}
export async function npmBuild(projectDir, buildCommand, distDir, forceRebuild) {
if (!projectDir) { throw new Error('missing project directory'); }
if (!buildCommand) { throw new Error('missing build command'); }
if (!distDir) { throw new Error('missing distribution directory'); }
const isMissingBuild = await isDirMissingOrEmpty(distDir);
if (!isMissingBuild && !forceRebuild) {
log(`Directory "${distDir}" exists and has content. Skipping build: '${buildCommand}'.`);
return;
}
if (forceRebuild) {
log(`Removing directory "${distDir}" for a clean build (triggered by --build flag).`);
await rmdir(distDir, { recursive: true });
}
log('Starting project build...');
const { error } = await runCommand(buildCommand, {
stdio: 'inherit',
cwd: projectDir,
});
if (error) {
log(error, LOG_LEVELS.WARN); // Cannot disable Vue CLI errors, stderr contains false-positives.
}
}
export async function getAppName(projectDir) {
if (!projectDir) { throw new Error('missing project directory'); }
const packageData = await readPackageJsonContents(projectDir);
try {
const packageJson = JSON.parse(packageData);
if (!packageJson.name) {
die(`The 'package.json' file doesn't specify a name: ${packageData}`);
}
return packageJson.name;
} catch (error) {
die(`Unable to parse 'package.json'. Error: ${error}\nContent: ${packageData}`, LOG_LEVELS.ERROR);
return undefined;
}
}
async function readPackageJsonContents(projectDir) {
if (!projectDir) { throw new Error('missing project directory'); }
const packagePath = join(projectDir, 'package.json');
if (!await exists(packagePath)) {
die(`'package.json' file not found at ${packagePath}`);
}
try {
const packageData = await readFile(packagePath, 'utf8');
return packageData;
} catch (error) {
log(`Error reading 'package.json' from ${packagePath}.`, LOG_LEVELS.ERROR);
die(`Error detail: ${error}`, LOG_LEVELS.ERROR);
throw error;
}
}

View File

@@ -1,9 +0,0 @@
import { platform } from 'os';
export const SUPPORTED_PLATFORMS = {
MAC: 'darwin',
LINUX: 'linux',
WINDOWS: 'win32',
};
export const CURRENT_PLATFORM = platform();

View File

@@ -1,95 +0,0 @@
#!/usr/bin/env bash
# Description:
# This script ensures npm is available, removes existing node modules, optionally
# removes package-lock.json (when -n flag is used), installs dependencies and runs unit tests.
# Usage:
# ./fresh-npm-install.sh # Regular execution
# ./fresh-npm-install.sh -n # Non-deterministic mode (removes package-lock.json)
declare NON_DETERMINISTIC_FLAG=0
main() {
parse_args "$@"
ensure_npm_is_available
ensure_npm_root
remove_existing_modules
if [[ $NON_DETERMINISTIC_FLAG -eq 1 ]]; then
remove_package_lock_json
fi
install_dependencies
run_unit_tests
}
ensure_npm_is_available() {
if ! command -v npm &> /dev/null; then
log::fatal 'npm could not be found, please install it first.'
fi
}
ensure_npm_root() {
if [ ! -f package.json ]; then
log::fatal 'Current directory is not a npm root. Please run the script in a npm root directory.'
fi
}
remove_existing_modules() {
if [ -d ./node_modules ]; then
log::info 'Removing existing node modules...'
if ! rm -rf ./node_modules; then
log::fatal 'Could not remove existing node modules.'
fi
fi
}
install_dependencies() {
log::info 'Installing dependencies...'
if ! npm install; then
log::fatal 'Failed to install dependencies.'
fi
}
remove_package_lock_json() {
if [ -f ./package-lock.json ]; then
log::info 'Removing package-lock.json...'
if ! rm -rf ./package-lock.json; then
log::fatal 'Could not remove package-lock.json.'
fi
fi
}
run_unit_tests() {
log::info 'Running unit tests...'
if ! npm run test:unit; then
pwd
log::fatal 'Failed to run unit tests.'
fi
}
log::info() {
local -r message="$1"
echo "📣 ${message}"
}
log::fatal() {
local -r message="$1"
echo "${message}" >&2
exit 1
}
parse_args() {
while getopts "n" opt; do
case ${opt} in
n)
NON_DETERMINISTIC_FLAG=1
;;
\?)
echo "Invalid option: $OPTARG" 1>&2
exit 1
;;
esac
done
}
main "$1"

199
scripts/npm-install.js Normal file
View File

@@ -0,0 +1,199 @@
/*
Description:
This script manages NPM dependencies for a project.
It offers capabilities like doing a fresh install, retries on network errors, and other features.
Usage:
npm run install-deps [-- <options>]
node scripts/npm-install.js [options]
Options:
--root-directory <path>
Specifies the root directory where package.json resides
Defaults to the current working directory.
Example: npm run install-deps -- --root-directory /your/path/here
--no-errors
Ignores errors and continues the execution.
Example: npm run install-deps -- --no-errors
--ci
Uses 'npm ci' for dependency installation instead of 'npm install'.
Example: npm run install-deps -- --ci
--fresh
Removes the existing node_modules directory before installing dependencies.
Example: npm run install-deps -- --fresh
--non-deterministic
Removes package-lock.json for a non-deterministic installation.
Example: npm run install-deps -- --non-deterministic
Note:
Flags can be combined as needed.
Example: npm run install-deps -- --fresh --non-deterministic
*/
import { exec } from 'child_process';
import { resolve } from 'path';
import { access, rm, unlink } from 'fs/promises';
import { constants } from 'fs';
const MAX_RETRIES = 5;
const RETRY_DELAY_IN_MS = 5 /* seconds */ * 1000;
const ARG_NAMES = {
rootDirectory: '--root-directory',
ignoreErrors: '--no-errors',
ci: '--ci',
fresh: '--fresh',
nonDeterministic: '--non-deterministic',
};
async function main() {
const options = getOptions();
console.log('Options:', options);
await ensureNpmRootDirectory(options.rootDirectory);
await ensureNpmIsAvailable();
if (options.fresh) {
await removeNodeModules(options.rootDirectory);
}
if (options.nonDeterministic) {
await removePackageLockJson(options.rootDirectory);
}
const command = buildCommand(options.ci, options.outputErrors);
console.log('Starting dependency installation...');
const exitCode = await executeWithRetry(
command,
options.workingDirectory,
MAX_RETRIES,
RETRY_DELAY_IN_MS,
);
if (exitCode === 0) {
console.log('🎊 Installed dependencies...');
} else {
console.error(`💀 Failed to install dependencies, exit code: ${exitCode}`);
}
process.exit(exitCode);
}
async function removeNodeModules(workingDirectory) {
const nodeModulesDirectory = resolve(workingDirectory, 'node_modules');
if (await exists('./node_modules')) {
console.log('Removing node_modules...');
await rm(nodeModulesDirectory, { recursive: true });
}
}
async function removePackageLockJson(workingDirectory) {
const packageLockJsonFile = resolve(workingDirectory, 'package-lock.json');
if (await exists(packageLockJsonFile)) {
console.log('Removing package-lock.json...');
await unlink(packageLockJsonFile);
}
}
async function ensureNpmIsAvailable() {
const exitCode = await executeCommand('npm --version');
if (exitCode !== 0) {
throw new Error('`npm` in not available!');
}
}
async function ensureNpmRootDirectory(workingDirectory) {
const packageJsonPath = resolve(workingDirectory, 'package.json');
if (!await exists(packageJsonPath)) {
throw new Error(`Not an NPM project root: ${workingDirectory}`);
}
}
function buildCommand(ci, outputErrors) {
const baseCommand = ci ? 'npm ci' : 'npm install';
if (!outputErrors) {
return `${baseCommand} --loglevel=error`;
}
return baseCommand;
}
function getOptions() {
const processArgs = process.argv.slice(2); // Slice off the node and script name
return {
rootDirectory: processArgs.includes('--root-directory') ? processArgs[processArgs.indexOf('--root-directory') + 1] : process.cwd(),
outputErrors: !processArgs.includes(ARG_NAMES.ignoreErrors),
ci: processArgs.includes(ARG_NAMES.ci),
fresh: processArgs.includes(ARG_NAMES.fresh),
nonDeterministic: processArgs.includes(ARG_NAMES.nonDeterministic),
};
}
async function executeWithRetry(
command,
workingDirectory,
maxRetries,
retryDelayInMs,
currentAttempt = 1,
) {
const statusCode = await executeCommand(command, workingDirectory, true, true);
if (statusCode === 0 || currentAttempt >= maxRetries) {
return statusCode;
}
console.log(`⚠️🔄 Attempt ${currentAttempt} failed. Retrying in ${retryDelayInMs / 1000} seconds...`);
await sleep(retryDelayInMs);
const retryResult = await executeWithRetry(
command,
workingDirectory,
maxRetries,
retryDelayInMs,
currentAttempt + 1,
);
return retryResult;
}
async function executeCommand(
command,
workingDirectory = process.cwd(),
logStdout = false,
logCommand = false,
) {
if (logCommand) {
console.log(`▶️ Executing command "${command}" at "${workingDirectory}"`);
}
const process = exec(
command,
{
cwd: workingDirectory,
},
);
if (logStdout) {
process.stdout.on('data', (data) => {
console.log(data.toString());
});
}
process.stderr.on('data', (data) => {
console.error(data.toString());
});
return new Promise((resolve) => {
process.on('exit', (code) => {
resolve(code);
});
});
}
function sleep(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}
async function exists(path) {
try {
await access(path, constants.F_OK);
return true;
} catch {
return false;
}
}
await main();

58
scripts/print-dist-dir.js Normal file
View File

@@ -0,0 +1,58 @@
/**
* Description:
* This script determines the absolute path of a distribution directory based on CLI arguments
* and outputs its absolute path. It is designed to be run programmatically by other scripts.
*
* Usage:
* node scripts/print-dist-dir.js [options]
*
* Options:
* --electron-unbundled Path for the unbundled Electron application
* --electron-bundled Path for the bundled Electron application
* --web Path for the web application
*/
import { resolve } from 'path';
import { readFile } from 'fs/promises';
const DIST_DIRS_JSON_FILE_PATH = resolve(process.cwd(), 'dist-dirs.json'); // cannot statically import because ESLint does not support it https://github.com/eslint/eslint/discussions/15305
const CLI_ARGUMENTS = process.argv.slice(2);
async function main() {
const distDirs = await readDistDirsJsonFile(DIST_DIRS_JSON_FILE_PATH);
const relativeDistDir = determineRelativeDistDir(distDirs, CLI_ARGUMENTS);
const absoluteDistDir = resolve(process.cwd(), relativeDistDir);
console.log(absoluteDistDir);
}
function mapCliFlagsToDistDirs(distDirs) {
return {
'--electron-unbundled': distDirs.electronUnbundled,
'--electron-bundled': distDirs.electronBundled,
'--web': distDirs.web,
};
}
function determineRelativeDistDir(distDirsJsonObject, cliArguments) {
const cliFlagDistDirMap = mapCliFlagsToDistDirs(distDirsJsonObject);
const availableCliFlags = Object.keys(cliFlagDistDirMap);
const requestedCliFlags = cliArguments.filter((arg) => {
return availableCliFlags.includes(arg);
});
if (!requestedCliFlags.length) {
throw new Error(`No distribution directory was requested. Please use one of these flags: ${availableCliFlags.join(', ')}`);
}
if (requestedCliFlags.length > 1) {
throw new Error(`Multiple distribution directories were requested, but this script only supports one: ${requestedCliFlags.join(', ')}`);
}
const selectedCliFlag = requestedCliFlags[0];
return cliFlagDistDirMap[selectedCliFlag];
}
async function readDistDirsJsonFile(absoluteConfigJsonFilePath) {
const fileContentAsText = await readFile(absoluteConfigJsonFilePath, 'utf8');
const parsedJsonData = JSON.parse(fileContentAsText);
return parsedJsonData;
}
await main();

View File

@@ -0,0 +1,133 @@
/**
* Description:
* This script verifies the existence and content of build artifacts based on the
* provided CLI flags. It exists with exit code `0` if all verifications pass, otherwise
* with exit code `1`.
*
* Usage:
* node scripts/verify-build-artifacts.js [options]
*
* Options:
* --electron-unbundled Verify artifacts for the unbundled Electron application.
* --electron-bundled Verify artifacts for the bundled Electron application.
* --web Verify artifacts for the web application.
*/
import { access, readdir } from 'fs/promises';
import { exec } from 'child_process';
import { resolve } from 'path';
const PROCESS_ARGUMENTS = process.argv.slice(2);
const PRINT_DIST_DIR_SCRIPT_BASE_COMMAND = 'node scripts/print-dist-dir';
async function main() {
const buildConfigs = getBuildVerificationConfigs();
if (!anyCommandsFound(Object.keys(buildConfigs))) {
die(`No valid command found in process arguments. Expected one of: ${Object.keys(buildConfigs).join(', ')}`);
}
/* eslint-disable no-await-in-loop */
for (const [command, config] of Object.entries(buildConfigs)) {
if (PROCESS_ARGUMENTS.includes(command)) {
const distDir = await executePrintDistDirScript(config.printDistDirScriptArgument);
await verifyDirectoryExists(distDir);
await verifyNonEmptyDirectory(distDir);
await verifyFilesExist(distDir, config.filePatterns);
}
}
/* eslint-enable no-await-in-loop */
console.log('✅ Build completed successfully and all expected artifacts are in place.');
process.exit(0);
}
function getBuildVerificationConfigs() {
return {
'--electron-unbundled': {
printDistDirScriptArgument: '--electron-unbundled',
filePatterns: [
/main[/\\]index\.cjs/,
/preload[/\\]index\.cjs/,
/renderer[/\\]index\.htm(l)?/,
],
},
'--electron-bundled': {
printDistDirScriptArgument: '--electron-bundled',
filePatterns: [
/latest.*\.yml/, // generates latest.yml for auto-updates
/.*-\d+\.\d+\.\d+\..*/, // a file with extension and semantic version (packaged application)
],
},
'--web': {
printDistDirScriptArgument: '--web',
filePatterns: [
/index\.htm(l)?/,
],
},
};
}
function anyCommandsFound(commands) {
return PROCESS_ARGUMENTS.some((arg) => commands.includes(arg));
}
async function verifyDirectoryExists(directoryPath) {
try {
await access(directoryPath);
} catch (error) {
die(`Directory does not exist at \`${directoryPath}\`:\n\t${error.message}`);
}
}
async function verifyNonEmptyDirectory(directoryPath) {
const files = await readdir(directoryPath);
if (files.length === 0) {
die(`Directory is empty at \`${directoryPath}\``);
}
}
async function verifyFilesExist(directoryPath, filePatterns) {
const files = await listAllFilesRecursively(directoryPath);
for (const pattern of filePatterns) {
const match = files.some((file) => pattern.test(file));
if (!match) {
die(
`No file matches the pattern ${pattern.source} in directory \`${directoryPath}\``,
`\nFiles in directory:\n${files.map((file) => `\t- ${file}`).join('\n')}`,
);
}
}
}
async function listAllFilesRecursively(directoryPath) {
const dir = await readdir(directoryPath, { withFileTypes: true });
const files = await Promise.all(dir.map(async (dirent) => {
const absolutePath = resolve(directoryPath, dirent.name);
if (dirent.isDirectory()) {
return listAllFilesRecursively(absolutePath);
}
return absolutePath;
}));
return files.flat();
}
async function executePrintDistDirScript(flag) {
return new Promise((resolve, reject) => {
const commandToRun = `${PRINT_DIST_DIR_SCRIPT_BASE_COMMAND} ${flag}`;
exec(commandToRun, (error, stdout, stderr) => {
if (error) {
reject(new Error(`Execution failed with error: ${error}`));
} else if (stderr) {
reject(new Error(`Execution failed with stderr: ${stderr}`));
} else {
resolve(stdout.trim());
}
});
});
}
function die(...message) {
console.error(...message);
process.exit(1);
}
await main();

View File

@@ -1,6 +1,7 @@
export type Constructible<T, TArgs extends unknown[] = never> = {
prototype: T;
apply: (this: unknown, args: TArgs) => void;
readonly name: string;
};
export type PropertyKeys<T> = {

View File

@@ -1,25 +1,23 @@
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { IApplication } from '@/domain/IApplication';
import { Environment } from '@/infrastructure/Environment/Environment';
import { IEnvironment } from '@/infrastructure/Environment/IEnvironment';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { IApplicationFactory } from '../IApplicationFactory';
import { ApplicationFactory } from '../ApplicationFactory';
import { ApplicationContext } from './ApplicationContext';
export async function buildContext(
factory: IApplicationFactory = ApplicationFactory.Current,
environment = Environment.CurrentEnvironment,
environment = RuntimeEnvironment.CurrentEnvironment,
): Promise<IApplicationContext> {
if (!factory) { throw new Error('missing factory'); }
if (!environment) { throw new Error('missing environment'); }
const app = await factory.getApp();
const os = getInitialOs(app, environment);
const os = getInitialOs(app, environment.os);
return new ApplicationContext(app, os);
}
function getInitialOs(app: IApplication, environment: IEnvironment): OperatingSystem {
const currentOs = environment.os;
function getInitialOs(app: IApplication, currentOs: OperatingSystem): OperatingSystem {
const supportedOsList = app.getSupportedOsList();
if (supportedOsList.includes(currentOs)) {
return currentOs;

View File

@@ -7,14 +7,14 @@ import MacOsData from '@/application/collections/macos.yaml';
import LinuxData from '@/application/collections/linux.yaml';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { Application } from '@/domain/Application';
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory';
import { IAppMetadata } from '@/infrastructure/EnvironmentVariables/IAppMetadata';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { parseCategoryCollection } from './CategoryCollectionParser';
export function parseApplication(
categoryParser = parseCategoryCollection,
informationParser = parseProjectInformation,
metadata: IAppMetadata = AppMetadataFactory.Current.instance,
metadata: IAppMetadata = EnvironmentVariablesFactory.Current.instance,
collectionsData = PreParsedCollections,
): IApplication {
validateCollectionsData(collectionsData);

View File

@@ -1,13 +1,13 @@
import { IProjectInformation } from '@/domain/IProjectInformation';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
import { IAppMetadata } from '@/infrastructure/EnvironmentVariables/IAppMetadata';
import { Version } from '@/domain/Version';
import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { ConstructorArguments } from '@/TypeHelpers';
export function
parseProjectInformation(
metadata: IAppMetadata = AppMetadataFactory.Current.instance,
metadata: IAppMetadata = EnvironmentVariablesFactory.Current.instance,
createProjectInformation: ProjectInformationFactory = (
...args
) => new ProjectInformation(...args),

View File

@@ -3558,82 +3558,98 @@ functions:
parameters:
- name: prefName
- name: jsonValue
# prefs.js file (https://web.archive.org/web/20221029211757/https://kb.mozillazine.org/Prefs.js_file) exists at
# - Default installation:
# ~/.mozilla/firefox/<profile-name>/prefs.js
# - Flatpak installation:
# ~/.var/app/org.mozilla.firefox/.mozilla/firefox/<profile-name>/prefs.js
# - Snap installation:
# ~/snap/firefox/common/.mozilla/firefox/<profile-name>/prefs.js
docs: |-
This script either creates or updates the `user.js` file to set specific Mozilla Firefox preferences.
The `user.js` file can be found in a Firefox profile folder [1] and its location depends on the type of installation:
- Default: `~/.mozilla/firefox/<profile-name>/user.js`
- Flatpak: `~/.var/app/org.mozilla.firefox/.mozilla/firefox/<profile-name>/user.js`
- Snap: `~/snap/firefox/common/.mozilla/firefox/<profile-name>/user.js`
While the `user.js` file is optional [2], if it's present, the Firefox application will prioritize its settings over
those in `prefs.js` upon startup [1][2]. To prevent potential profile corruption, Mozilla advises against editing
`prefs.js` directly [2].
[1]: https://web.archive.org/web/20230811005205/https://kb.mozillazine.org/User.js_file "User.js file - MozillaZine Knowledge Base"
[2]: https://web.archive.org/web/20221029211757/https://kb.mozillazine.org/Prefs.js_file "Prefs.js file - MozillaZine Knowledge Base"
code: |-
pref_name='{{ $prefName }}'
pref_value='{{ $jsonValue }}'
echo "Setting preference \"$pref_name\" to \"$pref_value\"."
pref_file_paths=(
~/.mozilla/firefox/*/prefs.js
~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/prefs.js
~/snap/firefox/common/.mozilla/firefox/*/prefs.js
declare -a profile_paths=(
~/.mozilla/firefox/*/
~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/
~/snap/firefox/common/.mozilla/firefox/*/
)
declare -i total_files_found=0
for pref_file in "${pref_file_paths[@]}"; do
if [ -f "$pref_file" ]; then
((total_files_found++))
echo "$pref_file:"
pref_start="user_pref(\"$pref_name\","
pref_line="user_pref(\"$pref_name\", $pref_value);"
if ! grep --quiet "^$pref_start" "${pref_file}"; then
echo $'\t'"Preference is not configured before."
echo -n $'\n'"$pref_line" >> "$pref_file"
echo $'\t'"Successfully configured."
else
if grep --quiet "^$pref_line$" "${pref_file}"; then
echo $'\t'"Skipping. Preference is already configured as expected."
else
sed --in-place "/^$pref_start/d" "$pref_file"
echo $'\t'"Deleted assignment with unexpected value."
echo -n $'\n'"$pref_line" >> "$pref_file"
echo $'\t'"Successfully reconfigured with expected value."
fi
fi
declare -i total_profiles_found=0
for profile_dir in "${profile_paths[@]}"; do
if [ ! -d "$profile_dir" ]; then
continue
fi
if [[ ! "$(basename "$profile_dir")" =~ ^[a-z0-9]{8}\..+ ]]; then
continue # Not a profile folder
fi
((total_profiles_found++))
user_js_file="${profile_dir}user.js"
echo "$user_js_file:"
if [ ! -f "$user_js_file" ]; then
touch "$user_js_file"
echo $'\t''Created new user.js file'
fi
pref_start="user_pref(\"$pref_name\","
pref_line="user_pref(\"$pref_name\", $pref_value);"
if ! grep --quiet "^$pref_start" "${user_js_file}"; then
echo -n $'\n'"$pref_line" >> "$user_js_file"
echo $'\t'"Successfully added a new preference in $user_js_file."
elif grep --quiet "^$pref_line$" "$user_js_file"; then
echo $'\t'"Skipping, preference is already set as expected in $user_js_file."
else
sed --in-place "/^$pref_start/c\\$pref_line" "$user_js_file"
echo $'\t'"Successfully replaced the existing incorrect preference in $user_js_file."
fi
done
if [ "$total_files_found" -eq 0 ]; then
echo "No changes, no preference file is found."
if [ "$total_profiles_found" -eq 0 ]; then
echo 'No profile folders are found, no changes are made.'
else
echo "Ensured that $total_files_found profiles are compilant."
echo "Preferences verified in $total_profiles_found profiles."
fi
revertCode: |-
pref_name='{{ $prefName }}'
pref_value='{{ $jsonValue }}'
echo "Restoring \"$pref_name\" to its default."
pref_file_paths=(
~/.mozilla/firefox/*/prefs.js
~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/prefs.js
~/snap/firefox/common/.mozilla/firefox/*/prefs.js
echo "Reverting preference: \"$pref_name\" to its default."
declare -a profile_paths=(
~/.mozilla/firefox/*/
~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/
~/snap/firefox/common/.mozilla/firefox/*/
)
declare -i total_files_found=0
for pref_file in "${pref_file_paths[@]}"; do
if [ -f "$pref_file" ]; then
((total_files_found++))
echo "$pref_file:"
pref_start="user_pref(\"$pref_name\","
pref_line="user_pref(\"$pref_name\", $pref_value);"
if ! grep --quiet "^$pref_start" "${pref_file}"; then
echo $'\t'"Skipping. Preference is not configured before."
else
if grep --quiet "^$pref_line$" "${pref_file}"; then
sed --in-place "/^$pref_line/d" "$pref_file"
echo $'\t'"Successfully restored preference value to its default."
else
echo $'\t'"Skipping, the preference has value that is not configured by privacy.sexy."
fi
declare -i total_profiles_found=0
for profile_dir in "${profile_paths[@]}"; do
user_js_file="${profile_dir}user.js"
if [ ! -f "$user_js_file" ]; then
continue
fi
((total_profiles_found++))
echo "$user_js_file:"
pref_start="user_pref(\"$pref_name\","
pref_line="user_pref(\"$pref_name\", $pref_value);"
if ! grep --quiet "^$pref_start" "${user_js_file}"; then
echo $'\t''Skipping, preference was not configured before.'
elif grep --quiet "^$pref_line$" "${user_js_file}"; then
sed --in-place "/^$pref_line/d" "$user_js_file"
echo $'\t''Successfully reverted preference to default.'
if ! grep --quiet '[^[:space:]]' "$user_js_file"; then
rm "$user_js_file"
echo $'\t''Removed user.js file as it became empty.'
fi
else
echo $'\t''Skipping, the preference has value that is not configured by privacy.sexy.'
fi
done
if [ "$total_files_found" -eq 0 ]; then
echo "No changes, no preference file is found."
if [ "$total_profiles_found" -eq 0 ]; then
echo 'No reversion was necessary.'
else
echo "Ensured that $total_files_found profiles are compilant."
echo "Preferences verified in $total_profiles_found profiles."
fi
-
name: RenameFile

View File

@@ -2456,7 +2456,7 @@ actions:
recommend: standard
docs: https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.EdgeUI::DisableRecentApps
code: reg add "HKCU\Software\Policies\Microsoft\Windows\EdgeUI" /v "DisableRecentApps" /t REG_DWORD /d 1 /f
revertCode: reg add "HKCU\Software\Policies\Microsoft\Windows\EdgeUI" /v "DisableRecentApps" /t REG_DWORD /d 0/f
revertCode: reg add "HKCU\Software\Policies\Microsoft\Windows\EdgeUI" /v "DisableRecentApps" /t REG_DWORD /d 0 /f
-
name: Turn off backtracking
recommend: standard
@@ -3871,7 +3871,7 @@ actions:
code: reg add "HKLM\Software\Policies\Microsoft\Windows Defender\Scan" /v "DisableRestorePoint" /t REG_DWORD /d "1" /f
revertCode: reg delete "HKLM\Software\Policies\Microsoft\Windows Defender\Scan" /v "DisableRestorePoint" /f 2>nul
-
name: Set minumum time for keeping files in scan history folder
name: Set minimum time for keeping files in scan history folder
docs:
- https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefender::Scan_PurgeItemsAfterDelay
# Managing with MpPreference module:

View File

@@ -1,32 +1,37 @@
import { Environment } from '@/infrastructure/Environment/Environment';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { getWindowInjectedSystemOperations } from './SystemOperations/WindowInjectedSystemOperations';
export class CodeRunner {
constructor(
private readonly environment = Environment.CurrentEnvironment,
private readonly system = getWindowInjectedSystemOperations(),
private readonly environment = RuntimeEnvironment.CurrentEnvironment,
) {
if (!environment.system) {
if (!system) {
throw new Error('missing system operations');
}
}
public async runCode(code: string, folderName: string, fileExtension: string): Promise<void> {
const { system } = this.environment;
const dir = system.location.combinePaths(
system.operatingSystem.getTempDirectory(),
const { os } = this.environment;
const dir = this.system.location.combinePaths(
this.system.operatingSystem.getTempDirectory(),
folderName,
);
await system.fileSystem.createDirectory(dir, true);
const filePath = system.location.combinePaths(dir, `run.${fileExtension}`);
await system.fileSystem.writeToFile(filePath, code);
await system.fileSystem.setFilePermissions(filePath, '755');
const command = getExecuteCommand(filePath, this.environment);
system.command.execute(command);
await this.system.fileSystem.createDirectory(dir, true);
const filePath = this.system.location.combinePaths(dir, `run.${fileExtension}`);
await this.system.fileSystem.writeToFile(filePath, code);
await this.system.fileSystem.setFilePermissions(filePath, '755');
const command = getExecuteCommand(filePath, os);
this.system.command.execute(command);
}
}
function getExecuteCommand(scriptPath: string, environment: Environment): string {
switch (environment.os) {
function getExecuteCommand(
scriptPath: string,
currentOperatingSystem: OperatingSystem,
): string {
switch (currentOperatingSystem) {
case OperatingSystem.Linux:
return `x-terminal-emulator -e '${scriptPath}'`;
case OperatingSystem.macOS:
@@ -37,6 +42,6 @@ function getExecuteCommand(scriptPath: string, environment: Environment): string
case OperatingSystem.Windows:
return scriptPath;
default:
throw Error(`unsupported os: ${OperatingSystem[environment.os]}`);
throw Error(`unsupported os: ${OperatingSystem[currentOperatingSystem]}`);
}
}

View File

@@ -1,8 +0,0 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ISystemOperations } from '@/infrastructure/Environment/SystemOperations/ISystemOperations';
export interface IEnvironment {
readonly isDesktop: boolean;
readonly os: OperatingSystem | undefined;
readonly system: ISystemOperations | undefined;
}

View File

@@ -1,13 +0,0 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ISystemOperations } from './SystemOperations/ISystemOperations';
export type WindowVariables = {
system: ISystemOperations;
isDesktop: boolean;
os: OperatingSystem;
};
declare global {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Window extends WindowVariables { }
}

View File

@@ -0,0 +1,18 @@
import { IEnvironmentVariablesFactory } from './IEnvironmentVariablesFactory';
import { validateEnvironmentVariables } from './EnvironmentVariablesValidator';
import { ViteEnvironmentVariables } from './Vite/ViteEnvironmentVariables';
import { IEnvironmentVariables } from './IEnvironmentVariables';
export class EnvironmentVariablesFactory implements IEnvironmentVariablesFactory {
public static readonly Current = new EnvironmentVariablesFactory();
public readonly instance: IEnvironmentVariables;
protected constructor(validator: EnvironmentVariablesValidator = validateEnvironmentVariables) {
const environment = new ViteEnvironmentVariables();
validator(environment);
this.instance = environment;
}
}
export type EnvironmentVariablesValidator = typeof validateEnvironmentVariables;

View File

@@ -1,24 +1,24 @@
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
import { IEnvironmentVariables } from './IEnvironmentVariables';
/* Validation is externalized to keep the environment objects simple */
export function validateMetadata(metadata: IAppMetadata): void {
if (!metadata) {
throw new Error('missing metadata');
export function validateEnvironmentVariables(environment: IEnvironmentVariables): void {
if (!environment) {
throw new Error('missing environment');
}
const keyValues = capturePropertyValues(metadata);
const keyValues = capturePropertyValues(environment);
if (!Object.keys(keyValues).length) {
throw new Error('Unable to capture metadata key/value pairs');
throw new Error('Unable to capture key/value pairs');
}
const keysMissingValue = getMissingMetadataKeys(keyValues);
const keysMissingValue = getKeysMissingValues(keyValues);
if (keysMissingValue.length > 0) {
throw new Error(`Metadata keys missing: ${keysMissingValue.join(', ')}`);
throw new Error(`Environment keys missing: ${keysMissingValue.join(', ')}`);
}
}
function getMissingMetadataKeys(keyValuePairs: Record<string, unknown>): string[] {
function getKeysMissingValues(keyValuePairs: Record<string, unknown>): string[] {
return Object.entries(keyValuePairs)
.reduce((acc, [key, value]) => {
if (!value) {
if (!value && typeof value !== 'boolean') {
acc.push(key);
}
return acc;

View File

@@ -1,8 +1,5 @@
/**
* Represents essential metadata about the application.
*
* Designed to decouple the process of retrieving metadata
* (e.g., from the build environment) from the rest of the application.
*/
export interface IAppMetadata {
readonly version: string;

View File

@@ -0,0 +1,9 @@
import { IAppMetadata } from './IAppMetadata';
/**
* Designed to decouple the process of retrieving environment variables
* (e.g., from the build environment) from the rest of the application.
*/
export interface IEnvironmentVariables extends IAppMetadata {
readonly isNonProduction: boolean;
}

View File

@@ -0,0 +1,5 @@
import { IEnvironmentVariables } from './IEnvironmentVariables';
export interface IEnvironmentVariablesFactory {
readonly instance: IEnvironmentVariables;
}

View File

@@ -1,8 +1,13 @@
// Only variables prefixed with VITE_ are exposed to Vite-processed code
export const VITE_ENVIRONMENT_KEYS = {
export const VITE_USER_DEFINED_ENVIRONMENT_KEYS = {
VERSION: 'VITE_APP_VERSION',
NAME: 'VITE_APP_NAME',
SLOGAN: 'VITE_APP_SLOGAN',
REPOSITORY_URL: 'VITE_APP_REPOSITORY_URL',
HOMEPAGE_URL: 'VITE_APP_HOMEPAGE_URL',
} as const;
export const VITE_ENVIRONMENT_KEYS = {
...VITE_USER_DEFINED_ENVIRONMENT_KEYS,
DEV: 'DEV',
} as const;

View File

@@ -1,9 +1,9 @@
import { IAppMetadata } from '../IAppMetadata';
import { IEnvironmentVariables } from '../IEnvironmentVariables';
/**
* Provides the application's metadata using Vite's environment variables.
* Provides the application's environment variables.
*/
export class ViteAppMetadata implements IAppMetadata {
export class ViteEnvironmentVariables implements IEnvironmentVariables {
// Ensure the use of import.meta.env prefix for the following properties.
// Vue will replace these statically during production builds.
@@ -26,4 +26,8 @@ export class ViteAppMetadata implements IAppMetadata {
public get homepageUrl(): string {
return import.meta.env.VITE_APP_HOMEPAGE_URL;
}
public get isNonProduction(): boolean {
return import.meta.env.DEV;
}
}

View File

@@ -1,10 +1,20 @@
import { IEventSubscription } from './IEventSource';
import { IEventSubscriptionCollection } from './IEventSubscriptionCollection';
import { IEventSubscription } from './IEventSource';
export class EventSubscriptionCollection implements IEventSubscriptionCollection {
private readonly subscriptions = new Array<IEventSubscription>();
public register(...subscriptions: IEventSubscription[]) {
public get subscriptionCount() {
return this.subscriptions.length;
}
public register(subscriptions: IEventSubscription[]) {
if (!subscriptions || subscriptions.length === 0) {
throw new Error('missing subscriptions');
}
if (subscriptions.some((subscription) => !subscription)) {
throw new Error('missing subscription in list');
}
this.subscriptions.push(...subscriptions);
}
@@ -12,4 +22,9 @@ export class EventSubscriptionCollection implements IEventSubscriptionCollection
this.subscriptions.forEach((listener) => listener.unsubscribe());
this.subscriptions.splice(0, this.subscriptions.length);
}
public unsubscribeAllAndRegister(subscriptions: IEventSubscription[]) {
this.unsubscribeAll();
this.register(subscriptions);
}
}

View File

@@ -1,7 +1,9 @@
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
export interface IEventSubscriptionCollection {
register(...subscriptions: IEventSubscription[]);
readonly subscriptionCount: number;
unsubscribeAll();
register(subscriptions: IEventSubscription[]): void;
unsubscribeAll(): void;
unsubscribeAllAndRegister(subscriptions: IEventSubscription[]);
}

View File

@@ -0,0 +1,13 @@
import { ILogger } from './ILogger';
export class ConsoleLogger implements ILogger {
constructor(private readonly globalConsole: Partial<Console> = console) {
if (!globalConsole) {
throw new Error('missing console');
}
}
public info(...params: unknown[]): void {
this.globalConsole.info(...params);
}
}

View File

@@ -0,0 +1,12 @@
import { ElectronLog } from 'electron-log';
import { ILogger } from './ILogger';
// Using plain-function rather than class so it can be used in Electron's context-bridging.
export function createElectronLogger(logger: Partial<ElectronLog>): ILogger {
if (!logger) {
throw new Error('missing logger');
}
return {
info: (...params) => logger.info(...params),
};
}

View File

@@ -0,0 +1,3 @@
export interface ILogger {
info (...params: unknown[]): void;
}

View File

@@ -0,0 +1,5 @@
import { ILogger } from './ILogger';
export interface ILoggerFactory {
readonly logger: ILogger;
}

View File

@@ -0,0 +1,5 @@
import { ILogger } from './ILogger';
export class NoopLogger implements ILogger {
public info(): void { /* NOOP */ }
}

View File

@@ -0,0 +1,20 @@
import { WindowVariables } from '../WindowVariables/WindowVariables';
import { ILogger } from './ILogger';
export class WindowInjectedLogger implements ILogger {
private readonly logger: ILogger;
constructor(windowVariables: WindowVariables = window) {
if (!windowVariables) {
throw new Error('missing window');
}
if (!windowVariables.log) {
throw new Error('missing log');
}
this.logger = windowVariables.log;
}
public info(...params: unknown[]): void {
this.logger.info(...params);
}
}

View File

@@ -1,18 +0,0 @@
import { IAppMetadata } from './IAppMetadata';
import { IAppMetadataFactory } from './IAppMetadataFactory';
import { validateMetadata } from './MetadataValidator';
import { ViteAppMetadata } from './Vite/ViteAppMetadata';
export class AppMetadataFactory implements IAppMetadataFactory {
public static readonly Current = new AppMetadataFactory();
public readonly instance: IAppMetadata;
protected constructor(validator: MetadataValidator = validateMetadata) {
const metadata = new ViteAppMetadata();
validator(metadata);
this.instance = metadata;
}
}
export type MetadataValidator = typeof validateMetadata;

View File

@@ -1,5 +0,0 @@
import { IAppMetadata } from './IAppMetadata';
export interface IAppMetadataFactory {
readonly instance: IAppMetadata;
}

View File

@@ -0,0 +1,7 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
export interface IRuntimeEnvironment {
readonly isDesktop: boolean;
readonly os: OperatingSystem | undefined;
readonly isNonProduction: boolean;
}

View File

@@ -1,29 +1,29 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ISystemOperations } from '@/infrastructure/Environment/SystemOperations/ISystemOperations';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector';
import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector';
import { IEnvironment } from './IEnvironment';
import { WindowVariables } from './WindowVariables';
import { validateWindowVariables } from './WindowVariablesValidator';
import { IRuntimeEnvironment } from './IRuntimeEnvironment';
export class Environment implements IEnvironment {
public static readonly CurrentEnvironment: IEnvironment = new Environment(window);
export class RuntimeEnvironment implements IRuntimeEnvironment {
public static readonly CurrentEnvironment: IRuntimeEnvironment = new RuntimeEnvironment(window);
public readonly isDesktop: boolean;
public readonly os: OperatingSystem | undefined;
public readonly system: ISystemOperations | undefined;
public readonly isNonProduction: boolean;
protected constructor(
window: Partial<Window>,
environmentVariables: IEnvironmentVariables = EnvironmentVariablesFactory.Current.instance,
browserOsDetector: IBrowserOsDetector = new BrowserOsDetector(),
windowValidator: WindowValidator = validateWindowVariables,
) {
if (!window) {
throw new Error('missing window');
}
windowValidator(window);
this.isNonProduction = environmentVariables.isNonProduction;
this.isDesktop = isDesktop(window);
if (this.isDesktop) {
this.os = window?.os;
@@ -34,7 +34,6 @@ export class Environment implements IEnvironment {
this.os = browserOsDetector.detect(userAgent);
}
}
this.system = window?.system;
}
}
@@ -45,5 +44,3 @@ function getUserAgent(window: Partial<Window>): string {
function isDesktop(window: Partial<WindowVariables>): boolean {
return window?.isDesktop === true;
}
export type WindowValidator = typeof validateWindowVariables;

View File

@@ -1,4 +1,4 @@
export interface ISanityCheckOptions {
readonly validateMetadata: boolean;
readonly validateEnvironment: boolean;
readonly validateEnvironmentVariables: boolean;
readonly validateWindowVariables: boolean;
}

View File

@@ -1,9 +1,9 @@
import { ISanityCheckOptions } from './Common/ISanityCheckOptions';
import { ISanityValidator } from './Common/ISanityValidator';
import { MetadataValidator } from './Validators/MetadataValidator';
import { EnvironmentVariablesValidator } from './Validators/EnvironmentVariablesValidator';
const DefaultSanityValidators: ISanityValidator[] = [
new MetadataValidator(),
new EnvironmentVariablesValidator(),
];
/* Helps to fail-fast on errors */

View File

@@ -1,16 +0,0 @@
import { Environment } from '@/infrastructure/Environment/Environment';
import { IEnvironment } from '@/infrastructure/Environment/IEnvironment';
import { ISanityCheckOptions } from '../Common/ISanityCheckOptions';
import { FactoryValidator, FactoryFunction } from '../Common/FactoryValidator';
export class EnvironmentValidator extends FactoryValidator<IEnvironment> {
constructor(factory: FactoryFunction<IEnvironment> = () => Environment.CurrentEnvironment) {
super(factory);
}
public override name = 'environment';
public override shouldValidate(options: ISanityCheckOptions): boolean {
return options.validateEnvironment;
}
}

View File

@@ -0,0 +1,20 @@
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { ISanityCheckOptions } from '../Common/ISanityCheckOptions';
import { FactoryValidator, FactoryFunction } from '../Common/FactoryValidator';
export class EnvironmentVariablesValidator extends FactoryValidator<IEnvironmentVariables> {
constructor(
factory: FactoryFunction<IEnvironmentVariables> = () => {
return EnvironmentVariablesFactory.Current.instance;
},
) {
super(factory);
}
public override name = 'environment variables';
public override shouldValidate(options: ISanityCheckOptions): boolean {
return options.validateEnvironmentVariables;
}
}

View File

@@ -1,16 +0,0 @@
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory';
import { ISanityCheckOptions } from '../Common/ISanityCheckOptions';
import { FactoryValidator, FactoryFunction } from '../Common/FactoryValidator';
export class MetadataValidator extends FactoryValidator<IAppMetadata> {
constructor(factory: FactoryFunction<IAppMetadata> = () => AppMetadataFactory.Current.instance) {
super(factory);
}
public override name = 'metadata';
public override shouldValidate(options: ISanityCheckOptions): boolean {
return options.validateMetadata;
}
}

View File

@@ -0,0 +1,15 @@
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { ISanityCheckOptions } from '../Common/ISanityCheckOptions';
import { FactoryValidator, FactoryFunction } from '../Common/FactoryValidator';
export class WindowVariablesValidator extends FactoryValidator<WindowVariables> {
constructor(factory: FactoryFunction<WindowVariables> = () => window) {
super(factory);
}
public override name = 'window variables';
public override shouldValidate(options: ISanityCheckOptions): boolean {
return options.validateWindowVariables;
}
}

View File

@@ -0,0 +1,14 @@
import { WindowVariables } from '../WindowVariables/WindowVariables';
import { ISystemOperations } from './ISystemOperations';
export function getWindowInjectedSystemOperations(
windowVariables: Partial<WindowVariables> = window,
): ISystemOperations {
if (!windowVariables) {
throw new Error('missing window');
}
if (!windowVariables.system) {
throw new Error('missing system');
}
return windowVariables.system;
}

View File

@@ -0,0 +1,11 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
import { ILogger } from '@/infrastructure/Log/ILogger';
/* Primary entry point for platform-specific injections */
export interface WindowVariables {
readonly system: ISystemOperations;
readonly isDesktop: boolean;
readonly os: OperatingSystem;
readonly log: ILogger;
}

View File

@@ -6,11 +6,8 @@ import { WindowVariables } from './WindowVariables';
* Checks for consistency in runtime environment properties injected by Electron preloader.
*/
export function validateWindowVariables(variables: Partial<WindowVariables>) {
if (!variables) {
throw new Error('missing variables');
}
if (!isObject(variables)) {
throw new Error(`window is not an object but ${typeof variables}`);
throw new Error('window is not an object');
}
const errors = [...testEveryProperty(variables)];
if (errors.length > 0) {
@@ -25,6 +22,7 @@ function* testEveryProperty(variables: Partial<WindowVariables>): Iterable<strin
os: testOperatingSystem(variables.os),
isDesktop: testIsDesktop(variables.isDesktop),
system: testSystem(variables),
log: testLogger(variables),
};
for (const [propertyName, testResult] of Object.entries(tests)) {
@@ -47,11 +45,18 @@ function testOperatingSystem(os: unknown): boolean {
.includes(os);
}
function testLogger(variables: Partial<WindowVariables>): boolean {
if (!variables.isDesktop) {
return true;
}
return isObject(variables.log);
}
function testSystem(variables: Partial<WindowVariables>): boolean {
if (!variables.isDesktop) {
return true;
}
return variables.system !== undefined && isObject(variables.system);
return isObject(variables.system);
}
function testIsDesktop(isDesktop: unknown): boolean {
@@ -70,7 +75,7 @@ function isBoolean(variable: unknown): variable is boolean {
}
function isObject(variable: unknown): variable is object {
return typeof variable === 'object'
&& variable !== null // the data type of null is an object
return Boolean(variable) // the data type of null is an object
&& typeof variable === 'object'
&& !Array.isArray(variable);
}

View File

@@ -0,0 +1,6 @@
import { WindowVariables } from './WindowVariables';
declare global {
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface Window extends WindowVariables { }
}

View File

@@ -44,4 +44,10 @@
transform: translateY($offset-upward);
}
}
}
}
@mixin reset-ul {
margin: 0;
padding: 0;
list-style: none;
}

View File

@@ -9,4 +9,3 @@
@forward "./components/card";
@forward "./third-party-extensions/tooltip.scss";
@forward "./third-party-extensions/tree.scss";

View File

@@ -1,62 +0,0 @@
// Overrides base styling for LiquorTree
@use "@/presentation/assets/styles/colors" as *;
@use "@/presentation/assets/styles/mixins" as *;
$color-tree-bg : $color-primary-darker;
$color-node-arrow : $color-on-primary;
$color-node-fg : $color-on-primary;
$color-node-hover-bg : $color-primary-dark;
$color-node-keyboard-bg : $color-surface;
$color-node-keyboard-fg : $color-on-surface;
$color-node-checkbox-bg-checked : $color-secondary;
$color-node-checkbox-bg-unchecked : $color-primary-darkest;
$color-node-checkbox-border-checked : $color-secondary;
$color-node-checkbox-border-unchecked : $color-on-primary;
$color-node-checkbox-tick-checked : $color-on-secondary;
.tree {
background: $color-tree-bg;
&-node {
white-space: normal !important;
> .tree-content {
> .tree-anchor {
> span {
color: $color-node-fg;
font-size: 1.5em;
}
display: block; // so it takes full width to allow aligning items inside
}
@include hover-or-touch {
background: $color-node-hover-bg !important;
}
background: $color-tree-bg !important; // If not styled, it gets white background on mobile.
}
&.selected { // When using keyboard navigation it highlights current item and its child items
background: $color-node-keyboard-bg;
.tree-text {
color: $color-node-keyboard-fg !important; // $block
}
}
}
&-checkbox {
border-color: $color-node-checkbox-border-unchecked !important;
&.checked {
background: $color-node-checkbox-bg-checked !important;
border-color: $color-node-checkbox-border-checked !important;
&:after {
border-color: $color-node-checkbox-tick-checked !important;
}
}
&.indeterminate {
border-color: $color-node-checkbox-border-unchecked !important;
}
background: $color-node-checkbox-bg-unchecked !important;
}
&-arrow {
&.has-child {
&.rtl:after, &:after {
border-color: $color-node-arrow !important;
}
}
}
}

View File

@@ -1,8 +1,9 @@
import { TreeBootstrapper } from './Modules/TreeBootstrapper';
import { IconBootstrapper } from './Modules/IconBootstrapper';
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
import { VueBootstrapper } from './Modules/VueBootstrapper';
import { TooltipBootstrapper } from './Modules/TooltipBootstrapper';
import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator';
import { AppInitializationLogger } from './Modules/AppInitializationLogger';
export class ApplicationBootstrapper implements IVueBootstrapper {
public bootstrap(vue: VueConstructor): void {
@@ -15,9 +16,10 @@ export class ApplicationBootstrapper implements IVueBootstrapper {
private static getAllBootstrappers(): IVueBootstrapper[] {
return [
new IconBootstrapper(),
new TreeBootstrapper(),
new VueBootstrapper(),
new TooltipBootstrapper(),
new RuntimeSanityValidator(),
new AppInitializationLogger(),
];
}
}

View File

@@ -0,0 +1,25 @@
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment';
import { ConsoleLogger } from '@/infrastructure/Log/ConsoleLogger';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { ILoggerFactory } from '@/infrastructure/Log/ILoggerFactory';
import { NoopLogger } from '@/infrastructure/Log/NoopLogger';
import { WindowInjectedLogger } from '@/infrastructure/Log/WindowInjectedLogger';
export class ClientLoggerFactory implements ILoggerFactory {
public static readonly Current: ILoggerFactory = new ClientLoggerFactory();
public readonly logger: ILogger;
protected constructor(environment: IRuntimeEnvironment = RuntimeEnvironment.CurrentEnvironment) {
if (environment.isDesktop) {
this.logger = new WindowInjectedLogger();
return;
}
if (environment.isNonProduction) {
this.logger = new ConsoleLogger();
return;
}
this.logger = new NoopLogger();
}
}

View File

@@ -1,28 +1,31 @@
import { InjectionKey, provide } from 'vue';
import { InjectionKey, provide, inject } from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
import {
useCollectionStateKey, useApplicationKey, useEnvironmentKey,
} from '@/presentation/injectionSymbols';
import { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { Environment } from '@/infrastructure/Environment/Environment';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { InjectionKeys } from '@/presentation/injectionSymbols';
export function provideDependencies(context: IApplicationContext) {
registerSingleton(useApplicationKey, useApplication(context.app));
registerTransient(useCollectionStateKey, () => useCollectionState(context));
registerSingleton(useEnvironmentKey, Environment.CurrentEnvironment);
}
function registerSingleton<T>(
key: InjectionKey<T>,
value: T,
export function provideDependencies(
context: IApplicationContext,
api: VueDependencyInjectionApi = { provide, inject },
) {
provide(key, value);
const registerSingleton = <T>(key: InjectionKey<T>, value: T) => api.provide(key, value);
const registerTransient = <T>(
key: InjectionKey<() => T>,
factory: () => T,
) => api.provide(key, factory);
registerSingleton(InjectionKeys.useApplication, useApplication(context.app));
registerSingleton(InjectionKeys.useRuntimeEnvironment, RuntimeEnvironment.CurrentEnvironment);
registerTransient(InjectionKeys.useAutoUnsubscribedEvents, () => useAutoUnsubscribedEvents());
registerTransient(InjectionKeys.useCollectionState, () => {
const { events } = api.inject(InjectionKeys.useAutoUnsubscribedEvents)();
return useCollectionState(context, events);
});
}
function registerTransient<T>(
key: InjectionKey<() => T>,
factory: () => T,
) {
provide(key, factory);
export interface VueDependencyInjectionApi {
provide<T>(key: InjectionKey<T>, value: T): void;
inject<T>(key: InjectionKey<T>): T;
}

View File

@@ -0,0 +1,14 @@
import { ILogger } from '@/infrastructure/Log/ILogger';
import { IVueBootstrapper } from '../IVueBootstrapper';
import { ClientLoggerFactory } from '../ClientLoggerFactory';
export class AppInitializationLogger implements IVueBootstrapper {
constructor(
private readonly logger: ILogger = ClientLoggerFactory.Current.logger,
) { }
public bootstrap(): void {
// Do not remove [APP_INIT]; it's a marker used in tests.
this.logger.info('[APP_INIT] Application is initialized.');
}
}

View File

@@ -0,0 +1,15 @@
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { IVueBootstrapper } from '../IVueBootstrapper';
export class RuntimeSanityValidator implements IVueBootstrapper {
constructor(private readonly validator = validateRuntimeSanity) {
}
public bootstrap(): void {
this.validator({
validateEnvironmentVariables: true,
validateWindowVariables: true,
});
}
}

View File

@@ -1,8 +0,0 @@
import LiquorTree from 'liquor-tree';
import { VueConstructor, IVueBootstrapper } from '../IVueBootstrapper';
export class TreeBootstrapper implements IVueBootstrapper {
public bootstrap(vue: VueConstructor): void {
vue.use(LiquorTree);
}
}

View File

@@ -18,7 +18,6 @@ import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeBu
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { provideDependencies } from '../bootstrapping/DependencyProvider';
const singletonAppContext = await buildContext();
@@ -33,10 +32,6 @@ export default defineComponent({
},
setup() {
provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
validateRuntimeSanity({
validateMetadata: true,
validateEnvironment: true,
});
},
});
</script>

View File

@@ -59,7 +59,7 @@ import {
defineComponent, PropType, computed,
inject,
} from 'vue';
import { useApplicationKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import CodeInstruction from './CodeInstruction.vue';
@@ -77,7 +77,7 @@ export default defineComponent({
},
},
setup(props) {
const { info } = inject(useApplicationKey);
const { info } = inject(InjectionKeys.useApplication);
const appName = computed<string>(() => info.name);

View File

@@ -29,11 +29,10 @@
import {
defineComponent, ref, computed, inject,
} from 'vue';
import { useCollectionStateKey, useEnvironmentKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard';
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
import { Environment } from '@/infrastructure/Environment/Environment';
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
@@ -54,12 +53,13 @@ export default defineComponent({
},
setup() {
const {
currentState, currentContext, onStateChange, events,
} = inject(useCollectionStateKey)();
const { isDesktop } = inject(useEnvironmentKey);
currentState, currentContext, onStateChange,
} = inject(InjectionKeys.useCollectionState)();
const { os, isDesktop } = inject(InjectionKeys.useRuntimeEnvironment);
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const areInstructionsVisible = ref(false);
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop));
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop, os));
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
const hasCode = ref(false);
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
@@ -82,15 +82,18 @@ export default defineComponent({
}
onStateChange((newState) => {
updateCurrentCode(newState.code.current);
subscribeToCodeChanges(newState.code);
}, { immediate: true });
function subscribeToCodeChanges(code: IApplicationCode) {
hasCode.value = code.current && code.current.length > 0;
events.unsubscribeAll();
events.register(code.changed.on((newCode) => {
hasCode.value = newCode && newCode.code.length > 0;
}));
events.unsubscribeAllAndRegister([
code.changed.on((newCode) => updateCurrentCode(newCode.code)),
]);
}
function updateCurrentCode(code: string) {
hasCode.value = code && code.length > 0;
}
async function getCurrentCode(): Promise<IApplicationCode> {
@@ -122,8 +125,12 @@ function getDownloadInstructions(
return getInstructions(os, fileName);
}
function getCanRunState(selectedOs: OperatingSystem, isDesktopVersion: boolean): boolean {
const isRunningOnSelectedOs = selectedOs === Environment.CurrentEnvironment.os;
function getCanRunState(
selectedOs: OperatingSystem,
isDesktopVersion: boolean,
hostOs: OperatingSystem,
): boolean {
const isRunningOnSelectedOs = selectedOs === hostOs;
return isDesktopVersion && isRunningOnSelectedOs;
}

View File

@@ -13,7 +13,7 @@
import {
defineComponent, onUnmounted, onMounted, inject,
} from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
import { IScript } from '@/domain/IScript';
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
@@ -37,7 +37,8 @@ export default defineComponent({
NonCollapsing,
},
setup(props) {
const { onStateChange, currentState, events } = inject(useCollectionStateKey)();
const { onStateChange, currentState } = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const editorId = 'codeEditor';
let editor: ace.Ace.Editor | undefined;
@@ -61,20 +62,20 @@ export default defineComponent({
newState.collection.scripting.language,
);
const appCode = newState.code;
const innerCode = appCode.current || getDefaultCode(newState.collection.scripting.language);
editor.setValue(innerCode, 1);
events.unsubscribeAll();
events.register(appCode.changed.on((code) => updateCode(code)));
updateCode(appCode.current, newState.collection.scripting.language);
events.unsubscribeAllAndRegister([
appCode.changed.on((code) => handleCodeChange(code)),
]);
}
function updateCode(event: ICodeChangedEvent) {
function updateCode(code: string, language: ScriptingLanguage) {
const innerCode = code || getDefaultCode(language);
editor.setValue(innerCode, 1);
}
function handleCodeChange(event: ICodeChangedEvent) {
removeCurrentHighlighting();
if (event.isEmpty()) {
const defaultCode = getDefaultCode(currentState.value.collection.scripting.language);
editor.setValue(defaultCode, 1);
return;
}
editor.setValue(event.code, 1);
updateCode(event.code, currentState.value.collection.scripting.language);
if (event.addedScripts?.length > 0) {
reactToChanges(event, event.addedScripts);
} else if (event.changedScripts?.length > 0) {

View File

@@ -66,9 +66,10 @@
<script lang="ts">
import { defineComponent, ref, inject } from 'vue';
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
import { ICategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
import MenuOptionList from '../MenuOptionList.vue';
import MenuOptionListItem from '../MenuOptionListItem.vue';
import { SelectionType, SelectionTypeHandler } from './SelectionTypeHandler';
@@ -80,28 +81,27 @@ export default defineComponent({
TooltipWrapper,
},
setup() {
const { modifyCurrentState, onStateChange, events } = inject(useCollectionStateKey)();
const { modifyCurrentState, onStateChange } = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const currentSelection = ref(SelectionType.None);
let selectionTypeHandler: SelectionTypeHandler;
onStateChange(() => {
unregisterMutators();
modifyCurrentState((state) => {
registerStateMutator(state);
selectionTypeHandler = new SelectionTypeHandler(state);
updateSelections();
events.unsubscribeAllAndRegister([
subscribeAndUpdateSelections(state),
]);
});
}, { immediate: true });
function unregisterMutators() {
events.unsubscribeAll();
}
function registerStateMutator(state: ICategoryCollectionState) {
selectionTypeHandler = new SelectionTypeHandler(state);
updateSelections();
events.register(state.selection.changed.on(() => updateSelections()));
function subscribeAndUpdateSelections(
state: ICategoryCollectionState,
): IEventSubscription {
return state.selection.changed.on(() => updateSelections());
}
function selectType(type: SelectionType) {

View File

@@ -14,7 +14,7 @@
import {
defineComponent, computed, inject,
} from 'vue';
import { useApplicationKey, useCollectionStateKey } from '@/presentation/injectionSymbols';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import MenuOptionList from './MenuOptionList.vue';
import MenuOptionListItem from './MenuOptionListItem.vue';
@@ -30,8 +30,8 @@ export default defineComponent({
MenuOptionListItem,
},
setup() {
const { modifyCurrentContext, currentState } = inject(useCollectionStateKey)();
const { application } = inject(useApplicationKey);
const { modifyCurrentContext, currentState } = inject(InjectionKeys.useCollectionState)();
const { application } = inject(InjectionKeys.useApplication);
const allOses = computed<ReadonlyArray<IOsViewModel>>(() => (
application.getSupportedOsList() ?? [])

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