Compare commits
21 Commits
capability
...
0.12.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e9e0001ef8 | ||
|
|
62f8bfac2f | ||
|
|
75c9b51bf2 | ||
|
|
ec98d8417f | ||
|
|
736590558b | ||
|
|
6e40edd3f8 | ||
|
|
5f11c8d98f | ||
|
|
08737698c2 | ||
|
|
04b3133500 | ||
|
|
0d15992d56 | ||
|
|
a14929a13c | ||
|
|
6a20d804dc | ||
|
|
ae75059cc1 | ||
|
|
39e650cf11 | ||
|
|
bc91237d7c | ||
|
|
9e5491fdbf | ||
|
|
986ba078a6 | ||
|
|
061afad967 | ||
|
|
3bc8da4cbf | ||
|
|
1b9be8fe2d | ||
|
|
3a594ac7fd |
@@ -1 +1,2 @@
|
|||||||
dist/
|
dist/
|
||||||
|
dist_electron/
|
||||||
@@ -6,10 +6,10 @@ module.exports = {
|
|||||||
root: true,
|
root: true,
|
||||||
env: {
|
env: {
|
||||||
node: true,
|
node: true,
|
||||||
|
es2022: true, // add globals and sets parserOptions.ecmaVersion to 2022
|
||||||
},
|
},
|
||||||
extends: [
|
extends: [
|
||||||
// Vue specific rules, eslint-plugin-vue
|
// Vue specific rules, eslint-plugin-vue
|
||||||
// Added by Vue CLI
|
|
||||||
'plugin:vue/essential',
|
'plugin:vue/essential',
|
||||||
|
|
||||||
// Extends eslint-config-airbnb
|
// Extends eslint-config-airbnb
|
||||||
@@ -17,42 +17,14 @@ module.exports = {
|
|||||||
|
|
||||||
// Extends @typescript-eslint/recommended
|
// Extends @typescript-eslint/recommended
|
||||||
// Uses the recommended rules from the @typescript-eslint/eslint-plugin
|
// Uses the recommended rules from the @typescript-eslint/eslint-plugin
|
||||||
// Added by Vue CLI
|
|
||||||
'@vue/typescript/recommended',
|
'@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: {
|
rules: {
|
||||||
...getOwnRules(),
|
...getOwnRules(),
|
||||||
...getTurnedOffBrokenRules(),
|
...getTurnedOffBrokenRules(),
|
||||||
...getOpinionatedRuleOverrides(),
|
...getOpinionatedRuleOverrides(),
|
||||||
...getTodoRules(),
|
...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() {
|
function getOwnRules() {
|
||||||
26
.github/workflows/checks.build.yaml
vendored
26
.github/workflows/checks.build.yaml
vendored
@@ -9,7 +9,13 @@ jobs:
|
|||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ macos, ubuntu, windows ]
|
os: [ macos, ubuntu, windows ]
|
||||||
mode: [ development, test, production ]
|
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
|
||||||
|
]
|
||||||
fail-fast: false # Allows to see results from other combinations
|
fail-fast: false # Allows to see results from other combinations
|
||||||
runs-on: ${{ matrix.os }}-latest
|
runs-on: ${{ matrix.os }}-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -26,12 +32,15 @@ jobs:
|
|||||||
name: Build
|
name: Build
|
||||||
run: npm run build -- --mode ${{ matrix.mode }}
|
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:
|
build-desktop:
|
||||||
strategy:
|
strategy:
|
||||||
matrix:
|
matrix:
|
||||||
os: [ macos, ubuntu, windows ]
|
os: [ macos, ubuntu, windows ]
|
||||||
mode: [ development, production ] # "test" is not supported https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1627
|
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
|
||||||
|
]
|
||||||
fail-fast: false # Allows to see results from other combinations
|
fail-fast: false # Allows to see results from other combinations
|
||||||
runs-on: ${{ matrix.os }}-latest
|
runs-on: ${{ matrix.os }}-latest
|
||||||
steps:
|
steps:
|
||||||
@@ -45,14 +54,11 @@ jobs:
|
|||||||
name: Install dependencies
|
name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
-
|
-
|
||||||
name: Install cross-env
|
name: Prebuild
|
||||||
# Used to set NODE_ENV due to https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1626
|
run: npm run electron:prebuild -- --mode ${{ matrix.mode }}
|
||||||
run: npm install --global cross-env
|
|
||||||
-
|
-
|
||||||
name: Build
|
name: Build
|
||||||
run: |-
|
run: npm run electron:build -- --publish never
|
||||||
cross-env-shell NODE_ENV=${{ matrix.mode }}
|
|
||||||
npm run electron:build -- --publish never --mode ${{ matrix.mode }}
|
|
||||||
|
|
||||||
create-icons:
|
create-icons:
|
||||||
strategy:
|
strategy:
|
||||||
@@ -72,4 +78,4 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
-
|
-
|
||||||
name: Create icons
|
name: Create icons
|
||||||
run: npm run create-icons
|
run: npm run icons:build
|
||||||
|
|||||||
67
.github/workflows/checks.desktop-runtime-errors.yaml
vendored
Normal file
67
.github/workflows/checks.desktop-runtime-errors.yaml
vendored
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
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
|
||||||
2
.github/workflows/checks.security.yaml
vendored
2
.github/workflows/checks.security.yaml
vendored
@@ -19,4 +19,4 @@ jobs:
|
|||||||
uses: ./.github/actions/setup-node
|
uses: ./.github/actions/setup-node
|
||||||
-
|
-
|
||||||
name: NPM audit
|
name: NPM audit
|
||||||
run: exit "$(npm audit)" # Since node 15.x, it does not fail with error if we don't explicitly exit
|
run: npm audit --omit=dev
|
||||||
|
|||||||
23
.github/workflows/release.desktop.yaml
vendored
23
.github/workflows/release.desktop.yaml
vendored
@@ -13,20 +13,29 @@ jobs:
|
|||||||
fail-fast: false # So publish runs for other OSes if one fails
|
fail-fast: false # So publish runs for other OSes if one fails
|
||||||
runs-on: ${{ matrix.os }}-latest
|
runs-on: ${{ matrix.os }}-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v2
|
-
|
||||||
|
uses: actions/checkout@v2
|
||||||
with:
|
with:
|
||||||
ref: master # otherwise it defaults to the version tag missing bump commit
|
ref: master # otherwise it defaults to the version tag missing bump commit
|
||||||
fetch-depth: 0 # fetch all history
|
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)"
|
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
|
uses: ./.github/actions/setup-node
|
||||||
- name: Install dependencies
|
-
|
||||||
|
name: Install dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
- name: Run unit tests
|
-
|
||||||
|
name: Run unit tests
|
||||||
run: npm run test:unit
|
run: npm run test:unit
|
||||||
- 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
|
name: Prebuild
|
||||||
|
run: npm run electron:prebuild
|
||||||
|
-
|
||||||
|
name: Build and publish
|
||||||
|
run: npm run electron:build -- --publish always
|
||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
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
|
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
|
||||||
|
|||||||
2
.github/workflows/tests.e2e.yaml
vendored
2
.github/workflows/tests.e2e.yaml
vendored
@@ -23,4 +23,4 @@ jobs:
|
|||||||
run: npm ci
|
run: npm ci
|
||||||
-
|
-
|
||||||
name: Run e2e tests
|
name: Run e2e tests
|
||||||
run: npm run test:e2e -- --headless
|
run: npm run test:cy:run
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -5,6 +5,3 @@ dist/
|
|||||||
!.vscode/extensions.json
|
!.vscode/extensions.json
|
||||||
#Electron-builder output
|
#Electron-builder output
|
||||||
/dist_electron
|
/dist_electron
|
||||||
# Cypress
|
|
||||||
/tests/e2e/screenshots
|
|
||||||
/tests/e2e/videos
|
|
||||||
|
|||||||
4
.vscode/extensions.json
vendored
4
.vscode/extensions.json
vendored
@@ -11,8 +11,8 @@
|
|||||||
"dbaeumer.vscode-eslint", // Lints JavaScript/TypeScript.
|
"dbaeumer.vscode-eslint", // Lints JavaScript/TypeScript.
|
||||||
"pmneo.tsimporter", // Provides better auto-complete for TypeScripts imports.
|
"pmneo.tsimporter", // Provides better auto-complete for TypeScripts imports.
|
||||||
// Vue
|
// Vue
|
||||||
"jcbuisson.vue", // Highlights syntax.
|
"Vue.volar", // Official Vue extensions
|
||||||
"octref.vetur", // Adds Vetur, Vue tooling support.
|
"Vue.vscode-typescript-vue-plugin", // Official TypeScript Vue Plugin
|
||||||
// Scripting
|
// Scripting
|
||||||
"timonwong.shellcheck", // Lints bash files.
|
"timonwong.shellcheck", // Lints bash files.
|
||||||
"ms-vscode.powershell", // Lints PowerShell files.
|
"ms-vscode.powershell", // Lints PowerShell files.
|
||||||
|
|||||||
17
CHANGELOG.md
17
CHANGELOG.md
@@ -1,5 +1,22 @@
|
|||||||
# Changelog
|
# 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)
|
## 0.12.0 (2023-08-03)
|
||||||
|
|
||||||
* Improve script/category name validation | [b210aad](https://github.com/undergroundwires/privacy.sexy/commit/b210aaddf26629179f77fe19f62f65d8a0ca2b87)
|
* Improve script/category name validation | [b210aad](https://github.com/undergroundwires/privacy.sexy/commit/b210aaddf26629179f77fe19f62f65d8a0ca2b87)
|
||||||
|
|||||||
34
README.md
34
README.md
@@ -4,13 +4,13 @@
|
|||||||
|
|
||||||
<!-- markdownlint-disable MD033 -->
|
<!-- markdownlint-disable MD033 -->
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://undergroundwires.dev/donate?project=privacy.sexy">
|
<a href="https://undergroundwires.dev/donate?project=privacy.sexy" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="donation badge"
|
alt="donation badge"
|
||||||
src="https://undergroundwires.dev/img/badges/donate/flat.svg"
|
src="https://undergroundwires.dev/img/badges/donate/flat.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md">
|
<a href="https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="contributions are welcome"
|
alt="contributions are welcome"
|
||||||
src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat"
|
src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat"
|
||||||
@@ -18,13 +18,13 @@
|
|||||||
</a>
|
</a>
|
||||||
<!-- Code quality -->
|
<!-- Code quality -->
|
||||||
<br />
|
<br />
|
||||||
<a href="https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript">
|
<a href="https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Language grade: JavaScript/TypeScript"
|
alt="Language grade: JavaScript/TypeScript"
|
||||||
src="https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18"
|
src="https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability">
|
<a href="https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Maintainability"
|
alt="Maintainability"
|
||||||
src="https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability"
|
src="https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability"
|
||||||
@@ -32,19 +32,19 @@
|
|||||||
</a>
|
</a>
|
||||||
<!-- Tests -->
|
<!-- Tests -->
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.unit.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.unit.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Unit tests status"
|
alt="Unit tests status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/unit-tests/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/unit-tests/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.integration.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.integration.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Integration tests status"
|
alt="Integration tests status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/integration-tests/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/integration-tests/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.e2e.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.e2e.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="E2E tests status"
|
alt="E2E tests status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/e2e-tests/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/e2e-tests/badge.svg"
|
||||||
@@ -52,39 +52,45 @@
|
|||||||
</a>
|
</a>
|
||||||
<!-- Checks -->
|
<!-- Checks -->
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.quality.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.quality.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Quality checks status"
|
alt="Quality checks status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/quality-checks/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/quality-checks/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.security.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.security.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Security checks status"
|
alt="Security checks status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/security-checks/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/security-checks/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.build.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.build.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Build checks status"
|
alt="Build checks status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</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 -->
|
<!-- Release -->
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Git release status"
|
alt="Git release status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-git/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-git/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.site.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.site.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Site release status"
|
alt="Site release status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-site/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-site/badge.svg"
|
||||||
/>
|
/>
|
||||||
</a>
|
</a>
|
||||||
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.desktop.yaml">
|
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.desktop.yaml" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Desktop application release status"
|
alt="Desktop application release status"
|
||||||
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-desktop/badge.svg"
|
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-desktop/badge.svg"
|
||||||
@@ -92,7 +98,7 @@
|
|||||||
</a>
|
</a>
|
||||||
<!-- Others -->
|
<!-- Others -->
|
||||||
<br />
|
<br />
|
||||||
<a href="https://github.com/undergroundwires/bump-everywhere">
|
<a href="https://github.com/undergroundwires/bump-everywhere" target="_blank" rel="noopener noreferrer">
|
||||||
<img
|
<img
|
||||||
alt="Auto-versioned by bump-everywhere"
|
alt="Auto-versioned by bump-everywhere"
|
||||||
src="https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true"
|
src="https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true"
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
presets: [
|
|
||||||
'@vue/cli-plugin-babel/preset',
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
import { defineConfig } from 'cypress'
|
import { defineConfig } from 'cypress';
|
||||||
|
import ViteConfig from './vite.config';
|
||||||
|
|
||||||
|
const CYPRESS_BASE_DIR = 'tests/e2e/';
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
fixturesFolder: 'tests/e2e/fixtures',
|
fixturesFolder: `${CYPRESS_BASE_DIR}/fixtures`,
|
||||||
screenshotsFolder: 'tests/e2e/screenshots',
|
screenshotsFolder: `${CYPRESS_BASE_DIR}/screenshots`,
|
||||||
videosFolder: 'tests/e2e/videos',
|
videosFolder: `${CYPRESS_BASE_DIR}/videos`,
|
||||||
e2e: {
|
e2e: {
|
||||||
setupNodeEvents(on, config) {
|
baseUrl: `http://localhost:${ViteConfig.server.port}/`,
|
||||||
return require('./tests/e2e/plugins/index.js')(on, config)
|
specPattern: `${CYPRESS_BASE_DIR}/**/*.cy.{js,jsx,ts,tsx}`, // Default: cypress/e2e/**/*.cy.{js,jsx,ts,tsx}
|
||||||
},
|
supportFile: `${CYPRESS_BASE_DIR}/support/e2e.ts`,
|
||||||
specPattern: 'tests/e2e/specs/**/*.cy.{js,jsx,ts,tsx}',
|
|
||||||
supportFile: 'tests/e2e/support/index.js',
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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).
|
Application layer parses the application data to compile the domain object [`Application.ts`](./../src/domain/Application.ts).
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,23 @@ Application is
|
|||||||
|
|
||||||
Application uses highly decoupled models & services in different DDD layers:
|
Application uses highly decoupled models & services in different DDD layers:
|
||||||
|
|
||||||
- presentation layer (see [presentation.md](./presentation.md)),
|
**Application layer** (see [application.md](./application.md)):
|
||||||
- application layer (see [application.md](./application.md)),
|
|
||||||
- and domain layer.
|
|
||||||
|
|
||||||
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.
|
- 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.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -27,6 +39,8 @@ Application layer depends on and consumes domain layer. [Presentation layer](./p
|
|||||||
|
|
||||||
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.
|
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.
|
Each layer treat application layer differently.
|
||||||
|
|
||||||

|

|
||||||
@@ -45,7 +59,7 @@ Each layer treat application layer differently.
|
|||||||
- So state is mutable, and fires related events when mutated.
|
- So state is mutable, and fires related events when mutated.
|
||||||
- 📖 Read more: [application.md | Application state](./application.md#application-state).
|
- 📖 Read more: [application.md | Application state](./application.md#application-state).
|
||||||
|
|
||||||
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).
|
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).
|
||||||
|
|
||||||
## AWS infrastructure
|
## AWS infrastructure
|
||||||
|
|
||||||
|
|||||||
@@ -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 unit tests: `npm run test:unit`
|
||||||
- Run integration tests: `npm run test:integration`
|
- Run integration tests: `npm run test:integration`
|
||||||
- Run e2e (end-to-end) tests
|
- Run end-to-end (e2e) tests:
|
||||||
- Interactive mode with GUI: `npm run test:e2e`
|
- `npm run test:cy:open`: Run tests interactively using the development server with hot-reloading.
|
||||||
- Headless mode without GUI: `npm run test:e2e -- --headless`
|
- `npm run test:cy:run`: Run tests on the production build in a headless mode.
|
||||||
|
|
||||||
📖 Read more about testing in [tests](./tests.md).
|
📖 Read more about testing in [tests](./tests.md).
|
||||||
|
|
||||||
@@ -35,11 +35,25 @@ You could run other types of tests as well, but they may take longer time and ov
|
|||||||
|
|
||||||
### Running
|
### Running
|
||||||
|
|
||||||
- Run in local server: `npm run serve`
|
**Web:**
|
||||||
|
|
||||||
|
- Run in local server: `npm run dev`
|
||||||
- 💡 Meant for local development with features such as hot-reloading.
|
- 💡 Meant for local development with features such as hot-reloading.
|
||||||
- Run using Docker:
|
- Preview production build: `npm run preview`
|
||||||
1. Build: `docker build -t undergroundwires/privacy.sexy:latest .`
|
- Start a local web server that serves the built solution from `./dist`.
|
||||||
2. Run: `docker run -it -p 8080:80 --rm --name privacy.sexy undergroundwires/privacy.sexy:latest`
|
- 💡 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`
|
||||||
|
|
||||||
### Building
|
### Building
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,34 @@
|
|||||||
# Presentation layer
|
# Presentation layer
|
||||||
|
|
||||||
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.
|
The presentation layer handles UI concerns using Vue as JavaScript framework and Electron to provide desktop functionality.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
📖 Refer to [architecture.md (Layered Application)](./architecture.md#layered-application) to read more about the layered architecture.
|
📖 Refer to [architecture.md (Layered Application)](./architecture.md#layered-application) to read more about the layered architecture.
|
||||||
|
|
||||||
## Structure
|
## Structure
|
||||||
|
|
||||||
- [`/src/` **`presentation/`**](./../src/presentation/): Contains all presentation related code including Vue and Electron configurations
|
- [`/src/` **`presentation/`**](./../src/presentation/): Contains Vue and Electron code.
|
||||||
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue global objects including components and plugins.
|
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins.
|
||||||
- [**`components/`**](./../src/presentation/components/): Contains all Vue components and their helper classes.
|
- [**`components/`**](./../src/presentation/components/): Contains Vue components and helpers.
|
||||||
- [**`Shared/`**](./../src/presentation/components/Shared): Contains Vue components and component helpers that other components share.
|
- [**`Shared/`**](./../src/presentation/components/Shared): Contains shared Vue components and helpers.
|
||||||
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets that webpack will process.
|
- [**`hooks`**](../src/presentation/components/Shared/Hooks): Hooks used by components through [dependency injection](#dependency-injections).
|
||||||
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts
|
- [**`/public/`**](../src/presentation/public/): Contains static assets.
|
||||||
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles used throughout different components.
|
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets processed by Vite.
|
||||||
- [**`components/`**](./../src/presentation/assets/styles/components): Contains reusable styles coupled to a Vue/HTML component.
|
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts.
|
||||||
- [**`vendors-extensions/`**](./../src/presentation/assets/styles/third-party-extensions): Contains styles that override third-party components used.
|
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles.
|
||||||
- [**`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.
|
- [**`components/`**](./../src/presentation/assets/styles/components): Contains styles for Vue components.
|
||||||
- [**`main.ts`**](./../src/presentation/main.ts): Application entry point that mounts and starts Vue application.
|
- [**`vendors-extensions/`**](./../src/presentation/assets/styles/third-party-extensions): Contains styles for third-party components.
|
||||||
- [**`electron/`**](./../src/presentation/electron/): Electron configuration for the desktop application.
|
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Main Sass file, imported by other components as single entrypoint.
|
||||||
- [**`main.ts`**](./../src/presentation/main.ts): Main process of Electron, started as first thing when app starts.
|
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.
|
||||||
- [**`/public/`**](./../public/): Contains static assets that are directly copied and do not go through webpack.
|
- [**`electron/`**](./../src/presentation/electron/): Contains Electron code.
|
||||||
- [**`/vue.config.js`**](./../vue.config.js): Global Vue CLI configurations loaded by `@vue/cli-service`.
|
- [`/main/` **`index.ts`**](./../src/presentation/main.ts): Main entry for Electron, managing application windows and lifecycle events.
|
||||||
- [**`/postcss.config.js`**](./../postcss.config.js): PostCSS configurations used by Vue CLI internally.
|
- [`/preload/` **`index.ts`**](./../src/presentation/main.ts): Script executed before the renderer, securing Node.js features for renderer use.
|
||||||
- [**`/babel.config.js`**](./../babel.config.js): Babel configurations for polyfills used by `@vue/cli-plugin-babel`.
|
- [**`/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.
|
||||||
|
|
||||||
## Visual design best-practices
|
## Visual design best-practices
|
||||||
|
|
||||||
@@ -32,7 +36,7 @@ Add visual clues for clickable items. It should be as clear as possible that the
|
|||||||
|
|
||||||
## Application data
|
## Application data
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
[Application.ts](../src/domain/Application.ts) is an immutable domain model that represents application state. It includes:
|
[Application.ts](../src/domain/Application.ts) is an immutable domain model that represents application state. It includes:
|
||||||
|
|
||||||
@@ -43,32 +47,49 @@ You can read more about how application layer provides application data to he pr
|
|||||||
|
|
||||||
## Application state
|
## Application state
|
||||||
|
|
||||||
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.
|
This project uses a singleton instance of the application state, making it available to all Vue components.
|
||||||
|
|
||||||
[`StatefulVue`](./../src/presentation/components/Shared/StatefulVue.ts) functions include:
|
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.
|
||||||
|
|
||||||
- Creating a singleton of the state and makes it available to presentation layer as single source of truth.
|
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.
|
||||||
- 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.
|
|
||||||
|
|
||||||
📖 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.
|
[`UseCollectionState.ts`](./../src/presentation/components/Shared/Hooks/UseCollectionState.ts) provides several functionalities including:
|
||||||
|
|
||||||
## Modals
|
- **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).
|
||||||
|
|
||||||
[Dialog.vue](./../src/presentation/components/Shared/Dialog.vue) is a shared component that other components used to show modal windows.
|
📖 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.
|
||||||
|
|
||||||
You can use it by wrapping the content inside of its `slot` and call `.show()` function on its reference. For example:
|
## Dependency injections
|
||||||
|
|
||||||
```html
|
The presentation layer uses Vue's native dependency injection system to increase testability and decouple components.
|
||||||
<Dialog ref="testDialog">
|
|
||||||
<div>Hello world</div>
|
To add a new dependency:
|
||||||
</Dialog>
|
|
||||||
<div @click="$refs.testDialog.show()">Show dialog</div>
|
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.
|
||||||
|
|
||||||
## Sass naming convention
|
## Sass naming convention
|
||||||
|
|
||||||
|
|||||||
122
docs/tests.md
122
docs/tests.md
@@ -5,77 +5,79 @@ There are different types of tests executed:
|
|||||||
1. [Unit tests](#unit-tests)
|
1. [Unit tests](#unit-tests)
|
||||||
2. [Integration tests](#integration-tests)
|
2. [Integration tests](#integration-tests)
|
||||||
3. [End-to-end (E2E) tests](#e2e-tests)
|
3. [End-to-end (E2E) tests](#e2e-tests)
|
||||||
|
4. [Automated checks](#automated-checks)
|
||||||
|
|
||||||
Common aspects for all tests:
|
## Unit and integration tests
|
||||||
|
|
||||||
- They use [Mocha](https://mochajs.org/) and [Chai](https://www.chaijs.com/).
|
- They utilize [Vitest](https://vitest.dev/).
|
||||||
- Their files end with `.spec.{ts|js}` suffix.
|
- Test files are suffixed with `.spec.ts`.
|
||||||
|
|
||||||
💡 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
|
### Act, arrange, assert
|
||||||
|
|
||||||
- Tests use act, arrange and assert (AAA) pattern when applicable.
|
- Tests implement the act, arrange, and assert (AAA) pattern.
|
||||||
- **Arrange**
|
- **Arrange**
|
||||||
- Sets up the test case.
|
- Sets up the test scenario and environment.
|
||||||
- Starts with comment line `// arrange`.
|
- Begins with comment line `// arrange`.
|
||||||
- **Act**
|
- **Act**
|
||||||
- Executes the actual test.
|
- Executes the actual test.
|
||||||
- Starts with comment line `// act`.
|
- Begins with comment line `// act`.
|
||||||
- **Assert**
|
- **Assert**
|
||||||
- Elicit some sort of expectation.
|
- Sets an expectation for the test's outcome.
|
||||||
- Starts with comment line `// assert`.
|
- Begins with comment line `// assert`.
|
||||||
|
|
||||||
## Integration tests
|
### Unit tests
|
||||||
|
|
||||||
- Tests functionality of a component in combination with others (not isolated).
|
- Evaluate individual components in isolation.
|
||||||
- Ensure dependencies to third parties work as expected.
|
- Located in [`./tests/unit`](./../tests/unit).
|
||||||
- Defined in [./tests/integration](./../tests/integration).
|
- 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.
|
||||||
|
|
||||||
## E2E tests
|
## E2E tests
|
||||||
|
|
||||||
- Test the functionality and performance of a running application.
|
- Examine the live web application's functionality and performance.
|
||||||
- Vue CLI plugin [`e2e-cypress`](https://github.com/vuejs/vue-cli/tree/dev/packages/@vue/cli-plugin-e2e-cypress#readme) configures E2E tests.
|
- Uses Cypress to run the tests.
|
||||||
- Test names and folders have logical structure based on tests executed.
|
|
||||||
- The structure is following:
|
## Automated checks
|
||||||
- [`cypress.config.ts`](./../cypress.config.ts): Cypress configuration file.
|
|
||||||
- [`./tests/e2e/`](./../tests/e2e/): Base Cypress folder.
|
These checks validate various qualities like runtime execution, building process, security testing, etc.
|
||||||
- [`/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.
|
- Use [various tools](./../package.json) and [scripts](./../scripts).
|
||||||
- [`/support/index.js`](./../tests/e2e/support/index.js): Support file, runs before every single spec file.
|
- Are automatically executed as [GitHub workflows](./../.github/workflows).
|
||||||
- *(Ignored)* `/videos`: Asset folder for videos taken during tests.
|
|
||||||
- *(Ignored)* `/screenshots`: Asset folder for Screenshots taken during tests.
|
## 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.
|
||||||
|
|||||||
31
electron-builder.yml
Normal file
31
electron-builder.yml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# -------
|
||||||
|
# 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
|
||||||
68
electron.vite.config.ts
Normal file
68
electron.vite.config.ts
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -4,9 +4,6 @@ This folder contains image files and other resources related to images.
|
|||||||
|
|
||||||
## logo.svg
|
## logo.svg
|
||||||
|
|
||||||
[logo.svg](./logo.svg) is the master logo from which all other icons or images are created from.
|
[`logo.svg`](./logo.svg) serves as the primary logo from which all other icons and images are derived.
|
||||||
It should be the only file that will be changed manually.
|
Only modify this file manually.
|
||||||
|
After making changes, execute `npm run build:icons` to regenerate logo files in various formats.
|
||||||
[`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.
|
|
||||||
|
|||||||
25923
package-lock.json
generated
25923
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
72
package.json
72
package.json
@@ -1,29 +1,35 @@
|
|||||||
{
|
{
|
||||||
"name": "privacy.sexy",
|
"name": "privacy.sexy",
|
||||||
"version": "0.12.0",
|
"version": "0.12.1",
|
||||||
"private": true,
|
"private": true,
|
||||||
"slogan": "Now you have the choice",
|
"slogan": "Now you have the choice",
|
||||||
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
|
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
|
||||||
"author": "undergroundwires",
|
"author": "undergroundwires",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"serve": "vue-cli-service serve",
|
"dev": "vite",
|
||||||
"build": "vue-cli-service build",
|
"build": "vue-tsc --noEmit && vite build",
|
||||||
"test:unit": "vue-cli-service test:unit",
|
"preview": "vite preview",
|
||||||
|
"test:unit": "vitest run --dir tests/unit",
|
||||||
|
"test:integration": "vitest run --dir tests/integration",
|
||||||
"test:e2e": "vue-cli-service test:e2e",
|
"test: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",
|
"lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml",
|
||||||
"create-icons": "node img/logo-update.mjs",
|
"icons:build": "node scripts/logo-update.js",
|
||||||
"electron:build": "vue-cli-service electron:build",
|
"electron:dev": "electron-vite dev",
|
||||||
"electron:serve": "vue-cli-service electron:serve",
|
"electron:preview": "electron-vite preview",
|
||||||
"lint:eslint": "vue-cli-service lint --no-fix --mode production",
|
"electron:prebuild": "electron-vite build",
|
||||||
|
"electron:build": "electron-builder",
|
||||||
|
"lint:eslint": "eslint .",
|
||||||
"lint:md": "markdownlint **/*.md --ignore node_modules",
|
"lint:md": "markdownlint **/*.md --ignore node_modules",
|
||||||
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
|
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
|
||||||
"lint:md:relative-urls": "remark . --frail --use remark-validate-links",
|
"lint:md:relative-urls": "remark . --frail --use remark-validate-links",
|
||||||
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
|
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
|
||||||
"postinstall": "electron-builder install-app-deps",
|
"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": "index.js",
|
"main": "./dist_electron/main/index.cjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
"@fortawesome/fontawesome-svg-core": "^6.4.0",
|
||||||
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
"@fortawesome/free-brands-svg-icons": "^6.4.0",
|
||||||
@@ -32,7 +38,6 @@
|
|||||||
"@fortawesome/vue-fontawesome": "^2.0.9",
|
"@fortawesome/vue-fontawesome": "^2.0.9",
|
||||||
"@juggle/resize-observer": "^3.4.0",
|
"@juggle/resize-observer": "^3.4.0",
|
||||||
"ace-builds": "^1.23.4",
|
"ace-builds": "^1.23.4",
|
||||||
"core-js": "^3.32.0",
|
|
||||||
"cross-fetch": "^4.0.0",
|
"cross-fetch": "^4.0.0",
|
||||||
"electron-progressbar": "^2.1.0",
|
"electron-progressbar": "^2.1.0",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
@@ -41,28 +46,21 @@
|
|||||||
"markdown-it": "^13.0.1",
|
"markdown-it": "^13.0.1",
|
||||||
"npm": "^9.8.1",
|
"npm": "^9.8.1",
|
||||||
"v-tooltip": "2.1.3",
|
"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": {
|
"devDependencies": {
|
||||||
|
"@modyfi/vite-plugin-yaml": "^1.0.4",
|
||||||
"@rushstack/eslint-patch": "^1.3.2",
|
"@rushstack/eslint-patch": "^1.3.2",
|
||||||
"@types/ace": "^0.0.48",
|
"@types/ace": "^0.0.48",
|
||||||
"@types/chai": "^4.3.5",
|
|
||||||
"@types/file-saver": "^2.0.5",
|
"@types/file-saver": "^2.0.5",
|
||||||
"@types/mocha": "^10.0.1",
|
|
||||||
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
"@typescript-eslint/eslint-plugin": "^5.62.0",
|
||||||
"@typescript-eslint/parser": "^5.62.0",
|
"@typescript-eslint/parser": "^5.62.0",
|
||||||
"@vue/cli-plugin-babel": "~5.0.8",
|
"@vitejs/plugin-legacy": "^4.1.1",
|
||||||
"@vue/cli-plugin-e2e-cypress": "~5.0.8",
|
"@vitejs/plugin-vue2": "^2.2.0",
|
||||||
"@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-airbnb-with-typescript": "^7.0.0",
|
||||||
"@vue/eslint-config-typescript": "^11.0.3",
|
"@vue/eslint-config-typescript": "^11.0.3",
|
||||||
"chai": "^4.3.7",
|
"@vue/test-utils": "^1.3.6",
|
||||||
|
"autoprefixer": "^10.4.15",
|
||||||
"cypress": "^12.17.2",
|
"cypress": "^12.17.2",
|
||||||
"electron": "^25.3.2",
|
"electron": "^25.3.2",
|
||||||
"electron-builder": "^24.6.3",
|
"electron-builder": "^24.6.3",
|
||||||
@@ -70,32 +68,32 @@
|
|||||||
"electron-icon-builder": "^2.0.1",
|
"electron-icon-builder": "^2.0.1",
|
||||||
"electron-log": "^4.4.8",
|
"electron-log": "^4.4.8",
|
||||||
"electron-updater": "^6.1.4",
|
"electron-updater": "^6.1.4",
|
||||||
|
"electron-vite": "^1.0.27",
|
||||||
"eslint": "^8.46.0",
|
"eslint": "^8.46.0",
|
||||||
"eslint-plugin-import": "^2.26.0",
|
"eslint-plugin-cypress": "^2.14.0",
|
||||||
"eslint-plugin-vue": "^9.6.0",
|
"eslint-plugin-vue": "^9.6.0",
|
||||||
"eslint-plugin-vuejs-accessibility": "^1.2.0",
|
"eslint-plugin-vuejs-accessibility": "^1.2.0",
|
||||||
"icon-gen": "^3.0.1",
|
"icon-gen": "^3.0.1",
|
||||||
"js-yaml-loader": "^1.2.2",
|
"jsdom": "^22.1.0",
|
||||||
"markdownlint-cli": "^0.35.0",
|
"markdownlint-cli": "^0.35.0",
|
||||||
|
"postcss": "^8.4.28",
|
||||||
"remark-cli": "^11.0.0",
|
"remark-cli": "^11.0.0",
|
||||||
"remark-lint-no-dead-urls": "^1.1.0",
|
"remark-lint-no-dead-urls": "^1.1.0",
|
||||||
"remark-preset-lint-consistent": "^5.1.2",
|
"remark-preset-lint-consistent": "^5.1.2",
|
||||||
"remark-validate-links": "^12.1.1",
|
"remark-validate-links": "^12.1.1",
|
||||||
"sass": "^1.64.1",
|
"sass": "^1.64.1",
|
||||||
"sass-loader": "^13.3.2",
|
"start-server-and-test": "^2.0.0",
|
||||||
"svgexport": "^0.4.2",
|
"svgexport": "^0.4.2",
|
||||||
"ts-loader": "^9.4.4",
|
"terser": "^5.19.2",
|
||||||
|
"tslib": "~2.4.0",
|
||||||
"typescript": "~4.6.2",
|
"typescript": "~4.6.2",
|
||||||
"vue-cli-plugin-electron-builder": "^3.0.0-alpha.4",
|
"vite": "^4.4.9",
|
||||||
"yaml-lint": "^1.7.0",
|
"vitest": "^0.34.2",
|
||||||
"tslib": "~2.4.0"
|
"vue-tsc": "^1.8.8",
|
||||||
},
|
"yaml-lint": "^1.7.0"
|
||||||
"overrides": {
|
|
||||||
"vue-cli-plugin-electron-builder": {
|
|
||||||
"electron-builder": "^24.6.3"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"//devDependencies": {
|
"//devDependencies": {
|
||||||
|
"terser": "Used by @vitejs/plugin-legacy for minification",
|
||||||
"typescript": [
|
"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 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"
|
"Cannot upgrade to > 4.6.X otherwise unit tests do not work, https://github.com/evanw/node-source-map-support/issues/252"
|
||||||
|
|||||||
9
postcss.config.cjs
Normal file
9
postcss.config.cjs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
const autoprefixer = require('autoprefixer');
|
||||||
|
|
||||||
|
module.exports = () => {
|
||||||
|
return {
|
||||||
|
plugins: [
|
||||||
|
autoprefixer(),
|
||||||
|
],
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
module.exports = {
|
|
||||||
plugins: {
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
10
scripts/check-desktop-runtime-errors/.eslintrc.cjs
Normal file
10
scripts/check-desktop-runtime-errors/.eslintrc.cjs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
require('@rushstack/eslint-patch/modern-module-resolution.js');
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
'import/extensions': ['error', 'always'],
|
||||||
|
},
|
||||||
|
};
|
||||||
35
scripts/check-desktop-runtime-errors/README.md
Normal file
35
scripts/check-desktop-runtime-errors/README.md
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
# 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).
|
||||||
55
scripts/check-desktop-runtime-errors/app/app-logs.js
Normal file
55
scripts/check-desktop-runtime-errors/app/app-logs.js
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
126
scripts/check-desktop-runtime-errors/app/check-for-errors.js
Normal file
126
scripts/check-desktop-runtime-errors/app/check-for-errors.js
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
34
scripts/check-desktop-runtime-errors/app/extractors/linux.js
Normal file
34
scripts/check-desktop-runtime-errors/app/extractors/linux.js
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
66
scripts/check-desktop-runtime-errors/app/extractors/macos.js
Normal file
66
scripts/check-desktop-runtime-errors/app/extractors/macos.js
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
164
scripts/check-desktop-runtime-errors/app/runner.js
Normal file
164
scripts/check-desktop-runtime-errors/app/runner.js
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
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];
|
||||||
|
}
|
||||||
20
scripts/check-desktop-runtime-errors/cli-args.js
Normal file
20
scripts/check-desktop-runtime-errors/cli-args.js
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
7
scripts/check-desktop-runtime-errors/config.js
Normal file
7
scripts/check-desktop-runtime-errors/config.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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');
|
||||||
3
scripts/check-desktop-runtime-errors/index.js
Normal file
3
scripts/check-desktop-runtime-errors/index.js
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
import { main } from './main.js';
|
||||||
|
|
||||||
|
await main();
|
||||||
68
scripts/check-desktop-runtime-errors/main.js
Normal file
68
scripts/check-desktop-runtime-errors/main.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
48
scripts/check-desktop-runtime-errors/utils/io.js
Normal file
48
scripts/check-desktop-runtime-errors/utils/io.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
39
scripts/check-desktop-runtime-errors/utils/log.js
Normal file
39
scripts/check-desktop-runtime-errors/utils/log.js
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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,
|
||||||
|
},
|
||||||
|
};
|
||||||
87
scripts/check-desktop-runtime-errors/utils/npm.js
Normal file
87
scripts/check-desktop-runtime-errors/utils/npm.js
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
9
scripts/check-desktop-runtime-errors/utils/platform.js
Normal file
9
scripts/check-desktop-runtime-errors/utils/platform.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import { platform } from 'os';
|
||||||
|
|
||||||
|
export const SUPPORTED_PLATFORMS = {
|
||||||
|
MAC: 'darwin',
|
||||||
|
LINUX: 'linux',
|
||||||
|
WINDOWS: 'win32',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const CURRENT_PLATFORM = platform();
|
||||||
44
scripts/check-desktop-runtime-errors/utils/run-command.js
Normal file
44
scripts/check-desktop-runtime-errors/utils/run-command.js
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
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');
|
||||||
|
}
|
||||||
19
scripts/check-desktop-runtime-errors/utils/text.js
Normal file
19
scripts/check-desktop-runtime-errors/utils/text.js
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ class Paths {
|
|||||||
constructor(selfDirectory) {
|
constructor(selfDirectory) {
|
||||||
const projectRoot = resolve(selfDirectory, '../');
|
const projectRoot = resolve(selfDirectory, '../');
|
||||||
this.sourceImage = join(projectRoot, 'img/logo.svg');
|
this.sourceImage = join(projectRoot, 'img/logo.svg');
|
||||||
this.publicDirectory = join(projectRoot, 'public');
|
this.publicDirectory = join(projectRoot, 'src/presentation/public');
|
||||||
this.electronBuildDirectory = join(projectRoot, 'build');
|
this.electronBuildDirectory = join(projectRoot, 'build');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,7 +61,7 @@ async function updateDesktopIcons(sourceImage, electronIconsDir) {
|
|||||||
await ensureFolderExists(electronIconsDir);
|
await ensureFolderExists(electronIconsDir);
|
||||||
const temporaryDir = await mkdtemp('icon-');
|
const temporaryDir = await mkdtemp('icon-');
|
||||||
const temporaryPngFile = join(temporaryDir, 'icon.png');
|
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(
|
await runCommand(
|
||||||
'npx',
|
'npx',
|
||||||
'svgexport',
|
'svgexport',
|
||||||
15
src/TypeHelpers.ts
Normal file
15
src/TypeHelpers.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
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];
|
||||||
50
src/application/Common/CustomError.ts
Normal file
50
src/application/Common/CustomError.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
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';
|
||||||
|
}
|
||||||
@@ -1,8 +1,8 @@
|
|||||||
import { IApplicationContext } from '@/application/Context/IApplicationContext';
|
import { IApplicationContext } from '@/application/Context/IApplicationContext';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { IApplication } from '@/domain/IApplication';
|
import { IApplication } from '@/domain/IApplication';
|
||||||
import { Environment } from '../Environment/Environment';
|
import { Environment } from '@/infrastructure/Environment/Environment';
|
||||||
import { IEnvironment } from '../Environment/IEnvironment';
|
import { IEnvironment } from '@/infrastructure/Environment/IEnvironment';
|
||||||
import { IApplicationFactory } from '../IApplicationFactory';
|
import { IApplicationFactory } from '../IApplicationFactory';
|
||||||
import { ApplicationFactory } from '../ApplicationFactory';
|
import { ApplicationFactory } from '../ApplicationFactory';
|
||||||
import { ApplicationContext } from './ApplicationContext';
|
import { ApplicationContext } from './ApplicationContext';
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export enum FilterActionType {
|
||||||
|
Apply,
|
||||||
|
Clear,
|
||||||
|
}
|
||||||
37
src/application/Context/State/Filter/Event/FilterChange.ts
Normal file
37
src/application/Context/State/Filter/Event/FilterChange.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { IEventSource } from '@/infrastructure/Events/IEventSource';
|
import { IEventSource } from '@/infrastructure/Events/IEventSource';
|
||||||
import { IFilterResult } from './IFilterResult';
|
import { IFilterResult } from './IFilterResult';
|
||||||
|
import { IFilterChangeDetails } from './Event/IFilterChangeDetails';
|
||||||
|
|
||||||
export interface IReadOnlyUserFilter {
|
export interface IReadOnlyUserFilter {
|
||||||
readonly currentFilter: IFilterResult | undefined;
|
readonly currentFilter: IFilterResult | undefined;
|
||||||
readonly filtered: IEventSource<IFilterResult>;
|
readonly filterChanged: IEventSource<IFilterChangeDetails>;
|
||||||
readonly filterRemoved: IEventSource<void>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IUserFilter extends IReadOnlyUserFilter {
|
export interface IUserFilter extends IReadOnlyUserFilter {
|
||||||
setFilter(filter: string): void;
|
applyFilter(filter: string): void;
|
||||||
removeFilter(): void;
|
clearFilter(): void;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
|||||||
import { FilterResult } from './FilterResult';
|
import { FilterResult } from './FilterResult';
|
||||||
import { IFilterResult } from './IFilterResult';
|
import { IFilterResult } from './IFilterResult';
|
||||||
import { IUserFilter } from './IUserFilter';
|
import { IUserFilter } from './IUserFilter';
|
||||||
|
import { IFilterChangeDetails } from './Event/IFilterChangeDetails';
|
||||||
|
import { FilterChange } from './Event/FilterChange';
|
||||||
|
|
||||||
export class UserFilter implements IUserFilter {
|
export class UserFilter implements IUserFilter {
|
||||||
public readonly filtered = new EventSource<IFilterResult>();
|
public readonly filterChanged = new EventSource<IFilterChangeDetails>();
|
||||||
|
|
||||||
public readonly filterRemoved = new EventSource<void>();
|
|
||||||
|
|
||||||
public currentFilter: IFilterResult | undefined;
|
public currentFilter: IFilterResult | undefined;
|
||||||
|
|
||||||
@@ -16,9 +16,9 @@ export class UserFilter implements IUserFilter {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public setFilter(filter: string): void {
|
public applyFilter(filter: string): void {
|
||||||
if (!filter) {
|
if (!filter) {
|
||||||
throw new Error('Filter must be defined and not empty. Use removeFilter() to remove the filter');
|
throw new Error('Filter must be defined and not empty. Use clearFilter() to remove the filter');
|
||||||
}
|
}
|
||||||
const filterLowercase = filter.toLocaleLowerCase();
|
const filterLowercase = filter.toLocaleLowerCase();
|
||||||
const filteredScripts = this.collection.getAllScripts().filter(
|
const filteredScripts = this.collection.getAllScripts().filter(
|
||||||
@@ -33,12 +33,12 @@ export class UserFilter implements IUserFilter {
|
|||||||
filter,
|
filter,
|
||||||
);
|
);
|
||||||
this.currentFilter = matches;
|
this.currentFilter = matches;
|
||||||
this.filtered.notify(matches);
|
this.filterChanged.notify(FilterChange.forApply(this.currentFilter));
|
||||||
}
|
}
|
||||||
|
|
||||||
public removeFilter(): void {
|
public clearFilter(): void {
|
||||||
this.currentFilter = undefined;
|
this.currentFilter = undefined;
|
||||||
this.filterRemoved.notify();
|
this.filterChanged.notify(FilterChange.forClear());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
|
||||||
|
|
||||||
export interface IEnvironment {
|
|
||||||
readonly isDesktop: boolean;
|
|
||||||
readonly os: OperatingSystem;
|
|
||||||
}
|
|
||||||
@@ -7,16 +7,19 @@ import MacOsData from '@/application/collections/macos.yaml';
|
|||||||
import LinuxData from '@/application/collections/linux.yaml';
|
import LinuxData from '@/application/collections/linux.yaml';
|
||||||
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
|
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
|
||||||
import { Application } from '@/domain/Application';
|
import { Application } from '@/domain/Application';
|
||||||
|
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
|
||||||
|
import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory';
|
||||||
import { parseCategoryCollection } from './CategoryCollectionParser';
|
import { parseCategoryCollection } from './CategoryCollectionParser';
|
||||||
|
|
||||||
export function parseApplication(
|
export function parseApplication(
|
||||||
parser = CategoryCollectionParser,
|
categoryParser = parseCategoryCollection,
|
||||||
processEnv: NodeJS.ProcessEnv = process.env,
|
informationParser = parseProjectInformation,
|
||||||
|
metadata: IAppMetadata = AppMetadataFactory.Current.instance,
|
||||||
collectionsData = PreParsedCollections,
|
collectionsData = PreParsedCollections,
|
||||||
): IApplication {
|
): IApplication {
|
||||||
validateCollectionsData(collectionsData);
|
validateCollectionsData(collectionsData);
|
||||||
const information = parseProjectInformation(processEnv);
|
const information = informationParser(metadata);
|
||||||
const collections = collectionsData.map((collection) => parser(collection, information));
|
const collections = collectionsData.map((collection) => categoryParser(collection, information));
|
||||||
const app = new Application(information, collections);
|
const app = new Application(information, collections);
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
@@ -24,16 +27,12 @@ export function parseApplication(
|
|||||||
export type CategoryCollectionParserType
|
export type CategoryCollectionParserType
|
||||||
= (file: CollectionData, info: IProjectInformation) => ICategoryCollection;
|
= (file: CollectionData, info: IProjectInformation) => ICategoryCollection;
|
||||||
|
|
||||||
const CategoryCollectionParser: CategoryCollectionParserType = (file, info) => {
|
|
||||||
return parseCategoryCollection(file, info);
|
|
||||||
};
|
|
||||||
|
|
||||||
const PreParsedCollections: readonly CollectionData [] = [
|
const PreParsedCollections: readonly CollectionData [] = [
|
||||||
WindowsData, MacOsData, LinuxData,
|
WindowsData, MacOsData, LinuxData,
|
||||||
];
|
];
|
||||||
|
|
||||||
function validateCollectionsData(collections: readonly CollectionData[]) {
|
function validateCollectionsData(collections: readonly CollectionData[]) {
|
||||||
if (!collections || !collections.length) {
|
if (!collections?.length) {
|
||||||
throw new Error('missing collections');
|
throw new Error('missing collections');
|
||||||
}
|
}
|
||||||
if (collections.some((collection) => !collection)) {
|
if (collections.some((collection) => !collection)) {
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
|
import { CustomError } from '@/application/Common/CustomError';
|
||||||
import { NodeType } from './NodeType';
|
import { NodeType } from './NodeType';
|
||||||
import { NodeData } from './NodeData';
|
import { NodeData } from './NodeData';
|
||||||
|
|
||||||
export class NodeDataError extends Error {
|
export class NodeDataError extends CustomError {
|
||||||
constructor(message: string, public readonly context: INodeDataErrorContext) {
|
constructor(message: string, public readonly context: INodeDataErrorContext) {
|
||||||
super(createMessage(message, context));
|
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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,28 +1,29 @@
|
|||||||
import { IProjectInformation } from '@/domain/IProjectInformation';
|
import { IProjectInformation } from '@/domain/IProjectInformation';
|
||||||
import { ProjectInformation } from '@/domain/ProjectInformation';
|
import { ProjectInformation } from '@/domain/ProjectInformation';
|
||||||
|
import { IAppMetadata } from '@/infrastructure/Metadata/IAppMetadata';
|
||||||
import { Version } from '@/domain/Version';
|
import { Version } from '@/domain/Version';
|
||||||
|
import { AppMetadataFactory } from '@/infrastructure/Metadata/AppMetadataFactory';
|
||||||
|
import { ConstructorArguments } from '@/TypeHelpers';
|
||||||
|
|
||||||
export function parseProjectInformation(
|
export function
|
||||||
environment: NodeJS.ProcessEnv | VueAppEnvironment,
|
parseProjectInformation(
|
||||||
|
metadata: IAppMetadata = AppMetadataFactory.Current.instance,
|
||||||
|
createProjectInformation: ProjectInformationFactory = (
|
||||||
|
...args
|
||||||
|
) => new ProjectInformation(...args),
|
||||||
): IProjectInformation {
|
): IProjectInformation {
|
||||||
const version = new Version(environment[VueAppEnvironmentKeys.VUE_APP_VERSION]);
|
const version = new Version(
|
||||||
return new ProjectInformation(
|
metadata.version,
|
||||||
environment[VueAppEnvironmentKeys.VUE_APP_NAME],
|
);
|
||||||
|
return createProjectInformation(
|
||||||
|
metadata.name,
|
||||||
version,
|
version,
|
||||||
environment[VueAppEnvironmentKeys.VUE_APP_SLOGAN],
|
metadata.slogan,
|
||||||
environment[VueAppEnvironmentKeys.VUE_APP_REPOSITORY_URL],
|
metadata.repositoryUrl,
|
||||||
environment[VueAppEnvironmentKeys.VUE_APP_HOMEPAGE_URL],
|
metadata.homepageUrl,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const VueAppEnvironmentKeys = {
|
export type ProjectInformationFactory = (
|
||||||
VUE_APP_VERSION: 'VUE_APP_VERSION',
|
...args: ConstructorArguments<typeof ProjectInformation>
|
||||||
VUE_APP_NAME: 'VUE_APP_NAME',
|
) => IProjectInformation;
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -3139,6 +3139,68 @@ actions:
|
|||||||
-
|
-
|
||||||
category: Disable Windows Defender Firewall # Also known as Windows Firewall, Microsoft Defender Firewall
|
category: Disable Windows Defender Firewall # Also known as Windows Firewall, Microsoft Defender Firewall
|
||||||
children:
|
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
|
name: Disable Firewall through command-line utility
|
||||||
# ❗️ Following must be enabled and in running state:
|
# ❗️ Following must be enabled and in running state:
|
||||||
@@ -3214,6 +3276,11 @@ 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\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\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
|
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
|
name: Disable Microsoft Defender Antivirus # Depreciated since Windows 10 version 1903
|
||||||
docs:
|
docs:
|
||||||
@@ -4504,11 +4571,6 @@ actions:
|
|||||||
docs: https://admx.help/?Category=Windows_10_2016&Policy=Microsoft.Policies.WindowsDefenderSecurityCenter::DeviceSecurity_DisableTpmFirmwareUpdateWarning
|
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
|
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
|
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
|
category: Hide Windows Defender notifications
|
||||||
children:
|
children:
|
||||||
@@ -4609,43 +4671,6 @@ actions:
|
|||||||
# 1. Some cannot be disabled (access error) normally but only with DisableServiceInRegistry
|
# 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
|
# 2. Some cannot be disabled even using DisableServiceInRegistry, must be disabled as TrustedInstaller using RunInlineCodeAsTrustedInstaller
|
||||||
children:
|
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
|
name: Disable Windows Defender Antivirus service
|
||||||
# ❗️ Breaks `Set-MpPreference` PowerShell cmdlet that helps to manage Defender
|
# ❗️ Breaks `Set-MpPreference` PowerShell cmdlet that helps to manage Defender
|
||||||
@@ -4657,8 +4682,8 @@ actions:
|
|||||||
-
|
-
|
||||||
function: RunInlineCodeAsTrustedInstaller
|
function: RunInlineCodeAsTrustedInstaller
|
||||||
parameters:
|
parameters:
|
||||||
code: sc stop "WinDefend" >nul & sc config "WinDefend" start=disabled
|
code: sc stop "WinDefend" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WinDefend" /v "Start" /t REG_DWORD /d "4" /f
|
||||||
revertCode: sc config "WinDefend" start=auto & sc start "WinDefend" >nul
|
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WinDefend" /v "Start" /t REG_DWORD /d "2" /f & sc start "WinDefend" >nul 2>&1
|
||||||
# - # "Access is denied" when renaming file
|
# - # "Access is denied" when renaming file
|
||||||
# function: RenameSystemFile
|
# function: RenameSystemFile
|
||||||
# parameters:
|
# parameters:
|
||||||
@@ -4666,24 +4691,6 @@ actions:
|
|||||||
-
|
-
|
||||||
category: Disable kernel-level Windows Defender drivers
|
category: Disable kernel-level Windows Defender drivers
|
||||||
children:
|
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
|
# - Skipping wdnsfltr "Windows Defender Network Stream Filter Driver" as it's Windows 1709 only
|
||||||
-
|
-
|
||||||
name: Disable Microsoft Defender Antivirus Network Inspection System Driver service
|
name: Disable Microsoft Defender Antivirus Network Inspection System Driver service
|
||||||
@@ -4693,8 +4700,8 @@ actions:
|
|||||||
function: RunInlineCodeAsTrustedInstaller
|
function: RunInlineCodeAsTrustedInstaller
|
||||||
parameters:
|
parameters:
|
||||||
# "net stop" is used to stop dependend services as well, "sc stop" fails
|
# "net stop" is used to stop dependend services as well, "sc stop" fails
|
||||||
code: net stop "WdNisDrv" /yes >nul & sc config "WdNisDrv" start=disabled
|
code: net stop "WdNisDrv" /yes >nul & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisDrv" /v "Start" /t REG_DWORD /d "4" /f
|
||||||
revertCode: sc config "WdNisDrv" start=demand & sc start "WdNisDrv" >nul
|
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisDrv" /v "Start" /t REG_DWORD /d "3" /f & sc start "WdNisDrv" >nul
|
||||||
-
|
-
|
||||||
function: RenameSystemFile
|
function: RenameSystemFile
|
||||||
parameters:
|
parameters:
|
||||||
@@ -4712,8 +4719,8 @@ actions:
|
|||||||
-
|
-
|
||||||
function: RunInlineCodeAsTrustedInstaller
|
function: RunInlineCodeAsTrustedInstaller
|
||||||
parameters:
|
parameters:
|
||||||
code: sc stop "WdFilter" >nul & sc config "WdFilter" start=disabled
|
code: sc stop "WdFilter" >nul & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdFilter" /v "Start" /t REG_DWORD /d "4" /f
|
||||||
revertCode: sc config "WdFilter" start=boot & sc start "WdFilter" >nul
|
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdFilter" /v "Start" /t REG_DWORD /d "0" /f & sc start "WdFilter" >nul
|
||||||
-
|
-
|
||||||
function: RenameSystemFile
|
function: RenameSystemFile
|
||||||
parameters:
|
parameters:
|
||||||
@@ -4729,8 +4736,8 @@ actions:
|
|||||||
-
|
-
|
||||||
function: RunInlineCodeAsTrustedInstaller
|
function: RunInlineCodeAsTrustedInstaller
|
||||||
parameters:
|
parameters:
|
||||||
code: sc stop "WdBoot" >nul & sc config "WdBoot" start=disabled
|
code: sc stop "WdBoot" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdBoot" /v "Start" /t REG_DWORD /d "4" /f
|
||||||
revertCode: sc config "WdBoot" start=boot & sc start "WdBoot" >nul
|
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdBoot" /v "Start" /t REG_DWORD /d "0" /f & sc start "WdBoot" >nul 2>&1
|
||||||
-
|
-
|
||||||
function: RenameSystemFile
|
function: RenameSystemFile
|
||||||
parameters:
|
parameters:
|
||||||
@@ -4748,8 +4755,8 @@ actions:
|
|||||||
-
|
-
|
||||||
function: RunInlineCodeAsTrustedInstaller
|
function: RunInlineCodeAsTrustedInstaller
|
||||||
parameters:
|
parameters:
|
||||||
code: sc stop "WdNisSvc" >nul & sc config "WdNisSvc" start=disabled
|
code: sc stop "WdNisSvc" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisSvc" /v "Start" /t REG_DWORD /d "4" /f
|
||||||
revertCode: sc config "WdNisSvc" start=auto & sc start "WdNisSvc" >nul
|
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\WdNisSvc" /v "Start" /t REG_DWORD /d "2" /f & sc start "WdNisSvc" >nul 2>&1
|
||||||
# - # "Access is denied" when renaming file
|
# - # "Access is denied" when renaming file
|
||||||
# function: RenameSystemFile
|
# function: RenameSystemFile
|
||||||
# parameters:
|
# parameters:
|
||||||
@@ -4759,10 +4766,10 @@ actions:
|
|||||||
docs: http://batcmd.com/windows/10/services/sense/
|
docs: http://batcmd.com/windows/10/services/sense/
|
||||||
call:
|
call:
|
||||||
-
|
-
|
||||||
function: DisableServiceInRegistry # We must disable it on registry level, "Access is denied" for sc config
|
function: RunInlineCodeAsTrustedInstaller # We must disable it on registry level, "Access is denied" for sc config
|
||||||
parameters:
|
parameters:
|
||||||
serviceName: Sense # Check: (Get-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Services\Sense").Start
|
code: sc stop "Sense" >nul 2>&1 & reg add "HKLM\SYSTEM\CurrentControlSet\Services\Sense" /v "Start" /t REG_DWORD /d "4" /f
|
||||||
defaultStartupMode: Manual # Alowed values: Boot | System | Automatic | Manual
|
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
|
||||||
-
|
-
|
||||||
function: RenameSystemFile
|
function: RenameSystemFile
|
||||||
parameters:
|
parameters:
|
||||||
@@ -4782,8 +4789,8 @@ actions:
|
|||||||
# ✅ Can disable using registry as TrustedInstaller
|
# ✅ Can disable using registry as TrustedInstaller
|
||||||
function: RunInlineCodeAsTrustedInstaller
|
function: RunInlineCodeAsTrustedInstaller
|
||||||
parameters:
|
parameters:
|
||||||
code: reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 4 /f
|
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
|
revertCode: reg add "HKLM\SYSTEM\CurrentControlSet\Services\SecurityHealthService" /v Start /t REG_DWORD /d 3 /f & sc start "SecurityHealthService" >nul 2>&1
|
||||||
-
|
-
|
||||||
function: RenameSystemFile
|
function: RenameSystemFile
|
||||||
parameters:
|
parameters:
|
||||||
@@ -4901,7 +4908,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 "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 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 "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
|
function: DisableService
|
||||||
parameters:
|
parameters:
|
||||||
|
|||||||
@@ -1,26 +1,27 @@
|
|||||||
import os from 'os';
|
import { Environment } from '@/infrastructure/Environment/Environment';
|
||||||
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';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
|
|
||||||
export class CodeRunner {
|
export class CodeRunner {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly node = getNodeJs(),
|
|
||||||
private readonly environment = Environment.CurrentEnvironment,
|
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> {
|
public async runCode(code: string, folderName: string, fileExtension: string): Promise<void> {
|
||||||
const dir = this.node.path.join(this.node.os.tmpdir(), folderName);
|
const { system } = this.environment;
|
||||||
await this.node.fs.promises.mkdir(dir, { recursive: true });
|
const dir = system.location.combinePaths(
|
||||||
const filePath = this.node.path.join(dir, `run.${fileExtension}`);
|
system.operatingSystem.getTempDirectory(),
|
||||||
await this.node.fs.promises.writeFile(filePath, code);
|
folderName,
|
||||||
await this.node.fs.promises.chmod(filePath, '755');
|
);
|
||||||
|
await system.fileSystem.createDirectory(dir, true);
|
||||||
|
const filePath = system.location.combinePaths(dir, `run.${fileExtension}`);
|
||||||
|
await system.fileSystem.writeToFile(filePath, code);
|
||||||
|
await system.fileSystem.setFilePermissions(filePath, '755');
|
||||||
const command = getExecuteCommand(filePath, this.environment);
|
const command = getExecuteCommand(filePath, this.environment);
|
||||||
this.node.child_process.exec(command);
|
system.command.execute(command);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -39,43 +40,3 @@ function getExecuteCommand(scriptPath: string, environment: Environment): string
|
|||||||
throw Error(`unsupported os: ${OperatingSystem[environment.os]}`);
|
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>;
|
|
||||||
}
|
|
||||||
|
|||||||
49
src/infrastructure/Environment/Environment.ts
Normal file
49
src/infrastructure/Environment/Environment.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
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;
|
||||||
8
src/infrastructure/Environment/IEnvironment.ts
Normal file
8
src/infrastructure/Environment/IEnvironment.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
13
src/infrastructure/Environment/WindowVariables.ts
Normal file
13
src/infrastructure/Environment/WindowVariables.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
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 { }
|
||||||
|
}
|
||||||
76
src/infrastructure/Environment/WindowVariablesValidator.ts
Normal file
76
src/infrastructure/Environment/WindowVariablesValidator.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { IEventSubscription } from './IEventSource';
|
import { IEventSubscription } from './IEventSource';
|
||||||
|
import { IEventSubscriptionCollection } from './IEventSubscriptionCollection';
|
||||||
|
|
||||||
export class EventSubscriptionCollection {
|
export class EventSubscriptionCollection implements IEventSubscriptionCollection {
|
||||||
private readonly subscriptions = new Array<IEventSubscription>();
|
private readonly subscriptions = new Array<IEventSubscription>();
|
||||||
|
|
||||||
public register(...subscriptions: IEventSubscription[]) {
|
public register(...subscriptions: IEventSubscription[]) {
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
|
||||||
|
|
||||||
|
export interface IEventSubscriptionCollection {
|
||||||
|
register(...subscriptions: IEventSubscription[]);
|
||||||
|
|
||||||
|
unsubscribeAll();
|
||||||
|
}
|
||||||
18
src/infrastructure/Metadata/AppMetadataFactory.ts
Normal file
18
src/infrastructure/Metadata/AppMetadataFactory.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
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;
|
||||||
13
src/infrastructure/Metadata/IAppMetadata.ts
Normal file
13
src/infrastructure/Metadata/IAppMetadata.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
5
src/infrastructure/Metadata/IAppMetadataFactory.ts
Normal file
5
src/infrastructure/Metadata/IAppMetadataFactory.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
import { IAppMetadata } from './IAppMetadata';
|
||||||
|
|
||||||
|
export interface IAppMetadataFactory {
|
||||||
|
readonly instance: IAppMetadata;
|
||||||
|
}
|
||||||
50
src/infrastructure/Metadata/MetadataValidator.ts
Normal file
50
src/infrastructure/Metadata/MetadataValidator.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
29
src/infrastructure/Metadata/Vite/ViteAppMetadata.ts
Normal file
29
src/infrastructure/Metadata/Vite/ViteAppMetadata.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
8
src/infrastructure/Metadata/Vite/ViteEnvironmentKeys.ts
Normal file
8
src/infrastructure/Metadata/Vite/ViteEnvironmentKeys.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
// 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;
|
||||||
11
src/infrastructure/Metadata/Vite/vite-env.d.ts
vendored
Normal file
11
src/infrastructure/Metadata/Vite/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
/// <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
|
||||||
|
}
|
||||||
31
src/infrastructure/RuntimeSanity/Common/FactoryValidator.ts
Normal file
31
src/infrastructure/RuntimeSanity/Common/FactoryValidator.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export interface ISanityCheckOptions {
|
||||||
|
readonly validateMetadata: boolean;
|
||||||
|
readonly validateEnvironment: boolean;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { ISanityCheckOptions } from './ISanityCheckOptions';
|
||||||
|
|
||||||
|
export interface ISanityValidator {
|
||||||
|
readonly name: string;
|
||||||
|
shouldValidate(options: ISanityCheckOptions): boolean;
|
||||||
|
collectErrors(): Iterable<string>;
|
||||||
|
}
|
||||||
50
src/infrastructure/RuntimeSanity/SanityChecks.ts
Normal file
50
src/infrastructure/RuntimeSanity/SanityChecks.ts
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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')}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/presentation/README.md
Normal file
3
src/presentation/README.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# presentation
|
||||||
|
|
||||||
|
See [`presentation.md`](./../../docs/presentation.md)
|
||||||
@@ -1,17 +1,19 @@
|
|||||||
// https://google-webfonts-helper.herokuapp.com/fonts
|
// https://google-webfonts-helper.herokuapp.com/fonts
|
||||||
|
|
||||||
|
@use "@/presentation/assets/styles/vite-path" as *;
|
||||||
|
|
||||||
/* slabo-27px-regular - latin-ext_latin */
|
/* slabo-27px-regular - latin-ext_latin */
|
||||||
@font-face {
|
@font-face {
|
||||||
font-family: 'Slabo 27px';
|
font-family: 'Slabo 27px';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url('~@/presentation/assets/fonts/slabo-27px-v6-latin-ext_latin-regular.eot'); /* IE9 Compat Modes */
|
src: url('#{$base-assets-path}/fonts/slabo-27px-v6-latin-ext_latin-regular.eot'); /* IE9 Compat Modes */
|
||||||
src: local('Slabo 27px'), local('Slabo27px-Regular'),
|
src: local('Slabo 27px'), local('Slabo27px-Regular'),
|
||||||
url('~@/presentation/assets/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.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('#{$base-assets-path}/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('#{$base-assets-path}/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('#{$base-assets-path}/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 */
|
url('#{$base-assets-path}/fonts/slabo-27px-v6-latin-ext_latin-regular.svg#Slabo27px') format('svg'); /* Legacy iOS */
|
||||||
}
|
}
|
||||||
|
|
||||||
/* yesteryear-regular - latin */
|
/* yesteryear-regular - latin */
|
||||||
@@ -19,13 +21,13 @@
|
|||||||
font-family: 'Yesteryear';
|
font-family: 'Yesteryear';
|
||||||
font-style: normal;
|
font-style: normal;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
src: url('~@/presentation/assets/fonts/yesteryear-v8-latin-regular.eot'); /* IE9 Compat Modes */
|
src: url('#{$base-assets-path}/fonts/yesteryear-v8-latin-regular.eot'); /* IE9 Compat Modes */
|
||||||
src: local('Yesteryear'), local('Yesteryear-Regular'),
|
src: local('Yesteryear'), local('Yesteryear-Regular'),
|
||||||
url('~@/presentation/assets/fonts/yesteryear-v8-latin-regular.eot?#iefix') format('embedded-opentype'), /* IE6-IE8 */
|
url('#{$base-assets-path}/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('#{$base-assets-path}/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('#{$base-assets-path}/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('#{$base-assets-path}/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 */
|
url('#{$base-assets-path}/fonts/yesteryear-v8-latin-regular.svg#Yesteryear') format('svg'); /* Legacy iOS */
|
||||||
}
|
}
|
||||||
|
|
||||||
$font-normal : 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
|
$font-normal : 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
@use "@/presentation/assets/styles/colors" as *;
|
@use "@/presentation/assets/styles/colors" as *;
|
||||||
@use "@/presentation/assets/styles/fonts" as *;
|
@use "@/presentation/assets/styles/fonts" as *;
|
||||||
@use "@/presentation/assets/styles/mixins" as *;
|
@use "@/presentation/assets/styles/mixins" as *;
|
||||||
|
@use "@/presentation/assets/styles/vite-path" as *;
|
||||||
|
|
||||||
* {
|
* {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|||||||
@@ -27,3 +27,21 @@
|
|||||||
*/
|
*/
|
||||||
-webkit-tap-highlight-color: transparent;
|
-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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
4
src/presentation/assets/styles/_vite-path.scss
Normal file
4
src/presentation/assets/styles/_vite-path.scss
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Define paths specific to Vite's resolution system.
|
||||||
|
// Vite uses the "@" symbol to resolve its aliases for styles.
|
||||||
|
|
||||||
|
$base-assets-path: "@/presentation/assets/";
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { VModalBootstrapper } from './Modules/VModalBootstrapper';
|
|
||||||
import { TreeBootstrapper } from './Modules/TreeBootstrapper';
|
import { TreeBootstrapper } from './Modules/TreeBootstrapper';
|
||||||
import { IconBootstrapper } from './Modules/IconBootstrapper';
|
import { IconBootstrapper } from './Modules/IconBootstrapper';
|
||||||
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
|
import { VueConstructor, IVueBootstrapper } from './IVueBootstrapper';
|
||||||
@@ -19,7 +18,6 @@ export class ApplicationBootstrapper implements IVueBootstrapper {
|
|||||||
new TreeBootstrapper(),
|
new TreeBootstrapper(),
|
||||||
new VueBootstrapper(),
|
new VueBootstrapper(),
|
||||||
new TooltipBootstrapper(),
|
new TooltipBootstrapper(),
|
||||||
new VModalBootstrapper(),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
src/presentation/bootstrapping/DependencyProvider.ts
Normal file
28
src/presentation/bootstrapping/DependencyProvider.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,14 +11,19 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from 'vue-property-decorator';
|
import { defineComponent } from 'vue';
|
||||||
import TheHeader from '@/presentation/components/TheHeader.vue';
|
import TheHeader from '@/presentation/components/TheHeader.vue';
|
||||||
import TheFooter from '@/presentation/components/TheFooter/TheFooter.vue';
|
import TheFooter from '@/presentation/components/TheFooter/TheFooter.vue';
|
||||||
import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue';
|
import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeButtons.vue';
|
||||||
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
|
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
|
||||||
import TheSearchBar from '@/presentation/components/TheSearchBar.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';
|
||||||
|
|
||||||
@Component({
|
const singletonAppContext = await buildContext();
|
||||||
|
|
||||||
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
TheHeader,
|
TheHeader,
|
||||||
TheCodeButtons,
|
TheCodeButtons,
|
||||||
@@ -26,10 +31,14 @@ import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
|
|||||||
TheSearchBar,
|
TheSearchBar,
|
||||||
TheFooter,
|
TheFooter,
|
||||||
},
|
},
|
||||||
})
|
setup() {
|
||||||
export default class App extends Vue {
|
provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
|
||||||
|
validateRuntimeSanity({
|
||||||
}
|
validateMetadata: true,
|
||||||
|
validateEnvironment: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|||||||
@@ -14,20 +14,36 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import {
|
import { defineComponent } from 'vue';
|
||||||
Component, Prop, Emit, Vue,
|
|
||||||
} from 'vue-property-decorator';
|
|
||||||
|
|
||||||
@Component
|
export default defineComponent({
|
||||||
export default class IconButton extends Vue {
|
props: {
|
||||||
@Prop() public text!: number;
|
text: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
iconPrefix: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
iconName: {
|
||||||
|
type: String,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
emits: [
|
||||||
|
'click',
|
||||||
|
],
|
||||||
|
setup(_, { emit }) {
|
||||||
|
function onClicked() {
|
||||||
|
emit('click');
|
||||||
|
}
|
||||||
|
|
||||||
@Prop() public iconPrefix!: string;
|
return {
|
||||||
|
onClicked,
|
||||||
@Prop() public iconName!: string;
|
};
|
||||||
|
},
|
||||||
@Emit('click') public onClicked() { /* do nothing except firing event */ }
|
});
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -2,26 +2,41 @@
|
|||||||
<span class="code-wrapper">
|
<span class="code-wrapper">
|
||||||
<span class="dollar">$</span>
|
<span class="dollar">$</span>
|
||||||
<code><slot /></code>
|
<code><slot /></code>
|
||||||
<font-awesome-icon
|
<TooltipWrapper>
|
||||||
class="copy-button"
|
<font-awesome-icon
|
||||||
:icon="['fas', 'copy']"
|
class="copy-button"
|
||||||
@click="copyCode"
|
:icon="['fas', 'copy']"
|
||||||
v-tooltip.top-center="'Copy'"
|
@click="copyCode"
|
||||||
/>
|
/>
|
||||||
|
<template v-slot:tooltip>
|
||||||
|
Copy
|
||||||
|
</template>
|
||||||
|
</TooltipWrapper>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Vue } from 'vue-property-decorator';
|
import { defineComponent, useSlots } from 'vue';
|
||||||
import { Clipboard } from '@/infrastructure/Clipboard';
|
import { Clipboard } from '@/infrastructure/Clipboard';
|
||||||
|
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
||||||
|
|
||||||
@Component
|
export default defineComponent({
|
||||||
export default class Code extends Vue {
|
components: {
|
||||||
public copyCode(): void {
|
TooltipWrapper,
|
||||||
const code = this.$slots.default[0].text;
|
},
|
||||||
Clipboard.copyText(code);
|
setup() {
|
||||||
}
|
const slots = useSlots();
|
||||||
}
|
|
||||||
|
function copyCode() {
|
||||||
|
const code = slots.default()[0].text;
|
||||||
|
Clipboard.copyText(code);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
copyCode,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
@@ -6,8 +6,8 @@
|
|||||||
<hr />
|
<hr />
|
||||||
<p>
|
<p>
|
||||||
<strong>1. The easy alternative</strong>. Run your script without any manual steps by
|
<strong>1. The easy alternative</strong>. Run your script without any manual steps by
|
||||||
<a :href="this.macOsDownloadUrl">downloading desktop version</a> of {{ this.appName }} on the
|
<a :href="macOsDownloadUrl">downloading desktop version</a> of {{ appName }} on the
|
||||||
{{ this.osName }} system you wish to configure, and then click on the Run button. This is
|
{{ osName }} system you wish to configure, and then click on the Run button. This is
|
||||||
recommended for most users.
|
recommended for most users.
|
||||||
</p>
|
</p>
|
||||||
<hr />
|
<hr />
|
||||||
@@ -20,27 +20,33 @@
|
|||||||
<p>
|
<p>
|
||||||
<ol>
|
<ol>
|
||||||
<li
|
<li
|
||||||
v-for='(step, index) in this.data.steps'
|
v-for='(step, index) in data.steps'
|
||||||
v-bind:key="index"
|
v-bind:key="index"
|
||||||
class="step"
|
class="step"
|
||||||
>
|
>
|
||||||
<div class="step__action">
|
<div class="step__action">
|
||||||
<span>{{ step.action.instruction }}</span>
|
<span>{{ step.action.instruction }}</span>
|
||||||
<font-awesome-icon
|
<TooltipWrapper v-if="step.action.details">
|
||||||
v-if="step.action.details"
|
<font-awesome-icon
|
||||||
class="explanation"
|
class="explanation"
|
||||||
:icon="['fas', 'info-circle']"
|
:icon="['fas', 'info-circle']"
|
||||||
v-tooltip.top-center="step.action.details"
|
/>
|
||||||
/>
|
<template v-slot:tooltip>
|
||||||
|
<div v-html="step.action.details" />
|
||||||
|
</template>
|
||||||
|
</TooltipWrapper>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="step.code" class="step__code">
|
<div v-if="step.code" class="step__code">
|
||||||
<Code>{{ step.code.instruction }}</Code>
|
<CodeInstruction>{{ step.code.instruction }}</CodeInstruction>
|
||||||
<font-awesome-icon
|
<TooltipWrapper v-if="step.code.details">
|
||||||
v-if="step.code.details"
|
<font-awesome-icon
|
||||||
class="explanation"
|
class="explanation"
|
||||||
:icon="['fas', 'info-circle']"
|
:icon="['fas', 'info-circle']"
|
||||||
v-tooltip.top-center="step.code.details"
|
/>
|
||||||
/>
|
<template v-slot:tooltip>
|
||||||
|
<div v-html="step.code.details" />
|
||||||
|
</template>
|
||||||
|
</TooltipWrapper>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ol>
|
</ol>
|
||||||
@@ -49,36 +55,50 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop, Vue } from 'vue-property-decorator';
|
import {
|
||||||
|
defineComponent, PropType, computed,
|
||||||
|
inject,
|
||||||
|
} from 'vue';
|
||||||
|
import { useApplicationKey } from '@/presentation/injectionSymbols';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { ApplicationFactory } from '@/application/ApplicationFactory';
|
import TooltipWrapper from '@/presentation/components/Shared/TooltipWrapper.vue';
|
||||||
import Code from './Code.vue';
|
import CodeInstruction from './CodeInstruction.vue';
|
||||||
import { IInstructionListData } from './InstructionListData';
|
import { IInstructionListData } from './InstructionListData';
|
||||||
|
|
||||||
@Component({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
Code,
|
CodeInstruction,
|
||||||
|
TooltipWrapper,
|
||||||
},
|
},
|
||||||
})
|
props: {
|
||||||
export default class InstructionList extends Vue {
|
data: {
|
||||||
public appName = '';
|
type: Object as PropType<IInstructionListData>,
|
||||||
|
required: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { info } = inject(useApplicationKey);
|
||||||
|
|
||||||
public macOsDownloadUrl = '';
|
const appName = computed<string>(() => info.name);
|
||||||
|
|
||||||
public osName = '';
|
const macOsDownloadUrl = computed<string>(
|
||||||
|
() => info.getDownloadUrl(OperatingSystem.macOS),
|
||||||
|
);
|
||||||
|
|
||||||
@Prop() public data: IInstructionListData;
|
const osName = computed<string>(() => {
|
||||||
|
if (!props.data) {
|
||||||
|
throw new Error('missing data');
|
||||||
|
}
|
||||||
|
return renderOsName(props.data.operatingSystem);
|
||||||
|
});
|
||||||
|
|
||||||
public async created() {
|
return {
|
||||||
if (!this.data) {
|
appName,
|
||||||
throw new Error('missing data');
|
macOsDownloadUrl,
|
||||||
}
|
osName,
|
||||||
const app = await ApplicationFactory.Current.getApp();
|
};
|
||||||
this.appName = app.info.name;
|
},
|
||||||
this.macOsDownloadUrl = app.info.getDownloadUrl(OperatingSystem.macOS);
|
});
|
||||||
this.osName = renderOsName(this.data.operatingSystem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOsName(os: OperatingSystem): string {
|
function renderOsName(os: OperatingSystem): string {
|
||||||
switch (os) {
|
switch (os) {
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="container" v-if="hasCode">
|
<div class="container" v-if="hasCode">
|
||||||
<IconButton
|
<IconButton
|
||||||
v-if="this.canRun"
|
v-if="canRun"
|
||||||
text="Run"
|
text="Run"
|
||||||
v-on:click="executeCode"
|
v-on:click="executeCode"
|
||||||
icon-prefix="fas"
|
icon-prefix="fas"
|
||||||
icon-name="play"
|
icon-name="play"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
:text="this.isDesktopVersion ? 'Save' : 'Download'"
|
:text="isDesktopVersion ? 'Save' : 'Download'"
|
||||||
v-on:click="saveCode"
|
v-on:click="saveCode"
|
||||||
icon-prefix="fas"
|
icon-prefix="fas"
|
||||||
:icon-name="this.isDesktopVersion ? 'save' : 'file-download'"
|
:icon-name="isDesktopVersion ? 'save' : 'file-download'"
|
||||||
/>
|
/>
|
||||||
<IconButton
|
<IconButton
|
||||||
text="Copy"
|
text="Copy"
|
||||||
@@ -19,25 +19,26 @@
|
|||||||
icon-prefix="fas"
|
icon-prefix="fas"
|
||||||
icon-name="copy"
|
icon-name="copy"
|
||||||
/>
|
/>
|
||||||
<Dialog v-if="this.hasInstructions" ref="instructionsDialog">
|
<ModalDialog v-if="instructions" v-model="areInstructionsVisible">
|
||||||
<InstructionList :data="this.instructions" />
|
<InstructionList :data="instructions" />
|
||||||
</Dialog>
|
</ModalDialog>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component } from 'vue-property-decorator';
|
import {
|
||||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
defineComponent, ref, computed, inject,
|
||||||
|
} from 'vue';
|
||||||
|
import { useCollectionStateKey, useEnvironmentKey } from '@/presentation/injectionSymbols';
|
||||||
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
|
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
|
||||||
import { Clipboard } from '@/infrastructure/Clipboard';
|
import { Clipboard } from '@/infrastructure/Clipboard';
|
||||||
import Dialog from '@/presentation/components/Shared/Dialog.vue';
|
import ModalDialog from '@/presentation/components/Shared/Modal/ModalDialog.vue';
|
||||||
import { Environment } from '@/application/Environment/Environment';
|
import { Environment } from '@/infrastructure/Environment/Environment';
|
||||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||||
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
|
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';
|
||||||
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
|
||||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||||
import { ICategoryCollection } from '@/domain/ICategoryCollection';
|
|
||||||
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
import { CodeRunner } from '@/infrastructure/CodeRunner';
|
||||||
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
import { IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
|
||||||
import InstructionList from './Instructions/InstructionList.vue';
|
import InstructionList from './Instructions/InstructionList.vue';
|
||||||
@@ -45,79 +46,88 @@ import IconButton from './IconButton.vue';
|
|||||||
import { IInstructionListData } from './Instructions/InstructionListData';
|
import { IInstructionListData } from './Instructions/InstructionListData';
|
||||||
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
|
import { getInstructions, hasInstructions } from './Instructions/InstructionListDataFactory';
|
||||||
|
|
||||||
@Component({
|
export default defineComponent({
|
||||||
components: {
|
components: {
|
||||||
IconButton,
|
IconButton,
|
||||||
InstructionList,
|
InstructionList,
|
||||||
Dialog,
|
ModalDialog,
|
||||||
},
|
},
|
||||||
})
|
setup() {
|
||||||
export default class TheCodeButtons extends StatefulVue {
|
const {
|
||||||
public readonly isDesktopVersion = Environment.CurrentEnvironment.isDesktop;
|
currentState, currentContext, onStateChange, events,
|
||||||
|
} = inject(useCollectionStateKey)();
|
||||||
|
const { isDesktop } = inject(useEnvironmentKey);
|
||||||
|
|
||||||
public canRun = false;
|
const areInstructionsVisible = ref(false);
|
||||||
|
const canRun = computed<boolean>(() => getCanRunState(currentState.value.os, isDesktop));
|
||||||
|
const fileName = computed<string>(() => buildFileName(currentState.value.collection.scripting));
|
||||||
|
const hasCode = ref(false);
|
||||||
|
const instructions = computed<IInstructionListData | undefined>(() => getDownloadInstructions(
|
||||||
|
currentState.value.collection.os,
|
||||||
|
fileName.value,
|
||||||
|
));
|
||||||
|
|
||||||
public hasCode = false;
|
async function copyCode() {
|
||||||
|
const code = await getCurrentCode();
|
||||||
public instructions: IInstructionListData | undefined;
|
Clipboard.copyText(code.current);
|
||||||
|
|
||||||
public hasInstructions = false;
|
|
||||||
|
|
||||||
public fileName = '';
|
|
||||||
|
|
||||||
public async copyCode() {
|
|
||||||
const code = await this.getCurrentCode();
|
|
||||||
Clipboard.copyText(code.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async saveCode() {
|
|
||||||
const context = await this.getCurrentContext();
|
|
||||||
saveCode(this.fileName, context.state);
|
|
||||||
if (this.hasInstructions) {
|
|
||||||
(this.$refs.instructionsDialog as Dialog).show();
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
public async executeCode() {
|
function saveCode() {
|
||||||
const context = await this.getCurrentContext();
|
saveCodeToDisk(fileName.value, currentState.value);
|
||||||
await executeCode(context);
|
areInstructionsVisible.value = true;
|
||||||
}
|
|
||||||
|
|
||||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
|
||||||
this.updateRunState(newState.os);
|
|
||||||
this.updateDownloadState(newState.collection);
|
|
||||||
this.updateCodeState(newState.code);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getCurrentCode(): Promise<IApplicationCode> {
|
|
||||||
const context = await this.getCurrentContext();
|
|
||||||
const { code } = context.state;
|
|
||||||
return code;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateRunState(selectedOs: OperatingSystem) {
|
|
||||||
const isRunningOnSelectedOs = selectedOs === Environment.CurrentEnvironment.os;
|
|
||||||
this.canRun = this.isDesktopVersion && isRunningOnSelectedOs;
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateDownloadState(collection: ICategoryCollection) {
|
|
||||||
this.fileName = buildFileName(collection.scripting);
|
|
||||||
this.hasInstructions = hasInstructions(collection.os);
|
|
||||||
if (this.hasInstructions) {
|
|
||||||
this.instructions = getInstructions(collection.os, this.fileName);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private updateCodeState(code: IApplicationCode) {
|
async function executeCode() {
|
||||||
this.hasCode = code.current && code.current.length > 0;
|
await runCode(currentContext);
|
||||||
this.events.unsubscribeAll();
|
}
|
||||||
this.events.register(code.changed.on((newCode) => {
|
|
||||||
this.hasCode = newCode && newCode.code.length > 0;
|
onStateChange((newState) => {
|
||||||
}));
|
subscribeToCodeChanges(newState.code);
|
||||||
|
}, { immediate: true });
|
||||||
|
|
||||||
|
function subscribeToCodeChanges(code: IApplicationCode) {
|
||||||
|
hasCode.value = code.current && code.current.length > 0;
|
||||||
|
events.unsubscribeAll();
|
||||||
|
events.register(code.changed.on((newCode) => {
|
||||||
|
hasCode.value = newCode && newCode.code.length > 0;
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCurrentCode(): Promise<IApplicationCode> {
|
||||||
|
const { code } = currentContext.state;
|
||||||
|
return code;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
isDesktopVersion: isDesktop,
|
||||||
|
canRun,
|
||||||
|
hasCode,
|
||||||
|
instructions,
|
||||||
|
fileName,
|
||||||
|
areInstructionsVisible,
|
||||||
|
copyCode,
|
||||||
|
saveCode,
|
||||||
|
executeCode,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function getDownloadInstructions(
|
||||||
|
os: OperatingSystem,
|
||||||
|
fileName: string,
|
||||||
|
): IInstructionListData | undefined {
|
||||||
|
if (!hasInstructions(os)) {
|
||||||
|
return undefined;
|
||||||
}
|
}
|
||||||
|
return getInstructions(os, fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveCode(fileName: string, state: IReadOnlyCategoryCollectionState) {
|
function getCanRunState(selectedOs: OperatingSystem, isDesktopVersion: boolean): boolean {
|
||||||
|
const isRunningOnSelectedOs = selectedOs === Environment.CurrentEnvironment.os;
|
||||||
|
return isDesktopVersion && isRunningOnSelectedOs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveCodeToDisk(fileName: string, state: IReadOnlyCategoryCollectionState) {
|
||||||
const content = state.code.current;
|
const content = state.code.current;
|
||||||
const type = getType(state.collection.scripting.language);
|
const type = getType(state.collection.scripting.language);
|
||||||
SaveFileDialog.saveFile(content, fileName, type);
|
SaveFileDialog.saveFile(content, fileName, type);
|
||||||
@@ -141,7 +151,7 @@ function buildFileName(scripting: IScriptingDefinition) {
|
|||||||
return fileName;
|
return fileName;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeCode(context: IReadOnlyApplicationContext) {
|
async function runCode(context: IReadOnlyApplicationContext) {
|
||||||
const runner = new CodeRunner();
|
const runner = new CodeRunner();
|
||||||
await runner.runCode(
|
await runner.runCode(
|
||||||
/* code: */ context.state.code.current,
|
/* code: */ context.state.code.current,
|
||||||
|
|||||||
@@ -1,126 +1,140 @@
|
|||||||
<template>
|
<template>
|
||||||
<Responsive
|
<SizeObserver
|
||||||
v-on:sizeChanged="sizeChanged()"
|
v-on:sizeChanged="sizeChanged()"
|
||||||
v-non-collapsing>
|
v-non-collapsing>
|
||||||
<div
|
<div
|
||||||
:id="editorId"
|
:id="editorId"
|
||||||
class="code-area"
|
class="code-area"
|
||||||
/>
|
/>
|
||||||
</Responsive>
|
</SizeObserver>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { Component, Prop } from 'vue-property-decorator';
|
import {
|
||||||
import { StatefulVue } from '@/presentation/components/Shared/StatefulVue';
|
defineComponent, onUnmounted, onMounted, inject,
|
||||||
|
} from 'vue';
|
||||||
|
import { useCollectionStateKey } from '@/presentation/injectionSymbols';
|
||||||
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
|
import { ICodeChangedEvent } from '@/application/Context/State/Code/Event/ICodeChangedEvent';
|
||||||
import { IScript } from '@/domain/IScript';
|
import { IScript } from '@/domain/IScript';
|
||||||
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
|
||||||
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
import { IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
|
||||||
import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/CodeBuilderFactory';
|
import { CodeBuilderFactory } from '@/application/Context/State/Code/Generation/CodeBuilderFactory';
|
||||||
import Responsive from '@/presentation/components/Shared/Responsive.vue';
|
import SizeObserver from '@/presentation/components/Shared/SizeObserver.vue';
|
||||||
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
|
import { NonCollapsing } from '@/presentation/components/Scripts/View/Cards/NonCollapsingDirective';
|
||||||
import ace from './ace-importer';
|
import ace from './ace-importer';
|
||||||
|
|
||||||
@Component({
|
export default defineComponent({
|
||||||
components: {
|
props: {
|
||||||
Responsive,
|
theme: {
|
||||||
|
type: String,
|
||||||
|
default: undefined,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
directives: { NonCollapsing },
|
components: {
|
||||||
})
|
SizeObserver,
|
||||||
export default class TheCodeArea extends StatefulVue {
|
},
|
||||||
public readonly editorId = 'codeEditor';
|
directives: {
|
||||||
|
NonCollapsing,
|
||||||
|
},
|
||||||
|
setup(props) {
|
||||||
|
const { onStateChange, currentState, events } = inject(useCollectionStateKey)();
|
||||||
|
|
||||||
private editor!: ace.Ace.Editor;
|
const editorId = 'codeEditor';
|
||||||
|
let editor: ace.Ace.Editor | undefined;
|
||||||
|
let currentMarkerId: number | undefined;
|
||||||
|
|
||||||
private currentMarkerId?: number;
|
onUnmounted(() => {
|
||||||
|
destroyEditor();
|
||||||
|
});
|
||||||
|
|
||||||
@Prop() private theme!: string;
|
onMounted(() => { // allow editor HTML to render
|
||||||
|
onStateChange((newState) => {
|
||||||
|
handleNewState(newState);
|
||||||
|
}, { immediate: true });
|
||||||
|
});
|
||||||
|
|
||||||
public destroyed() {
|
function handleNewState(newState: IReadOnlyCategoryCollectionState) {
|
||||||
this.destroyEditor();
|
destroyEditor();
|
||||||
}
|
editor = initializeEditor(
|
||||||
|
props.theme,
|
||||||
public sizeChanged() {
|
editorId,
|
||||||
if (this.editor) {
|
newState.collection.scripting.language,
|
||||||
this.editor.resize();
|
);
|
||||||
|
const appCode = newState.code;
|
||||||
|
const innerCode = appCode.current || getDefaultCode(newState.collection.scripting.language);
|
||||||
|
editor.setValue(innerCode, 1);
|
||||||
|
events.unsubscribeAll();
|
||||||
|
events.register(appCode.changed.on((code) => updateCode(code)));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
protected handleCollectionState(newState: IReadOnlyCategoryCollectionState): void {
|
function updateCode(event: ICodeChangedEvent) {
|
||||||
this.destroyEditor();
|
removeCurrentHighlighting();
|
||||||
this.editor = initializeEditor(
|
if (event.isEmpty()) {
|
||||||
this.theme,
|
const defaultCode = getDefaultCode(currentState.value.collection.scripting.language);
|
||||||
this.editorId,
|
editor.setValue(defaultCode, 1);
|
||||||
newState.collection.scripting.language,
|
return;
|
||||||
);
|
}
|
||||||
const appCode = newState.code;
|
editor.setValue(event.code, 1);
|
||||||
const innerCode = appCode.current || getDefaultCode(newState.collection.scripting.language);
|
if (event.addedScripts?.length > 0) {
|
||||||
this.editor.setValue(innerCode, 1);
|
reactToChanges(event, event.addedScripts);
|
||||||
this.events.unsubscribeAll();
|
} else if (event.changedScripts?.length > 0) {
|
||||||
this.events.register(appCode.changed.on((code) => this.updateCode(code)));
|
reactToChanges(event, event.changedScripts);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateCode(event: ICodeChangedEvent) {
|
|
||||||
this.removeCurrentHighlighting();
|
|
||||||
if (event.isEmpty()) {
|
|
||||||
const context = await this.getCurrentContext();
|
|
||||||
const defaultCode = getDefaultCode(context.state.collection.scripting.language);
|
|
||||||
this.editor.setValue(defaultCode, 1);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
this.editor.setValue(event.code, 1);
|
|
||||||
if (event.addedScripts && event.addedScripts.length) {
|
function sizeChanged() {
|
||||||
this.reactToChanges(event, event.addedScripts);
|
editor?.resize();
|
||||||
} else if (event.changedScripts && event.changedScripts.length) {
|
|
||||||
this.reactToChanges(event, event.changedScripts);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
|
function destroyEditor() {
|
||||||
const positions = scripts
|
editor?.destroy();
|
||||||
.map((script) => event.getScriptPositionInCode(script));
|
editor = undefined;
|
||||||
const start = Math.min(
|
|
||||||
...positions.map((position) => position.startLine),
|
|
||||||
);
|
|
||||||
const end = Math.max(
|
|
||||||
...positions.map((position) => position.endLine),
|
|
||||||
);
|
|
||||||
this.scrollToLine(end + 2);
|
|
||||||
this.highlight(start, end);
|
|
||||||
}
|
|
||||||
|
|
||||||
private highlight(startRow: number, endRow: number) {
|
|
||||||
const AceRange = ace.require('ace/range').Range;
|
|
||||||
this.currentMarkerId = this.editor.session.addMarker(
|
|
||||||
new AceRange(startRow, 0, endRow, 0),
|
|
||||||
'code-area__highlight',
|
|
||||||
'fullLine',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private scrollToLine(row: number) {
|
|
||||||
const column = this.editor.session.getLine(row).length;
|
|
||||||
this.editor.gotoLine(row, column, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
private removeCurrentHighlighting() {
|
|
||||||
if (!this.currentMarkerId) {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
this.editor.session.removeMarker(this.currentMarkerId);
|
|
||||||
this.currentMarkerId = undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private destroyEditor() {
|
function removeCurrentHighlighting() {
|
||||||
if (this.editor) {
|
if (!currentMarkerId) {
|
||||||
this.editor.destroy();
|
return;
|
||||||
this.editor = undefined;
|
}
|
||||||
|
editor.session.removeMarker(currentMarkerId);
|
||||||
|
currentMarkerId = undefined;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
function reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
|
||||||
|
const positions = scripts
|
||||||
|
.map((script) => event.getScriptPositionInCode(script));
|
||||||
|
const start = Math.min(
|
||||||
|
...positions.map((position) => position.startLine),
|
||||||
|
);
|
||||||
|
const end = Math.max(
|
||||||
|
...positions.map((position) => position.endLine),
|
||||||
|
);
|
||||||
|
scrollToLine(end + 2);
|
||||||
|
highlight(start, end);
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlight(startRow: number, endRow: number) {
|
||||||
|
const AceRange = ace.require('ace/range').Range;
|
||||||
|
currentMarkerId = editor.session.addMarker(
|
||||||
|
new AceRange(startRow, 0, endRow, 0),
|
||||||
|
'code-area__highlight',
|
||||||
|
'fullLine',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToLine(row: number) {
|
||||||
|
const column = editor.session.getLine(row).length;
|
||||||
|
editor.gotoLine(row, column, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
editorId,
|
||||||
|
sizeChanged,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function initializeEditor(
|
function initializeEditor(
|
||||||
theme: string,
|
theme: string | undefined,
|
||||||
editorId: string,
|
editorId: string,
|
||||||
language: ScriptingLanguage,
|
language: ScriptingLanguage,
|
||||||
): ace.Ace.Editor {
|
): ace.Ace.Editor {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import ace from 'ace-builds';
|
import ace from 'ace-builds';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
Following is here because `import 'ace-builds/webpack-resolver';` does not work with webpack 5.
|
Following is here because `import 'ace-builds/esm-resolver' imports all unused functionality
|
||||||
Related issue: https://github.com/ajaxorg/ace-builds/issues/211, PR: https://github.com/ajaxorg/ace-builds/pull/221
|
when built with Vite (`npm run build`).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import 'ace-builds/src-noconflict/theme-github';
|
import 'ace-builds/src-noconflict/theme-github';
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user