Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0900492ccb | ||
|
|
5db8c6b591 | ||
|
|
9e8bad0084 | ||
|
|
eb8812b26e | ||
|
|
3f56166655 | ||
|
|
69e7e0adf1 | ||
|
|
74378f74bf | ||
|
|
2f31bc7b06 | ||
|
|
4e06d543b3 | ||
|
|
a536c6970f | ||
|
|
e17744faf0 | ||
|
|
a05a600071 | ||
|
|
8b6067f83f | ||
|
|
98e8dc0a67 | ||
|
|
6b8f6aae81 | ||
|
|
dc5c87376b | ||
|
|
db090f3696 | ||
|
|
aee24cdaa1 | ||
|
|
be0ab9b125 |
12
.github/actions/install-imagemagick/action.yml
vendored
Normal file
12
.github/actions/install-imagemagick/action.yml
vendored
Normal 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 }}
|
||||
56
.github/actions/install-imagemagick/install-imagemagick.sh
vendored
Executable file
56
.github/actions/install-imagemagick/install-imagemagick.sh
vendored
Executable 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
|
||||
@@ -26,9 +26,12 @@ jobs:
|
||||
-
|
||||
name: Install dependencies
|
||||
uses: ./.github/actions/npm-install-dependencies
|
||||
-
|
||||
name: Install ImageMagick # For screenshots
|
||||
uses: ./.github/actions/install-imagemagick
|
||||
-
|
||||
name: Configure Ubuntu
|
||||
if: contains(matrix.os, 'ubuntu') # macOS runner is missing Docker
|
||||
if: contains(matrix.os, 'ubuntu')
|
||||
shell: bash
|
||||
run: |-
|
||||
sudo apt update
|
||||
@@ -56,11 +59,20 @@ jobs:
|
||||
sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
|
||||
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
|
||||
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
|
||||
shell: bash
|
||||
|
||||
3
.github/workflows/checks.quality.yaml
vendored
3
.github/workflows/checks.quality.yaml
vendored
@@ -11,8 +11,9 @@ jobs:
|
||||
- npm run lint:eslint
|
||||
- npm run lint:yaml
|
||||
- npm run lint:md
|
||||
- npm run lint:md:relative-urls
|
||||
- npm run lint:md:consistency
|
||||
- npm run lint:md:relative-urls
|
||||
- npm run lint:md:external-urls
|
||||
os: [ macos, ubuntu, windows ]
|
||||
fail-fast: false # Still interested to see results from other combinations
|
||||
steps:
|
||||
|
||||
5
.github/workflows/checks.scripts.yaml
vendored
5
.github/workflows/checks.scripts.yaml
vendored
@@ -16,9 +16,8 @@ jobs:
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
-
|
||||
name: Install ImageMagick on macOS
|
||||
if: matrix.os == 'macos'
|
||||
run: brew install imagemagick
|
||||
name: Install ImageMagick
|
||||
uses: ./.github/actions/install-imagemagick
|
||||
-
|
||||
name: Setup node
|
||||
uses: ./.github/actions/setup-node
|
||||
|
||||
33
CHANGELOG.md
33
CHANGELOG.md
@@ -1,5 +1,38 @@
|
||||
# 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)
|
||||
|
||||
* ci/cd: centralize and bump artifact uploads | [22d6c79](https://github.com/undergroundwires/privacy.sexy/commit/22d6c7991eb2c138578a7d41950f301906dbf703)
|
||||
|
||||
@@ -122,7 +122,7 @@
|
||||
## Get started
|
||||
|
||||
- 🌍️ **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:
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
> **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.
|
||||
> 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 ❤️.
|
||||
|
||||
### Logging
|
||||
|
||||
@@ -39,6 +39,7 @@ See [ci-cd.md](./ci-cd.md) for more information.
|
||||
- Markdown: `npm run lint:md`
|
||||
- Markdown consistency `npm run lint:md:consistency`
|
||||
- Markdown relative URLs: `npm run lint:md:relative-urls`
|
||||
- Markdown external URLs: `npm run lint:md:external-urls`
|
||||
- JavaScript/TypeScript: `npm run lint:eslint`
|
||||
- Yaml: `npm run lint:yaml`
|
||||
|
||||
|
||||
4746
package-lock.json
generated
4746
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "privacy.sexy",
|
||||
"version": "0.13.5",
|
||||
"version": "0.13.6",
|
||||
"private": true,
|
||||
"slogan": "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:cy:run": "start-server-and-test \"vite build && vite preview --port 7070\" http://localhost:7070 \"cypress run --config baseUrl=http://localhost:7070\"",
|
||||
"test:cy:open": "start-server-and-test \"vite --port 7070 --mode production\" http://localhost:7070 \"cypress open --config baseUrl=http://localhost:7070\"",
|
||||
"lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml && 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",
|
||||
"icons:build": "node scripts/logo-update.js",
|
||||
"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:consistency": "remark . --frail --use remark-preset-lint-consistent",
|
||||
"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:pylint": "pylint **/*.py",
|
||||
"postinstall": "electron-builder install-app-deps",
|
||||
@@ -71,15 +72,15 @@
|
||||
"markdownlint-cli": "^0.41.0",
|
||||
"postcss": "^8.4.39",
|
||||
"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-validate-links": "^13.0.1",
|
||||
"sass": "^1.77.8",
|
||||
"sass": "~1.79.4",
|
||||
"start-server-and-test": "^2.0.4",
|
||||
"terser": "^5.31.3",
|
||||
"tslib": "^2.6.3",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.3.4",
|
||||
"typescript": "~5.5.4",
|
||||
"vite": "^5.4.8",
|
||||
"vitest": "^2.0.3",
|
||||
"vue-tsc": "^2.0.26",
|
||||
"yaml-lint": "^1.7.0"
|
||||
|
||||
@@ -33,23 +33,25 @@ function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
|
||||
if (!casedValue) {
|
||||
throw new Error(`unknown ${enumName}: "${value}"`);
|
||||
}
|
||||
return enumVariable[casedValue as keyof typeof enumVariable];
|
||||
return enumVariable[casedValue as keyof EnumVariable<T, TEnumValue>];
|
||||
}
|
||||
|
||||
export function getEnumNames
|
||||
<T extends EnumType, TEnumValue extends EnumType>(
|
||||
enumVariable: EnumVariable<T, TEnumValue>,
|
||||
): string[] {
|
||||
): (string & keyof EnumVariable<T, TEnumValue>)[] {
|
||||
return Object
|
||||
.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>(
|
||||
enumVariable: EnumVariable<T, TEnumValue>,
|
||||
): TEnumValue[] {
|
||||
return getEnumNames(enumVariable)
|
||||
.map((level) => enumVariable[level]) as TEnumValue[];
|
||||
.map((name) => enumVariable[name]) as TEnumValue[];
|
||||
}
|
||||
|
||||
export function assertInRange<T extends EnumType, TEnumValue extends EnumType>(
|
||||
|
||||
@@ -17,7 +17,7 @@ export class ApplicationContext implements IApplicationContext {
|
||||
public currentOs: OperatingSystem;
|
||||
|
||||
public get state(): ICategoryCollectionState {
|
||||
return this.states[this.collection.os];
|
||||
return this.getState(this.collection.os);
|
||||
}
|
||||
|
||||
private readonly states: StateMachine;
|
||||
@@ -26,30 +26,51 @@ export class ApplicationContext implements IApplicationContext {
|
||||
public readonly app: IApplication,
|
||||
initialContext: OperatingSystem,
|
||||
) {
|
||||
this.setContext(initialContext);
|
||||
this.states = initializeStates(app);
|
||||
this.changeContext(initialContext);
|
||||
}
|
||||
|
||||
public changeContext(os: OperatingSystem): void {
|
||||
assertInRange(os, OperatingSystem);
|
||||
if (this.currentOs === os) {
|
||||
return;
|
||||
}
|
||||
const collection = this.app.getCollection(os);
|
||||
this.collection = collection;
|
||||
const event: IApplicationContextChangedEvent = {
|
||||
newState: this.states[os],
|
||||
oldState: this.states[this.currentOs],
|
||||
newState: this.getState(os),
|
||||
oldState: this.getState(this.currentOs),
|
||||
};
|
||||
this.setContext(os);
|
||||
this.contextChanged.notify(event);
|
||||
}
|
||||
|
||||
private setContext(os: OperatingSystem): void {
|
||||
validateOperatingSystem(os, this.app);
|
||||
this.collection = this.app.getCollection(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 {
|
||||
const machine = new Map<OperatingSystem, ICategoryCollectionState>();
|
||||
for (const collection of app.collections) {
|
||||
machine[collection.os] = new CategoryCollectionState(collection);
|
||||
machine.set(collection.os, new CategoryCollectionState(collection));
|
||||
}
|
||||
return machine;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { createEnumParser, type EnumParser } from '../Common/Enum';
|
||||
import { parseCategory, type CategoryParser } from './Executable/CategoryParser';
|
||||
import { parseScriptingDefinition, type ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser';
|
||||
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 = (
|
||||
content,
|
||||
@@ -16,9 +16,9 @@ export const parseCategoryCollection: CategoryCollectionParser = (
|
||||
) => {
|
||||
validateCollection(content, utilities.validator);
|
||||
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(
|
||||
(action) => utilities.parseCategory(action, collectionUtilities),
|
||||
(action) => utilities.parseCategory(action, collectionContext),
|
||||
);
|
||||
const os = utilities.osParser.parseEnum(content.os, 'os');
|
||||
const collection = utilities.createCategoryCollection({
|
||||
@@ -60,7 +60,7 @@ interface CategoryCollectionParserUtilities {
|
||||
readonly osParser: EnumParser<OperatingSystem>;
|
||||
readonly validator: TypeValidator;
|
||||
readonly parseScriptingDefinition: ScriptingDefinitionParser;
|
||||
readonly createUtilities: CategoryCollectionSpecificUtilitiesFactory;
|
||||
readonly createContext: CategoryCollectionContextFactory;
|
||||
readonly parseCategory: CategoryParser;
|
||||
readonly createCategoryCollection: CategoryCollectionFactory;
|
||||
}
|
||||
@@ -69,7 +69,7 @@ const DefaultUtilities: CategoryCollectionParserUtilities = {
|
||||
osParser: createEnumParser(OperatingSystem),
|
||||
validator: createTypeValidator(),
|
||||
parseScriptingDefinition,
|
||||
createUtilities: createCollectionUtilities,
|
||||
createContext: createCategoryCollectionContext,
|
||||
parseCategory,
|
||||
createCategoryCollection: (...args) => new CategoryCollection(...args),
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -9,16 +9,16 @@ import { parseDocs, type DocsParser } from './DocumentationParser';
|
||||
import { parseScript, type ScriptParser } from './Script/ScriptParser';
|
||||
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from './Validation/ExecutableValidator';
|
||||
import { ExecutableType } from './Validation/ExecutableType';
|
||||
import type { CategoryCollectionSpecificUtilities } from './CategoryCollectionSpecificUtilities';
|
||||
import type { CategoryCollectionContext } from './CategoryCollectionContext';
|
||||
|
||||
export const parseCategory: CategoryParser = (
|
||||
category: CategoryData,
|
||||
collectionUtilities: CategoryCollectionSpecificUtilities,
|
||||
collectionContext: CategoryCollectionContext,
|
||||
categoryUtilities: CategoryParserUtilities = DefaultCategoryParserUtilities,
|
||||
) => {
|
||||
return parseCategoryRecursively({
|
||||
categoryData: category,
|
||||
collectionUtilities,
|
||||
collectionContext,
|
||||
categoryUtilities,
|
||||
});
|
||||
};
|
||||
@@ -26,14 +26,14 @@ export const parseCategory: CategoryParser = (
|
||||
export interface CategoryParser {
|
||||
(
|
||||
category: CategoryData,
|
||||
collectionUtilities: CategoryCollectionSpecificUtilities,
|
||||
collectionContext: CategoryCollectionContext,
|
||||
categoryUtilities?: CategoryParserUtilities,
|
||||
): Category;
|
||||
}
|
||||
|
||||
interface CategoryParseContext {
|
||||
readonly categoryData: CategoryData;
|
||||
readonly collectionUtilities: CategoryCollectionSpecificUtilities;
|
||||
readonly collectionContext: CategoryCollectionContext;
|
||||
readonly parentCategory?: CategoryData;
|
||||
readonly categoryUtilities: CategoryParserUtilities;
|
||||
}
|
||||
@@ -52,7 +52,7 @@ function parseCategoryRecursively(
|
||||
children,
|
||||
parent: context.categoryData,
|
||||
categoryUtilities: context.categoryUtilities,
|
||||
collectionUtilities: context.collectionUtilities,
|
||||
collectionContext: context.collectionContext,
|
||||
});
|
||||
}
|
||||
try {
|
||||
@@ -82,7 +82,7 @@ function ensureValidCategory(
|
||||
});
|
||||
validator.assertType((v) => v.assertObject({
|
||||
value: category,
|
||||
valueName: `Category '${category.category}'` ?? 'Category',
|
||||
valueName: category.category ? `Category '${category.category}'` : 'Category',
|
||||
allowedProperties: [
|
||||
'docs', 'children', 'category',
|
||||
],
|
||||
@@ -104,7 +104,7 @@ interface ExecutableParseContext {
|
||||
readonly data: ExecutableData;
|
||||
readonly children: CategoryChildren;
|
||||
readonly parent: CategoryData;
|
||||
readonly collectionUtilities: CategoryCollectionSpecificUtilities;
|
||||
readonly collectionContext: CategoryCollectionContext;
|
||||
readonly categoryUtilities: CategoryParserUtilities;
|
||||
}
|
||||
|
||||
@@ -124,13 +124,13 @@ function parseUnknownExecutable(context: ExecutableParseContext) {
|
||||
if (isCategory(context.data)) {
|
||||
const subCategory = parseCategoryRecursively({
|
||||
categoryData: context.data,
|
||||
collectionUtilities: context.collectionUtilities,
|
||||
collectionContext: context.collectionContext,
|
||||
parentCategory: context.parent,
|
||||
categoryUtilities: context.categoryUtilities,
|
||||
});
|
||||
context.children.subcategories.push(subCategory);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,14 +2,12 @@ import type {
|
||||
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData,
|
||||
CallInstruction, ParameterDefinitionData,
|
||||
} from '@/application/collections/';
|
||||
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 { NoDuplicatedLines } from '@/application/Parser/Executable/Script/Validation/Rules/NoDuplicatedLines';
|
||||
import type { ICodeValidator } from '@/application/Parser/Executable/Script/Validation/ICodeValidator';
|
||||
import { validateCode, type CodeValidator } from '@/application/Parser/Executable/Script/Validation/CodeValidator';
|
||||
import { isArray, isNullOrUndefined, isPlainObject } from '@/TypeHelpers';
|
||||
import { wrapErrorWithAdditionalContext, type ErrorWithContextWrapper } from '@/application/Parser/Common/ContextualError';
|
||||
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 { SharedFunctionCollection } from './SharedFunctionCollection';
|
||||
import { parseFunctionCalls, type FunctionCallsParser } from './Call/FunctionCallsParser';
|
||||
@@ -23,14 +21,14 @@ import type { ISharedFunction } from './ISharedFunction';
|
||||
export interface SharedFunctionsParser {
|
||||
(
|
||||
functions: readonly FunctionData[],
|
||||
syntax: ILanguageSyntax,
|
||||
language: ScriptingLanguage,
|
||||
utilities?: SharedFunctionsParsingUtilities,
|
||||
): ISharedFunctionCollection;
|
||||
}
|
||||
|
||||
export const parseSharedFunctions: SharedFunctionsParser = (
|
||||
functions: readonly FunctionData[],
|
||||
syntax: ILanguageSyntax,
|
||||
language: ScriptingLanguage,
|
||||
utilities = DefaultUtilities,
|
||||
) => {
|
||||
const collection = new SharedFunctionCollection();
|
||||
@@ -39,7 +37,7 @@ export const parseSharedFunctions: SharedFunctionsParser = (
|
||||
}
|
||||
ensureValidFunctions(functions);
|
||||
return functions
|
||||
.map((func) => parseFunction(func, syntax, utilities))
|
||||
.map((func) => parseFunction(func, language, utilities))
|
||||
.reduce((acc, func) => {
|
||||
acc.addFunction(func);
|
||||
return acc;
|
||||
@@ -49,7 +47,7 @@ export const parseSharedFunctions: SharedFunctionsParser = (
|
||||
const DefaultUtilities: SharedFunctionsParsingUtilities = {
|
||||
wrapError: wrapErrorWithAdditionalContext,
|
||||
parseParameter: parseFunctionParameter,
|
||||
codeValidator: CodeValidator.instance,
|
||||
codeValidator: validateCode,
|
||||
createParameterCollection: createFunctionParameterCollection,
|
||||
parseFunctionCalls,
|
||||
};
|
||||
@@ -57,20 +55,20 @@ const DefaultUtilities: SharedFunctionsParsingUtilities = {
|
||||
interface SharedFunctionsParsingUtilities {
|
||||
readonly wrapError: ErrorWithContextWrapper;
|
||||
readonly parseParameter: FunctionParameterParser;
|
||||
readonly codeValidator: ICodeValidator;
|
||||
readonly codeValidator: CodeValidator;
|
||||
readonly createParameterCollection: FunctionParameterCollectionFactory;
|
||||
readonly parseFunctionCalls: FunctionCallsParser;
|
||||
}
|
||||
|
||||
function parseFunction(
|
||||
data: FunctionData,
|
||||
syntax: ILanguageSyntax,
|
||||
language: ScriptingLanguage,
|
||||
utilities: SharedFunctionsParsingUtilities,
|
||||
): ISharedFunction {
|
||||
const { name } = data;
|
||||
const parameters = parseParameters(data, utilities);
|
||||
if (hasCode(data)) {
|
||||
validateCode(data, syntax, utilities.codeValidator);
|
||||
validateNonEmptyCode(data, language, utilities.codeValidator);
|
||||
return createFunctionWithInlineCode(name, parameters, data.code, data.revertCode);
|
||||
}
|
||||
// Has call
|
||||
@@ -78,16 +76,20 @@ function parseFunction(
|
||||
return createCallerFunction(name, parameters, calls);
|
||||
}
|
||||
|
||||
function validateCode(
|
||||
function validateNonEmptyCode(
|
||||
data: CodeFunctionData,
|
||||
syntax: ILanguageSyntax,
|
||||
validator: ICodeValidator,
|
||||
language: ScriptingLanguage,
|
||||
validate: CodeValidator,
|
||||
): void {
|
||||
filterEmptyStrings([data.code, data.revertCode])
|
||||
.forEach(
|
||||
(code) => validator.throwIfInvalid(
|
||||
(code) => validate(
|
||||
code,
|
||||
[new NoEmptyLines(), new NoDuplicatedLines(syntax)],
|
||||
language,
|
||||
[
|
||||
CodeValidationRule.NoEmptyLines,
|
||||
CodeValidationRule.NoDuplicatedLines,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 { 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 {
|
||||
readonly sharedFunctionsParser: SharedFunctionsParser;
|
||||
readonly callCompiler: FunctionCallCompiler;
|
||||
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;
|
||||
export interface ScriptCompiler {
|
||||
canCompile(script: ScriptData): boolean;
|
||||
compile(script: ScriptData): ScriptCode;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
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 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 type { ScriptCodeFactory } 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 { filterEmptyStrings } from '@/application/Common/Text/FilterEmptyStrings';
|
||||
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 { ExecutableType } from '../Validation/ExecutableType';
|
||||
import { createExecutableDataValidator, type ExecutableValidator, type ExecutableValidatorFactory } from '../Validation/ExecutableValidator';
|
||||
import { CodeValidator } from './Validation/CodeValidator';
|
||||
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
|
||||
import type { CategoryCollectionSpecificUtilities } from '../CategoryCollectionSpecificUtilities';
|
||||
import type { CategoryCollectionContext } from '../CategoryCollectionContext';
|
||||
|
||||
export interface ScriptParser {
|
||||
(
|
||||
data: ScriptData,
|
||||
collectionUtilities: CategoryCollectionSpecificUtilities,
|
||||
collectionContext: CategoryCollectionContext,
|
||||
scriptUtilities?: ScriptParserUtilities,
|
||||
): Script;
|
||||
}
|
||||
|
||||
export const parseScript: ScriptParser = (
|
||||
data,
|
||||
collectionUtilities,
|
||||
collectionContext,
|
||||
scriptUtilities = DefaultUtilities,
|
||||
) => {
|
||||
const validator = scriptUtilities.createValidator({
|
||||
@@ -42,7 +40,7 @@ export const parseScript: ScriptParser = (
|
||||
name: data.name,
|
||||
code: parseCode(
|
||||
data,
|
||||
collectionUtilities,
|
||||
collectionContext,
|
||||
scriptUtilities.codeValidator,
|
||||
scriptUtilities.createCode,
|
||||
),
|
||||
@@ -70,29 +68,34 @@ function parseLevel(
|
||||
|
||||
function parseCode(
|
||||
script: ScriptData,
|
||||
collectionUtilities: CategoryCollectionSpecificUtilities,
|
||||
codeValidator: ICodeValidator,
|
||||
collectionContext: CategoryCollectionContext,
|
||||
codeValidator: CodeValidator,
|
||||
createCode: ScriptCodeFactory,
|
||||
): ScriptCode {
|
||||
if (collectionUtilities.compiler.canCompile(script)) {
|
||||
return collectionUtilities.compiler.compile(script);
|
||||
if (collectionContext.compiler.canCompile(script)) {
|
||||
return collectionContext.compiler.compile(script);
|
||||
}
|
||||
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled
|
||||
const code = createCode(codeScript.code, codeScript.revertCode);
|
||||
validateHardcodedCodeWithoutCalls(code, codeValidator, collectionUtilities.syntax);
|
||||
validateHardcodedCodeWithoutCalls(code, codeValidator, collectionContext.language);
|
||||
return code;
|
||||
}
|
||||
|
||||
function validateHardcodedCodeWithoutCalls(
|
||||
scriptCode: ScriptCode,
|
||||
validator: ICodeValidator,
|
||||
syntax: ILanguageSyntax,
|
||||
validate: CodeValidator,
|
||||
language: ScriptingLanguage,
|
||||
) {
|
||||
filterEmptyStrings([scriptCode.execute, scriptCode.revert])
|
||||
.forEach(
|
||||
(code) => validator.throwIfInvalid(
|
||||
(code) => validate(
|
||||
code,
|
||||
[new NoEmptyLines(), new NoDuplicatedLines(syntax)],
|
||||
language,
|
||||
[
|
||||
CodeValidationRule.NoEmptyLines,
|
||||
CodeValidationRule.NoDuplicatedLines,
|
||||
CodeValidationRule.NoTooLongLines,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -103,7 +106,7 @@ function validateScript(
|
||||
): asserts script is NonNullable<ScriptData> {
|
||||
validator.assertType((v) => v.assertObject<CallScriptData & CodeScriptData>({
|
||||
value: script,
|
||||
valueName: `Script '${script.name}'` ?? 'Script',
|
||||
valueName: script.name ? `Script '${script.name}'` : 'Script',
|
||||
allowedProperties: [
|
||||
'name', 'recommend', 'code', 'revertCode', 'call', 'docs',
|
||||
],
|
||||
@@ -126,7 +129,7 @@ function validateScript(
|
||||
interface ScriptParserUtilities {
|
||||
readonly levelParser: EnumParser<RecommendationLevel>;
|
||||
readonly createScript: ScriptFactory;
|
||||
readonly codeValidator: ICodeValidator;
|
||||
readonly codeValidator: CodeValidator;
|
||||
readonly wrapError: ErrorWithContextWrapper;
|
||||
readonly createValidator: ExecutableValidatorFactory;
|
||||
readonly createCode: ScriptCodeFactory;
|
||||
@@ -136,7 +139,7 @@ interface ScriptParserUtilities {
|
||||
const DefaultUtilities: ScriptParserUtilities = {
|
||||
levelParser: createEnumParser(RecommendationLevel),
|
||||
createScript,
|
||||
codeValidator: CodeValidator.instance,
|
||||
codeValidator: validateCode,
|
||||
wrapError: wrapErrorWithAdditionalContext,
|
||||
createValidator: createExecutableDataValidator,
|
||||
createCode: createScriptCode,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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})`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 PowerShellCommonCodeParts = ['{', '}'];
|
||||
|
||||
export class BatchFileSyntax implements ILanguageSyntax {
|
||||
export class BatchFileSyntax implements LanguageSyntax {
|
||||
public readonly commentDelimiters = ['REM', '::'];
|
||||
|
||||
public readonly commonCodeParts = [...BatchFileCommonCodeParts, ...PowerShellCommonCodeParts];
|
||||
@@ -0,0 +1,4 @@
|
||||
export interface LanguageSyntax {
|
||||
readonly commentDelimiters: readonly string[];
|
||||
readonly commonCodeParts: readonly string[];
|
||||
}
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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]}"`);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum CodeValidationRule {
|
||||
NoEmptyLines,
|
||||
NoDuplicatedLines,
|
||||
NoTooLongLines,
|
||||
}
|
||||
@@ -1,46 +1,78 @@
|
||||
import { splitTextIntoLines } from '@/application/Common/Text/SplitTextIntoLines';
|
||||
import type { ICodeLine } from './ICodeLine';
|
||||
import type { ICodeValidationRule, IInvalidCodeLine } from './ICodeValidationRule';
|
||||
import type { ICodeValidator } from './ICodeValidator';
|
||||
import type { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||
import { createValidationAnalyzers, type ValidationRuleAnalyzerFactory } from './ValidationRuleAnalyzerFactory';
|
||||
import type { CodeLine, InvalidCodeLine } from './Analyzers/CodeValidationAnalyzer';
|
||||
import type { CodeValidationRule } from './CodeValidationRule';
|
||||
|
||||
export class CodeValidator implements ICodeValidator {
|
||||
public static readonly instance: ICodeValidator = new CodeValidator();
|
||||
|
||||
public throwIfInvalid(
|
||||
export interface CodeValidator {
|
||||
(
|
||||
code: string,
|
||||
rules: readonly ICodeValidationRule[],
|
||||
): void {
|
||||
if (rules.length === 0) { throw new Error('missing rules'); }
|
||||
language: ScriptingLanguage,
|
||||
rules: readonly CodeValidationRule[],
|
||||
analyzerFactory?: ValidationRuleAnalyzerFactory,
|
||||
): void;
|
||||
}
|
||||
|
||||
export const validateCode: CodeValidator = (
|
||||
code,
|
||||
language,
|
||||
rules,
|
||||
analyzerFactory = createValidationAnalyzers,
|
||||
) => {
|
||||
const analyzers = analyzerFactory(rules);
|
||||
if (!code) {
|
||||
return;
|
||||
}
|
||||
const lines = extractLines(code);
|
||||
const invalidLines = rules.flatMap((rule) => rule.analyze(lines));
|
||||
const invalidLines = analyzers.flatMap((analyze) => analyze(lines, language));
|
||||
if (invalidLines.length === 0) {
|
||||
return;
|
||||
}
|
||||
const errorText = `Errors with the code.\n${printLines(lines, invalidLines)}`;
|
||||
const errorText = `Errors with the code.\n${formatLines(lines, invalidLines)}`;
|
||||
throw new Error(errorText);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
function extractLines(code: string): ICodeLine[] {
|
||||
function extractLines(code: string): CodeLine[] {
|
||||
const lines = splitTextIntoLines(code);
|
||||
return lines.map((lineText, lineIndex): ICodeLine => ({
|
||||
index: lineIndex + 1,
|
||||
return lines.map((lineText, lineIndex): CodeLine => ({
|
||||
lineNumber: lineIndex + 1,
|
||||
text: lineText,
|
||||
}));
|
||||
}
|
||||
|
||||
function printLines(
|
||||
lines: readonly ICodeLine[],
|
||||
invalidLines: readonly IInvalidCodeLine[],
|
||||
function formatLines(
|
||||
lines: readonly CodeLine[],
|
||||
invalidLines: readonly InvalidCodeLine[],
|
||||
): string {
|
||||
return lines.map((line) => {
|
||||
const badLine = invalidLines.find((invalidLine) => invalidLine.index === line.index);
|
||||
if (!badLine) {
|
||||
return `[${line.index}] ✅ ${line.text}`;
|
||||
}
|
||||
return `[${badLine.index}] ❌ ${line.text}\n\t⟶ ${badLine.error}`;
|
||||
const badLine = invalidLines.find((invalidLine) => invalidLine.lineNumber === line.lineNumber);
|
||||
return formatLine({
|
||||
lineNumber: line.lineNumber,
|
||||
text: line.text,
|
||||
error: badLine?.error,
|
||||
});
|
||||
}).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;
|
||||
}
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface ICodeLine {
|
||||
readonly index: number;
|
||||
readonly text: string;
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import type { ICodeValidationRule } from './ICodeValidationRule';
|
||||
|
||||
export interface ICodeValidator {
|
||||
throwIfInvalid(
|
||||
code: string,
|
||||
rules: readonly ICodeValidationRule[],
|
||||
): void;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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}"`;
|
||||
})(),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export interface ILanguageSyntax {
|
||||
readonly commentDelimiters: string[];
|
||||
readonly commonCodeParts: string[];
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory';
|
||||
import type { ILanguageSyntax } from './ILanguageSyntax';
|
||||
|
||||
export type ISyntaxFactory = IScriptingLanguageFactory<ILanguageSyntax>;
|
||||
@@ -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'];
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -10,7 +10,7 @@ export type CompositeCategoryCollectionValidator = CategoryCollectionValidator &
|
||||
...Parameters<CategoryCollectionValidator>,
|
||||
(readonly CategoryCollectionValidator[])?,
|
||||
]
|
||||
): void;
|
||||
): ReturnType<CategoryCollectionValidator>;
|
||||
};
|
||||
|
||||
export const validateCategoryCollection: CompositeCategoryCollectionValidator = (
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,21 +1,22 @@
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
|
||||
import { NodeElectronSystemOperations } from '../System/NodeElectronSystemOperations';
|
||||
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
|
||||
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 { PersistentDirectoryProvider } from './Directory/PersistentDirectoryProvider';
|
||||
import type { SystemOperations } from '../System/SystemOperations';
|
||||
import type { FilenameGenerator } from './Filename/FilenameGenerator';
|
||||
import type { ScriptFilenameParts, ScriptFileCreator, ScriptFileCreationOutcome } from './ScriptFileCreator';
|
||||
import type { ScriptDirectoryProvider } from './Directory/ScriptDirectoryProvider';
|
||||
|
||||
export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
|
||||
constructor(
|
||||
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
|
||||
private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
|
||||
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 logger: Logger = ElectronLogger,
|
||||
) { }
|
||||
@@ -26,9 +27,12 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
|
||||
): Promise<ScriptFileCreationOutcome> {
|
||||
const {
|
||||
success: isDirectoryCreated, error: directoryCreationError, directoryAbsolutePath,
|
||||
} = await this.directoryProvider.provideScriptDirectory();
|
||||
} = await this.directoryProvider.provideDirectory('script-runs');
|
||||
if (!isDirectoryCreated) {
|
||||
return createFailure(directoryCreationError);
|
||||
return createFailure({
|
||||
type: 'DirectoryCreationError',
|
||||
message: `[${directoryCreationError.type}] ${directoryCreationError.message}`,
|
||||
});
|
||||
}
|
||||
const {
|
||||
success: isFilePathConstructed, error: filePathGenerationError, filePath,
|
||||
@@ -54,7 +58,7 @@ export class ScriptFileCreationOrchestrator implements ScriptFileCreator {
|
||||
): FilePathConstructionOutcome {
|
||||
try {
|
||||
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 };
|
||||
} catch (error) {
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ExecutablePermissionSetter } from './ExecutablePermissionSetter';
|
||||
|
||||
export class FileSystemExecutablePermissionSetter implements ExecutablePermissionSetter {
|
||||
constructor(
|
||||
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
|
||||
private readonly system: SystemOperations = NodeElectronSystemOperations,
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
) { }
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ShellCommandOutcome, ShellCommandRunner } from './ShellCommandRunn
|
||||
export class LoggingNodeShellCommandRunner implements ShellCommandRunner {
|
||||
constructor(
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
private readonly systemOps: SystemOperations = new NodeElectronSystemOperations(),
|
||||
private readonly systemOps: SystemOperations = NodeElectronSystemOperations,
|
||||
) {
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +1,13 @@
|
||||
import { join } from 'node:path';
|
||||
import { chmod, mkdir } from 'node:fs/promises';
|
||||
import { exec } from 'node:child_process';
|
||||
import { app } from 'electron/main';
|
||||
import type {
|
||||
CommandOps, FileSystemOps, LocationOps, OperatingSystemOps, SystemOperations,
|
||||
} from './SystemOperations';
|
||||
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
|
||||
import type { SystemOperations } from './SystemOperations';
|
||||
|
||||
/**
|
||||
* Thin wrapper for Node and Electron APIs.
|
||||
*/
|
||||
export class NodeElectronSystemOperations implements SystemOperations {
|
||||
public readonly operatingSystem: OperatingSystemOps = {
|
||||
/*
|
||||
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 = {
|
||||
export const NodeElectronSystemOperations: SystemOperations = {
|
||||
fileSystem: NodeElectronFileSystemOperations,
|
||||
command: {
|
||||
exec,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,25 +1,11 @@
|
||||
import type { FileSystemOperations } from '@/infrastructure/FileSystem/FileSystemOperations';
|
||||
import type { exec } from 'node:child_process';
|
||||
|
||||
export interface SystemOperations {
|
||||
readonly operatingSystem: OperatingSystemOps;
|
||||
readonly location: LocationOps;
|
||||
readonly fileSystem: FileSystemOps;
|
||||
readonly fileSystem: FileSystemOperations;
|
||||
readonly command: CommandOps;
|
||||
}
|
||||
|
||||
export interface OperatingSystemOps {
|
||||
getUserDataDirectory(): string;
|
||||
}
|
||||
|
||||
export interface LocationOps {
|
||||
combinePaths(...pathSegments: string[]): string;
|
||||
}
|
||||
|
||||
export interface CommandOps {
|
||||
exec(command: string): ReturnType<typeof exec>;
|
||||
}
|
||||
|
||||
export interface FileSystemOps {
|
||||
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
|
||||
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<void>;
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@ import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import {
|
||||
FileType, type SaveFileError, type SaveFileErrorType, type SaveFileOutcome,
|
||||
} from '@/presentation/common/Dialog';
|
||||
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { NodeReadbackFileWriter } from '@/infrastructure/ReadbackFileWriter/NodeReadbackFileWriter';
|
||||
import { FileReadbackVerificationErrors, type ReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/ReadbackFileWriter';
|
||||
import { NodeReadbackFileWriter } from '@/infrastructure/FileSystem/ReadbackFileWriter/NodeReadbackFileWriter';
|
||||
import type { ElectronSaveFileDialog } from './ElectronSaveFileDialog';
|
||||
|
||||
export class NodeElectronSaveFileDialog implements ElectronSaveFileDialog {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -1,32 +1,37 @@
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import type { CodeRunError, CodeRunErrorType } from '@/application/CodeRunner/CodeRunner';
|
||||
import { NodeElectronSystemOperations } from '../../System/NodeElectronSystemOperations';
|
||||
import type { SystemOperations } from '../../System/SystemOperations';
|
||||
import type { ScriptDirectoryOutcome, ScriptDirectoryProvider } from './ScriptDirectoryProvider';
|
||||
import { NodeElectronFileSystemOperations } from '@/infrastructure/FileSystem/NodeElectronFileSystemOperations';
|
||||
import type {
|
||||
DirectoryCreationOutcome, ApplicationDirectoryProvider, DirectoryType,
|
||||
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:
|
||||
* - Antivirus Exclusions: Easier antivirus configuration.
|
||||
* - Auditability: Stores script execution history for troubleshooting.
|
||||
* - Reliability: Avoids issues with directory clean-ups during execution,
|
||||
* seen in Windows Pro Azure VMs when stored on Windows temporary directory.
|
||||
*/
|
||||
export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
|
||||
export class PersistentApplicationDirectoryProvider implements ApplicationDirectoryProvider {
|
||||
constructor(
|
||||
private readonly system: SystemOperations = new NodeElectronSystemOperations(),
|
||||
private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
) { }
|
||||
|
||||
public async provideScriptDirectory(): Promise<ScriptDirectoryOutcome> {
|
||||
public async provideDirectory(type: DirectoryType): Promise<DirectoryCreationOutcome> {
|
||||
const {
|
||||
success: isPathConstructed,
|
||||
error: pathConstructionError,
|
||||
directoryPath,
|
||||
} = this.constructScriptDirectoryPath();
|
||||
} = this.constructScriptDirectoryPath(type);
|
||||
if (!isPathConstructed) {
|
||||
return {
|
||||
success: false,
|
||||
@@ -52,7 +57,7 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
|
||||
private async createDirectory(directoryPath: string): Promise<DirectoryPathCreationOutcome> {
|
||||
try {
|
||||
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}`);
|
||||
return {
|
||||
success: true,
|
||||
@@ -60,17 +65,26 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.handleException(error, 'DirectoryCreationError'),
|
||||
error: this.handleError(error, 'DirectoryWriteError'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private constructScriptDirectoryPath(): DirectoryPathConstructionOutcome {
|
||||
private constructScriptDirectoryPath(type: DirectoryType): DirectoryPathConstructionOutcome {
|
||||
let parentDirectory: string;
|
||||
try {
|
||||
const parentDirectory = this.system.operatingSystem.getUserDataDirectory();
|
||||
const scriptDirectory = this.system.location.combinePaths(
|
||||
parentDirectory = this.fileSystem.getUserDataDirectory();
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.handleError(error, 'UserDataFolderRetrievalError'),
|
||||
};
|
||||
}
|
||||
try {
|
||||
const subdirectoryName = SubdirectoryNames[type];
|
||||
const scriptDirectory = this.fileSystem.combinePaths(
|
||||
parentDirectory,
|
||||
ExecutionSubdirectory,
|
||||
subdirectoryName,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
@@ -79,15 +93,15 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: this.handleException(error, 'DirectoryCreationError'),
|
||||
error: this.handleError(error, 'PathConstructionError'),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private handleException(
|
||||
private handleError(
|
||||
exception: Error,
|
||||
errorType: CodeRunErrorType,
|
||||
): CodeRunError {
|
||||
errorType: DirectoryCreationErrorType,
|
||||
): DirectoryCreationError {
|
||||
const errorMessage = 'Error during script directory creation';
|
||||
this.logger.error(errorType, errorMessage, exception);
|
||||
return {
|
||||
@@ -99,7 +113,7 @@ export class PersistentDirectoryProvider implements ScriptDirectoryProvider {
|
||||
|
||||
type DirectoryPathConstructionOutcome = {
|
||||
readonly success: false;
|
||||
readonly error: CodeRunError;
|
||||
readonly error: DirectoryCreationError;
|
||||
readonly directoryPath?: undefined;
|
||||
} | {
|
||||
readonly success: true;
|
||||
@@ -109,7 +123,7 @@ type DirectoryPathConstructionOutcome = {
|
||||
|
||||
type DirectoryPathCreationOutcome = {
|
||||
readonly success: false;
|
||||
readonly error: CodeRunError;
|
||||
readonly error: DirectoryCreationError;
|
||||
} | {
|
||||
readonly success: true;
|
||||
readonly error?: undefined;
|
||||
20
src/infrastructure/FileSystem/FileSystemOperations.ts
Normal file
20
src/infrastructure/FileSystem/FileSystemOperations.ts
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 { ElectronLogger } from '../Log/ElectronLogger';
|
||||
import { ElectronLogger } from '../../Log/ElectronLogger';
|
||||
import { NodeElectronFileSystemOperations } from '../NodeElectronFileSystemOperations';
|
||||
import type {
|
||||
FailedFileWrite, ReadbackFileWriter, FileWriteErrorType,
|
||||
FileWriteOutcome, SuccessfulFileWrite,
|
||||
} from './ReadbackFileWriter';
|
||||
import type { FileSystemOperations } from '../FileSystemOperations';
|
||||
|
||||
const FILE_ENCODING: NodeJS.BufferEncoding = 'utf-8';
|
||||
|
||||
export class NodeReadbackFileWriter implements ReadbackFileWriter {
|
||||
constructor(
|
||||
private readonly logger: Logger = ElectronLogger,
|
||||
private readonly fileSystem: FileReadWriteOperations = {
|
||||
writeFile,
|
||||
readFile: (path, encoding) => readFile(path, encoding),
|
||||
access,
|
||||
},
|
||||
private readonly fileSystem: FileSystemOperations = NodeElectronFileSystemOperations,
|
||||
) { }
|
||||
|
||||
public async writeAndVerifyFile(
|
||||
@@ -55,7 +51,9 @@ export class NodeReadbackFileWriter implements ReadbackFileWriter {
|
||||
filePath: string,
|
||||
): Promise<FileWriteOutcome> {
|
||||
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.');
|
||||
} catch (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>;
|
||||
}
|
||||
@@ -1,16 +1,16 @@
|
||||
import type { ISanityValidator } from './ISanityValidator';
|
||||
import type { ISanityCheckOptions } from './ISanityCheckOptions';
|
||||
import type { SanityValidator } from './SanityValidator';
|
||||
import type { SanityCheckOptions } from './SanityCheckOptions';
|
||||
|
||||
export type FactoryFunction<T> = () => T;
|
||||
|
||||
export abstract class FactoryValidator<T> implements ISanityValidator {
|
||||
export abstract class FactoryValidator<T> implements SanityValidator {
|
||||
private readonly factory: FactoryFunction<T>;
|
||||
|
||||
protected constructor(factory: FactoryFunction<T>) {
|
||||
this.factory = factory;
|
||||
}
|
||||
|
||||
public abstract shouldValidate(options: ISanityCheckOptions): boolean;
|
||||
public abstract shouldValidate(options: SanityCheckOptions): boolean;
|
||||
|
||||
public abstract name: string;
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
import type { ISanityCheckOptions } from './ISanityCheckOptions';
|
||||
|
||||
export interface ISanityValidator {
|
||||
readonly name: string;
|
||||
shouldValidate(options: ISanityCheckOptions): boolean;
|
||||
collectErrors(): Iterable<string>;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
export interface ISanityCheckOptions {
|
||||
export interface SanityCheckOptions {
|
||||
readonly validateEnvironmentVariables: boolean;
|
||||
readonly validateWindowVariables: boolean;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { SanityCheckOptions } from './SanityCheckOptions';
|
||||
|
||||
export interface SanityValidator {
|
||||
readonly name: string;
|
||||
shouldValidate(options: SanityCheckOptions): boolean;
|
||||
collectErrors(): Iterable<string>;
|
||||
}
|
||||
@@ -1,16 +1,23 @@
|
||||
import { EnvironmentVariablesValidator } from './Validators/EnvironmentVariablesValidator';
|
||||
import type { ISanityCheckOptions } from './Common/ISanityCheckOptions';
|
||||
import type { ISanityValidator } from './Common/ISanityValidator';
|
||||
import type { SanityCheckOptions } from './Common/SanityCheckOptions';
|
||||
import type { SanityValidator } from './Common/SanityValidator';
|
||||
|
||||
const DefaultSanityValidators: ISanityValidator[] = [
|
||||
const DefaultSanityValidators: SanityValidator[] = [
|
||||
new EnvironmentVariablesValidator(),
|
||||
];
|
||||
|
||||
export interface RuntimeSanityValidator {
|
||||
(
|
||||
options: SanityCheckOptions,
|
||||
validators?: readonly SanityValidator[],
|
||||
): void;
|
||||
}
|
||||
|
||||
/* Helps to fail-fast on errors */
|
||||
export function validateRuntimeSanity(
|
||||
options: ISanityCheckOptions,
|
||||
validators: readonly ISanityValidator[] = DefaultSanityValidators,
|
||||
): void {
|
||||
export const validateRuntimeSanity: RuntimeSanityValidator = (
|
||||
options: SanityCheckOptions,
|
||||
validators: readonly SanityValidator[] = DefaultSanityValidators,
|
||||
) => {
|
||||
if (!validators.length) {
|
||||
throw new Error('missing validators');
|
||||
}
|
||||
@@ -26,9 +33,9 @@ export function validateRuntimeSanity(
|
||||
if (errorMessages.length > 0) {
|
||||
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()];
|
||||
if (!errorMessages.length) {
|
||||
return undefined;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
|
||||
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
|
||||
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> {
|
||||
constructor(
|
||||
@@ -14,7 +14,7 @@ export class EnvironmentVariablesValidator extends FactoryValidator<IEnvironment
|
||||
|
||||
public override name = 'environment variables';
|
||||
|
||||
public override shouldValidate(options: ISanityCheckOptions): boolean {
|
||||
public override shouldValidate(options: SanityCheckOptions): boolean {
|
||||
return options.validateEnvironmentVariables;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
|
||||
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> {
|
||||
constructor(factory: FactoryFunction<WindowVariables> = () => window) {
|
||||
@@ -9,7 +9,7 @@ export class WindowVariablesValidator extends FactoryValidator<WindowVariables>
|
||||
|
||||
public override name = 'window variables';
|
||||
|
||||
public override shouldValidate(options: ISanityCheckOptions): boolean {
|
||||
public override shouldValidate(options: SanityCheckOptions): boolean {
|
||||
return options.validateWindowVariables;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
import type { ScriptDiagnosticData, ScriptDiagnosticsCollector } from '@/application/ScriptDiagnostics/ScriptDiagnosticsCollector';
|
||||
import type { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
|
||||
import { CurrentEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironmentFactory';
|
||||
import { PersistentDirectoryProvider } from '@/infrastructure/CodeRunner/Creation/Directory/PersistentDirectoryProvider';
|
||||
import type { ScriptDirectoryProvider } from '../CodeRunner/Creation/Directory/ScriptDirectoryProvider';
|
||||
import type { ApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/ApplicationDirectoryProvider';
|
||||
import { PersistentApplicationDirectoryProvider } from '@/infrastructure/FileSystem/Directory/PersistentApplicationDirectoryProvider';
|
||||
|
||||
export class ScriptEnvironmentDiagnosticsCollector implements ScriptDiagnosticsCollector {
|
||||
constructor(
|
||||
private readonly directoryProvider: ScriptDirectoryProvider = new PersistentDirectoryProvider(),
|
||||
private readonly directoryProvider: ApplicationDirectoryProvider
|
||||
= new PersistentApplicationDirectoryProvider(),
|
||||
private readonly environment: RuntimeEnvironment = CurrentEnvironment,
|
||||
) { }
|
||||
|
||||
public async collectDiagnosticInformation(): Promise<ScriptDiagnosticData> {
|
||||
const { directoryAbsolutePath } = await this.directoryProvider.provideScriptDirectory();
|
||||
const {
|
||||
directoryAbsolutePath: scriptsDirectory,
|
||||
} = await this.directoryProvider.provideDirectory('script-runs');
|
||||
return {
|
||||
scriptsDirectoryAbsolutePath: directoryAbsolutePath,
|
||||
scriptsDirectoryAbsolutePath: scriptsDirectory,
|
||||
currentOperatingSystem: this.environment.os,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,19 +6,20 @@
|
||||
- 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`.
|
||||
*/
|
||||
@use 'sass:color';
|
||||
|
||||
// --- Primary | The color displayed most frequently across screens and components
|
||||
$color-primary : #3a65ab;
|
||||
$color-primary-light : lighten($color-primary, 30%);
|
||||
$color-primary-dark : darken($color-primary, 18%);
|
||||
$color-primary-darker : darken($color-primary, 32%);
|
||||
$color-primary-darkest : darken($color-primary, 44%);
|
||||
$color-primary-light : color.adjust($color-primary, $lightness: 30%);
|
||||
$color-primary-dark : color.adjust($color-primary, $lightness: -18%);
|
||||
$color-primary-darker : color.adjust($color-primary, $lightness: -32%);
|
||||
$color-primary-darkest : color.adjust($color-primary, $lightness: -44%);
|
||||
// Text/iconography color that is usable on top of primary color
|
||||
$color-on-primary : #e4f1fe;
|
||||
|
||||
// --- Secondary | Accent color, should be applied sparingly to accent select parts of UI
|
||||
$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
|
||||
$color-on-secondary : #005051;
|
||||
|
||||
|
||||
@@ -53,3 +53,17 @@ sup {
|
||||
vertical-align: super;
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator';
|
||||
import { RuntimeSanityBootstrapper } from './Modules/RuntimeSanityBootstrapper';
|
||||
import { AppInitializationLogger } from './Modules/AppInitializationLogger';
|
||||
import { DependencyBootstrapper } from './Modules/DependencyBootstrapper';
|
||||
import { MobileSafariActivePseudoClassEnabler } from './Modules/MobileSafariActivePseudoClassEnabler';
|
||||
@@ -17,7 +17,7 @@ export class ApplicationBootstrapper implements Bootstrapper {
|
||||
|
||||
private static getAllBootstrappers(): Bootstrapper[] {
|
||||
return [
|
||||
new RuntimeSanityValidator(),
|
||||
new RuntimeSanityBootstrapper(),
|
||||
new DependencyBootstrapper(),
|
||||
new AppInitializationLogger(),
|
||||
new MobileSafariActivePseudoClassEnabler(),
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
91
src/presentation/components/Code/Ace/AceCodeEditorFactory.ts
Normal file
91
src/presentation/components/Code/Ace/AceCodeEditorFactory.ts
Normal 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
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import ace from 'ace-builds';
|
||||
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/mode-batchfile';
|
||||
import 'ace-builds/src-noconflict/mode-sh';
|
||||
@@ -6,7 +6,7 @@
|
||||
<article>
|
||||
<h3>1. The Easy Alternative</h3>
|
||||
<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
|
||||
{{ osName }} system you wish to configure, and then click on the Run button. This is
|
||||
recommended for most users.
|
||||
@@ -47,6 +47,8 @@ export default defineComponent({
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
ensureBrowserEnvironment();
|
||||
|
||||
const { currentState } = injectKey((keys) => keys.useCollectionState);
|
||||
|
||||
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>
|
||||
|
||||
<style scoped lang="scss">
|
||||
@@ -16,29 +16,29 @@
|
||||
Open terminal.
|
||||
<InfoTooltipInline>
|
||||
<p>
|
||||
Opening terminal changes based on the distro you run.
|
||||
Opening terminal changes based on the distribution you run.
|
||||
</p>
|
||||
<p>
|
||||
You may search for "Terminal" in your application launcher to find it.
|
||||
</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:
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<code>Ctrl-Alt-T</code>:
|
||||
<kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>T</kbd>:
|
||||
Ubuntu, CentOS, Linux Mint, Elementary OS, ubermix, Kali…
|
||||
</li>
|
||||
<li>
|
||||
<code>Super-T</code>: Pop!_OS…
|
||||
<kbd>Super</kbd> + <kbd>T</kbd>: Pop!_OS…
|
||||
</li>
|
||||
<li>
|
||||
<code>Alt-T</code>: Parrot OS…
|
||||
<kbd>Alt</kbd> + <kbd>T</kbd>: Parrot OS…
|
||||
</li>
|
||||
<li>
|
||||
<code>Ctrl-Alt-Insert</code>: Bodhi Linux…
|
||||
<kbd>Ctrl</kbd> + <kbd>Alt</kbd> + <kbd>Insert</kbd>: Bodhi Linux…
|
||||
</li>
|
||||
</ul>
|
||||
</p>
|
||||
</InfoTooltipInline>
|
||||
</InstructionStep>
|
||||
<InstructionStep>
|
||||
@@ -50,19 +50,22 @@
|
||||
<CopyableCommand>cd ~/Downloads</CopyableCommand>
|
||||
<template #info>
|
||||
<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>
|
||||
If the file is not downloaded on Downloads folder,
|
||||
change <code>Downloads</code> to path where the file is downloaded.
|
||||
If you didn't save the file in your <strong>Downloads</strong> folder:
|
||||
</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>
|
||||
This command means:
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>cd</code> will change the current folder.</li>
|
||||
<li><code>~</code> is the user home directory.</li>
|
||||
</ul>
|
||||
</p>
|
||||
</template>
|
||||
</InfoTooltipWrapper>
|
||||
</p>
|
||||
@@ -76,21 +79,23 @@
|
||||
<CopyableCommand>chmod +x {{ filename }}</CopyableCommand>
|
||||
<template #info>
|
||||
<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>
|
||||
It will make the file executable.
|
||||
</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
|
||||
without the command line:
|
||||
</p>
|
||||
<ol>
|
||||
<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>
|
||||
<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>
|
||||
</template>
|
||||
</InfoTooltipWrapper>
|
||||
@@ -110,7 +115,8 @@
|
||||
</p>
|
||||
<ol>
|
||||
<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>
|
||||
</template>
|
||||
</InfoTooltipWrapper>
|
||||
@@ -124,10 +130,10 @@
|
||||
registered, so keep typing.
|
||||
</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>
|
||||
Administrator privileges are required to configure OS.
|
||||
Administrator privileges are required for configurations.
|
||||
</p>
|
||||
</InfoTooltipInline>
|
||||
</InstructionStep>
|
||||
@@ -13,9 +13,28 @@
|
||||
</InfoTooltipInline>
|
||||
</InstructionStep>
|
||||
<InstructionStep>
|
||||
Open terminal.
|
||||
Open your terminal.
|
||||
<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>
|
||||
</InstructionStep>
|
||||
<InstructionStep>
|
||||
@@ -27,19 +46,22 @@
|
||||
<CopyableCommand>cd ~/Downloads</CopyableCommand>
|
||||
<template #info>
|
||||
<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>
|
||||
If the file is not downloaded on Downloads folder,
|
||||
change <code>Downloads</code> to path where the file is downloaded.
|
||||
If you didn't save the file in your <strong>Downloads</strong> folder:
|
||||
</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>
|
||||
This command means:
|
||||
</p>
|
||||
<ul>
|
||||
<li><code>cd</code> will change the current folder.</li>
|
||||
<li><code>~</code> is the user home directory.</li>
|
||||
</ul>
|
||||
</p>
|
||||
</template>
|
||||
</InfoTooltipWrapper>
|
||||
</p>
|
||||
@@ -53,7 +75,7 @@
|
||||
<CopyableCommand>chmod +x {{ filename }}</CopyableCommand>
|
||||
<template #info>
|
||||
<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>
|
||||
It will make the file executable.
|
||||
@@ -83,10 +105,10 @@
|
||||
still registered, so keep typing.
|
||||
</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>
|
||||
Administrator privileges are required to configure OS.
|
||||
Administrator privileges are required for configurations.
|
||||
</p>
|
||||
</InfoTooltipInline>
|
||||
</InstructionStep>
|
||||
@@ -36,7 +36,7 @@
|
||||
</InfoTooltipInline>
|
||||
</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>
|
||||
<!--
|
||||
Tests (15/01/2023):
|
||||
@@ -52,7 +52,7 @@
|
||||
Don't worry; privacy.sexy is secure, transparent, and open-source.
|
||||
</p>
|
||||
<p>
|
||||
To handle false warnings in Microsoft Defender:
|
||||
To handle false warnings in <strong>Defender</strong>:
|
||||
</p>
|
||||
<ol>
|
||||
<li>
|
||||
@@ -103,21 +103,22 @@
|
||||
</InfoTooltipInline>
|
||||
</InstructionStep>
|
||||
<InstructionStep>
|
||||
If prompted, confirm SmartScreen warnings.
|
||||
If prompted, confirm <strong>SmartScreen</strong> warnings.
|
||||
<InfoTooltipInline>
|
||||
<p>
|
||||
Windows SmartScreen might display a cautionary message.
|
||||
<strong>Defender SmartScreen</strong> may display a cautionary message.
|
||||
</p>
|
||||
<p>
|
||||
This happens since privacy.sexy scripts are not recognized
|
||||
by Microsoft's certification process.
|
||||
</p>
|
||||
<p>
|
||||
If you see the warning, bypass it by following these steps:
|
||||
</p>
|
||||
<ol>
|
||||
<li>Select <strong>More info</strong>.</li>
|
||||
<li>Select <strong>Run anyway</strong>.</li>
|
||||
</ol>
|
||||
</p>
|
||||
</InfoTooltipInline>
|
||||
</InstructionStep>
|
||||
<InstructionStep>
|
||||
@@ -6,7 +6,7 @@
|
||||
@click="saveCode"
|
||||
/>
|
||||
<ModalDialog v-model="areInstructionsVisible">
|
||||
<RunInstructions :filename="filename" />
|
||||
<BrowserRunInstructions :filename="filename" />
|
||||
</ModalDialog>
|
||||
</div>
|
||||
</template>
|
||||
@@ -23,12 +23,12 @@ import { ScriptFilename } from '@/application/CodeRunner/ScriptFilename';
|
||||
import { FileType } from '@/presentation/common/Dialog';
|
||||
import IconButton from '../IconButton.vue';
|
||||
import { createScriptErrorDialog } from '../ScriptErrorDialog';
|
||||
import RunInstructions from './RunInstructions/RunInstructions.vue';
|
||||
import BrowserRunInstructions from './BrowserRunInstructions/BrowserRunInstructions.vue';
|
||||
|
||||
export default defineComponent({
|
||||
components: {
|
||||
IconButton,
|
||||
RunInstructions,
|
||||
BrowserRunInstructions,
|
||||
ModalDialog,
|
||||
},
|
||||
setup() {
|
||||
@@ -55,8 +55,13 @@ export default defineComponent({
|
||||
}, scriptDiagnosticsCollector)));
|
||||
return;
|
||||
}
|
||||
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 {
|
||||
isRunningAsDesktopApplication,
|
||||
|
||||
30
src/presentation/components/Code/CodeEditorFactory.ts
Normal file
30
src/presentation/components/Code/CodeEditorFactory.ts
Normal 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;
|
||||
}
|
||||
@@ -25,7 +25,8 @@ import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/
|
||||
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
|
||||
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
|
||||
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({
|
||||
components: {
|
||||
@@ -34,13 +35,7 @@ export default defineComponent({
|
||||
directives: {
|
||||
NonCollapsing,
|
||||
},
|
||||
props: {
|
||||
theme: {
|
||||
type: String,
|
||||
default: undefined,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
setup() {
|
||||
const { onStateChange, currentState } = injectKey((keys) => keys.useCollectionState);
|
||||
const { projectDetails } = injectKey((keys) => keys.useApplication);
|
||||
const { events } = injectKey((keys) => keys.useAutoUnsubscribedEvents);
|
||||
@@ -48,8 +43,8 @@ export default defineComponent({
|
||||
const editorId = 'codeEditor';
|
||||
const highlightedRange = ref(0);
|
||||
|
||||
let editor: ace.Ace.Editor | undefined;
|
||||
let currentMarkerId: number | undefined;
|
||||
let editor: CodeEditor | undefined;
|
||||
let currentMarker: CodeEditorStyleHandle | undefined;
|
||||
|
||||
onUnmounted(() => {
|
||||
destroyEditor();
|
||||
@@ -63,11 +58,10 @@ export default defineComponent({
|
||||
|
||||
function handleNewState(newState: IReadOnlyCategoryCollectionState) {
|
||||
destroyEditor();
|
||||
editor = initializeEditor(
|
||||
props.theme,
|
||||
editorId,
|
||||
newState.collection.scripting.language,
|
||||
);
|
||||
editor = initializeAceEditor({
|
||||
editorContainerElementId: editorId,
|
||||
language: getLanguage(newState.collection.scripting.language),
|
||||
});
|
||||
const appCode = newState.code;
|
||||
updateCode(appCode.current, newState.collection.scripting.language);
|
||||
events.unsubscribeAllAndRegister([
|
||||
@@ -77,7 +71,7 @@ export default defineComponent({
|
||||
|
||||
function updateCode(code: string, language: ScriptingLanguage) {
|
||||
const innerCode = code || getDefaultCode(language, projectDetails);
|
||||
editor?.setValue(innerCode, 1);
|
||||
editor?.setContent(innerCode);
|
||||
}
|
||||
|
||||
function handleCodeChange(event: ICodeChangedEvent) {
|
||||
@@ -91,7 +85,7 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
function sizeChanged() {
|
||||
editor?.resize();
|
||||
editor?.updateSize();
|
||||
}
|
||||
|
||||
function destroyEditor() {
|
||||
@@ -100,11 +94,11 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
function removeCurrentHighlighting() {
|
||||
if (!currentMarkerId) {
|
||||
if (!currentMarker) {
|
||||
return;
|
||||
}
|
||||
editor?.session.removeMarker(currentMarkerId);
|
||||
currentMarkerId = undefined;
|
||||
currentMarker?.clearStyle();
|
||||
currentMarker = undefined;
|
||||
highlightedRange.value = 0;
|
||||
}
|
||||
|
||||
@@ -117,28 +111,15 @@ export default defineComponent({
|
||||
const end = Math.max(
|
||||
...positions.map((position) => position.endLine),
|
||||
);
|
||||
scrollToLine(end + 2);
|
||||
editor?.scrollToLine(end + 2);
|
||||
highlight(start, end);
|
||||
}
|
||||
|
||||
function highlight(startRow: number, endRow: number) {
|
||||
const AceRange = ace.require('ace/range').Range;
|
||||
currentMarkerId = editor?.session.addMarker(
|
||||
new AceRange(startRow, 0, endRow, 0),
|
||||
'code-area__highlight',
|
||||
'fullLine',
|
||||
);
|
||||
currentMarker = editor?.applyStyleToLineRange(startRow, endRow, 'code-area__highlight');
|
||||
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 {
|
||||
editorId,
|
||||
highlightedRange,
|
||||
@@ -147,29 +128,12 @@ export default defineComponent({
|
||||
},
|
||||
});
|
||||
|
||||
function initializeEditor(
|
||||
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) {
|
||||
function getLanguage(language: ScriptingLanguage): SupportedSyntaxLanguage {
|
||||
switch (language) {
|
||||
case ScriptingLanguage.batchfile: return 'batchfile';
|
||||
case ScriptingLanguage.shellscript: return 'sh';
|
||||
case ScriptingLanguage.shellscript: return 'shellscript';
|
||||
default:
|
||||
throw new Error('unknown language');
|
||||
throw new Error(`Unsupported language: ${language}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<TheScriptsView :current-view="currentView" />
|
||||
</template>
|
||||
<template #second>
|
||||
<TheCodeArea theme="xcode" />
|
||||
<TheCodeArea />
|
||||
</template>
|
||||
</HorizontalResizeSlider>
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
:tree-root="treeRoot"
|
||||
:rendering-strategy="renderingStrategy"
|
||||
>
|
||||
<template #node-content="slotProps">
|
||||
<template #node-content="slotProps: NodeMetadata">
|
||||
<slot name="node-content" v-bind="slotProps" />
|
||||
</template>
|
||||
</HierarchicalTreeNode>
|
||||
@@ -55,6 +55,7 @@ import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
|
||||
import { useNodeState } from './UseNodeState';
|
||||
import LeafTreeNode from './LeafTreeNode.vue';
|
||||
import InteractableNode from './InteractableNode.vue';
|
||||
import type { NodeMetadata } from '../../NodeContent/NodeMetadata';
|
||||
import type { TreeRoot } from '../TreeRoot/TreeRoot';
|
||||
import type { TreeNode, TreeNodeId } from './TreeNode';
|
||||
import type { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStrategy';
|
||||
@@ -107,6 +108,7 @@ export default defineComponent({
|
||||
);
|
||||
|
||||
return {
|
||||
NodeMetadata: Object as PropType<NodeMetadata>,
|
||||
renderedNodeIds,
|
||||
isExpanded,
|
||||
toggleExpand,
|
||||
|
||||
@@ -11,11 +11,17 @@ export class TreeRootManager implements TreeRoot {
|
||||
|
||||
constructor(
|
||||
collection: TreeNodeCollection = new TreeNodeInitializerAndUpdater(),
|
||||
createFocusManager: (
|
||||
collection: TreeNodeCollection
|
||||
) => SingleNodeFocusManager = (nodes) => new SingleNodeCollectionFocusManager(nodes),
|
||||
createFocusManager: FocusManagerFactory = (
|
||||
nodes,
|
||||
) => new SingleNodeCollectionFocusManager(nodes),
|
||||
) {
|
||||
this.collection = collection;
|
||||
this.focus = createFocusManager(this.collection);
|
||||
}
|
||||
}
|
||||
|
||||
export interface FocusManagerFactory {
|
||||
(
|
||||
collection: TreeNodeCollection
|
||||
): SingleNodeFocusManager;
|
||||
}
|
||||
|
||||
@@ -120,17 +120,15 @@ function getArrowPositionStyles(
|
||||
coordinations: Partial<Coords>,
|
||||
placement: Placement,
|
||||
): CSSProperties {
|
||||
const style: CSSProperties = {};
|
||||
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 { x, y } = coordinations; // either X or Y is calculated
|
||||
const oppositeSide = getCounterpartBoxOffsetProperty(placement);
|
||||
style[oppositeSide.toString()] = `-${ARROW_SIZE_IN_PX}px`;
|
||||
return style;
|
||||
const newStyle: CSSProperties = {
|
||||
[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 {
|
||||
|
||||
@@ -22,7 +22,8 @@ export function registerAllIpcChannels(
|
||||
};
|
||||
Object.entries(ipcInstanceCreators).forEach(([name, instanceFactory]) => {
|
||||
try {
|
||||
const definition = IpcChannelDefinitions[name];
|
||||
const definitionKey = name as keyof typeof IpcChannelDefinitions;
|
||||
const definition = IpcChannelDefinitions[definitionKey] as IpcChannel<unknown>;
|
||||
const instance = instanceFactory();
|
||||
registrar(definition, instance);
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,67 +1,106 @@
|
||||
import { app, dialog } from 'electron/main';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { UpdateProgressBar } from './UpdateProgressBar';
|
||||
import { UpdateProgressBar } from './ProgressBar/UpdateProgressBar';
|
||||
import { getAutoUpdater } from './ElectronAutoUpdaterFactory';
|
||||
import type { AppUpdater, UpdateInfo } from 'electron-updater';
|
||||
import type { ProgressInfo } from 'electron-builder';
|
||||
|
||||
export async function handleAutoUpdate() {
|
||||
const autoUpdater = getAutoUpdater();
|
||||
if (await askDownloadAndInstall() === DownloadDialogResult.NotNow) {
|
||||
if (await askDownloadAndInstall() === UpdateDialogResult.Postpone) {
|
||||
ElectronLogger.info('User chose to postpone update');
|
||||
return;
|
||||
}
|
||||
startHandlingUpdateProgress(autoUpdater);
|
||||
await autoUpdater.downloadUpdate();
|
||||
ElectronLogger.info('User chose to download and install update');
|
||||
try {
|
||||
await startHandlingUpdateProgress(autoUpdater);
|
||||
} catch (error) {
|
||||
ElectronLogger.error('Failed to handle auto-update process', { error });
|
||||
}
|
||||
}
|
||||
|
||||
function startHandlingUpdateProgress(autoUpdater: AppUpdater) {
|
||||
function startHandlingUpdateProgress(autoUpdater: AppUpdater): Promise<void> {
|
||||
return new Promise((resolve, reject) => { // Block until update process completes
|
||||
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('@download-progress@\n', progress);
|
||||
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@\n', info);
|
||||
progressBar.close();
|
||||
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 handleUpdateDownloaded(autoUpdater: AppUpdater) {
|
||||
if (await askRestartAndInstall() === InstallDialogResult.NotNow) {
|
||||
return;
|
||||
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);
|
||||
}
|
||||
setTimeout(() => autoUpdater.quitAndInstall(), 1);
|
||||
}, 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 DownloadDialogResult {
|
||||
Install = 0,
|
||||
NotNow = 1,
|
||||
enum UpdateDialogResult {
|
||||
Update = 0,
|
||||
Postpone = 1,
|
||||
}
|
||||
async function askDownloadAndInstall(): Promise<DownloadDialogResult> {
|
||||
async function askDownloadAndInstall(): Promise<UpdateDialogResult> {
|
||||
const updateDialogResult = await dialog.showMessageBox({
|
||||
type: 'question',
|
||||
buttons: ['Install', 'Not now'],
|
||||
title: 'Confirm Update',
|
||||
message: 'Update available.\n\nWould you like to download and install new version?',
|
||||
detail: 'Application will automatically restart to apply update after download',
|
||||
defaultId: DownloadDialogResult.Install,
|
||||
cancelId: DownloadDialogResult.NotNow,
|
||||
defaultId: UpdateDialogResult.Update,
|
||||
cancelId: UpdateDialogResult.Postpone,
|
||||
});
|
||||
return updateDialogResult.response;
|
||||
}
|
||||
|
||||
enum InstallDialogResult {
|
||||
InstallAndRestart = 0,
|
||||
NotNow = 1,
|
||||
Postpone = 1,
|
||||
}
|
||||
async function askRestartAndInstall(): Promise<InstallDialogResult> {
|
||||
const installDialogResult = await dialog.showMessageBox({
|
||||
@@ -70,7 +109,7 @@ async function askRestartAndInstall(): Promise<InstallDialogResult> {
|
||||
message: `A new version of ${app.name} has been downloaded.`,
|
||||
detail: 'It will be installed the next time you restart the application.',
|
||||
defaultId: InstallDialogResult.InstallAndRestart,
|
||||
cancelId: InstallDialogResult.NotNow,
|
||||
cancelId: InstallDialogResult.Postpone,
|
||||
});
|
||||
return installDialogResult.response;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import { existsSync, createWriteStream, type WriteStream } from 'node:fs';
|
||||
import { unlink, mkdir } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { app } from 'electron/main';
|
||||
import { createWriteStream, type WriteStream } from 'node:fs';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { UpdateProgressBar } from '../UpdateProgressBar';
|
||||
import { retryFileSystemAccess } from './RetryFileSystemAccess';
|
||||
import type { Logger } from '@/application/Common/Log/Logger';
|
||||
import { UpdateProgressBar } from '../ProgressBar/UpdateProgressBar';
|
||||
import { provideUpdateInstallationFilepath, type InstallationFilepathProvider } from './InstallationFiles/InstallationFilepathProvider';
|
||||
import type { UpdateInfo } from 'electron-updater';
|
||||
import type { ReadableStream } from 'node:stream/web';
|
||||
|
||||
@@ -18,18 +16,25 @@ export type DownloadUpdateResult = {
|
||||
readonly installerPath: string;
|
||||
};
|
||||
|
||||
interface UpdateDownloadUtilities {
|
||||
readonly logger: Logger;
|
||||
readonly provideInstallationFilePath: InstallationFilepathProvider;
|
||||
}
|
||||
|
||||
export async function downloadUpdate(
|
||||
info: UpdateInfo,
|
||||
remoteFileUrl: string,
|
||||
progressBar: UpdateProgressBar,
|
||||
utilities: UpdateDownloadUtilities = DefaultUtilities,
|
||||
): Promise<DownloadUpdateResult> {
|
||||
ElectronLogger.info('Starting manual update download.');
|
||||
utilities.logger.info('Starting manual update download.');
|
||||
progressBar.showIndeterminateState();
|
||||
try {
|
||||
const { filePath } = await downloadInstallerFile(
|
||||
info.version,
|
||||
remoteFileUrl,
|
||||
(percentage) => { progressBar.showPercentage(percentage); },
|
||||
utilities,
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
@@ -47,58 +52,40 @@ async function downloadInstallerFile(
|
||||
version: string,
|
||||
remoteFileUrl: string,
|
||||
progressHandler: ProgressCallback,
|
||||
utilities: UpdateDownloadUtilities,
|
||||
): Promise<{ readonly filePath: string; }> {
|
||||
const filePath = `${path.dirname(app.getPath('temp'))}/privacy.sexy/${version}-installer.dmg`;
|
||||
if (!await ensureFilePathReady(filePath)) {
|
||||
throw new Error(`Failed to prepare the file path for the installer: ${filePath}`);
|
||||
}
|
||||
const filePath = await utilities.provideInstallationFilePath(version);
|
||||
await downloadFileWithProgress(
|
||||
remoteFileUrl,
|
||||
filePath,
|
||||
progressHandler,
|
||||
utilities,
|
||||
);
|
||||
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;
|
||||
|
||||
async function downloadFileWithProgress(
|
||||
url: string,
|
||||
filePath: string,
|
||||
progressHandler: ProgressCallback,
|
||||
utilities: UpdateDownloadUtilities,
|
||||
) {
|
||||
// autoUpdater cannot handle DMG files, requiring manual download management for these file types.
|
||||
ElectronLogger.info(`Retrieving update from ${url}.`);
|
||||
utilities.logger.info(`Retrieving update from ${url}.`);
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
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) => {
|
||||
ElectronLogger.info(contentLength.isValid
|
||||
? `Saving file to ${filePath} (Size: ${contentLength.totalLength} bytes).`
|
||||
: `Saving file to ${filePath}.`);
|
||||
utilities.logger.info(contentLength.isValid
|
||||
? `Saving file to '${filePath}' (Size: ${contentLength.totalLength} bytes).`
|
||||
: `Saving file to '${filePath}'.`);
|
||||
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;
|
||||
};
|
||||
|
||||
function getContentLengthFromResponse(response: Response): ResponseContentLength {
|
||||
function getContentLengthFromResponse(
|
||||
response: Response,
|
||||
utilities: UpdateDownloadUtilities,
|
||||
): ResponseContentLength {
|
||||
const contentLengthString = response.headers.get('content-length');
|
||||
const headersInfo = Array.from(response.headers.entries());
|
||||
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 };
|
||||
}
|
||||
const contentLength = Number(contentLengthString);
|
||||
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 { totalLength: contentLength, isValid: true };
|
||||
@@ -153,6 +143,7 @@ async function streamWithProgress(
|
||||
readStream: ReadableStream,
|
||||
writeStream: WriteStream,
|
||||
progressHandler: ProgressCallback,
|
||||
utilities: UpdateDownloadUtilities,
|
||||
): Promise<void> {
|
||||
let receivedLength = 0;
|
||||
let logThreshold = 0;
|
||||
@@ -163,22 +154,23 @@ async function streamWithProgress(
|
||||
writeStream.write(Buffer.from(chunk));
|
||||
receivedLength += chunk.length;
|
||||
notifyProgress(contentLength, receivedLength, progressHandler);
|
||||
const progressLog = logProgress(receivedLength, contentLength, logThreshold);
|
||||
const progressLog = logProgress(receivedLength, contentLength, logThreshold, utilities);
|
||||
logThreshold = progressLog.nextLogThreshold;
|
||||
}
|
||||
ElectronLogger.info('Update download completed successfully.');
|
||||
utilities.logger.info('Update download completed successfully.');
|
||||
}
|
||||
|
||||
function logProgress(
|
||||
receivedLength: number,
|
||||
contentLength: ResponseContentLength,
|
||||
logThreshold: number,
|
||||
utilities: UpdateDownloadUtilities,
|
||||
): { readonly nextLogThreshold: number; } {
|
||||
const {
|
||||
shouldLog, nextLogThreshold,
|
||||
} = shouldLogProgress(receivedLength, contentLength, logThreshold);
|
||||
if (shouldLog) {
|
||||
ElectronLogger.debug(`Download progress: ${receivedLength} bytes received.`);
|
||||
utilities.logger.debug(`Download progress: ${receivedLength} bytes received.`);
|
||||
}
|
||||
return { nextLogThreshold };
|
||||
}
|
||||
@@ -220,3 +212,8 @@ function createReader(response: Response): ReadableStream {
|
||||
// https://github.com/DefinitelyTyped/DefinitelyTyped/discussions/65542#discussioncomment-6071004
|
||||
return response.body as ReadableStream;
|
||||
}
|
||||
|
||||
const DefaultUtilities: UpdateDownloadUtilities = {
|
||||
logger: ElectronLogger,
|
||||
provideInstallationFilePath: provideUpdateInstallationFilepath,
|
||||
};
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
|
||||
|
||||
export function retryFileSystemAccess(
|
||||
export interface FileSystemAccessorWithRetry {
|
||||
(
|
||||
fileOperation: () => Promise<boolean>,
|
||||
): Promise<boolean> {
|
||||
): Promise<boolean>;
|
||||
}
|
||||
|
||||
export const retryFileSystemAccess: FileSystemAccessorWithRetry = (
|
||||
fileOperation,
|
||||
) => {
|
||||
return retryWithExponentialBackoff(
|
||||
fileOperation,
|
||||
TOTAL_RETRIES,
|
||||
INITIAL_DELAY_MS,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// These values provide a balanced approach for handling transient file system
|
||||
// issues without excessive waiting.
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { app } from 'electron/main';
|
||||
import { shell } from 'electron/common';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { retryFileSystemAccess } from './RetryFileSystemAccess';
|
||||
import { retryFileSystemAccess } from './FileSystemAccessorWithRetry';
|
||||
|
||||
export async function startInstallation(filePath: string): Promise<boolean> {
|
||||
return retryFileSystemAccess(async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createReadStream } from 'node:fs';
|
||||
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
|
||||
import { retryFileSystemAccess } from './RetryFileSystemAccess';
|
||||
import { retryFileSystemAccess } from './FileSystemAccessorWithRetry';
|
||||
|
||||
export async function checkIntegrity(
|
||||
filePath: string,
|
||||
|
||||
@@ -4,7 +4,7 @@ import { GitHubProjectDetails } from '@/domain/Project/GitHubProjectDetails';
|
||||
import { Version } from '@/domain/Version';
|
||||
import { parseProjectDetails } from '@/application/Parser/ProjectDetailsParser';
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
import { UpdateProgressBar } from '../UpdateProgressBar';
|
||||
import { UpdateProgressBar } from '../ProgressBar/UpdateProgressBar';
|
||||
import {
|
||||
promptForManualUpdate, promptInstallerOpenError,
|
||||
promptIntegrityCheckFailure, promptDownloadError,
|
||||
@@ -14,29 +14,43 @@ import {
|
||||
import { type DownloadUpdateResult, downloadUpdate } from './Downloader';
|
||||
import { checkIntegrity } from './Integrity';
|
||||
import { startInstallation } from './Installer';
|
||||
import { clearUpdateInstallationFiles } from './InstallationFiles/InstallationFileCleaner';
|
||||
import type { UpdateInfo } from 'electron-updater';
|
||||
|
||||
export function requiresManualUpdate(): boolean {
|
||||
return process.platform === 'darwin';
|
||||
export function requiresManualUpdate(
|
||||
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) {
|
||||
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 {
|
||||
const updateAction = await promptForManualUpdate();
|
||||
if (updateAction === ManualUpdateChoice.NoAction) {
|
||||
ElectronLogger.info('User cancelled the update.');
|
||||
ElectronLogger.info('User chose to cancel the update');
|
||||
return;
|
||||
}
|
||||
const { releaseUrl, downloadUrl } = getRemoteUpdateUrls(info.version);
|
||||
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);
|
||||
} 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);
|
||||
}
|
||||
} catch (err) {
|
||||
ElectronLogger.error('Unexpected error during updates', err);
|
||||
ElectronLogger.error('Failed to execute auto-update process', { error: err });
|
||||
await handleUnexpectedError(info);
|
||||
}
|
||||
}
|
||||
@@ -56,9 +70,10 @@ async function downloadAndInstallUpdate(fileUrl: string, info: UpdateInfo) {
|
||||
}
|
||||
const userAction = await promptIntegrityCheckFailure();
|
||||
if (userAction === IntegrityCheckChoice.RetryDownload) {
|
||||
ElectronLogger.info('User chose to retry download after integrity check failure');
|
||||
await startManualUpdateProcess(info);
|
||||
} 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);
|
||||
}
|
||||
}
|
||||
@@ -66,9 +81,9 @@ async function downloadAndInstallUpdate(fileUrl: string, info: UpdateInfo) {
|
||||
async function handleFailedDownload(info: UpdateInfo) {
|
||||
const userAction = await promptDownloadError();
|
||||
if (userAction === DownloadErrorChoice.Cancel) {
|
||||
ElectronLogger.info('Update download canceled.');
|
||||
ElectronLogger.info('User chose to cancel update download');
|
||||
} else if (userAction === DownloadErrorChoice.RetryDownload) {
|
||||
ElectronLogger.info('Retrying update download.');
|
||||
ElectronLogger.info('User chose to retry update download');
|
||||
await startManualUpdateProcess(info);
|
||||
}
|
||||
}
|
||||
@@ -76,9 +91,9 @@ async function handleFailedDownload(info: UpdateInfo) {
|
||||
async function handleUnexpectedError(info: UpdateInfo) {
|
||||
const userAction = await showUnexpectedError();
|
||||
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) {
|
||||
ElectronLogger.info('Retrying the update process.');
|
||||
ElectronLogger.info('User chose to retry update process after unexpected error');
|
||||
await startManualUpdateProcess(info);
|
||||
}
|
||||
}
|
||||
@@ -89,8 +104,10 @@ async function openInstaller(installerPath: string, info: UpdateInfo) {
|
||||
}
|
||||
const userAction = await promptInstallerOpenError();
|
||||
if (userAction === InstallerErrorChoice.RetryDownload) {
|
||||
ElectronLogger.info('User chose to retry download after installer open error');
|
||||
await startManualUpdateProcess(info);
|
||||
} else if (userAction === InstallerErrorChoice.RetryOpen) {
|
||||
ElectronLogger.info('User chose to retry opening installer');
|
||||
await openInstaller(installerPath, info);
|
||||
}
|
||||
}
|
||||
@@ -100,7 +117,7 @@ async function withProgressBar(
|
||||
) {
|
||||
const progressBar = new UpdateProgressBar();
|
||||
await action(progressBar);
|
||||
progressBar.close();
|
||||
progressBar.closeIfOpen();
|
||||
}
|
||||
|
||||
async function isIntegrityPreserved(
|
||||
@@ -119,16 +136,16 @@ async function isIntegrityPreserved(
|
||||
function getRemoteSha512Hash(info: UpdateInfo, fileUrl: string): string | undefined {
|
||||
const fileInfos = info.files.filter((file) => fileUrl.includes(file.url));
|
||||
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) {
|
||||
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 undefined;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user