Compare commits

..

1 Commits

Author SHA1 Message Date
undergroundwires
c7054521a7 win: improve output/user feedback capabilities
- Improve error handling.
- Provide more detailed user feedback with meaningful messages.
- Check if capability exists before performing operations.
- Inform system restart is needed.
2023-08-04 17:50:07 +01:00
357 changed files with 21329 additions and 18259 deletions

View File

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

View File

@@ -6,10 +6,10 @@ module.exports = {
root: true,
env: {
node: true,
es2022: true, // add globals and sets parserOptions.ecmaVersion to 2022
},
extends: [
// Vue specific rules, eslint-plugin-vue
// Added by Vue CLI
'plugin:vue/essential',
// Extends eslint-config-airbnb
@@ -17,14 +17,42 @@ module.exports = {
// Extends @typescript-eslint/recommended
// Uses the recommended rules from the @typescript-eslint/eslint-plugin
// Added by Vue CLI
'@vue/typescript/recommended',
],
parserOptions: {
ecmaVersion: 12, // ECMA 2021
/*
Having 'latest' leads to:
```
Parsing error: ecmaVersion must be a number. Received value of type string instead
```
For .js files in the project
*/
},
rules: {
...getOwnRules(),
...getTurnedOffBrokenRules(),
...getOpinionatedRuleOverrides(),
...getTodoRules(),
},
overrides: [
{
files: [
'**/__tests__/*.{j,t}s?(x)',
'**/tests/unit/**/*.spec.{j,t}s?(x)',
],
env: {
mocha: true,
},
},
{
files: ['**/tests/**/*.{j,t}s?(x)'],
rules: {
'no-console': 'off',
},
},
],
};
function getOwnRules() {

View File

@@ -9,13 +9,7 @@ jobs:
strategy:
matrix:
os: [ macos, ubuntu, windows ]
mode: [
# Vite mode: https://vitejs.dev/guide/env-and-mode.html
development, # Used by `dev` command
production, # Used by `build` command
# Vitest mode: https://vitest.dev/guide/cli.html
test, # Used by Vitest
]
mode: [ development, test, production ]
fail-fast: false # Allows to see results from other combinations
runs-on: ${{ matrix.os }}-latest
steps:
@@ -32,15 +26,12 @@ jobs:
name: Build
run: npm run build -- --mode ${{ matrix.mode }}
# A new job is used due to environments/modes different from Vue CLI, https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1626
build-desktop:
strategy:
matrix:
os: [ macos, ubuntu, windows ]
mode: [
# electron-vite modes: https://electron-vite.org/guide/env-and-mode.html#global-env-variables
development, # Used by `dev` command
production, # Used by `build` and `preview` commands
]
mode: [ development, production ] # "test" is not supported https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1627
fail-fast: false # Allows to see results from other combinations
runs-on: ${{ matrix.os }}-latest
steps:
@@ -54,11 +45,14 @@ jobs:
name: Install dependencies
run: npm ci
-
name: Prebuild
run: npm run electron:prebuild -- --mode ${{ matrix.mode }}
name: Install cross-env
# Used to set NODE_ENV due to https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1626
run: npm install --global cross-env
-
name: Build
run: npm run electron:build -- --publish never
run: |-
cross-env-shell NODE_ENV=${{ matrix.mode }}
npm run electron:build -- --publish never --mode ${{ matrix.mode }}
create-icons:
strategy:
@@ -78,4 +72,4 @@ jobs:
run: npm ci
-
name: Create icons
run: npm run icons:build
run: npm run create-icons

View File

@@ -1,67 +0,0 @@
name: checks.desktop-runtime-errors
# Verifies desktop builds for Electron applications across multiple OS platforms (macOS ,Ubuntu, and Windows).
on:
push:
pull_request:
jobs:
build-desktop:
strategy:
matrix:
os: [ macos, ubuntu, windows ]
fail-fast: false # Allows to see results from other combinations
runs-on: ${{ matrix.os }}-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Setup node
uses: ./.github/actions/setup-node
-
name: Configure Ubuntu
if: matrix.os == 'ubuntu'
shell: bash
run: |-
sudo apt update
# Configure AppImage dependencies
sudo apt install -y libfuse2
# Configure DBUS (fixes `Failed to connect to the bus: Could not parse server address: Unknown address type`)
if ! command -v 'dbus-launch' &> /dev/null; then
echo 'DBUS does not exist, installing...'
sudo apt install -y dbus-x11 # Gives both dbus and dbus-launch utility
fi
sudo systemctl start dbus
DBUS_LAUNCH_OUTPUT=$(dbus-launch)
if [ $? -eq 0 ]; then
echo "${DBUS_LAUNCH_OUTPUT}" >> $GITHUB_ENV
else
echo 'Error: dbus-launch command did not execute successfully. Exiting.' >&2
echo "${DBUS_LAUNCH_OUTPUT}" >&2
exit 1
fi
# Configure fake (virtual) display
sudo apt install -y xvfb
sudo Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &
echo "DISPLAY=:99" >> $GITHUB_ENV
# Install ImageMagick for screenshots
sudo apt install -y imagemagick
# Install xdotool and xprop (from x11-utils) for window title capturing
sudo apt install -y xdotool x11-utils
-
name: Test
shell: bash
run: node ./scripts/check-desktop-runtime-errors --screenshot
-
name: Upload screenshot
if: always() # Run even if previous step fails
uses: actions/upload-artifact@v3
with:
name: screenshot-${{ matrix.os }}
path: screenshot.png

View File

@@ -19,4 +19,4 @@ jobs:
uses: ./.github/actions/setup-node
-
name: NPM audit
run: npm audit --omit=dev
run: exit "$(npm audit)" # Since node 15.x, it does not fail with error if we don't explicitly exit

View File

@@ -13,29 +13,20 @@ jobs:
fail-fast: false # So publish runs for other OSes if one fails
runs-on: ${{ matrix.os }}-latest
steps:
-
uses: actions/checkout@v2
- uses: actions/checkout@v2
with:
ref: master # otherwise it defaults to the version tag missing bump commit
fetch-depth: 0 # fetch all history
-
name: Checkout to bump commit
- name: Checkout to bump commit
run: git checkout "$(git rev-list "${{ github.event.release.tag_name }}"..master | tail -1)"
-
name: Setup node
- name: Setup node
uses: ./.github/actions/setup-node
-
name: Install dependencies
- name: Install dependencies
run: npm ci
-
name: Run unit tests
- name: Run unit tests
run: npm run test:unit
-
name: Prebuild
run: npm run electron:prebuild
-
name: Build and publish
run: npm run electron:build -- --publish always
- name: Publish desktop app
run: npm run electron:build -- -p always # https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/recipes.html#upload-release-to-github
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true # Otherwise publishing fails if GitHub release is more than 2 hours old https://github.com/electron-userland/electron-builder/issues/2074

View File

@@ -23,4 +23,4 @@ jobs:
run: npm ci
-
name: Run e2e tests
run: npm run test:cy:run
run: npm run test:e2e -- --headless

3
.gitignore vendored
View File

@@ -5,3 +5,6 @@ dist/
!.vscode/extensions.json
#Electron-builder output
/dist_electron
# Cypress
/tests/e2e/screenshots
/tests/e2e/videos

View File

@@ -11,8 +11,8 @@
"dbaeumer.vscode-eslint", // Lints JavaScript/TypeScript.
"pmneo.tsimporter", // Provides better auto-complete for TypeScripts imports.
// Vue
"Vue.volar", // Official Vue extensions
"Vue.vscode-typescript-vue-plugin", // Official TypeScript Vue Plugin
"jcbuisson.vue", // Highlights syntax.
"octref.vetur", // Adds Vetur, Vue tooling support.
// Scripting
"timonwong.shellcheck", // Lints bash files.
"ms-vscode.powershell", // Lints PowerShell files.

View File

@@ -1,22 +1,5 @@
# Changelog
## 0.12.1 (2023-08-17)
* Transition to eslint-config-airbnb-with-typescript | [ff84f56](https://github.com/undergroundwires/privacy.sexy/commit/ff84f5676e496dd7ec5b3599e34ec9627d181ea2)
* Improve user privacy with secure outbound links | [3a594ac](https://github.com/undergroundwires/privacy.sexy/commit/3a594ac7fd708dc1e98155ffb9b21acd4e1fcf2d)
* Refactor Vue components using Composition API #230 | [1b9be8f](https://github.com/undergroundwires/privacy.sexy/commit/1b9be8fe2d72d8fb5cf1fed6dcc0b9777171aa98)
* Fix failing security tests | [3bc8da4](https://github.com/undergroundwires/privacy.sexy/commit/3bc8da4cbf1e2bd758dc3fffe4b1e62dc3beb7b3)
* Improve Defender scripts #201 | [061afad](https://github.com/undergroundwires/privacy.sexy/commit/061afad9673a41454c2421c318898f2b4f4cf504)
* Fix failing tests due to failed error logging | [986ba07](https://github.com/undergroundwires/privacy.sexy/commit/986ba078a643de6acbee50fff9cf77494ca7ea7f)
* Implement custom lightweight modal #230 | [9e5491f](https://github.com/undergroundwires/privacy.sexy/commit/9e5491fdbf2d9d40d974f5ad0e879a6d5c6d1e55)
* Refactor usage of tooltips for flexibility | [bc91237](https://github.com/undergroundwires/privacy.sexy/commit/bc91237d7c54bdcd15c5c39a55def50d172bb659)
* Fix revert toggle partial rendering | [39e650c](https://github.com/undergroundwires/privacy.sexy/commit/39e650cf110bee6b1b21d9b2902b36b0e2568d54)
* Increase testability through dependency injection | [ae75059](https://github.com/undergroundwires/privacy.sexy/commit/ae75059cc14db41f55dd2056f528442c7d319dd2)
* Refactor filter (search query) event handling | [6a20d80](https://github.com/undergroundwires/privacy.sexy/commit/6a20d804dc365d22c1248d787f9912271f508eeb)
* Migrate to ES6 modules | [a14929a](https://github.com/undergroundwires/privacy.sexy/commit/a14929a13cc6260b514692d9b4f1cdf5fb85d8b2)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.0...0.12.1)
## 0.12.0 (2023-08-03)
* Improve script/category name validation | [b210aad](https://github.com/undergroundwires/privacy.sexy/commit/b210aaddf26629179f77fe19f62f65d8a0ca2b87)

View File

@@ -4,13 +4,13 @@
<!-- markdownlint-disable MD033 -->
<p align="center">
<a href="https://undergroundwires.dev/donate?project=privacy.sexy" target="_blank" rel="noopener noreferrer">
<a href="https://undergroundwires.dev/donate?project=privacy.sexy">
<img
alt="donation badge"
src="https://undergroundwires.dev/img/badges/donate/flat.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md">
<img
alt="contributions are welcome"
src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat"
@@ -18,13 +18,13 @@
</a>
<!-- Code quality -->
<br />
<a href="https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript" target="_blank" rel="noopener noreferrer">
<a href="https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript">
<img
alt="Language grade: JavaScript/TypeScript"
src="https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18"
/>
</a>
<a href="https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability" target="_blank" rel="noopener noreferrer">
<a href="https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability">
<img
alt="Maintainability"
src="https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability"
@@ -32,19 +32,19 @@
</a>
<!-- Tests -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.unit.yaml" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.unit.yaml">
<img
alt="Unit tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/unit-tests/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.integration.yaml" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.integration.yaml">
<img
alt="Integration tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/integration-tests/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.e2e.yaml" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.e2e.yaml">
<img
alt="E2E tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/e2e-tests/badge.svg"
@@ -52,45 +52,39 @@
</a>
<!-- Checks -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.quality.yaml" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.quality.yaml">
<img
alt="Quality checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/quality-checks/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.security.yaml" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.security.yaml">
<img
alt="Security checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/security-checks/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.build.yaml" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.build.yaml">
<img
alt="Build checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.desktop-runtime-errors.yaml" target="_blank" rel="noopener noreferrer">
<img
alt="Status of runtime error checks for the desktop application"
src="https://github.com/undergroundwires/privacy.sexy/workflows/checks.desktop-runtime-errors/badge.svg"
/>
</a>
<!-- Release -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml">
<img
alt="Git release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-git/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.site.yaml" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.site.yaml">
<img
alt="Site release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-site/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.desktop.yaml" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.desktop.yaml">
<img
alt="Desktop application release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-desktop/badge.svg"
@@ -98,7 +92,7 @@
</a>
<!-- Others -->
<br />
<a href="https://github.com/undergroundwires/bump-everywhere" target="_blank" rel="noopener noreferrer">
<a href="https://github.com/undergroundwires/bump-everywhere">
<img
alt="Auto-versioned by bump-everywhere"
src="https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true"

5
babel.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
presets: [
'@vue/cli-plugin-babel/preset',
],
};

View File

@@ -1,15 +1,14 @@
import { defineConfig } from 'cypress';
import ViteConfig from './vite.config';
const CYPRESS_BASE_DIR = 'tests/e2e/';
import { defineConfig } from 'cypress'
export default defineConfig({
fixturesFolder: `${CYPRESS_BASE_DIR}/fixtures`,
screenshotsFolder: `${CYPRESS_BASE_DIR}/screenshots`,
videosFolder: `${CYPRESS_BASE_DIR}/videos`,
fixturesFolder: 'tests/e2e/fixtures',
screenshotsFolder: 'tests/e2e/screenshots',
videosFolder: 'tests/e2e/videos',
e2e: {
baseUrl: `http://localhost:${ViteConfig.server.port}/`,
specPattern: `${CYPRESS_BASE_DIR}/**/*.cy.{js,jsx,ts,tsx}`, // Default: cypress/e2e/**/*.cy.{js,jsx,ts,tsx}
supportFile: `${CYPRESS_BASE_DIR}/support/e2e.ts`,
setupNodeEvents(on, config) {
return require('./tests/e2e/plugins/index.js')(on, config)
},
specPattern: 'tests/e2e/specs/**/*.cy.{js,jsx,ts,tsx}',
supportFile: 'tests/e2e/support/index.js',
},
});

View File

@@ -35,7 +35,7 @@ Application layer enables [data-driven programming](https://en.wikipedia.org/wik
Application layer parses the application data to compile the domain object [`Application.ts`](./../src/domain/Application.ts).
The build tool loads (or injects) application data ([collection yaml files](./../src/application/collections/)) into the application layer in compile time. Application layer ([`ApplicationFactory.ts`](./../src/application/ApplicationFactory.ts)) parses and compiles this data in runtime.
A webpack loader loads (or injects) application data ([collection yaml files](./../src/application/collections/)) into the application layer in compile time. Application layer ([`ApplicationFactory.ts`](./../src/application/ApplicationFactory.ts)) parses and compiles this data in runtime.
Application layer compiles templating syntax during parsing to create the end scripts. You can read more about templating syntax in [templating.md](./templating.md) and how application data uses them through functions in [collection-files.md | Function](./collection-files.md#function).

View File

@@ -15,23 +15,11 @@ Application is
Application uses highly decoupled models & services in different DDD layers:
**Application layer** (see [application.md](./application.md)):
- presentation layer (see [presentation.md](./presentation.md)),
- application layer (see [application.md](./application.md)),
- and domain layer.
- Coordinates application activities and consumes the domain layer.
**Presentation layer** (see [presentation.md](./presentation.md)):
- Handles UI/UX, consumes both the application and domain layers.
- May communicate directly with the infrastructure layer for technical needs, but avoids domain logic.
**Domain layer**:
- Serves as the system's core and central truth.
- Facilitates communication between the application and presentation layers through the domain model.
**Infrastructure layer**:
- Manages technical implementations without dependencies on other layers or domain knowledge.
Application layer depends on and consumes domain layer. [Presentation layer](./presentation.md) consumes and depends on application layer along with domain layer. Application and presentation layers can communicate through domain model.
![DDD + vue.js](./../img/architecture/app-ddd.png)
@@ -39,8 +27,6 @@ Application uses highly decoupled models & services in different DDD layers:
State handling uses an event-driven subscription model to signal state changes and special functions to register changes. It does not depend on third party packages.
The presentation layer can read and modify state through the context. State changes trigger events that components can subscribe to for reactivity.
Each layer treat application layer differently.
![State](./../img/architecture/app-state.png)
@@ -59,7 +45,7 @@ Each layer treat application layer differently.
- So state is mutable, and fires related events when mutated.
- 📖 Read more: [application.md | Application state](./application.md#application-state).
It's comparable with `flux`, `vuex`, and `pinia`. A difference is that mutable application layer state in privacy.sexy is mutable and lies in single "store" that holds app state and logic. The "actions" mutate the state directly which in turns act as dispatcher to notify its own event subscriptions (callbacks).
It's comparable with flux ([`redux`](https://redux.js.org/)) or flux-like ([`vuex`](https://vuex.vuejs.org/)) patterns. Flux component "view" is [presentation layer](./presentation.md) in Vue. Flux functions "dispatcher", "store" and "action creation" functions lie in the [application layer](./application.md). A difference is that application state in privacy.sexy is mutable and lies in single flux "store" that holds app state and logic. The "actions" mutate the state directly which in turns act as dispatcher to notify its own event subscriptions (callbacks).
## AWS infrastructure

View File

@@ -18,9 +18,9 @@ You could run other types of tests as well, but they may take longer time and ov
- Run unit tests: `npm run test:unit`
- Run integration tests: `npm run test:integration`
- Run end-to-end (e2e) tests:
- `npm run test:cy:open`: Run tests interactively using the development server with hot-reloading.
- `npm run test:cy:run`: Run tests on the production build in a headless mode.
- Run e2e (end-to-end) tests
- Interactive mode with GUI: `npm run test:e2e`
- Headless mode without GUI: `npm run test:e2e -- --headless`
📖 Read more about testing in [tests](./tests.md).
@@ -35,25 +35,11 @@ You could run other types of tests as well, but they may take longer time and ov
### Running
**Web:**
- Run in local server: `npm run dev`
- Run in local server: `npm run serve`
- 💡 Meant for local development with features such as hot-reloading.
- Preview production build: `npm run preview`
- Start a local web server that serves the built solution from `./dist`.
- 💡 Run `npm run build` before `npm run preview`.
**Desktop apps:**
- `npm run electron:dev`: The command will build the main process and preload scripts source code, and start a dev server for the renderer, and start the Electron app.
- `npm run electron:preview`: The command will build the main process, preload scripts and renderer source code, and start the Electron app to preview.
- `npm run electron:prebuild`: The command will build the main process, preload scripts and renderer source code. Usually before packaging the Electron application, you need to execute this command.
- `npm run electron:build`: Prebuilds the Electron application, packages and publishes it through `electron-builder`.
**Docker:**
1. Build: `docker build -t undergroundwires/privacy.sexy:latest .`
2. Run: `docker run -it -p 8080:80 --rm --name privacy.sexy undergroundwires/privacy.sexy:latest`
- Run using Docker:
1. Build: `docker build -t undergroundwires/privacy.sexy:latest .`
2. Run: `docker run -it -p 8080:80 --rm --name privacy.sexy undergroundwires/privacy.sexy:latest`
### Building

View File

@@ -1,34 +1,30 @@
# Presentation layer
The presentation layer handles UI concerns using Vue as JavaScript framework and Electron to provide desktop functionality.
Presentation layer consists of UI-related code. It uses Vue.js as JavaScript framework and includes Vue.js components. It also includes [Electron](https://www.electronjs.org/) to provide functionality to desktop application.
It reflects the [application state](./application.md#application-state) and allows user interactions to modify it. Components manage their own local UI state.
The presentation layer uses an event-driven architecture for bidirectional reactivity between the application state and UI. State change events flow bottom-up to trigger UI updates, while user events flow top-down through components, some ultimately modifying the application state.
It's designed event-driven from bottom to top. It listens user events (from top) and state events (from bottom) to update state or the GUI.
📖 Refer to [architecture.md (Layered Application)](./architecture.md#layered-application) to read more about the layered architecture.
## Structure
- [`/src/` **`presentation/`**](./../src/presentation/): Contains Vue and Electron code.
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins.
- [**`components/`**](./../src/presentation/components/): Contains Vue components and helpers.
- [**`Shared/`**](./../src/presentation/components/Shared): Contains shared Vue components and helpers.
- [**`hooks`**](../src/presentation/components/Shared/Hooks): Hooks used by components through [dependency injection](#dependency-injections).
- [**`/public/`**](../src/presentation/public/): Contains static assets.
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets processed by Vite.
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts.
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles.
- [**`components/`**](./../src/presentation/assets/styles/components): Contains styles for Vue components.
- [**`vendors-extensions/`**](./../src/presentation/assets/styles/third-party-extensions): Contains styles for third-party components.
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Main Sass file, imported by other components as single entrypoint.
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.
- [**`electron/`**](./../src/presentation/electron/): Contains Electron code.
- [`/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.
- [**`/vite.config.ts`**](./../vite.config.ts): Contains Vite configurations for building web application.
- [**`/electron.vite.config.ts`**](./../electron.vite.config.ts): Contains Vite configurations for building desktop applications.
- [**`/postcss.config.cjs`**](./../postcss.config.cjs): Contains PostCSS configurations for Vite.
- [`/src/` **`presentation/`**](./../src/presentation/): Contains all presentation related code including Vue and Electron configurations
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue global objects including components and plugins.
- [**`components/`**](./../src/presentation/components/): Contains all Vue components and their helper classes.
- [**`Shared/`**](./../src/presentation/components/Shared): Contains Vue components and component helpers that other components share.
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets that webpack will process.
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles used throughout different components.
- [**`components/`**](./../src/presentation/assets/styles/components): Contains reusable styles coupled to a Vue/HTML component.
- [**`vendors-extensions/`**](./../src/presentation/assets/styles/third-party-extensions): Contains styles that override third-party components used.
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Primary Sass file, passes along all other styles, should be the single file used from other components.
- [**`main.ts`**](./../src/presentation/main.ts): Application entry point that mounts and starts Vue application.
- [**`electron/`**](./../src/presentation/electron/): Electron configuration for the desktop application.
- [**`main.ts`**](./../src/presentation/main.ts): Main process of Electron, started as first thing when app starts.
- [**`/public/`**](./../public/): Contains static assets that are directly copied and do not go through webpack.
- [**`/vue.config.js`**](./../vue.config.js): Global Vue CLI configurations loaded by `@vue/cli-service`.
- [**`/postcss.config.js`**](./../postcss.config.js): PostCSS configurations used by Vue CLI internally.
- [**`/babel.config.js`**](./../babel.config.js): Babel configurations for polyfills used by `@vue/cli-plugin-babel`.
## Visual design best-practices
@@ -36,7 +32,7 @@ Add visual clues for clickable items. It should be as clear as possible that the
## Application data
Components (should) use [`UseApplication`](./../src/presentation/components/Shared/Hooks/UseApplication.ts) to reach the application domain to avoid [parsing and compiling](./application.md#parsing-and-compiling) the application again.
Components (should) use [ApplicationFactory](./../src/application/ApplicationFactory.ts) singleton to reach the application domain to avoid [parsing and compiling](./application.md#parsing-and-compiling) the application again.
[Application.ts](../src/domain/Application.ts) is an immutable domain model that represents application state. It includes:
@@ -47,49 +43,32 @@ You can read more about how application layer provides application data to he pr
## Application state
This project uses a singleton instance of the application state, making it available to all Vue components.
Inheritance of a Vue components marks whether it uses application state . Components that does not handle application state extends `Vue`. Stateful components mutate or/and react to state changes (such as user selection or search queries) in [ApplicationContext](./../src/application/Context/ApplicationContext.ts) extend [`StatefulVue`](./../src/presentation/components/Shared/StatefulVue.ts) class to access the context / state.
The decision to not use third-party state management libraries like [`vuex`](https://web.archive.org/web/20230801191617/https://vuex.vuejs.org/) or [`pinia`](https://web.archive.org/web/20230801191743/https://pinia.vuejs.org/) was made to promote code independence and enhance portability.
[`StatefulVue`](./../src/presentation/components/Shared/StatefulVue.ts) functions include:
Stateful components can mutate and/or react to state changes (e.g., user selection, search queries) in the [ApplicationContext](./../src/application/Context/ApplicationContext.ts). Vue components import [`CollectionState.ts`](./../src/presentation/components/Shared/Hooks/UseCollectionState.ts) to access both the application context and the state.
- Creating a singleton of the state and makes it available to presentation layer as single source of truth.
- Providing virtual abstract `handleCollectionState` callback that it calls when
- the Vue loads the component,
- and also every time when state changes.
- Providing `events` member to make lifecycling of state subscriptions events easier because it ensures that components unsubscribe from listening to state events when
- the component is no longer used (destroyed),
- an if [ApplicationContext](./../src/application/Context/ApplicationContext.ts) changes the active [collection](./collection-files.md) to a different one.
[`UseCollectionState.ts`](./../src/presentation/components/Shared/Hooks/UseCollectionState.ts) provides several functionalities including:
📖 Refer to [architecture.md | Application State](./architecture.md#application-state) to get an overview of event handling and [application.md | Application State](./presentation.md#application-state) for deeper look into how the application layer manages state.
- **Singleton State Instance**: It creates a singleton instance of the state, which is shared across the presentation layer. The singleton instance ensures that there's a single source of truth for the application's state.
- **State Change Callback and Lifecycle Management**: It offers a mechanism to register callbacks, which will be invoked when the state initializes or mutates. It ensures that components unsubscribe from state events when they are no longer in use or when [ApplicationContext](./../src/application/Context/ApplicationContext.ts) switches the active [collection](./collection-files.md).
- **State Access and Modification**: It provides functions to read and mutate for accessing and modifying the state, encapsulating the details of these operations.
- **Event Subscription Lifecycle Management**: Includes an `events` member that simplifies state subscription lifecycle events. This ensures that components unsubscribe from state events when they are no longer in use, or when [ApplicationContext](./../src/application/Context/ApplicationContext.ts) switches the active [collection](./collection-files.md).
## Modals
📖 Refer to [architecture.md | Application State](./architecture.md#application-state) for an overview of event handling and [application.md | Application State](./presentation.md#application-state) for an in-depth understanding of state management in the application layer.
[Dialog.vue](./../src/presentation/components/Shared/Dialog.vue) is a shared component that other components used to show modal windows.
## Dependency injections
You can use it by wrapping the content inside of its `slot` and call `.show()` function on its reference. For example:
The presentation layer uses Vue's native dependency injection system to increase testability and decouple components.
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:
- **Singletons**: Shared across components, instantiated once.
- **Transients**: Factories yielding a new instance on every access.
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.
3. **Inject the dependency**: Use Vue's `inject` method alongside the defined symbol to incorporate the dependency into components.
- For singletons, invoke the factory method: `inject(symbolKey)()`.
- For transients, directly inject: `inject(symbolKey)`.
## Shared UI components
Shared UI components promote consistency and simplifies the creation of the front-end.
In order to maintain portability and easy maintainability, the preference is towards using homegrown components over third-party ones or comprehensive UI frameworks like Quasar.
Shared components include:
- [ModalDialog.vue](./../src/presentation/components/Shared/Modal/ModalDialog.vue) is utilized for rendering modal windows.
- [TooltipWrapper.vue](./../src/presentation/components/Shared/TooltipWrapper.vue) acts as a wrapper for rendering tooltips.
## Desktop builds
Desktop builds uses `electron-vite` to bundle the code, and `electron-builder` to build and publish the packages.
```html
<Dialog ref="testDialog">
<div>Hello world</div>
</Dialog>
<div @click="$refs.testDialog.show()">Show dialog</div>
```
## Sass naming convention

View File

@@ -5,79 +5,77 @@ There are different types of tests executed:
1. [Unit tests](#unit-tests)
2. [Integration tests](#integration-tests)
3. [End-to-end (E2E) tests](#e2e-tests)
4. [Automated checks](#automated-checks)
## Unit and integration tests
Common aspects for all tests:
- They utilize [Vitest](https://vitest.dev/).
- Test files are suffixed with `.spec.ts`.
- They use [Mocha](https://mochajs.org/) and [Chai](https://www.chaijs.com/).
- Their files end with `.spec.{ts|js}` suffix.
💡 You can use path/module alias `@/tests` in import statements.
## Unit tests
- Unit tests test each component in isolation.
- All unit tests goes under [`./tests/unit`](./../tests/unit).
- They rely on [stubs](./../tests/unit/shared/Stubs) for isolation.
### Unit tests structure
- [`./src/`](./../src/)
- Includes source code that unit tests will test.
- [`./tests/unit/`](./../tests/unit/)
- Includes test code.
- Tests follow same folder structure as [`./src/`](./../src).
- E.g. if system under test lies in [`./src/application/ApplicationFactory.ts`](./../src/application/ApplicationFactory.ts) then its tests would be in test would be at [`./tests/unit/application/ApplicationFactory.spec.ts`](./../tests/unit/application/ApplicationFactory.spec.ts).
- [`shared/`](./../tests/unit/shared/)
- Includes common functionality that's shared across unit tests.
- [`Assertions/`](./../tests/unit/shared/Assertions):
- Common assertions that extend [Chai Assertion Library](https://www.chaijs.com/).
- Asserting functions should start with `expect` prefix.
- [`TestCases/`](./../tests/unit/shared/TestCases/)
- Shared test cases.
- Functions that calls `it()` from [Mocha test framework](https://mochajs.org/) should have `it` prefix.
- E.g. `itEachAbsentCollectionValue()`.
- [`Stubs/`](./../tests/unit/shared/Stubs)
- Includes stubs to be able to test components in isolation.
- Stubs have minimal and dummy behavior to be functional, they may also have spying or mocking functions.
### Unit tests naming
- Each test suite first describe the system under test.
- E.g. tests for class `Application.ts` are all inside `Application.spec.ts`.
- `describe` blocks tests for same function (if applicable).
- E.g. test for `run()` are inside `describe('run', () => ..)`.
### Act, arrange, assert
- Tests implement the act, arrange, and assert (AAA) pattern.
- Tests use act, arrange and assert (AAA) pattern when applicable.
- **Arrange**
- Sets up the test scenario and environment.
- Begins with comment line `// arrange`.
- Sets up the test case.
- Starts with comment line `// arrange`.
- **Act**
- Executes the actual test.
- Begins with comment line `// act`.
- Starts with comment line `// act`.
- **Assert**
- Sets an expectation for the test's outcome.
- Begins with comment line `// assert`.
- Elicit some sort of expectation.
- Starts with comment line `// assert`.
### Unit tests
## Integration tests
- Evaluate individual components in isolation.
- Located in [`./tests/unit`](./../tests/unit).
- Achieve isolation using [stubs](./../tests/unit/shared/Stubs).
- Include Vue component tests, enabled by `@vue/test-utils`.
#### Unit tests naming
- Test suites start with a description of the component or system under test.
- E.g., tests for `Application.ts` are contained in `Application.spec.ts`.
- Whenever possible, `describe` blocks group tests of the same function.
- E.g., tests for `run()` are inside `describe('run', () => ...)`.
### Integration tests
- Assess the combined functionality of components.
- They verify that third-party dependencies function as anticipated.
- Tests functionality of a component in combination with others (not isolated).
- Ensure dependencies to third parties work as expected.
- Defined in [./tests/integration](./../tests/integration).
## E2E tests
- Examine the live web application's functionality and performance.
- Uses Cypress to run the tests.
## Automated checks
These checks validate various qualities like runtime execution, building process, security testing, etc.
- Use [various tools](./../package.json) and [scripts](./../scripts).
- Are automatically executed as [GitHub workflows](./../.github/workflows).
## Tests structure
- [`package.json`](./../package.json): Defines test commands and includes tools used in tests.
- [`vite.config.ts`](./../vite.config.ts): Configures `vitest` for unit and integration tests.
- [`./src/`](./../src/): Contains the code subject to testing.
- [`./tests/shared/`](./../tests/shared/): Contains code shared by different test categories.
- [`bootstrap/setup.ts`](./../tests/shared/bootstrap/setup.ts): Initializes unit and integration tests.
- [`./tests/unit/`](./../tests/unit/)
- Stores unit test code.
- 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).
- [`shared/`](./../tests/unit/shared/)
- Contains shared unit test functionalities.
- [`Assertions/`](./../tests/unit/shared/Assertions): Contains common assertion functions, prefixed with `expect`.
- [`TestCases/`](./../tests/unit/shared/TestCases/)
- Shared test cases.
- 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.
- [`./tests/integration/`](./../tests/integration/): Contains integration test files.
- [`cypress.config.ts`](./../cypress.config.ts): Cypress (E2E tests) configuration file.
- [`./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.
- *(git ignored)* `/videos`: Asset folder for videos taken during tests.
- *(git ignored)* `/screenshots`: Asset folder for Screenshots taken during tests.
- Test the functionality and performance of a running application.
- Vue CLI plugin [`e2e-cypress`](https://github.com/vuejs/vue-cli/tree/dev/packages/@vue/cli-plugin-e2e-cypress#readme) configures E2E tests.
- Test names and folders have logical structure based on tests executed.
- The structure is following:
- [`cypress.config.ts`](./../cypress.config.ts): Cypress configuration file.
- [`./tests/e2e/`](./../tests/e2e/): Base Cypress folder.
- [`/specs/`](./../tests/e2e/specs/): Test files named with `.spec.js` extension.
- [`/plugins/index.js`](./../tests/e2e/plugins/index.js): Plugin file executed before loading project.
- [`/support/index.js`](./../tests/e2e/support/index.js): Support file, runs before every single spec file.
- *(Ignored)* `/videos`: Asset folder for videos taken during tests.
- *(Ignored)* `/screenshots`: Asset folder for Screenshots taken during tests.

View File

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

View File

@@ -1,68 +0,0 @@
import { resolve } from 'path';
import { mergeConfig, UserConfig } from 'vite';
import { defineConfig, externalizeDepsPlugin } from 'electron-vite';
import { getAliasesFromTsConfig, getClientEnvironmentVariables } from './vite-config-helper';
import { createVueConfig } from './vite.config';
const MAIN_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/main/index.ts');
const PRELOAD_ENTRY_FILE = resolvePathFromProjectRoot('src/presentation/electron/preload/index.ts');
const WEB_INDEX_HTML_PATH = resolvePathFromProjectRoot('src/presentation/index.html');
const DIST_DIR = resolvePathFromProjectRoot('dist_electron/');
export default defineConfig({
main: getSharedElectronConfig({
distDirSubfolder: 'main',
entryFilePath: MAIN_ENTRY_FILE,
}),
preload: getSharedElectronConfig({
distDirSubfolder: 'preload',
entryFilePath: PRELOAD_ENTRY_FILE,
}),
renderer: mergeConfig(
createVueConfig({
supportLegacyBrowsers: false,
}),
{
build: {
outDir: resolve(DIST_DIR, 'renderer'),
rollupOptions: {
input: {
index: WEB_INDEX_HTML_PATH,
},
},
},
},
),
});
function getSharedElectronConfig(options: {
readonly distDirSubfolder: string;
readonly entryFilePath: string;
}): UserConfig {
return {
build: {
outDir: resolve(DIST_DIR, options.distDirSubfolder),
lib: {
entry: options.entryFilePath,
},
rollupOptions: {
output: {
entryFileNames: '[name].cjs', // This is needed so `type="module"` works
},
},
},
plugins: [externalizeDepsPlugin()],
define: {
...getClientEnvironmentVariables(),
},
resolve: {
alias: {
...getAliasesFromTsConfig(),
},
},
};
}
function resolvePathFromProjectRoot(pathSegment: string) {
return resolve(__dirname, pathSegment);
}

View File

@@ -4,6 +4,9 @@ This folder contains image files and other resources related to images.
## logo.svg
[`logo.svg`](./logo.svg) serves as the primary logo from which all other icons and images are derived.
Only modify this file manually.
After making changes, execute `npm run build:icons` to regenerate logo files in various formats.
[logo.svg](./logo.svg) is the master logo from which all other icons or images are created from.
It should be the only file that will be changed manually.
[`logo-update.mjs`](./logo-update.mjs) script in this folder updates all the logo files.
It should be executed everytime the logo is changed.
It automates recreation of logo files in different formats.

View File

@@ -8,7 +8,7 @@ class Paths {
constructor(selfDirectory) {
const projectRoot = resolve(selfDirectory, '../');
this.sourceImage = join(projectRoot, 'img/logo.svg');
this.publicDirectory = join(projectRoot, 'src/presentation/public');
this.publicDirectory = join(projectRoot, 'public');
this.electronBuildDirectory = join(projectRoot, 'build');
}
@@ -61,7 +61,7 @@ async function updateDesktopIcons(sourceImage, electronIconsDir) {
await ensureFolderExists(electronIconsDir);
const temporaryDir = await mkdtemp('icon-');
const temporaryPngFile = join(temporaryDir, 'icon.png');
console.log(`Converting from SVG (${sourceImage}) to PNG: ${temporaryPngFile}`); // required by `icon-builder`
console.log(`Converting from SVG (${sourceImage}) to PNG: ${temporaryPngFile}`); // required by icon-builder
await runCommand(
'npx',
'svgexport',

25983
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,35 +1,29 @@
{
"name": "privacy.sexy",
"version": "0.12.1",
"version": "0.12.0",
"private": true,
"slogan": "Now you have the choice",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
"author": "undergroundwires",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"test:unit": "vitest run --dir tests/unit",
"test:integration": "vitest run --dir tests/integration",
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit",
"test:e2e": "vue-cli-service test:e2e",
"test:cy:run": "start-server-and-test \"vite build && vite preview --port 7070\" http://localhost:7070 \"cypress run --config baseUrl=http://localhost:7070\"",
"test:cy:open": "start-server-and-test \"vite --port 7070 --mode production\" http://localhost:7070 \"cypress open --config baseUrl=http://localhost:7070\"",
"lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml",
"icons:build": "node scripts/logo-update.js",
"electron:dev": "electron-vite dev",
"electron:preview": "electron-vite preview",
"electron:prebuild": "electron-vite build",
"electron:build": "electron-builder",
"lint:eslint": "eslint .",
"create-icons": "node img/logo-update.mjs",
"electron:build": "vue-cli-service electron:build",
"electron:serve": "vue-cli-service electron:serve",
"lint:eslint": "vue-cli-service lint --no-fix --mode production",
"lint:md": "markdownlint **/*.md --ignore node_modules",
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
"lint:md:relative-urls": "remark . --frail --use remark-validate-links",
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
"postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps"
"postuninstall": "electron-builder install-app-deps",
"test:integration": "vue-cli-service test:unit \"tests/integration/**/*.spec.ts\""
},
"main": "./dist_electron/main/index.cjs",
"main": "index.js",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/free-brands-svg-icons": "^6.4.0",
@@ -38,6 +32,7 @@
"@fortawesome/vue-fontawesome": "^2.0.9",
"@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.23.4",
"core-js": "^3.32.0",
"cross-fetch": "^4.0.0",
"electron-progressbar": "^2.1.0",
"file-saver": "^2.0.5",
@@ -46,21 +41,28 @@
"markdown-it": "^13.0.1",
"npm": "^9.8.1",
"v-tooltip": "2.1.3",
"vue": "^2.7.14"
"vue": "^2.7.14",
"vue-class-component": "^7.2.6",
"vue-js-modal": "^2.0.1",
"vue-property-decorator": "^9.1.2"
},
"devDependencies": {
"@modyfi/vite-plugin-yaml": "^1.0.4",
"@rushstack/eslint-patch": "^1.3.2",
"@types/ace": "^0.0.48",
"@types/chai": "^4.3.5",
"@types/file-saver": "^2.0.5",
"@types/mocha": "^10.0.1",
"@typescript-eslint/eslint-plugin": "^5.62.0",
"@typescript-eslint/parser": "^5.62.0",
"@vitejs/plugin-legacy": "^4.1.1",
"@vitejs/plugin-vue2": "^2.2.0",
"@vue/cli-plugin-babel": "~5.0.8",
"@vue/cli-plugin-e2e-cypress": "~5.0.8",
"@vue/cli-plugin-eslint": "~5.0.8",
"@vue/cli-plugin-typescript": "~5.0.8",
"@vue/cli-plugin-unit-mocha": "~5.0.8",
"@vue/cli-service": "~5.0.8",
"@vue/eslint-config-airbnb-with-typescript": "^7.0.0",
"@vue/eslint-config-typescript": "^11.0.3",
"@vue/test-utils": "^1.3.6",
"autoprefixer": "^10.4.15",
"chai": "^4.3.7",
"cypress": "^12.17.2",
"electron": "^25.3.2",
"electron-builder": "^24.6.3",
@@ -68,32 +70,32 @@
"electron-icon-builder": "^2.0.1",
"electron-log": "^4.4.8",
"electron-updater": "^6.1.4",
"electron-vite": "^1.0.27",
"eslint": "^8.46.0",
"eslint-plugin-cypress": "^2.14.0",
"eslint-plugin-import": "^2.26.0",
"eslint-plugin-vue": "^9.6.0",
"eslint-plugin-vuejs-accessibility": "^1.2.0",
"icon-gen": "^3.0.1",
"jsdom": "^22.1.0",
"js-yaml-loader": "^1.2.2",
"markdownlint-cli": "^0.35.0",
"postcss": "^8.4.28",
"remark-cli": "^11.0.0",
"remark-lint-no-dead-urls": "^1.1.0",
"remark-preset-lint-consistent": "^5.1.2",
"remark-validate-links": "^12.1.1",
"sass": "^1.64.1",
"start-server-and-test": "^2.0.0",
"sass-loader": "^13.3.2",
"svgexport": "^0.4.2",
"terser": "^5.19.2",
"tslib": "~2.4.0",
"ts-loader": "^9.4.4",
"typescript": "~4.6.2",
"vite": "^4.4.9",
"vitest": "^0.34.2",
"vue-tsc": "^1.8.8",
"yaml-lint": "^1.7.0"
"vue-cli-plugin-electron-builder": "^3.0.0-alpha.4",
"yaml-lint": "^1.7.0",
"tslib": "~2.4.0"
},
"overrides": {
"vue-cli-plugin-electron-builder": {
"electron-builder": "^24.6.3"
}
},
"//devDependencies": {
"terser": "Used by @vitejs/plugin-legacy for minification",
"typescript": [
"Cannot upgrade to 5.X.X due to unmaintained @vue/cli-plugin-typescript, https://github.com/vuejs/vue-cli/issues/7401",
"Cannot upgrade to > 4.6.X otherwise unit tests do not work, https://github.com/evanw/node-source-map-support/issues/252"

View File

@@ -1,9 +0,0 @@
const autoprefixer = require('autoprefixer');
module.exports = () => {
return {
plugins: [
autoprefixer(),
],
};
};

5
postcss.config.js Normal file
View File

@@ -0,0 +1,5 @@
module.exports = {
plugins: {
autoprefixer: {},
},
};

View File

Before

Width:  |  Height:  |  Size: 353 KiB

After

Width:  |  Height:  |  Size: 353 KiB

View File

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

View File

@@ -1,4 +1,13 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Privacy is sexy 🍑🍆 - Enforce privacy & security on Windows, macOS and Linux</title>
<meta name="robots" content="index,follow" />
<meta name="description" content="Web tool to generate scripts for enforcing privacy & security best-practices such as stopping data collection of Windows and different softwares on it."/>
<link rel="icon" href="<%= BASE_URL %>favicon.ico">
</head>
<body>
<noscript>
<style>
@@ -19,5 +28,7 @@
<p>The page does not work without JavaScript enabled. Please enable it to use privacy.sexy. There's no shady stuff as 100% of the website is open source.</p>
</div>
</noscript>
<script type="module" src="/src/main.js"></script>
<div id="app"></div>
<!-- built files will be auto injected -->
</body>
</html>

View File

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

View File

@@ -1,35 +0,0 @@
# check-desktop-runtime-errors
This script automates the processes of:
1) Building
2) Packaging
3) Installing
4) Executing
5) Verifying Electron distributions
It runs the application for a duration and detects runtime errors in the packaged application via:
- **Log verification**: Checking application logs for errors and validating successful application initialization.
- **`stderr` monitoring**: Continuous listening to the `stderr` stream for unexpected errors.
- **Window title inspection**: Checking for window titles that indicate crashes before logging becomes possible.
Upon error, the script captures a screenshot (if `--screenshot` is provided) and terminates.
## Usage
```sh
node ./scripts/check-desktop-runtime-errors
```
## Options
- `--build`: Clears the electron distribution directory and forces a rebuild of the Electron app.
- `--screenshot`: Takes a screenshot of the desktop environment after running the application.
This module provides utilities for building, executing, and validating Electron desktop apps.
It can be used to automate checking for runtime errors during development.
## Configs
Configurations are defined in [`config.js`](./config.js).

View File

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

View File

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

View File

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

View File

@@ -1,66 +0,0 @@
import { runCommand } from '../../utils/run-command.js';
import { findSingleFileByExtension, exists } from '../../utils/io.js';
import { log, die, LOG_LEVELS } from '../../utils/log.js';
export async function prepareMacOsApp(desktopDistPath) {
const { absolutePath: dmgPath } = await findSingleFileByExtension('dmg', desktopDistPath);
const { mountPath } = await mountDmg(dmgPath);
const appPath = await findMacAppExecutablePath(mountPath);
return {
appExecutablePath: appPath,
cleanup: async () => {
log('Cleaning up resources...');
await detachMount(mountPath);
},
};
}
async function mountDmg(dmgFile) {
const { stdout: hdiutilOutput, error } = await runCommand(`hdiutil attach '${dmgFile}'`);
if (error) {
die(`Failed to mount DMG file at ${dmgFile}.\n${error}`);
}
const mountPathMatch = hdiutilOutput.match(/\/Volumes\/[^\n]+/);
const mountPath = mountPathMatch ? mountPathMatch[0] : null;
return {
mountPath,
};
}
async function findMacAppExecutablePath(mountPath) {
const { stdout: findOutput, error } = await runCommand(
`find '${mountPath}' -maxdepth 1 -type d -name "*.app"`,
);
if (error) {
die(`Failed to find executable path at mount path ${mountPath}\n${error}`);
}
const appFolder = findOutput.trim();
const appName = appFolder.split('/').pop().replace('.app', '');
const appPath = `${appFolder}/Contents/MacOS/${appName}`;
if (await exists(appPath)) {
log(`Application is located at ${appPath}`);
} else {
die(`Application does not exist at ${appPath}`);
}
return appPath;
}
async function detachMount(mountPath, retries = 5) {
const { error } = await runCommand(`hdiutil detach '${mountPath}'`);
if (error) {
if (retries <= 0) {
log(`Failed to detach mount after multiple attempts: ${mountPath}\n${error}`, LOG_LEVELS.WARN);
return;
}
await sleep(500);
await detachMount(mountPath, retries - 1);
return;
}
log(`Successfully detached from ${mountPath}`);
}
function sleep(milliseconds) {
return new Promise((resolve) => {
setTimeout(resolve, milliseconds);
});
}

View File

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

View File

@@ -1,164 +0,0 @@
import { spawn } from 'child_process';
import { log, LOG_LEVELS, die } from '../utils/log.js';
import { captureScreen } from './system-capture/screen-capture.js';
import { captureWindowTitles } from './system-capture/window-title-capture.js';
const TERMINATION_GRACE_PERIOD_IN_SECONDS = 60;
const TERMINATION_CHECK_INTERVAL_IN_MS = 1000;
const WINDOW_TITLE_CAPTURE_INTERVAL_IN_MS = 100;
export function runApplication(
appFile,
executionDurationInSeconds,
enableScreenshot,
screenshotPath,
) {
if (!appFile) {
throw new Error('Missing app file');
}
logDetails(appFile, executionDurationInSeconds);
const processDetails = {
stderrData: '',
stdoutData: '',
explicitlyKilled: false,
windowTitles: [],
isCrashed: false,
isDone: false,
process: undefined,
resolve: () => { /* NOOP */ },
};
const process = spawn(appFile);
processDetails.process = process;
return new Promise((resolve) => {
processDetails.resolve = resolve;
handleTitleCapture(process.pid, processDetails);
handleProcessEvents(
processDetails,
enableScreenshot,
screenshotPath,
executionDurationInSeconds,
);
});
}
function logDetails(appFile, executionDurationInSeconds) {
log(
[
'Executing the app to check for errors...',
`Maximum execution time: ${executionDurationInSeconds}`,
`Application path: ${appFile}`,
].join('\n\t'),
);
}
function handleTitleCapture(processId, processDetails) {
const capture = async () => {
const titles = await captureWindowTitles(processId);
(titles || []).forEach((title) => {
if (!title || !title.length) {
return;
}
if (!processDetails.windowTitles.includes(title)) {
log(`New window title captured: ${title}`);
processDetails.windowTitles.push(title);
}
});
if (!processDetails.isDone) {
setTimeout(capture, WINDOW_TITLE_CAPTURE_INTERVAL_IN_MS);
}
};
capture();
}
function handleProcessEvents(
processDetails,
enableScreenshot,
screenshotPath,
executionDurationInSeconds,
) {
const { process } = processDetails;
process.stderr.on('data', (data) => {
processDetails.stderrData += data.toString();
});
process.stdout.on('data', (data) => {
processDetails.stdoutData += data.toString();
});
process.on('error', (error) => {
die(`An issue spawning the child process: ${error}`, LOG_LEVELS.ERROR);
});
process.on('exit', async (code) => {
await onProcessExit(code, processDetails, enableScreenshot, screenshotPath);
});
setTimeout(async () => {
await onExecutionLimitReached(process, processDetails, enableScreenshot, screenshotPath);
}, executionDurationInSeconds * 1000);
}
async function onProcessExit(code, processDetails, enableScreenshot, screenshotPath) {
log(`Application exited ${code === null || Number.isNaN(code) ? '.' : `with code ${code}`}`);
if (processDetails.explicitlyKilled) return;
processDetails.isCrashed = true;
if (enableScreenshot) {
await captureScreen(screenshotPath);
}
finishProcess(processDetails);
}
async function onExecutionLimitReached(process, processDetails, enableScreenshot, screenshotPath) {
if (enableScreenshot) {
await captureScreen(screenshotPath);
}
processDetails.explicitlyKilled = true;
await terminateGracefully(process);
finishProcess(processDetails);
}
function finishProcess(processDetails) {
processDetails.isDone = true;
processDetails.resolve({
stderr: processDetails.stderrData,
stdout: processDetails.stdoutData,
windowTitles: [...processDetails.windowTitles],
isCrashed: processDetails.isCrashed,
});
}
async function terminateGracefully(process) {
let elapsedSeconds = 0;
log('Attempting to terminate the process gracefully...');
process.kill('SIGTERM');
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
elapsedSeconds += TERMINATION_CHECK_INTERVAL_IN_MS / 1000;
if (!process.killed) {
if (elapsedSeconds >= TERMINATION_GRACE_PERIOD_IN_SECONDS) {
process.kill('SIGKILL');
log('Process did not terminate gracefully within the grace period. Forcing termination.', LOG_LEVELS.WARN);
clearInterval(checkInterval);
resolve();
}
} else {
log('Process terminated gracefully.');
clearInterval(checkInterval);
resolve();
}
}, TERMINATION_CHECK_INTERVAL_IN_MS);
});
}

View File

@@ -1,59 +0,0 @@
import { unlink } from 'fs/promises';
import { runCommand } from '../../utils/run-command.js';
import { log, LOG_LEVELS } from '../../utils/log.js';
import { CURRENT_PLATFORM, SUPPORTED_PLATFORMS } from '../../utils/platform.js';
import { exists } from '../../utils/io.js';
export async function captureScreen(imagePath) {
if (!imagePath) {
throw new Error('Path for screenshot not provided');
}
if (await exists(imagePath)) {
log(`Screenshot file already exists at ${imagePath}. It will be overwritten.`, LOG_LEVELS.WARN);
unlink(imagePath);
}
const platformCommands = {
[SUPPORTED_PLATFORMS.MAC]: `screencapture -x ${imagePath}`,
[SUPPORTED_PLATFORMS.LINUX]: `import -window root ${imagePath}`,
[SUPPORTED_PLATFORMS.WINDOWS]: `powershell -NoProfile -EncodedCommand ${encodeForPowershell(getScreenshotPowershellScript(imagePath))}`,
};
const commandForPlatform = platformCommands[CURRENT_PLATFORM];
if (!commandForPlatform) {
log(`Screenshot capture not supported on: ${CURRENT_PLATFORM}`, LOG_LEVELS.WARN);
return;
}
log(`Capturing screenshot to ${imagePath} using command:\n\t> ${commandForPlatform}`);
const { error } = await runCommand(commandForPlatform);
if (error) {
log(`Failed to capture screenshot.\n${error}`, LOG_LEVELS.WARN);
return;
}
log(`Captured screenshot to ${imagePath}.`);
}
function getScreenshotPowershellScript(imagePath) {
return `
$ProgressPreference = 'SilentlyContinue' # Do not pollute stderr
Add-Type -AssemblyName System.Windows.Forms
$screenBounds = [System.Windows.Forms.Screen]::PrimaryScreen.Bounds
$bmp = New-Object System.Drawing.Bitmap $screenBounds.Width, $screenBounds.Height
$graphics = [System.Drawing.Graphics]::FromImage($bmp)
$graphics.CopyFromScreen([System.Drawing.Point]::Empty, [System.Drawing.Point]::Empty, $screenBounds.Size)
$bmp.Save('${imagePath}')
$graphics.Dispose()
$bmp.Dispose()
`;
}
function encodeForPowershell(script) {
const buffer = Buffer.from(script, 'utf-16le');
return buffer.toString('base64');
}

View File

@@ -1,113 +0,0 @@
import { runCommand } from '../../utils/run-command.js';
import { log, LOG_LEVELS } from '../../utils/log.js';
import { SUPPORTED_PLATFORMS, CURRENT_PLATFORM } from '../../utils/platform.js';
export async function captureWindowTitles(processId) {
if (!processId) { throw new Error('Missing process ID.'); }
const captureFunction = windowTitleCaptureFunctions[CURRENT_PLATFORM];
if (!captureFunction) {
log(`Cannot capture window title, unsupported OS: ${CURRENT_PLATFORM}`, LOG_LEVELS.WARN);
return undefined;
}
return captureFunction(processId);
}
const windowTitleCaptureFunctions = {
[SUPPORTED_PLATFORMS.MAC]: captureTitlesOnMac,
[SUPPORTED_PLATFORMS.LINUX]: captureTitlesOnLinux,
[SUPPORTED_PLATFORMS.WINDOWS]: captureTitlesOnWindows,
};
async function captureTitlesOnWindows(processId) {
if (!processId) { throw new Error('Missing process ID.'); }
const { stdout: tasklistOutput, error } = await runCommand(
`tasklist /FI "PID eq ${processId}" /fo list /v`,
);
if (error) {
log(`Failed to retrieve window title.\n${error}`, LOG_LEVELS.WARN);
return [];
}
const match = tasklistOutput.match(/Window Title:\s*(.*)/);
if (match && match[1]) {
const title = match[1].trim();
if (title === 'N/A') {
return [];
}
return [title];
}
return [];
}
async function captureTitlesOnLinux(processId) {
if (!processId) { throw new Error('Missing process ID.'); }
const { stdout: windowIdsOutput, error: windowIdError } = await runCommand(
`xdotool search --pid '${processId}'`,
);
if (windowIdError || !windowIdsOutput) {
return undefined;
}
const windowIds = windowIdsOutput.trim().split('\n');
const titles = await Promise.all(windowIds.map(async (windowId) => {
const { stdout: titleOutput, error: titleError } = await runCommand(
`xprop -id ${windowId} | grep "WM_NAME(STRING)" | cut -d '=' -f 2 | sed 's/^[[:space:]]*"\\(.*\\)"[[:space:]]*$/\\1/'`,
);
if (titleError || !titleOutput) {
return undefined;
}
return titleOutput.trim();
}));
return titles.filter(Boolean);
}
let hasAssistiveAccessOnMac = true;
async function captureTitlesOnMac(processId) {
if (!processId) { throw new Error('Missing process ID.'); }
if (!hasAssistiveAccessOnMac) {
return [];
}
const script = `
tell application "System Events"
try
set targetProcess to first process whose unix id is ${processId}
on error
return
end try
tell targetProcess
if (count of windows) > 0 then
set window_name to name of front window
return window_name
end if
end tell
end tell
`;
const argument = script.trim()
.split(/[\r\n]+/)
.map((line) => `-e '${line.trim()}'`)
.join(' ');
const { stdout: titleOutput, error } = await runCommand(`osascript ${argument}`);
if (error) {
let errorMessage = '';
if (error.includes('-25211')) {
errorMessage += 'Capturing window title requires assistive access. You do not have it.\n';
hasAssistiveAccessOnMac = false;
}
errorMessage += error;
log(errorMessage, LOG_LEVELS.WARN);
return [];
}
const title = titleOutput?.trim();
if (!title) {
return [];
}
return [title];
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,44 +0,0 @@
import { exec } from 'child_process';
import { indentText } from './text.js';
const TIMEOUT_IN_SECONDS = 180;
const MAX_OUTPUT_BUFFER_SIZE = 1024 * 1024; // 1 MB
export function runCommand(commandString, options) {
return new Promise((resolve) => {
options = {
cwd: process.cwd(),
timeout: TIMEOUT_IN_SECONDS * 1000,
maxBuffer: MAX_OUTPUT_BUFFER_SIZE * 2,
...options,
};
exec(commandString, options, (error, stdout, stderr) => {
let errorText;
if (error || stderr?.length > 0) {
errorText = formatError(commandString, error, stdout, stderr);
}
resolve({
stdout,
error: errorText,
});
});
});
}
function formatError(commandString, error, stdout, stderr) {
const errorParts = [
'Error while running command.',
`Command:\n${indentText(commandString, 1)}`,
];
if (error?.toString().trim()) {
errorParts.push(`Error:\n${indentText(error.toString(), 1)}`);
}
if (stderr?.toString().trim()) {
errorParts.push(`stderr:\n${indentText(stderr, 1)}`);
}
if (stdout?.toString().trim()) {
errorParts.push(`stdout:\n${indentText(stdout, 1)}`);
}
return errorParts.join('\n---\n');
}

View File

@@ -1,19 +0,0 @@
export function indentText(text, indentLevel = 1) {
validateText(text);
const indentation = '\t'.repeat(indentLevel);
return splitTextIntoLines(text)
.map((line) => (line ? `${indentation}${line}` : line))
.join('\n');
}
export function splitTextIntoLines(text) {
validateText(text);
return text
.split(/[\r\n]+/);
}
function validateText(text) {
if (typeof text !== 'string') {
throw new Error(`text is not a string. It is: ${typeof text}\n${text}`);
}
}

View File

@@ -1,15 +0,0 @@
export type Constructible<T, TArgs extends unknown[] = never> = {
prototype: T;
apply: (this: unknown, args: TArgs) => void;
};
export type PropertyKeys<T> = {
[K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? never : K;
}[keyof T];
export type ConstructorArguments<T> =
T extends new (...args: infer U) => unknown ? U : never;
export type FunctionKeys<T> = {
[K in keyof T]: T[K] extends (...args: unknown[]) => unknown ? K : never;
}[keyof T];

View File

@@ -1,50 +0,0 @@
/*
Provides a unified and resilient way to extend errors across platforms.
Rationale:
- Babel:
> "Built-in classes cannot be properly subclassed due to limitations in ES5"
> https://web.archive.org/web/20230810014108/https://babeljs.io/docs/caveats#classes
- TypeScript:
> "Extending built-ins like Error, Array, and Map may no longer work"
> https://web.archive.org/web/20230810014143/https://github.com/Microsoft/TypeScript-wiki/blob/main/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
*/
export abstract class CustomError extends Error {
constructor(message?: string, options?: ErrorOptions) {
super(message, options);
fixPrototype(this, new.target.prototype);
ensureStackTrace(this);
this.name = this.constructor.name;
}
}
export const Environment = {
getSetPrototypeOf: () => Object.setPrototypeOf,
getCaptureStackTrace: () => Error.captureStackTrace,
};
function fixPrototype(target: Error, prototype: CustomError) {
// https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget
const setPrototypeOf = Environment.getSetPrototypeOf();
if (!functionExists(setPrototypeOf)) {
return;
}
setPrototypeOf(target, prototype);
}
function ensureStackTrace(target: Error) {
const captureStackTrace = Environment.getCaptureStackTrace();
if (!functionExists(captureStackTrace)) {
// 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.
return;
}
captureStackTrace(target, target.constructor);
}
function functionExists(func: unknown): boolean {
// Not doing truthy/falsy check i.e. if(func) as most values are truthy in JS for robustness
return typeof func === 'function';
}

View File

@@ -1,8 +1,8 @@
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { IApplication } from '@/domain/IApplication';
import { Environment } from '@/infrastructure/Environment/Environment';
import { IEnvironment } from '@/infrastructure/Environment/IEnvironment';
import { Environment } from '../Environment/Environment';
import { IEnvironment } from '../Environment/IEnvironment';
import { IApplicationFactory } from '../IApplicationFactory';
import { ApplicationFactory } from '../ApplicationFactory';
import { ApplicationContext } from './ApplicationContext';

View File

@@ -1,4 +0,0 @@
export enum FilterActionType {
Apply,
Clear,
}

View File

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

View File

@@ -1,14 +0,0 @@
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { FilterActionType } from './FilterActionType';
export interface IFilterChangeDetails {
readonly actionType: FilterActionType;
readonly filter?: IFilterResult;
visit(visitor: IFilterChangeDetailsVisitor): void;
}
export interface IFilterChangeDetailsVisitor {
onClear(): void;
onApply(filter: IFilterResult): void;
}

View File

@@ -1,13 +1,13 @@
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { IFilterResult } from './IFilterResult';
import { IFilterChangeDetails } from './Event/IFilterChangeDetails';
export interface IReadOnlyUserFilter {
readonly currentFilter: IFilterResult | undefined;
readonly filterChanged: IEventSource<IFilterChangeDetails>;
readonly filtered: IEventSource<IFilterResult>;
readonly filterRemoved: IEventSource<void>;
}
export interface IUserFilter extends IReadOnlyUserFilter {
applyFilter(filter: string): void;
clearFilter(): void;
setFilter(filter: string): void;
removeFilter(): void;
}

View File

@@ -4,11 +4,11 @@ import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { FilterResult } from './FilterResult';
import { IFilterResult } from './IFilterResult';
import { IUserFilter } from './IUserFilter';
import { IFilterChangeDetails } from './Event/IFilterChangeDetails';
import { FilterChange } from './Event/FilterChange';
export class UserFilter implements IUserFilter {
public readonly filterChanged = new EventSource<IFilterChangeDetails>();
public readonly filtered = new EventSource<IFilterResult>();
public readonly filterRemoved = new EventSource<void>();
public currentFilter: IFilterResult | undefined;
@@ -16,9 +16,9 @@ export class UserFilter implements IUserFilter {
}
public applyFilter(filter: string): void {
public setFilter(filter: string): void {
if (!filter) {
throw new Error('Filter must be defined and not empty. Use clearFilter() to remove the filter');
throw new Error('Filter must be defined and not empty. Use removeFilter() to remove the filter');
}
const filterLowercase = filter.toLocaleLowerCase();
const filteredScripts = this.collection.getAllScripts().filter(
@@ -33,12 +33,12 @@ export class UserFilter implements IUserFilter {
filter,
);
this.currentFilter = matches;
this.filterChanged.notify(FilterChange.forApply(this.currentFilter));
this.filtered.notify(matches);
}
public clearFilter(): void {
public removeFilter(): void {
this.currentFilter = undefined;
this.filterChanged.notify(FilterChange.forClear());
this.filterRemoved.notify();
}
}

View File

@@ -0,0 +1,89 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector';
import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector';
import { IEnvironment } from './IEnvironment';
export interface IEnvironmentVariables {
readonly window: Window & typeof globalThis;
readonly process: NodeJS.Process;
readonly navigator: Navigator;
}
export class Environment implements IEnvironment {
public static readonly CurrentEnvironment: IEnvironment = new Environment({
window,
process: typeof process !== 'undefined' ? process /* electron only */ : undefined,
navigator,
});
public readonly isDesktop: boolean;
public readonly os: OperatingSystem;
protected constructor(
variables: IEnvironmentVariables,
browserOsDetector: IBrowserOsDetector = new BrowserOsDetector(),
) {
if (!variables) {
throw new Error('variables is null or empty');
}
this.isDesktop = isDesktop(variables);
if (this.isDesktop) {
this.os = getDesktopOsType(getProcessPlatform(variables));
} else {
const userAgent = getUserAgent(variables);
this.os = !userAgent ? undefined : browserOsDetector.detect(userAgent);
}
}
}
function getUserAgent(variables: IEnvironmentVariables): string {
if (!variables.window || !variables.window.navigator) {
return undefined;
}
return variables.window.navigator.userAgent;
}
function getProcessPlatform(variables: IEnvironmentVariables): string {
if (!variables.process || !variables.process.platform) {
return undefined;
}
return variables.process.platform;
}
function getDesktopOsType(processPlatform: string): OperatingSystem | undefined {
// https://nodejs.org/api/process.html#process_process_platform
switch (processPlatform) {
case 'darwin':
return OperatingSystem.macOS;
case 'win32':
return OperatingSystem.Windows;
case 'linux':
return OperatingSystem.Linux;
default:
return undefined;
}
}
function isDesktop(variables: IEnvironmentVariables): boolean {
// More: https://github.com/electron/electron/issues/2288
// Renderer process
if (variables.window
&& variables.window.process
&& variables.window.process.type === 'renderer') {
return true;
}
// Main process
if (variables.process
&& variables.process.versions
&& Boolean(variables.process.versions.electron)) {
return true;
}
// Detect the user agent when the `nodeIntegration` option is set to true
if (variables.navigator
&& variables.navigator.userAgent
&& variables.navigator.userAgent.includes('Electron')) {
return true;
}
return false;
}

View File

@@ -0,0 +1,6 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
export interface IEnvironment {
readonly isDesktop: boolean;
readonly os: OperatingSystem;
}

View File

@@ -7,19 +7,16 @@ import MacOsData from '@/application/collections/macos.yaml';
import LinuxData from '@/application/collections/linux.yaml';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { Application } from '@/domain/Application';
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory';
import { parseCategoryCollection } from './CategoryCollectionParser';
export function parseApplication(
categoryParser = parseCategoryCollection,
informationParser = parseProjectInformation,
metadata: IAppMetadata = AppMetadataFactory.Current.instance,
parser = CategoryCollectionParser,
processEnv: NodeJS.ProcessEnv = process.env,
collectionsData = PreParsedCollections,
): IApplication {
validateCollectionsData(collectionsData);
const information = informationParser(metadata);
const collections = collectionsData.map((collection) => categoryParser(collection, information));
const information = parseProjectInformation(processEnv);
const collections = collectionsData.map((collection) => parser(collection, information));
const app = new Application(information, collections);
return app;
}
@@ -27,12 +24,16 @@ export function parseApplication(
export type CategoryCollectionParserType
= (file: CollectionData, info: IProjectInformation) => ICategoryCollection;
const CategoryCollectionParser: CategoryCollectionParserType = (file, info) => {
return parseCategoryCollection(file, info);
};
const PreParsedCollections: readonly CollectionData [] = [
WindowsData, MacOsData, LinuxData,
];
function validateCollectionsData(collections: readonly CollectionData[]) {
if (!collections?.length) {
if (!collections || !collections.length) {
throw new Error('missing collections');
}
if (collections.some((collection) => !collection)) {

View File

@@ -1,10 +1,11 @@
import { CustomError } from '@/application/Common/CustomError';
import { NodeType } from './NodeType';
import { NodeData } from './NodeData';
export class NodeDataError extends CustomError {
export class NodeDataError extends Error {
constructor(message: string, public readonly context: INodeDataErrorContext) {
super(createMessage(message, context));
Object.setPrototypeOf(this, new.target.prototype); // https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-2.html#support-for-newtarget
this.name = new.target.name;
}
}

View File

@@ -1,29 +1,28 @@
import { IProjectInformation } from '@/domain/IProjectInformation';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
import { Version } from '@/domain/Version';
import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory';
import { ConstructorArguments } from '@/TypeHelpers';
export function
parseProjectInformation(
metadata: IAppMetadata = AppMetadataFactory.Current.instance,
createProjectInformation: ProjectInformationFactory = (
...args
) => new ProjectInformation(...args),
export function parseProjectInformation(
environment: NodeJS.ProcessEnv | VueAppEnvironment,
): IProjectInformation {
const version = new Version(
metadata.version,
);
return createProjectInformation(
metadata.name,
const version = new Version(environment[VueAppEnvironmentKeys.VUE_APP_VERSION]);
return new ProjectInformation(
environment[VueAppEnvironmentKeys.VUE_APP_NAME],
version,
metadata.slogan,
metadata.repositoryUrl,
metadata.homepageUrl,
environment[VueAppEnvironmentKeys.VUE_APP_SLOGAN],
environment[VueAppEnvironmentKeys.VUE_APP_REPOSITORY_URL],
environment[VueAppEnvironmentKeys.VUE_APP_HOMEPAGE_URL],
);
}
export type ProjectInformationFactory = (
...args: ConstructorArguments<typeof ProjectInformation>
) => IProjectInformation;
export const VueAppEnvironmentKeys = {
VUE_APP_VERSION: 'VUE_APP_VERSION',
VUE_APP_NAME: 'VUE_APP_NAME',
VUE_APP_SLOGAN: 'VUE_APP_SLOGAN',
VUE_APP_REPOSITORY_URL: 'VUE_APP_REPOSITORY_URL',
VUE_APP_HOMEPAGE_URL: 'VUE_APP_HOMEPAGE_URL',
} as const;
export type VueAppEnvironment = {
[K in keyof typeof VueAppEnvironmentKeys]: string;
};

View File

@@ -3139,68 +3139,6 @@ actions:
-
category: Disable Windows Defender Firewall # Also known as Windows Firewall, Microsoft Defender Firewall
children:
-
category: Disable Windows Defender Firewall Services and Drivers (breaks Microsoft Store and `netsh advfirewall` CLI)
children:
-
name: Disable Windows Defender Firewall Authorization Driver service
docs:
- http://batcmd.com/windows/10/services/mpsdrv/
# ❗️ Breaks: `netsh advfirewall set`
# Disabling and stopping it breaks "netsh advfirewall set" commands such as
# `netsh advfirewall set allprofiles state on`, `netsh advfirewall set allprofiles state off`.
# More about `netsh firewall` context: https://docs.microsoft.com/en-us/troubleshoot/windows-server/networking/netsh-advfirewall-firewall-control-firewall-behavior
# ! Breaks: Windows Store
# The Windows Defender Firewall service depends on this service.
# Disabling this will also disable the Windows Defender Firewall service, breaking Microsoft Store.
# https://i.imgur.com/zTmtSwT.png
call:
-
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
parameters:
serviceName: mpsdrv # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\mpsdrv").Start
defaultStartupMode: Manual # Alowed values: Boot | System | Automatic | Manual
-
function: RenameSystemFile
parameters:
filePath: '%SystemRoot%\System32\drivers\mpsdrv.sys'
-
name: Disable Windows Defender Firewall service
docs:
- http://batcmd.com/windows/10/services/mpssvc/
- https://en.wikipedia.org/wiki/Windows_Firewall
# More information about MpsSvc:
- https://web.archive.org/web/20110203202612/http://technet.microsoft.com/en-us/library/dd364391(v=WS.10).aspx
# More information about boot time protection and stopping the firewall service:
- https://web.archive.org/web/20110131034058/http://blogs.technet.com:80/b/networking/archive/2009/03/24/stopping-the-windows-authenticating-firewall-service-and-the-boot-time-policy.aspx
# Stopping the service associated with Windows Firewall is not supported by Microsoft:
- https://web.archive.org/web/20121106033255/http://technet.microsoft.com/en-us/library/cc753180.aspx
# ❗️ Breaks Microsoft Store
# Can no longer update nor install apps, they both fail with 0x80073D0A
# Also breaks some of Store apps such as Photos:
# - https://answers.microsoft.com/en-us/windows/forum/all/microsoft-store-windows-defender-windows-firewall/f2f68cd7-64ec-4fe1-ade4-9d12cde057f9
# - https://github.com/undergroundwires/privacy.sexy/issues/104#issuecomment-962651791
# > The MpsSvc service host much more functionality than just windows firewall. For instance, Windows
# Service hardening which is a windows protection of system services. It also host network isolatio
# which is a crucial part of the confidence model for Windows Store based applications. 3rd party firewalls
# know this fact and instead of disabling the firewall service they coordinate through public APIs with Windows
# Firewall so that they can have ownership of the firewall policies of the computer. Hence you do not have to do
# anything special once you install a 3rd party security product.
# Source: https://www.walkernews.net/2012/09/23/how-to-fix-windows-store-app-update-error-code-0x80073d0a/
# ❗️ Breaks: `netsh advfirewall set`
# Disabling and stopping it breaks "netsh advfirewall set" commands such as
# `netsh advfirewall set allprofiles state on`, `netsh advfirewall set allprofiles state off`.
# More about `netsh firewall` context: https://docs.microsoft.com/en-us/troubleshoot/windows-server/networking/netsh-advfirewall-firewall-control-firewall-behavior
call:
-
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
parameters:
serviceName: MpsSvc # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\MpsSvc").Start
defaultStartupMode: Automatic # Alowed values: Boot | System | Automatic | Manual
-
function: RenameSystemFile
parameters:
filePath: '%WinDir%\system32\mpssvc.dll'
-
name: Disable Firewall through command-line utility
# ❗️ Following must be enabled and in running state:
@@ -3276,11 +3214,6 @@ actions:
reg add "HKLM\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\DomainProfile" /v "EnableFirewall" /t REG_DWORD /d 1 /f
reg add "HKLM\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\PublicProfile" /v "EnableFirewall" /t REG_DWORD /d 1 /f
reg add "HKLM\SYSTEM\CurrentControlSet\Services\SharedAccess\Parameters\FirewallPolicy\PrivateProfile" /v "EnableFirewall" /t REG_DWORD /d 1 /f
-
name: Hide the "Firewall and network protection" area from Windows Defender Security Center
docs: https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefenderSecurityCenter::FirewallNetworkProtection_UILockdown
code: reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Firewall and network protection" /v "UILockdown" /t REG_DWORD /d "1" /f
revertCode: reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Firewall and network protection" /v "UILockdown" /f 2>nul
-
name: Disable Microsoft Defender Antivirus # Depreciated since Windows 10 version 1903
docs:
@@ -4571,6 +4504,11 @@ actions:
docs: https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefenderSecurityCenter::DeviceSecurity_DisableTpmFirmwareUpdateWarning
code: reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Device security" /v "DisableTpmFirmwareUpdateWarning" /t REG_DWORD /d "1" /f
revertCode: reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Device security" /v "DisableTpmFirmwareUpdateWarning" /f 2>nul
-
name: Hide the "Firewall and network protection" area
docs: https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefenderSecurityCenter::FirewallNetworkProtection_UILockdown
code: reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Firewall and network protection" /v "UILockdown" /t REG_DWORD /d "1" /f
revertCode: reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows Defender Security Center\Firewall and network protection" /v "UILockdown" /f 2>nul
-
category: Hide Windows Defender notifications
children:
@@ -4671,6 +4609,43 @@ actions:
# 1. Some cannot be disabled (access error) normally but only with DisableServiceInRegistry
# 2. Some cannot be disabled even using DisableServiceInRegistry, must be disabled as TrustedInstaller using RunInlineCodeAsTrustedInstaller
children:
-
name: Disable Windows Defender Firewall service (breaks Microsoft Store and `netsh advfirewall` CLI)
docs:
- http://batcmd.com/windows/10/services/mpssvc/
- https://en.wikipedia.org/wiki/Windows_Firewall
# More information about MpsSvc:
- https://web.archive.org/web/20110203202612/http://technet.microsoft.com/en-us/library/dd364391(v=WS.10).aspx
# More information about boot time protection and stopping the firewall service:
- https://web.archive.org/web/20110131034058/http://blogs.technet.com:80/b/networking/archive/2009/03/24/stopping-the-windows-authenticating-firewall-service-and-the-boot-time-policy.aspx
# Stopping the service associated with Windows Firewall is not supported by Microsoft:
- https://web.archive.org/web/20121106033255/http://technet.microsoft.com/en-us/library/cc753180.aspx
# ❗️ Breaks Microsoft Store
# Can no longer update nor install apps, they both fail with 0x80073D0A
# Also breaks some of Store apps such as Photos:
# - https://answers.microsoft.com/en-us/windows/forum/all/microsoft-store-windows-defender-windows-firewall/f2f68cd7-64ec-4fe1-ade4-9d12cde057f9
# - https://github.com/undergroundwires/privacy.sexy/issues/104#issuecomment-962651791
# > The MpsSvc service host much more functionality than just windows firewall. For instance, Windows
# Service hardening which is a windows protection of system services. It also host network isolatio
# which is a crucial part of the confidence model for Windows Store based applications. 3rd party firewalls
# know this fact and instead of disabling the firewall service they coordinate through public APIs with Windows
# Firewall so that they can have ownership of the firewall policies of the computer. Hence you do not have to do
# anything special once you install a 3rd party security product.
# Source: https://www.walkernews.net/2012/09/23/how-to-fix-windows-store-app-update-error-code-0x80073d0a/
# ❗️ Breaks: `netsh advfirewall set`
# Disabling and stopping it breaks "netsh advfirewall set" commands such as
# `netsh advfirewall set allprofiles state on`, `netsh advfirewall set allprofiles state off`.
# More about `netsh firewall` context: https://docs.microsoft.com/en-us/troubleshoot/windows-server/networking/netsh-advfirewall-firewall-control-firewall-behavior
call:
-
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
parameters:
serviceName: MpsSvc # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\MpsSvc").Start
defaultStartupMode: Automatic # Alowed values: Boot | System | Automatic | Manual
-
function: RenameSystemFile
parameters:
filePath: '%WinDir%\system32\mpssvc.dll'
-
name: Disable Windows Defender Antivirus service
# ❗️ Breaks `Set-MpPreference` PowerShell cmdlet that helps to manage Defender
@@ -4682,8 +4657,8 @@ actions:
-
function: RunInlineCodeAsTrustedInstaller
parameters:
code: sc stop "WinDefend" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WinDefend" /v "Start" /t REG_DWORD /d "4" /f
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WinDefend" /v "Start" /t REG_DWORD /d "2" /f & sc start "WinDefend" >nul 2>&1
code: sc stop "WinDefend" >nul & sc config "WinDefend" start=disabled
revertCode: sc config "WinDefend" start=auto & sc start "WinDefend" >nul
# - # "Access is denied" when renaming file
# function: RenameSystemFile
# parameters:
@@ -4691,6 +4666,24 @@ actions:
-
category: Disable kernel-level Windows Defender drivers
children:
-
name: Disable Windows Defender Firewall Authorization Driver service (breaks `netsh advfirewall` CLI)
docs:
- http://batcmd.com/windows/10/services/mpsdrv/
# ❗️ Breaks: `netsh advfirewall set`
# Disabling and stopping it breaks "netsh advfirewall set" commands such as
# `netsh advfirewall set allprofiles state on`, `netsh advfirewall set allprofiles state off`.
# More about `netsh firewall` context: https://docs.microsoft.com/en-us/troubleshoot/windows-server/networking/netsh-advfirewall-firewall-control-firewall-behavior
call:
-
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
parameters:
serviceName: mpsdrv # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\mpsdrv").Start
defaultStartupMode: Manual # Alowed values: Boot | System | Automatic | Manual
-
function: RenameSystemFile
parameters:
filePath: '%SystemRoot%\System32\drivers\mpsdrv.sys'
# - Skipping wdnsfltr "Windows Defender Network Stream Filter Driver" as it's Windows 1709 only
-
name: Disable Microsoft Defender Antivirus Network Inspection System Driver service
@@ -4700,8 +4693,8 @@ actions:
function: RunInlineCodeAsTrustedInstaller
parameters:
# "net stop" is used to stop dependend services as well, "sc stop" fails
code: net stop "WdNisDrv" /yes >nul & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisDrv" /v "Start" /t REG_DWORD /d "4" /f
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisDrv" /v "Start" /t REG_DWORD /d "3" /f & sc start "WdNisDrv" >nul
code: net stop "WdNisDrv" /yes >nul & sc config "WdNisDrv" start=disabled
revertCode: sc config "WdNisDrv" start=demand & sc start "WdNisDrv" >nul
-
function: RenameSystemFile
parameters:
@@ -4719,8 +4712,8 @@ actions:
-
function: RunInlineCodeAsTrustedInstaller
parameters:
code: sc stop "WdFilter" >nul & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdFilter" /v "Start" /t REG_DWORD /d "4" /f
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdFilter" /v "Start" /t REG_DWORD /d "0" /f & sc start "WdFilter" >nul
code: sc stop "WdFilter" >nul & sc config "WdFilter" start=disabled
revertCode: sc config "WdFilter" start=boot & sc start "WdFilter" >nul
-
function: RenameSystemFile
parameters:
@@ -4736,8 +4729,8 @@ actions:
-
function: RunInlineCodeAsTrustedInstaller
parameters:
code: sc stop "WdBoot" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdBoot" /v "Start" /t REG_DWORD /d "4" /f
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdBoot" /v "Start" /t REG_DWORD /d "0" /f & sc start "WdBoot" >nul 2>&1
code: sc stop "WdBoot" >nul & sc config "WdBoot" start=disabled
revertCode: sc config "WdBoot" start=boot & sc start "WdBoot" >nul
-
function: RenameSystemFile
parameters:
@@ -4755,8 +4748,8 @@ actions:
-
function: RunInlineCodeAsTrustedInstaller
parameters:
code: sc stop "WdNisSvc" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisSvc" /v "Start" /t REG_DWORD /d "4" /f
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisSvc" /v "Start" /t REG_DWORD /d "2" /f & sc start "WdNisSvc" >nul 2>&1
code: sc stop "WdNisSvc" >nul & sc config "WdNisSvc" start=disabled
revertCode: sc config "WdNisSvc" start=auto & sc start "WdNisSvc" >nul
# - # "Access is denied" when renaming file
# function: RenameSystemFile
# parameters:
@@ -4766,10 +4759,10 @@ actions:
docs: http://batcmd.com/windows/10/services/sense/
call:
-
function: RunInlineCodeAsTrustedInstaller # We must disable it on registry level, "Access is denied" for sc config
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
parameters:
code: sc stop "Sense" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\Sense" /v "Start" /t REG_DWORD /d "4" /f
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\Sense" /v "Start" /t REG_DWORD /d "3" /f & sc start "Sense" >nul 2>&1 # Alowed values: Boot | System | Automatic | Manual
serviceName: Sense # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Sense").Start
defaultStartupMode: Manual # Alowed values: Boot | System | Automatic | Manual
-
function: RenameSystemFile
parameters:
@@ -4789,8 +4782,8 @@ actions:
# ✅ Can disable using registry as TrustedInstaller
function: RunInlineCodeAsTrustedInstaller
parameters:
code: sc stop "SecurityHealthService" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 4 /f
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 3 /f & sc start "SecurityHealthService" >nul 2>&1
code: reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 4 /f
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 3 /f
-
function: RenameSystemFile
parameters:
@@ -4908,7 +4901,7 @@ actions:
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v "NoAutoUpdate" /t "REG_DWORD" /d "1" /f
reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v "AUOptions" /t "REG_DWORD" /d "3" /f
reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v "ScheduledInstallDay" /f 2>nul
reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v "ScheduledInstallTime" /f 2>nul
reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v "ScheduledInstallTime /f 2>nul
-
function: DisableService
parameters:
@@ -7366,10 +7359,44 @@ functions:
call:
function: RunPowerShell
parameters:
code: Get-WindowsCapability -Online -Name '{{ $capabilityName }}*' | Remove-WindowsCapability -Online
code: |-
$capabilityName = '{{ $capabilityName }}'
try {
# Using wildcard for version number handling
$capability = Get-WindowsCapability -Online -Name "$capabilityName*"
if (!$capability) {
Write-Host "Skipping. Capability `"$capabilityName`" is missing."
exit 0
}
if ($capability.State -eq 'NotPresent') {
Write-Host "Skipping. Capability `"$capabilityName`" is missing."
exit 0
}
Write-Host "Removing capability `"$capabilityName`""
Remove-WindowsCapability -Online -Name "$($capability.Name)" -ErrorAction Stop
Write-Host "Successfully removed `"$CapabilityName`""
}
catch {
Write-Error "Failed to remove `"$capabilityName`": $_"
}
revertCode: |-
$capability = Get-WindowsCapability -Online -Name '{{ $capabilityName }}*'
Add-WindowsCapability -Name "$capability.Name" -Online
$capabilityName = '{{ $capabilityName }}'
try {
# Using wildcard for version number handling
$capability = Get-WindowsCapability -Online -Name "$capabilityName*"
if (!$capability) {
Write-Error "Failed to find `"$capabilityName`"."
exit 1
}
$result = Add-WindowsCapability -Name $capability.Name -Online -ErrorAction Stop
Write-Host "Successfully added `"$capabilityName`"."
if ($result.RestartNeeded -eq 'Yes') {
Write-Warning "A restart is needed to finish installing `"$capabilityName`"."
}
}
catch {
Write-Error "Failed to add `"$capabilityName`": $_"
}
-
name: RenameSystemFile
parameters:

View File

@@ -1,27 +1,26 @@
import { Environment } from '@/infrastructure/Environment/Environment';
import os from 'os';
import path from 'path';
import fs from 'fs';
// eslint-disable-next-line camelcase
import child_process from 'child_process';
import { Environment } from '@/application/Environment/Environment';
import { OperatingSystem } from '@/domain/OperatingSystem';
export class CodeRunner {
constructor(
private readonly node = getNodeJs(),
private readonly environment = Environment.CurrentEnvironment,
) {
if (!environment.system) {
throw new Error('missing system operations');
}
}
public async runCode(code: string, folderName: string, fileExtension: string): Promise<void> {
const { system } = this.environment;
const dir = system.location.combinePaths(
system.operatingSystem.getTempDirectory(),
folderName,
);
await system.fileSystem.createDirectory(dir, true);
const filePath = system.location.combinePaths(dir, `run.${fileExtension}`);
await system.fileSystem.writeToFile(filePath, code);
await system.fileSystem.setFilePermissions(filePath, '755');
const dir = this.node.path.join(this.node.os.tmpdir(), folderName);
await this.node.fs.promises.mkdir(dir, { recursive: true });
const filePath = this.node.path.join(dir, `run.${fileExtension}`);
await this.node.fs.promises.writeFile(filePath, code);
await this.node.fs.promises.chmod(filePath, '755');
const command = getExecuteCommand(filePath, this.environment);
system.command.execute(command);
this.node.child_process.exec(command);
}
}
@@ -40,3 +39,43 @@ function getExecuteCommand(scriptPath: string, environment: Environment): string
throw Error(`unsupported os: ${OperatingSystem[environment.os]}`);
}
}
function getNodeJs(): INodeJs {
return {
os, path, fs, child_process,
};
}
export interface INodeJs {
os: INodeOs;
path: INodePath;
fs: INodeFs;
// eslint-disable-next-line camelcase
child_process: INodeChildProcess;
}
export interface INodeOs {
tmpdir(): string;
}
export interface INodePath {
join(...paths: string[]): string;
}
export interface INodeChildProcess {
exec(command: string): void;
}
export interface INodeFs {
readonly promises: INodeFsPromises;
}
interface INodeFsPromisesMakeDirectoryOptions {
recursive?: boolean;
}
interface INodeFsPromises { // https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/node/v13/fs.d.ts
chmod(path: string, mode: string | number): Promise<void>;
mkdir(path: string, options: INodeFsPromisesMakeDirectoryOptions): Promise<string>;
writeFile(path: string, data: string): Promise<void>;
}

View File

@@ -1,49 +0,0 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ISystemOperations } from '@/infrastructure/Environment/SystemOperations/ISystemOperations';
import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector';
import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector';
import { IEnvironment } from './IEnvironment';
import { WindowVariables } from './WindowVariables';
import { validateWindowVariables } from './WindowVariablesValidator';
export class Environment implements IEnvironment {
public static readonly CurrentEnvironment: IEnvironment = new Environment(window);
public readonly isDesktop: boolean;
public readonly os: OperatingSystem | undefined;
public readonly system: ISystemOperations | undefined;
protected constructor(
window: Partial<Window>,
browserOsDetector: IBrowserOsDetector = new BrowserOsDetector(),
windowValidator: WindowValidator = validateWindowVariables,
) {
if (!window) {
throw new Error('missing window');
}
windowValidator(window);
this.isDesktop = isDesktop(window);
if (this.isDesktop) {
this.os = window?.os;
} else {
this.os = undefined;
const userAgent = getUserAgent(window);
if (userAgent) {
this.os = browserOsDetector.detect(userAgent);
}
}
this.system = window?.system;
}
}
function getUserAgent(window: Partial<Window>): string {
return window?.navigator?.userAgent;
}
function isDesktop(window: Partial<WindowVariables>): boolean {
return window?.isDesktop === true;
}
export type WindowValidator = typeof validateWindowVariables;

View File

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

View File

@@ -1,24 +0,0 @@
export interface ISystemOperations {
readonly operatingSystem: IOperatingSystemOps;
readonly location: ILocationOps;
readonly fileSystem: IFileSystemOps;
readonly command: ICommandOps;
}
export interface IOperatingSystemOps {
getTempDirectory(): string;
}
export interface ILocationOps {
combinePaths(...pathSegments: string[]): string;
}
export interface ICommandOps {
execute(command: string): void;
}
export interface IFileSystemOps {
setFilePermissions(filePath: string, mode: string | number): Promise<void>;
createDirectory(directoryPath: string, isRecursive?: boolean): Promise<string>;
writeToFile(filePath: string, data: string): Promise<void>;
}

View File

@@ -1,33 +0,0 @@
import { tmpdir } from 'os';
import { join } from 'path';
import { chmod, mkdir, writeFile } from 'fs/promises';
import { exec } from 'child_process';
import { ISystemOperations } from './ISystemOperations';
export function createNodeSystemOperations(): ISystemOperations {
return {
operatingSystem: {
getTempDirectory: () => tmpdir(),
},
location: {
combinePaths: (...pathSegments) => join(...pathSegments),
},
fileSystem: {
setFilePermissions: (
filePath: string,
mode: string | number,
) => chmod(filePath, mode),
createDirectory: (
directoryPath: string,
isRecursive?: boolean,
) => mkdir(directoryPath, { recursive: isRecursive }),
writeToFile: (
filePath: string,
data: string,
) => writeFile(filePath, data),
},
command: {
execute: (command) => exec(command),
},
};
}

View File

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

View File

@@ -1,76 +0,0 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { PropertyKeys } from '@/TypeHelpers';
import { WindowVariables } from './WindowVariables';
/**
* Checks for consistency in runtime environment properties injected by Electron preloader.
*/
export function validateWindowVariables(variables: Partial<WindowVariables>) {
if (!variables) {
throw new Error('missing variables');
}
if (!isObject(variables)) {
throw new Error(`window is not an object but ${typeof variables}`);
}
const errors = [...testEveryProperty(variables)];
if (errors.length > 0) {
throw new Error(errors.join('\n'));
}
}
function* testEveryProperty(variables: Partial<WindowVariables>): Iterable<string> {
const tests: {
[K in PropertyKeys<WindowVariables>]: boolean;
} = {
os: testOperatingSystem(variables.os),
isDesktop: testIsDesktop(variables.isDesktop),
system: testSystem(variables),
};
for (const [propertyName, testResult] of Object.entries(tests)) {
if (!testResult) {
const propertyValue = variables[propertyName as keyof WindowVariables];
yield `Unexpected ${propertyName} (${typeof propertyValue})`;
}
}
}
function testOperatingSystem(os: unknown): boolean {
if (os === undefined) {
return true;
}
if (!isNumber(os)) {
return false;
}
return Object
.values(OperatingSystem)
.includes(os);
}
function testSystem(variables: Partial<WindowVariables>): boolean {
if (!variables.isDesktop) {
return true;
}
return variables.system !== undefined && isObject(variables.system);
}
function testIsDesktop(isDesktop: unknown): boolean {
if (isDesktop === undefined) {
return true;
}
return isBoolean(isDesktop);
}
function isNumber(variable: unknown): variable is number {
return typeof variable === 'number';
}
function isBoolean(variable: unknown): variable is boolean {
return typeof variable === 'boolean';
}
function isObject(variable: unknown): variable is object {
return typeof variable === 'object'
&& variable !== null // the data type of null is an object
&& !Array.isArray(variable);
}

View File

@@ -1,7 +1,6 @@
import { IEventSubscription } from './IEventSource';
import { IEventSubscriptionCollection } from './IEventSubscriptionCollection';
export class EventSubscriptionCollection implements IEventSubscriptionCollection {
export class EventSubscriptionCollection {
private readonly subscriptions = new Array<IEventSubscription>();
public register(...subscriptions: IEventSubscription[]) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,50 +0,0 @@
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
/* Validation is externalized to keep the environment objects simple */
export function validateMetadata(metadata: IAppMetadata): void {
if (!metadata) {
throw new Error('missing metadata');
}
const keyValues = capturePropertyValues(metadata);
if (!Object.keys(keyValues).length) {
throw new Error('Unable to capture metadata key/value pairs');
}
const keysMissingValue = getMissingMetadataKeys(keyValues);
if (keysMissingValue.length > 0) {
throw new Error(`Metadata keys missing: ${keysMissingValue.join(', ')}`);
}
}
function getMissingMetadataKeys(keyValuePairs: Record<string, unknown>): string[] {
return Object.entries(keyValuePairs)
.reduce((acc, [key, value]) => {
if (!value) {
acc.push(key);
}
return acc;
}, new Array<string>());
}
/**
* Captures values of properties and getters from the provided instance.
* Necessary because code transformations can make class getters non-enumerable during bundling.
* This ensures that even if getters are non-enumerable, their values are still captured and used.
*/
function capturePropertyValues(instance: unknown): Record<string, unknown> {
const obj: Record<string, unknown> = {};
const descriptors = Object.getOwnPropertyDescriptors(instance.constructor.prototype);
// Capture regular properties from the instance
for (const [key, value] of Object.entries(instance)) {
obj[key] = value;
}
// Capture getter properties from the instance's prototype
for (const [key, descriptor] of Object.entries(descriptors)) {
if (typeof descriptor.get === 'function') {
obj[key] = descriptor.get.call(instance);
}
}
return obj;
}

View File

@@ -1,29 +0,0 @@
import { IAppMetadata } from '../IAppMetadata';
/**
* Provides the application's metadata using Vite's environment variables.
*/
export class ViteAppMetadata implements IAppMetadata {
// Ensure the use of import.meta.env prefix for the following properties.
// Vue will replace these statically during production builds.
public get version(): string {
return import.meta.env.VITE_APP_VERSION;
}
public get name(): string {
return import.meta.env.VITE_APP_NAME;
}
public get slogan(): string {
return import.meta.env.VITE_APP_SLOGAN;
}
public get repositoryUrl(): string {
return import.meta.env.VITE_APP_REPOSITORY_URL;
}
public get homepageUrl(): string {
return import.meta.env.VITE_APP_HOMEPAGE_URL;
}
}

View File

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

View File

@@ -1,11 +0,0 @@
/// <reference types="vite/client" />
import { VITE_ENVIRONMENT_KEYS } from './ViteEnvironmentKeys';
export type ViteEnvironmentVariables = {
readonly [K in keyof typeof VITE_ENVIRONMENT_KEYS]: string;
};
interface ImportMeta {
readonly env: ViteEnvironmentVariables
}

View File

@@ -1,31 +0,0 @@
import { ISanityCheckOptions } from './ISanityCheckOptions';
import { ISanityValidator } from './ISanityValidator';
export type FactoryFunction<T> = () => T;
export abstract class FactoryValidator<T> implements ISanityValidator {
private readonly factory: FactoryFunction<T>;
protected constructor(factory: FactoryFunction<T>) {
if (!factory) {
throw new Error('missing factory');
}
this.factory = factory;
}
public abstract shouldValidate(options: ISanityCheckOptions): boolean;
public abstract name: string;
public* collectErrors(): Iterable<string> {
try {
const value = this.factory();
if (!value) {
// Do not remove this check, it ensures that the factory call is not optimized away.
yield 'Factory resulted in a falsy value';
}
} catch (error) {
yield `Error in factory creation: ${error.message}`;
}
}
}

View File

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

View File

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

View File

@@ -1,50 +0,0 @@
import { ISanityCheckOptions } from './Common/ISanityCheckOptions';
import { ISanityValidator } from './Common/ISanityValidator';
import { MetadataValidator } from './Validators/MetadataValidator';
const DefaultSanityValidators: ISanityValidator[] = [
new MetadataValidator(),
];
/* Helps to fail-fast on errors */
export function validateRuntimeSanity(
options: ISanityCheckOptions,
validators: readonly ISanityValidator[] = DefaultSanityValidators,
): void {
validateContext(options, validators);
const errorMessages = validators.reduce((errors, validator) => {
if (validator.shouldValidate(options)) {
const errorMessage = getErrorMessage(validator);
if (errorMessage) {
errors.push(errorMessage);
}
}
return errors;
}, new Array<string>());
if (errorMessages.length > 0) {
throw new Error(`Sanity check failed.\n${errorMessages.join('\n---\n')}`);
}
}
function validateContext(
options: ISanityCheckOptions,
validators: readonly ISanityValidator[],
) {
if (!options) {
throw new Error('missing options');
}
if (!validators?.length) {
throw new Error('missing validators');
}
if (validators.some((validator) => !validator)) {
throw new Error('missing validator in validators');
}
}
function getErrorMessage(validator: ISanityValidator): string | undefined {
const errorMessages = [...validator.collectErrors()];
if (!errorMessages.length) {
return undefined;
}
return `${validator.name}:\n${errorMessages.join('\n')}`;
}

View File

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

View File

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

View File

@@ -1,3 +0,0 @@
# presentation
See [`presentation.md`](./../../docs/presentation.md)

View File

@@ -1,19 +1,17 @@
// https://google-webfonts-helper.herokuapp.com/fonts
@use "@/presentation/assets/styles/vite-path" as *;
/* slabo-27px-regular - latin-ext_latin */
@font-face {
font-family: 'Slabo 27px';
font-style: normal;
font-weight: 400;
src: url('#{$base-assets-path}/fonts/slabo-27px-v6-latin-ext_latin-regular.eot'); /* IE9 Compat Modes */
src: url('~@/presentation/assets/fonts/slabo-27px-v6-latin-ext_latin-regular.eot'); /* IE9 Compat Modes */
src: local('Slabo 27px'), local('Slabo27px-Regular'),
url('#{$base-assets-path}/fonts/slabo-27px-v6-latin-ext_latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('#{$base-assets-path}/fonts/slabo-27px-v6-latin-ext_latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
url('#{$base-assets-path}/fonts/slabo-27px-v6-latin-ext_latin-regular.woff') format('woff'), /* Modern Browsers */
url('#{$base-assets-path}/fonts/slabo-27px-v6-latin-ext_latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */
url('#{$base-assets-path}/fonts/slabo-27px-v6-latin-ext_latin-regular.svg#Slabo27px') format('svg'); /* Legacy iOS */
url('~@/presentation/assets/fonts/slabo-27px-v6-latin-ext_latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('~@/presentation/assets/fonts/slabo-27px-v6-latin-ext_latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
url('~@/presentation/assets/fonts/slabo-27px-v6-latin-ext_latin-regular.woff') format('woff'), /* Modern Browsers */
url('~@/presentation/assets/fonts/slabo-27px-v6-latin-ext_latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */
url('~@/presentation/assets/fonts/slabo-27px-v6-latin-ext_latin-regular.svg#Slabo27px') format('svg'); /* Legacy iOS */
}
/* yesteryear-regular - latin */
@@ -21,13 +19,13 @@
font-family: 'Yesteryear';
font-style: normal;
font-weight: 400;
src: url('#{$base-assets-path}/fonts/yesteryear-v8-latin-regular.eot'); /* IE9 Compat Modes */
src: url('~@/presentation/assets/fonts/yesteryear-v8-latin-regular.eot'); /* IE9 Compat Modes */
src: local('Yesteryear'), local('Yesteryear-Regular'),
url('#{$base-assets-path}/fonts/yesteryear-v8-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('#{$base-assets-path}/fonts/yesteryear-v8-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
url('#{$base-assets-path}/fonts/yesteryear-v8-latin-regular.woff') format('woff'), /* Modern Browsers */
url('#{$base-assets-path}/fonts/yesteryear-v8-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */
url('#{$base-assets-path}/fonts/yesteryear-v8-latin-regular.svg#Yesteryear') format('svg'); /* Legacy iOS */
url('~@/presentation/assets/fonts/yesteryear-v8-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
url('~@/presentation/assets/fonts/yesteryear-v8-latin-regular.woff2') format('woff2'), /* Super Modern Browsers */
url('~@/presentation/assets/fonts/yesteryear-v8-latin-regular.woff') format('woff'), /* Modern Browsers */
url('~@/presentation/assets/fonts/yesteryear-v8-latin-regular.ttf') format('truetype'), /* Safari, Android, iOS */
url('~@/presentation/assets/fonts/yesteryear-v8-latin-regular.svg#Yesteryear') format('svg'); /* Legacy iOS */
}
$font-normal : 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;

View File

@@ -5,7 +5,6 @@
@use "@/presentation/assets/styles/colors" as *;
@use "@/presentation/assets/styles/fonts" as *;
@use "@/presentation/assets/styles/mixins" as *;
@use "@/presentation/assets/styles/vite-path" as *;
* {
box-sizing: border-box;

View File

@@ -27,21 +27,3 @@
*/
-webkit-tap-highlight-color: transparent;
}
@mixin fade-slide-transition($name, $duration, $offset-upward: null) {
.#{$name}-enter-active,
.#{$name}-leave-active {
transition: all $duration;
}
.#{$name}-leave-active,
.#{$name}-enter, // Vue 2.X compatibility
.#{$name}-enter-from // Vue 3.X compatibility
{
opacity: 0;
@if $offset-upward {
transform: translateY($offset-upward);
}
}
}

View File

@@ -1,4 +0,0 @@
// Define paths specific to Vite's resolution system.
// Vite uses the "@" symbol to resolve its aliases for styles.
$base-assets-path: "@/presentation/assets/";

View File

@@ -1,3 +1,4 @@
import { VModalBootstrapper } from './Modules/VModalBootstrapper';
import { TreeBootstrapper } from './Modules/TreeBootstrapper';
import { IconBootstrapper } from './Modules/IconBootstrapper';
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
@@ -18,6 +19,7 @@ export class ApplicationBootstrapper implements IVueBootstrapper {
new TreeBootstrapper(),
new VueBootstrapper(),
new TooltipBootstrapper(),
new VModalBootstrapper(),
];
}
}

View File

@@ -1,28 +0,0 @@
import { InjectionKey, provide } from 'vue';
import { useCollectionState } from '@/presentation/components/Shared/Hooks/UseCollectionState';
import { useApplication } from '@/presentation/components/Shared/Hooks/UseApplication';
import {
useCollectionStateKey, useApplicationKey, useEnvironmentKey,
} from '@/presentation/injectionSymbols';
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { Environment } from '@/infrastructure/Environment/Environment';
export function provideDependencies(context: IApplicationContext) {
registerSingleton(useApplicationKey, useApplication(context.app));
registerTransient(useCollectionStateKey, () => useCollectionState(context));
registerSingleton(useEnvironmentKey, Environment.CurrentEnvironment);
}
function registerSingleton<T>(
key: InjectionKey<T>,
value: T,
) {
provide(key, value);
}
function registerTransient<T>(
key: InjectionKey<() => T>,
factory: () => T,
) {
provide(key, factory);
}

View File

@@ -0,0 +1,8 @@
import VModal from 'vue-js-modal';
import { VueConstructor, IVueBootstrapper } from '../IVueBootstrapper';
export class VModalBootstrapper implements IVueBootstrapper {
public bootstrap(vue: VueConstructor): void {
vue.use(VModal, { dynamic: true, injectModalsContainer: true });
}
}

View File

@@ -11,19 +11,14 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { Component, Vue } from 'vue-property-decorator';
import TheHeader from '@/presentation/components/TheHeader.vue';
import TheFooter from '@/presentation/components/TheFooter/TheFooter.vue';
import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue';
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { provideDependencies } from '../bootstrapping/DependencyProvider';
const singletonAppContext = await buildContext();
export default defineComponent({
@Component({
components: {
TheHeader,
TheCodeButtons,
@@ -31,14 +26,10 @@ export default defineComponent({
TheSearchBar,
TheFooter,
},
setup() {
provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
validateRuntimeSanity({
validateMetadata: true,
validateEnvironment: true,
});
},
});
})
export default class App extends Vue {
}
</script>
<style lang="scss">

View File

@@ -14,36 +14,20 @@
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import {
Component, Prop, Emit, Vue,
} from 'vue-property-decorator';
export default defineComponent({
props: {
text: {
type: String,
required: true,
},
iconPrefix: {
type: String,
required: true,
},
iconName: {
type: String,
required: true,
},
},
emits: [
'click',
],
setup(_, { emit }) {
function onClicked() {
emit('click');
}
@Component
export default class IconButton extends Vue {
@Prop() public text!: number;
return {
onClicked,
};
},
});
@Prop() public iconPrefix!: string;
@Prop() public iconName!: string;
@Emit('click') public onClicked() { /* do nothing except firing event */ }
}
</script>
<style scoped lang="scss">

View File

@@ -2,41 +2,26 @@
<span class="code-wrapper">
<span class="dollar">$</span>
<code><slot /></code>
<TooltipWrapper>
<font-awesome-icon
class="copy-button"
:icon="['fas', 'copy']"
@click="copyCode"
/>
<template v-slot:tooltip>
Copy
</template>
</TooltipWrapper>
<font-awesome-icon
class="copy-button"
:icon="['fas', 'copy']"
@click="copyCode"
v-tooltip.top-center="'Copy'"
/>
</span>
</template>
<script lang="ts">
import { defineComponent, useSlots } from 'vue';
import { Component, Vue } from 'vue-property-decorator';
import { Clipboard } from '@/infrastructure/Clipboard';
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
export default defineComponent({
components: {
TooltipWrapper,
},
setup() {
const slots = useSlots();
function copyCode() {
const code = slots.default()[0].text;
Clipboard.copyText(code);
}
return {
copyCode,
};
},
});
@Component
export default class Code extends Vue {
public copyCode(): void {
const code = this.$slots.default[0].text;
Clipboard.copyText(code);
}
}
</script>
<style scoped lang="scss">

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