Compare commits

..

2 Commits

Author SHA1 Message Date
undergroundwires
493fb1ec16 mac: add new scripts and category for services 2023-10-19 01:21:03 +02:00
undergroundwires
b167a69976 Fix YAML error for site release in CI/CD
Fix the syntax error in the GitHub action script that was caused by
improper multi-line YAML notation. This correction ensures the action
can successfully parse and execute.
2023-10-19 00:45:23 +02:00
478 changed files with 10169 additions and 17120 deletions

View File

@@ -10,7 +10,7 @@ module.exports = {
}, },
extends: [ extends: [
// Vue specific rules, eslint-plugin-vue // Vue specific rules, eslint-plugin-vue
'plugin:vue/vue3-recommended', 'plugin:vue/essential',
// Extends eslint-config-airbnb // Extends eslint-config-airbnb
'@vue/eslint-config-airbnb-with-typescript', '@vue/eslint-config-airbnb-with-typescript',

View File

@@ -24,41 +24,3 @@ jobs:
- -
name: Run e2e tests name: Run e2e tests
run: npm run test:cy:run run: npm run test:cy:run
-
name: Output artifact directories
id: artifacts
if: always() # Run even if previous steps fail because test run video is always captured
shell: bash
run: |-
declare -r dirs_json_file='cypress-dirs.json'
if [ ! -f "${dirs_json_file}" ]; then
echo "${dirs_json_file} does not exist"
exit 1
fi
SCREENSHOTS_DIR=$(jq -r '.screenshots' "${dirs_json_file}")
VIDEOS_DIR=$(jq -r '.videos' "${dirs_json_file}")
for dir in "${SCREENSHOTS_DIR}" "${VIDEOS_DIR}"; do
if [ "${dir}" = 'null' ] || [ -z "${dir}" ]; then
echo "One or more directories are null or not specified in cypress-dirs.json"
exit 1
fi
done
echo "SCREENSHOTS_DIR=${SCREENSHOTS_DIR}" >> "${GITHUB_OUTPUT}"
echo "VIDEOS_DIR=${VIDEOS_DIR}" >> "${GITHUB_OUTPUT}"
-
name: Upload screenshots
if: failure() # Run only if previous steps fail because screenshots will be generated only if E2E test failed
uses: actions/upload-artifact@v3
with:
name: e2e-screenshots-${{ matrix.os }}
path: ${{ steps.artifacts.outputs.SCREENSHOTS_DIR }}
-
name: Upload videos
if: always() # Run even if previous steps fail because test run video is always captured
uses: actions/upload-artifact@v3
with:
name: e2e-videos-${{ matrix.os }}
path: ${{ steps.artifacts.outputs.VIDEOS_DIR }}

12
.gitignore vendored
View File

@@ -1,13 +1,5 @@
# Application build artifacts
/dist-*/
# npm
node_modules node_modules
/dist-*/
# Visual Studio Code .vs
.vscode/**/* .vscode/**/*
!.vscode/extensions.json !.vscode/extensions.json
# draw.io
*.bkp
*.dtmp

View File

@@ -1,38 +1,5 @@
# Changelog # Changelog
## 0.12.7 (2023-11-07)
* Add winget download instructions | [b2ffc90](https://github.com/undergroundwires/privacy.sexy/commit/b2ffc90da70367b9e65c82556e8f440f865ceb98)
* Fix unresponsive copy button on instructions modal | [8ccaec7](https://github.com/undergroundwires/privacy.sexy/commit/8ccaec7af6ea3ecfd46bab5c13b90f71d55e32c1)
* Fix tree node check states not being updated | [af7219f](https://github.com/undergroundwires/privacy.sexy/commit/af7219f6e12ab4a65ce07190f691cf3234e87e35)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.6...0.12.7)
## 0.12.6 (2023-11-03)
* Bump dependencies to latest | [25d7f7b](https://github.com/undergroundwires/privacy.sexy/commit/25d7f7b2a479e51e092881cc2751e67a7d3f179f)
* win: improve system app uninstall cleanup #73 | [dbe3c5c](https://github.com/undergroundwires/privacy.sexy/commit/dbe3c5cfb91ba8a1657838b69117858843c8fbc8)
* win: improve system app uninstall /w fallback #260 | [98a26f9](https://github.com/undergroundwires/privacy.sexy/commit/98a26f9ae47af2668aa53f39d1768983036048ce)
* Improve performance of rendering during search | [79b46bf](https://github.com/undergroundwires/privacy.sexy/commit/79b46bf21004d96d31551439e5db5d698a3f71f3)
* Fix YAML error for site release in CI/CD | [237d994](https://github.com/undergroundwires/privacy.sexy/commit/237d9944f900f5172366868d75219224ff0542b0)
* win: fix Microsoft Advertising app removal #200 | [e40b9a3](https://github.com/undergroundwires/privacy.sexy/commit/e40b9a3cf53c341f2e84023a9f0e9680ac08f3fa)
* win: improve directory cleanup security | [060e789](https://github.com/undergroundwires/privacy.sexy/commit/060e7896624309aebd25e8b190c127282de177e8)
* Centralize Electron entry file path configuration | [d6da406](https://github.com/undergroundwires/privacy.sexy/commit/d6da406c61e5b9f5408851d1302d6d7398157a2e)
* win: prevent updates from reinstalling apps #260 | [8570b02](https://github.com/undergroundwires/privacy.sexy/commit/8570b02dde14ffad64863f614682c3fc1f87b6c2)
* win: improve script environment robustness #221 | [dfd4451](https://github.com/undergroundwires/privacy.sexy/commit/dfd44515613f38abe5a806bda36f44e7b715b50b)
* Fix compiler failing with nested `with` expression | [80821fc](https://github.com/undergroundwires/privacy.sexy/commit/80821fca0769e5fd2c6338918fbdcea12fbe83d2)
* win: improve soft file/app delete security #260 | [f4a74f0](https://github.com/undergroundwires/privacy.sexy/commit/f4a74f058db9b5bcbcbe438785db5ec88ecc1657)
* Fix incorrect tooltip position after window resize | [f8e5f1a](https://github.com/undergroundwires/privacy.sexy/commit/f8e5f1a5a2afa1f18567e6d965359b6a1f082367)
* linux: fix string formatting of Firefox configs | [e775d68](https://github.com/undergroundwires/privacy.sexy/commit/e775d68a9b4a5f9e893ff0e3500dade036185193)
* win: improve file delete | [e72c1c1](https://github.com/undergroundwires/privacy.sexy/commit/e72c1c13ea2d73ebfc7a8da5a21254fdfc0e5b59)
* win: change system app removal to hard delete #260 | [77123d8](https://github.com/undergroundwires/privacy.sexy/commit/77123d8c929d23676a9cb21d7b697703fd1b6e82)
* Improve UI performance by optimizing reactivity | [4995e49](https://github.com/undergroundwires/privacy.sexy/commit/4995e49c469211404dac9fcb79b75eb121f80bce)
* Migrate to Vue 3.0 #230 | [ca81f68](https://github.com/undergroundwires/privacy.sexy/commit/ca81f68ff1c3bbe5b22981096ae9220b0b5851c7)
* win, linux: unify & improve Firefox clean-up #273 | [0466b86](https://github.com/undergroundwires/privacy.sexy/commit/0466b86f1013341c966a9bbf6513990337b16598)
* win: fix store revert for multiple installs #260 | [5bb13e3](https://github.com/undergroundwires/privacy.sexy/commit/5bb13e34f8de2e2a7ba943ff72b12c0569435e62)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.5...0.12.6)
## 0.12.5 (2023-10-13) ## 0.12.5 (2023-10-13)
* Fix Docker build and improve checks #220 | [7669985](https://github.com/undergroundwires/privacy.sexy/commit/7669985f8e1446e726a95626ecf35b3ce6b60a16) * Fix Docker build and improve checks #220 | [7669985](https://github.com/undergroundwires/privacy.sexy/commit/7669985f8e1446e726a95626ecf35b3ce6b60a16)

View File

@@ -43,7 +43,6 @@ You have two alternatives:
1. [Create an issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose) and ask for someone else to add the script for you. 1. [Create an issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose) and ask for someone else to add the script for you.
2. Or send a PR yourself. This would make it faster to get your code into the project. You need to add scripts to related OS in [collections](src/application/collections/) folder. Then you'd sent a pull request, see [pull request process](#pull-request-process). 2. Or send a PR yourself. This would make it faster to get your code into the project. You need to add scripts to related OS in [collections](src/application/collections/) folder. Then you'd sent a pull request, see [pull request process](#pull-request-process).
- 💡 You should use existing shared functions for most of the operations, like `DisableService` for disabling services, to maintain code consistency and efficiency.
- 📖 If you're unsure about the syntax, check [collection-files.md](docs/collection-files.md). - 📖 If you're unsure about the syntax, check [collection-files.md](docs/collection-files.md).
- 📖 If you wish to use templates, use [templating.md](./docs/templating.md). - 📖 If you wish to use templates, use [templating.md](./docs/templating.md).

View File

@@ -122,7 +122,7 @@
## Get started ## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy). - 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.7/privacy.sexy-Setup-0.12.7.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.7/privacy.sexy-0.12.7.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.7/privacy.sexy-0.12.7.AppImage). For more options, see [here](#additional-install-options). - 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-Setup-0.12.5.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-0.12.5.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.5/privacy.sexy-0.12.5.AppImage). For more options, see [here](#additional-install-options).
Online version does not require to run any software on your computer. Offline version has more functions such as running the scripts directly. Online version does not require to run any software on your computer. Offline version has more functions such as running the scripts directly.
@@ -153,21 +153,12 @@ Online version does not require to run any software on your computer. Offline ve
## Additional Install Options ## Additional Install Options
- Check the [releases page](https://github.com/undergroundwires/privacy.sexy/releases) for all available versions. - Check the [releases page](https://github.com/undergroundwires/privacy.sexy/releases) for all available versions.
- Other unofficial channels (not maintained by privacy.sexy) for Windows include: - Using [Scoop](https://scoop.sh/#/apps?q=privacy.sexy&s=2&d=1&o=true) package manager on Windows:
- [Scoop 🥄](https://scoop.sh/#/apps?q=privacy.sexy&s=2&d=1&o=true) (latest version):
```powershell ```powershell
scoop bucket add extras scoop bucket add extras
scoop install privacy.sexy scoop install privacy.sexy
``` ```
- [winget 🪟](https://winget.run/pkg/undergroundwires/privacy.sexy) (may be outdated):
```powershell
winget install -e --id undergroundwires.privacy.sexy
```
With winget, updates require manual submission; the auto-update feature within privacy.sexy will notify you of new releases post-installation.
## Development ## Development

View File

@@ -1,5 +0,0 @@
{
"base": "tests/e2e",
"videos": "tests/e2e/videos",
"screenshots": "tests/e2e/videos"
}

View File

@@ -1,31 +1,18 @@
import { defineConfig } from 'cypress'; import { defineConfig } from 'cypress';
import ViteConfig from './vite.config'; import ViteConfig from './vite.config';
import cypressDirs from './cypress-dirs.json' assert { type: 'json' };
const CYPRESS_BASE_DIR = 'tests/e2e/';
export default defineConfig({ export default defineConfig({
fixturesFolder: `${cypressDirs.base}/fixtures`, fixturesFolder: `${CYPRESS_BASE_DIR}/fixtures`,
screenshotsFolder: cypressDirs.screenshots, screenshotsFolder: `${CYPRESS_BASE_DIR}/screenshots`,
video: true, video: true,
videosFolder: cypressDirs.videos, videosFolder: `${CYPRESS_BASE_DIR}/videos`,
e2e: { e2e: {
baseUrl: `http://localhost:${getApplicationPort()}/`, baseUrl: `http://localhost:${ViteConfig.server.port}/`,
specPattern: `${cypressDirs.base}/**/*.cy.{js,jsx,ts,tsx}`, // Default: cypress/e2e/**/*.cy.{js,jsx,ts,tsx} specPattern: `${CYPRESS_BASE_DIR}/**/*.cy.{js,jsx,ts,tsx}`, // Default: cypress/e2e/**/*.cy.{js,jsx,ts,tsx}
supportFile: `${cypressDirs.base}/support/e2e.ts`, supportFile: `${CYPRESS_BASE_DIR}/support/e2e.ts`,
}, },
/*
Disabling Chrome's web security to allow for faster DOM queries to access DOM earlier than
`cy.get()`. It bypasses the usual same-origin policy constraints
*/
chromeWebSecurity: false,
}); });
function getApplicationPort(): number {
const port = ViteConfig.server?.port;
if (port === undefined) {
throw new Error('Unknown application port');
}
return port;
}

View File

@@ -11,8 +11,6 @@ The presentation layer uses an event-driven architecture for bidirectional react
## Structure ## Structure
- [`/src/` **`presentation/`**](./../src/presentation/): Contains Vue and Electron code. - [`/src/` **`presentation/`**](./../src/presentation/): Contains Vue and Electron code.
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.
- [**`index.html`**](./../src/presentation/index.html): The `index.html` entry file, located at the root of the project as required by Vite
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins. - [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins.
- [**`components/`**](./../src/presentation/components/): Contains Vue components and helpers. - [**`components/`**](./../src/presentation/components/): Contains Vue components and helpers.
- [**`Shared/`**](./../src/presentation/components/Shared): Contains shared Vue components and helpers. - [**`Shared/`**](./../src/presentation/components/Shared): Contains shared Vue components and helpers.
@@ -22,7 +20,8 @@ The presentation layer uses an event-driven architecture for bidirectional react
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts. - [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts.
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles. - [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles.
- [**`components/`**](./../src/presentation/assets/styles/components): Contains styles coupled to Vue components. - [**`components/`**](./../src/presentation/assets/styles/components): Contains styles coupled to Vue components.
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Main Sass file, imported by other components as single entrypoint.. - [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Main Sass file, imported by other components as single entrypoint.
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.
- [**`electron/`**](./../src/presentation/electron/): Contains Electron code. - [**`electron/`**](./../src/presentation/electron/): Contains Electron code.
- [`/main/` **`index.ts`**](./../src/presentation/main.ts): Main entry for Electron, managing application windows and lifecycle events. - [`/main/` **`index.ts`**](./../src/presentation/main.ts): Main entry for Electron, managing application windows and lifecycle events.
- [`/preload/` **`index.ts`**](./../src/presentation/main.ts): Script executed before the renderer, securing Node.js features for renderer use. - [`/preload/` **`index.ts`**](./../src/presentation/main.ts): Script executed before the renderer, securing Node.js features for renderer use.
@@ -71,11 +70,10 @@ To add a new dependency:
1. **Define its symbol**: Define an associated symbol for every dependency in [`injectionSymbols.ts`](./../src/presentation/injectionSymbols.ts). Symbols are grouped into: 1. **Define its symbol**: Define an associated symbol for every dependency in [`injectionSymbols.ts`](./../src/presentation/injectionSymbols.ts). Symbols are grouped into:
- **Singletons**: Shared across components, instantiated once. - **Singletons**: Shared across components, instantiated once.
- **Transients**: Factories yielding a new instance on every access. - **Transients**: Factories yielding a new instance on every access.
2. **Provide the dependency**: 2. **Provide the dependency**: Modify the [`provideDependencies`](./../src/presentation/bootstrapping/DependencyProvider.ts) function to include the new dependency. [`App.vue`](./../src/presentation/components/App.vue) calls this function within its `setup()` hook to register the dependencies.
Modify the [`provideDependencies`](./../src/presentation/bootstrapping/DependencyProvider.ts) function to include the new dependency. 3. **Inject the dependency**: Use Vue's `inject` method alongside the defined symbol to incorporate the dependency into components.
[`App.vue`](./../src/presentation/components/App.vue) calls this function within its `setup()` hook to register the dependencies. - For singletons, invoke the factory method: `inject(symbolKey)()`.
3. **Inject the dependency**: Use `injectKey` to inject a dependency. Pass a selector function to `injectKey` that retrieves the appropriate symbol from the provided dependencies. - For transients, directly inject: `inject(symbolKey)`.
- Example usage: `injectKey((keys) => keys.useCollectionState)`;
## Shared UI components ## Shared UI components

View File

@@ -2,142 +2,79 @@
## Benefits of templating ## Benefits of templating
- **Code sharing:** Share code across scripts for consistent practices and easier maintenance. - Generating scripts by sharing code to increase best-practice usage and maintainability.
- **Script independence:** Generate self-contained scripts, eliminating the need for external code. - Creating self-contained scripts without cross-dependencies.
- **Cleaner code:** Use pipes for complex operations, resulting in more readable and streamlined code. - Use of pipes for writing cleaner code and letting pipes do dirty work.
## Expressions ## Expressions
**Syntax:** - Expressions start and end with mustaches (double brackets, `{{` and `}}`).
- E.g. `Hello {{ $name }} !`
- Syntax is close to [Go Templates ❤️](https://pkg.go.dev/text/template) but not the same.
- Functions enables usage of expressions.
- In script definition parts of a function, see [`Function`](./collection-files.md#Function).
- When doing a call as argument values, see [`FunctionCall`](./collection-files.md#Function).
- Expressions inside expressions (nested templates) are supported.
- An expression can output another expression that will also be compiled.
- E.g. following would compile first [with expression](#with), and then [parameter substitution](#parameter-substitution) in its output.
Expressions are enclosed within `{{` and `}}`. ```go
Example: `Hello {{ $name }}!`. {{ with $condition }}
They are a core component of templating, enhancing scripts with dynamic capabilities and functionality. echo {{ $text }}
{{ end }}
**Syntax similarity:** ```
The syntax shares similarities with [Go Templates ❤️](https://pkg.go.dev/text/template), but with some differences:
**Function definitions:**
You can use expressions in function definition.
Refer to [Function](./collection-files.md#function) for more details.
Example usage:
```yaml
name: GreetFunction
parameters:
- name: name
code: Hello {{ $name }}!
```
If you assign `name` the value `world`, invoking `GreetFunction` would result in `Hello world!`.
**Function arguments:**
You can also use expressions in arguments in nested function calls.
Refer to [`Function | collection-files.md`](./collection-files.md#functioncall) for more details.
Example with nested function calls:
```yaml
-
name: PrintMessageFunction
parameters:
- name: message
code: echo "{{ $message }}"
-
name: GreetUserFunction
parameters:
- name: userName
call:
name: PrintMessageFunction
parameters:
argument: 'Hello, {{ $userName }}!'
```
Here, if `userName` is `Alice`, invoking `GreetUserFunction` would execute `echo "Hello, Alice!"`.
**Nested templates:**
You can nest expressions inside expressions (also called "nested templates").
This means that an expression can output another expression where compiler will compile both.
For example, following would compile first [with expression](#with), and then [parameter substitution](#parameter-substitution) in its output:
```go
{{ with $condition }}
echo {{ $text }}
{{ end }}
```
### Parameter substitution ### Parameter substitution
Parameter substitution dynamically replaces variable references with their corresponding values in the script. A simple function example:
**Example function:**
```yaml ```yaml
name: DisplayTextFunction function: EchoArgument
parameters: parameters:
- name: 'text' - name: 'argument'
code: echo {{ $text }} code: Hello {{ $argument }} !
``` ```
Invoking `DisplayTextFunction` with `text` set to `"Hello, world!"` would result in `echo "Hello, World!"`. It would print "Hello world" if it's called in a [script](./collection-files.md#script) as following:
```yaml
script: Echo script
call:
function: EchoArgument
parameters:
argument: World
```
A function can call other functions such as:
```yaml
-
function: CallerFunction
parameters:
- name: 'value'
call:
function: EchoArgument
parameters:
argument: {{ $value }}
-
function: EchoArgument
parameters:
- name: 'argument'
code: Hello {{ $argument }} !
```
### with ### with
The `with` expression enables conditional rendering and provides a context variable for simpler code. Skips its "block" if the variable is absent or empty. Its "block" is between `with` start (`{{ with .. }}`) and end (`{{ end }`}) expressions.
E.g. `{{ with $parameterName }} Hi, I'm a block! {{ end }}` would only output `Hi, I'm a block!` if `parameterName` has any value..
**Optional block rendering:** It binds its context (value of the provided parameter value) as arbitrary `.` value. It allows you to use the argument value of the given parameter when it is provided and not empty such as:
If the provided variable is falsy (`false`, `null`, or empty), the compiler skips the enclosed block of code.
A "block" lies between the with start (`{{ with .. }}`) and end (`{{ end }}`) expressions, defining its boundaries.
Example:
```go
{{ with $optionalVariable }}
Hello
{{ end }}
```
This would display `Hello` if `$optionalVariable` is truthy.
**Parameter declaration:**
You should set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
Declare parameters used for `with` condition as optional such as:
```yaml
name: ConditionalOutputFunction
parameters:
- name: 'data'
optional: true
code: |-
{{ with $data }}
Data is: {{ . }}
{{ end }}
```
**Context variable:**
`with` statement binds its context (value of the provided parameter value) as arbitrary `.` value.
`{{ . }}` syntax gives you access to the context variable.
This is optional to use, and not required to use `with` expressions.
For example:
```go ```go
{{ with $parameterName }}Parameter value is {{ . }} here {{ end }} {{ with $parameterName }}Parameter value is {{ . }} here {{ end }}
``` ```
**Multiline text:** It supports multiline text inside the block. You can have something like:
It supports multiline text inside the block. You can write something like:
```go ```go
{{ with $argument }} {{ with $argument }}
@@ -146,9 +83,7 @@ It supports multiline text inside the block. You can write something like:
{{ end }} {{ end }}
``` ```
**Inner expressions:** You can also use other expressions inside its block, such as [parameter substitution](#parameter-substitution):
You can also embed other expressions inside its block, such as [parameter substitution](#parameter-substitution):
```go ```go
{{ with $condition }} {{ with $condition }}
@@ -156,44 +91,32 @@ You can also embed other expressions inside its block, such as [parameter substi
{{ end }} {{ end }}
``` ```
This also includes nesting `with` statements: 💡 Declare parameters used for `with` condition as optional. Set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
```go Example:
{{ with $condition1 }}
Value of $condition1: {{ . }} ```yaml
{{ with $condition2 }} function: FunctionThatOutputsConditionally
Value of $condition2: {{ . }} parameters:
- name: 'argument'
optional: true
code: |-
{{ with $argument }}
Value is: {{ . }}
{{ end }} {{ end }}
{{ end }}
``` ```
### Pipes ### Pipes
Pipes are functions designed for text manipulation. - Pipes are functions available for handling text.
They allow for a sequential application of operations resembling [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), also known as "chaining". - Allows stacking actions one after another also known as "chaining".
Each pipeline's output becomes the input of the following pipe. - Like [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), the concept is simple: each pipeline's output becomes the input of the following pipe.
- You cannot create pipes. [A dedicated compiler](./application.md#parsing-and-compiling) provides pre-defined pipes to consume in collection files.
**Pre-defined**: - You can combine pipes with other expressions such as [parameter substitution](#parameter-substitution) and [with](#with) syntax.
- ❗ Pipe names must be camelCase without any space or special characters.
Pipes are pre-defined by the system. - **Existing pipes**
You cannot create pipes in [collection files](./collection-files.md). - `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
[A dedicated compiler](./application.md#parsing-and-compiling) provides pre-defined pipes to consume in collection files. - `escapeDoubleQuotes`: Escapes `"` characters, allows you to use them inside double quotes (`"`).
- **Example usages**
**Compatibility:** - `{{ with $code }} echo "{{ . | inlinePowerShell }}" {{ end }}`
- `{{ with $code }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}`
You can combine pipes with other expressions such as [parameter substitution](#parameter-substitution) and [with](#with) syntax.
For example:
```go
{{ with $script }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}
```
**Naming:**
❗ Pipe names must be camelCase without any space or special characters.
**Available pipes:**
- `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
- `escapeDoubleQuotes`: Escapes `"` characters for batch command execution, allows you to use them inside double quotes (`"`).

View File

@@ -68,23 +68,21 @@ These checks validate various qualities like runtime execution, building process
- [`./src/`](./../src/): Contains the code subject to testing. - [`./src/`](./../src/): Contains the code subject to testing.
- [`./tests/shared/`](./../tests/shared/): Contains code shared by different test categories. - [`./tests/shared/`](./../tests/shared/): Contains code shared by different test categories.
- [`bootstrap/setup.ts`](./../tests/shared/bootstrap/setup.ts): Initializes unit and integration tests. - [`bootstrap/setup.ts`](./../tests/shared/bootstrap/setup.ts): Initializes unit and integration tests.
- [`Assertions/`](./../tests/shared/Assertions/): Contains common assertion functions, prefixed with `expect`.
- [`./tests/unit/`](./../tests/unit/) - [`./tests/unit/`](./../tests/unit/)
- Stores unit test code. - Stores unit test code.
- The directory structure mirrors [`./src/`](./../src). - The directory structure mirrors [`./src/`](./../src).
- E.g., tests for [`./src/application/ApplicationFactory.ts`](./../src/application/ApplicationFactory.ts) reside in [`./tests/unit/application/ApplicationFactory.spec.ts`](./../tests/unit/application/ApplicationFactory.spec.ts). - E.g., tests for [`./src/application/ApplicationFactory.ts`](./../src/application/ApplicationFactory.ts) reside in [`./tests/unit/application/ApplicationFactory.spec.ts`](./../tests/unit/application/ApplicationFactory.spec.ts).
- [`shared/`](./../tests/unit/shared/) - [`shared/`](./../tests/unit/shared/)
- Contains shared unit test functionalities. - Contains shared unit test functionalities.
- [`Assertions/`](./../tests/unit/shared/Assertions): Contains common assertion functions, prefixed with `expect`.
- [`TestCases/`](./../tests/unit/shared/TestCases/) - [`TestCases/`](./../tests/unit/shared/TestCases/)
- Shared test cases. - Shared test cases.
- Functions that calls `it()` from [Vitest](https://vitest.dev/) should have `it` prefix. - Functions that calls `it()` from [Vitest](https://vitest.dev/) should have `it` prefix.
- [`Stubs/`](./../tests/unit/shared/Stubs): Maintains stubs for component isolation, equipped with basic functionalities and, when necessary, spying or mocking capabilities. - [`Stubs/`](./../tests/unit/shared/Stubs): Maintains stubs for component isolation, equipped with basic functionalities and, when necessary, spying or mocking capabilities.
- [`./tests/integration/`](./../tests/integration/): Contains integration test files. - [`./tests/integration/`](./../tests/integration/): Contains integration test files.
- [`cypress.config.ts`](./../cypress.config.ts): Cypress (E2E tests) configuration file. - [`cypress.config.ts`](./../cypress.config.ts): Cypress (E2E tests) configuration file.
- [`cypress-dirs.json`](./../cypress-dirs.json): A central definition of directories used by Cypress, designed for reuse across different configurations.
- [`./tests/e2e/`](./../tests/e2e/): Base Cypress folder, includes tests with `.cy.ts` extension. - [`./tests/e2e/`](./../tests/e2e/): Base Cypress folder, includes tests with `.cy.ts` extension.
- [`/support/e2e.ts`](./../tests/e2e/support/e2e.ts): Support file, runs before every single spec file.
- [`/tsconfig.json`]: TypeScript configuration for file Cypress code, improves IDE support, recommended to have by official documentation. - [`/tsconfig.json`]: TypeScript configuration for file Cypress code, improves IDE support, recommended to have by official documentation.
- *(git ignored)* `/videos`: Asset folder for videos taken during tests. - *(git ignored)* `/videos`: Asset folder for videos taken during tests.
- *(git ignored)* `/screenshots`: Asset folder for Screenshots taken during tests. - *(git ignored)* `/screenshots`: Asset folder for Screenshots taken during tests.
- [`/support/e2e.ts`](./../tests/e2e/support/e2e.ts): Support file, runs before every single test file.
- [`/support/interactions/`](./../tests/e2e/support/interactions/): Contains reusable functions for simulating user interactions, enhancing test readability and maintainability.

View File

@@ -8,21 +8,15 @@ import distDirs from './dist-dirs.json' assert { type: 'json' };
const MAIN_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/main/index.ts'); const MAIN_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/main/index.ts');
const PRELOAD_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/preload/index.ts'); const PRELOAD_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/preload/index.ts');
const WEB_INDEX_HTML_PATH = resolvePathFromProjectRoot('src/presentation/index.html'); const WEB_INDEX_HTML_PATH = resolvePathFromProjectRoot('src/presentation/index.html');
const ELECTRON_DIST_SUBDIRECTORIES = { const DIST_DIR = resolvePathFromProjectRoot(distDirs.electronUnbundled);
main: resolveElectronDistSubdirectory('main'),
preload: resolveElectronDistSubdirectory('preload'),
renderer: resolveElectronDistSubdirectory('renderer'),
};
process.env.ELECTRON_ENTRY = resolve(ELECTRON_DIST_SUBDIRECTORIES.main, 'index.cjs');
export default defineConfig({ export default defineConfig({
main: getSharedElectronConfig({ main: getSharedElectronConfig({
distDirSubfolder: ELECTRON_DIST_SUBDIRECTORIES.main, distDirSubfolder: 'main',
entryFilePath: MAIN_ENTRY_FILE, entryFilePath: MAIN_ENTRY_FILE,
}), }),
preload: getSharedElectronConfig({ preload: getSharedElectronConfig({
distDirSubfolder: ELECTRON_DIST_SUBDIRECTORIES.preload, distDirSubfolder: 'preload',
entryFilePath: PRELOAD_ENTRY_FILE, entryFilePath: PRELOAD_ENTRY_FILE,
}), }),
renderer: mergeConfig( renderer: mergeConfig(
@@ -31,7 +25,7 @@ export default defineConfig({
}), }),
{ {
build: { build: {
outDir: ELECTRON_DIST_SUBDIRECTORIES.renderer, outDir: resolve(DIST_DIR, 'renderer'),
rollupOptions: { rollupOptions: {
input: { input: {
index: WEB_INDEX_HTML_PATH, index: WEB_INDEX_HTML_PATH,
@@ -48,7 +42,7 @@ function getSharedElectronConfig(options: {
}): UserConfig { }): UserConfig {
return { return {
build: { build: {
outDir: options.distDirSubfolder, outDir: resolve(DIST_DIR, options.distDirSubfolder),
lib: { lib: {
entry: options.entryFilePath, entry: options.entryFilePath,
}, },
@@ -70,11 +64,6 @@ function getSharedElectronConfig(options: {
}; };
} }
function resolvePathFromProjectRoot(pathSegment: string): string { function resolvePathFromProjectRoot(pathSegment: string) {
return resolve(__dirname, pathSegment); return resolve(__dirname, pathSegment);
} }
function resolveElectronDistSubdirectory(subDirectory: string): string {
const electronDistDir = resolvePathFromProjectRoot(distDirs.electronUnbundled);
return resolve(electronDistDir, subDirectory);
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

1185
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,11 +1,12 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.12.7", "version": "0.12.5",
"private": true, "private": true,
"slogan": "Now you have the choice", "slogan": "Now you have the choice",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆", "description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
"author": "undergroundwires", "author": "undergroundwires",
"type": "module", "type": "module",
"main": "./dist-electron-unbundled/main/index.cjs",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
@@ -24,7 +25,7 @@
"electron:preview": "electron-vite preview", "electron:preview": "electron-vite preview",
"electron:prebuild": "electron-vite build", "electron:prebuild": "electron-vite build",
"electron:build": "electron-builder", "electron:build": "electron-builder",
"lint:eslint": "eslint . --max-warnings=0 --ignore-path .gitignore", "lint:eslint": "eslint . --ignore-path .gitignore",
"lint:md": "markdownlint **/*.md --ignore node_modules", "lint:md": "markdownlint **/*.md --ignore node_modules",
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent", "lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
"lint:md:relative-urls": "remark . --frail --use remark-validate-links", "lint:md:relative-urls": "remark . --frail --use remark-validate-links",
@@ -42,7 +43,7 @@
"electron-updater": "^6.1.4", "electron-updater": "^6.1.4",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"markdown-it": "^13.0.2", "markdown-it": "^13.0.2",
"vue": "^3.3.7" "vue": "^2.7.14"
}, },
"devDependencies": { "devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.4", "@modyfi/vite-plugin-yaml": "^1.0.4",
@@ -52,17 +53,17 @@
"@typescript-eslint/eslint-plugin": "^5.62.0", "@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0", "@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-legacy": "^4.1.1", "@vitejs/plugin-legacy": "^4.1.1",
"@vitejs/plugin-vue": "^4.4.0", "@vitejs/plugin-vue2": "^2.2.0",
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0", "@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.3", "@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^2.4.1", "@vue/test-utils": "^1.3.6",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"cypress": "^13.3.1", "cypress": "^13.3.1",
"electron": "^27.0.0", "electron": "^27.0.0",
"electron-builder": "^24.6.4", "electron-builder": "^24.6.4",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-icon-builder": "^2.0.1", "electron-icon-builder": "^2.0.1",
"electron-vite": "^1.0.28", "electron-vite": "^1.0.27",
"eslint": "^8.51.0", "eslint": "^8.51.0",
"eslint-plugin-cypress": "^2.15.1", "eslint-plugin-cypress": "^2.15.1",
"eslint-plugin-vue": "^9.17.0", "eslint-plugin-vue": "^9.17.0",
@@ -97,11 +98,5 @@
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://github.com/undergroundwires/privacy.sexy.git" "url": "https://github.com/undergroundwires/privacy.sexy.git"
},
"optionalDependencies": {
"dmg-license": "^1.0.11"
},
"//optionalDependencies": {
"dmg-license": "Required by `electron-builder` for DMG builds on macOS, https://github.com/electron-userland/electron-builder/issues/6489, https://github.com/electron-userland/electron-builder/issues/6520"
} }
} }

View File

@@ -12,6 +12,9 @@ export class ApplicationFactory implements IApplicationFactory {
private readonly getter: AsyncLazy<IApplication>; private readonly getter: AsyncLazy<IApplication>;
protected constructor(costlyGetter: ApplicationGetterType) { protected constructor(costlyGetter: ApplicationGetterType) {
if (!costlyGetter) {
throw new Error('missing getter');
}
this.getter = new AsyncLazy<IApplication>(() => Promise.resolve(costlyGetter())); this.getter = new AsyncLazy<IApplication>(() => Promise.resolve(costlyGetter()));
} }

View File

@@ -1,5 +1,7 @@
// Compares to Array<T> objects for equality, ignoring order // Compares to Array<T> objects for equality, ignoring order
export function scrambledEqual<T>(array1: readonly T[], array2: readonly T[]) { export function scrambledEqual<T>(array1: readonly T[], array2: readonly T[]) {
if (!array1) { throw new Error('missing first array'); }
if (!array2) { throw new Error('missing second array'); }
const sortedArray1 = sort(array1); const sortedArray1 = sort(array1);
const sortedArray2 = sort(array2); const sortedArray2 = sort(array2);
return sequenceEqual(sortedArray1, sortedArray2); return sequenceEqual(sortedArray1, sortedArray2);
@@ -10,6 +12,8 @@ export function scrambledEqual<T>(array1: readonly T[], array2: readonly T[]) {
// Compares to Array<T> objects for equality in same order // Compares to Array<T> objects for equality in same order
export function sequenceEqual<T>(array1: readonly T[], array2: readonly T[]) { export function sequenceEqual<T>(array1: readonly T[], array2: readonly T[]) {
if (!array1) { throw new Error('missing first array'); }
if (!array2) { throw new Error('missing second array'); }
if (array1.length !== array2.length) { if (array1.length !== array2.length) {
return false; return false;
} }

View File

@@ -20,30 +20,23 @@ export abstract class CustomError extends Error {
} }
} }
interface ErrorPrototypeManipulation { export const Environment = {
getSetPrototypeOf: () => (typeof Object.setPrototypeOf | undefined);
getCaptureStackTrace: () => (typeof Error.captureStackTrace | undefined);
}
export const PlatformErrorPrototypeManipulation: ErrorPrototypeManipulation = {
getSetPrototypeOf: () => Object.setPrototypeOf, getSetPrototypeOf: () => Object.setPrototypeOf,
getCaptureStackTrace: () => Error.captureStackTrace, getCaptureStackTrace: () => Error.captureStackTrace,
}; };
function fixPrototype(target: Error, prototype: CustomError) { function fixPrototype(target: Error, prototype: CustomError) {
// This is recommended by TypeScript guidelines. // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget
// Source: https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget const setPrototypeOf = Environment.getSetPrototypeOf();
// Snapshots: https://web.archive.org/web/20231111234849/https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget, https://archive.ph/tr7cX#support-for-newtarget if (!functionExists(setPrototypeOf)) {
const setPrototypeOf = PlatformErrorPrototypeManipulation.getSetPrototypeOf();
if (!isFunction(setPrototypeOf)) {
return; return;
} }
setPrototypeOf(target, prototype); setPrototypeOf(target, prototype);
} }
function ensureStackTrace(target: Error) { function ensureStackTrace(target: Error) {
const captureStackTrace = PlatformErrorPrototypeManipulation.getCaptureStackTrace(); const captureStackTrace = Environment.getCaptureStackTrace();
if (!isFunction(captureStackTrace)) { if (!functionExists(captureStackTrace)) {
// captureStackTrace is only available on V8, if it's not available // captureStackTrace is only available on V8, if it's not available
// modern JS engines will usually generate a stack trace on error objects when they're thrown. // modern JS engines will usually generate a stack trace on error objects when they're thrown.
return; return;
@@ -51,7 +44,7 @@ function ensureStackTrace(target: Error) {
captureStackTrace(target, target.constructor); captureStackTrace(target, target.constructor);
} }
// eslint-disable-next-line @typescript-eslint/ban-types function functionExists(func: unknown): boolean {
function isFunction(func: unknown): func is Function { // Not doing truthy/falsy check i.e. if(func) as most values are truthy in JS for robustness
return typeof func === 'function'; return typeof func === 'function';
} }

View File

@@ -54,6 +54,9 @@ export function assertInRange<T extends EnumType, TEnumValue extends EnumType>(
value: TEnumValue, value: TEnumValue,
enumVariable: EnumVariable<T, TEnumValue>, enumVariable: EnumVariable<T, TEnumValue>,
) { ) {
if (value === undefined || value === null) {
throw new Error('absent enum value');
}
if (!(value in enumVariable)) { if (!(value in enumVariable)) {
throw new RangeError(`enum value "${value}" is out of range`); throw new RangeError(`enum value "${value}" is out of range`);
} }

View File

@@ -9,16 +9,19 @@ export abstract class ScriptingLanguageFactory<T> implements IScriptingLanguageF
public create(language: ScriptingLanguage): T { public create(language: ScriptingLanguage): T {
assertInRange(language, ScriptingLanguage); assertInRange(language, ScriptingLanguage);
const getter = this.getters.get(language); if (!this.getters.has(language)) {
if (!getter) {
throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`); throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`);
} }
const getter = this.getters.get(language);
const instance = getter(); const instance = getter();
return instance; return instance;
} }
protected registerGetter(language: ScriptingLanguage, getter: Getter<T>) { protected registerGetter(language: ScriptingLanguage, getter: Getter<T>) {
assertInRange(language, ScriptingLanguage); assertInRange(language, ScriptingLanguage);
if (!getter) {
throw new Error('missing getter');
}
if (this.getters.has(language)) { if (this.getters.has(language)) {
throw new Error(`${ScriptingLanguage[language]} is already registered`); throw new Error(`${ScriptingLanguage[language]} is already registered`);
} }

View File

@@ -1,27 +0,0 @@
import { PlatformTimer } from './PlatformTimer';
import { TimeoutType, Timer } from './Timer';
export function batchedDebounce<T>(
callback: (batches: readonly T[]) => void,
waitInMs: number,
timer: Timer = PlatformTimer,
): (arg: T) => void {
let lastTimeoutId: TimeoutType | undefined;
let batches: Array<T> = [];
return (arg: T) => {
batches.push(arg);
const later = () => {
callback(batches);
batches = [];
lastTimeoutId = undefined;
};
if (lastTimeoutId !== undefined) {
timer.clearTimeout(lastTimeoutId);
}
lastTimeoutId = timer.setTimeout(later, waitInMs);
};
}

View File

@@ -1,7 +0,0 @@
import { Timer } from './Timer';
export const PlatformTimer: Timer = {
setTimeout: (callback, ms) => setTimeout(callback, ms),
clearTimeout: (timeoutId) => clearTimeout(timeoutId),
dateNow: () => Date.now(),
};

View File

@@ -1,8 +0,0 @@
// Allows aligning with both NodeJs (NodeJs.Timeout) and Window type (number)
export type TimeoutType = ReturnType<typeof setTimeout>;
export interface Timer {
setTimeout: (callback: () => void, ms: number) => TimeoutType;
clearTimeout: (timeoutId: TimeoutType) => void;
dateNow(): number;
}

View File

@@ -26,6 +26,7 @@ export class ApplicationContext implements IApplicationContext {
public readonly app: IApplication, public readonly app: IApplication,
initialContext: OperatingSystem, initialContext: OperatingSystem,
) { ) {
validateApp(app);
this.states = initializeStates(app); this.states = initializeStates(app);
this.changeContext(initialContext); this.changeContext(initialContext);
} }
@@ -35,8 +36,10 @@ export class ApplicationContext implements IApplicationContext {
if (this.currentOs === os) { if (this.currentOs === os) {
return; return;
} }
const collection = this.app.getCollection(os); this.collection = this.app.getCollection(os);
this.collection = collection; if (!this.collection) {
throw new Error(`os "${OperatingSystem[os]}" is not defined in application`);
}
const event: IApplicationContextChangedEvent = { const event: IApplicationContextChangedEvent = {
newState: this.states[os], newState: this.states[os],
oldState: this.states[this.currentOs], oldState: this.states[this.currentOs],
@@ -46,6 +49,12 @@ export class ApplicationContext implements IApplicationContext {
} }
} }
function validateApp(app: IApplication) {
if (!app) {
throw new Error('missing app');
}
}
function initializeStates(app: IApplication): StateMachine { function initializeStates(app: IApplication): StateMachine {
const machine = new Map<OperatingSystem, ICategoryCollectionState>(); const machine = new Map<OperatingSystem, ICategoryCollectionState>();
for (const collection of app.collections) { for (const collection of app.collections) {

View File

@@ -10,23 +10,18 @@ export async function buildContext(
factory: IApplicationFactory = ApplicationFactory.Current, factory: IApplicationFactory = ApplicationFactory.Current,
environment = RuntimeEnvironment.CurrentEnvironment, environment = RuntimeEnvironment.CurrentEnvironment,
): Promise<IApplicationContext> { ): Promise<IApplicationContext> {
if (!factory) { throw new Error('missing factory'); }
if (!environment) { throw new Error('missing environment'); }
const app = await factory.getApp(); const app = await factory.getApp();
const os = getInitialOs(app, environment.os); const os = getInitialOs(app, environment.os);
return new ApplicationContext(app, os); return new ApplicationContext(app, os);
} }
function getInitialOs( function getInitialOs(app: IApplication, currentOs: OperatingSystem): OperatingSystem {
app: IApplication,
currentOs: OperatingSystem | undefined,
): OperatingSystem {
const supportedOsList = app.getSupportedOsList(); const supportedOsList = app.getSupportedOsList();
if (currentOs !== undefined && supportedOsList.includes(currentOs)) { if (supportedOsList.includes(currentOs)) {
return currentOs; return currentOs;
} }
return getMostSupportedOs(supportedOsList, app);
}
function getMostSupportedOs(supportedOsList: OperatingSystem[], app: IApplication) {
supportedOsList.sort((os1, os2) => { supportedOsList.sort((os1, os2) => {
const getPriority = (os: OperatingSystem) => app.getCollection(os).totalScripts; const getPriority = (os: OperatingSystem) => app.getCollection(os).totalScripts;
return getPriority(os2) - getPriority(os1); return getPriority(os2) - getPriority(os1);

View File

@@ -4,48 +4,23 @@ import { UserFilter } from './Filter/UserFilter';
import { IUserFilter } from './Filter/IUserFilter'; import { IUserFilter } from './Filter/IUserFilter';
import { ApplicationCode } from './Code/ApplicationCode'; import { ApplicationCode } from './Code/ApplicationCode';
import { UserSelection } from './Selection/UserSelection'; import { UserSelection } from './Selection/UserSelection';
import { IUserSelection } from './Selection/IUserSelection';
import { ICategoryCollectionState } from './ICategoryCollectionState'; import { ICategoryCollectionState } from './ICategoryCollectionState';
import { IApplicationCode } from './Code/IApplicationCode'; import { IApplicationCode } from './Code/IApplicationCode';
import { UserSelectionFacade } from './Selection/UserSelectionFacade';
export class CategoryCollectionState implements ICategoryCollectionState { export class CategoryCollectionState implements ICategoryCollectionState {
public readonly os: OperatingSystem; public readonly os: OperatingSystem;
public readonly code: IApplicationCode; public readonly code: IApplicationCode;
public readonly selection: UserSelection; public readonly selection: IUserSelection;
public readonly filter: IUserFilter; public readonly filter: IUserFilter;
public constructor( public constructor(readonly collection: ICategoryCollection) {
public readonly collection: ICategoryCollection, this.selection = new UserSelection(collection, []);
selectionFactory = DefaultSelectionFactory, this.code = new ApplicationCode(this.selection, collection.scripting);
codeFactory = DefaultCodeFactory, this.filter = new UserFilter(collection);
filterFactory = DefaultFilterFactory,
) {
this.selection = selectionFactory(collection, []);
this.code = codeFactory(this.selection.scripts, collection.scripting);
this.filter = filterFactory(collection);
this.os = collection.os; this.os = collection.os;
} }
} }
export type CodeFactory = (
...params: ConstructorParameters<typeof ApplicationCode>
) => IApplicationCode;
const DefaultCodeFactory: CodeFactory = (...params) => new ApplicationCode(...params);
export type SelectionFactory = (
...params: ConstructorParameters<typeof UserSelectionFacade>
) => UserSelection;
const DefaultSelectionFactory: SelectionFactory = (
...params
) => new UserSelectionFacade(...params);
export type FilterFactory = (
...params: ConstructorParameters<typeof UserFilter>
) => IUserFilter;
const DefaultFilterFactory: FilterFactory = (...params) => new UserFilter(...params);

View File

@@ -1,7 +1,7 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IReadOnlyUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { EventSource } from '@/infrastructure/Events/EventSource'; import { EventSource } from '@/infrastructure/Events/EventSource';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { ReadonlyScriptSelection } from '@/application/Context/State/Selection/Script/ScriptSelection';
import { CodeChangedEvent } from './Event/CodeChangedEvent'; import { CodeChangedEvent } from './Event/CodeChangedEvent';
import { CodePosition } from './Position/CodePosition'; import { CodePosition } from './Position/CodePosition';
import { ICodeChangedEvent } from './Event/ICodeChangedEvent'; import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
@@ -17,12 +17,15 @@ export class ApplicationCode implements IApplicationCode {
private scriptPositions = new Map<SelectedScript, CodePosition>(); private scriptPositions = new Map<SelectedScript, CodePosition>();
constructor( constructor(
selection: ReadonlyScriptSelection, userSelection: IReadOnlyUserSelection,
private readonly scriptingDefinition: IScriptingDefinition, private readonly scriptingDefinition: IScriptingDefinition,
private readonly generator: IUserScriptGenerator = new UserScriptGenerator(), private readonly generator: IUserScriptGenerator = new UserScriptGenerator(),
) { ) {
this.setCode(selection.selectedScripts); if (!userSelection) { throw new Error('missing userSelection'); }
selection.changed.on((scripts) => { if (!scriptingDefinition) { throw new Error('missing scriptingDefinition'); }
if (!generator) { throw new Error('missing generator'); }
this.setCode(userSelection.selectedScripts);
userSelection.changed.on((scripts) => {
this.setCode(scripts); this.setCode(scripts);
}); });
} }

View File

@@ -1,6 +1,6 @@
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript'; import { SelectedScript } from '../../Selection/SelectedScript';
import { ICodeChangedEvent } from './ICodeChangedEvent'; import { ICodeChangedEvent } from './ICodeChangedEvent';
export class CodeChangedEvent implements ICodeChangedEvent { export class CodeChangedEvent implements ICodeChangedEvent {
@@ -36,18 +36,7 @@ export class CodeChangedEvent implements ICodeChangedEvent {
} }
public getScriptPositionInCode(script: IScript): ICodePosition { public getScriptPositionInCode(script: IScript): ICodePosition {
return this.getPositionById(script.id); return this.scripts.get(script);
}
private getPositionById(scriptId: string): ICodePosition {
const position = [...this.scripts.entries()]
.filter(([s]) => s.id === scriptId)
.map(([, pos]) => pos)
.at(0);
if (!position) {
throw new Error('Unknown script: Position could not be found for the script');
}
return position;
} }
} }

View File

@@ -3,9 +3,9 @@ import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePo
export interface ICodeChangedEvent { export interface ICodeChangedEvent {
readonly code: string; readonly code: string;
readonly addedScripts: ReadonlyArray<IScript>; addedScripts: ReadonlyArray<IScript>;
readonly removedScripts: ReadonlyArray<IScript>; removedScripts: ReadonlyArray<IScript>;
readonly changedScripts: ReadonlyArray<IScript>; changedScripts: ReadonlyArray<IScript>;
isEmpty(): boolean; isEmpty(): boolean;
getScriptPositionInCode(script: IScript): ICodePosition; getScriptPositionInCode(script: IScript): ICodePosition;
} }

View File

@@ -16,9 +16,7 @@ export abstract class CodeBuilder implements ICodeBuilder {
return this; return this;
} }
const lines = code.match(/[^\r\n]+/g); const lines = code.match(/[^\r\n]+/g);
if (lines) { this.lines.push(...lines);
this.lines.push(...lines);
}
return this; return this;
} }

View File

@@ -1,7 +1,7 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
export interface IUserScript { export interface IUserScript {
readonly code: string; code: string;
readonly scriptPositions: Map<SelectedScript, ICodePosition>; scriptPositions: Map<SelectedScript, ICodePosition>;
} }

View File

@@ -1,10 +1,9 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { IUserScript } from './IUserScript'; import { IUserScript } from './IUserScript';
export interface IUserScriptGenerator { export interface IUserScriptGenerator {
buildCode( buildCode(
selectedScripts: ReadonlyArray<SelectedScript>, selectedScripts: ReadonlyArray<SelectedScript>,
scriptingDefinition: IScriptingDefinition, scriptingDefinition: IScriptingDefinition): IUserScript;
): IUserScript;
} }

View File

@@ -1,6 +1,6 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { CodePosition } from '../Position/CodePosition'; import { CodePosition } from '../Position/CodePosition';
import { IUserScriptGenerator } from './IUserScriptGenerator'; import { IUserScriptGenerator } from './IUserScriptGenerator';
import { IUserScript } from './IUserScript'; import { IUserScript } from './IUserScript';
@@ -17,6 +17,8 @@ export class UserScriptGenerator implements IUserScriptGenerator {
selectedScripts: ReadonlyArray<SelectedScript>, selectedScripts: ReadonlyArray<SelectedScript>,
scriptingDefinition: IScriptingDefinition, scriptingDefinition: IScriptingDefinition,
): IUserScript { ): IUserScript {
if (!selectedScripts) { throw new Error('missing scripts'); }
if (!scriptingDefinition) { throw new Error('missing definition'); }
if (!selectedScripts.length) { if (!selectedScripts.length) {
return { code: '', scriptPositions: new Map<SelectedScript, ICodePosition>() }; return { code: '', scriptPositions: new Map<SelectedScript, ICodePosition>() };
} }
@@ -66,19 +68,8 @@ function appendSelection(
function appendCode(selection: SelectedScript, builder: ICodeBuilder): ICodeBuilder { function appendCode(selection: SelectedScript, builder: ICodeBuilder): ICodeBuilder {
const { script } = selection; const { script } = selection;
const name = selection.revert ? `${script.name} (revert)` : script.name; const name = selection.revert ? `${script.name} (revert)` : script.name;
const scriptCode = getSelectedCode(selection); const scriptCode = selection.revert ? script.code.revert : script.code.execute;
return builder return builder
.appendLine() .appendLine()
.appendFunction(name, scriptCode); .appendFunction(name, scriptCode);
} }
function getSelectedCode(selection: SelectedScript): string {
const { code } = selection.script;
if (!selection.revert) {
return code.execute;
}
if (!code.revert) {
throw new Error('Reverted script lacks revert code.');
}
return code.revert;
}

View File

@@ -1,37 +1,37 @@
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'; import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { FilterActionType } from './FilterActionType'; import { FilterActionType } from './FilterActionType';
import { import { IFilterChangeDetails, IFilterChangeDetailsVisitor } from './IFilterChangeDetails';
IFilterChangeDetails, IFilterChangeDetailsVisitor,
ApplyFilterAction, ClearFilterAction,
} from './IFilterChangeDetails';
export class FilterChange implements IFilterChangeDetails { export class FilterChange implements IFilterChangeDetails {
public static forApply( public static forApply(filter: IFilterResult) {
filter: IFilterResult, if (!filter) {
): IFilterChangeDetails { throw new Error('missing filter');
return new FilterChange({ type: FilterActionType.Apply, filter }); }
return new FilterChange(FilterActionType.Apply, filter);
} }
public static forClear(): IFilterChangeDetails { public static forClear() {
return new FilterChange({ type: FilterActionType.Clear }); return new FilterChange(FilterActionType.Clear);
} }
private constructor(public readonly action: ApplyFilterAction | ClearFilterAction) { } private constructor(
public readonly actionType: FilterActionType,
public readonly filter?: IFilterResult,
) { }
public visit(visitor: IFilterChangeDetailsVisitor): void { public visit(visitor: IFilterChangeDetailsVisitor): void {
switch (this.action.type) { if (!visitor) {
throw new Error('missing visitor');
}
switch (this.actionType) {
case FilterActionType.Apply: case FilterActionType.Apply:
if (visitor.onApply) { visitor.onApply(this.filter);
visitor.onApply(this.action.filter);
}
break; break;
case FilterActionType.Clear: case FilterActionType.Clear:
if (visitor.onClear) { visitor.onClear();
visitor.onClear();
}
break; break;
default: default:
throw new Error(`Unknown action: ${this.action}`); throw new Error(`Unknown action type: ${this.actionType}`);
} }
} }
} }

View File

@@ -2,22 +2,13 @@ import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult'
import { FilterActionType } from './FilterActionType'; import { FilterActionType } from './FilterActionType';
export interface IFilterChangeDetails { export interface IFilterChangeDetails {
readonly action: FilterAction; readonly actionType: FilterActionType;
readonly filter?: IFilterResult;
visit(visitor: IFilterChangeDetailsVisitor): void; visit(visitor: IFilterChangeDetailsVisitor): void;
} }
export interface IFilterChangeDetailsVisitor { export interface IFilterChangeDetailsVisitor {
readonly onClear?: () => void; onClear(): void;
readonly onApply?: (filter: IFilterResult) => void; onApply(filter: IFilterResult): void;
} }
export type ApplyFilterAction = {
readonly type: FilterActionType.Apply,
readonly filter: IFilterResult;
};
export type ClearFilterAction = {
readonly type: FilterActionType.Clear,
};
export type FilterAction = ApplyFilterAction | ClearFilterAction;

View File

@@ -9,6 +9,8 @@ export class FilterResult implements IFilterResult {
public readonly query: string, public readonly query: string,
) { ) {
if (!query) { throw new Error('Query is empty or undefined'); } if (!query) { throw new Error('Query is empty or undefined'); }
if (!scriptMatches) { throw new Error('Script matches is undefined'); }
if (!categoryMatches) { throw new Error('Category matches is undefined'); }
} }
public hasAnyMatches(): boolean { public hasAnyMatches(): boolean {

View File

@@ -1,18 +1,18 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { IReadOnlyUserFilter, IUserFilter } from './Filter/IUserFilter'; import { IReadOnlyUserFilter, IUserFilter } from './Filter/IUserFilter';
import { ReadonlyUserSelection, UserSelection } from './Selection/UserSelection'; import { IReadOnlyUserSelection, IUserSelection } from './Selection/IUserSelection';
import { IApplicationCode } from './Code/IApplicationCode'; import { IApplicationCode } from './Code/IApplicationCode';
export interface IReadOnlyCategoryCollectionState { export interface IReadOnlyCategoryCollectionState {
readonly code: IApplicationCode; readonly code: IApplicationCode;
readonly os: OperatingSystem; readonly os: OperatingSystem;
readonly filter: IReadOnlyUserFilter; readonly filter: IReadOnlyUserFilter;
readonly selection: ReadonlyUserSelection; readonly selection: IReadOnlyUserSelection;
readonly collection: ICategoryCollection; readonly collection: ICategoryCollection;
} }
export interface ICategoryCollectionState extends IReadOnlyCategoryCollectionState { export interface ICategoryCollectionState extends IReadOnlyCategoryCollectionState {
readonly filter: IUserFilter; readonly filter: IUserFilter;
readonly selection: UserSelection; readonly selection: IUserSelection;
} }

View File

@@ -1,11 +0,0 @@
import { ICategory } from '@/domain/ICategory';
import { CategorySelectionChangeCommand } from './CategorySelectionChange';
export interface ReadonlyCategorySelection {
areAllScriptsSelected(category: ICategory): boolean;
isAnyScriptSelected(category: ICategory): boolean;
}
export interface CategorySelection extends ReadonlyCategorySelection {
processChanges(action: CategorySelectionChangeCommand): void;
}

View File

@@ -1,15 +0,0 @@
type CategorySelectionStatus = {
readonly isSelected: true;
readonly isReverted: boolean;
} | {
readonly isSelected: false;
};
export interface CategorySelectionChange {
readonly categoryId: number;
readonly newStatus: CategorySelectionStatus;
}
export interface CategorySelectionChangeCommand {
readonly changes: readonly CategorySelectionChange[];
}

View File

@@ -1,60 +0,0 @@
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { ScriptSelection } from '../Script/ScriptSelection';
import { ScriptSelectionChange } from '../Script/ScriptSelectionChange';
import { CategorySelection } from './CategorySelection';
import { CategorySelectionChange, CategorySelectionChangeCommand } from './CategorySelectionChange';
export class ScriptToCategorySelectionMapper implements CategorySelection {
constructor(
private readonly scriptSelection: ScriptSelection,
private readonly collection: ICategoryCollection,
) {
}
public areAllScriptsSelected(category: ICategory): boolean {
const { selectedScripts } = this.scriptSelection;
if (selectedScripts.length === 0) {
return false;
}
const scripts = category.getAllScriptsRecursively();
if (selectedScripts.length < scripts.length) {
return false;
}
return scripts.every(
(script) => selectedScripts.some((selected) => selected.id === script.id),
);
}
public isAnyScriptSelected(category: ICategory): boolean {
const { selectedScripts } = this.scriptSelection;
if (selectedScripts.length === 0) {
return false;
}
return selectedScripts.some((s) => category.includes(s.script));
}
public processChanges(action: CategorySelectionChangeCommand): void {
const scriptChanges = action.changes.reduce((changes, change) => {
changes.push(...this.collectScriptChanges(change));
return changes;
}, new Array<ScriptSelectionChange>());
this.scriptSelection.processChanges({
changes: scriptChanges,
});
}
private collectScriptChanges(change: CategorySelectionChange): ScriptSelectionChange[] {
const category = this.collection.getCategory(change.categoryId);
const scripts = category.getAllScriptsRecursively();
const scriptsChangesInCategory = scripts
.map((script): ScriptSelectionChange => ({
scriptId: script.id,
newStatus: {
...change.newStatus,
},
}));
return scriptsChangesInCategory;
}
}

View File

@@ -0,0 +1,23 @@
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { SelectedScript } from './SelectedScript';
export interface IReadOnlyUserSelection {
readonly changed: IEventSource<ReadonlyArray<SelectedScript>>;
readonly selectedScripts: ReadonlyArray<SelectedScript>;
isSelected(scriptId: string): boolean;
areAllSelected(category: ICategory): boolean;
isAnySelected(category: ICategory): boolean;
}
export interface IUserSelection extends IReadOnlyUserSelection {
removeAllInCategory(categoryId: number): void;
addOrUpdateAllInCategory(categoryId: number, revert: boolean): void;
addSelectedScript(scriptId: string, revert: boolean): void;
addOrUpdateSelectedScript(scriptId: string, revert: boolean): void;
removeSelectedScript(scriptId: string): void;
selectOnly(scripts: ReadonlyArray<IScript>): void;
selectAll(): void;
deselectAll(): void;
}

View File

@@ -1,171 +0,0 @@
import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository';
import { IScript } from '@/domain/IScript';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { ReadonlyRepository, Repository } from '@/application/Repository/Repository';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { batchedDebounce } from '@/application/Common/Timing/BatchedDebounce';
import { ScriptSelection } from './ScriptSelection';
import { ScriptSelectionChange, ScriptSelectionChangeCommand } from './ScriptSelectionChange';
import { SelectedScript } from './SelectedScript';
import { UserSelectedScript } from './UserSelectedScript';
const DEBOUNCE_DELAY_IN_MS = 100;
export type DebounceFunction = typeof batchedDebounce<ScriptSelectionChangeCommand>;
export class DebouncedScriptSelection implements ScriptSelection {
public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
private readonly scripts: Repository<string, SelectedScript>;
public readonly processChanges: ScriptSelection['processChanges'];
constructor(
private readonly collection: ICategoryCollection,
selectedScripts: ReadonlyArray<SelectedScript>,
debounce: DebounceFunction = batchedDebounce,
) {
this.scripts = new InMemoryRepository<string, SelectedScript>();
for (const script of selectedScripts) {
this.scripts.addItem(script);
}
this.processChanges = debounce(
(batchedRequests: readonly ScriptSelectionChangeCommand[]) => {
const consolidatedChanges = batchedRequests.flatMap((request) => request.changes);
this.processScriptChanges(consolidatedChanges);
},
DEBOUNCE_DELAY_IN_MS,
);
}
public isSelected(scriptId: string): boolean {
return this.scripts.exists(scriptId);
}
public get selectedScripts(): readonly SelectedScript[] {
return this.scripts.getItems();
}
public selectAll(): void {
const scriptsToSelect = this.collection
.getAllScripts()
.filter((script) => !this.scripts.exists(script.id))
.map((script) => new UserSelectedScript(script, false));
if (scriptsToSelect.length === 0) {
return;
}
this.processChanges({
changes: scriptsToSelect.map((script): ScriptSelectionChange => ({
scriptId: script.id,
newStatus: {
isSelected: true,
isReverted: false,
},
})),
});
}
public deselectAll(): void {
if (this.scripts.length === 0) {
return;
}
const selectedScriptIds = this.scripts.getItems().map((script) => script.id);
this.processChanges({
changes: selectedScriptIds.map((scriptId): ScriptSelectionChange => ({
scriptId,
newStatus: {
isSelected: false,
},
})),
});
}
public selectOnly(scripts: readonly IScript[]): void {
if (scripts.length === 0) {
throw new Error('Provided script array is empty. To deselect all scripts, please use the deselectAll() method instead.');
}
this.processChanges({
changes: [
...getScriptIdsToBeDeselected(this.scripts, scripts)
.map((scriptId): ScriptSelectionChange => ({
scriptId,
newStatus: {
isSelected: false,
},
})),
...getScriptIdsToBeSelected(this.scripts, scripts)
.map((scriptId): ScriptSelectionChange => ({
scriptId,
newStatus: {
isSelected: true,
isReverted: false,
},
})),
],
});
}
private processScriptChanges(changes: readonly ScriptSelectionChange[]): void {
let totalChanged = 0;
for (const change of changes) {
totalChanged += this.applyChange(change);
}
if (totalChanged > 0) {
this.changed.notify(this.scripts.getItems());
}
}
private applyChange(change: ScriptSelectionChange): number {
const script = this.collection.getScript(change.scriptId);
if (change.newStatus.isSelected) {
return this.addOrUpdateScript(script.id, change.newStatus.isReverted);
}
return this.removeScript(script.id);
}
private addOrUpdateScript(scriptId: string, revert: boolean): number {
const script = this.collection.getScript(scriptId);
const selectedScript = new UserSelectedScript(script, revert);
if (!this.scripts.exists(selectedScript.id)) {
this.scripts.addItem(selectedScript);
return 1;
}
const existingSelectedScript = this.scripts.getById(selectedScript.id);
if (equals(selectedScript, existingSelectedScript)) {
return 0;
}
this.scripts.addOrUpdateItem(selectedScript);
return 1;
}
private removeScript(scriptId: string): number {
if (!this.scripts.exists(scriptId)) {
return 0;
}
this.scripts.removeItem(scriptId);
return 1;
}
}
function getScriptIdsToBeSelected(
existingItems: ReadonlyRepository<string, SelectedScript>,
desiredScripts: readonly IScript[],
): string[] {
return desiredScripts
.filter((script) => !existingItems.exists(script.id))
.map((script) => script.id);
}
function getScriptIdsToBeDeselected(
existingItems: ReadonlyRepository<string, SelectedScript>,
desiredScripts: readonly IScript[],
): string[] {
return existingItems
.getItems()
.filter((existing) => !desiredScripts.some((script) => existing.id === script.id))
.map((script) => script.id);
}
function equals(a: SelectedScript, b: SelectedScript): boolean {
return a.script.equals(b.script.id) && a.revert === b.revert;
}

View File

@@ -1,17 +0,0 @@
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { IScript } from '@/domain/IScript';
import { SelectedScript } from './SelectedScript';
import { ScriptSelectionChangeCommand } from './ScriptSelectionChange';
export interface ReadonlyScriptSelection {
readonly changed: IEventSource<readonly SelectedScript[]>;
readonly selectedScripts: readonly SelectedScript[];
isSelected(scriptId: string): boolean;
}
export interface ScriptSelection extends ReadonlyScriptSelection {
selectOnly(scripts: readonly IScript[]): void;
selectAll(): void;
deselectAll(): void;
processChanges(action: ScriptSelectionChangeCommand): void;
}

View File

@@ -1,15 +0,0 @@
export type ScriptSelectionStatus = {
readonly isSelected: true;
readonly isReverted: boolean;
} | {
readonly isSelected: false;
};
export interface ScriptSelectionChange {
readonly scriptId: string;
readonly newStatus: ScriptSelectionStatus;
}
export interface ScriptSelectionChangeCommand {
readonly changes: ReadonlyArray<ScriptSelectionChange>;
}

View File

@@ -1,9 +0,0 @@
import { IEntity } from '@/infrastructure/Entity/IEntity';
import { IScript } from '@/domain/IScript';
type ScriptId = IScript['id'];
export interface SelectedScript extends IEntity<ScriptId> {
readonly script: IScript;
readonly revert: boolean;
}

View File

@@ -1,17 +1,14 @@
import { BaseEntity } from '@/infrastructure/Entity/BaseEntity'; import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { SelectedScript } from './SelectedScript';
type SelectedScriptId = SelectedScript['id']; export class SelectedScript extends BaseEntity<string> {
export class UserSelectedScript extends BaseEntity<SelectedScriptId> {
constructor( constructor(
public readonly script: IScript, public readonly script: IScript,
public readonly revert: boolean, public readonly revert: boolean,
) { ) {
super(script.id); super(script.id);
if (revert && !script.canRevert()) { if (revert && !script.canRevert()) {
throw new Error(`The script with ID '${script.id}' is not reversible and cannot be reverted.`); throw new Error('cannot revert an irreversible script');
} }
} }
} }

View File

@@ -1,12 +1,167 @@
import { CategorySelection, ReadonlyCategorySelection } from './Category/CategorySelection'; import { InMemoryRepository } from '@/infrastructure/Repository/InMemoryRepository';
import { ReadonlyScriptSelection, ScriptSelection } from './Script/ScriptSelection'; import { IScript } from '@/domain/IScript';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { IRepository } from '@/infrastructure/Repository/IRepository';
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { IUserSelection } from './IUserSelection';
import { SelectedScript } from './SelectedScript';
export interface ReadonlyUserSelection { export class UserSelection implements IUserSelection {
readonly categories: ReadonlyCategorySelection; public readonly changed = new EventSource<ReadonlyArray<SelectedScript>>();
readonly scripts: ReadonlyScriptSelection;
}
export interface UserSelection extends ReadonlyUserSelection { private readonly scripts: IRepository<string, SelectedScript>;
readonly categories: CategorySelection;
readonly scripts: ScriptSelection; constructor(
private readonly collection: ICategoryCollection,
selectedScripts: ReadonlyArray<SelectedScript>,
) {
this.scripts = new InMemoryRepository<string, SelectedScript>();
for (const script of selectedScripts) {
this.scripts.addItem(script);
}
}
public areAllSelected(category: ICategory): boolean {
if (this.selectedScripts.length === 0) {
return false;
}
const scripts = category.getAllScriptsRecursively();
if (this.selectedScripts.length < scripts.length) {
return false;
}
return scripts.every(
(script) => this.selectedScripts.some((selected) => selected.id === script.id),
);
}
public isAnySelected(category: ICategory): boolean {
if (this.selectedScripts.length === 0) {
return false;
}
return this.selectedScripts.some((s) => category.includes(s.script));
}
public removeAllInCategory(categoryId: number): void {
const category = this.collection.findCategory(categoryId);
const scriptsToRemove = category.getAllScriptsRecursively()
.filter((script) => this.scripts.exists(script.id));
if (!scriptsToRemove.length) {
return;
}
for (const script of scriptsToRemove) {
this.scripts.removeItem(script.id);
}
this.changed.notify(this.scripts.getItems());
}
public addOrUpdateAllInCategory(categoryId: number, revert = false): void {
const scriptsToAddOrUpdate = this.collection
.findCategory(categoryId)
.getAllScriptsRecursively()
.filter(
(script) => !this.scripts.exists(script.id)
|| this.scripts.getById(script.id).revert !== revert,
)
.map((script) => new SelectedScript(script, revert));
if (!scriptsToAddOrUpdate.length) {
return;
}
for (const script of scriptsToAddOrUpdate) {
this.scripts.addOrUpdateItem(script);
}
this.changed.notify(this.scripts.getItems());
}
public addSelectedScript(scriptId: string, revert: boolean): void {
const script = this.collection.findScript(scriptId);
if (!script) {
throw new Error(`Cannot add (id: ${scriptId}) as it is unknown`);
}
const selectedScript = new SelectedScript(script, revert);
this.scripts.addItem(selectedScript);
this.changed.notify(this.scripts.getItems());
}
public addOrUpdateSelectedScript(scriptId: string, revert: boolean): void {
const script = this.collection.findScript(scriptId);
const selectedScript = new SelectedScript(script, revert);
this.scripts.addOrUpdateItem(selectedScript);
this.changed.notify(this.scripts.getItems());
}
public removeSelectedScript(scriptId: string): void {
this.scripts.removeItem(scriptId);
this.changed.notify(this.scripts.getItems());
}
public isSelected(scriptId: string): boolean {
return this.scripts.exists(scriptId);
}
/** Get users scripts based on his/her selections */
public get selectedScripts(): ReadonlyArray<SelectedScript> {
return this.scripts.getItems();
}
public selectAll(): void {
const scriptsToSelect = this.collection
.getAllScripts()
.filter((script) => !this.scripts.exists(script.id))
.map((script) => new SelectedScript(script, false));
if (scriptsToSelect.length === 0) {
return;
}
for (const script of scriptsToSelect) {
this.scripts.addItem(script);
}
this.changed.notify(this.scripts.getItems());
}
public deselectAll(): void {
if (this.scripts.length === 0) {
return;
}
const selectedScriptIds = this.scripts.getItems().map((script) => script.id);
for (const scriptId of selectedScriptIds) {
this.scripts.removeItem(scriptId);
}
this.changed.notify([]);
}
public selectOnly(scripts: readonly IScript[]): void {
if (!scripts || scripts.length === 0) {
throw new Error('Scripts are empty. Use deselectAll() if you want to deselect everything');
}
let totalChanged = 0;
totalChanged += this.unselectMissingWithoutNotifying(scripts);
totalChanged += this.selectNewWithoutNotifying(scripts);
if (totalChanged > 0) {
this.changed.notify(this.scripts.getItems());
}
}
private unselectMissingWithoutNotifying(scripts: readonly IScript[]): number {
if (this.scripts.length === 0 || scripts.length === 0) {
return 0;
}
const existingItems = this.scripts.getItems();
const missingIds = existingItems
.filter((existing) => !scripts.some((script) => existing.id === script.id))
.map((script) => script.id);
for (const id of missingIds) {
this.scripts.removeItem(id);
}
return missingIds.length;
}
private selectNewWithoutNotifying(scripts: readonly IScript[]): number {
const unselectedScripts = scripts
.filter((script) => !this.scripts.exists(script.id))
.map((script) => new SelectedScript(script, false));
for (const newScript of unselectedScripts) {
this.scripts.addItem(newScript);
}
return unselectedScripts.length;
}
} }

View File

@@ -1,39 +0,0 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { CategorySelection } from './Category/CategorySelection';
import { ScriptToCategorySelectionMapper } from './Category/ScriptToCategorySelectionMapper';
import { DebouncedScriptSelection } from './Script/DebouncedScriptSelection';
import { ScriptSelection } from './Script/ScriptSelection';
import { UserSelection } from './UserSelection';
import { SelectedScript } from './Script/SelectedScript';
export class UserSelectionFacade implements UserSelection {
public readonly categories: CategorySelection;
public readonly scripts: ScriptSelection;
constructor(
collection: ICategoryCollection,
selectedScripts: readonly SelectedScript[],
scriptsFactory = DefaultScriptsFactory,
categoriesFactory = DefaultCategoriesFactory,
) {
this.scripts = scriptsFactory(collection, selectedScripts);
this.categories = categoriesFactory(this.scripts, collection);
}
}
export type ScriptsFactory = (
...params: ConstructorParameters<typeof DebouncedScriptSelection>
) => ScriptSelection;
const DefaultScriptsFactory: ScriptsFactory = (
...params
) => new DebouncedScriptSelection(...params);
export type CategoriesFactory = (
...params: ConstructorParameters<typeof ScriptToCategorySelectionMapper>
) => CategorySelection;
const DefaultCategoriesFactory: CategoriesFactory = (
...params
) => new ScriptToCategorySelectionMapper(...params);

View File

@@ -32,7 +32,10 @@ const PreParsedCollections: readonly CollectionData [] = [
]; ];
function validateCollectionsData(collections: readonly CollectionData[]) { function validateCollectionsData(collections: readonly CollectionData[]) {
if (!collections.length) { if (!collections?.length) {
throw new Error('missing collections'); throw new Error('missing collections');
} }
if (collections.some((collection) => !collection)) {
throw new Error('missing collection provided');
}
} }

View File

@@ -28,7 +28,10 @@ export function parseCategoryCollection(
} }
function validate(content: CollectionData): void { function validate(content: CollectionData): void {
if (!content.actions.length) { if (!content) {
throw new Error('missing content');
}
if (!content.actions || content.actions.length <= 0) {
throw new Error('content does not define any action'); throw new Error('content does not define any action');
} }
} }

View File

@@ -1,5 +1,5 @@
import type { import type {
CategoryData, ScriptData, CategoryOrScriptData, CategoryData, ScriptData, CategoryOrScriptData, InstructionHolder,
} from '@/application/collections/'; } from '@/application/collections/';
import { Script } from '@/domain/Script'; import { Script } from '@/domain/Script';
import { Category } from '@/domain/Category'; import { Category } from '@/domain/Category';
@@ -16,6 +16,7 @@ export function parseCategory(
context: ICategoryCollectionParseContext, context: ICategoryCollectionParseContext,
factory: CategoryFactoryType = CategoryFactory, factory: CategoryFactoryType = CategoryFactory,
): Category { ): Category {
if (!context) { throw new Error('missing context'); }
return parseCategoryRecursively({ return parseCategoryRecursively({
categoryData: category, categoryData: category,
context, context,
@@ -29,8 +30,8 @@ interface ICategoryParseContext {
readonly factory: CategoryFactoryType, readonly factory: CategoryFactoryType,
readonly parentCategory?: CategoryData, readonly parentCategory?: CategoryData,
} }
// eslint-disable-next-line consistent-return
function parseCategoryRecursively(context: ICategoryParseContext): Category | never { function parseCategoryRecursively(context: ICategoryParseContext): Category {
ensureValidCategory(context.categoryData, context.parentCategory); ensureValidCategory(context.categoryData, context.parentCategory);
const children: ICategoryChildren = { const children: ICategoryChildren = {
subCategories: new Array<Category>(), subCategories: new Array<Category>(),
@@ -54,7 +55,7 @@ function parseCategoryRecursively(context: ICategoryParseContext): Category | ne
/* scripts: */ children.subScripts, /* scripts: */ children.subScripts,
); );
} catch (err) { } catch (err) {
return new NodeValidator({ new NodeValidator({
type: NodeType.Category, type: NodeType.Category,
selfNode: context.categoryData, selfNode: context.categoryData,
parentNode: context.parentCategory, parentNode: context.parentCategory,
@@ -71,7 +72,7 @@ function ensureValidCategory(category: CategoryData, parentCategory?: CategoryDa
.assertDefined(category) .assertDefined(category)
.assertValidName(category.category) .assertValidName(category.category)
.assert( .assert(
() => category.children.length > 0, () => category.children && category.children.length > 0,
`"${category.category}" has no children.`, `"${category.category}" has no children.`,
); );
} }
@@ -93,14 +94,14 @@ function parseNode(context: INodeParseContext) {
validator.assertDefined(context.nodeData); validator.assertDefined(context.nodeData);
if (isCategory(context.nodeData)) { if (isCategory(context.nodeData)) {
const subCategory = parseCategoryRecursively({ const subCategory = parseCategoryRecursively({
categoryData: context.nodeData, categoryData: context.nodeData as CategoryData,
context: context.context, context: context.context,
factory: context.factory, factory: context.factory,
parentCategory: context.parent, parentCategory: context.parent,
}); });
context.children.subCategories.push(subCategory); context.children.subCategories.push(subCategory);
} else if (isScript(context.nodeData)) { } else if (isScript(context.nodeData)) {
const script = parseScript(context.nodeData, context.context); const script = parseScript(context.nodeData as ScriptData, context.context);
context.children.subScripts.push(script); context.children.subScripts.push(script);
} else { } else {
validator.throw('Node is neither a category or a script.'); validator.throw('Node is neither a category or a script.');
@@ -108,18 +109,19 @@ function parseNode(context: INodeParseContext) {
} }
function isScript(data: CategoryOrScriptData): data is ScriptData { function isScript(data: CategoryOrScriptData): data is ScriptData {
return hasCode(data) || hasCall(data); const holder = (data as InstructionHolder);
return hasCode(holder) || hasCall(holder);
} }
function isCategory(data: CategoryOrScriptData): data is CategoryData { function isCategory(data: CategoryOrScriptData): data is CategoryData {
return hasProperty(data, 'category'); return hasProperty(data, 'category');
} }
function hasCode(data: unknown): boolean { function hasCode(data: InstructionHolder): boolean {
return hasProperty(data, 'code'); return hasProperty(data, 'code');
} }
function hasCall(data: unknown) { function hasCall(data: InstructionHolder) {
return hasProperty(data, 'call'); return hasProperty(data, 'call');
} }

View File

@@ -1,6 +1,9 @@
import type { DocumentableData, DocumentationData } from '@/application/collections/'; import type { DocumentableData, DocumentationData } from '@/application/collections/';
export function parseDocs(documentable: DocumentableData): readonly string[] { export function parseDocs(documentable: DocumentableData): readonly string[] {
if (!documentable) {
throw new Error('missing documentable');
}
const { docs } = documentable; const { docs } = documentable;
if (!docs) { if (!docs) {
return []; return [];

View File

@@ -32,7 +32,7 @@ export class NodeValidator {
return this; return this;
} }
public throw(errorMessage: string): never { public throw(errorMessage: string) {
throw new NodeDataError(errorMessage, this.context); throw new NodeDataError(errorMessage, this.context);
} }
} }

View File

@@ -17,7 +17,8 @@ export class CategoryCollectionParseContext implements ICategoryCollectionParseC
scripting: IScriptingDefinition, scripting: IScriptingDefinition,
syntaxFactory: ISyntaxFactory = new SyntaxFactory(), syntaxFactory: ISyntaxFactory = new SyntaxFactory(),
) { ) {
if (!scripting) { throw new Error('missing scripting'); }
this.syntax = syntaxFactory.create(scripting.language); this.syntax = syntaxFactory.create(scripting.language);
this.compiler = new ScriptCompiler(functionsData ?? [], this.syntax); this.compiler = new ScriptCompiler(functionsData, this.syntax);
} }
} }

View File

@@ -15,10 +15,19 @@ export class Expression implements IExpression {
public readonly evaluator: ExpressionEvaluator, public readonly evaluator: ExpressionEvaluator,
parameters?: IReadOnlyFunctionParameterCollection, parameters?: IReadOnlyFunctionParameterCollection,
) { ) {
if (!position) {
throw new Error('missing position');
}
if (!evaluator) {
throw new Error('missing evaluator');
}
this.parameters = parameters ?? new FunctionParameterCollection(); this.parameters = parameters ?? new FunctionParameterCollection();
} }
public evaluate(context: IExpressionEvaluationContext): string { public evaluate(context: IExpressionEvaluationContext): string {
if (!context) {
throw new Error('missing context');
}
validateThatAllRequiredParametersAreSatisfied(this.parameters, context.args); validateThatAllRequiredParametersAreSatisfied(this.parameters, context.args);
const args = filterUnusedArguments(this.parameters, context.args); const args = filterUnusedArguments(this.parameters, context.args);
const filteredContext = new ExpressionEvaluationContext(args, context.pipelineCompiler); const filteredContext = new ExpressionEvaluationContext(args, context.pipelineCompiler);

View File

@@ -12,5 +12,8 @@ export class ExpressionEvaluationContext implements IExpressionEvaluationContext
public readonly args: IReadOnlyFunctionCallArgumentCollection, public readonly args: IReadOnlyFunctionCallArgumentCollection,
public readonly pipelineCompiler: IPipelineCompiler = new PipelineCompiler(), public readonly pipelineCompiler: IPipelineCompiler = new PipelineCompiler(),
) { ) {
if (!args) {
throw new Error('missing args, send empty collection instead.');
}
} }
} }

View File

@@ -1,16 +0,0 @@
import { ExpressionPosition } from './ExpressionPosition';
export function createPositionFromRegexFullMatch(
match: RegExpMatchArray,
): ExpressionPosition {
const startPos = match.index;
if (startPos === undefined) {
throw new Error(`Regex match did not yield any results: ${JSON.stringify(match)}`);
}
const fullMatch = match[0];
if (!fullMatch.length) {
throw new Error(`Regex match is empty: ${JSON.stringify(match)}`);
}
const endPos = startPos + fullMatch.length;
return new ExpressionPosition(startPos, endPos);
}

View File

@@ -11,11 +11,14 @@ export class ExpressionsCompiler implements IExpressionsCompiler {
) { } ) { }
public compileExpressions( public compileExpressions(
code: string, code: string | undefined,
args: IReadOnlyFunctionCallArgumentCollection, args: IReadOnlyFunctionCallArgumentCollection,
): string { ): string {
if (!args) {
throw new Error('missing args, send empty collection instead.');
}
if (!code) { if (!code) {
return ''; return code;
} }
const context = new ExpressionEvaluationContext(args); const context = new ExpressionEvaluationContext(args);
const compiledCode = compileRecursively(code, context, this.extractor); const compiledCode = compileRecursively(code, context, this.extractor);
@@ -142,7 +145,7 @@ function ensureParamsUsedInCodeHasArgsProvided(
providedArgs: IReadOnlyFunctionCallArgumentCollection, providedArgs: IReadOnlyFunctionCallArgumentCollection,
): void { ): void {
const usedParameterNames = extractRequiredParameterNames(expressions); const usedParameterNames = extractRequiredParameterNames(expressions);
if (!usedParameterNames.length) { if (!usedParameterNames?.length) {
return; return;
} }
const notProvidedParameters = usedParameterNames const notProvidedParameters = usedParameterNames

View File

@@ -2,7 +2,6 @@ import { IReadOnlyFunctionCallArgumentCollection } from '../Function/Call/Argume
export interface IExpressionsCompiler { export interface IExpressionsCompiler {
compileExpressions( compileExpressions(
code: string, code: string | undefined,
args: IReadOnlyFunctionCallArgumentCollection, args: IReadOnlyFunctionCallArgumentCollection): string;
): string;
} }

View File

@@ -10,9 +10,12 @@ const Parsers = [
export class CompositeExpressionParser implements IExpressionParser { export class CompositeExpressionParser implements IExpressionParser {
public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) { public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) {
if (!leafs.length) { if (!leafs) {
throw new Error('missing leafs'); throw new Error('missing leafs');
} }
if (leafs.some((leaf) => !leaf)) {
throw new Error('missing leaf');
}
} }
public findExpressions(code: string): IExpression[] { public findExpressions(code: string): IExpression[] {

View File

@@ -14,44 +14,45 @@ export class ExpressionRegexBuilder {
.addRawRegex('\\s+'); .addRawRegex('\\s+');
} }
public captureOptionalPipeline() { public matchPipeline() {
return this return this
.addRawRegex('((?:\\|\\s*\\b[a-zA-Z]+\\b\\s*)*)'); .expectZeroOrMoreWhitespaces()
.addRawRegex('(\\|\\s*.+?)?');
} }
public captureUntilWhitespaceOrPipe() { public matchUntilFirstWhitespace() {
return this return this
.addRawRegex('([^|\\s]+)'); .addRawRegex('([^|\\s]+)');
} }
public captureMultilineAnythingExceptSurroundingWhitespaces() { public matchMultilineAnythingExceptSurroundingWhitespaces() {
return this return this
.expectOptionalWhitespaces() .expectZeroOrMoreWhitespaces()
.addRawRegex('([\\s\\S]*\\S)') .addRawRegex('([\\S\\s]+?)')
.expectOptionalWhitespaces(); .expectZeroOrMoreWhitespaces();
} }
public expectExpressionStart() { public expectExpressionStart() {
return this return this
.expectCharacters('{{') .expectCharacters('{{')
.expectOptionalWhitespaces(); .expectZeroOrMoreWhitespaces();
} }
public expectExpressionEnd() { public expectExpressionEnd() {
return this return this
.expectOptionalWhitespaces() .expectZeroOrMoreWhitespaces()
.expectCharacters('}}'); .expectCharacters('}}');
} }
public expectOptionalWhitespaces() {
return this
.addRawRegex('\\s*');
}
public buildRegExp(): RegExp { public buildRegExp(): RegExp {
return new RegExp(this.parts.join(''), 'g'); return new RegExp(this.parts.join(''), 'g');
} }
private expectZeroOrMoreWhitespaces() {
return this
.addRawRegex('\\s*');
}
private addRawRegex(regex: string) { private addRawRegex(regex: string) {
this.parts.push(regex); this.parts.push(regex);
return this; return this;

View File

@@ -1,9 +1,9 @@
import { IExpressionParser } from '../IExpressionParser'; import { IExpressionParser } from '../IExpressionParser';
import { ExpressionPosition } from '../../Expression/ExpressionPosition';
import { IExpression } from '../../Expression/IExpression'; import { IExpression } from '../../Expression/IExpression';
import { Expression, ExpressionEvaluator } from '../../Expression/Expression'; import { Expression, ExpressionEvaluator } from '../../Expression/Expression';
import { IFunctionParameter } from '../../../Function/Parameter/IFunctionParameter'; import { IFunctionParameter } from '../../../Function/Parameter/IFunctionParameter';
import { FunctionParameterCollection } from '../../../Function/Parameter/FunctionParameterCollection'; import { FunctionParameterCollection } from '../../../Function/Parameter/FunctionParameterCollection';
import { createPositionFromRegexFullMatch } from '../../Expression/ExpressionPositionFactory';
export abstract class RegexParser implements IExpressionParser { export abstract class RegexParser implements IExpressionParser {
protected abstract readonly regex: RegExp; protected abstract readonly regex: RegExp;
@@ -21,7 +21,7 @@ export abstract class RegexParser implements IExpressionParser {
const matches = code.matchAll(this.regex); const matches = code.matchAll(this.regex);
for (const match of matches) { for (const match of matches) {
const primitiveExpression = this.buildExpression(match); const primitiveExpression = this.buildExpression(match);
const position = this.doOrRethrow(() => createPositionFromRegexFullMatch(match), 'invalid script position', code); const position = this.doOrRethrow(() => createPosition(match), 'invalid script position', code);
const parameters = createParameters(primitiveExpression); const parameters = createParameters(primitiveExpression);
const expression = new Expression(position, primitiveExpression.evaluator, parameters); const expression = new Expression(position, primitiveExpression.evaluator, parameters);
yield expression; yield expression;
@@ -37,6 +37,12 @@ export abstract class RegexParser implements IExpressionParser {
} }
} }
function createPosition(match: RegExpMatchArray): ExpressionPosition {
const startPos = match.index;
const endPos = startPos + match[0].length;
return new ExpressionPosition(startPos, endPos);
}
function createParameters( function createParameters(
expression: IPrimitiveExpression, expression: IPrimitiveExpression,
): FunctionParameterCollection { ): FunctionParameterCollection {

View File

@@ -28,7 +28,7 @@ function hasLines(text: string) {
*/ */
function inlineComments(code: string): string { function inlineComments(code: string): string {
const makeInlineComment = (comment: string) => { const makeInlineComment = (comment: string) => {
const value = comment.trim(); const value = comment?.trim();
if (!value) { if (!value) {
return '<##>'; return '<##>';
} }

View File

@@ -15,6 +15,12 @@ export class PipeFactory implements IPipeFactory {
private readonly pipes = new Map<string, IPipe>(); private readonly pipes = new Map<string, IPipe>();
constructor(pipes: readonly IPipe[] = RegisteredPipes) { constructor(pipes: readonly IPipe[] = RegisteredPipes) {
if (!pipes) {
throw new Error('missing pipes');
}
if (pipes.some((pipe) => !pipe)) {
throw new Error('missing pipe in list');
}
for (const pipe of pipes) { for (const pipe of pipes) {
this.registerPipe(pipe); this.registerPipe(pipe);
} }
@@ -22,11 +28,10 @@ export class PipeFactory implements IPipeFactory {
public get(pipeName: string): IPipe { public get(pipeName: string): IPipe {
validatePipeName(pipeName); validatePipeName(pipeName);
const pipe = this.pipes.get(pipeName); if (!this.pipes.has(pipeName)) {
if (!pipe) {
throw new Error(`Unknown pipe: "${pipeName}"`); throw new Error(`Unknown pipe: "${pipeName}"`);
} }
return pipe; return this.pipes.get(pipeName);
} }
private registerPipe(pipe: IPipe): void { private registerPipe(pipe: IPipe): void {

View File

@@ -6,9 +6,8 @@ export class ParameterSubstitutionParser extends RegexParser {
protected readonly regex = new ExpressionRegexBuilder() protected readonly regex = new ExpressionRegexBuilder()
.expectExpressionStart() .expectExpressionStart()
.expectCharacters('$') .expectCharacters('$')
.captureUntilWhitespaceOrPipe() // First capture: Parameter name .matchUntilFirstWhitespace() // First match: Parameter name
.expectOptionalWhitespaces() .matchPipeline() // Second match: Pipeline
.captureOptionalPipeline() // Second capture: Pipeline
.expectExpressionEnd() .expectExpressionEnd()
.buildRegExp(); .buildRegExp();

View File

@@ -1,220 +1,59 @@
// eslint-disable-next-line max-classes-per-file
import { IExpressionParser } from '@/application/Parser/Script/Compiler/Expressions/Parser/IExpressionParser';
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection';
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter'; import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { IExpression } from '../Expression/IExpression'; import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser';
import { ExpressionPosition } from '../Expression/ExpressionPosition';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder'; import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
import { createPositionFromRegexFullMatch } from '../Expression/ExpressionPositionFactory';
export class WithParser implements IExpressionParser { export class WithParser extends RegexParser {
public findExpressions(code: string): IExpression[] { protected readonly regex = new ExpressionRegexBuilder()
if (!code) { // {{ with $parameterName }}
throw new Error('missing code'); .expectExpressionStart()
} .expectCharacters('with')
return parseWithExpressions(code); .expectOneOrMoreWhitespaces()
} .expectCharacters('$')
} .matchUntilFirstWhitespace() // First match: parameter name
.expectExpressionEnd()
// ...
.matchMultilineAnythingExceptSurroundingWhitespaces() // Second match: Scope text
// {{ end }}
.expectExpressionStart()
.expectCharacters('end')
.expectExpressionEnd()
.buildRegExp();
enum WithStatementType { protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
Start, const parameterName = match[1];
End, const scopeText = match[2];
ContextVariable,
}
type WithStatement = {
readonly type: WithStatementType.Start;
readonly parameterName: string;
readonly position: ExpressionPosition;
} | {
readonly type: WithStatementType.End;
readonly position: ExpressionPosition;
} | {
readonly type: WithStatementType.ContextVariable;
readonly position: ExpressionPosition;
readonly pipeline: string | undefined;
};
function parseAllWithExpressions(
input: string,
): WithStatement[] {
const expressions = new Array<WithStatement>();
for (const match of input.matchAll(WithStatementStartRegEx)) {
expressions.push({
type: WithStatementType.Start,
parameterName: match[1],
position: createPositionFromRegexFullMatch(match),
});
}
for (const match of input.matchAll(WithStatementEndRegEx)) {
expressions.push({
type: WithStatementType.End,
position: createPositionFromRegexFullMatch(match),
});
}
for (const match of input.matchAll(ContextVariableWithPipelineRegEx)) {
expressions.push({
type: WithStatementType.ContextVariable,
position: createPositionFromRegexFullMatch(match),
pipeline: match[1],
});
}
return expressions;
}
class WithStatementBuilder {
private readonly contextVariables = new Array<{
readonly positionInScope: ExpressionPosition;
readonly pipeline: string | undefined;
}>();
public addContextVariable(
absolutePosition: ExpressionPosition,
pipeline: string | undefined,
): void {
const positionInScope = new ExpressionPosition(
absolutePosition.start - this.startExpressionPosition.end,
absolutePosition.end - this.startExpressionPosition.end,
);
this.contextVariables.push({
positionInScope,
pipeline,
});
}
public buildExpression(endExpressionPosition: ExpressionPosition, input: string): IExpression {
const parameters = new FunctionParameterCollection();
parameters.addParameter(new FunctionParameter(this.parameterName, true));
const position = new ExpressionPosition(
this.startExpressionPosition.start,
endExpressionPosition.end,
);
const scope = input.substring(this.startExpressionPosition.end, endExpressionPosition.start);
return { return {
parameters, parameters: [new FunctionParameter(parameterName, true)],
position, evaluator: (context) => {
evaluate: (context) => { const argumentValue = context.args.hasArgument(parameterName)
const argumentValue = context.args.hasArgument(this.parameterName) ? context.args.getArgument(parameterName).argumentValue
? context.args.getArgument(this.parameterName).argumentValue
: undefined; : undefined;
if (!argumentValue) { if (!argumentValue) {
return ''; return '';
} }
const substitutedScope = this.substituteContextVariables(scope, (pipeline) => { return replaceEachScopeSubstitution(scopeText, (pipeline) => {
if (!pipeline) { if (!pipeline) {
return argumentValue; return argumentValue;
} }
return context.pipelineCompiler.compile(argumentValue, pipeline); return context.pipelineCompiler.compile(argumentValue, pipeline);
}); });
return substitutedScope;
}, },
}; };
} }
constructor(
private readonly startExpressionPosition: ExpressionPosition,
private readonly parameterName: string,
) {
}
private substituteContextVariables(
scope: string,
substituter: (pipeline?: string) => string,
): string {
if (!this.contextVariables.length) {
return scope;
}
let substitutedScope = '';
let scopeSubstrIndex = 0;
for (const contextVariable of this.contextVariables) {
substitutedScope += scope.substring(scopeSubstrIndex, contextVariable.positionInScope.start);
substitutedScope += substituter(contextVariable.pipeline);
scopeSubstrIndex = contextVariable.positionInScope.end;
}
substitutedScope += scope.substring(scopeSubstrIndex, scope.length);
return substitutedScope;
}
} }
function buildErrorContext(code: string, statements: readonly WithStatement[]): string { const ScopeSubstitutionRegEx = new ExpressionRegexBuilder()
const formattedStatements = statements.map((s) => `- [${s.position.start}, ${s.position.end}] ${WithStatementType[s.type]}`).join('\n');
return [
'Code:', '---', code, '---',
'nStatements:', '---', formattedStatements, '---',
].join('\n');
}
function parseWithExpressions(input: string): IExpression[] {
const allStatements = parseAllWithExpressions(input);
const sortedStatements = allStatements
.slice()
.sort((a, b) => b.position.start - a.position.start);
const expressions = new Array<IExpression>();
const builders = new Array<WithStatementBuilder>();
const throwWithContext = (message: string): never => {
throw new Error(`${message}\n${buildErrorContext(input, allStatements)}}`);
};
while (sortedStatements.length > 0) {
const statement = sortedStatements.pop();
if (!statement) {
break;
}
switch (statement.type) { // eslint-disable-line default-case
case WithStatementType.Start:
builders.push(new WithStatementBuilder(
statement.position,
statement.parameterName,
));
break;
case WithStatementType.ContextVariable:
if (builders.length === 0) {
throwWithContext('Context variable before `with` statement.');
}
builders[builders.length - 1].addContextVariable(statement.position, statement.pipeline);
break;
case WithStatementType.End: {
const builder = builders.pop();
if (!builder) {
throwWithContext('Redundant `end` statement, missing `with`?');
break;
}
expressions.push(builder.buildExpression(statement.position, input));
break;
}
}
}
if (builders.length > 0) {
throwWithContext('Missing `end` statement, forgot `{{ end }}?');
}
return expressions;
}
const ContextVariableWithPipelineRegEx = new ExpressionRegexBuilder()
// {{ . | pipeName }} // {{ . | pipeName }}
.expectExpressionStart() .expectExpressionStart()
.expectCharacters('.') .expectCharacters('.')
.expectOptionalWhitespaces() .matchPipeline() // First match: pipeline
.captureOptionalPipeline() // First capture: pipeline
.expectExpressionEnd() .expectExpressionEnd()
.buildRegExp(); .buildRegExp();
const WithStatementStartRegEx = new ExpressionRegexBuilder() function replaceEachScopeSubstitution(scopeText: string, replacer: (pipeline: string) => string) {
// {{ with $parameterName }} // Not using /{{\s*.\s*(?:(\|\s*[^{}]*?)\s*)?}}/g for not matching brackets,
.expectExpressionStart() // but instead letting the pipeline compiler to fail on those.
.expectCharacters('with') return scopeText.replaceAll(ScopeSubstitutionRegEx, (_$, match1) => {
.expectOneOrMoreWhitespaces() return replacer(match1);
.expectCharacters('$') });
.captureUntilWhitespaceOrPipe() // First capture: parameter name }
.expectExpressionEnd()
.expectOptionalWhitespaces()
.buildRegExp();
const WithStatementEndRegEx = new ExpressionRegexBuilder()
// {{ end }}
.expectOptionalWhitespaces()
.expectExpressionStart()
.expectCharacters('end')
.expectOptionalWhitespaces()
.expectExpressionEnd()
.buildRegExp();

View File

@@ -5,6 +5,9 @@ export class FunctionCallArgumentCollection implements IFunctionCallArgumentColl
private readonly arguments = new Map<string, IFunctionCallArgument>(); private readonly arguments = new Map<string, IFunctionCallArgument>();
public addArgument(argument: IFunctionCallArgument): void { public addArgument(argument: IFunctionCallArgument): void {
if (!argument) {
throw new Error('missing argument');
}
if (this.hasArgument(argument.parameterName)) { if (this.hasArgument(argument.parameterName)) {
throw new Error(`argument value for parameter ${argument.parameterName} is already provided`); throw new Error(`argument value for parameter ${argument.parameterName} is already provided`);
} }

View File

@@ -3,22 +3,18 @@ import { CodeSegmentMerger } from './CodeSegmentMerger';
export class NewlineCodeSegmentMerger implements CodeSegmentMerger { export class NewlineCodeSegmentMerger implements CodeSegmentMerger {
public mergeCodeParts(codeSegments: readonly CompiledCode[]): CompiledCode { public mergeCodeParts(codeSegments: readonly CompiledCode[]): CompiledCode {
if (!codeSegments.length) { if (!codeSegments?.length) {
throw new Error('missing segments'); throw new Error('missing segments');
} }
return { return {
code: joinCodeParts(codeSegments.map((f) => f.code)), code: joinCodeParts(codeSegments.map((f) => f.code)),
revertCode: joinCodeParts( revertCode: joinCodeParts(codeSegments.map((f) => f.revertCode)),
codeSegments
.map((f) => f.revertCode)
.filter((code): code is string => Boolean(code)),
),
}; };
} }
} }
function joinCodeParts(codeSegments: readonly string[]): string { function joinCodeParts(codeSegments: readonly string[]): string {
return codeSegments return codeSegments
.filter((segment) => segment.length > 0) .filter((segment) => segment?.length > 0)
.join('\n'); .join('\n');
} }

View File

@@ -21,7 +21,9 @@ export class FunctionCallSequenceCompiler implements FunctionCallCompiler {
calls: readonly FunctionCall[], calls: readonly FunctionCall[],
functions: ISharedFunctionCollection, functions: ISharedFunctionCollection,
): CompiledCode { ): CompiledCode {
if (!calls.length) { throw new Error('missing calls'); } if (!functions) { throw new Error('missing functions'); }
if (!calls?.length) { throw new Error('missing calls'); }
if (calls.some((f) => !f)) { throw new Error('missing function call'); }
const context: FunctionCallCompilationContext = { const context: FunctionCallCompilationContext = {
allFunctions: functions, allFunctions: functions,
rootCallSequence: calls, rootCallSequence: calls,

View File

@@ -1,6 +1,6 @@
import { ExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler'; import { ExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/ExpressionsCompiler';
import { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler'; import { IExpressionsCompiler } from '@/application/Parser/Script/Compiler/Expressions/IExpressionsCompiler';
import { FunctionBodyType, ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; import { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
import { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall'; import { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode'; import { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
import { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy'; import { SingleCallCompilerStrategy } from '../SingleCallCompilerStrategy';
@@ -12,33 +12,19 @@ export class InlineFunctionCallCompiler implements SingleCallCompilerStrategy {
} }
public canCompile(func: ISharedFunction): boolean { public canCompile(func: ISharedFunction): boolean {
return func.body.type === FunctionBodyType.Code; return func.body.code !== undefined;
} }
public compileFunction( public compileFunction(
calledFunction: ISharedFunction, calledFunction: ISharedFunction,
callToFunction: FunctionCall, callToFunction: FunctionCall,
): CompiledCode[] { ): CompiledCode[] {
if (calledFunction.body.type !== FunctionBodyType.Code) {
throw new Error([
'Unexpected function body type.',
`\tExpected: "${FunctionBodyType[FunctionBodyType.Code]}"`,
`\tActual: "${FunctionBodyType[calledFunction.body.type]}"`,
'Function:',
`\t${JSON.stringify(callToFunction)}`,
].join('\n'));
}
const { code } = calledFunction.body; const { code } = calledFunction.body;
const { args } = callToFunction; const { args } = callToFunction;
return [ return [
{ {
code: this.expressionsCompiler.compileExpressions(code.execute, args), code: this.expressionsCompiler.compileExpressions(code.execute, args),
revertCode: (() => { revertCode: this.expressionsCompiler.compileExpressions(code.revert, args),
if (!code.revert) {
return undefined;
}
return this.expressionsCompiler.compileExpressions(code.revert, args);
})(),
}, },
]; ];
} }

View File

@@ -1,4 +1,4 @@
import { CallFunctionBody, FunctionBodyType, ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction'; import { ISharedFunction } from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
import { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall'; import { FunctionCall } from '@/application/Parser/Script/Compiler/Function/Call/FunctionCall';
import { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext'; import { FunctionCallCompilationContext } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/FunctionCallCompilationContext';
import { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode'; import { CompiledCode } from '@/application/Parser/Script/Compiler/Function/Call/Compiler/CompiledCode';
@@ -13,7 +13,7 @@ export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy {
} }
public canCompile(func: ISharedFunction): boolean { public canCompile(func: ISharedFunction): boolean {
return func.body.type === FunctionBodyType.Calls; return func.body.calls !== undefined;
} }
public compileFunction( public compileFunction(
@@ -21,7 +21,7 @@ export class NestedFunctionCallCompiler implements SingleCallCompilerStrategy {
callToFunction: FunctionCall, callToFunction: FunctionCall,
context: FunctionCallCompilationContext, context: FunctionCallCompilationContext,
): CompiledCode[] { ): CompiledCode[] {
const nestedCalls = (calledFunction.body as CallFunctionBody).calls; const nestedCalls = calledFunction.body.calls;
return nestedCalls.map((nestedCall) => { return nestedCalls.map((nestedCall) => {
try { try {
const compiledParentCall = this.argumentCompiler const compiledParentCall = this.argumentCompiler

View File

@@ -5,6 +5,9 @@ import { FunctionCallArgument } from './Argument/FunctionCallArgument';
import { ParsedFunctionCall } from './ParsedFunctionCall'; import { ParsedFunctionCall } from './ParsedFunctionCall';
export function parseFunctionCalls(calls: FunctionCallsData): FunctionCall[] { export function parseFunctionCalls(calls: FunctionCallsData): FunctionCall[] {
if (calls === undefined) {
throw new Error('missing call data');
}
const sequence = getCallSequence(calls); const sequence = getCallSequence(calls);
return sequence.map((call) => parseFunctionCall(call)); return sequence.map((call) => parseFunctionCall(call));
} }
@@ -16,21 +19,22 @@ function getCallSequence(calls: FunctionCallsData): FunctionCallData[] {
if (calls instanceof Array) { if (calls instanceof Array) {
return calls as FunctionCallData[]; return calls as FunctionCallData[];
} }
const singleCall = calls; return [calls as FunctionCallData];
return [singleCall];
} }
function parseFunctionCall(call: FunctionCallData): FunctionCall { function parseFunctionCall(call: FunctionCallData): FunctionCall {
if (!call) {
throw new Error('missing call data');
}
const callArgs = parseArgs(call.parameters); const callArgs = parseArgs(call.parameters);
return new ParsedFunctionCall(call.function, callArgs); return new ParsedFunctionCall(call.function, callArgs);
} }
function parseArgs( function parseArgs(
parameters: FunctionCallParametersData | undefined, parameters: FunctionCallParametersData,
): FunctionCallArgumentCollection { ): FunctionCallArgumentCollection {
const parametersMap = parameters ?? {}; return Object.keys(parameters || {})
return Object.keys(parametersMap) .map((parameterName) => new FunctionCallArgument(parameterName, parameters[parameterName]))
.map((parameterName) => new FunctionCallArgument(parameterName, parametersMap[parameterName]))
.reduce((args, arg) => { .reduce((args, arg) => {
args.addArgument(arg); args.addArgument(arg);
return args; return args;

View File

@@ -9,5 +9,8 @@ export class ParsedFunctionCall implements FunctionCall {
if (!functionName) { if (!functionName) {
throw new Error('missing function name in function call'); throw new Error('missing function name in function call');
} }
if (!args) {
throw new Error('missing args');
}
} }
} }

View File

@@ -4,21 +4,15 @@ import { FunctionCall } from './Call/FunctionCall';
export interface ISharedFunction { export interface ISharedFunction {
readonly name: string; readonly name: string;
readonly parameters: IReadOnlyFunctionParameterCollection; readonly parameters: IReadOnlyFunctionParameterCollection;
readonly body: SharedFunctionBody; readonly body: ISharedFunctionBody;
} }
export interface CallFunctionBody { export interface ISharedFunctionBody {
readonly type: FunctionBodyType.Calls, readonly type: FunctionBodyType;
readonly calls: readonly FunctionCall[], readonly code: IFunctionCode | undefined;
readonly calls: readonly FunctionCall[] | undefined;
} }
export interface CodeFunctionBody {
readonly type: FunctionBodyType.Code;
readonly code: IFunctionCode,
}
export type SharedFunctionBody = CallFunctionBody | CodeFunctionBody;
export enum FunctionBodyType { export enum FunctionBodyType {
Code, Code,
Calls, Calls,

View File

@@ -18,6 +18,9 @@ export class FunctionParameterCollection implements IFunctionParameterCollection
} }
private ensureValidParameter(parameter: IFunctionParameter) { private ensureValidParameter(parameter: IFunctionParameter) {
if (!parameter) {
throw new Error('missing parameter');
}
if (this.includesName(parameter.name)) { if (this.includesName(parameter.name)) {
throw new Error(`duplicate parameter name: "${parameter.name}"`); throw new Error(`duplicate parameter name: "${parameter.name}"`);
} }

View File

@@ -1,7 +1,7 @@
import { FunctionCall } from './Call/FunctionCall'; import { FunctionCall } from './Call/FunctionCall';
import { import {
FunctionBodyType, IFunctionCode, ISharedFunction, SharedFunctionBody, FunctionBodyType, IFunctionCode, ISharedFunction, ISharedFunctionBody,
} from './ISharedFunction'; } from './ISharedFunction';
import { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection'; import { IReadOnlyFunctionParameterCollection } from './Parameter/IFunctionParameterCollection';
@@ -10,7 +10,7 @@ export function createCallerFunction(
parameters: IReadOnlyFunctionParameterCollection, parameters: IReadOnlyFunctionParameterCollection,
callSequence: readonly FunctionCall[], callSequence: readonly FunctionCall[],
): ISharedFunction { ): ISharedFunction {
if (!callSequence.length) { if (!callSequence || !callSequence.length) {
throw new Error(`missing call sequence in function "${name}"`); throw new Error(`missing call sequence in function "${name}"`);
} }
return new SharedFunction(name, parameters, callSequence, FunctionBodyType.Calls); return new SharedFunction(name, parameters, callSequence, FunctionBodyType.Calls);
@@ -33,7 +33,7 @@ export function createFunctionWithInlineCode(
} }
class SharedFunction implements ISharedFunction { class SharedFunction implements ISharedFunction {
public readonly body: SharedFunctionBody; public readonly body: ISharedFunctionBody;
constructor( constructor(
public readonly name: string, public readonly name: string,
@@ -42,22 +42,11 @@ class SharedFunction implements ISharedFunction {
bodyType: FunctionBodyType, bodyType: FunctionBodyType,
) { ) {
if (!name) { throw new Error('missing function name'); } if (!name) { throw new Error('missing function name'); }
if (!parameters) { throw new Error('missing parameters'); }
switch (bodyType) { this.body = {
case FunctionBodyType.Code: type: bodyType,
this.body = { code: bodyType === FunctionBodyType.Code ? content as IFunctionCode : undefined,
type: FunctionBodyType.Code, calls: bodyType === FunctionBodyType.Calls ? content as readonly FunctionCall[] : undefined,
code: content as IFunctionCode, };
};
break;
case FunctionBodyType.Calls:
this.body = {
type: FunctionBodyType.Calls,
calls: content as readonly FunctionCall[],
};
break;
default:
throw new Error(`unknown body type: ${FunctionBodyType[bodyType]}`);
}
} }
} }

View File

@@ -5,6 +5,7 @@ export class SharedFunctionCollection implements ISharedFunctionCollection {
private readonly functionsByName = new Map<string, ISharedFunction>(); private readonly functionsByName = new Map<string, ISharedFunction>();
public addFunction(func: ISharedFunction): void { public addFunction(func: ISharedFunction): void {
if (!func) { throw new Error('missing function'); }
if (this.has(func.name)) { if (this.has(func.name)) {
throw new Error(`function with name ${func.name} already exists`); throw new Error(`function with name ${func.name} already exists`);
} }

View File

@@ -1,6 +1,4 @@
import type { import type { FunctionData, InstructionHolder } from '@/application/collections/';
FunctionData, CodeInstruction, CodeFunctionData, CallFunctionData, CallInstruction,
} from '@/application/collections/';
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator'; import { CodeValidator } from '@/application/Parser/Script/Validation/CodeValidator';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
@@ -25,8 +23,9 @@ export class SharedFunctionsParser implements ISharedFunctionsParser {
functions: readonly FunctionData[], functions: readonly FunctionData[],
syntax: ILanguageSyntax, syntax: ILanguageSyntax,
): ISharedFunctionCollection { ): ISharedFunctionCollection {
if (!syntax) { throw new Error('missing syntax'); }
const collection = new SharedFunctionCollection(); const collection = new SharedFunctionCollection();
if (!functions.length) { if (!functions || !functions.length) {
return collection; return collection;
} }
ensureValidFunctions(functions); ensureValidFunctions(functions);
@@ -56,18 +55,16 @@ function parseFunction(
} }
function validateCode( function validateCode(
data: CodeFunctionData, data: FunctionData,
syntax: ILanguageSyntax, syntax: ILanguageSyntax,
validator: ICodeValidator, validator: ICodeValidator,
): void { ): void {
[data.code, data.revertCode] [data.code, data.revertCode].forEach(
.filter((code): code is string => Boolean(code)) (code) => validator.throwIfInvalid(
.forEach( code,
(code) => validator.throwIfInvalid( [new NoEmptyLines(), new NoDuplicatedLines(syntax)],
code, ),
[new NoEmptyLines(), new NoDuplicatedLines(syntax)], );
),
);
} }
function parseParameters(data: FunctionData): IReadOnlyFunctionParameterCollection { function parseParameters(data: FunctionData): IReadOnlyFunctionParameterCollection {
@@ -88,18 +85,19 @@ function parseParameters(data: FunctionData): IReadOnlyFunctionParameterCollecti
}, new FunctionParameterCollection()); }, new FunctionParameterCollection());
} }
function hasCode(data: FunctionData): data is CodeFunctionData { function hasCode(data: FunctionData): boolean {
return (data as CodeInstruction).code !== undefined; return Boolean(data.code);
} }
function hasCall(data: FunctionData): data is CallFunctionData { function hasCall(data: FunctionData): boolean {
return (data as CallInstruction).call !== undefined; return Boolean(data.call);
} }
function ensureValidFunctions(functions: readonly FunctionData[]) { function ensureValidFunctions(functions: readonly FunctionData[]) {
ensureNoUndefinedItem(functions);
ensureNoDuplicatesInFunctionNames(functions); ensureNoDuplicatesInFunctionNames(functions);
ensureEitherCallOrCodeIsDefined(functions);
ensureNoDuplicateCode(functions); ensureNoDuplicateCode(functions);
ensureEitherCallOrCodeIsDefined(functions);
ensureExpectedParametersType(functions); ensureExpectedParametersType(functions);
} }
@@ -107,7 +105,7 @@ function printList(list: readonly string[]): string {
return `"${list.join('","')}"`; return `"${list.join('","')}"`;
} }
function ensureEitherCallOrCodeIsDefined(holders: readonly FunctionData[]) { function ensureEitherCallOrCodeIsDefined(holders: readonly InstructionHolder[]) {
// Ensure functions do not define both call and code // Ensure functions do not define both call and code
const withBothCallAndCode = holders.filter((holder) => hasCode(holder) && hasCall(holder)); const withBothCallAndCode = holders.filter((holder) => hasCode(holder) && hasCall(holder));
if (withBothCallAndCode.length) { if (withBothCallAndCode.length) {
@@ -134,7 +132,7 @@ function isArrayOfObjects(value: unknown): boolean {
&& value.every((item) => typeof item === 'object'); && value.every((item) => typeof item === 'object');
} }
function printNames(holders: readonly FunctionData[]) { function printNames(holders: readonly InstructionHolder[]) {
return printList(holders.map((holder) => holder.name)); return printList(holders.map((holder) => holder.name));
} }
@@ -146,19 +144,22 @@ function ensureNoDuplicatesInFunctionNames(functions: readonly FunctionData[]) {
} }
} }
function ensureNoUndefinedItem(functions: readonly FunctionData[]) {
if (functions.some((func) => !func)) {
throw new Error('some functions are undefined');
}
}
function ensureNoDuplicateCode(functions: readonly FunctionData[]) { function ensureNoDuplicateCode(functions: readonly FunctionData[]) {
const callFunctions = functions const duplicateCodes = getDuplicates(functions
.filter((func) => hasCode(func))
.map((func) => func as CodeFunctionData);
const duplicateCodes = getDuplicates(callFunctions
.map((func) => func.code) .map((func) => func.code)
.filter((code) => code)); .filter((code) => code));
if (duplicateCodes.length > 0) { if (duplicateCodes.length > 0) {
throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`); throw new Error(`duplicate "code" in functions: ${printList(duplicateCodes)}`);
} }
const duplicateRevertCodes = getDuplicates(callFunctions const duplicateRevertCodes = getDuplicates(functions
.map((func) => func.revertCode) .filter((func) => func.revertCode)
.filter((code): code is string => Boolean(code))); .map((func) => func.revertCode));
if (duplicateRevertCodes.length > 0) { if (duplicateRevertCodes.length > 0) {
throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`); throw new Error(`duplicate "revertCode" in functions: ${printList(duplicateRevertCodes)}`);
} }

View File

@@ -1,4 +1,4 @@
import type { FunctionData, ScriptData, CallInstruction } from '@/application/collections/'; import type { FunctionData, ScriptData } from '@/application/collections/';
import { IScriptCode } from '@/domain/IScriptCode'; import { IScriptCode } from '@/domain/IScriptCode';
import { ScriptCode } from '@/domain/ScriptCode'; import { ScriptCode } from '@/domain/ScriptCode';
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
@@ -18,24 +18,27 @@ export class ScriptCompiler implements IScriptCompiler {
private readonly functions: ISharedFunctionCollection; private readonly functions: ISharedFunctionCollection;
constructor( constructor(
functions: readonly FunctionData[], functions: readonly FunctionData[] | undefined,
syntax: ILanguageSyntax, syntax: ILanguageSyntax,
sharedFunctionsParser: ISharedFunctionsParser = SharedFunctionsParser.instance, sharedFunctionsParser: ISharedFunctionsParser = SharedFunctionsParser.instance,
private readonly callCompiler: FunctionCallCompiler = FunctionCallSequenceCompiler.instance, private readonly callCompiler: FunctionCallCompiler = FunctionCallSequenceCompiler.instance,
private readonly codeValidator: ICodeValidator = CodeValidator.instance, private readonly codeValidator: ICodeValidator = CodeValidator.instance,
) { ) {
if (!syntax) { throw new Error('missing syntax'); }
this.functions = sharedFunctionsParser.parseFunctions(functions, syntax); this.functions = sharedFunctionsParser.parseFunctions(functions, syntax);
} }
public canCompile(script: ScriptData): boolean { public canCompile(script: ScriptData): boolean {
return hasCall(script); if (!script) { throw new Error('missing script'); }
if (!script.call) {
return false;
}
return true;
} }
public compile(script: ScriptData): IScriptCode { public compile(script: ScriptData): IScriptCode {
if (!script) { throw new Error('missing script'); }
try { try {
if (!hasCall(script)) {
throw new Error('Script does include any calls.');
}
const calls = parseFunctionCalls(script.call); const calls = parseFunctionCalls(script.call);
const compiledCode = this.callCompiler.compileFunctionCalls(calls, this.functions); const compiledCode = this.callCompiler.compileFunctionCalls(calls, this.functions);
validateCompiledCode(compiledCode, this.codeValidator); validateCompiledCode(compiledCode, this.codeValidator);
@@ -50,17 +53,7 @@ export class ScriptCompiler implements IScriptCompiler {
} }
function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void { function validateCompiledCode(compiledCode: CompiledCode, validator: ICodeValidator): void {
[compiledCode.code, compiledCode.revertCode] [compiledCode.code, compiledCode.revertCode].forEach(
.filter((code): code is string => Boolean(code)) (code) => validator.throwIfInvalid(code, [new NoEmptyLines()]),
.map((code) => code as string) );
.forEach(
(code) => validator.throwIfInvalid(
code,
[new NoEmptyLines()],
),
);
}
function hasCall(data: ScriptData): data is ScriptData & CallInstruction {
return (data as CallInstruction).call !== undefined;
} }

View File

@@ -1,4 +1,4 @@
import type { ScriptData, CodeScriptData, CallScriptData } from '@/application/collections/'; import type { ScriptData } from '@/application/collections/';
import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines'; import { NoEmptyLines } from '@/application/Parser/Script/Validation/Rules/NoEmptyLines';
import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax'; import { ILanguageSyntax } from '@/application/Parser/Script/Validation/Syntax/ILanguageSyntax';
import { Script } from '@/domain/Script'; import { Script } from '@/domain/Script';
@@ -14,6 +14,7 @@ import { ICategoryCollectionParseContext } from './ICategoryCollectionParseConte
import { CodeValidator } from './Validation/CodeValidator'; import { CodeValidator } from './Validation/CodeValidator';
import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines'; import { NoDuplicatedLines } from './Validation/Rules/NoDuplicatedLines';
// eslint-disable-next-line consistent-return
export function parseScript( export function parseScript(
data: ScriptData, data: ScriptData,
context: ICategoryCollectionParseContext, context: ICategoryCollectionParseContext,
@@ -23,6 +24,7 @@ export function parseScript(
): Script { ): Script {
const validator = new NodeValidator({ type: NodeType.Script, selfNode: data }); const validator = new NodeValidator({ type: NodeType.Script, selfNode: data });
validateScript(data, validator); validateScript(data, validator);
if (!context) { throw new Error('missing context'); }
try { try {
const script = scriptFactory( const script = scriptFactory(
/* name: */ data.name, /* name: */ data.name,
@@ -32,12 +34,12 @@ export function parseScript(
); );
return script; return script;
} catch (err) { } catch (err) {
return validator.throw(err.message); validator.throw(err.message);
} }
} }
function parseLevel( function parseLevel(
level: string | undefined, level: string,
parser: IEnumParser<RecommendationLevel>, parser: IEnumParser<RecommendationLevel>,
): RecommendationLevel | undefined { ): RecommendationLevel | undefined {
if (!level) { if (!level) {
@@ -54,45 +56,39 @@ function parseCode(
if (context.compiler.canCompile(script)) { if (context.compiler.canCompile(script)) {
return context.compiler.compile(script); return context.compiler.compile(script);
} }
const codeScript = script as CodeScriptData; // Must be inline code if it cannot be compiled const code = new ScriptCode(script.code, script.revertCode);
const code = new ScriptCode(codeScript.code, codeScript.revertCode);
validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax); validateHardcodedCodeWithoutCalls(code, codeValidator, context.syntax);
return code; return code;
} }
function validateHardcodedCodeWithoutCalls( function validateHardcodedCodeWithoutCalls(
scriptCode: ScriptCode, scriptCode: ScriptCode,
validator: ICodeValidator, codeValidator: ICodeValidator,
syntax: ILanguageSyntax, syntax: ILanguageSyntax,
) { ) {
[scriptCode.execute, scriptCode.revert] [scriptCode.execute, scriptCode.revert].forEach(
.filter((code): code is string => Boolean(code)) (code) => codeValidator.throwIfInvalid(
.forEach( code,
(code) => validator.throwIfInvalid( [new NoEmptyLines(), new NoDuplicatedLines(syntax)],
code, ),
[new NoEmptyLines(), new NoDuplicatedLines(syntax)], );
),
);
} }
function validateScript( function validateScript(script: ScriptData, validator: NodeValidator) {
script: ScriptData,
validator: NodeValidator,
): asserts script is NonNullable<ScriptData> {
validator validator
.assertDefined(script) .assertDefined(script)
.assertValidName(script.name) .assertValidName(script.name)
.assert( .assert(
() => Boolean((script as CodeScriptData).code || (script as CallScriptData).call), () => Boolean(script.code || script.call),
'Neither "call" or "code" is defined.', 'Must define either "call" or "code".',
) )
.assert( .assert(
() => !((script as CodeScriptData).code && (script as CallScriptData).call), () => !(script.code && script.call),
'Both "call" and "code" are defined.', 'Cannot define both "call" and "code".',
) )
.assert( .assert(
() => !((script as CodeScriptData).revertCode && (script as CallScriptData).call), () => !(script.revertCode && script.call),
'Both "call" and "revertCode" are defined.', 'Cannot define "revertCode" if "call" is defined.',
); );
} }

View File

@@ -9,7 +9,7 @@ export class CodeValidator implements ICodeValidator {
code: string, code: string,
rules: readonly ICodeValidationRule[], rules: readonly ICodeValidationRule[],
): void { ): void {
if (rules.length === 0) { throw new Error('missing rules'); } if (!rules || rules.length === 0) { throw new Error('missing rules'); }
if (!code) { if (!code) {
return; return;
} }

View File

@@ -3,7 +3,9 @@ import { ICodeLine } from '../ICodeLine';
import { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule'; import { ICodeValidationRule, IInvalidCodeLine } from '../ICodeValidationRule';
export class NoDuplicatedLines implements ICodeValidationRule { export class NoDuplicatedLines implements ICodeValidationRule {
constructor(private readonly syntax: ILanguageSyntax) { } constructor(private readonly syntax: ILanguageSyntax) {
if (!syntax) { throw new Error('missing syntax'); }
}
public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] { public analyze(lines: readonly ICodeLine[]): IInvalidCodeLine[] {
return lines return lines

View File

@@ -17,6 +17,7 @@ export class CodeSubstituter implements ICodeSubstituter {
public substitute(code: string, info: IProjectInformation): string { public substitute(code: string, info: IProjectInformation): string {
if (!code) { throw new Error('missing code'); } if (!code) { throw new Error('missing code'); }
if (!info) { throw new Error('missing info'); }
const args = new FunctionCallArgumentCollection(); const args = new FunctionCallArgumentCollection();
const substitute = (name: string, value: string) => args const substitute = (name: string, value: string) => args
.addArgument(new FunctionCallArgument(name, value)); .addArgument(new FunctionCallArgument(name, value));

View File

@@ -18,6 +18,8 @@ export class ScriptingDefinitionParser {
definition: ScriptingDefinitionData, definition: ScriptingDefinitionData,
info: IProjectInformation, info: IProjectInformation,
): IScriptingDefinition { ): IScriptingDefinition {
if (!info) { throw new Error('missing info'); }
if (!definition) { throw new Error('missing definition'); }
const language = this.languageParser.parseEnum(definition.language, 'language'); const language = this.languageParser.parseEnum(definition.language, 'language');
const startCode = this.codeSubstituter.substitute(definition.startCode, info); const startCode = this.codeSubstituter.substitute(definition.startCode, info);
const endCode = this.codeSubstituter.substitute(definition.endCode, info); const endCode = this.codeSubstituter.substitute(definition.endCode, info);

View File

@@ -1,17 +0,0 @@
import { IEntity } from '@/infrastructure/Entity/IEntity';
export interface ReadonlyRepository<TKey, TEntity extends IEntity<TKey>> {
readonly length: number;
getItems(predicate?: (entity: TEntity) => boolean): readonly TEntity[];
getById(id: TKey): TEntity;
exists(id: TKey): boolean;
}
export interface MutableRepository<TKey, TEntity extends IEntity<TKey>> {
addItem(item: TEntity): void;
addOrUpdateItem(item: TEntity): void;
removeItem(id: TKey): void;
}
export interface Repository<TKey, TEntity extends IEntity<TKey>>
extends ReadonlyRepository<TKey, TEntity>, MutableRepository<TKey, TEntity> { }

View File

@@ -12,38 +12,29 @@ declare module '@/application/collections/*' {
} }
export type CategoryOrScriptData = CategoryData | ScriptData; export type CategoryOrScriptData = CategoryData | ScriptData;
export type DocumentationData = ReadonlyArray<string> | string | undefined; export type DocumentationData = ReadonlyArray<string> | string;
export interface DocumentableData { export interface DocumentableData {
readonly docs?: DocumentationData; readonly docs?: DocumentationData;
} }
export interface CodeInstruction { export interface InstructionHolder {
readonly code: string; readonly name: string;
readonly code?: string;
readonly revertCode?: string; readonly revertCode?: string;
}
export interface CallInstruction { readonly call?: FunctionCallsData;
readonly call: FunctionCallsData;
} }
export type InstructionHolder = CodeInstruction | CallInstruction;
export interface ParameterDefinitionData { export interface ParameterDefinitionData {
readonly name: string; readonly name: string;
readonly optional?: boolean; readonly optional?: boolean;
} }
export type FunctionDefinition = { export interface FunctionData extends InstructionHolder {
readonly name: string;
readonly parameters?: readonly ParameterDefinitionData[]; readonly parameters?: readonly ParameterDefinitionData[];
}; }
export type CodeFunctionData = FunctionDefinition & CodeInstruction;
export type CallFunctionData = FunctionDefinition & CallInstruction;
export type FunctionData = CodeFunctionData | CallFunctionData;
export interface FunctionCallParametersData { export interface FunctionCallParametersData {
readonly [index: string]: string; readonly [index: string]: string;
@@ -56,16 +47,10 @@ declare module '@/application/collections/*' {
export type FunctionCallsData = readonly FunctionCallData[] | FunctionCallData | undefined; export type FunctionCallsData = readonly FunctionCallData[] | FunctionCallData | undefined;
export type ScriptDefinition = DocumentableData & { export interface ScriptData extends InstructionHolder, DocumentableData {
readonly name: string; readonly name: string;
readonly recommend?: string; readonly recommend?: string;
}; }
export type CodeScriptData = ScriptDefinition & CodeInstruction;
export type CallScriptData = ScriptDefinition & CallInstruction;
export type ScriptData = CodeScriptData | CallScriptData;
export interface ScriptingDefinitionData { export interface ScriptingDefinitionData {
readonly language: string; readonly language: string;

View File

@@ -707,9 +707,15 @@ actions:
- -
category: Clear Firefox history category: Clear Firefox history
docs: |- docs: |-
This category encompasses a series of scripts aimed at helping users manage and delete their browsing history and related data in Mozilla Firefox. Mozilla Firefox, or simply Firefox, is a free and open-source web browser developed by the Mozilla Foundation and
its subsidiary the Mozilla Corporation [1].
The scripts are designed to target different aspects of user data stored by Firefox, providing users options for maintaining privacy and freeing up disk space. Firefox stores user-related data in user profiles [2].
See also [the Firefox homepage](https://web.archive.org/web/20221029214632/https://www.mozilla.org/en-US/firefox/).
[1]: https://web.archive.org/web/20221029145113/https://en.wikipedia.org/wiki/Firefox "Firefox | Wikipedia | en.wikipedia.org"
[2]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
children: children:
- -
name: Clear Firefox cache name: Clear Firefox cache
@@ -749,13 +755,9 @@ actions:
# Snap installation # Snap installation
rm -rfv ~/snap/firefox/common/.mozilla/firefox/Crash\ Reports/* rm -rfv ~/snap/firefox/common/.mozilla/firefox/Crash\ Reports/*
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: crashes/* path: crashes/
-
function: DeleteFilesFromFirefoxProfiles
parameters:
pathGlob: crashes/events/*
- -
name: Clear Firefox cookies name: Clear Firefox cookies
docs: |- docs: |-
@@ -763,37 +765,41 @@ actions:
[1]: https://web.archive.org/web/20221029140816/https://kb.mozillazine.org/Cookies.sqlite "Cookies.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org" [1]: https://web.archive.org/web/20221029140816/https://kb.mozillazine.org/Cookies.sqlite "Cookies.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
call: call:
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: cookies.sqlite path: cookies.sqlite
- -
name: Clear Firefox browsing history (URLs, downloads, bookmarks, visits, etc.) name: Clear Firefox browsing history (URLs, downloads, bookmarks, visits, etc.)
# This script (name, documentation and code) is same in Linux and Windows collections.
# Changes should be done at both places.
# Marked: refactor-with-partials
docs: |- docs: |-
This script targets the Firefox browsing history, including URLs, downloads, bookmarks, and site visits, by deleting specific database entries. The file "places.sqlite" stores the annotations, bookmarks, favorite icons, input history, keywords, and browsing history (a record of visited pages) [1].
The tables include [1]:
- `moz_anno_attributes`: Annotation Attributes
- `moz_annos`: Annotations
- `moz_bookmarks`: Bookmarks
- `moz_bookmarks_roots`: Bookmark roots i.e. places, menu, toolbar, tags, unfiled
- `moz_favicons`: Favorite icons - including URL of icon
- `moz_historyvisits`: A history of the number of times a site has been visited
- `moz_inputhistory`: A history of URLs typed by the user
- `moz_items_annos`: Item annotations
- `moz_keywords`: Keywords
- `moz_places`: Places/Sites visited - referenced by `moz_historyvisits`
URL data is stored in the `moz_places` table. However, this table is connected to `moz_annos`, `moz_bookmarks`, and `moz_inputhistory` and `moz_historyvisits`.
As these entries are connected to each other, we'll delete all of them at the same time [2].
Firefox stores various user data in a file named `places.sqlite`. This file includes: **Bookmarks**:
Firefox bookmarks are stored in tables such as `moz_bookmarks`, `moz_bookmarks_folders`, `moz_bookmarks_roots` [3].
There are also not very well documented tables, such as `moz_bookmarks_deleted` [4].
- Annotations, bookmarks, and favorite icons (`moz_anno_attributes`, `moz_annos`, `moz_favicons`) [1] **Downloads:**
- Browsing history, a record of pages visited (`moz_places`, `moz_historyvisits`) [1] Firefox downloads are stored in the 'places.sqlite' database, within the 'moz_annos' table [5].
- Keywords and typed URLs (`moz_keywords`, `moz_inputhistory`) [1] The entries in `moz_annos` are linked to `moz_places` that store the actual history entry (`moz_places.id = moz_annos.place_id`) [6].
- Item annotations (`moz_items_annos`) [1] Associated URL information is stored within the 'moz_places' table [5].
- Bookmark roots such as places, menu, toolbar, tags, unfiled (`moz_bookmarks_roots`) [1] Downloads have been historically stored in `downloads.rdf` for Firefox 2.x and below [7].
Starting with Firefox 3.x they're stored in `downloads.sqlite` [7].
The `moz_places` table holds URL data, connecting to various other tables like `moz_annos`, `moz_bookmarks`, `moz_inputhistory`, and `moz_historyvisits` [2]. **Favicons:**
Due to these connections, the script removes entries from all relevant tables simultaneously to maintain database integrity. Firefox favicons are stored in the `favicons.sqlite` database, within the `moz_icons` table [5].
Older versions of Firefox stored Favicons in the 'places.sqlite' database, within the `moz_favicons` table [5].
**Bookmarks**: Stored across several tables (`moz_bookmarks`, `moz_bookmarks_folders`, `moz_bookmarks_roots`) [3], with additional undocumented tables like `moz_bookmarks_deleted` [4].
**Downloads**: Stored in the 'places.sqlite' database, within the 'moz_annos' table [5]. The entries in `moz_annos` are linked to `moz_places` that store the actual history entry
(`moz_places.id = moz_annos.place_id`) [6]. Associated URL information is stored within the 'moz_places' table [5]. Downloads have been historically stored in `downloads.rdf` for Firefox 2.x
and below [7], and `downloads.sqlite` later on [7].
**Favicons**: Older Firefox versions stored favicons in `places.sqlite` within the `moz_favicons` table [5], while newer versions use `favicons.sqlite` and the `moz_icons` table [5].
By executing this script, users can ensure their Firefox browsing history, bookmarks, and downloads are thoroughly removed, contributing to a cleaner and more private browsing experience.
[1]: https://web.archive.org/web/20221029141626/https://kb.mozillazine.org/Places.sqlite "Places.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org" [1]: https://web.archive.org/web/20221029141626/https://kb.mozillazine.org/Places.sqlite "Places.sqlite - MozillaZine Knowledge Base | kb.mozillazine.org"
[2]: https://web.archive.org/web/20221030160803/https://wiki.mozilla.org/images/0/08/Places.sqlite.schema.pdf "Places.sqlite.schema.pdf | Mozilla Wiki" [2]: https://web.archive.org/web/20221030160803/https://wiki.mozilla.org/images/0/08/Places.sqlite.schema.pdf "Places.sqlite.schema.pdf | Mozilla Wiki"
@@ -804,21 +810,21 @@ actions:
[7]: https://web.archive.org/web/20221029145712/https://kb.mozillazine.org/Downloads.rdf "Downloads.rdf | MozillaZine Knowledge Base | kb.mozillazine.org" [7]: https://web.archive.org/web/20221029145712/https://kb.mozillazine.org/Downloads.rdf "Downloads.rdf | MozillaZine Knowledge Base | kb.mozillazine.org"
call: call:
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: downloads.rdf path: downloads.rdf
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: downloads.sqlite path: downloads.sqlite
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: places.sqlite path: places.sqlite
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: favicons.sqlite path: favicons.sqlite
- -
name: Clear Firefox logins name: Clear Firefox logins
docs: |- docs: |-
@@ -831,17 +837,17 @@ actions:
[2]: https://web.archive.org/web/20221029145757/https://bugzilla.mozilla.org/show_bug.cgi?id=1593467 "1593467 - Automatically restore from logins-backup.json when logins.json is missing or corrupt | Bugzilla | mozilla.org | bugzilla.mozilla.org" [2]: https://web.archive.org/web/20221029145757/https://bugzilla.mozilla.org/show_bug.cgi?id=1593467 "1593467 - Automatically restore from logins-backup.json when logins.json is missing or corrupt | Bugzilla | mozilla.org | bugzilla.mozilla.org"
call: call:
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: logins.json path: logins.json
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: logins-backup.json path: logins-backup.json
- -
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: signons.sqlite path: signons.sqlite
- -
name: Clear Firefox autocomplete history name: Clear Firefox autocomplete history
docs: |- docs: |-
@@ -850,9 +856,9 @@ actions:
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org" [1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
call: call:
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: formhistory.sqlite path: formhistory.sqlite
- -
name: Clear Firefox "Multi-Account Containers" data name: Clear Firefox "Multi-Account Containers" data
docs: |- docs: |-
@@ -860,9 +866,9 @@ actions:
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org" [1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
call: call:
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: containers.json path: containers.json
- -
name: Clear Firefox open tabs and windows data name: Clear Firefox open tabs and windows data
docs: |- docs: |-
@@ -872,9 +878,9 @@ actions:
[1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org" [1]: https://web.archive.org/web/20221029145152/https://support.mozilla.org/en-US/kb/profiles-where-firefox-stores-user-data "Profiles - Where Firefox stores your bookmarks, passwords and other user data | Firefox Help | support.mozilla.org"
call: call:
function: DeleteFilesFromFirefoxProfiles function: DeleteFromFirefoxProfiles
parameters: parameters:
pathGlob: sessionstore.jsonlz4 path: sessionstore.jsonlz4
- -
category: Clear system and kernel usage data category: Clear system and kernel usage data
docs: |- docs: |-
@@ -2905,8 +2911,7 @@ actions:
function: AddFirefoxPrefs function: AddFirefoxPrefs
parameters: parameters:
prefName: toolkit.telemetry.log.level prefName: toolkit.telemetry.log.level
jsonValue: >- jsonValue: 'Fatal'
"Fatal"
- -
name: Disable Firefox telemetry log output name: Disable Firefox telemetry log output
recommend: standard recommend: standard
@@ -2919,8 +2924,7 @@ actions:
function: AddFirefoxPrefs function: AddFirefoxPrefs
parameters: parameters:
prefName: toolkit.telemetry.log.dump prefName: toolkit.telemetry.log.dump
jsonValue: >- jsonValue: 'Fatal'
"Fatal"
- -
name: Clear Firefox telemetry user ID name: Clear Firefox telemetry user ID
recommend: standard recommend: standard
@@ -3487,66 +3491,16 @@ functions:
>&2 echo "Failed, $service does not exist." >&2 echo "Failed, $service does not exist."
fi fi
- -
name: Comment name: DeleteFromFirefoxProfiles
# 💡 Purpose:
# Adds a comment in the executed code for better readability and debugging.
# This function does not affect the execution flow but helps in understanding the purpose of subsequent code.
parameters: parameters:
- name: codeComment - name: path # file or folder in profile file
optional: true code: |-
- name: revertCodeComment # {{ $path }}: Global installation
optional: true rm -rfv ~/.mozilla/firefox/*/{{ $path }}
call: # {{ $path }}: Flatpak installation
function: RunInlineCode rm -rfv ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/{{ $path }}
parameters: # {{ $path }}: Snap installation
code: '{{ with $codeComment }}# {{ . }}{{ end }}' rm -rfv ~/snap/firefox/common/.mozilla/firefox/*/{{ $path }}
revertCode: '{{ with $revertCodeComment }}# {{ . }}{{ end }}'
-
name: DeleteFiles
parameters:
- name: fileGlob
call:
-
function: Comment
parameters:
codeComment: >-
Delete files matching pattern: "{{ $fileGlob }}"
-
function: RunPython3Code
parameters:
code: |-
import glob
import os
path = '{{ $fileGlob }}'
expanded_path = os.path.expandvars(os.path.expanduser(path))
print(f'Deleting files matching pattern: {expanded_path}')
paths = glob.glob(expanded_path)
if not paths:
print('Skipping, no paths found.')
for path in paths:
if not os.path.isfile(path):
print(f'Skipping folder: "{path}".')
continue
os.remove(path)
print(f'Successfully delete file: "{path}".')
print(f'Successfully deleted {len(paths)} file(s).')
-
name: DeleteFilesFromFirefoxProfiles
parameters:
- name: pathGlob # file or folder in profile file
call:
- # Global installation
function: DeleteFiles
parameters:
fileGlob: ~/.mozilla/firefox/*/{{ $pathGlob }}
- # Flatpak installation
function: DeleteFiles
parameters:
fileGlob: ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/{{ $pathGlob }}
- # Snap installation
function: DeleteFiles
parameters:
fileGlob: ~/snap/firefox/common/.mozilla/firefox/*/{{ $pathGlob }}
- -
name: CleanTableFromFirefoxProfileDatabase name: CleanTableFromFirefoxProfileDatabase
parameters: parameters:
@@ -3739,7 +3693,7 @@ functions:
- name: prefName - name: prefName
- name: jsonValue - name: jsonValue
docs: |- docs: |-
This script modifies the `user.js` file in Firefox profiles to set specific preferences. This script either creates or updates the `user.js` file to set specific Mozilla Firefox preferences.
The `user.js` file can be found in a Firefox profile folder [1] and its location depends on the type of installation: The `user.js` file can be found in a Firefox profile folder [1] and its location depends on the type of installation:
@@ -3747,18 +3701,12 @@ functions:
- Flatpak: `~/.var/app/org.mozilla.firefox/.mozilla/firefox/<profile-name>/user.js` - Flatpak: `~/.var/app/org.mozilla.firefox/.mozilla/firefox/<profile-name>/user.js`
- Snap: `~/snap/firefox/common/.mozilla/firefox/<profile-name>/user.js` - Snap: `~/snap/firefox/common/.mozilla/firefox/<profile-name>/user.js`
While the `user.js` file is optional [2], if it's present, the Firefox will prioritize its settings over While the `user.js` file is optional [2], if it's present, the Firefox application will prioritize its settings over
those in `prefs.js` upon startup [1] [2]. It's recommended not to directly edit `prefs.js` to avoid profile corruption [2]. those in `prefs.js` upon startup [1][2]. To prevent potential profile corruption, Mozilla advises against editing
`prefs.js` directly [2].
When `user.js` is modified or deleted, corresponding changes in `prefs.js` are necessary for reversion, as Firefox
doesn't automatically revert these changes [3].
This script safely modifies `user.js` and ensures changes are reflected in `prefs.js` during reversion, addressing
issues with preference persistence [3].
[1]: https://web.archive.org/web/20230811005205/https://kb.mozillazine.org/User.js_file "User.js file - MozillaZine Knowledge Base" [1]: https://web.archive.org/web/20230811005205/https://kb.mozillazine.org/User.js_file "User.js file - MozillaZine Knowledge Base"
[2]: https://web.archive.org/web/20221029211757/https://kb.mozillazine.org/Prefs.js_file "Prefs.js file - MozillaZine Knowledge Base" [2]: https://web.archive.org/web/20221029211757/https://kb.mozillazine.org/Prefs.js_file "Prefs.js file - MozillaZine Knowledge Base"
[3]: https://github.com/undergroundwires/privacy.sexy/issues/282 "[BUG]: Reverting Firefox settings do not work on Linux · Issue #282 · undergroundwires/privacy.sexy | github.com"
code: |- code: |-
pref_name='{{ $prefName }}' pref_name='{{ $prefName }}'
pref_value='{{ $jsonValue }}' pref_value='{{ $jsonValue }}'
@@ -3798,16 +3746,12 @@ functions:
if [ "$total_profiles_found" -eq 0 ]; then if [ "$total_profiles_found" -eq 0 ]; then
echo 'No profile folders are found, no changes are made.' echo 'No profile folders are found, no changes are made.'
else else
echo "Successfully verified preferences in $total_profiles_found profiles." echo "Preferences verified in $total_profiles_found profiles."
fi fi
revertCode: |- revertCode: |-
pref_name='{{ $prefName }}' pref_name='{{ $prefName }}'
pref_value='{{ $jsonValue }}' pref_value='{{ $jsonValue }}'
echo "Reverting preference: \"$pref_name\" to its default." echo "Reverting preference: \"$pref_name\" to its default."
if command -v 'ps' &> /dev/null && ps aux | grep -i "[f]irefox" > /dev/null; then
>&2 echo -e "\e[33mWarning: Firefox is currently running. Please close Firefox before executing the revert script to ensure changes are applied effectively.\e[0m"
fi
declare -a files_to_modify=('prefs.js' 'user.js')
declare -a profile_paths=( declare -a profile_paths=(
~/.mozilla/firefox/*/ ~/.mozilla/firefox/*/
~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/ ~/.var/app/org.mozilla.firefox/.mozilla/firefox/*/
@@ -3815,39 +3759,31 @@ functions:
) )
declare -i total_profiles_found=0 declare -i total_profiles_found=0
for profile_dir in "${profile_paths[@]}"; do for profile_dir in "${profile_paths[@]}"; do
if [ ! -d "$profile_dir" ]; then user_js_file="${profile_dir}user.js"
if [ ! -f "$user_js_file" ]; then
continue continue
fi fi
if [[ ! "$(basename "$profile_dir")" =~ ^[a-z0-9]{8}\..+ ]]; then
continue # Not a profile folder
fi
((total_profiles_found++)) ((total_profiles_found++))
for file_to_modify in "${files_to_modify[@]}"; do echo "$user_js_file:"
config_file_path="${profile_dir}${file_to_modify}" pref_start="user_pref(\"$pref_name\","
if [ ! -f "$config_file_path" ]; then pref_line="user_pref(\"$pref_name\", $pref_value);"
continue if ! grep --quiet "^$pref_start" "${user_js_file}"; then
echo $'\t''Skipping, preference was not configured before.'
elif grep --quiet "^$pref_line$" "${user_js_file}"; then
sed --in-place "/^$pref_line/d" "$user_js_file"
echo $'\t''Successfully reverted preference to default.'
if ! grep --quiet '[^[:space:]]' "$user_js_file"; then
rm "$user_js_file"
echo $'\t''Removed user.js file as it became empty.'
fi fi
echo "$config_file_path:" else
pref_start="user_pref(\"$pref_name\"," echo $'\t''Skipping, the preference has value that is not configured by privacy.sexy.'
pref_line="user_pref(\"$pref_name\", $pref_value);" fi
if ! grep --quiet "^$pref_start" "${config_file_path}"; then
echo $'\t''Skipping, preference was not configured before.'
elif grep --quiet "^$pref_line$" "${config_file_path}"; then
sed --in-place "/^$pref_line/d" "$config_file_path"
echo $'\t''Successfully reverted preference to default.'
if ! grep --quiet '[^[:space:]]' "$config_file_path"; then
rm "$config_file_path"
echo $'\t'"Removed the file as it became empty."
fi
else
echo $'\t''Skipping, the preference has value that is not configured by privacy.sexy.'
fi
done
done done
if [ "$total_profiles_found" -eq 0 ]; then if [ "$total_profiles_found" -eq 0 ]; then
echo 'No reversion was necessary.' echo 'No reversion was necessary.'
else else
echo "Successfully verified preferences in $total_profiles_found profiles." echo "Preferences verified in $total_profiles_found profiles."
fi fi
- -
name: RenameFile name: RenameFile

View File

@@ -1238,6 +1238,376 @@ actions:
sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate 'CriticalUpdateInstall' -bool true sudo defaults write /Library/Preferences/com.apple.SoftwareUpdate 'CriticalUpdateInstall' -bool true
# Trigger background check with normal scan (critical updates only) # Trigger background check with normal scan (critical updates only)
sudo softwareupdate --background-critical sudo softwareupdate --background-critical
-
category: Disable OS services
children:
# Get active services : launchctl list | grep -v "\-\t0"
# Find a service : sudo grep -lR [service] /System/Library/Launch* /Library/Launch* ~/Library/LaunchAgents
# Locate a service : pgrep -fl [service]
# TODO: https://gist.github.com/ecompayment/b1054421eb90f296bbca226683c7ff7e
-
category: Disable continuously data-collecting services by default
children:
-
name: Disable diagnostics and usage data sender
recommend: standard
docs: https://apple.stackexchange.com/questions/66119/disable-submitdiaginfo
call:
function: DisableService
parameters:
name: com.apple.SubmitDiagInfo
type: LaunchDaemons
-
name: Disable diagnostics and usage data sender
recommend: standard
call:
-
function: DisableService
parameters:
name: com.apple.rtcreportingd.plist
type: LaunchDaemons
-
function: RenameSystemFile
parameters:
filePath: /usr/libexec/rtcreportingd
-
name: Disable Family Circle Daemon for Family Sharing
docs: https://support.apple.com/en-us/HT201060
recommend: standard
# Connects to setup.icloud.com HTTPS (TCP 443 )
call:
-
function: DisableService
parameters:
name: com.apple.familycircled
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/FamilyCircle.framework/Versions/A/Resources/familycircled
-
name: Disable home sharing
docs: https://discussions.apple.com/thread/7434075?answerId=29677460022#29677460022
# Connects to apps.mzstatic.com and init.itunes.apple.com HTTPS (TCP 443 )
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.itunescloudd
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /usr/libexec/rtcreportingd # TODO: SIP required?
-
name: Disable CommerceKit handling purchases for Apple products
# the Mac App Store, iTunes store, and Book Store
# Connects to init.itunes.apple.com and xp.apple.com HTTPS (TCP 443 )
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.commerce.plist
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/CommerceKit.framework/Versions/A/Resources/commerce
-
category: Disable Siri services # TODO: merge with other assistantd script
children:
-
name: Disable Siri dictation service sending voice data
recommend: strict
docs: https://apple.stackexchange.com/questions/57514/what-is-assistantd
# Connects to guzzoni.apple.com HTTPS (TCP 443 )
call:
-
function: DisableService
parameters:
name: com.apple.assistantd
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/AssistantServices.framework/Versions/A/Support/assistantd
-
name: Disable Siri assistant service
recommend: strict
docs: https://www.howtogeek.com/354897/what-are-assistant_service-and-assistantd-and-why-are-they-running-on-my-mac/
# Connects to radio.itunes.apple.com HTTPS (TCP 443 )
call:
-
function: DisableService
parameters:
name: com.apple.assistant_service.plist
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/AssistantServices.framework/Versions/A/Support/assistant_service
-
category: Disable Messages services
docs: https://blog.quarkslab.com/imessage-privacy.html
children:
-
name: Disable Apple Push Service Daemon used for Notification Center and Messages
# Connects to *-courier.push.apple.com (where * is a number) using HTTPS (TCP 443) and apple-push (TCP 5223)
call:
-
function: DisableService
parameters:
name: com.apple.apsd
type: LaunchDaemons
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/ApplePushService.framework/apsd
-
name: Disable iMessage Agent in Messages app
# Used for e.g. FaceTime invitations
docs:
- https://apple.stackexchange.com/questions/86814/firewall-settings-with-imagent
- https://blog.quarkslab.com/imessage-privacy.html
# Connects to using HTTPS (TCP 443) and apple-push (TCP 5223)
call:
-
function: DisableService
parameters:
name: com.apple.imagent
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/IMCore.framework/imagent.app/Contents/MacOS/imagent
-
name: Disable Address Book Source Sync (breaks Contacts data sync)
# Synchronizes data data for the “Contacts” app with iCloud, CardDAV, and Exchange servers
docs: https://apple.stackexchange.com/questions/219774/how-to-disable-addressbooksourcesync-in-el-capitan
# Connects to p25-contacts.icloud.com using HTTPS (TCP 443) and apple-push (TCP 5223)
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.AddressBook.SourceSync
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/Frameworks/AddressBook.framework/Versions/A/Helpers/AddressBookSourceSync.app/Contents/MacOS/AddressBookSourceSync
-
name: Disable usage tracking agent
recommend: strict
docs: https://www.unix.com/man-page/mojave/8/USAGETRACKINGAGENT/
# Connects to itunes.apple.com using HTTPS 443 (TCP)
call:
-
function: DisableService
parameters:
name: com.apple.UsageTrackingAgent
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/UsageTracking.framework/Versions/A/UsageTrackingAgent
-
name: Disable AMPLibraryAgent for Apple Music
# Connects to buy.itunes.apple.com, init.itunes.apple.com, play.itunes.apple.com, xp.apple.com using HTTPS 443 (TCP)
call:
-
function: DisableService
parameters:
name: com.apple.AMPLibraryAgent
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: System/Library/PrivateFrameworks/AMPLibrary.framework/Versions/A/Support/AMPLibraryAgent
-
category: Disable location services
children:
-
name: Disable Maps push daemon
docs:
- https://www.unix.com/man-page/mojave/8/MAPSPUSHD/
- https://discussions.apple.com/thread/7025815
call:
function: DisableService
parameters:
name: com.apple.Maps.pushdaemon
type: LaunchAgents
-
name: Disable Geo Daemon / geolocation daemon used to show maps by apps e.g. Maps
# Connects to Apple servers for loading map data on behalf of other apps and for resolving geographical coordinates to readable addresses.
# Connects to gspe*-ssl.ls.apple.com (where * is a number from 1 to 100 ), sp-ssl.ls.apple.com, configuration.ls.apple.com using HTTPS 443 (TCP)
call:
function: "RenameSystemFile (TODO: Just like Windows.yaml, requires SIP)"
parameters:
filePath: /System/Library/PrivateFrameworks/GeoServices.framework/Versions/A/XPCServices/com.apple.geod.xpc/Contents/MacOS/com.apple.geod
-
name: Disable Location-Based Suggestions for Siri, Spotlight and other places
# Used for suggestions in Spotlight, Messages, Lookup, Safari, Siri, and other place
# Connects to api-glb-euc1b.smoot.apple.com, api.smoot.apple.com using HTTPS 443 (TCP)
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.parsecd
type: LaunchAgents
-
function: "RenameSystemFile (TODO: Just like Windows.yaml, requires SIP)"
parameters:
filePath: /System/Library/PrivateFrameworks/CoreParsec.framework/parsecd
-
category: Disable iCloud services
children:
-
name: Disable iCloud notification agent
recommend: strict
call:
function: DisableService
parameters:
name: com.apple.iCloudNotificationAgent
type: LaunchAgents
-
name: Disable Sync Defaults Daemon
# Syncs user preferences or other configuration related data via iCloud
docs: https://www.unix.com/man-page/mojave/8/syncdefaultsd
# Connects to keyvalueservice.icloud.com and p*-keyvalueservice.icloud.com (where * is a number) using HTTPS 443 (TCP)
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.syncdefaultsd
type: LaunchAgents
-
function: "RenameSystemFile (TODO: Just like Windows.yaml, requires SIP)"
parameters:
filePath: /System/Library/PrivateFrameworks/SyncedDefaults.framework/Support/syncdefaultsd
-
name: Disable Reminder Daemon that synchronizes the reminder list in "Reminders" with iCloud
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.remindd
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /usr/libexec/remindd #TODO: Mb don't require SIP
-
name: Disable Cloud Daemon used for iCloud syncing
# Connects to gateway.icloud.com, metrics.icloud.com using HTTPS 443 (TCP)
recommend: strict
call:
-
function: DisableService
parameters:
name: com.apple.cloudd
type: LaunchAgents
-
function: DisableService
parameters:
name: com.apple.cloudd
type: LaunchDaemons
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/CloudKitDaemon.framework/Support/cloudd
-
name: Disable Help Daemon (breaks HelpViewer feature)
recommend: strict
docs: https://discussions.apple.com/thread/3930621
# Connects to cds.apple.com, help.apple.com using HTTPS (TCP 443)
call:
-
function: DisableService
parameters:
name: com.apple.helpd
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/HelpData.framework/Versions/A/Resources/helpd
-
name: Disable Rapport Daemon for communication between Apple devices
# Rapport Daemon is a macOS system process that enables Phone Call Handoff and other communication features between Apple devices.
# Connects to init.ess.apple.com using HTTPS (TCP 443)
docs: https://apple.stackexchange.com/questions/308294/what-is-rapportd-and-why-does-it-want-incoming-network-connections
call:
-
function: DisableService
parameters:
name: com.apple.rapportd-user
type: LaunchAgents
-
function: DisableService
parameters:
name: com.apple.rapportd
type: LaunchDaemons
-
function: RenameSystemFile
parameters:
filePath: /usr/libexec/rapportd #TODO: No SIP required?
-
name: Disable App Tracking Transparency framework
docs:
- https://apple.stackexchange.com/questions/409349/what-is-the-transparencyd-daemon-for
- https://developer.apple.com/documentation/apptrackingtransparency
# Connects to server kt-prod.apple.com using HTTPS (TCP 443 )
call:
-
function: DisableService
parameters:
name: com.apple.transparencyd
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /usr/libexec/transparencyd #TODO: No need for SIP?
-
category: Disable Calendar Agent that sync Calender App to iCloud and other servers
call:
-
function: DisableService
parameters:
name: com.apple.CalendarAgent
type: LaunchAgents
-
function: RenameSystemFile
parameters:
filePath: /System/Library/PrivateFrameworks/CalendarAgent.framework/Executables/CalendarAgent
-
name: Disable advertising services daemon
recommend: strict
docs: https://www.unix.com/man-page/mojave/8/adservicesd
call:
function: DisableService
parameters:
name: com.apple.ap.adservicesd
type: LaunchAgents
-
name: Disable NetBIOS interactions (might break Microsoft services)
# Mostly used for mostly SMB network volumes
docs: https://www.manpagez.com/man/8/netbiosd/
call:
-
function: DisableService
parameters:
name: com.apple.netbiosd
type: LaunchDaemons
function: RenameSystemFile
parameters:
filePath: /usr/sbin/netbiosd
requireSip: false # TODO: Test
functions: functions:
- -
name: PersistUserEnvironmentConfiguration name: PersistUserEnvironmentConfiguration
@@ -1268,3 +1638,31 @@ functions:
echo "[$profile_file] No need for any action, configuration does not exist" echo "[$profile_file] No need for any action, configuration does not exist"
fi fi
done done
-
name: DisableService
parameters:
- name: name
- name: type
code: |-
original_file='/System/Library/{{ $type }}/{{ $name }}.plist'
backup_file="$original_file.disabled"
if [ -f "$original_file" ]; then
sudo launchctl unload -w "$original_file" 2> /dev/null
mv "$original_file" "$backup_file"
echo 'Disabled successfully'
else
echo 'Already disabled'
fi
revertCode: |-
original_file='/System/Library/{{ $type }}/{{ $name }}.plist'
backup_file="$original_file.disabled"
if [ -f "$original_file" ]; then
sudo launchctl unload -w "$original_file" 2> /dev/null
if mv "$original_file" "$backup_file"; then
echo 'Disabled successfully'
else
>&2 echo 'Failed to disable'
fi
else
echo 'Already disabled'
fi

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ export class Application implements IApplication {
public info: IProjectInformation, public info: IProjectInformation,
public collections: readonly ICategoryCollection[], public collections: readonly ICategoryCollection[],
) { ) {
validateInformation(info);
validateCollections(collections); validateCollections(collections);
} }
@@ -15,17 +16,19 @@ export class Application implements IApplication {
return this.collections.map((collection) => collection.os); return this.collections.map((collection) => collection.os);
} }
public getCollection(operatingSystem: OperatingSystem): ICategoryCollection { public getCollection(operatingSystem: OperatingSystem): ICategoryCollection | undefined {
const collection = this.collections.find((c) => c.os === operatingSystem); return this.collections.find((collection) => collection.os === operatingSystem);
if (!collection) { }
throw new Error(`Operating system "${OperatingSystem[operatingSystem]}" is not defined in application`); }
}
return collection; function validateInformation(info: IProjectInformation) {
if (!info) {
throw new Error('missing project information');
} }
} }
function validateCollections(collections: readonly ICategoryCollection[]) { function validateCollections(collections: readonly ICategoryCollection[]) {
if (!collections.length) { if (!collections || !collections.length) {
throw new Error('missing collections'); throw new Error('missing collections');
} }
if (collections.filter((c) => !c).length > 0) { if (collections.filter((c) => !c).length > 0) {

View File

@@ -3,14 +3,14 @@ import { IScript } from './IScript';
import { ICategory } from './ICategory'; import { ICategory } from './ICategory';
export class Category extends BaseEntity<number> implements ICategory { export class Category extends BaseEntity<number> implements ICategory {
private allSubScripts?: ReadonlyArray<IScript> = undefined; private allSubScripts: ReadonlyArray<IScript> = undefined;
constructor( constructor(
id: number, id: number,
public readonly name: string, public readonly name: string,
public readonly docs: ReadonlyArray<string>, public readonly docs: ReadonlyArray<string>,
public readonly subCategories: ReadonlyArray<ICategory>, public readonly subCategories?: ReadonlyArray<ICategory>,
public readonly scripts: ReadonlyArray<IScript>, public readonly scripts?: ReadonlyArray<IScript>,
) { ) {
super(id); super(id);
validateCategory(this); validateCategory(this);
@@ -39,7 +39,10 @@ function validateCategory(category: ICategory) {
if (!category.name) { if (!category.name) {
throw new Error('missing name'); throw new Error('missing name');
} }
if (category.subCategories.length === 0 && category.scripts.length === 0) { if (
(!category.subCategories || category.subCategories.length === 0)
&& (!category.scripts || category.scripts.length === 0)
) {
throw new Error('A category must have at least one sub-category or script'); throw new Error('A category must have at least one sub-category or script');
} }
} }

View File

@@ -19,6 +19,9 @@ export class CategoryCollection implements ICategoryCollection {
public readonly actions: ReadonlyArray<ICategory>, public readonly actions: ReadonlyArray<ICategory>,
public readonly scripting: IScriptingDefinition, public readonly scripting: IScriptingDefinition,
) { ) {
if (!scripting) {
throw new Error('missing scripting definition');
}
this.queryable = makeQueryable(actions); this.queryable = makeQueryable(actions);
assertInRange(os, OperatingSystem); assertInRange(os, OperatingSystem);
ensureValid(this.queryable); ensureValid(this.queryable);
@@ -26,26 +29,17 @@ export class CategoryCollection implements ICategoryCollection {
ensureNoDuplicates(this.queryable.allScripts); ensureNoDuplicates(this.queryable.allScripts);
} }
public getCategory(categoryId: number): ICategory { public findCategory(categoryId: number): ICategory | undefined {
const category = this.queryable.allCategories.find((c) => c.id === categoryId); return this.queryable.allCategories.find((category) => category.id === categoryId);
if (!category) {
throw new Error(`Missing category with ID: "${categoryId}"`);
}
return category;
} }
public getScriptsByLevel(level: RecommendationLevel): readonly IScript[] { public getScriptsByLevel(level: RecommendationLevel): readonly IScript[] {
assertInRange(level, RecommendationLevel); assertInRange(level, RecommendationLevel);
const scripts = this.queryable.scriptsByLevel.get(level); return this.queryable.scriptsByLevel.get(level);
return scripts ?? [];
} }
public getScript(scriptId: string): IScript { public findScript(scriptId: string): IScript | undefined {
const script = this.queryable.allScripts.find((s) => s.id === scriptId); return this.queryable.allScripts.find((script) => script.id === scriptId);
if (!script) {
throw new Error(`missing script: ${scriptId}`);
}
return script;
} }
public getAllScripts(): IScript[] { public getAllScripts(): IScript[] {
@@ -84,13 +78,13 @@ function ensureValid(application: IQueryableCollection) {
} }
function ensureValidCategories(allCategories: readonly ICategory[]) { function ensureValidCategories(allCategories: readonly ICategory[]) {
if (!allCategories.length) { if (!allCategories || allCategories.length === 0) {
throw new Error('must consist of at least one category'); throw new Error('must consist of at least one category');
} }
} }
function ensureValidScripts(allScripts: readonly IScript[]) { function ensureValidScripts(allScripts: readonly IScript[]) {
if (!allScripts.length) { if (!allScripts || allScripts.length === 0) {
throw new Error('must consist of at least one script'); throw new Error('must consist of at least one script');
} }
const missingRecommendationLevels = getEnumValues(RecommendationLevel) const missingRecommendationLevels = getEnumValues(RecommendationLevel)

View File

@@ -7,5 +7,5 @@ export interface IApplication {
readonly collections: readonly ICategoryCollection[]; readonly collections: readonly ICategoryCollection[];
getSupportedOsList(): OperatingSystem[]; getSupportedOsList(): OperatingSystem[];
getCollection(operatingSystem: OperatingSystem): ICategoryCollection; getCollection(operatingSystem: OperatingSystem): ICategoryCollection | undefined;
} }

View File

@@ -5,8 +5,8 @@ import { IDocumentable } from './IDocumentable';
export interface ICategory extends IEntity<number>, IDocumentable { export interface ICategory extends IEntity<number>, IDocumentable {
readonly id: number; readonly id: number;
readonly name: string; readonly name: string;
readonly subCategories: ReadonlyArray<ICategory>; readonly subCategories?: ReadonlyArray<ICategory>;
readonly scripts: ReadonlyArray<IScript>; readonly scripts?: ReadonlyArray<IScript>;
includes(script: IScript): boolean; includes(script: IScript): boolean;
getAllScriptsRecursively(): ReadonlyArray<IScript>; getAllScriptsRecursively(): ReadonlyArray<IScript>;
} }

View File

@@ -12,8 +12,8 @@ export interface ICategoryCollection {
readonly actions: ReadonlyArray<ICategory>; readonly actions: ReadonlyArray<ICategory>;
getScriptsByLevel(level: RecommendationLevel): ReadonlyArray<IScript>; getScriptsByLevel(level: RecommendationLevel): ReadonlyArray<IScript>;
getCategory(categoryId: number): ICategory; findCategory(categoryId: number): ICategory | undefined;
getScript(scriptId: string): IScript; findScript(scriptId: string): IScript | undefined;
getAllScripts(): ReadonlyArray<IScript>; getAllScripts(): ReadonlyArray<IScript>;
getAllCategories(): ReadonlyArray<ICategory>; getAllCategories(): ReadonlyArray<ICategory>;
} }

View File

@@ -1,4 +1,4 @@
export interface IScriptCode { export interface IScriptCode {
readonly execute: string; readonly execute: string;
readonly revert?: string; readonly revert: string;
} }

View File

@@ -16,6 +16,9 @@ export class ProjectInformation implements IProjectInformation {
if (!name) { if (!name) {
throw new Error('name is undefined'); throw new Error('name is undefined');
} }
if (!version) {
throw new Error('undefined version');
}
if (!slogan) { if (!slogan) {
throw new Error('undefined slogan'); throw new Error('undefined slogan');
} }

View File

@@ -11,6 +11,9 @@ export class Script extends BaseEntity<string> implements IScript {
public readonly level?: RecommendationLevel, public readonly level?: RecommendationLevel,
) { ) {
super(name); super(name);
if (!code) {
throw new Error('missing code');
}
validateLevel(level); validateLevel(level);
} }

View File

@@ -3,14 +3,14 @@ import { IScriptCode } from './IScriptCode';
export class ScriptCode implements IScriptCode { export class ScriptCode implements IScriptCode {
constructor( constructor(
public readonly execute: string, public readonly execute: string,
public readonly revert: string | undefined, public readonly revert: string,
) { ) {
validateCode(execute); validateCode(execute);
validateRevertCode(revert, execute); validateRevertCode(revert, execute);
} }
} }
function validateRevertCode(revertCode: string | undefined, execute: string) { function validateRevertCode(revertCode: string, execute: string) {
if (!revertCode) { if (!revertCode) {
return; return;
} }
@@ -25,7 +25,7 @@ function validateRevertCode(revertCode: string | undefined, execute: string) {
} }
function validateCode(code: string): void { function validateCode(code: string): void {
if (code.length === 0) { if (!code || code.length === 0) {
throw new Error('missing code'); throw new Error('missing code');
} }
} }

View File

@@ -0,0 +1,13 @@
export class Clipboard {
public static copyText(text: string): void {
const el = document.createElement('textarea');
el.value = text;
el.setAttribute('readonly', ''); // to avoid focus
el.style.position = 'absolute';
el.style.left = '-9999px';
document.body.appendChild(el);
el.select();
document.execCommand('copy');
document.body.removeChild(el);
}
}

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