Compare commits

...

19 Commits

Author SHA1 Message Date
undergroundwires
0900492ccb win: fix Defender service #128 #385 #393 #402 #426
This commit adds disabling missing low-level Defender service/drivers,
improve disabling existing ones, and improve their documentation.

Key changes:

- Add disabling missing Defender services.
- Add disabling missing Defender processes.
- Add soft-deleting of missing service files
- Fix `ServiceKeepAlive` value #393, #426
- Add disabling system modification restrictions for persistent Disable
  service disabling.
- Recommend more Defender scripts on 'Strict' level

Other supporting changes:

- Add more documentation for related scripts.
- Move disabling `SecHealthUI` to disabling Windows Security.
- Fix `DisableService` attempting to disable the service even though its
  disabled.
- Add ability to disable service on revert in
  `DisableServiceInRegistry`.
- Improve categorization for simplicity, add new categories for new
  scripts.
- Add ability to run `DeleteRegistryValue` as `TrustedInstaller`.
- Rename some scripts/categories for simplicity and clarity.
2024-10-29 20:58:04 +01:00
undergroundwires
5db8c6b591 Fix HTML semantics in script run instructions
This commit corrects HTML semantic errors in browser instruction dialogs
displayed when a script is downloaded via browser.

Key changes:

- Fix HTML structure by removing `<ul>` and `<ol>` tags from `<p>`
  elements
- Replace `<code>` with `<kbd>` for keyboard inputs

Other supporting changes:

- Improve clarity readability of some instructions
- Add CSS styles for `<kbd>`
2024-10-17 14:42:15 +02:00
undergroundwires
9e8bad0084 Fix browser instructions appearing on desktop
Previously, the app showed browser download/run instructions on the
desktop version, which was irrelevant for desktop users. This change
improves the user experience by displaying these instructions only
in browser environments.

This change streamlines the script saving process for desktop users
while maintaining the necessary guidance for browser users.

The commit:

- Prevents the instruction modal from appearing on desktop
- Renames components for clarity (e.g., 'RunInstructions' to
  'BrowserRunInstructions')
- Adds a check to ensure browser environment before showing
  instructions
2024-10-14 13:03:44 +02:00
undergroundwires
eb8812b26e Update Saas and Vite to fix deprecation warnings
This commit updates Dart Sass to version 1.79.4 to address deprecation
warnings. It also updates Vite to 5.4.x to be able to use the modern
Sass compiler.

Changes:

- Update `saas` to latest
- Update `vite` from 5.3.x to 5.4.x
- Replace `lighten` and `darken` with `color.adjust` due
  to deprecation.
- Set Vite to use the modern compiler instead of the deprecated legacy
  one
- Pin `sass` to patch versions to prevent unexpected deprecations using
  tilde (~) for in package.json.
2024-10-13 02:13:52 +02:00
undergroundwires
3f56166655 Fix CI/CD runtime checks failing on Ubuntu 24.04
GitHub runners now use Ubuntu 24.04, which introduces two issues
affecting Electron application runtime checks:

1. AppArmor restrictions on unprivileged user namespaces
2. Outdated Mesa drivers

This commit resolves both with workarounds.

Changes:

- Disable AppArmor restrictions on unprivileged user namespaces:
  - Resolves the following error:
    ```
    [5475:1011/121711.489417:FATAL:setuid_sandbox_host.cc(158)] The SUID sandbox helper binary was found, but is not configured correctly. Rather than run without sandboxing I'm aborting now. You need to make sure that /tmp/.mount_privacv1kcOj/chrome-sandbox is owned by root and has mode 4755.
    ```
  - Related key Electron issues:
    - electron/electron#41066
    - electron/electron#42510
    - electron-userland/electron-builder#8440
- Update Mesa drivers
  - Fixes following errors:
    ```
    MESA: error: ZINK: failed to choose pdev
    glx: failed to create drisw screen
    ```
  - Installs latest Mesa drivers from Kisak PPA
2024-10-12 13:03:39 +02:00
undergroundwires
69e7e0adf1 Fix CI/CD fail by installing ImageMagick on runner
This commit addresses an issue where CI/CD jobs fail due to the removal of
`imagemagick` from GitHub's preinstalled software list for Ubuntu
runners. As a result, commands relying on `imagemagick` were not found.

To resolve this, the commit installs `imagemagick` across all platforms
(Linux, macOS, and Windows). This safeguards against future changes to
the preinstalled software list on GitHub runners.

This commit also centralizes installing of ImageMagick as its own action
for reusability.
2024-10-11 23:01:30 +02:00
undergroundwires
74378f74bf Hide code highlight and cursor until interaction
By default, the code area displays line highlighting (active line and
gutter indicators) and shows the cursor even before user interaction,
which clutters the initial UI.

This commit hides the highlighting and cursor until the user interacts
with the code area, providing a cleaner initial UI similar to modern
editors when code is first displayed.

When code is generated automatically, the code is already highlighted
with a custom marker. Therefore, indicating a specific line by default
is unnecessary and clutters the view.

Key changes:

- Hide active line highlighting before user interaction.
- Hide cursor before user interaction.

Other supporting changes:

- Refactor `TheCodeArea` to extract third-party component (`Ace`)
  related logic for better maintainability.
- Simplify code editor theme setting by removing the component property.
- Remove unnecessary `github` theme import for the code editor component
  as it's unused.
2024-10-10 17:16:32 +02:00
undergroundwires
2f31bc7b06 Fix file retention after updates on macOS #417
This fixes issue #417 where autoupdate installer files were not deleted
on macOS, leading to accumulation of old installers.

Key changes:

- Store update files in application-specific directory
- Clear update files directory on every app launch

Other supporting changes:

- Refactor file system operations to be more testable and reusable
- Improve separation of concerns in directory management
- Enhance dependency injection for auto-update logic
- Fix async completion to support `await` operations
- Add additional logging and revise some log messages during updates
2024-10-07 17:33:47 +02:00
undergroundwires
4e06d543b3 Add external URL linting for markdown files
This commit integrates `remark-lint-no-dead-urls` into the project's
linting process, improving documentation quality by checking for dead
links.

Key changes:

- Add NPM command to verify external URLs in markdown files
- Include new command in the main lint script

Other supporting changes:

- Replace archive.ph link with Wayback Machine for better verification
- Update `remark-lint-no-dead-urls` to latest version (2.0.0)
- Update Browserslist DB to latest to avoid build errors
2024-10-01 16:54:56 +02:00
undergroundwires
a536c6970f win: add disabling Phishing Protection #385
This commit adds options to disable Enhanced Phishing Protection
features in Defender SmartScreen. This includes disabling background
services, automatic data collection and various notification types.

Key changes:

- Add disabling of W11-only "Enhanced Phishing Protection"
- Add disabling of Web Threat Defense services.

Supporting changes:

- Add minimum version constraint for `DisablePerUserService`
- Use less characters in `RunPowerShellWithWindowsVersionConstraints` to
  avoid reaching the max batchfile line lengths.
2024-09-30 15:23:46 +02:00
undergroundwires
e17744faf0 Bump to TypeScript 5.5 and enable noImplicitAny
This commit upgrades TypeScript from 5.4 to 5.5 and enables the
`noImplicitAny` option for stricter type checking. It refactors code to
comply with `noImplicitAny` and adapts to new TypeScript features and
limitations.

Key changes:

- Migrate from TypeScript 5.4 to 5.5
- Enable `noImplicitAny` for stricter type checking
- Refactor code to comply with new TypeScript features and limitations

Other supporting changes:

- Refactor progress bar handling for type safety
- Drop 'I' prefix from interfaces to align with new code convention
- Update TypeScript target from `ES2017` and `ES2018`.
  This allows named capturing groups. Otherwise, new TypeScript compiler
  does not compile the project and shows the following error:
  ```
  ...
  TimestampedFilenameGenerator.spec.ts:105:23 - error TS1503: Named capturing groups are only available when targeting 'ES2018' or later
  const pattern = /^(?<timestamp>\d{4}-\d{2}-\d{2}_\d{2}-\d{2}-\d{2})-(?<scriptName>[^.]+?)(?:\.(?<extension>[^.]+))?$/;// timestamp-scriptName.extension
  ...
  ```
- Refactor usage of `electron-progressbar` for type safety and
  less complexity.
2024-09-26 16:07:37 +02:00
undergroundwires
a05a600071 win: add CLSID/COM object removal #412
This commit improves existing scripts (or adds new ones) to add COM
object removal to scripts. This fixes slow application launches that
occur when SmartScreen is removed by privacy.sexy, resolving #412.

Key changes:

- Introduce `SoftDeleteRegistryKey` to preserve complex registry trees
  and their permissions.
- Add missing CLSIDs for Defender/Windows Update components.

Other supporting changes:

- Improve documentation for related categories and scripts.
- Introduce categories as necessary to structure new scripts.
- Add supporting actions along with COM object removal, such as deleting
  related files or configuring registry settings.
- Add ability to constrain soft deletion of files based on Windows
  version.
- Shorten dependent functions to avoid hitting the max character limit
  in `SoftDeleteRegistryKey`.
2024-09-24 14:00:43 +02:00
undergroundwires
8b6067f83f Improve compiler output for line validation
This commit improves feedback when a line is too long to enhance
developer/maintainer productivity.

- Trim long lines in output to 500 characters
- Show character count exceeding max line length
- Refactor line formatting for better readability
2024-09-16 12:39:52 +02:00
undergroundwires
98e8dc0a67 win: add missing system apps #279 #316 #343
This commit adds missing system apps for Windows 10 19H2 to 22H2 and
Windows 11 21H2 to 23H2.

Changes:

- Add missing system apps.
- Improve documentation and naming of existing apps
- Add documentation about excluded system apps (#343)
- Adjust recommendation levels
- Enhance disabling of some apps with extra configurations

New apps added:

- Microsoft.MicrosoftEdge.Stable
- MicrosoftWindows.UndockedDevKit
- Microsoft.Windows.XGpuEjectDialog
- NarratorQuickStart

New apps excluded:

- Microsoft.Windows.StartMenuExperienceHost (#316)
- Microsoft.Windows.ShellExperienceHost (#316)
- MicrosoftWindows.Client.Core
- MicrosoftWindows.Client.CBS
- MicrosoftWindows.Client.FileExp
- Microsoft.Windows.Cortana

Other supporting changes:

- Add conditional store app removal when constraining Windows version
- Add more generated comments in code for better script readability
2024-08-30 11:51:20 +02:00
undergroundwires
6b8f6aae81 win: enable PowerShell as TI runs #128 #412 #421
Refactor Windows scripts to run as TrustedInstaller using PowerShell
instead of batch files. This improves code reuse and enables more
complex logic for system modifications.

Key changes:

- Add function to run any PowerShell script as TrustedInstaller
- Refactor existing functions to use new TrustedInstaller capability
- Enable soft deletion of protected registry keys and files (#412).
- Resolve issues with renaming Defender files (#128).

Other supporting changes:

- Enhance service disabling to handle dependent services
- Use base64 encoding of 'privacy.sexy' to avoid Defender alerts (#421).
- Add comments to generated code for better documentation
2024-08-28 14:01:54 +02:00
undergroundwires
dc5c87376b Add validation for max line length in compiler
This commit adds validation logic in compiler to check for max allowed
characters per line for scripts. This allows preventing bugs caused by
limitation of terminal emulators.

Other supporting changes:

- Rename/refactor related code for clarity and better maintainability.
- Drop `I` prefix from interfaces to align with latest convention.
- Refactor CodeValidator to be functional rather than object-oriented
  for simplicity.
- Refactor syntax definition construction to be functional and be part
  of rule for better separation of concerns.
- Refactored validation logic to use an enum-based factory pattern for
  improved maintainability and scalability.
2024-08-27 11:32:52 +02:00
undergroundwires
db090f3696 win: add disabling Defender core service #385
This commit adds disabling Microsoft Defender Core Service (MDCoreSvc)
and its related telemetry.

Key changes:

- Add disabling MDCoreSvc, resolving #385
- Add disabling its telemetry
- Add disabling its ECS integration

Supporting changes:

- Update script names/docs to clarify Defender Antivirus data
  collection
2024-08-23 12:12:14 +02:00
undergroundwires
aee24cdaa1 win: categorize disabling Defender components
This commit restructures disabling Defender components. This improves
organization and clarity for users by grouping related scripts together.
It also updates names and docs to match latest Defender branding.

Changes:

- Add new parent categories for disabling Defender Antivirus, user
  interface, Exploit Guard and Defender for Endpoint.
- Move relevant scripts under new categories.
- Update script names for clarity and consistency
- Add more documentation explaining Defender components.
- Reorder subcategories based on impact
- Simplify naming, e.g. "Defender" instead of "Microsoft Defender"
2024-08-21 13:02:23 +02:00
undergroundwires-bot
be0ab9b125 ⬆️ bump everywhere to 0.13.6 2024-08-13 12:01:49 +00:00
208 changed files with 17795 additions and 7075 deletions

View File

@@ -0,0 +1,12 @@
inputs:
project-root:
required: false
default: '.'
runs:
using: composite
steps:
-
name: Install ImageMagick
shell: bash
run: ./.github/actions/install-imagemagick/install-imagemagick.sh
working-directory: ${{ inputs.project-root }}

View File

@@ -0,0 +1,56 @@
#!/usr/bin/env bash
main() {
local install_command
if ! install_command=$(get_install_command); then
fatal_error 'Could not find available command to install'
fi
if ! eval "$install_command"; then
echo "Failed to install ImageMagick. Command: ${install_command}"
exit 1
fi
echo 'ImageMagick installation completed successfully'
}
get_install_command() {
case "$OSTYPE" in
darwin*)
ensure_command_exists 'brew'
echo 'brew install imagemagick'
;;
linux-gnu*)
if is_ubuntu; then
ensure_command_exists 'apt'
echo 'sudo apt install -y imagemagick'
else
fatal_error 'Unsupported Linux distribution'
fi
;;
msys*|cygwin*)
ensure_command_exists 'choco'
echo 'choco install -y imagemagick'
;;
*)
fatal_error "Unsupported operating system: $OSTYPE"
;;
esac
}
ensure_command_exists() {
local -r command="$1"
if ! command -v "$command" >/dev/null 2>&1; then
fatal_error "Command missing: $command"
fi
}
fatal_error() {
local -r error_message="$1"
>&2 echo "$error_message"
exit 1
}
is_ubuntu() {
[ -f /etc/os-release ] && grep -qi 'ubuntu' /etc/os-release
}
main

View File

@@ -26,9 +26,12 @@ jobs:
- -
name: Install dependencies name: Install dependencies
uses: ./.github/actions/npm-install-dependencies uses: ./.github/actions/npm-install-dependencies
-
name: Install ImageMagick # For screenshots
uses: ./.github/actions/install-imagemagick
- -
name: Configure Ubuntu name: Configure Ubuntu
if: contains(matrix.os, 'ubuntu') # macOS runner is missing Docker if: contains(matrix.os, 'ubuntu')
shell: bash shell: bash
run: |- run: |-
sudo apt update sudo apt update
@@ -56,11 +59,20 @@ jobs:
sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 & sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
echo "DISPLAY=:99" >> $GITHUB_ENV echo "DISPLAY=:99" >> $GITHUB_ENV
# Install ImageMagick for screenshots
sudo apt install -y imagemagick
# Install xdotool and xprop (from x11-utils) for window title capturing # Install xdotool and xprop (from x11-utils) for window title capturing
sudo apt install -y xdotool x11-utils sudo apt install -y xdotool x11-utils
# Workaround for Electron AppImage apps failing to initialize on Ubuntu 24.04 due to AppArmor restrictions
# Disables unprivileged user namespaces restriction to allow Electron apps to run
# Reference: https://github.com/electron/electron/issues/42510
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
# Workaround for Mesa driver issues on Ubuntu 24.04
# Installs latest Mesa drivers from Kisak PPA
# Reference: https://askubuntu.com/q/1516040
sudo add-apt-repository ppa:kisak/kisak-mesa
sudo apt update
sudo apt upgrade
- -
name: Test name: Test
shell: bash shell: bash

View File

@@ -11,8 +11,9 @@ jobs:
- npm run lint:eslint - npm run lint:eslint
- npm run lint:yaml - npm run lint:yaml
- npm run lint:md - npm run lint:md
- npm run lint:md:relative-urls
- npm run lint:md:consistency - npm run lint:md:consistency
- npm run lint:md:relative-urls
- npm run lint:md:external-urls
os: [ macos, ubuntu, windows ] os: [ macos, ubuntu, windows ]
fail-fast: false # Still interested to see results from other combinations fail-fast: false # Still interested to see results from other combinations
steps: steps:

View File

@@ -9,16 +9,15 @@ jobs:
runs-on: ${{ matrix.os }}-latest runs-on: ${{ matrix.os }}-latest
strategy: strategy:
matrix: matrix:
os: [ macos, ubuntu, windows ] os: [macos, ubuntu, windows]
fail-fast: false # Still interested to see results from other combinations fail-fast: false # Still interested to see results from other combinations
steps: steps:
- -
name: Checkout name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- -
name: Install ImageMagick on macOS name: Install ImageMagick
if: matrix.os == 'macos' uses: ./.github/actions/install-imagemagick
run: brew install imagemagick
- -
name: Setup node name: Setup node
uses: ./.github/actions/setup-node uses: ./.github/actions/setup-node

View File

@@ -1,5 +1,38 @@
# Changelog # Changelog
## 0.13.6 (2024-08-13)
* win: improve service disabling as TrustedInstaller | [5d365f6](https://github.com/undergroundwires/privacy.sexy/commit/5d365f65fa0e34925b16b2eac2af53c31e34e99a)
* Fix documentation button spacing on small screens | [70959cc](https://github.com/undergroundwires/privacy.sexy/commit/70959ccadafac5abcfa83e90cdb0537890b05f14)
* Fix close button overlap by scrollbar | [19ea8db](https://github.com/undergroundwires/privacy.sexy/commit/19ea8dbc5bc2dc436200cd40bf2a84c3fc3c6471)
* win: refactor version-specific actions | [0239b52](https://github.com/undergroundwires/privacy.sexy/commit/0239b523859d5c2b80033cc03f0248a9af35f28f)
* win: support Microsoft Store Firefox installations | [8d7a7eb](https://github.com/undergroundwires/privacy.sexy/commit/8d7a7eb434b2d83e32fa758db7e6798849bad41c)
* Refactor text utilities and expand their usage | [851917e](https://github.com/undergroundwires/privacy.sexy/commit/851917e049c41c679644ddbe8ad4b6e45e5c8f35)
* Bump dependencies to latest | [dd7239b](https://github.com/undergroundwires/privacy.sexy/commit/dd7239b8c14027274926279a4c8c7e5845b55558)
* Refactor styles to match new CSS nesting behavior | [abe03ce](https://github.com/undergroundwires/privacy.sexy/commit/abe03cef3f691f6e56faee991cd2da9c45244279)
* Improve compiler error display for latest Chromium | [b16e136](https://github.com/undergroundwires/privacy.sexy/commit/b16e13678ce1b8a6871eba8196e82bb321410067)
* Fix intermittent `ModalDialog` unit test failures | [a650558](https://github.com/undergroundwires/privacy.sexy/commit/a6505587bf4a448f5f3de930004a95ee203416b8)
* Ensure tests do not log warning or errors | [ae0165f](https://github.com/undergroundwires/privacy.sexy/commit/ae0165f1fe7dba9dd8ddaa1afa722a939772d3b6)
* win: improve disabling SmartScreen #385 | [11e566d](https://github.com/undergroundwires/privacy.sexy/commit/11e566d0e5177214a2600f3fd2097aea62373b24)
* win: unify registry setting as TrustedInstaller | [8526d25](https://github.com/undergroundwires/privacy.sexy/commit/8526d2510b34cbd7e79342f79d444419f601b186)
* win: improve, fix, restructure CEIP disabling | [c2d3cdd](https://github.com/undergroundwires/privacy.sexy/commit/c2d3cddc47d8d4b34bff63d959612919fa971012)
* win: centralize, improve Defender data collection | [b185255](https://github.com/undergroundwires/privacy.sexy/commit/b185255a0a72d5bfa96d6cf60f868ecc67149d68)
* win: fix and document VStudio license removal | [109fc01](https://github.com/undergroundwires/privacy.sexy/commit/109fc01c9a047002c4309e7f8a2ca4647c494a8a)
* win: improve registry/recent cleaning | [48d97af](https://github.com/undergroundwires/privacy.sexy/commit/48d97afdf6c2964cab7951208e1b0a02c3fd4c9b)
* Relax linting to allow null recommendation | [6fbc816](https://github.com/undergroundwires/privacy.sexy/commit/6fbc81675f7f063c4ee2502b8d9f169aacb39ae4)
* Refactor executable IDs to use strings #262 | [ded55a6](https://github.com/undergroundwires/privacy.sexy/commit/ded55a66d6044a03d4e18330e146b69d159509a3)
* win: fix, improve and unify Windows version logic | [f89c232](https://github.com/undergroundwires/privacy.sexy/commit/f89c2322b05d19b82914b20416ecefd7bc7e3702)
* Fix PowerShell code block inlining in compiler | [d77c3cb](https://github.com/undergroundwires/privacy.sexy/commit/d77c3cbbe212d9929e083181cc331b45d01e2883)
* win: improve registry value deletion #381 | [55c23e9](https://github.com/undergroundwires/privacy.sexy/commit/55c23e9d4cee3b7f74c26a4ac8516535048d67f2)
* win: improve folder hiding in "This PC" #16 | [e8add5e](https://github.com/undergroundwires/privacy.sexy/commit/e8add5ec08d2e8b7636cc9c8f0f9a33e4b004265)
* win: improve Microsoft Edge associations removal | [c2f4b68](https://github.com/undergroundwires/privacy.sexy/commit/c2f4b6878635e97f9c4be7bf2ee194a2deebb38a)
* win: unify registry data setting, fix #380 | [4cea6b2](https://github.com/undergroundwires/privacy.sexy/commit/4cea6b26ec2717c792c2471cc587f370274f90c4)
* win: improve disabling NCSI #189, #216, #279 | [c7e57b8](https://github.com/undergroundwires/privacy.sexy/commit/c7e57b8913f409a1c149ba598dc2f8786df0f9a9)
* win, mac: fix minor typos, formatting, dead URLs | [29e1069](https://github.com/undergroundwires/privacy.sexy/commit/29e1069bf2bc317e3c255b38c1ba0ab078b42d98)
* win: fix, constrain and document WNS #227 #314 | [50ba00b](https://github.com/undergroundwires/privacy.sexy/commit/50ba00b0af6232fc9187532635b04c4d9d9a68af)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.13.5...0.13.6)
## 0.13.5 (2024-06-26) ## 0.13.5 (2024-06-26)
* ci/cd: centralize and bump artifact uploads | [22d6c79](https://github.com/undergroundwires/privacy.sexy/commit/22d6c7991eb2c138578a7d41950f301906dbf703) * ci/cd: centralize and bump artifact uploads | [22d6c79](https://github.com/undergroundwires/privacy.sexy/commit/22d6c7991eb2c138578a7d41950f301906dbf703)

View File

@@ -122,7 +122,7 @@
## Get started ## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy). - 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-Setup-0.13.5.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-0.13.5.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.5/privacy.sexy-0.13.5.AppImage). For more options, see [here](#additional-install-options). - 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-Setup-0.13.6.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-0.13.6.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.13.6/privacy.sexy-0.13.6.AppImage). For more options, see [here](#additional-install-options).
See also: See also:

View File

@@ -34,8 +34,10 @@ The desktop version ensures secure delivery through cryptographic signatures and
[Security is a top priority](./../../SECURITY.md#update-security-and-integrity) at privacy.sexy. [Security is a top priority](./../../SECURITY.md#update-security-and-integrity) at privacy.sexy.
> **Note for macOS users:** On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs. > **Note for macOS users:**
> On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs.
> Users get notified about updates but might need to complete the installation manually. > Users get notified about updates but might need to complete the installation manually.
> Updater stores update installation files temporarily at `$HOME/Library/Application Support/privacy.sexy/updates`.
> Consider [donating](https://github.com/sponsors/undergroundwires) to help improve this process ❤️. > Consider [donating](https://github.com/sponsors/undergroundwires) to help improve this process ❤️.
### Logging ### Logging

View File

@@ -39,6 +39,7 @@ See [ci-cd.md](./ci-cd.md) for more information.
- Markdown: `npm run lint:md` - Markdown: `npm run lint:md`
- Markdown consistency `npm run lint:md:consistency` - Markdown consistency `npm run lint:md:consistency`
- Markdown relative URLs: `npm run lint:md:relative-urls` - Markdown relative URLs: `npm run lint:md:relative-urls`
- Markdown external URLs: `npm run lint:md:external-urls`
- JavaScript/TypeScript: `npm run lint:eslint` - JavaScript/TypeScript: `npm run lint:eslint`
- Yaml: `npm run lint:yaml` - Yaml: `npm run lint:yaml`

4718
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.13.5", "version": "0.13.6",
"private": true, "private": true,
"slogan": "Privacy is sexy", "slogan": "Privacy is sexy",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.", "description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy.",
@@ -14,7 +14,7 @@
"test:integration": "vitest run --dir tests/integration", "test:integration": "vitest run --dir tests/integration",
"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: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\"", "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 && npm run lint:pylint", "lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:md:external-urls && npm run lint:eslint && npm run lint:yaml && npm run lint:pylint",
"install-deps": "node scripts/npm-install.js", "install-deps": "node scripts/npm-install.js",
"icons:build": "node scripts/logo-update.js", "icons:build": "node scripts/logo-update.js",
"check:desktop": "vitest run --dir tests/checks/desktop-runtime-errors --environment node", "check:desktop": "vitest run --dir tests/checks/desktop-runtime-errors --environment node",
@@ -28,6 +28,7 @@
"lint:md": "markdownlint **/*.md --ignore node_modules", "lint:md": "markdownlint **/*.md --ignore node_modules",
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent", "lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
"lint:md:relative-urls": "remark . --frail --use remark-validate-links", "lint:md:relative-urls": "remark . --frail --use remark-validate-links",
"lint:md:external-urls": "remark . --frail --use remark-lint-no-dead-urls",
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml", "lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
"lint:pylint": "pylint **/*.py", "lint:pylint": "pylint **/*.py",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
@@ -71,15 +72,15 @@
"markdownlint-cli": "^0.41.0", "markdownlint-cli": "^0.41.0",
"postcss": "^8.4.39", "postcss": "^8.4.39",
"remark-cli": "^12.0.1", "remark-cli": "^12.0.1",
"remark-lint-no-dead-urls": "^1.1.0", "remark-lint-no-dead-urls": "^2.0.0",
"remark-preset-lint-consistent": "^6.0.0", "remark-preset-lint-consistent": "^6.0.0",
"remark-validate-links": "^13.0.1", "remark-validate-links": "^13.0.1",
"sass": "^1.77.8", "sass": "~1.79.4",
"start-server-and-test": "^2.0.4", "start-server-and-test": "^2.0.4",
"terser": "^5.31.3", "terser": "^5.31.3",
"tslib": "^2.6.3", "tslib": "^2.6.3",
"typescript": "^5.4.5", "typescript": "~5.5.4",
"vite": "^5.3.4", "vite": "^5.4.8",
"vitest": "^2.0.3", "vitest": "^2.0.3",
"vue-tsc": "^2.0.26", "vue-tsc": "^2.0.26",
"yaml-lint": "^1.7.0" "yaml-lint": "^1.7.0"

View File

@@ -33,23 +33,25 @@ function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
if (!casedValue) { if (!casedValue) {
throw new Error(`unknown ${enumName}: "${value}"`); throw new Error(`unknown ${enumName}: "${value}"`);
} }
return enumVariable[casedValue as keyof typeof enumVariable]; return enumVariable[casedValue as keyof EnumVariable<T, TEnumValue>];
} }
export function getEnumNames export function getEnumNames
<T extends EnumType, TEnumValue extends EnumType>( <T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>,
): string[] { ): (string & keyof EnumVariable<T, TEnumValue>)[] {
return Object return Object
.values(enumVariable) .values(enumVariable)
.filter((enumMember): enumMember is string => isString(enumMember)); .filter((
enumMember,
): enumMember is string & (keyof EnumVariable<T, TEnumValue>) => isString(enumMember));
} }
export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>( export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>,
): TEnumValue[] { ): TEnumValue[] {
return getEnumNames(enumVariable) return getEnumNames(enumVariable)
.map((level) => enumVariable[level]) as TEnumValue[]; .map((name) => enumVariable[name]) as TEnumValue[];
} }
export function assertInRange<T extends EnumType, TEnumValue extends EnumType>( export function assertInRange<T extends EnumType, TEnumValue extends EnumType>(

View File

@@ -17,7 +17,7 @@ export class ApplicationContext implements IApplicationContext {
public currentOs: OperatingSystem; public currentOs: OperatingSystem;
public get state(): ICategoryCollectionState { public get state(): ICategoryCollectionState {
return this.states[this.collection.os]; return this.getState(this.collection.os);
} }
private readonly states: StateMachine; private readonly states: StateMachine;
@@ -26,30 +26,51 @@ export class ApplicationContext implements IApplicationContext {
public readonly app: IApplication, public readonly app: IApplication,
initialContext: OperatingSystem, initialContext: OperatingSystem,
) { ) {
this.setContext(initialContext);
this.states = initializeStates(app); this.states = initializeStates(app);
this.changeContext(initialContext);
} }
public changeContext(os: OperatingSystem): void { public changeContext(os: OperatingSystem): void {
assertInRange(os, OperatingSystem);
if (this.currentOs === os) { if (this.currentOs === os) {
return; return;
} }
const collection = this.app.getCollection(os);
this.collection = collection;
const event: IApplicationContextChangedEvent = { const event: IApplicationContextChangedEvent = {
newState: this.states[os], newState: this.getState(os),
oldState: this.states[this.currentOs], oldState: this.getState(this.currentOs),
}; };
this.setContext(os);
this.contextChanged.notify(event); this.contextChanged.notify(event);
}
private setContext(os: OperatingSystem): void {
validateOperatingSystem(os, this.app);
this.collection = this.app.getCollection(os);
this.currentOs = os; this.currentOs = os;
} }
private getState(os: OperatingSystem): ICategoryCollectionState {
const state = this.states.get(os);
if (!state) {
throw new Error(`Operating system "${OperatingSystem[os]}" state is unknown.`);
}
return state;
}
}
function validateOperatingSystem(
os: OperatingSystem,
app: IApplication,
): void {
assertInRange(os, OperatingSystem);
if (!app.getSupportedOsList().includes(os)) {
throw new Error(`Operating system "${OperatingSystem[os]}" is not supported.`);
}
} }
function initializeStates(app: IApplication): StateMachine { function initializeStates(app: IApplication): StateMachine {
const machine = new Map<OperatingSystem, ICategoryCollectionState>(); const machine = new Map<OperatingSystem, ICategoryCollectionState>();
for (const collection of app.collections) { for (const collection of app.collections) {
machine[collection.os] = new CategoryCollectionState(collection); machine.set(collection.os, new CategoryCollectionState(collection));
} }
return machine; return machine;
} }

View File

@@ -7,7 +7,7 @@ import { createEnumParser, type EnumParser } from '../Common/Enum';
import { parseCategory, type CategoryParser } from './Executable/CategoryParser'; import { parseCategory, type CategoryParser } from './Executable/CategoryParser';
import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser'; import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser';
import { createTypeValidator, type TypeValidator } from './Common/TypeValidator'; import { createTypeValidator, type TypeValidator } from './Common/TypeValidator';
import { createCollectionUtilities, type CategoryCollectionSpecificUtilitiesFactory } from './Executable/CategoryCollectionSpecificUtilities'; import { createCategoryCollectionContext, type CategoryCollectionContextFactory } from './Executable/CategoryCollectionContext';
export const parseCategoryCollection: CategoryCollectionParser = ( export const parseCategoryCollection: CategoryCollectionParser = (
content, content,
@@ -16,9 +16,9 @@ export const parseCategoryCollection: CategoryCollectionParser = (
) => { ) => {
validateCollection(content, utilities.validator); validateCollection(content, utilities.validator);
const scripting = utilities.parseScriptingDefinition(content.scripting, projectDetails); const scripting = utilities.parseScriptingDefinition(content.scripting, projectDetails);
const collectionUtilities = utilities.createUtilities(content.functions, scripting); const collectionContext = utilities.createContext(content.functions, scripting.language);
const categories = content.actions.map( const categories = content.actions.map(
(action) => utilities.parseCategory(action, collectionUtilities), (action) => utilities.parseCategory(action, collectionContext),
); );
const os = utilities.osParser.parseEnum(content.os, 'os'); const os = utilities.osParser.parseEnum(content.os, 'os');
const collection = utilities.createCategoryCollection({ const collection = utilities.createCategoryCollection({
@@ -60,7 +60,7 @@ interface CategoryCollectionParserUtilities {
readonly osParser: EnumParser<OperatingSystem>; readonly osParser: EnumParser<OperatingSystem>;
readonly validator: TypeValidator; readonly validator: TypeValidator;
readonly parseScriptingDefinition: ScriptingDefinitionParser; readonly parseScriptingDefinition: ScriptingDefinitionParser;
readonly createUtilities: CategoryCollectionSpecificUtilitiesFactory; readonly createContext: CategoryCollectionContextFactory;
readonly parseCategory: CategoryParser; readonly parseCategory: CategoryParser;
readonly createCategoryCollection: CategoryCollectionFactory; readonly createCategoryCollection: CategoryCollectionFactory;
} }
@@ -69,7 +69,7 @@ const DefaultUtilities: CategoryCollectionParserUtilities = {
osParser: createEnumParser(OperatingSystem), osParser: createEnumParser(OperatingSystem),
validator: createTypeValidator(), validator: createTypeValidator(),
parseScriptingDefinition, parseScriptingDefinition,
createUtilities: createCollectionUtilities, createContext: createCategoryCollectionContext,
parseCategory, parseCategory,
createCategoryCollection: (...args) => new CategoryCollection(...args), createCategoryCollection: (...args) => new CategoryCollection(...args),
}; };

View File

@@ -0,0 +1,33 @@
import type { FunctionData } from '@/application/collections/';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { createScriptCompiler, type ScriptCompilerFactory } from './Script/Compiler/ScriptCompilerFactory';
import type { ScriptCompiler } from './Script/Compiler/ScriptCompiler';
export interface CategoryCollectionContext {
readonly compiler: ScriptCompiler;
readonly language: ScriptingLanguage;
}
export interface CategoryCollectionContextFactory {
(
functionsData: ReadonlyArray<FunctionData> | undefined,
language: ScriptingLanguage,
compilerFactory?: ScriptCompilerFactory,
): CategoryCollectionContext;
}
export const createCategoryCollectionContext: CategoryCollectionContextFactory = (
functionsData: ReadonlyArray<FunctionData> | undefined,
language: ScriptingLanguage,
compilerFactory: ScriptCompilerFactory = createScriptCompiler,
) => {
return {
compiler: compilerFactory({
categoryContext: {
functions: functionsData ?? [],
language,
},
}),
language,
};
};

View File

@@ -1,35 +0,0 @@
import type { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import type { FunctionData } from '@/application/collections/';
import { ScriptCompiler } from './Script/Compiler/ScriptCompiler';
import { SyntaxFactory } from './Script/Validation/Syntax/SyntaxFactory';
import type { IScriptCompiler } from './Script/Compiler/IScriptCompiler';
import type { ILanguageSyntax } from './Script/Validation/Syntax/ILanguageSyntax';
import type { ISyntaxFactory } from './Script/Validation/Syntax/ISyntaxFactory';
export interface CategoryCollectionSpecificUtilities {
readonly compiler: IScriptCompiler;
readonly syntax: ILanguageSyntax;
}
export const createCollectionUtilities: CategoryCollectionSpecificUtilitiesFactory = (
functionsData: ReadonlyArray<FunctionData> | undefined,
scripting: IScriptingDefinition,
syntaxFactory: ISyntaxFactory = new SyntaxFactory(),
) => {
const syntax = syntaxFactory.create(scripting.language);
return {
compiler: new ScriptCompiler({
functions: functionsData ?? [],
syntax,
}),
syntax,
};
};
export interface CategoryCollectionSpecificUtilitiesFactory {
(
functionsData: ReadonlyArray<FunctionData> | undefined,
scripting: IScriptingDefinition,
syntaxFactory?: ISyntaxFactory,
): CategoryCollectionSpecificUtilities;
}

View File

@@ -9,16 +9,16 @@ import { parseDocs, type DocsParser } from './DocumentationParser';
import { parseScript, type ScriptParser } from './Script/ScriptParser'; import { parseScript, type ScriptParser } from './Script/ScriptParser';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator';
import { ExecutableType } from './Validation/ExecutableType'; import { ExecutableType } from './Validation/ExecutableType';
import type { CategoryCollectionSpecificUtilities } from './CategoryCollectionSpecificUtilities'; import type { CategoryCollectionContext } from './CategoryCollectionContext';
export const parseCategory: CategoryParser = ( export const parseCategory: CategoryParser = (
category: CategoryData, category: CategoryData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities, categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities,
) => { ) => {
return parseCategoryRecursively({ return parseCategoryRecursively({
categoryData: category, categoryData: category,
collectionUtilities, collectionContext,
categoryUtilities, categoryUtilities,
}); });
}; };
@@ -26,14 +26,14 @@ export const parseCategory: CategoryParser = (
export interface CategoryParser { export interface CategoryParser {
( (
category: CategoryData, category: CategoryData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
categoryUtilities?: CategoryParserUtilities, categoryUtilities?: CategoryParserUtilities,
): Category; ): Category;
} }
interface CategoryParseContext { interface CategoryParseContext {
readonly categoryData: CategoryData; readonly categoryData: CategoryData;
readonly collectionUtilities: CategoryCollectionSpecificUtilities; readonly collectionContext: CategoryCollectionContext;
readonly parentCategory?: CategoryData; readonly parentCategory?: CategoryData;
readonly categoryUtilities: CategoryParserUtilities; readonly categoryUtilities: CategoryParserUtilities;
} }
@@ -52,7 +52,7 @@ function parseCategoryRecursively(
children, children,
parent: context.categoryData, parent: context.categoryData,
categoryUtilities: context.categoryUtilities, categoryUtilities: context.categoryUtilities,
collectionUtilities: context.collectionUtilities, collectionContext: context.collectionContext,
}); });
} }
try { try {
@@ -82,7 +82,7 @@ function ensureValidCategory(
}); });
validator.assertType((v) => v.assertObject({ validator.assertType((v) => v.assertObject({
value: category, value: category,
valueName: `Category '${category.category}'` ?? 'Category', valueName: category.category ? `Category '${category.category}'` : 'Category',
allowedProperties: [ allowedProperties: [
'docs', 'children', 'category', 'docs', 'children', 'category',
], ],
@@ -104,7 +104,7 @@ interface ExecutableParseContext {
readonly data: ExecutableData; readonly data: ExecutableData;
readonly children: CategoryChildren; readonly children: CategoryChildren;
readonly parent: CategoryData; readonly parent: CategoryData;
readonly collectionUtilities: CategoryCollectionSpecificUtilities; readonly collectionContext: CategoryCollectionContext;
readonly categoryUtilities: CategoryParserUtilities; readonly categoryUtilities: CategoryParserUtilities;
} }
@@ -124,13 +124,13 @@ function parseUnknownExecutable(context: ExecutableParseContext) {
if (isCategory(context.data)) { if (isCategory(context.data)) {
const subCategory = parseCategoryRecursively({ const subCategory = parseCategoryRecursively({
categoryData: context.data, categoryData: context.data,
collectionUtilities: context.collectionUtilities, collectionContext: context.collectionContext,
parentCategory: context.parent, parentCategory: context.parent,
categoryUtilities: context.categoryUtilities, categoryUtilities: context.categoryUtilities,
}); });
context.children.subcategories.push(subCategory); context.children.subcategories.push(subCategory);
} else { // A script } else { // A script
const script = context.categoryUtilities.parseScript(context.data, context.collectionUtilities); const script = context.categoryUtilities.parseScript(context.data, context.collectionContext);
context.children.subscripts.push(script); context.children.subscripts.push(script);
} }
} }

View File

@@ -2,14 +2,12 @@ import type {
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData,
CallInstruction, ParameterDefinitionData, CallInstruction, ParameterDefinitionData,
} from '@/application/collections/'; } from '@/application/collections/';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers'; import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction'; import { createFunctionWithInlineCode, createCallerFunction } from './SharedFunction';
import { SharedFunctionCollection } from './SharedFunctionCollection'; import { SharedFunctionCollection } from './SharedFunctionCollection';
import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser'; import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser';
@@ -23,14 +21,14 @@ import type { ISharedFunction } from './ISharedFunction';
export interface SharedFunctionsParser { export interface SharedFunctionsParser {
( (
functions: readonly FunctionData[], functions: readonly FunctionData[],
syntax: ILanguageSyntax, language: ScriptingLanguage,
utilities?: SharedFunctionsParsingUtilities, utilities?: SharedFunctionsParsingUtilities,
): ISharedFunctionCollection; ): ISharedFunctionCollection;
} }
export const parseSharedFunctions: SharedFunctionsParser = ( export const parseSharedFunctions: SharedFunctionsParser = (
functions: readonly FunctionData[], functions: readonly FunctionData[],
syntax: ILanguageSyntax, language: ScriptingLanguage,
utilities = DefaultUtilities, utilities = DefaultUtilities,
) => { ) => {
const collection = new SharedFunctionCollection(); const collection = new SharedFunctionCollection();
@@ -39,7 +37,7 @@ export const parseSharedFunctions: SharedFunctionsParser = (
} }
ensureValidFunctions(functions); ensureValidFunctions(functions);
return functions return functions
.map((func) => parseFunction(func, syntax, utilities)) .map((func) => parseFunction(func, language, utilities))
.reduce((acc, func) => { .reduce((acc, func) => {
acc.addFunction(func); acc.addFunction(func);
return acc; return acc;
@@ -49,7 +47,7 @@ export const parseSharedFunctions: SharedFunctionsParser = (
const DefaultUtilities: SharedFunctionsParsingUtilities = { const DefaultUtilities: SharedFunctionsParsingUtilities = {
wrapError: wrapErrorWithAdditionalContext, wrapError: wrapErrorWithAdditionalContext,
parseParameter: parseFunctionParameter, parseParameter: parseFunctionParameter,
codeValidator: CodeValidator.instance, codeValidator: validateCode,
createParameterCollection: createFunctionParameterCollection, createParameterCollection: createFunctionParameterCollection,
parseFunctionCalls, parseFunctionCalls,
}; };
@@ -57,20 +55,20 @@ const DefaultUtilities: SharedFunctionsParsingUtilities = {
interface SharedFunctionsParsingUtilities { interface SharedFunctionsParsingUtilities {
readonly wrapError: ErrorWithContextWrapper; readonly wrapError: ErrorWithContextWrapper;
readonly parseParameter: FunctionParameterParser; readonly parseParameter: FunctionParameterParser;
readonly codeValidator: ICodeValidator; readonly codeValidator: CodeValidator;
readonly createParameterCollection: FunctionParameterCollectionFactory; readonly createParameterCollection: FunctionParameterCollectionFactory;
readonly parseFunctionCalls: FunctionCallsParser; readonly parseFunctionCalls: FunctionCallsParser;
} }
function parseFunction( function parseFunction(
data: FunctionData, data: FunctionData,
syntax: ILanguageSyntax, language: ScriptingLanguage,
utilities: SharedFunctionsParsingUtilities, utilities: SharedFunctionsParsingUtilities,
): ISharedFunction { ): ISharedFunction {
const { name } = data; const { name } = data;
const parameters = parseParameters(data, utilities); const parameters = parseParameters(data, utilities);
if (hasCode(data)) { if (hasCode(data)) {
validateCode(data, syntax, utilities.codeValidator); validateNonEmptyCode(data, language, utilities.codeValidator);
return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode); return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode);
} }
// Has call // Has call
@@ -78,16 +76,20 @@ function parseFunction(
return createCallerFunction(name, parameters, calls); return createCallerFunction(name, parameters, calls);
} }
function validateCode( function validateNonEmptyCode(
data: CodeFunctionData, data: CodeFunctionData,
syntax: ILanguageSyntax, language: ScriptingLanguage,
validator: ICodeValidator, validate: CodeValidator,
): void { ): void {
filterEmptyStrings([data.code, data.revertCode]) filterEmptyStrings([data.code, data.revertCode])
.forEach( .forEach(
(code) => validator.throwIfInvalid( (code) => validate(
code, code,
[new NoEmptyLines(), new NoDuplicatedLines(syntax)], language,
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoDuplicatedLines,
],
), ),
); );
} }

View File

@@ -1,7 +0,0 @@
import type { ScriptData } from '@/application/collections/';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
export interface IScriptCompiler {
canCompile(script: ScriptData): boolean;
compile(script: ScriptData): ScriptCode;
}

View File

@@ -1,86 +1,7 @@
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/'; import type { ScriptData } from '@/application/collections/';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
import type { CompiledCode } from './Function/Call/Compiler/CompiledCode';
import type { IScriptCompiler } from './IScriptCompiler';
import type { ISharedFunctionCollection } from './Function/ISharedFunctionCollection';
import type { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompiler';
interface ScriptCompilerUtilities { export interface ScriptCompiler {
readonly sharedFunctionsParser: SharedFunctionsParser; canCompile(script: ScriptData): boolean;
readonly callCompiler: FunctionCallCompiler; compile(script: ScriptData): ScriptCode;
readonly codeValidator: ICodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly scriptCodeFactory: ScriptCodeFactory;
}
const DefaultUtilities: ScriptCompilerUtilities = {
sharedFunctionsParser: parseSharedFunctions,
callCompiler: FunctionCallSequenceCompiler.instance,
codeValidator: CodeValidator.instance,
wrapError: wrapErrorWithAdditionalContext,
scriptCodeFactory: createScriptCode,
};
interface CategoryCollectionDataContext {
readonly functions: readonly FunctionData[];
readonly syntax: ILanguageSyntax;
}
export class ScriptCompiler implements IScriptCompiler {
private readonly functions: ISharedFunctionCollection;
constructor(
categoryContext: CategoryCollectionDataContext,
private readonly utilities: ScriptCompilerUtilities = DefaultUtilities,
) {
this.functions = this.utilities.sharedFunctionsParser(
categoryContext.functions,
categoryContext.syntax,
);
}
public canCompile(script: ScriptData): boolean {
return hasCall(script);
}
public compile(script: ScriptData): ScriptCode {
try {
if (!hasCall(script)) {
throw new Error('Script does include any calls.');
}
const calls = parseFunctionCalls(script.call);
const compiledCode = this.utilities.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(compiledCode, this.utilities.codeValidator);
return this.utilities.scriptCodeFactory(
compiledCode.code,
compiledCode.revertCode,
);
} catch (error) {
throw this.utilities.wrapError(error, `Failed to compile script: ${script.name}`);
}
}
}
function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void {
filterEmptyStrings([compiledCode.code, compiledCode.revertCode])
.forEach(
(code) => validator.throwIfInvalid(
code,
[new NoEmptyLines()],
),
);
}
function hasCall(data: ScriptData): data is ScriptData & CallInstruction {
return (data as CallInstruction).call !== undefined;
} }

View File

@@ -0,0 +1,119 @@
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import { createScriptCode, type ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { FunctionCallSequenceCompiler } from './Function/Call/Compiler/FunctionCallSequenceCompiler';
import { parseFunctionCalls } from './Function/Call/FunctionCallsParser';
import { parseSharedFunctions, type SharedFunctionsParser } from './Function/SharedFunctionsParser';
import type { CompiledCode } from './Function/Call/Compiler/CompiledCode';
import type { ScriptCompiler } from './ScriptCompiler';
import type { ISharedFunctionCollection } from './Function/ISharedFunctionCollection';
import type { FunctionCallCompiler } from './Function/Call/Compiler/FunctionCallCompiler';
export interface ScriptCompilerInitParameters {
readonly categoryContext: CategoryCollectionDataContext;
readonly utilities?: ScriptCompilerUtilities;
}
export interface ScriptCompilerFactory {
(parameters: ScriptCompilerInitParameters): ScriptCompiler;
}
export const createScriptCompiler: ScriptCompilerFactory = (
parameters,
) => {
return new FunctionCallScriptCompiler(
parameters.categoryContext,
parameters.utilities ?? DefaultUtilities,
);
};
interface ScriptCompilerUtilities {
readonly sharedFunctionsParser: SharedFunctionsParser;
readonly callCompiler: FunctionCallCompiler;
readonly codeValidator: CodeValidator;
readonly wrapError: ErrorWithContextWrapper;
readonly scriptCodeFactory: ScriptCodeFactory;
}
const DefaultUtilities: ScriptCompilerUtilities = {
sharedFunctionsParser: parseSharedFunctions,
callCompiler: FunctionCallSequenceCompiler.instance,
codeValidator: validateCode,
wrapError: wrapErrorWithAdditionalContext,
scriptCodeFactory: createScriptCode,
};
interface CategoryCollectionDataContext {
readonly functions: readonly FunctionData[];
readonly language: ScriptingLanguage;
}
class FunctionCallScriptCompiler implements ScriptCompiler {
private readonly functions: ISharedFunctionCollection;
private readonly language: ScriptingLanguage;
constructor(
categoryContext: CategoryCollectionDataContext,
private readonly utilities: ScriptCompilerUtilities = DefaultUtilities,
) {
this.functions = this.utilities.sharedFunctionsParser(
categoryContext.functions,
categoryContext.language,
);
this.language = categoryContext.language;
}
public canCompile(script: ScriptData): boolean {
return hasCall(script);
}
public compile(script: ScriptData): ScriptCode {
try {
if (!hasCall(script)) {
throw new Error('Script does include any calls.');
}
const calls = parseFunctionCalls(script.call);
const compiledCode = this.utilities.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(
compiledCode,
this.language,
this.utilities.codeValidator,
);
return this.utilities.scriptCodeFactory(
compiledCode.code,
compiledCode.revertCode,
);
} catch (error) {
throw this.utilities.wrapError(error, `Failed to compile script: ${script.name}`);
}
}
}
function validateCompiledCode(
compiledCode: CompiledCode,
language: ScriptingLanguage,
validate: CodeValidator,
): void {
filterEmptyStrings([compiledCode.code, compiledCode.revertCode])
.forEach(
(code) => validate(
code,
language,
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoTooLongLines,
// Allow duplicated lines to enable calling same function multiple times
],
),
);
}
function hasCall(data: ScriptData): data is ScriptData & CallInstruction {
return (data as CallInstruction).call !== undefined;
}

View File

@@ -1,9 +1,7 @@
import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/'; import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/';
import { NoEmptyLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoEmptyLines';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel'; import { RecommendationLevel } from '@/domain/Executables/Script/RecommendationLevel';
import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode'; import type { ScriptCode } from '@/domain/Executables/Script/Code/ScriptCode';
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator'; import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError'; import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import type { ScriptCodeFactory } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory'; import { createScriptCode } from '@/domain/Executables/Script/Code/ScriptCodeFactory';
@@ -11,24 +9,24 @@ import type { Script } from '@/domain/Executables/Script/Script';
import { createEnumParser, type EnumParser } from '@/application/Common/Enum'; import { createEnumParser, type EnumParser } from '@/application/Common/Enum';
import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings'; import { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory'; import { createScript, type ScriptFactory } from '@/domain/Executables/Script/ScriptFactory';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { CodeValidationRule } from '@/application/Parser/Executable/Script/Validation/CodeValidationRule';
import { parseDocs, type DocsParser } from '../DocumentationParser'; import { parseDocs, type DocsParser } from '../DocumentationParser';
import { ExecutableType } from '../Validation/ExecutableType'; import { ExecutableType } from '../Validation/ExecutableType';
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator'; import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator';
import { CodeValidator } from './Validation/CodeValidator'; import type { CategoryCollectionContext } from '../CategoryCollectionContext';
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
import type { CategoryCollectionSpecificUtilities } from '../CategoryCollectionSpecificUtilities';
export interface ScriptParser { export interface ScriptParser {
( (
data: ScriptData, data: ScriptData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
scriptUtilities?: ScriptParserUtilities, scriptUtilities?: ScriptParserUtilities,
): Script; ): Script;
} }
export const parseScript: ScriptParser = ( export const parseScript: ScriptParser = (
data, data,
collectionUtilities, collectionContext,
scriptUtilities = DefaultUtilities, scriptUtilities = DefaultUtilities,
) => { ) => {
const validator = scriptUtilities.createValidator({ const validator = scriptUtilities.createValidator({
@@ -42,7 +40,7 @@ export const parseScript: ScriptParser = (
name: data.name, name: data.name,
code: parseCode( code: parseCode(
data, data,
collectionUtilities, collectionContext,
scriptUtilities.codeValidator, scriptUtilities.codeValidator,
scriptUtilities.createCode, scriptUtilities.createCode,
), ),
@@ -70,29 +68,34 @@ function parseLevel(
function parseCode( function parseCode(
script: ScriptData, script: ScriptData,
collectionUtilities: CategoryCollectionSpecificUtilities, collectionContext: CategoryCollectionContext,
codeValidator: ICodeValidator, codeValidator: CodeValidator,
createCode: ScriptCodeFactory, createCode: ScriptCodeFactory,
): ScriptCode { ): ScriptCode {
if (collectionUtilities.compiler.canCompile(script)) { if (collectionContext.compiler.canCompile(script)) {
return collectionUtilities.compiler.compile(script); return collectionContext.compiler.compile(script);
} }
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled
const code = createCode(codeScript.code, codeScript.revertCode); const code = createCode(codeScript.code, codeScript.revertCode);
validateHardcodedCodeWithoutCalls(code, codeValidator, collectionUtilities.syntax); validateHardcodedCodeWithoutCalls(code, codeValidator, collectionContext.language);
return code; return code;
} }
function validateHardcodedCodeWithoutCalls( function validateHardcodedCodeWithoutCalls(
scriptCode: ScriptCode, scriptCode: ScriptCode,
validator: ICodeValidator, validate: CodeValidator,
syntax: ILanguageSyntax, language: ScriptingLanguage,
) { ) {
filterEmptyStrings([scriptCode.execute, scriptCode.revert]) filterEmptyStrings([scriptCode.execute, scriptCode.revert])
.forEach( .forEach(
(code) => validator.throwIfInvalid( (code) => validate(
code, code,
[new NoEmptyLines(), new NoDuplicatedLines(syntax)], language,
[
CodeValidationRule.NoEmptyLines,
CodeValidationRule.NoDuplicatedLines,
CodeValidationRule.NoTooLongLines,
],
), ),
); );
} }
@@ -103,7 +106,7 @@ function validateScript(
): asserts script is NonNullable<ScriptData> { ): asserts script is NonNullable<ScriptData> {
validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({ validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({
value: script, value: script,
valueName: `Script '${script.name}'` ?? 'Script', valueName: script.name ? `Script '${script.name}'` : 'Script',
allowedProperties: [ allowedProperties: [
'name', 'recommend', 'code', 'revertCode', 'call', 'docs', 'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
], ],
@@ -126,7 +129,7 @@ function validateScript(
interface ScriptParserUtilities { interface ScriptParserUtilities {
readonly levelParser: EnumParser<RecommendationLevel>; readonly levelParser: EnumParser<RecommendationLevel>;
readonly createScript: ScriptFactory; readonly createScript: ScriptFactory;
readonly codeValidator: ICodeValidator; readonly codeValidator: CodeValidator;
readonly wrapError: ErrorWithContextWrapper; readonly wrapError: ErrorWithContextWrapper;
readonly createValidator: ExecutableValidatorFactory; readonly createValidator: ExecutableValidatorFactory;
readonly createCode: ScriptCodeFactory; readonly createCode: ScriptCodeFactory;
@@ -136,7 +139,7 @@ interface ScriptParserUtilities {
const DefaultUtilities: ScriptParserUtilities = { const DefaultUtilities: ScriptParserUtilities = {
levelParser: createEnumParser(RecommendationLevel), levelParser: createEnumParser(RecommendationLevel),
createScript, createScript,
codeValidator: CodeValidator.instance, codeValidator: validateCode,
wrapError: wrapErrorWithAdditionalContext, wrapError: wrapErrorWithAdditionalContext,
createValidator: createExecutableDataValidator, createValidator: createExecutableDataValidator,
createCode: createScriptCode, createCode: createScriptCode,

View File

@@ -0,0 +1,63 @@
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { createSyntax, type SyntaxFactory } from './Syntax/SyntaxFactory';
import type { CodeLine, CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export type DuplicateLinesAnalyzer = CodeValidationAnalyzer & {
(
...args: [
...Parameters<CodeValidationAnalyzer>,
syntaxFactory?: SyntaxFactory,
]
): ReturnType<CodeValidationAnalyzer>;
};
export const analyzeDuplicateLines: DuplicateLinesAnalyzer = (
lines: readonly CodeLine[],
language: ScriptingLanguage,
syntaxFactory: SyntaxFactory = createSyntax,
) => {
const syntax = syntaxFactory(language);
return lines
.map((line): CodeLineWithDuplicateOccurrences => ({
lineNumber: line.lineNumber,
shouldBeIgnoredInAnalysis: shouldIgnoreLine(line.text, syntax),
duplicateLineNumbers: lines
.filter((other) => other.text === line.text)
.map((duplicatedLine) => duplicatedLine.lineNumber),
}))
.filter((line) => isNonIgnorableDuplicateLine(line))
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: `Line is duplicated at line numbers ${line.duplicateLineNumbers.join(',')}.`,
}));
};
interface CodeLineWithDuplicateOccurrences {
readonly lineNumber: number;
readonly duplicateLineNumbers: readonly number[];
readonly shouldBeIgnoredInAnalysis: boolean;
}
function isNonIgnorableDuplicateLine(line: CodeLineWithDuplicateOccurrences): boolean {
return !line.shouldBeIgnoredInAnalysis && line.duplicateLineNumbers.length > 1;
}
function shouldIgnoreLine(codeLine: string, syntax: LanguageSyntax): boolean {
return isCommentLine(codeLine, syntax)
|| isLineComposedEntirelyOfCommonCodeParts(codeLine, syntax);
}
function isCommentLine(codeLine: string, syntax: LanguageSyntax): boolean {
return syntax.commentDelimiters.some(
(delimiter) => codeLine.startsWith(delimiter),
);
}
function isLineComposedEntirelyOfCommonCodeParts(
codeLine: string,
syntax: LanguageSyntax,
): boolean {
const codeLineParts = codeLine.toLowerCase().trim().split(' ');
return codeLineParts.every((part) => syntax.commonCodeParts.includes(part));
}

View File

@@ -0,0 +1,24 @@
import type { CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export const analyzeEmptyLines: CodeValidationAnalyzer = (
lines,
) => {
return lines
.filter((line) => isEmptyLine(line.text))
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: (() => {
if (!line.text) {
return 'Empty line';
}
const markedText = line.text
.replaceAll(' ', '{whitespace}')
.replaceAll('\t', '{tab}');
return `Empty line: "${markedText}"`;
})(),
}));
};
function isEmptyLine(line: string): boolean {
return line.trim().length === 0;
}

View File

@@ -0,0 +1,44 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { CodeValidationAnalyzer, InvalidCodeLine } from './CodeValidationAnalyzer';
export const analyzeTooLongLines: CodeValidationAnalyzer = (
lines,
language,
) => {
const maxLineLength = getMaxAllowedLineLength(language);
return lines
.filter((line) => line.text.length > maxLineLength)
.map((line): InvalidCodeLine => ({
lineNumber: line.lineNumber,
error: [
`Line is too long (${line.text.length}).`,
`It exceed maximum allowed length ${maxLineLength} by ${line.text.length - maxLineLength} characters.`,
'This may cause bugs due to unintended trimming by operating system, shells or terminal emulators.',
].join(' '),
}));
};
function getMaxAllowedLineLength(language: ScriptingLanguage): number {
switch (language) {
case ScriptingLanguage.batchfile:
/*
The maximum length of the string that you can use at the command prompt is 8191 characters.
https://web.archive.org/web/20240815120224/https://learn.microsoft.com/en-us/troubleshoot/windows-client/shell-experience/command-line-string-limitation
*/
return 8191;
case ScriptingLanguage.shellscript:
/*
Tests show:
| OS | Command | Value |
| --- | ------- | ----- |
| Pop!_OS 22.04 | xargs --show-limits | 2088784 |
| macOS Sonoma 14.3 on Intel | getconf ARG_MAX | 1048576 |
| macOS Sonoma 14.3 on Apple Silicon M1 | getconf ARG_MAX | 1048576 |
| Android 12 (4.14.180) with Termux | xargs --show-limits | 2087244 |
*/
return 1048576; // Minimum value for reliability
default:
throw new Error(`Unsupported language: ${ScriptingLanguage[language]} (${language})`);
}
}

View File

@@ -0,0 +1,18 @@
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
export interface CodeValidationAnalyzer {
(
lines: readonly CodeLine[],
language: ScriptingLanguage,
): InvalidCodeLine[];
}
export interface InvalidCodeLine {
readonly lineNumber: number;
readonly error: string;
}
export interface CodeLine {
readonly lineNumber: number;
readonly text: string;
}

View File

@@ -1,9 +1,9 @@
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax'; import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
const BatchFileCommonCodeParts = ['(', ')', 'else', '||']; const BatchFileCommonCodeParts = ['(', ')', 'else', '||'];
const PowerShellCommonCodeParts = ['{', '}']; const PowerShellCommonCodeParts = ['{', '}'];
export class BatchFileSyntax implements ILanguageSyntax { export class BatchFileSyntax implements LanguageSyntax {
public readonly commentDelimiters = ['REM', '::']; public readonly commentDelimiters = ['REM', '::'];
public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts]; public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts];

View File

@@ -0,0 +1,4 @@
export interface LanguageSyntax {
readonly commentDelimiters: readonly string[];
readonly commonCodeParts: readonly string[];
}

View File

@@ -0,0 +1,7 @@
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
export class ShellScriptSyntax implements LanguageSyntax {
public readonly commentDelimiters = ['#'];
public readonly commonCodeParts = ['(', ')', 'else', 'fi', 'done'];
}

View File

@@ -0,0 +1,19 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { LanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Analyzers/Syntax/LanguageSyntax';
import { BatchFileSyntax } from './BatchFileSyntax';
import { ShellScriptSyntax } from './ShellScriptSyntax';
export interface SyntaxFactory {
(language: ScriptingLanguage): LanguageSyntax;
}
export const createSyntax: SyntaxFactory = (language: ScriptingLanguage): LanguageSyntax => {
switch (language) {
case ScriptingLanguage.batchfile:
return new BatchFileSyntax();
case ScriptingLanguage.shellscript:
return new ShellScriptSyntax();
default:
throw new RangeError(`Invalid language: "${ScriptingLanguage[language]}"`);
}
};

View File

@@ -0,0 +1,5 @@
export enum CodeValidationRule {
NoEmptyLines,
NoDuplicatedLines,
NoTooLongLines,
}

View File

@@ -1,46 +1,78 @@
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines'; import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
import type { ICodeLine } from './ICodeLine'; import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule'; import { createValidationAnalyzers, type ValidationRuleAnalyzerFactory } from './ValidationRuleAnalyzerFactory';
import type { ICodeValidator } from './ICodeValidator'; import type { CodeLine, InvalidCodeLine } from './Analyzers/CodeValidationAnalyzer';
import type { CodeValidationRule } from './CodeValidationRule';
export class CodeValidator implements ICodeValidator { export interface CodeValidator {
public static readonly instance: ICodeValidator = new CodeValidator(); (
public throwIfInvalid(
code: string, code: string,
rules: readonly ICodeValidationRule[], language: ScriptingLanguage,
): void { rules: readonly CodeValidationRule[],
if (rules.length === 0) { throw new Error('missing rules'); } analyzerFactory?: ValidationRuleAnalyzerFactory,
if (!code) { ): void;
return;
}
const lines = extractLines(code);
const invalidLines = rules.flatMap((rule) => rule.analyze(lines));
if (invalidLines.length === 0) {
return;
}
const errorText = `Errors with the code.\n${printLines(lines, invalidLines)}`;
throw new Error(errorText);
}
} }
function extractLines(code: string): ICodeLine[] { export const validateCode: CodeValidator = (
code,
language,
rules,
analyzerFactory = createValidationAnalyzers,
) => {
const analyzers = analyzerFactory(rules);
if (!code) {
return;
}
const lines = extractLines(code);
const invalidLines = analyzers.flatMap((analyze) => analyze(lines, language));
if (invalidLines.length === 0) {
return;
}
const errorText = `Errors with the code.\n${formatLines(lines, invalidLines)}`;
throw new Error(errorText);
};
function extractLines(code: string): CodeLine[] {
const lines = splitTextIntoLines(code); const lines = splitTextIntoLines(code);
return lines.map((lineText, lineIndex): ICodeLine => ({ return lines.map((lineText, lineIndex): CodeLine => ({
index: lineIndex + 1, lineNumber: lineIndex + 1,
text: lineText, text: lineText,
})); }));
} }
function printLines( function formatLines(
lines: readonly ICodeLine[], lines: readonly CodeLine[],
invalidLines: readonly IInvalidCodeLine[], invalidLines: readonly InvalidCodeLine[],
): string { ): string {
return lines.map((line) => { return lines.map((line) => {
const badLine = invalidLines.find((invalidLine) => invalidLine.index === line.index); const badLine = invalidLines.find((invalidLine) => invalidLine.lineNumber === line.lineNumber);
if (!badLine) { return formatLine({
return `[${line.index}] ✅ ${line.text}`; lineNumber: line.lineNumber,
} text: line.text,
return `[${badLine.index}] ❌ ${line.text}\n\t⟶ ${badLine.error}`; error: badLine?.error,
});
}).join('\n'); }).join('\n');
} }
function formatLine(
line: {
readonly lineNumber: number;
readonly text: string;
readonly error?: string;
},
): string {
let text = `[${line.lineNumber}] `;
text += line.error ? '❌' : '✅';
text += ` ${trimLine(line.text)}`;
if (line.error) {
text += `\n\t⟶ ${line.error}`;
}
return text;
}
function trimLine(line: string) {
const maxLength = 500;
if (line.length > maxLength) {
line = `${line.substring(0, maxLength)}... [Rest of the line trimmed]`;
}
return line;
}

View File

@@ -1,4 +0,0 @@
export interface ICodeLine {
readonly index: number;
readonly text: string;
}

View File

@@ -1,10 +0,0 @@
import type { ICodeLine } from './ICodeLine';
export interface IInvalidCodeLine {
readonly index: number;
readonly error: string;
}
export interface ICodeValidationRule {
analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[];
}

View File

@@ -1,8 +0,0 @@
import type { ICodeValidationRule } from './ICodeValidationRule';
export interface ICodeValidator {
throwIfInvalid(
code: string,
rules: readonly ICodeValidationRule[],
): void;
}

View File

@@ -1,45 +0,0 @@
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import type { ICodeLine } from '../ICodeLine';
import type { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
export class NoDuplicatedLines implements ICodeValidationRule {
constructor(private readonly syntax: ILanguageSyntax) { }
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
return lines
.map((line): IDuplicateAnalyzedLine => ({
index: line.index,
isIgnored: shouldIgnoreLine(line.text, this.syntax),
occurrenceIndices: lines
.filter((other) => other.text === line.text)
.map((duplicatedLine) => duplicatedLine.index),
}))
.filter((line) => hasInvalidDuplicates(line))
.map((line): IInvalidCodeLine => ({
index: line.index,
error: `Line is duplicated at line numbers ${line.occurrenceIndices.join(',')}.`,
}));
}
}
interface IDuplicateAnalyzedLine {
readonly index: number;
readonly occurrenceIndices: readonly number[];
readonly isIgnored: boolean;
}
function hasInvalidDuplicates(line: IDuplicateAnalyzedLine): boolean {
return !line.isIgnored && line.occurrenceIndices.length > 1;
}
function shouldIgnoreLine(codeLine: string, syntax: ILanguageSyntax): boolean {
const lowerCaseCodeLine = codeLine.toLowerCase();
const isCommentLine = () => syntax.commentDelimiters.some(
(delimiter) => lowerCaseCodeLine.startsWith(delimiter),
);
const consistsOfFrequentCommands = () => {
const trimmed = lowerCaseCodeLine.trim().split(' ');
return trimmed.every((part) => syntax.commonCodeParts.includes(part));
};
return isCommentLine() || consistsOfFrequentCommands();
}

View File

@@ -1,21 +0,0 @@
import type { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
import type { ICodeLine } from '../ICodeLine';
export class NoEmptyLines implements ICodeValidationRule {
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
return lines
.filter((line) => (line.text?.trim().length ?? 0) === 0)
.map((line): IInvalidCodeLine => ({
index: line.index,
error: (() => {
if (!line.text) {
return 'Empty line';
}
const markedText = line.text
.replaceAll(' ', '{whitespace}')
.replaceAll('\t', '{tab}');
return `Empty line: "${markedText}"`;
})(),
}));
}
}

View File

@@ -1,4 +0,0 @@
export interface ILanguageSyntax {
readonly commentDelimiters: string[];
readonly commonCodeParts: string[];
}

View File

@@ -1,4 +0,0 @@
import type { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory';
import type { ILanguageSyntax } from './ILanguageSyntax';
export type ISyntaxFactory = IScriptingLanguageFactory<ILanguageSyntax>;

View File

@@ -1,7 +0,0 @@
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
export class ShellScriptSyntax implements ILanguageSyntax {
public readonly commentDelimiters = ['#'];
public readonly commonCodeParts = ['(', ')', 'else', 'fi', 'done'];
}

View File

@@ -1,16 +0,0 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { ScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/ScriptingLanguageFactory';
import type { ILanguageSyntax } from '@/application/Parser/Executable/Script/Validation/Syntax/ILanguageSyntax';
import { BatchFileSyntax } from './BatchFileSyntax';
import { ShellScriptSyntax } from './ShellScriptSyntax';
import type { ISyntaxFactory } from './ISyntaxFactory';
export class SyntaxFactory
extends ScriptingLanguageFactory<ILanguageSyntax>
implements ISyntaxFactory {
constructor() {
super();
this.registerGetter(ScriptingLanguage.batchfile, () => new BatchFileSyntax());
this.registerGetter(ScriptingLanguage.shellscript, () => new ShellScriptSyntax());
}
}

View File

@@ -0,0 +1,47 @@
import { CodeValidationRule } from './CodeValidationRule';
import { analyzeDuplicateLines } from './Analyzers/AnalyzeDuplicateLines';
import { analyzeEmptyLines } from './Analyzers/AnalyzeEmptyLines';
import { analyzeTooLongLines } from './Analyzers/AnalyzeTooLongLines';
import type { CodeValidationAnalyzer } from './Analyzers/CodeValidationAnalyzer';
export interface ValidationRuleAnalyzerFactory {
(
rules: readonly CodeValidationRule[],
): CodeValidationAnalyzer[];
}
export const createValidationAnalyzers: ValidationRuleAnalyzerFactory = (
rules,
): CodeValidationAnalyzer[] => {
if (rules.length === 0) { throw new Error('missing rules'); }
validateUniqueRules(rules);
return rules.map((rule) => createValidationRule(rule));
};
function createValidationRule(rule: CodeValidationRule): CodeValidationAnalyzer {
switch (rule) {
case CodeValidationRule.NoEmptyLines:
return analyzeEmptyLines;
case CodeValidationRule.NoDuplicatedLines:
return analyzeDuplicateLines;
case CodeValidationRule.NoTooLongLines:
return analyzeTooLongLines;
default:
throw new Error(`Unknown rule: ${rule}`);
}
}
function validateUniqueRules(
rules: readonly CodeValidationRule[],
): void {
const ruleCounts = new Map<CodeValidationRule, number>();
rules.forEach((rule) => {
ruleCounts.set(rule, (ruleCounts.get(rule) || 0) + 1);
});
const duplicates = Array.from(ruleCounts.entries())
.filter(([, count]) => count > 1)
.map(([rule, count]) => `${CodeValidationRule[rule]} (${count} times)`);
if (duplicates.length > 0) {
throw new Error(`Duplicate rules are not allowed. Duplicates found: ${duplicates.join(', ')}`);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -10,7 +10,7 @@ export type CompositeCategoryCollectionValidator = CategoryCollectionValidator &
...Parameters<CategoryCollectionValidator>, ...Parameters<CategoryCollectionValidator>,
(readonly CategoryCollectionValidator[])?, (readonly CategoryCollectionValidator[])?,
] ]
): void; ): ReturnType<CategoryCollectionValidator>;
}; };
export const validateCategoryCollection: CompositeCategoryCollectionValidator = ( export const validateCategoryCollection: CompositeCategoryCollectionValidator = (

View File

@@ -1,23 +0,0 @@
import type { CodeRunError } from '@/application/CodeRunner/CodeRunner';
export interface ScriptDirectoryProvider {
provideScriptDirectory(): Promise<ScriptDirectoryOutcome>;
}
export type ScriptDirectoryOutcome = SuccessfulDirectoryCreation | FailedDirectoryCreation;
interface ScriptDirectoryCreationStatus {
readonly success: boolean;
readonly directoryAbsolutePath?: string;
readonly error?: CodeRunError;
}
interface SuccessfulDirectoryCreation extends ScriptDirectoryCreationStatus {
readonly success: true;
readonly directoryAbsolutePath: string;
}
interface FailedDirectoryCreation extends ScriptDirectoryCreationStatus {
readonly success: false;
readonly error: CodeRunError;
}

View File

@@ -1,21 +1,22 @@
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner'; import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter'; import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter'; import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations'; import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator'; import { TimestampedFilenameGenerator } from './Filename/TimestampedFilenameGenerator';
import { PersistentDirectoryProvider } from './Directory/PersistentDirectoryProvider';
import type { SystemOperations } from '../System/SystemOperations';
import type { FilenameGenerator } from './Filename/FilenameGenerator'; import type { FilenameGenerator } from './Filename/FilenameGenerator';
import type { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator'; import type { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator';
import type { ScriptDirectoryProvider } from './Directory/ScriptDirectoryProvider';
export class ScriptFileCreationOrchestrator implements ScriptFileCreator { export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
constructor( constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(), private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(), private readonly filenameGenerator: FilenameGenerator = new TimestampedFilenameGenerator(),
private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(), private readonly directoryProvider: ApplicationDirectoryProvider
= new PersistentApplicationDirectoryProvider(),
private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(), private readonly fileWriter: ReadbackFileWriter = new NodeReadbackFileWriter(),
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }
@@ -26,9 +27,12 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
): Promise<ScriptFileCreationOutcome> { ): Promise<ScriptFileCreationOutcome> {
const { const {
success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath, success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath,
} = await this.directoryProvider.provideScriptDirectory(); } = await this.directoryProvider.provideDirectory('script-runs');
if (!isDirectoryCreated) { if (!isDirectoryCreated) {
return createFailure(directoryCreationError); return createFailure({
type: 'DirectoryCreationError',
message: `[${directoryCreationError.type}] ${directoryCreationError.message}`,
});
} }
const { const {
success: isFilePathConstructed, error: filePathGenerationError, filePath, success: isFilePathConstructed, error: filePathGenerationError, filePath,
@@ -54,7 +58,7 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
): FilePathConstructionOutcome { ): FilePathConstructionOutcome {
try { try {
const filename = this.filenameGenerator.generateFilename(scriptFilenameParts); const filename = this.filenameGenerator.generateFilename(scriptFilenameParts);
const filePath = this.system.location.combinePaths(directoryPath, filename); const filePath = this.fileSystem.combinePaths(directoryPath, filename);
return { success: true, filePath }; return { success: true, filePath };
} catch (error) { } catch (error) {
return { return {

View File

@@ -7,7 +7,7 @@ import type { ExecutablePermissionSetter } from './ExecutablePermissionSetter';
export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter { export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter {
constructor( constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(), private readonly system: SystemOperations = NodeElectronSystemOperations,
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }

View File

@@ -7,7 +7,7 @@ import type { ShellCommandOutcome, ShellCommandRunner } from './ShellCommandRunn
export class LoggingNodeShellCommandRunner implements ShellCommandRunner { export class LoggingNodeShellCommandRunner implements ShellCommandRunner {
constructor( constructor(
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
private readonly systemOps: SystemOperations = new NodeElectronSystemOperations(), private readonly systemOps: SystemOperations = NodeElectronSystemOperations,
) { ) {
} }

View File

@@ -1,57 +1,13 @@
import { join } from 'node:path';
import { chmod, mkdir } from 'node:fs/promises';
import { exec } from 'node:child_process'; import { exec } from 'node:child_process';
import { app } from 'electron/main'; import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import type { import type { SystemOperations } from './SystemOperations';
CommandOps, FileSystemOps, LocationOps, OperatingSystemOps, SystemOperations,
} from './SystemOperations';
/** /**
* Thin wrapper for Node and Electron APIs. * Thin wrapper for Node and Electron APIs.
*/ */
export class NodeElectronSystemOperations implements SystemOperations { export const NodeElectronSystemOperations: SystemOperations = {
public readonly operatingSystem: OperatingSystemOps = { fileSystem: NodeElectronFileSystemOperations,
/* command: {
This method returns the directory for storing app's configuration files.
It appends your app's name to the default appData directory.
Conventionally, you should store user data files in this directory.
However, avoid writing large files here as some environments might back up this directory
to cloud storage, potentially causing issues with file size.
Based on tests it returns:
- Windows: `%APPDATA%\privacy.sexy`
- Linux: `$HOME/.config/privacy.sexy/runs`
- macOS: `$HOME/Library/Application Support/privacy.sexy/runs`
For more details, refer to the Electron documentation: https://web.archive.org/web/20240104154857/https://www.electronjs.org/docs/latest/api/app#appgetpathname
*/
getUserDataDirectory: () => {
return app.getPath('userData');
},
};
public readonly location: LocationOps = {
combinePaths: (...pathSegments) => join(...pathSegments),
};
public readonly fileSystem: FileSystemOps = {
setFilePermissions: (
filePath: string,
mode: string | number,
) => chmod(filePath, mode),
createDirectory: async (
directoryPath: string,
isRecursive?: boolean,
) => {
await mkdir(directoryPath, { recursive: isRecursive });
// Ignoring the return value from `mkdir`, which is the first directory created
// when `recursive` is true, or empty return value.
// See https://github.com/nodejs/node/pull/31530
},
};
public readonly command: CommandOps = {
exec, exec,
}; },
} };

View File

@@ -1,25 +1,11 @@
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { exec } from 'node:child_process'; import type { exec } from 'node:child_process';
export interface SystemOperations { export interface SystemOperations {
readonly operatingSystem: OperatingSystemOps; readonly fileSystem: FileSystemOperations;
readonly location: LocationOps;
readonly fileSystem: FileSystemOps;
readonly command: CommandOps; readonly command: CommandOps;
} }
export interface OperatingSystemOps {
getUserDataDirectory(): string;
}
export interface LocationOps {
combinePaths(...pathSegments: string[]): string;
}
export interface CommandOps { export interface CommandOps {
exec(command: string): ReturnType<typeof exec>; exec(command: string): ReturnType<typeof exec>;
} }
export interface FileSystemOps {
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
}

View File

@@ -5,8 +5,8 @@ import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { import {
FileType, type SaveFileError, type SaveFileErrorType, type SaveFileOutcome, FileType, type SaveFileError, type SaveFileErrorType, type SaveFileOutcome,
} from '@/presentation/common/Dialog'; } from '@/presentation/common/Dialog';
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter'; import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter'; import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
import type { ElectronSaveFileDialog } from './ElectronSaveFileDialog'; import type { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog { export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {

View File

@@ -0,0 +1,30 @@
export interface ApplicationDirectoryProvider {
provideDirectory(type: DirectoryType): Promise<DirectoryCreationOutcome>;
}
export type DirectoryType = 'update-installation-files' | 'script-runs';
export type DirectoryCreationOutcome = SuccessfulDirectoryCreation | FailedDirectoryCreation;
export type DirectoryCreationErrorType = 'PathConstructionError' | 'DirectoryWriteError' | 'UserDataFolderRetrievalError';
export interface DirectoryCreationError {
readonly type: DirectoryCreationErrorType;
readonly message: string;
}
interface DirectoryCreationStatus {
readonly success: boolean;
readonly directoryAbsolutePath?: string;
readonly error?: DirectoryCreationError;
}
interface SuccessfulDirectoryCreation extends DirectoryCreationStatus {
readonly success: true;
readonly directoryAbsolutePath: string;
}
interface FailedDirectoryCreation extends DirectoryCreationStatus {
readonly success: false;
readonly error: DirectoryCreationError;
}

View File

@@ -1,32 +1,37 @@
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner'; import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import { NodeElectronSystemOperations } from '../../System/NodeElectronSystemOperations'; import type {
import type { SystemOperations } from '../../System/SystemOperations'; DirectoryCreationOutcome, ApplicationDirectoryProvider, DirectoryType,
import type { ScriptDirectoryOutcome, ScriptDirectoryProvider } from './ScriptDirectoryProvider'; DirectoryCreationError, DirectoryCreationErrorType,
} from './ApplicationDirectoryProvider';
import type { FileSystemOperations } from '../FileSystemOperations';
export const ExecutionSubdirectory = 'runs'; export const SubdirectoryNames: Record<DirectoryType, string> = {
'script-runs': 'runs',
'update-installation-files': 'updates',
};
/** /**
* Provides a dedicated directory for script execution. * Provides persistent directories.
* Benefits of using a persistent directory: * Benefits of using a persistent directory:
* - Antivirus Exclusions: Easier antivirus configuration. * - Antivirus Exclusions: Easier antivirus configuration.
* - Auditability: Stores script execution history for troubleshooting. * - Auditability: Stores script execution history for troubleshooting.
* - Reliability: Avoids issues with directory clean-ups during execution, * - Reliability: Avoids issues with directory clean-ups during execution,
* seen in Windows Pro Azure VMs when stored on Windows temporary directory. * seen in Windows Pro Azure VMs when stored on Windows temporary directory.
*/ */
export class PersistentDirectoryProvider implements ScriptDirectoryProvider { export class PersistentApplicationDirectoryProvider implements ApplicationDirectoryProvider {
constructor( constructor(
private readonly system: SystemOperations = new NodeElectronSystemOperations(), private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
) { } ) { }
public async provideScriptDirectory(): Promise<ScriptDirectoryOutcome> { public async provideDirectory(type: DirectoryType): Promise<DirectoryCreationOutcome> {
const { const {
success: isPathConstructed, success: isPathConstructed,
error: pathConstructionError, error: pathConstructionError,
directoryPath, directoryPath,
} = this.constructScriptDirectoryPath(); } = this.constructScriptDirectoryPath(type);
if (!isPathConstructed) { if (!isPathConstructed) {
return { return {
success: false, success: false,
@@ -52,7 +57,7 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
private async createDirectory(directoryPath: string): Promise<DirectoryPathCreationOutcome> { private async createDirectory(directoryPath: string): Promise<DirectoryPathCreationOutcome> {
try { try {
this.logger.info(`Attempting to create script directory at path: ${directoryPath}`); this.logger.info(`Attempting to create script directory at path: ${directoryPath}`);
await this.system.fileSystem.createDirectory(directoryPath, true); await this.fileSystem.createDirectory(directoryPath, true);
this.logger.info(`Script directory successfully created at: ${directoryPath}`); this.logger.info(`Script directory successfully created at: ${directoryPath}`);
return { return {
success: true, success: true,
@@ -60,17 +65,26 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: this.handleException(error, 'DirectoryCreationError'), error: this.handleError(error, 'DirectoryWriteError'),
}; };
} }
} }
private constructScriptDirectoryPath(): DirectoryPathConstructionOutcome { private constructScriptDirectoryPath(type: DirectoryType): DirectoryPathConstructionOutcome {
let parentDirectory: string;
try { try {
const parentDirectory = this.system.operatingSystem.getUserDataDirectory(); parentDirectory = this.fileSystem.getUserDataDirectory();
const scriptDirectory = this.system.location.combinePaths( } catch (error) {
return {
success: false,
error: this.handleError(error, 'UserDataFolderRetrievalError'),
};
}
try {
const subdirectoryName = SubdirectoryNames[type];
const scriptDirectory = this.fileSystem.combinePaths(
parentDirectory, parentDirectory,
ExecutionSubdirectory, subdirectoryName,
); );
return { return {
success: true, success: true,
@@ -79,15 +93,15 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
} catch (error) { } catch (error) {
return { return {
success: false, success: false,
error: this.handleException(error, 'DirectoryCreationError'), error: this.handleError(error, 'PathConstructionError'),
}; };
} }
} }
private handleException( private handleError(
exception: Error, exception: Error,
errorType: CodeRunErrorType, errorType: DirectoryCreationErrorType,
): CodeRunError { ): DirectoryCreationError {
const errorMessage = 'Error during script directory creation'; const errorMessage = 'Error during script directory creation';
this.logger.error(errorType, errorMessage, exception); this.logger.error(errorType, errorMessage, exception);
return { return {
@@ -99,7 +113,7 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
type DirectoryPathConstructionOutcome = { type DirectoryPathConstructionOutcome = {
readonly success: false; readonly success: false;
readonly error: CodeRunError; readonly error: DirectoryCreationError;
readonly directoryPath?: undefined; readonly directoryPath?: undefined;
} | { } | {
readonly success: true; readonly success: true;
@@ -109,7 +123,7 @@ type DirectoryPathConstructionOutcome = {
type DirectoryPathCreationOutcome = { type DirectoryPathCreationOutcome = {
readonly success: false; readonly success: false;
readonly error: CodeRunError; readonly error: DirectoryCreationError;
} | { } | {
readonly success: true; readonly success: true;
readonly error?: undefined; readonly error?: undefined;

View File

@@ -0,0 +1,20 @@
export interface FileSystemOperations {
getUserDataDirectory(): string;
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
isFileAvailable(filePath: string): Promise<boolean>;
isDirectoryAvailable(filePath: string): Promise<boolean>;
deletePath(filePath: string): Promise<void>;
listDirectoryContents(directoryPath: string): Promise<string[]>;
combinePaths(...pathSegments: string[]): string;
readFile: (filePath: string, encoding: NodeJS.BufferEncoding) => Promise<string>;
writeFile: (
filePath: string,
fileContents: string,
encoding: NodeJS.BufferEncoding,
) => Promise<void>;
}

View File

@@ -0,0 +1,68 @@
import { join } from 'node:path';
import {
chmod, mkdir,
readdir, rm, stat,
readFile, writeFile,
} from 'node:fs/promises';
import { app } from 'electron/main';
import type { FileSystemOperations } from './FileSystemOperations';
import type { Stats } from 'node:original-fs';
/**
* Thin wrapper for Node and Electron APIs.
*/
export const NodeElectronFileSystemOperations: FileSystemOperations = {
combinePaths: (...pathSegments) => join(...pathSegments),
setFilePermissions: (
filePath: string,
mode: string | number,
) => chmod(filePath, mode),
createDirectory: async (
directoryPath: string,
isRecursive?: boolean,
) => {
await mkdir(directoryPath, { recursive: isRecursive });
// Ignoring the return value from `mkdir`, which is the first directory created
// when `recursive` is true, or empty return value.
// See https://github.com/nodejs/node/pull/31530
},
isFileAvailable: async (path) => isPathAvailable(path, (stats) => stats.isFile()),
isDirectoryAvailable: async (path) => isPathAvailable(path, (stats) => stats.isDirectory()),
deletePath: (path) => rm(path, { recursive: true, force: true }),
listDirectoryContents: (directoryPath) => readdir(directoryPath),
getUserDataDirectory: () => {
/*
This method returns the directory for storing app's configuration files.
It appends your app's name to the default appData directory.
Conventionally, you should store user data files in this directory.
However, avoid writing large files here as some environments might back up this directory
to cloud storage, potentially causing issues with file size.
Based on tests it returns:
- Windows: `%APPDATA%\privacy.sexy`
- Linux: `$HOME/.config/privacy.sexy/runs`
- macOS: `$HOME/Library/Application Support/privacy.sexy/runs`
For more details, refer to the Electron documentation: https://web.archive.org/web/20240104154857/https://www.electronjs.org/docs/latest/api/app#appgetpathname
*/
return app.getPath('userData');
},
writeFile,
readFile,
};
async function isPathAvailable(
path: string,
condition: (stats: Stats) => boolean,
): Promise<boolean> {
try {
const stats = await stat(path);
return condition(stats);
} catch (error) {
if (error.code === 'ENOENT') {
return false; // path does not exist
}
throw error;
}
}

View File

@@ -1,22 +1,18 @@
import { writeFile, access, readFile } from 'node:fs/promises';
import { constants } from 'node:fs';
import type { Logger } from '@/application/Common/Log/Logger'; import type { Logger } from '@/application/Common/Log/Logger';
import { ElectronLogger } from '../Log/ElectronLogger'; import { ElectronLogger } from '../../Log/ElectronLogger';
import { NodeElectronFileSystemOperations } from '../NodeElectronFileSystemOperations';
import type { import type {
FailedFileWrite, ReadbackFileWriter, FileWriteErrorType, FailedFileWrite, ReadbackFileWriter, FileWriteErrorType,
FileWriteOutcome, SuccessfulFileWrite, FileWriteOutcome, SuccessfulFileWrite,
} from './ReadbackFileWriter'; } from './ReadbackFileWriter';
import type { FileSystemOperations } from '../FileSystemOperations';
const FILE_ENCODING: NodeJS.BufferEncoding = 'utf-8'; const FILE_ENCODING: NodeJS.BufferEncoding = 'utf-8';
export class NodeReadbackFileWriter implements ReadbackFileWriter { export class NodeReadbackFileWriter implements ReadbackFileWriter {
constructor( constructor(
private readonly logger: Logger = ElectronLogger, private readonly logger: Logger = ElectronLogger,
private readonly fileSystem: FileReadWriteOperations = { private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
writeFile,
readFile: (path, encoding) => readFile(path, encoding),
access,
},
) { } ) { }
public async writeAndVerifyFile( public async writeAndVerifyFile(
@@ -55,7 +51,9 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter {
filePath: string, filePath: string,
): Promise<FileWriteOutcome> { ): Promise<FileWriteOutcome> {
try { try {
await this.fileSystem.access(filePath, constants.F_OK); if (!(await this.fileSystem.isFileAvailable(filePath))) {
return this.reportFailure('FileExistenceVerificationFailed', 'File does not exist.');
}
return this.reportSuccess('Verified file existence without reading.'); return this.reportSuccess('Verified file existence without reading.');
} catch (error) { } catch (error) {
return this.reportFailure('FileExistenceVerificationFailed', error); return this.reportFailure('FileExistenceVerificationFailed', error);
@@ -107,9 +105,3 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter {
}; };
} }
} }
export interface FileReadWriteOperations {
readonly writeFile: typeof writeFile;
readonly access: typeof access;
readFile: (filePath: string, encoding: NodeJS.BufferEncoding) => Promise<string>;
}

View File

@@ -1,16 +1,16 @@
import type { ISanityValidator } from './ISanityValidator'; import type { SanityValidator } from './SanityValidator';
import type { ISanityCheckOptions } from './ISanityCheckOptions'; import type { SanityCheckOptions } from './SanityCheckOptions';
export type FactoryFunction<T> = () => T; export type FactoryFunction<T> = () => T;
export abstract class FactoryValidator<T> implements ISanityValidator { export abstract class FactoryValidator<T> implements SanityValidator {
private readonly factory: FactoryFunction<T>; private readonly factory: FactoryFunction<T>;
protected constructor(factory: FactoryFunction<T>) { protected constructor(factory: FactoryFunction<T>) {
this.factory = factory; this.factory = factory;
} }
public abstract shouldValidate(options: ISanityCheckOptions): boolean; public abstract shouldValidate(options: SanityCheckOptions): boolean;
public abstract name: string; public abstract name: string;

View File

@@ -1,7 +0,0 @@
import type { ISanityCheckOptions } from './ISanityCheckOptions';
export interface ISanityValidator {
readonly name: string;
shouldValidate(options: ISanityCheckOptions): boolean;
collectErrors(): Iterable<string>;
}

View File

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

View File

@@ -0,0 +1,7 @@
import type { SanityCheckOptions } from './SanityCheckOptions';
export interface SanityValidator {
readonly name: string;
shouldValidate(options: SanityCheckOptions): boolean;
collectErrors(): Iterable<string>;
}

View File

@@ -1,16 +1,23 @@
import { EnvironmentVariablesValidator } from './Validators/EnvironmentVariablesValidator'; import { EnvironmentVariablesValidator } from './Validators/EnvironmentVariablesValidator';
import type { ISanityCheckOptions } from './Common/ISanityCheckOptions'; import type { SanityCheckOptions } from './Common/SanityCheckOptions';
import type { ISanityValidator } from './Common/ISanityValidator'; import type { SanityValidator } from './Common/SanityValidator';
const DefaultSanityValidators: ISanityValidator[] = [ const DefaultSanityValidators: SanityValidator[] = [
new EnvironmentVariablesValidator(), new EnvironmentVariablesValidator(),
]; ];
export interface RuntimeSanityValidator {
(
options: SanityCheckOptions,
validators?: readonly SanityValidator[],
): void;
}
/* Helps to fail-fast on errors */ /* Helps to fail-fast on errors */
export function validateRuntimeSanity( export const validateRuntimeSanity: RuntimeSanityValidator = (
options: ISanityCheckOptions, options: SanityCheckOptions,
validators: readonly ISanityValidator[] = DefaultSanityValidators, validators: readonly SanityValidator[] = DefaultSanityValidators,
): void { ) => {
if (!validators.length) { if (!validators.length) {
throw new Error('missing validators'); throw new Error('missing validators');
} }
@@ -26,9 +33,9 @@ export function validateRuntimeSanity(
if (errorMessages.length > 0) { if (errorMessages.length > 0) {
throw new Error(`Sanity check failed.\n${errorMessages.join('\n---\n')}`); throw new Error(`Sanity check failed.\n${errorMessages.join('\n---\n')}`);
} }
} };
function getErrorMessage(validator: ISanityValidator): string | undefined { function getErrorMessage(validator: SanityValidator): string | undefined {
const errorMessages = [...validator.collectErrors()]; const errorMessages = [...validator.collectErrors()];
if (!errorMessages.length) { if (!errorMessages.length) {
return undefined; return undefined;

View File

@@ -1,7 +1,7 @@
import type { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables'; import type { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory'; import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { FactoryValidator, type FactoryFunction } from '../Common/FactoryValidator'; import { FactoryValidator, type FactoryFunction } from '../Common/FactoryValidator';
import type { ISanityCheckOptions } from '../Common/ISanityCheckOptions'; import type { SanityCheckOptions } from '../Common/SanityCheckOptions';
export class EnvironmentVariablesValidator extends FactoryValidator<IEnvironmentVariables> { export class EnvironmentVariablesValidator extends FactoryValidator<IEnvironmentVariables> {
constructor( constructor(
@@ -14,7 +14,7 @@ export class EnvironmentVariablesValidator extends FactoryValidator<IEnvironment
public override name = 'environment variables'; public override name = 'environment variables';
public override shouldValidate(options: ISanityCheckOptions): boolean { public override shouldValidate(options: SanityCheckOptions): boolean {
return options.validateEnvironmentVariables; return options.validateEnvironmentVariables;
} }
} }

View File

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

View File

@@ -1,19 +1,22 @@
import type { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector'; import type { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector';
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment'; import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory'; import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
import { PersistentDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/PersistentDirectoryProvider'; import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import type { ScriptDirectoryProvider } from '../CodeRunner/Creation/Directory/ScriptDirectoryProvider'; import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
export class ScriptEnvironmentDiagnosticsCollector implements ScriptDiagnosticsCollector { export class ScriptEnvironmentDiagnosticsCollector implements ScriptDiagnosticsCollector {
constructor( constructor(
private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(), private readonly directoryProvider: ApplicationDirectoryProvider
= new PersistentApplicationDirectoryProvider(),
private readonly environment: RuntimeEnvironment = CurrentEnvironment, private readonly environment: RuntimeEnvironment = CurrentEnvironment,
) { } ) { }
public async collectDiagnosticInformation(): Promise<ScriptDiagnosticData> { public async collectDiagnosticInformation(): Promise<ScriptDiagnosticData> {
const { directoryAbsolutePath } = await this.directoryProvider.provideScriptDirectory(); const {
directoryAbsolutePath: scriptsDirectory,
} = await this.directoryProvider.provideDirectory('script-runs');
return { return {
scriptsDirectoryAbsolutePath: directoryAbsolutePath, scriptsDirectoryAbsolutePath: scriptsDirectory,
currentOperatingSystem: this.environment.os, currentOperatingSystem: this.environment.os,
}; };
} }

View File

@@ -6,19 +6,20 @@
- Darker than default: Shades are named as `color-{name}-dark`, `color-{name}-darker`, or `color-{name}-darkest`. - Darker than default: Shades are named as `color-{name}-dark`, `color-{name}-darker`, or `color-{name}-darkest`.
- Lighter than default: Tints are named as `color-{name}-light`, `color-{name}-lighter`, or `color-{name}-lightest`. - Lighter than default: Tints are named as `color-{name}-light`, `color-{name}-lighter`, or `color-{name}-lightest`.
*/ */
@use 'sass:color';
// --- Primary | The color displayed most frequently across screens and components // --- Primary | The color displayed most frequently across screens and components
$color-primary : #3a65ab; $color-primary : #3a65ab;
$color-primary-light : lighten($color-primary, 30%); $color-primary-light : color.adjust($color-primary, $lightness: 30%);
$color-primary-dark : darken($color-primary, 18%); $color-primary-dark : color.adjust($color-primary, $lightness: -18%);
$color-primary-darker : darken($color-primary, 32%); $color-primary-darker : color.adjust($color-primary, $lightness: -32%);
$color-primary-darkest : darken($color-primary, 44%); $color-primary-darkest : color.adjust($color-primary, $lightness: -44%);
// Text/iconography color that is usable on top of primary color // Text/iconography color that is usable on top of primary color
$color-on-primary : #e4f1fe; $color-on-primary : #e4f1fe;
// --- Secondary | Accent color, should be applied sparingly to accent select parts of UI // --- Secondary | Accent color, should be applied sparingly to accent select parts of UI
$color-secondary : #00D1AD; $color-secondary : #00D1AD;
$color-secondary-light : lighten($color-secondary, 48%); $color-secondary-light : color.adjust($color-secondary, $lightness: 48%);
// Text/iconography color that is usable on top of secondary color // Text/iconography color that is usable on top of secondary color
$color-on-secondary : #005051; $color-on-secondary : #005051;

View File

@@ -53,3 +53,17 @@ sup {
vertical-align: super; vertical-align: super;
font-size: $font-size-relative-smallest; font-size: $font-size-relative-smallest;
} }
kbd {
font-family: unset; // Reset the default browser styles
background-color: $color-primary-dark;
border: 1px solid $color-primary-darker;
border-radius: 0.2em;
box-shadow: inset 0 1px 0 0 $color-primary-dark, inset 0 -2px 0 0 $color-primary-darker;
font-family: $font-family-monospace;
font-size: $font-size-relative-smallest;
padding: $spacing-relative-x-small $spacing-relative-x-small;
text-align: center;
user-select: none;
white-space: nowrap;
}

View File

@@ -1,4 +1,4 @@
import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator'; import { RuntimeSanityBootstrapper } from './Modules/RuntimeSanityBootstrapper';
import { AppInitializationLogger } from './Modules/AppInitializationLogger'; import { AppInitializationLogger } from './Modules/AppInitializationLogger';
import { DependencyBootstrapper } from './Modules/DependencyBootstrapper'; import { DependencyBootstrapper } from './Modules/DependencyBootstrapper';
import { MobileSafariActivePseudoClassEnabler } from './Modules/MobileSafariActivePseudoClassEnabler'; import { MobileSafariActivePseudoClassEnabler } from './Modules/MobileSafariActivePseudoClassEnabler';
@@ -17,7 +17,7 @@ export class ApplicationBootstrapper implements Bootstrapper {
private static getAllBootstrappers(): Bootstrapper[] { private static getAllBootstrappers(): Bootstrapper[] {
return [ return [
new RuntimeSanityValidator(), new RuntimeSanityBootstrapper(),
new DependencyBootstrapper(), new DependencyBootstrapper(),
new AppInitializationLogger(), new AppInitializationLogger(),
new MobileSafariActivePseudoClassEnabler(), new MobileSafariActivePseudoClassEnabler(),

View File

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

View File

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

View File

@@ -0,0 +1,91 @@
import ace from './ace-importer';
import type { CodeEditorFactory, SupportedSyntaxLanguage } from '../CodeEditorFactory';
const CodeEditorTheme = 'xcode';
export const initializeAceEditor: CodeEditorFactory = (options) => {
const editor = ace.edit(options.editorContainerElementId);
const mode = getAceModeName(options.language);
editor.getSession().setMode(`ace/mode/${mode}`);
editor.setTheme(`ace/theme/${CodeEditorTheme}`);
editor.setReadOnly(true);
editor.setAutoScrollEditorIntoView(true);
editor.setShowPrintMargin(false); // Hide the vertical line
editor.getSession().setUseWrapMode(true); // Make code readable on mobile
hideActiveLineAndCursorUntilInteraction(editor);
return {
setContent: (content) => editor.setValue(content, 1),
destroy: () => editor.destroy(),
scrollToLine: (lineNumber) => {
const column = editor.session.getLine(lineNumber).length;
if (column === undefined) {
return;
}
editor.gotoLine(lineNumber, column, true);
},
updateSize: () => editor?.resize(),
applyStyleToLineRange: (start, end, className) => {
const AceRange = ace.require('ace/range').Range;
const markerId = editor.session.addMarker(
new AceRange(start, 0, end, 0),
className,
'fullLine',
);
return {
clearStyle: () => {
editor.session.removeMarker(markerId);
},
};
},
};
};
function getAceModeName(language: SupportedSyntaxLanguage): string {
switch (language) {
case 'batchfile': return 'batchfile';
case 'shellscript': return 'sh';
default:
throw new Error(`Language not supported: ${language}`);
}
}
function hideActiveLineAndCursorUntilInteraction(editor: ace.Ace.Editor) {
hideActiveLineAndCursor(editor);
editor.session.on('change', () => {
editor.session.selection.clearSelection();
hideActiveLineAndCursor(editor);
});
editor.session.selection.on('changeSelection', () => {
showActiveLineAndCursor(editor);
});
}
function hideActiveLineAndCursor(editor: ace.Ace.Editor): void {
editor.setHighlightGutterLine(false); // Remove highlighting on line number column
editor.setHighlightActiveLine(false); // Remove highlighting throughout the line
setCursorVisibility(false, editor);
}
function showActiveLineAndCursor(editor: ace.Ace.Editor): void {
editor.setHighlightGutterLine(true); // Show highlighting on line number column
editor.setHighlightActiveLine(true); // Show highlighting throughout the line
setCursorVisibility(true, editor);
}
// Shows/removes vertical line after focused character
function setCursorVisibility(
isVisible: boolean,
editor: ace.Ace.Editor,
) {
const cursor = editor.renderer.container.querySelector('.ace_cursor-layer') as HTMLElement;
if (!cursor) {
throw new Error('Cannot find Ace cursor, did Ace change its rendering?');
}
cursor.style.display = isVisible ? '' : 'none';
// Implementation options for cursor visibility:
// ❌ editor.renderer.showCursor() and hideCursor(): Not functioning as expected
// ❌ editor.renderer.#cursorLayer: No longer part of the public API
// ✅ .ace_hidden-cursors { opacity: 0; }: Hides cursor when not focused
// Pros: Works more automatically
// Cons: Provides less control over visibility toggling
}

View File

@@ -5,7 +5,6 @@ import ace from 'ace-builds';
when built with Vite (`npm run build`). when built with Vite (`npm run build`).
*/ */
import 'ace-builds/src-noconflict/theme-github';
import 'ace-builds/src-noconflict/theme-xcode'; import 'ace-builds/src-noconflict/theme-xcode';
import 'ace-builds/src-noconflict/mode-batchfile'; import 'ace-builds/src-noconflict/mode-batchfile';
import 'ace-builds/src-noconflict/mode-sh'; import 'ace-builds/src-noconflict/mode-sh';

View File

@@ -6,7 +6,7 @@
<article> <article>
<h3>1. The Easy Alternative</h3> <h3>1. The Easy Alternative</h3>
<p> <p>
Run your script without any manual steps by Run your script securely without any manual steps by
<a :href="downloadUrl">downloading desktop version</a> of {{ appName }} on the <a :href="downloadUrl">downloading desktop version</a> of {{ appName }} on the
{{ osName }} system you wish to configure, and then click on the Run button. This is {{ osName }} system you wish to configure, and then click on the Run button. This is
recommended for most users. recommended for most users.
@@ -47,6 +47,8 @@ export default defineComponent({
}, },
}, },
setup() { setup() {
ensureBrowserEnvironment();
const { currentState } = injectKey((keys) => keys.useCollectionState); const { currentState } = injectKey((keys) => keys.useCollectionState);
const { projectDetails } = injectKey((keys) => keys.useApplication); const { projectDetails } = injectKey((keys) => keys.useApplication);
@@ -70,6 +72,13 @@ export default defineComponent({
}; };
}, },
}); });
function ensureBrowserEnvironment(): void {
const { isRunningAsDesktopApplication } = injectKey((keys) => keys.useRuntimeEnvironment);
if (isRunningAsDesktopApplication) {
throw new Error('Not applicable in desktop environments');
}
}
</script> </script>
<style scoped lang="scss"> <style scoped lang="scss">

View File

@@ -16,29 +16,29 @@
Open terminal. Open terminal.
<InfoTooltipInline> <InfoTooltipInline>
<p> <p>
Opening terminal changes based on the distro you run. Opening terminal changes based on the distribution you run.
</p> </p>
<p> <p>
You may search for "Terminal" in your application launcher to find it. You may search for "Terminal" in your application launcher to find it.
</p> </p>
<p> <p>
Alternatively use terminal shortcut for your distro if it has one by default: Alternatively use terminal shortcut for your distribution if it has one by default:
<ul>
<li>
<code>Ctrl-Alt-T</code>:
Ubuntu, CentOS, Linux Mint, Elementary OS, ubermix, Kali
</li>
<li>
<code>Super-T</code>: Pop!_OS
</li>
<li>
<code>Alt-T</code>: Parrot OS
</li>
<li>
<code>Ctrl-Alt-Insert</code>: Bodhi Linux
</li>
</ul>
</p> </p>
<ul>
<li>
<kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>T</kbd>:
Ubuntu, CentOS, Linux Mint, Elementary OS, ubermix, Kali
</li>
<li>
<kbd>Super</kbd> + <kbd>T</kbd>: Pop!_OS
</li>
<li>
<kbd>Alt</kbd> + <kbd>T</kbd>: Parrot OS
</li>
<li>
<kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>Insert</kbd>: Bodhi Linux
</li>
</ul>
</InfoTooltipInline> </InfoTooltipInline>
</InstructionStep> </InstructionStep>
<InstructionStep> <InstructionStep>
@@ -50,19 +50,22 @@
<CopyableCommand>cd ~/Downloads</CopyableCommand> <CopyableCommand>cd ~/Downloads</CopyableCommand>
<template #info> <template #info>
<p> <p>
Press on <code>enter/return</code> key after running the command. Press on <kbd>Enter</kbd> or <kbd>Return</kbd> key after running the command.
</p> </p>
<p> <p>
If the file is not downloaded on Downloads folder, If you didn't save the file in your <strong>Downloads</strong> folder:
change <code>Downloads</code> to path where the file is downloaded.
</p> </p>
<ol>
<li>Locate where you saved the file.</li>
<li>In the command, replace <code>Downloads</code> with the correct folder path.</li>
</ol>
<p> <p>
This command means: This command means:
<ul>
<li><code>cd</code> will change the current folder.</li>
<li><code>~</code> is the user home directory.</li>
</ul>
</p> </p>
<ul>
<li><code>cd</code> will change the current folder.</li>
<li><code>~</code> is the user home directory.</li>
</ul>
</template> </template>
</InfoTooltipWrapper> </InfoTooltipWrapper>
</p> </p>
@@ -76,21 +79,23 @@
<CopyableCommand>chmod +x {{ filename }}</CopyableCommand> <CopyableCommand>chmod +x {{ filename }}</CopyableCommand>
<template #info> <template #info>
<p> <p>
Press on <code>enter/return</code> key after running the command. Press on <kbd>Enter</kbd> or <kbd>Return</kbd> key after running the command.
</p> </p>
<p> <p>
It will make the file executable. It will make the file executable.
</p> </p>
<p> <p>
If you use desktop environment you can alternatively (instead of running the command): Alternatively, if you're using a graphical desktop environment, you can do this
<ol> without the command line:
<li>Locate the file using your file manager.</li>
<li>Right click on the file, select "Properties".</li>
<li>Go to "Permissions" and check "Allow executing file as program".</li>
</ol>
</p> </p>
<ol>
<li>Locate the file using your file manager.</li>
<li>Right click on the file, select <strong>Properties</strong>.</li>
<li>Go to <strong>Permissions</strong>.</li>
<li>Check <strong>Allow executing file as program</strong>.</li>
</ol>
<p> <p>
These GUI steps and name of options may change depending on your file manager.' These GUI steps and name of options may change depending on your file manager.
</p> </p>
</template> </template>
</InfoTooltipWrapper> </InfoTooltipWrapper>
@@ -110,7 +115,8 @@
</p> </p>
<ol> <ol>
<li>Locate the file using your file manager.</li> <li>Locate the file using your file manager.</li>
<li>Right click on the file, select "Run as program".</li> <li>Right click on the file.</li>
<li>Select <strong>Run as program</strong> or similar option.</li>
</ol> </ol>
</template> </template>
</InfoTooltipWrapper> </InfoTooltipWrapper>
@@ -124,10 +130,10 @@
registered, so keep typing. registered, so keep typing.
</p> </p>
<p> <p>
Press on <code>enter/return</code> key after typing your password. Press the <kbd>Enter</kbd> or <kbd>Return</kbd> key after typing your password.
</p> </p>
<p> <p>
Administrator privileges are required to configure OS. Administrator privileges are required for configurations.
</p> </p>
</InfoTooltipInline> </InfoTooltipInline>
</InstructionStep> </InstructionStep>

View File

@@ -13,9 +13,28 @@
</InfoTooltipInline> </InfoTooltipInline>
</InstructionStep> </InstructionStep>
<InstructionStep> <InstructionStep>
Open terminal. Open your terminal.
<InfoTooltipInline> <InfoTooltipInline>
Type Terminal into Spotlight or open it from the Applications -> Utilities folder. <p>There are two easy ways to open the default terminal on your Mac:</p>
<ol>
<li>
Using macOS search (<strong>Spotlight</strong>):
<ul>
<li>Press <kbd>Cmd</kbd> + <kbd>Space</kbd> to open <strong>Spotlight</strong>.</li>
<li>
Type <strong>Terminal</strong> and press <kbd>Enter</kbd> or <kbd>Return</kbd>.
</li>
</ul>
</li>
<li>
Using <strong>Finder</strong>:
<ul>
<li>Open <strong>Finder</strong>.</li>
<li>Go to <strong>Applications</strong> <strong>Utilities</strong>.</li>
<li>Double-click on <strong>Terminal</strong>.</li>
</ul>
</li>
</ol>
</InfoTooltipInline> </InfoTooltipInline>
</InstructionStep> </InstructionStep>
<InstructionStep> <InstructionStep>
@@ -27,19 +46,22 @@
<CopyableCommand>cd ~/Downloads</CopyableCommand> <CopyableCommand>cd ~/Downloads</CopyableCommand>
<template #info> <template #info>
<p> <p>
Press on <code>enter/return</code> key after running the command. Press on <kbd>Enter</kbd> or <kbd>Return</kbd> key after running the command.
</p> </p>
<p> <p>
If the file is not downloaded on Downloads folder, If you didn't save the file in your <strong>Downloads</strong> folder:
change <code>Downloads</code> to path where the file is downloaded.
</p> </p>
<ol>
<li>Locate where you saved the file.</li>
<li>In the command, replace <code>Downloads</code> with the correct folder path.</li>
</ol>
<p> <p>
This command means: This command means:
<ul>
<li><code>cd</code> will change the current folder.</li>
<li><code>~</code> is the user home directory.</li>
</ul>
</p> </p>
<ul>
<li><code>cd</code> will change the current folder.</li>
<li><code>~</code> is the user home directory.</li>
</ul>
</template> </template>
</InfoTooltipWrapper> </InfoTooltipWrapper>
</p> </p>
@@ -53,7 +75,7 @@
<CopyableCommand>chmod +x {{ filename }}</CopyableCommand> <CopyableCommand>chmod +x {{ filename }}</CopyableCommand>
<template #info> <template #info>
<p> <p>
Press on <code>enter/return</code> key after running the command. Press on <kbd>Enter</kbd> or <kbd>Return</kbd> key after running the command.
</p> </p>
<p> <p>
It will make the file executable. It will make the file executable.
@@ -83,10 +105,10 @@
still registered, so keep typing. still registered, so keep typing.
</p> </p>
<p> <p>
Press on <code>enter/return</code> key after typing your password. Press the <kbd>Enter</kbd> or <kbd>Return</kbd> key after typing your password.
</p> </p>
<p> <p>
Administrator privileges are required to configure OS. Administrator privileges are required for configurations.
</p> </p>
</InfoTooltipInline> </InfoTooltipInline>
</InstructionStep> </InstructionStep>

View File

@@ -36,7 +36,7 @@
</InfoTooltipInline> </InfoTooltipInline>
</InstructionStep> </InstructionStep>
<InstructionStep> <InstructionStep>
If your antivirus (e.g., Defender) alerts you, address the warning. If your antivirus (e.g., <strong>Defender</strong>) alerts you, address the warning.
<InfoTooltipInline> <InfoTooltipInline>
<!-- <!--
Tests (15/01/2023): Tests (15/01/2023):
@@ -52,7 +52,7 @@
Don't worry; privacy.sexy is secure, transparent, and open-source. Don't worry; privacy.sexy is secure, transparent, and open-source.
</p> </p>
<p> <p>
To handle false warnings in Microsoft Defender: To handle false warnings in <strong>Defender</strong>:
</p> </p>
<ol> <ol>
<li> <li>
@@ -103,21 +103,22 @@
</InfoTooltipInline> </InfoTooltipInline>
</InstructionStep> </InstructionStep>
<InstructionStep> <InstructionStep>
If prompted, confirm SmartScreen warnings. If prompted, confirm <strong>SmartScreen</strong> warnings.
<InfoTooltipInline> <InfoTooltipInline>
<p> <p>
Windows SmartScreen might display a cautionary message. <strong>Defender SmartScreen</strong> may display a cautionary message.
</p> </p>
<p> <p>
This happens since privacy.sexy scripts are not recognized This happens since privacy.sexy scripts are not recognized
by Microsoft's certification process. by Microsoft's certification process.
</p> </p>
<p> <p>
<ol> If you see the warning, bypass it by following these steps:
<li>Select <strong>More info</strong>.</li>
<li>Select <strong>Run anyway</strong>.</li>
</ol>
</p> </p>
<ol>
<li>Select <strong>More info</strong>.</li>
<li>Select <strong>Run anyway</strong>.</li>
</ol>
</InfoTooltipInline> </InfoTooltipInline>
</InstructionStep> </InstructionStep>
<InstructionStep> <InstructionStep>

View File

@@ -6,7 +6,7 @@
@click="saveCode" @click="saveCode"
/> />
<ModalDialog v-model="areInstructionsVisible"> <ModalDialog v-model="areInstructionsVisible">
<RunInstructions :filename="filename" /> <BrowserRunInstructions :filename="filename" />
</ModalDialog> </ModalDialog>
</div> </div>
</template> </template>
@@ -23,12 +23,12 @@ import { ScriptFilename } from '@/application/CodeRunner/ScriptFilename';
import { FileType } from '@/presentation/common/Dialog'; import { FileType } from '@/presentation/common/Dialog';
import IconButton from '../IconButton.vue'; import IconButton from '../IconButton.vue';
import { createScriptErrorDialog } from '../ScriptErrorDialog'; import { createScriptErrorDialog } from '../ScriptErrorDialog';
import RunInstructions from './RunInstructions/RunInstructions.vue'; import BrowserRunInstructions from './BrowserRunInstructions/BrowserRunInstructions.vue';
export default defineComponent({ export default defineComponent({
components: { components: {
IconButton, IconButton,
RunInstructions, BrowserRunInstructions,
ModalDialog, ModalDialog,
}, },
setup() { setup() {
@@ -55,7 +55,12 @@ export default defineComponent({
}, scriptDiagnosticsCollector))); }, scriptDiagnosticsCollector)));
return; return;
} }
areInstructionsVisible.value = true; if (!isRunningAsDesktopApplication) {
areInstructionsVisible.value = true;
}
// On desktop, it would be better to to prompt the user with a system
// dialog offering options after saving, such as:
// • Open Containing Folder • "Open File" in default text editor • Close
} }
return { return {

View File

@@ -0,0 +1,30 @@
/**
* Abstraction layer for code editor functionality.
* Allows for flexible integration and easy switching of third-party editor implementations.
*/
export interface CodeEditorFactory {
(options: CodeEditorOptions): CodeEditor;
}
export interface CodeEditorOptions {
readonly editorContainerElementId: string;
readonly language: SupportedSyntaxLanguage;
}
export type SupportedSyntaxLanguage = 'batchfile' | 'shellscript';
export interface CodeEditor {
destroy(): void;
setContent(content: string): void;
scrollToLine(lineNumber: number): void;
updateSize(): void;
applyStyleToLineRange(
startLineNumber: number,
endLineNumber: number,
className: string,
): CodeEditorStyleHandle;
}
export interface CodeEditorStyleHandle {
clearStyle(): void;
}

View File

@@ -25,7 +25,8 @@ import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue'; import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective'; import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
import type { ProjectDetails } from '@/domain/Project/ProjectDetails'; import type { ProjectDetails } from '@/domain/Project/ProjectDetails';
import ace from './ace-importer'; import { initializeAceEditor } from './Ace/AceCodeEditorFactory';
import type { SupportedSyntaxLanguage, CodeEditor, CodeEditorStyleHandle } from './CodeEditorFactory';
export default defineComponent({ export default defineComponent({
components: { components: {
@@ -34,13 +35,7 @@ export default defineComponent({
directives: { directives: {
NonCollapsing, NonCollapsing,
}, },
props: { setup() {
theme: {
type: String,
default: undefined,
},
},
setup(props) {
const { onStateChange, currentState } = injectKey((keys) => keys.useCollectionState); const { onStateChange, currentState } = injectKey((keys) => keys.useCollectionState);
const { projectDetails } = injectKey((keys) => keys.useApplication); const { projectDetails } = injectKey((keys) => keys.useApplication);
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents); const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
@@ -48,8 +43,8 @@ export default defineComponent({
const editorId = 'codeEditor'; const editorId = 'codeEditor';
const highlightedRange = ref(0); const highlightedRange = ref(0);
let editor: ace.Ace.Editor | undefined; let editor: CodeEditor | undefined;
let currentMarkerId: number | undefined; let currentMarker: CodeEditorStyleHandle | undefined;
onUnmounted(() => { onUnmounted(() => {
destroyEditor(); destroyEditor();
@@ -63,11 +58,10 @@ export default defineComponent({
function handleNewState(newState: IReadOnlyCategoryCollectionState) { function handleNewState(newState: IReadOnlyCategoryCollectionState) {
destroyEditor(); destroyEditor();
editor = initializeEditor( editor = initializeAceEditor({
props.theme, editorContainerElementId: editorId,
editorId, language: getLanguage(newState.collection.scripting.language),
newState.collection.scripting.language, });
);
const appCode = newState.code; const appCode = newState.code;
updateCode(appCode.current, newState.collection.scripting.language); updateCode(appCode.current, newState.collection.scripting.language);
events.unsubscribeAllAndRegister([ events.unsubscribeAllAndRegister([
@@ -77,7 +71,7 @@ export default defineComponent({
function updateCode(code: string, language: ScriptingLanguage) { function updateCode(code: string, language: ScriptingLanguage) {
const innerCode = code || getDefaultCode(language, projectDetails); const innerCode = code || getDefaultCode(language, projectDetails);
editor?.setValue(innerCode, 1); editor?.setContent(innerCode);
} }
function handleCodeChange(event: ICodeChangedEvent) { function handleCodeChange(event: ICodeChangedEvent) {
@@ -91,7 +85,7 @@ export default defineComponent({
} }
function sizeChanged() { function sizeChanged() {
editor?.resize(); editor?.updateSize();
} }
function destroyEditor() { function destroyEditor() {
@@ -100,11 +94,11 @@ export default defineComponent({
} }
function removeCurrentHighlighting() { function removeCurrentHighlighting() {
if (!currentMarkerId) { if (!currentMarker) {
return; return;
} }
editor?.session.removeMarker(currentMarkerId); currentMarker?.clearStyle();
currentMarkerId = undefined; currentMarker = undefined;
highlightedRange.value = 0; highlightedRange.value = 0;
} }
@@ -117,28 +111,15 @@ export default defineComponent({
const end = Math.max( const end = Math.max(
...positions.map((position) => position.endLine), ...positions.map((position) => position.endLine),
); );
scrollToLine(end + 2); editor?.scrollToLine(end + 2);
highlight(start, end); highlight(start, end);
} }
function highlight(startRow: number, endRow: number) { function highlight(startRow: number, endRow: number) {
const AceRange = ace.require('ace/range').Range; currentMarker = editor?.applyStyleToLineRange(startRow, endRow, 'code-area__highlight');
currentMarkerId = editor?.session.addMarker(
new AceRange(startRow, 0, endRow, 0),
'code-area__highlight',
'fullLine',
);
highlightedRange.value = endRow - startRow; highlightedRange.value = endRow - startRow;
} }
function scrollToLine(row: number) {
const column = editor?.session.getLine(row).length;
if (column === undefined) {
return;
}
editor?.gotoLine(row, column, true);
}
return { return {
editorId, editorId,
highlightedRange, highlightedRange,
@@ -147,29 +128,12 @@ export default defineComponent({
}, },
}); });
function initializeEditor( function getLanguage(language: ScriptingLanguage): SupportedSyntaxLanguage {
theme: string | undefined,
editorId: string,
language: ScriptingLanguage,
): ace.Ace.Editor {
theme = theme || 'github';
const editor = ace.edit(editorId);
const lang = getLanguage(language);
editor.getSession().setMode(`ace/mode/${lang}`);
editor.setTheme(`ace/theme/${theme}`);
editor.setReadOnly(true);
editor.setAutoScrollEditorIntoView(true);
editor.setShowPrintMargin(false); // hides vertical line
editor.getSession().setUseWrapMode(true); // So code is readable on mobile
return editor;
}
function getLanguage(language: ScriptingLanguage) {
switch (language) { switch (language) {
case ScriptingLanguage.batchfile: return 'batchfile'; case ScriptingLanguage.batchfile: return 'batchfile';
case ScriptingLanguage.shellscript: return 'sh'; case ScriptingLanguage.shellscript: return 'shellscript';
default: default:
throw new Error('unknown language'); throw new Error(`Unsupported language: ${language}`);
} }
} }

View File

@@ -11,7 +11,7 @@
<TheScriptsView :current-view="currentView" /> <TheScriptsView :current-view="currentView" />
</template> </template>
<template #second> <template #second>
<TheCodeArea theme="xcode" /> <TheCodeArea />
</template> </template>
</HorizontalResizeSlider> </HorizontalResizeSlider>
</div> </div>

View File

@@ -39,7 +39,7 @@
:tree-root="treeRoot" :tree-root="treeRoot"
:rendering-strategy="renderingStrategy" :rendering-strategy="renderingStrategy"
> >
<template #node-content="slotProps"> <template #node-content="slotProps: NodeMetadata">
<slot name="node-content" v-bind="slotProps" /> <slot name="node-content" v-bind="slotProps" />
</template> </template>
</HierarchicalTreeNode> </HierarchicalTreeNode>
@@ -55,6 +55,7 @@ import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { useNodeState } from './UseNodeState'; import { useNodeState } from './UseNodeState';
import LeafTreeNode from './LeafTreeNode.vue'; import LeafTreeNode from './LeafTreeNode.vue';
import InteractableNode from './InteractableNode.vue'; import InteractableNode from './InteractableNode.vue';
import type { NodeMetadata } from '../../NodeContent/NodeMetadata';
import type { TreeRoot } from '../TreeRoot/TreeRoot'; import type { TreeRoot } from '../TreeRoot/TreeRoot';
import type { TreeNode, TreeNodeId } from './TreeNode'; import type { TreeNode, TreeNodeId } from './TreeNode';
import type { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStrategy'; import type { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStrategy';
@@ -107,6 +108,7 @@ export default defineComponent({
); );
return { return {
NodeMetadata: Object as PropType<NodeMetadata>,
renderedNodeIds, renderedNodeIds,
isExpanded, isExpanded,
toggleExpand, toggleExpand,

View File

@@ -11,11 +11,17 @@ export class TreeRootManager implements TreeRoot {
constructor( constructor(
collection: TreeNodeCollection = new TreeNodeInitializerAndUpdater(), collection: TreeNodeCollection = new TreeNodeInitializerAndUpdater(),
createFocusManager: ( createFocusManager: FocusManagerFactory = (
collection: TreeNodeCollection nodes,
) => SingleNodeFocusManager = (nodes) => new SingleNodeCollectionFocusManager(nodes), ) => new SingleNodeCollectionFocusManager(nodes),
) { ) {
this.collection = collection; this.collection = collection;
this.focus = createFocusManager(this.collection); this.focus = createFocusManager(this.collection);
} }
} }
export interface FocusManagerFactory {
(
collection: TreeNodeCollection
): SingleNodeFocusManager;
}

View File

@@ -120,17 +120,15 @@ function getArrowPositionStyles(
coordinations: Partial<Coords>, coordinations: Partial<Coords>,
placement: Placement, placement: Placement,
): CSSProperties { ): CSSProperties {
const style: CSSProperties = {}; const { x, y } = coordinations; // either X or Y is calculated
style.position = 'absolute';
const { x, y } = coordinations;
if (x) {
style.left = `${x}px`;
} else if (y) { // either X or Y is calculated
style.top = `${y}px`;
}
const oppositeSide = getCounterpartBoxOffsetProperty(placement); const oppositeSide = getCounterpartBoxOffsetProperty(placement);
style[oppositeSide.toString()] = `-${ARROW_SIZE_IN_PX}px`; const newStyle: CSSProperties = {
return style; [oppositeSide]: `-${ARROW_SIZE_IN_PX}px`,
position: 'absolute',
left: x ? `${x}px` : undefined,
top: y ? `${y}px` : undefined,
};
return newStyle;
} }
function getCounterpartBoxOffsetProperty(placement: Placement): keyof CSSProperties { function getCounterpartBoxOffsetProperty(placement: Placement): keyof CSSProperties {

View File

@@ -22,7 +22,8 @@ export function registerAllIpcChannels(
}; };
Object.entries(ipcInstanceCreators).forEach(([name, instanceFactory]) => { Object.entries(ipcInstanceCreators).forEach(([name, instanceFactory]) => {
try { try {
const definition = IpcChannelDefinitions[name]; const definitionKey = name as keyof typeof IpcChannelDefinitions;
const definition = IpcChannelDefinitions[definitionKey] as IpcChannel<unknown>;
const instance = instanceFactory(); const instance = instanceFactory();
registrar(definition, instance); registrar(definition, instance);
} catch (err) { } catch (err) {

View File

@@ -1,67 +1,106 @@
import { app, dialog } from 'electron/main'; import { app, dialog } from 'electron/main';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { UpdateProgressBar } from './UpdateProgressBar'; import { UpdateProgressBar } from './ProgressBar/UpdateProgressBar';
import { getAutoUpdater } from './ElectronAutoUpdaterFactory'; import { getAutoUpdater } from './ElectronAutoUpdaterFactory';
import type { AppUpdater, UpdateInfo } from 'electron-updater'; import type { AppUpdater, UpdateInfo } from 'electron-updater';
import type { ProgressInfo } from 'electron-builder'; import type { ProgressInfo } from 'electron-builder';
export async function handleAutoUpdate() { export async function handleAutoUpdate() {
const autoUpdater = getAutoUpdater(); const autoUpdater = getAutoUpdater();
if (await askDownloadAndInstall() === DownloadDialogResult.NotNow) { if (await askDownloadAndInstall() === UpdateDialogResult.Postpone) {
ElectronLogger.info('User chose to postpone update');
return; return;
} }
startHandlingUpdateProgress(autoUpdater); ElectronLogger.info('User chose to download and install update');
await autoUpdater.downloadUpdate(); try {
} await startHandlingUpdateProgress(autoUpdater);
} catch (error) {
function startHandlingUpdateProgress(autoUpdater: AppUpdater) { ElectronLogger.error('Failed to handle auto-update process', { error });
const progressBar = new UpdateProgressBar();
progressBar.showIndeterminateState();
autoUpdater.on('error', (e) => {
progressBar.showError(e);
});
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
/*
On macOS, download-progress event is not called.
So the indeterminate progress will continue until download is finished.
*/
ElectronLogger.debug('@download-progress@\n', progress);
progressBar.showProgress(progress);
});
autoUpdater.on('update-downloaded', async (info: UpdateInfo) => {
ElectronLogger.info('@update-downloaded@\n', info);
progressBar.close();
await handleUpdateDownloaded(autoUpdater);
});
}
async function handleUpdateDownloaded(autoUpdater: AppUpdater) {
if (await askRestartAndInstall() === InstallDialogResult.NotNow) {
return;
} }
setTimeout(() => autoUpdater.quitAndInstall(), 1);
} }
enum DownloadDialogResult { function startHandlingUpdateProgress(autoUpdater: AppUpdater): Promise<void> {
Install = 0, return new Promise((resolve, reject) => { // Block until update process completes
NotNow = 1, const progressBar = new UpdateProgressBar();
progressBar.showIndeterminateState();
autoUpdater.on('error', (e) => {
progressBar.showError(e);
reject(e);
});
autoUpdater.on('download-progress', (progress: ProgressInfo) => {
/*
On macOS, download-progress event is not called.
So the indeterminate progress will continue until download is finished.
*/
ElectronLogger.debug('Update download progress', { progress });
if (progressBar.isOpen) { // May be closed by the user
progressBar.showProgress(progress);
}
});
autoUpdater.on('update-downloaded', async (info: UpdateInfo) => {
ElectronLogger.info('Update downloaded successfully', { version: info.version });
progressBar.closeIfOpen();
try {
await handleUpdateDownloaded(autoUpdater);
} catch (error) {
ElectronLogger.error('Failed to handle downloaded update', { error });
reject(error);
}
resolve();
});
autoUpdater.downloadUpdate();
});
} }
async function askDownloadAndInstall(): Promise<DownloadDialogResult> {
async function handleUpdateDownloaded(
autoUpdater: AppUpdater,
): Promise<void> {
return new Promise((resolve, reject) => { // Block until update download process completes
askRestartAndInstall()
.then((result) => {
if (result === InstallDialogResult.InstallAndRestart) {
ElectronLogger.info('User chose to install and restart for update');
setTimeout(() => {
try {
autoUpdater.quitAndInstall();
resolve();
} catch (error) {
ElectronLogger.error('Failed to quit and install update', { error });
reject(error);
}
}, 1);
} else {
ElectronLogger.info('User chose to postpone update installation');
resolve();
}
})
.catch((error) => {
ElectronLogger.error('Failed to prompt user for restart and install', { error });
reject(error);
});
});
}
enum UpdateDialogResult {
Update = 0,
Postpone = 1,
}
async function askDownloadAndInstall(): Promise<UpdateDialogResult> {
const updateDialogResult = await dialog.showMessageBox({ const updateDialogResult = await dialog.showMessageBox({
type: 'question', type: 'question',
buttons: ['Install', 'Not now'], buttons: ['Install', 'Not now'],
title: 'Confirm Update', title: 'Confirm Update',
message: 'Update available.\n\nWould you like to download and install new version?', message: 'Update available.\n\nWould you like to download and install new version?',
detail: 'Application will automatically restart to apply update after download', detail: 'Application will automatically restart to apply update after download',
defaultId: DownloadDialogResult.Install, defaultId: UpdateDialogResult.Update,
cancelId: DownloadDialogResult.NotNow, cancelId: UpdateDialogResult.Postpone,
}); });
return updateDialogResult.response; return updateDialogResult.response;
} }
enum InstallDialogResult { enum InstallDialogResult {
InstallAndRestart = 0, InstallAndRestart = 0,
NotNow = 1, Postpone = 1,
} }
async function askRestartAndInstall(): Promise<InstallDialogResult> { async function askRestartAndInstall(): Promise<InstallDialogResult> {
const installDialogResult = await dialog.showMessageBox({ const installDialogResult = await dialog.showMessageBox({
@@ -70,7 +109,7 @@ async function askRestartAndInstall(): Promise<InstallDialogResult> {
message: `A new version of ${app.name} has been downloaded.`, message: `A new version of ${app.name} has been downloaded.`,
detail: 'It will be installed the next time you restart the application.', detail: 'It will be installed the next time you restart the application.',
defaultId: InstallDialogResult.InstallAndRestart, defaultId: InstallDialogResult.InstallAndRestart,
cancelId: InstallDialogResult.NotNow, cancelId: InstallDialogResult.Postpone,
}); });
return installDialogResult.response; return installDialogResult.response;
} }

View File

@@ -1,10 +1,8 @@
import { existsSync, createWriteStream, type WriteStream } from 'node:fs'; import { createWriteStream, type WriteStream } from 'node:fs';
import { unlink, mkdir } from 'node:fs/promises';
import path from 'node:path';
import { app } from 'electron/main';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { UpdateProgressBar } from '../UpdateProgressBar'; import type { Logger } from '@/application/Common/Log/Logger';
import { retryFileSystemAccess } from './RetryFileSystemAccess'; import { UpdateProgressBar } from '../ProgressBar/UpdateProgressBar';
import { provideUpdateInstallationFilepath, type InstallationFilepathProvider } from './InstallationFiles/InstallationFilepathProvider';
import type { UpdateInfo } from 'electron-updater'; import type { UpdateInfo } from 'electron-updater';
import type { ReadableStream } from 'node:stream/web'; import type { ReadableStream } from 'node:stream/web';
@@ -18,18 +16,25 @@ export type DownloadUpdateResult = {
readonly installerPath: string; readonly installerPath: string;
}; };
interface UpdateDownloadUtilities {
readonly logger: Logger;
readonly provideInstallationFilePath: InstallationFilepathProvider;
}
export async function downloadUpdate( export async function downloadUpdate(
info: UpdateInfo, info: UpdateInfo,
remoteFileUrl: string, remoteFileUrl: string,
progressBar: UpdateProgressBar, progressBar: UpdateProgressBar,
utilities: UpdateDownloadUtilities = DefaultUtilities,
): Promise<DownloadUpdateResult> { ): Promise<DownloadUpdateResult> {
ElectronLogger.info('Starting manual update download.'); utilities.logger.info('Starting manual update download.');
progressBar.showIndeterminateState(); progressBar.showIndeterminateState();
try { try {
const { filePath } = await downloadInstallerFile( const { filePath } = await downloadInstallerFile(
info.version, info.version,
remoteFileUrl, remoteFileUrl,
(percentage) => { progressBar.showPercentage(percentage); }, (percentage) => { progressBar.showPercentage(percentage); },
utilities,
); );
return { return {
success: true, success: true,
@@ -47,58 +52,40 @@ async function downloadInstallerFile(
version: string, version: string,
remoteFileUrl: string, remoteFileUrl: string,
progressHandler: ProgressCallback, progressHandler: ProgressCallback,
utilities: UpdateDownloadUtilities,
): Promise<{ readonly filePath: string; }> { ): Promise<{ readonly filePath: string; }> {
const filePath = `${path.dirname(app.getPath('temp'))}/privacy.sexy/${version}-installer.dmg`; const filePath = await utilities.provideInstallationFilePath(version);
if (!await ensureFilePathReady(filePath)) {
throw new Error(`Failed to prepare the file path for the installer: ${filePath}`);
}
await downloadFileWithProgress( await downloadFileWithProgress(
remoteFileUrl, remoteFileUrl,
filePath, filePath,
progressHandler, progressHandler,
utilities,
); );
return { filePath }; return { filePath };
} }
async function ensureFilePathReady(filePath: string): Promise<boolean> {
return retryFileSystemAccess(async () => {
try {
const parentFolder = path.dirname(filePath);
if (existsSync(filePath)) {
ElectronLogger.info(`Existing update file found and will be replaced: ${filePath}`);
await unlink(filePath);
} else {
await mkdir(parentFolder, { recursive: true });
}
return true;
} catch (error) {
ElectronLogger.error(`Failed to prepare file path for update: ${filePath}`, error);
return false;
}
});
}
type ProgressCallback = (progress: number) => void; type ProgressCallback = (progress: number) => void;
async function downloadFileWithProgress( async function downloadFileWithProgress(
url: string, url: string,
filePath: string, filePath: string,
progressHandler: ProgressCallback, progressHandler: ProgressCallback,
utilities: UpdateDownloadUtilities,
) { ) {
// autoUpdater cannot handle DMG files, requiring manual download management for these file types. utilities.logger.info(`Retrieving update from ${url}.`);
ElectronLogger.info(`Retrieving update from ${url}.`);
const response = await fetch(url); const response = await fetch(url);
if (!response.ok) { if (!response.ok) {
throw Error(`Download failed: Server responded with ${response.status} ${response.statusText}.`); throw Error(`Download failed: Server responded with ${response.status} ${response.statusText}.`);
} }
const contentLength = getContentLengthFromResponse(response); const contentLength = getContentLengthFromResponse(response, utilities);
await withWriteStream(filePath, async (writer) => { await withWriteStream(filePath, async (writer) => {
ElectronLogger.info(contentLength.isValid utilities.logger.info(contentLength.isValid
? `Saving file to ${filePath} (Size: ${contentLength.totalLength} bytes).` ? `Saving file to '${filePath}' (Size: ${contentLength.totalLength} bytes).`
: `Saving file to ${filePath}.`); : `Saving file to '${filePath}'.`);
await withReadableStream(response, async (reader) => { await withReadableStream(response, async (reader) => {
await streamWithProgress(contentLength, reader, writer, progressHandler); await streamWithProgress(contentLength, reader, writer, progressHandler, utilities);
}); });
ElectronLogger.info(`Successfully saved the file: '${filePath}'`);
}); });
} }
@@ -109,16 +96,19 @@ type ResponseContentLength = {
readonly isValid: false; readonly isValid: false;
}; };
function getContentLengthFromResponse(response: Response): ResponseContentLength { function getContentLengthFromResponse(
response: Response,
utilities: UpdateDownloadUtilities,
): ResponseContentLength {
const contentLengthString = response.headers.get('content-length'); const contentLengthString = response.headers.get('content-length');
const headersInfo = Array.from(response.headers.entries()); const headersInfo = Array.from(response.headers.entries());
if (!contentLengthString) { if (!contentLengthString) {
ElectronLogger.warn('Missing \'Content-Length\' header in the response.', headersInfo); utilities.logger.warn('Missing \'Content-Length\' header in the response.', headersInfo);
return { isValid: false }; return { isValid: false };
} }
const contentLength = Number(contentLengthString); const contentLength = Number(contentLengthString);
if (Number.isNaN(contentLength) || contentLength <= 0) { if (Number.isNaN(contentLength) || contentLength <= 0) {
ElectronLogger.error('Unable to determine download size from server response.', headersInfo); utilities.logger.error('Unable to determine download size from server response.', headersInfo);
return { isValid: false }; return { isValid: false };
} }
return { totalLength: contentLength, isValid: true }; return { totalLength: contentLength, isValid: true };
@@ -153,6 +143,7 @@ async function streamWithProgress(
readStream: ReadableStream, readStream: ReadableStream,
writeStream: WriteStream, writeStream: WriteStream,
progressHandler: ProgressCallback, progressHandler: ProgressCallback,
utilities: UpdateDownloadUtilities,
): Promise<void> { ): Promise<void> {
let receivedLength = 0; let receivedLength = 0;
let logThreshold = 0; let logThreshold = 0;
@@ -163,22 +154,23 @@ async function streamWithProgress(
writeStream.write(Buffer.from(chunk)); writeStream.write(Buffer.from(chunk));
receivedLength += chunk.length; receivedLength += chunk.length;
notifyProgress(contentLength, receivedLength, progressHandler); notifyProgress(contentLength, receivedLength, progressHandler);
const progressLog = logProgress(receivedLength, contentLength, logThreshold); const progressLog = logProgress(receivedLength, contentLength, logThreshold, utilities);
logThreshold = progressLog.nextLogThreshold; logThreshold = progressLog.nextLogThreshold;
} }
ElectronLogger.info('Update download completed successfully.'); utilities.logger.info('Update download completed successfully.');
} }
function logProgress( function logProgress(
receivedLength: number, receivedLength: number,
contentLength: ResponseContentLength, contentLength: ResponseContentLength,
logThreshold: number, logThreshold: number,
utilities: UpdateDownloadUtilities,
): { readonly nextLogThreshold: number; } { ): { readonly nextLogThreshold: number; } {
const { const {
shouldLog, nextLogThreshold, shouldLog, nextLogThreshold,
} = shouldLogProgress(receivedLength, contentLength, logThreshold); } = shouldLogProgress(receivedLength, contentLength, logThreshold);
if (shouldLog) { if (shouldLog) {
ElectronLogger.debug(`Download progress: ${receivedLength} bytes received.`); utilities.logger.debug(`Download progress: ${receivedLength} bytes received.`);
} }
return { nextLogThreshold }; return { nextLogThreshold };
} }
@@ -220,3 +212,8 @@ function createReader(response: Response): ReadableStream {
// https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/65542#discussioncomment-6071004 // https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/65542#discussioncomment-6071004
return response.body as ReadableStream; return response.body as ReadableStream;
} }
const DefaultUtilities: UpdateDownloadUtilities = {
logger: ElectronLogger,
provideInstallationFilePath: provideUpdateInstallationFilepath,
};

View File

@@ -1,15 +1,21 @@
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { sleep } from '@/infrastructure/Threading/AsyncSleep'; import { sleep } from '@/infrastructure/Threading/AsyncSleep';
export function retryFileSystemAccess( export interface FileSystemAccessorWithRetry {
fileOperation: () => Promise<boolean>, (
): Promise<boolean> { fileOperation: () => Promise<boolean>,
): Promise<boolean>;
}
export const retryFileSystemAccess: FileSystemAccessorWithRetry = (
fileOperation,
) => {
return retryWithExponentialBackoff( return retryWithExponentialBackoff(
fileOperation, fileOperation,
TOTAL_RETRIES, TOTAL_RETRIES,
INITIAL_DELAY_MS, INITIAL_DELAY_MS,
); );
} };
// These values provide a balanced approach for handling transient file system // These values provide a balanced approach for handling transient file system
// issues without excessive waiting. // issues without excessive waiting.

View File

@@ -0,0 +1,107 @@
import type { Logger } from '@/application/Common/Log/Logger';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
export interface InstallationFileCleaner {
(
utilities?: UpdateFileUtilities,
): Promise<void>;
}
interface UpdateFileUtilities {
readonly logger: Logger;
readonly directoryProvider: ApplicationDirectoryProvider;
readonly fileSystem: FileSystemOperations;
}
export const clearUpdateInstallationFiles: InstallationFileCleaner = async (
utilities = DefaultUtilities,
) => {
utilities.logger.info('Starting update installation files cleanup');
const { success, error, directoryAbsolutePath } = await utilities.directoryProvider.provideDirectory('update-installation-files');
if (!success) {
utilities.logger.error('Failed to locate installation files directory', { error });
throw new Error('Cannot locate the installation files directory path');
}
const installationFileNames = await readDirectoryContents(directoryAbsolutePath, utilities);
if (installationFileNames.length === 0) {
utilities.logger.info('No update installation files found');
return;
}
utilities.logger.debug(`Found ${installationFileNames.length} installation files to delete`);
utilities.logger.info('Deleting installation files');
const errors = await executeIndependentTasksAndCollectErrors(
installationFileNames.map(async (fileOrFolderName) => {
await deleteItemFromDirectory(directoryAbsolutePath, fileOrFolderName, utilities);
}),
);
if (errors.length > 0) {
utilities.logger.error('Failed to delete some installation files', { errors });
throw new Error(`Failed to delete some items:\n${errors.join('\n')}`);
}
utilities.logger.info('Update installation files cleanup completed successfully');
};
async function deleteItemFromDirectory(
directoryPath: string,
fileOrFolderName: string,
utilities: UpdateFileUtilities,
): Promise<void> {
const itemPath = utilities.fileSystem.combinePaths(
directoryPath,
fileOrFolderName,
);
try {
utilities.logger.debug(`Deleting installation artifact: ${itemPath}`);
await utilities.fileSystem.deletePath(itemPath);
utilities.logger.debug(`Successfully deleted installation artifact: ${itemPath}`);
} catch (error) {
utilities.logger.error(`Failed to delete installation artifact: ${itemPath}`, { error });
throw error;
}
}
async function readDirectoryContents(
directoryPath: string,
utilities: UpdateFileUtilities,
): Promise<string[]> {
try {
utilities.logger.debug(`Reading directory contents: ${directoryPath}`);
const items = await utilities.fileSystem.listDirectoryContents(directoryPath);
utilities.logger.debug(`Read ${items.length} items from directory: ${directoryPath}`);
return items;
} catch (error) {
utilities.logger.error('Failed to read directory contents', { directoryPath, error });
throw new Error('Failed to read directory contents', { cause: error });
}
}
async function executeIndependentTasksAndCollectErrors(
tasks: (Promise<void>)[],
): Promise<string[]> {
const results = await Promise.allSettled(tasks);
const errors = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map((result) => result.reason);
return errors.map((error) => {
if (!error) {
return 'unknown error';
}
if (error instanceof Error) {
return error.message;
}
if (typeof error === 'string') {
return error;
}
return String(error);
});
}
const DefaultUtilities: UpdateFileUtilities = {
logger: ElectronLogger,
directoryProvider: new PersistentApplicationDirectoryProvider(),
fileSystem: NodeElectronFileSystemOperations,
};

View File

@@ -0,0 +1,73 @@
import type { Logger } from '@/application/Common/Log/Logger';
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { retryFileSystemAccess, type FileSystemAccessorWithRetry } from '../FileSystemAccessorWithRetry';
export interface InstallationFilepathProvider {
(
version: string,
utilities?: InstallationFilepathProviderUtilities,
): Promise<string>;
}
interface InstallationFilepathProviderUtilities {
readonly logger: Logger;
readonly directoryProvider: ApplicationDirectoryProvider;
readonly fileSystem: FileSystemOperations;
readonly accessFileSystemWithRetry: FileSystemAccessorWithRetry;
}
export const InstallerFileSuffix = '-installer.dmg';
export const provideUpdateInstallationFilepath: InstallationFilepathProvider = async (
version,
utilities = DefaultUtilities,
) => {
const {
success, error, directoryAbsolutePath,
} = await utilities.directoryProvider.provideDirectory('update-installation-files');
if (!success) {
utilities.logger.error('Error when providing download directory', error);
throw new Error('Failed to provide download directory.');
}
const filepath = utilities.fileSystem.combinePaths(directoryAbsolutePath, `${version}${InstallerFileSuffix}`);
if (!await makeFilepathAvailable(filepath, utilities)) {
throw new Error(`Failed to prepare the file path for the installer: ${filepath}`);
}
return filepath;
};
async function makeFilepathAvailable(
filePath: string,
utilities: InstallationFilepathProviderUtilities,
): Promise<boolean> {
let isFileAvailable = false;
try {
isFileAvailable = await utilities.fileSystem.isFileAvailable(filePath);
} catch (error) {
throw new Error('File availability check failed');
}
if (!isFileAvailable) {
return true;
}
return utilities.accessFileSystemWithRetry(async () => {
try {
utilities.logger.info(`Existing update file found and will be replaced: ${filePath}`);
await utilities.fileSystem.deletePath(filePath);
return true;
} catch (error) {
utilities.logger.error(`Failed to prepare file path for update: ${filePath}`, error);
return false;
}
});
}
const DefaultUtilities: InstallationFilepathProviderUtilities = {
logger: ElectronLogger,
directoryProvider: new PersistentApplicationDirectoryProvider(),
fileSystem: NodeElectronFileSystemOperations,
accessFileSystemWithRetry: retryFileSystemAccess,
};

View File

@@ -1,7 +1,7 @@
import { app } from 'electron/main'; import { app } from 'electron/main';
import { shell } from 'electron/common'; import { shell } from 'electron/common';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { retryFileSystemAccess } from './RetryFileSystemAccess'; import { retryFileSystemAccess } from './FileSystemAccessorWithRetry';
export async function startInstallation(filePath: string): Promise<boolean> { export async function startInstallation(filePath: string): Promise<boolean> {
return retryFileSystemAccess(async () => { return retryFileSystemAccess(async () => {

View File

@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { createReadStream } from 'node:fs'; import { createReadStream } from 'node:fs';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger'; import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { retryFileSystemAccess } from './RetryFileSystemAccess'; import { retryFileSystemAccess } from './FileSystemAccessorWithRetry';
export async function checkIntegrity( export async function checkIntegrity(
filePath: string, filePath: string,

View File

@@ -4,7 +4,7 @@ import { GitHubProjectDetails } from '@/domain/Project/GitHubProjectDetails';
import { Version } from '@/domain/Version'; import { Version } from '@/domain/Version';
import { parseProjectDetails } from '@/application/Parser/ProjectDetailsParser'; import { parseProjectDetails } from '@/application/Parser/ProjectDetailsParser';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { UpdateProgressBar } from '../UpdateProgressBar'; import { UpdateProgressBar } from '../ProgressBar/UpdateProgressBar';
import { import {
promptForManualUpdate, promptInstallerOpenError, promptForManualUpdate, promptInstallerOpenError,
promptIntegrityCheckFailure, promptDownloadError, promptIntegrityCheckFailure, promptDownloadError,
@@ -14,29 +14,43 @@ import {
import { type DownloadUpdateResult, downloadUpdate } from './Downloader'; import { type DownloadUpdateResult, downloadUpdate } from './Downloader';
import { checkIntegrity } from './Integrity'; import { checkIntegrity } from './Integrity';
import { startInstallation } from './Installer'; import { startInstallation } from './Installer';
import { clearUpdateInstallationFiles } from './InstallationFiles/InstallationFileCleaner';
import type { UpdateInfo } from 'electron-updater'; import type { UpdateInfo } from 'electron-updater';
export function requiresManualUpdate(): boolean { export function requiresManualUpdate(
return process.platform === 'darwin'; nodePlatform: string = process.platform,
): boolean {
// autoUpdater cannot handle DMG files, requiring manual download management for these file types.
return nodePlatform === 'darwin';
} }
export async function startManualUpdateProcess(info: UpdateInfo) { export async function startManualUpdateProcess(info: UpdateInfo) {
try {
await clearUpdateInstallationFiles();
} catch (error) {
ElectronLogger.warn('Failed to clear previous update installation files', { error });
} finally {
await executeManualUpdateProcess(info);
}
}
async function executeManualUpdateProcess(info: UpdateInfo): Promise<void> {
try { try {
const updateAction = await promptForManualUpdate(); const updateAction = await promptForManualUpdate();
if (updateAction === ManualUpdateChoice.NoAction) { if (updateAction === ManualUpdateChoice.NoAction) {
ElectronLogger.info('User cancelled the update.'); ElectronLogger.info('User chose to cancel the update');
return; return;
} }
const { releaseUrl, downloadUrl } = getRemoteUpdateUrls(info.version); const { releaseUrl, downloadUrl } = getRemoteUpdateUrls(info.version);
if (updateAction === ManualUpdateChoice.VisitReleasesPage) { if (updateAction === ManualUpdateChoice.VisitReleasesPage) {
ElectronLogger.info(`Navigating to release page: ${releaseUrl}`); ElectronLogger.info('User chose to visit release page', { url: releaseUrl });
await shell.openExternal(releaseUrl); await shell.openExternal(releaseUrl);
} else if (updateAction === ManualUpdateChoice.UpdateNow) { } else if (updateAction === ManualUpdateChoice.UpdateNow) {
ElectronLogger.info('Initiating update download and installation.'); ElectronLogger.info('User chose to download and install update');
await downloadAndInstallUpdate(downloadUrl, info); await downloadAndInstallUpdate(downloadUrl, info);
} }
} catch (err) { } catch (err) {
ElectronLogger.error('Unexpected error during updates', err); ElectronLogger.error('Failed to execute auto-update process', { error: err });
await handleUnexpectedError(info); await handleUnexpectedError(info);
} }
} }
@@ -56,9 +70,10 @@ async function downloadAndInstallUpdate(fileUrl: string, info: UpdateInfo) {
} }
const userAction = await promptIntegrityCheckFailure(); const userAction = await promptIntegrityCheckFailure();
if (userAction === IntegrityCheckChoice.RetryDownload) { if (userAction === IntegrityCheckChoice.RetryDownload) {
ElectronLogger.info('User chose to retry download after integrity check failure');
await startManualUpdateProcess(info); await startManualUpdateProcess(info);
} else if (userAction === IntegrityCheckChoice.ContinueAnyway) { } else if (userAction === IntegrityCheckChoice.ContinueAnyway) {
ElectronLogger.warn('Proceeding to install with failed integrity check.'); ElectronLogger.warn('User chose to proceed with installation despite failed integrity check');
await openInstaller(download.installerPath, info); await openInstaller(download.installerPath, info);
} }
} }
@@ -66,9 +81,9 @@ async function downloadAndInstallUpdate(fileUrl: string, info: UpdateInfo) {
async function handleFailedDownload(info: UpdateInfo) { async function handleFailedDownload(info: UpdateInfo) {
const userAction = await promptDownloadError(); const userAction = await promptDownloadError();
if (userAction === DownloadErrorChoice.Cancel) { if (userAction === DownloadErrorChoice.Cancel) {
ElectronLogger.info('Update download canceled.'); ElectronLogger.info('User chose to cancel update download');
} else if (userAction === DownloadErrorChoice.RetryDownload) { } else if (userAction === DownloadErrorChoice.RetryDownload) {
ElectronLogger.info('Retrying update download.'); ElectronLogger.info('User chose to retry update download');
await startManualUpdateProcess(info); await startManualUpdateProcess(info);
} }
} }
@@ -76,9 +91,9 @@ async function handleFailedDownload(info: UpdateInfo) {
async function handleUnexpectedError(info: UpdateInfo) { async function handleUnexpectedError(info: UpdateInfo) {
const userAction = await showUnexpectedError(); const userAction = await showUnexpectedError();
if (userAction === UnexpectedErrorChoice.Cancel) { if (userAction === UnexpectedErrorChoice.Cancel) {
ElectronLogger.info('Unexpected error handling canceled.'); ElectronLogger.info('User chose to cancel update process after unexpected error');
} else if (userAction === UnexpectedErrorChoice.RetryUpdate) { } else if (userAction === UnexpectedErrorChoice.RetryUpdate) {
ElectronLogger.info('Retrying the update process.'); ElectronLogger.info('User chose to retry update process after unexpected error');
await startManualUpdateProcess(info); await startManualUpdateProcess(info);
} }
} }
@@ -89,8 +104,10 @@ async function openInstaller(installerPath: string, info: UpdateInfo) {
} }
const userAction = await promptInstallerOpenError(); const userAction = await promptInstallerOpenError();
if (userAction === InstallerErrorChoice.RetryDownload) { if (userAction === InstallerErrorChoice.RetryDownload) {
ElectronLogger.info('User chose to retry download after installer open error');
await startManualUpdateProcess(info); await startManualUpdateProcess(info);
} else if (userAction === InstallerErrorChoice.RetryOpen) { } else if (userAction === InstallerErrorChoice.RetryOpen) {
ElectronLogger.info('User chose to retry opening installer');
await openInstaller(installerPath, info); await openInstaller(installerPath, info);
} }
} }
@@ -100,7 +117,7 @@ async function withProgressBar(
) { ) {
const progressBar = new UpdateProgressBar(); const progressBar = new UpdateProgressBar();
await action(progressBar); await action(progressBar);
progressBar.close(); progressBar.closeIfOpen();
} }
async function isIntegrityPreserved( async function isIntegrityPreserved(
@@ -119,16 +136,16 @@ async function isIntegrityPreserved(
function getRemoteSha512Hash(info: UpdateInfo, fileUrl: string): string | undefined { function getRemoteSha512Hash(info: UpdateInfo, fileUrl: string): string | undefined {
const fileInfos = info.files.filter((file) => fileUrl.includes(file.url)); const fileInfos = info.files.filter((file) => fileUrl.includes(file.url));
if (!fileInfos.length) { if (!fileInfos.length) {
ElectronLogger.error(`Remote hash not found for the URL: ${fileUrl}`, info.files); ElectronLogger.error('Failed to find remote hash for download URL', { url: fileUrl, files: info.files });
if (info.files.length > 0) { if (info.files.length > 0) {
const firstHash = info.files[0].sha512; const firstHash = info.files[0].sha512;
ElectronLogger.info(`Selecting the first available hash: ${firstHash}`); ElectronLogger.info('Using first available hash due to missing match', { hash: firstHash });
return firstHash; return firstHash;
} }
return undefined; return undefined;
} }
if (fileInfos.length > 1) { if (fileInfos.length > 1) {
ElectronLogger.error(`Found multiple file entries for the URL: ${fileUrl}`, fileInfos); ElectronLogger.warn('Multiple file entries found for download URL', { url: fileUrl, entries: fileInfos });
} }
return fileInfos[0].sha512; return fileInfos[0].sha512;
} }

View File

@@ -0,0 +1,158 @@
// @ts-expect-error Outdated `@types/electron-progressbar` causes build failure on macOS
import ProgressBar from 'electron-progressbar';
import { BrowserWindow } from 'electron/main';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import type { Logger } from '@/application/Common/Log/Logger';
import { EventSource } from '@/infrastructure/Events/EventSource';
import type {
InitialProgressBarWindowOptions,
ProgressBarUpdater,
ProgressBarStatus,
ProgressBarUpdateCallback, ProgressBarWithLifecycle,
} from './ProgressBarWithLifecycle';
/**
* It provides a type-safe way to manage `electron-progressbar` instance,
* through its lifecycle, ensuring correct usage, state transitions, and cleanup.
*/
export class ElectronProgressBarWithLifecycle implements ProgressBarWithLifecycle {
private state: ProgressBarWithState = { status: 'closed' };
public readonly statusChanged = new EventSource<ProgressBarStatus>();
private readyCallbacks = new Array<ProgressBarUpdateCallback>();
constructor(
private readonly logger: Logger = ElectronLogger,
) { }
public update(handler: ProgressBarUpdateCallback): void {
switch (this.state.status) { // eslint-disable-line default-case
case 'closed':
// Throwing an error here helps catch bugs early in the development process.
throw new Error('Cannot update the progress bar because it is not currently open.');
case 'ready':
handler(wrapForUpdate(this.state));
break;
case 'loading':
this.readyCallbacks.push(handler);
break;
}
}
public resetAndOpen(options: InitialProgressBarWindowOptions): void {
this.closeIfOpen();
const bar = createElectronProgressBar(options);
this.state = { status: 'loading', progressBar: bar };
bar.on('ready', () => this.handleReadyEvent(bar));
bar.on('aborted' /* closed by user */, (value: number) => {
this.changeState({ status: 'closed' });
this.logger.info(`Progress bar window closed by user. State: ${this.state.status}, Value: ${value}.`);
});
}
public closeIfOpen() {
if (this.state.status === 'closed') {
return;
}
this.state.progressBar.close();
this.changeState({
status: 'closed',
});
}
private handleReadyEvent(bar: ProgressBar): void {
if (this.state.status !== 'loading' || this.state.progressBar !== bar) {
// Handle race conditions if `open` called rapidly without closing to avoid leaks
this.logger.warn('Unexpected state when handling ready event. Closing the progress bar.');
bar.close();
return;
}
const readyBar: ReadyProgressBar = {
status: 'ready',
progressBar: bar,
browserWindow: getWindow(bar),
};
this.readyCallbacks.forEach((callback) => callback(wrapForUpdate(readyBar)));
this.changeState(readyBar);
}
private changeState(newState: ProgressBarWithState): void {
if (isSameState(this.state, newState)) {
return;
}
this.readyCallbacks = [];
this.state = newState;
this.statusChanged.notify(newState.status);
}
}
type ProgressBarWithState = { readonly status: 'closed' }
| { readonly status: 'loading', readonly progressBar: ProgressBar }
| ReadyProgressBar;
interface ReadyProgressBar {
readonly status: 'ready';
readonly progressBar: ProgressBar;
readonly browserWindow: BrowserWindow;
}
function getWindow(bar: ProgressBar): BrowserWindow {
// Note: The ProgressBar library does not provide a public method or event
// to access the BrowserWindow, so we access the internal `_window` property directly.
if (!('_window' in bar)) {
throw new Error('Unable to access the progress bar window.');
}
const browserWindow = bar._window as BrowserWindow; // eslint-disable-line no-underscore-dangle
if (!browserWindow) {
throw new Error('Missing internal browser window');
}
return browserWindow;
}
function isSameState( // eslint-disable-line consistent-return
first: ProgressBarWithState,
second: ProgressBarWithState,
): boolean {
switch (first.status) { // eslint-disable-line default-case
case 'closed':
return second.status === 'closed';
case 'loading':
return second.status === 'loading'
&& second.progressBar === first.progressBar;
case 'ready':
return second.status === 'ready'
&& second.progressBar === first.progressBar
&& second.browserWindow === first.browserWindow;
}
}
function wrapForUpdate(bar: ReadyProgressBar): ProgressBarUpdater {
return {
setText: (text: string) => {
bar.progressBar.detail = text;
},
setClosable: (closable: boolean) => {
bar.browserWindow.setClosable(closable);
},
setProgress: (progress: number) => {
bar.progressBar.value = progress;
},
};
}
function createElectronProgressBar(
options: InitialProgressBarWindowOptions,
): ProgressBar {
const bar = new ProgressBar({
indeterminate: options.type === 'indeterminate',
title: options.title,
text: options.initialText,
});
if (options.type === 'percentile') { // Indeterminate progress bar does not fire `completed` event, see `electron-progressbar` docs
bar.on('completed', () => {
bar.detail = options.textOnCompleted;
});
}
return bar;
}

View File

@@ -0,0 +1,31 @@
import type { IEventSource } from '@/infrastructure/Events/IEventSource';
/*
Defines interfaces to abstract the progress bar implementation,
serving as an anti-corruption layer. This approach allows for
flexibility in switching implementations if needed in the future,
while maintaining a consistent API for the rest of the application.
*/
export interface ProgressBarWithLifecycle {
resetAndOpen(options: InitialProgressBarWindowOptions): void;
closeIfOpen(): void;
update(handler: ProgressBarUpdateCallback): void;
readonly statusChanged: IEventSource<ProgressBarStatus>;
}
export type ProgressBarStatus = 'closed' | 'loading' | 'ready';
export type ProgressBarUpdateCallback = (bar: ProgressBarUpdater) => void;
export interface InitialProgressBarWindowOptions {
readonly type: 'indeterminate' | 'percentile';
readonly title: string,
readonly initialText: string;
readonly textOnCompleted: string;
}
export interface ProgressBarUpdater {
setText(text: string): void;
setClosable(closable: boolean): void;
setProgress(progress: number): void;
}

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