Compare commits

..

10 Commits

Author SHA1 Message Date
undergroundwires
e9e0001ef8 Improve desktop security by isolating Electron
Enable `contextIsolation` in Electron to securely expose a limited set
of Node.js APIs to the renderer process. It:

1. Isolates renderer and main process contexts. It ensures that the
   powerful main process functions aren't directly accessible from
   renderer process(es), adding a security boundary.
2. Mitigates remote exploitation risks. By isolating contexts, potential
   malicious code injections in the renderer can't directly reach and
   compromise the main process.
3. Reduces attack surface.
4. Protect against prototype pollution: It prevents tampering of
   JavaScript object prototypes in one context from affecting another
   context, improving app reliability and security.

Supporting changes include:

- Extract environment and system operations classes to the infrastructure
  layer. This removes node dependencies from core domain and application
  code.
- Introduce `ISystemOperations` to encapsulate OS interactions. Use it
  from `CodeRunner` to isolate node API usage.
- Add a preloader script to inject validated environment variables into
  renderer context. This keeps Electron integration details
  encapsulated.
- Add new sanity check to fail fast on issues with preloader injected
  variables.
- Improve test coverage of runtime sanity checks and environment
  components. Move validation logic into separate classes for Single
  Responsibility.
- Improve absent value test case generation.
2023-08-25 14:31:30 +02:00
undergroundwires
62f8bfac2f Fix searching/filtering bugs #235
- Fix a bug (introduced in 1b9be8fe) preventing the tree view from being
  visible during a search.
- Fix a minor bug where the scripts view does not render based on the
  initial filter.
- Add Vue component tests for `TheScriptView` to prevent regressions.
- Refactor `isSearching` in `TheScriptView` to simplify its logic.
2023-08-25 00:32:01 +02:00
undergroundwires
75c9b51bf2 Migrate to electron-vite and electron-builder
- Switch from deprecated Vue CLI plugin to `electron-vite` (see
  nklayman/vue-cli-plugin-electron-builder#1982)
- Update main/preload scripts to use `index.cjs` filenames to support
  `"type": "module"`, resolving crash issue (#233). This crash was
  related to Electron not supporting ESM (see electron/asar#249,
  electron/electron#21457).
- This commit completes migration to Vite from Vue CLI (#230).

Structure changes:

- Introduce separate folders for Electron's main and preload processes.
- Move TypeHelpers to `src/` to mark tit as accessible by the rest of
  the code.

Config changes:

- Make `vite.config.ts` reusable by Electron configuration.
- On electron-builder, use `--publish` flag instead of `-p` for clarity.

Tests:

- Add log for preload script loading verification.
- Implement runtime environment sanity checks.
- Enhance logging in `check-desktop-runtime-errors`.
2023-08-24 20:01:53 +02:00
undergroundwires
ec98d8417f Migrate Cypress (E2E) tests to Vite and TypeScript
This commit progresses the migration from Vue CLI to Vite (#230).

TypeScript migration:

- Convert JavaScript Cypress tests and configurations to TypeScript.
- Introduce `tsconfig.json` for Cypress, following official
  recommendation.

Test execution:

- Use Cypress CLI to run the tests.
- Rename Cypress commands to reflect official naming conventions.
- Start Vue server prior to Cypress execution, using
  `start-server-and-test` package based on official documentation.
- Remove dependency on Vue CLI plugin ((`@vue/cli-plugin-e2e-cypress`).

Configuration standardization (based on Cypress docs):

- Delete unused `plugins/` directory.
- Move test (spec) files to to the root directory.
- Add official ESLint plugin (`eslint-plugin-cypress`).

Changes for importing `vite.config.ts` into `cypress.config.ts`:

- Add TypeScript import assertations to files importing JSON files.
- Use ESM friendly way instead of `__dirname` to solve `ReferenceError:
  __dirname is not defined in ES module scrope`.

Other changes:

- Simplify comments in placeholder files.
- Create Cypress specific `.gitignore` for enhanced maintainability,
  clarity and scalability.
- Remove redundant `vue.config.cjs`.
2023-08-24 13:45:34 +02:00
undergroundwires
736590558b Migrate web builds from Vue CLI to Vite
This commit changes the web application's build, transpilation and
minification process from Vue CLI to Vite. This shift paves the way for
a full migration to Vite as the primary build tool (#230).

Configuration changes:

- `.vscode/extensions.json`: Update recommended plugins, replacing
  unmaintained ones with official recommendations.
- Legacy browser support:
  - Use `@vitejs/plugin-legacy` to transpile for older browsers.
  - Remove `core-js` dependency and `babel.config.cjs` configuration as
    they're now handled by the legacy plugin.
  - Delete `@babel/preset-typescript` and `@babel/preset-typescript`
    dependencies as legacy plugin handles babel dependencies by default.
  - Add `terser` dependency that's used by the legacy plugin for
    minification, as per Vite's official documentation.
- `tsconfig.json`:
  - Remove obsolete `webpack-env` types.
  - Add `"resolveJsonModule": true` to be able to read JSON files in
    right way.
  - Use correct casing as configuration values.
  - Simplify `lib` to align with Vite and Vue starter configuration.
  - Add `"skipLibCheck": true` as `npm run build` now runs `tsc` which
    fails on inconsistent typings inside `node_modules` due to npm's
    weak dependency resoultion.
- PostCSS:
  - Add `autoprefixer` as dependency, no longer installed by Vue CLI.
  - Epxlicitly added `postcss` as dependency to anticipate potential
    peer dependency changes.
- Remove related `@vue/cli` dependencies.
- Remove `sass-loader` as Vite has native CSS preprocessing support.
- Run integration tests with `jsdom` environment so `window` object can
  be used.

Client-side changes:

- Abstract build tool specific environment variable population.
  Environment variables were previously populated by Vue CLI and now by
  Vite but not having an abstraction caused issues. This abstraction
  solves build errors and allows easier future migrations and testing.
- Change Vue CLI-specific `~@` aliases to `@` to be able to compile with
  Vite.
- Update types in LiquorTree to satisfy `tsc`.
- Remove Vue CLI-specific workaround from `src/presentation/main.ts`.

Restructuring:

- Move `public/` to `presentation/` to align with the layered structure,
  which was not possible with Vue CLI.
- Move `index.html` to web root instead of having it inside `public/` to
  align with official recommended structure.
- Move logic shared by both integration and unit tests to
  `tests/shared`.
- Move logo creation script to `scripts/` and its npm command to include
  `build` to align with rest of the structure.
2023-08-23 23:12:56 +02:00
undergroundwires
6e40edd3f8 Remove Vue ESLint plugin for Vite compatibility
The Vue ESLint plugin is not compatible with Vite and isn't provided in
Vite's default template. By removing it, the codebase progresses toward
the migration to Vue 3.0 and Vite (#230).

Changes:

- Directly execute `eslint` in the `npm run lint:eslint` command.
- Fix previously undetected linting issues that weren't covered by Vue
  CLI's default configuration.
- Updated various configuration files, reflecting the removal and lint
  fixes.
- Remove unused `eslint-plugin-import` dependency that is already
  imported by `@vue/eslint-config-airbnb-with-typescript`.

In `.eslintrc.cjs`:

- Add `es2022` as environment in to simplify setting parser options and
  align with Vite starter configuration.
- Remove useless tests override.
- Move tests override in root `.eslintrc.cjs` to `tests/` for clarity,
  better organization, scalability and separation of concerns.
2023-08-23 09:47:44 +02:00
undergroundwires
5f11c8d98f Migrate unit/integration tests to Vitest with Vite
As part of transition to Vue 3.0 and Vite (#230), this commit
facilitates the shift towards building rest of the application using
Vite. By doing so, it eliminates reliance on outdated Electron building
system that offered limited control, blocking desktop builds (#233).

Changes include:

- Introduce Vite with Vue 2.0 plugin for test execution.
- Remove `mocha`, `chai` and other related dependencies.
- Adjust test to Vitest syntax.
- Revise and update `tests.md` to document the changes.
- Add `@modyfi/vite-plugin-yaml` plugin to be able to use yaml file
  depended logic on test files, replacing previous webpack behavior.
- Fix failing tests that are revealed by Vitest due to unhandled errors
  and lack of assertments.
- Remove the test that depends on Vue CLI populating `process.env`.
- Use `jsdom` for unit test environment, adding it to dependency to
  `package.json` as project now depends on it and it was not specified
  even though `package-lock.json` included it.
2023-08-22 14:02:35 +02:00
SNOWZ
08737698c2 win: fix automatic updates revert #234
Close registry key string
2023-08-21 12:15:08 +02:00
undergroundwires
04b3133500 Add automated checks for desktop app runtime #233
- Add automation script for building, packaging, installing, executing
  and verifying Electron distrubtions across macOS, Ubuntu and Windows.
- Add GitHub workflow to run the script to test distributions using the
  script.
- Update README with new workflow status badge.
- Add application initialization log to desktop applications to be able
  to test against crashes before application initialization.
2023-08-21 01:35:19 +02:00
undergroundwires-bot
0d15992d56 ⬆️ bump everywhere to 0.12.1 2023-08-18 15:53:16 +00:00
286 changed files with 12720 additions and 19633 deletions

View File

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

View File

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

View File

@@ -9,7 +9,13 @@ jobs:
strategy:
matrix:
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
runs-on: ${{ matrix.os }}-latest
steps:
@@ -26,12 +32,15 @@ jobs:
name: Build
run: npm run build -- --mode ${{ matrix.mode }}
# A new job is used due to environments/modes different from Vue CLI, https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1626
build-desktop:
strategy:
matrix:
os: [ macos, ubuntu, windows ]
mode: [ 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
runs-on: ${{ matrix.os }}-latest
steps:
@@ -45,14 +54,11 @@ jobs:
name: Install dependencies
run: npm ci
-
name: Install cross-env
# Used to set NODE_ENV due to https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1626
run: npm install --global cross-env
name: Prebuild
run: npm run electron:prebuild -- --mode ${{ matrix.mode }}
-
name: Build
run: |-
cross-env-shell NODE_ENV=${{ matrix.mode }}
npm run electron:build -- --publish never --mode ${{ matrix.mode }}
run: npm run electron:build -- --publish never
create-icons:
strategy:
@@ -72,4 +78,4 @@ jobs:
run: npm ci
-
name: Create icons
run: npm run create-icons
run: npm run icons:build

View 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

View File

@@ -13,20 +13,29 @@ jobs:
fail-fast: false # So publish runs for other OSes if one fails
runs-on: ${{ matrix.os }}-latest
steps:
- uses: actions/checkout@v2
-
uses: actions/checkout@v2
with:
ref: master # otherwise it defaults to the version tag missing bump commit
fetch-depth: 0 # fetch all history
- name: Checkout to bump commit
-
name: Checkout to bump commit
run: git checkout "$(git rev-list "${{ github.event.release.tag_name }}"..master | tail -1)"
- name: Setup node
-
name: Setup node
uses: ./.github/actions/setup-node
- name: Install dependencies
-
name: Install dependencies
run: npm ci
- name: Run unit tests
-
name: Run unit tests
run: npm run test:unit
- name: 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:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
EP_GH_IGNORE_TIME: true # Otherwise publishing fails if GitHub release is more than 2 hours old https://github.com/electron-userland/electron-builder/issues/2074

View File

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

3
.gitignore vendored
View File

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

View File

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

View File

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

View File

@@ -70,6 +70,12 @@
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.desktop-runtime-errors.yaml" target="_blank" rel="noopener noreferrer">
<img
alt="Status of runtime error checks for the desktop application"
src="https://github.com/undergroundwires/privacy.sexy/workflows/checks.desktop-runtime-errors/badge.svg"
/>
</a>
<!-- Release -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml" target="_blank" rel="noopener noreferrer">

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ Application layer enables [data-driven programming](https://en.wikipedia.org/wik
Application layer parses the application data to compile the domain object [`Application.ts`](./../src/domain/Application.ts).
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).

View File

@@ -15,11 +15,23 @@ Application is
Application uses highly decoupled models & services in different DDD layers:
- presentation layer (see [presentation.md](./presentation.md)),
- application layer (see [application.md](./application.md)),
- and domain layer.
**Application layer** (see [application.md](./application.md)):
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.
![DDD + vue.js](./../img/architecture/app-ddd.png)

View File

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

View File

@@ -10,24 +10,25 @@ The presentation layer uses an event-driven architecture for bidirectional react
## Structure
- [`/src/` **`presentation/`**](./../src/presentation/): Contains all presentation related code including Vue and Electron configurations
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue global objects including components and plugins.
- [**`components/`**](./../src/presentation/components/): Contains all Vue components and their helper classes.
- [**`Shared/`**](./../src/presentation/components/Shared): Contains Vue components and component helpers that other components share.
- [**`hooks`**](../src/presentation/components/Shared/Hooks): Shared hooks for state access
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets that webpack will process.
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles used throughout different components.
- [**`components/`**](./../src/presentation/assets/styles/components): Contains reusable styles coupled to a Vue/HTML component.
- [**`vendors-extensions/`**](./../src/presentation/assets/styles/third-party-extensions): Contains styles that override third-party components used.
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Primary Sass file, passes along all other styles, should be the single file used from other components.
- [**`main.ts`**](./../src/presentation/main.ts): Application entry point that mounts and starts Vue application.
- [**`electron/`**](./../src/presentation/electron/): Electron configuration for the desktop application.
- [**`main.ts`**](./../src/presentation/main.ts): Main process of Electron, started as first thing when app starts.
- [**`/public/`**](./../public/): Contains static assets that are directly copied and do not go through webpack.
- [**`/vue.config.cjs`**](./../vue.config.cjs): Global Vue CLI configurations loaded by `@vue/cli-service`.
- [**`/postcss.config.cjs`**](./../postcss.config.cjs): PostCSS configurations used by Vue CLI internally.
- [**`/babel.config.cjs`**](./../babel.config.cjs): Babel configurations for polyfills used by `@vue/cli-plugin-babel`.
- [`/src/` **`presentation/`**](./../src/presentation/): Contains Vue and Electron code.
- [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue components and plugins.
- [**`components/`**](./../src/presentation/components/): Contains Vue components and helpers.
- [**`Shared/`**](./../src/presentation/components/Shared): Contains shared Vue components and helpers.
- [**`hooks`**](../src/presentation/components/Shared/Hooks): Hooks used by components through [dependency injection](#dependency-injections).
- [**`/public/`**](../src/presentation/public/): Contains static assets.
- [**`assets/`**](./../src/presentation/assets/styles/): Contains assets processed by Vite.
- [**`fonts/`**](./../src/presentation/assets/fonts/): Contains fonts.
- [**`styles/`**](./../src/presentation/assets/styles/): Contains shared styles.
- [**`components/`**](./../src/presentation/assets/styles/components): Contains styles for Vue components.
- [**`vendors-extensions/`**](./../src/presentation/assets/styles/third-party-extensions): Contains styles for third-party components.
- [**`main.scss`**](./../src/presentation/assets/styles/main.scss): Main Sass file, imported by other components as single entrypoint.
- [**`main.ts`**](./../src/presentation/main.ts): Starts Vue app.
- [**`electron/`**](./../src/presentation/electron/): Contains Electron code.
- [`/main/` **`index.ts`**](./../src/presentation/main.ts): Main entry for Electron, managing application windows and lifecycle events.
- [`/preload/` **`index.ts`**](./../src/presentation/main.ts): Script executed before the renderer, securing Node.js features for renderer use.
- [**`/vite.config.ts`**](./../vite.config.ts): Contains Vite configurations for building web application.
- [**`/electron.vite.config.ts`**](./../electron.vite.config.ts): Contains Vite configurations for building desktop applications.
- [**`/postcss.config.cjs`**](./../postcss.config.cjs): Contains PostCSS configurations for Vite.
## Visual design best-practices
@@ -86,6 +87,10 @@ 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
- Use lowercase for variables/functions/mixins, e.g.:

View File

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

31
electron-builder.yml Normal file
View 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
View 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);
}

View File

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

25629
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View 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).

View 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;
}

View 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);
}

View 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;
}
}

View 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);
});
}

View File

@@ -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,
};
}

View 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);
});
}

View File

@@ -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');
}

View File

@@ -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];
}

View 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);
}

View 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');

View File

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

View 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();
}
}
}

View 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;
}

View 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,
},
};

View 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;
}
}

View File

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

View 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');
}

View 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}`);
}
}

View File

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

15
src/TypeHelpers.ts Normal file
View 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];

View File

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

View File

@@ -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;
}

View File

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

View File

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

View File

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

View File

@@ -4908,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 "AUOptions" /t "REG_DWORD" /d "3" /f
reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v "ScheduledInstallDay" /f 2>nul
reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v "ScheduledInstallTime /f 2>nul
reg delete "HKLM\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU" /v "ScheduledInstallTime" /f 2>nul
-
function: DisableService
parameters:

View File

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

View File

@@ -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;

View 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;
}

View File

@@ -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>;
}

View File

@@ -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),
},
};
}

View 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 { }
}

View 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);
}

View File

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

View File

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

View 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;

View 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;
}

View File

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

View 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;
}

View 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;
}
}

View 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;

View 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
}

View 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}`;
}
}
}

View File

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

View File

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

View 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')}`;
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

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

View File

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

View File

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

View File

@@ -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/";

View File

@@ -5,7 +5,7 @@ import {
useCollectionStateKey, useApplicationKey, useEnvironmentKey,
} from '@/presentation/injectionSymbols';
import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { Environment } from '@/application/Environment/Environment';
import { Environment } from '@/infrastructure/Environment/Environment';
export function provideDependencies(context: IApplicationContext) {
registerSingleton(useApplicationKey, useApplication(context.app));

View File

@@ -18,6 +18,7 @@ import TheCodeButtons from '@/presentation/components/Code/CodeButtons/TheCodeBu
import TheScriptArea from '@/presentation/components/Scripts/TheScriptArea.vue';
import TheSearchBar from '@/presentation/components/TheSearchBar.vue';
import { buildContext } from '@/application/Context/ApplicationContextFactory';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { provideDependencies } from '../bootstrapping/DependencyProvider';
const singletonAppContext = await buildContext();
@@ -32,6 +33,10 @@ export default defineComponent({
},
setup() {
provideDependencies(singletonAppContext); // In Vue 3.0 we can move it to main.ts
validateRuntimeSanity({
validateMetadata: true,
validateEnvironment: true,
});
},
});
</script>

View File

@@ -33,7 +33,7 @@ import { useCollectionStateKey, useEnvironmentKey } from '@/presentation/injecti
import { SaveFileDialog, FileType } from '@/infrastructure/SaveFileDialog';
import { Clipboard } from '@/infrastructure/Clipboard';
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 { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IApplicationCode } from '@/application/Context/State/Code/IApplicationCode';

View File

@@ -1,8 +1,8 @@
import ace from 'ace-builds';
/*
Following is here because `import 'ace-builds/webpack-resolver';` does not work with webpack 5.
Related issue: https://github.com/ajaxorg/ace-builds/issues/211, PR: https://github.com/ajaxorg/ace-builds/pull/221
Following is here because `import 'ace-builds/esm-resolver' imports all unused functionality
when built with Vite (`npm run build`).
*/
import 'ace-builds/src-noconflict/theme-github';

View File

@@ -69,6 +69,8 @@ declare module 'liquor-tree' {
matcher(query: string, node: ILiquorTreeExistingNode): boolean;
}
const LiquorTree: PluginObject<Vue>;
interface LiquorTreeVueComponent extends PluginObject<Vue> {
install(Vue: VueConstructor<Vue>, options?: unknown);
}
export default LiquorTree;
}

View File

@@ -76,7 +76,7 @@ $text-size: 0.75em; // Lower looks bad on Firefox
Use mask element instead of content/background-image etc.
This way we can apply current font color to it to match the theme
*/
mask: url(~@/presentation/assets/icons/external-link.svg) no-repeat 50% 50%;
mask: url(@/presentation/assets/icons/external-link.svg) no-repeat 50% 50%;
mask-size: cover;
content: '';

View File

@@ -8,9 +8,11 @@
@node:unchecked="nodeSelected($event)"
ref="liquorTree"
>
<span class="tree-text" slot-scope="{ node }">
<NodeContent :data="convertExistingToNode(node)" />
</span>
<template v-slot:default="{ node }">
<span class="tree-text">
<NodeContent :data="convertExistingToNode(node)" />
</span>
</template>
</LiquorTree>
</span>
<span v-else>Nooo 😢</span>
@@ -192,4 +194,3 @@ async function tryUntilDefined<T>(
return value;
}
</script>
./Node/INodeContent

View File

@@ -1,19 +1,24 @@
<template>
<div class="scripts">
<div v-if="!isSearching">
<CardList v-if="currentView === ViewType.Cards" />
<div class="tree" v-else-if="currentView === ViewType.Tree">
<ScriptsTree />
</div>
<template v-if="currentView === ViewType.Cards">
<CardList />
</template>
<template v-else-if="currentView === ViewType.Tree">
<div class="tree">
<ScriptsTree />
</div>
</template>
</div>
<div v-else> <!-- Searching -->
<div class="search">
<div class="search__query">
<div>Searching for "{{ trimmedSearchQuery }}"</div>
<div class="search__query__close-button">
<font-awesome-icon
:icon="['fas', 'times']"
v-on:click="clearSearchQuery()" />
<div
class="search__query__close-button"
v-on:click="clearSearchQuery()"
>
<font-awesome-icon :icon="['fas', 'times']" />
</div>
</div>
<div v-if="!searchHasMatches" class="search-no-matches">
@@ -41,6 +46,7 @@ import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/Scri
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
export default defineComponent({
components: {
@@ -58,8 +64,8 @@ export default defineComponent({
const { info } = inject(useApplicationKey);
const repositoryUrl = computed<string>(() => info.repositoryWebUrl);
const searchQuery = ref<string>();
const isSearching = ref(false);
const searchQuery = ref<string | undefined>();
const isSearching = computed(() => Boolean(searchQuery.value));
const searchHasMatches = ref(false);
const trimmedSearchQuery = computed(() => {
const query = searchQuery.value;
@@ -72,8 +78,9 @@ export default defineComponent({
onStateChange((newState) => {
events.unsubscribeAll();
updateFromInitialFilter(newState.filter.currentFilter);
subscribeToFilterChanges(newState.filter);
});
}, { immediate: true });
function clearSearchQuery() {
modifyCurrentState((state) => {
@@ -82,17 +89,21 @@ export default defineComponent({
});
}
function updateFromInitialFilter(filter?: IFilterResult) {
searchQuery.value = filter?.query;
searchHasMatches.value = filter?.hasAnyMatches();
}
function subscribeToFilterChanges(filter: IReadOnlyUserFilter) {
events.register(
filter.filterChanged.on((event) => {
event.visit({
onApply: (newFilter) => {
searchQuery.value = newFilter.query;
isSearching.value = true;
searchHasMatches.value = newFilter.hasAnyMatches();
},
onClear: () => {
isSearching.value = false;
searchQuery.value = undefined;
},
});
}),

View File

@@ -2,6 +2,7 @@ import { ref, computed, readonly } from 'vue';
import { IApplicationContext, IReadOnlyApplicationContext } from '@/application/Context/IApplicationContext';
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { EventSubscriptionCollection } from '@/infrastructure/Events/EventSubscriptionCollection';
import { IEventSubscriptionCollection } from '@/infrastructure/Events/IEventSubscriptionCollection';
export function useCollectionState(context: IApplicationContext) {
if (!context) {
@@ -18,13 +19,6 @@ export function useCollectionState(context: IApplicationContext) {
}),
);
type NewStateEventHandler = (
newState: IReadOnlyCategoryCollectionState,
oldState: IReadOnlyCategoryCollectionState | undefined,
) => void;
interface IStateCallbackSettings {
readonly immediate: boolean;
}
const defaultSettings: IStateCallbackSettings = {
immediate: false,
};
@@ -49,9 +43,6 @@ export function useCollectionState(context: IApplicationContext) {
}
}
type StateModifier = (
state: ICategoryCollectionState,
) => void;
function modifyCurrentState(mutator: StateModifier) {
if (!mutator) {
throw new Error('missing state mutator');
@@ -59,9 +50,6 @@ export function useCollectionState(context: IApplicationContext) {
mutator(context.state);
}
type ContextModifier = (
state: IApplicationContext,
) => void;
function modifyCurrentContext(mutator: ContextModifier) {
if (!mutator) {
throw new Error('missing context mutator');
@@ -75,6 +63,23 @@ export function useCollectionState(context: IApplicationContext) {
onStateChange,
currentContext: context as IReadOnlyApplicationContext,
currentState: readonly(computed<IReadOnlyCategoryCollectionState>(() => currentState.value)),
events,
events: events as IEventSubscriptionCollection,
};
}
export type NewStateEventHandler = (
newState: IReadOnlyCategoryCollectionState,
oldState: IReadOnlyCategoryCollectionState | undefined,
) => void;
export interface IStateCallbackSettings {
readonly immediate: boolean;
}
export type StateModifier = (
state: ICategoryCollectionState,
) => void;
export type ContextModifier = (
state: IApplicationContext,
) => void;

View File

@@ -1,4 +1,4 @@
import { IEnvironment } from '@/application/Environment/IEnvironment';
import { IEnvironment } from '@/infrastructure/Environment/IEnvironment';
export function useEnvironment(environment: IEnvironment) {
if (!environment) {

View File

@@ -0,0 +1,16 @@
/**
* Abstraction for electron-vite specific logic and other Electron CLI helpers/wrappers.
* Allows for agnostic application design and centralizes adjustments when switching wrappers.
*/
/// <reference types="electron-vite/node" />
import { join } from 'path';
import appIcon from '@/presentation/public/icon.png?asset';
export const APP_ICON_PATH = appIcon;
export const RENDERER_URL = process.env.ELECTRON_RENDERER_URL;
export const RENDERER_HTML_PATH = join('file://', __dirname, '../renderer/index.html');
export const PRELOADER_SCRIPT_PATH = join(__dirname, '../preload/index.cjs');

View File

@@ -6,6 +6,7 @@ import log from 'electron-log';
import fetch from 'cross-fetch';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ViteAppMetadata } from '@/infrastructure/Metadata/Vite/ViteAppMetadata';
import { Version } from '@/domain/Version';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { UpdateProgressBar } from './UpdateProgressBar';
@@ -28,7 +29,7 @@ export async function handleManualUpdate(info: UpdateInfo) {
}
function getTargetProject(targetVersion: string) {
const existingProject = parseProjectInformation(process.env);
const existingProject = parseProjectInformation(new ViteAppMetadata());
const targetProject = new ProjectInformation(
existingProject.name,
new Version(targetVersion),

View File

@@ -1,21 +1,17 @@
// This is main process of Electron, started as first thing when app starts.
// This script is running through entire life of the application.
// It doesn't have any windows which you can see on screen, opens the main window from here.
// Initializes Electron's main process, always runs in the background, and manages the main window.
import path from 'path';
import {
app, protocol, BrowserWindow, shell, screen,
} from 'electron';
import { createProtocol } from 'vue-cli-plugin-electron-builder/lib';
import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer';
import log from 'electron-log';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { setupAutoUpdater } from './Update/Updater';
import {
APP_ICON_PATH, PRELOADER_SCRIPT_PATH, RENDERER_HTML_PATH, RENDERER_URL,
} from './ElectronConfig';
const isDevelopment = process.env.NODE_ENV !== 'production';
// Path of static assets, magic variable populated by electron
// eslint-disable-next-line @typescript-eslint/naming-convention, no-underscore-dangle
declare const __static: string; // https://github.com/electron-userland/electron-webpack/issues/172
const isDevelopment = !app.isPackaged;
// Keep a global reference of the window object, if you don't, the window will
// be closed automatically when the JavaScript object is garbage collected.
@@ -26,10 +22,15 @@ protocol.registerSchemesAsPrivileged([
{ scheme: 'app', privileges: { secure: true, standard: true } },
]);
log.transports.file.level = 'silly';
if (!process.env.IS_TEST) {
Object.assign(console, log.functions); // override console.log, console.warn etc.
}
setupLogger();
validateRuntimeSanity({
// Metadata is used by manual updates.
validateMetadata: true,
// Environment is populated by the preload script and is in the renderer's context;
// it's not directly accessible from the main process.
validateEnvironment: false,
});
function createWindow() {
// Create the browser window.
@@ -38,14 +39,11 @@ function createWindow() {
width: size.width,
height: size.height,
webPreferences: {
contextIsolation: false, // To reach node https://github.com/nklayman/vue-cli-plugin-electron-builder/issues/1285
// Use pluginOptions.nodeIntegration, leave this alone
// See https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/security.html#node-integration
nodeIntegration: (process.env
.ELECTRON_NODE_INTEGRATION as unknown) as boolean,
nodeIntegration: true, // disabling does not work with electron-vite, https://electron-vite.org/guide/dev.html#nodeintegration
contextIsolation: true,
preload: PRELOADER_SCRIPT_PATH,
},
// https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/recipes.html#set-tray-icon
icon: path.join(__static, 'icon.png'),
icon: APP_ICON_PATH,
});
win.setMenuBarVisibility(false);
@@ -86,17 +84,12 @@ app.on('activate', () => {
}
});
// This method will be called when Electron has finished
// initialization and is ready to create browser windows.
// Some APIs can only be used after this event occurs.
app.on('ready', async () => {
if (isDevelopment && !process.env.IS_TEST) {
// Install Vue Devtools
if (isDevelopment) {
try {
await installExtension(VUEJS_DEVTOOLS);
} catch (e) {
// eslint-disable-next-line no-console
console.error('Vue Devtools failed to install:', e.toString());
log.error('Vue Devtools failed to install:', e.toString());
}
}
createWindow();
@@ -118,19 +111,19 @@ if (isDevelopment) {
}
function loadApplication(window: BrowserWindow) {
if (process.env.WEBPACK_DEV_SERVER_URL) {
// Load the url of the dev server if in development mode
loadUrlWithNodeWorkaround(win, process.env.WEBPACK_DEV_SERVER_URL as string);
if (!process.env.IS_TEST) {
window.webContents.openDevTools();
}
if (RENDERER_URL) { // Populated in a dev server during development
loadUrlWithNodeWorkaround(win, RENDERER_URL);
} else {
loadUrlWithNodeWorkaround(win, RENDERER_HTML_PATH);
}
if (isDevelopment) {
window.webContents.openDevTools();
} else {
createProtocol('app');
// Load the index.html when not in development
loadUrlWithNodeWorkaround(win, 'app://./index.html');
const updater = setupAutoUpdater();
updater.checkForUpdates();
}
// Do not remove [WINDOW_INIT]; it's a marker used in tests.
log.info('[WINDOW_INIT] Main window initialized and content loading.');
}
function configureExternalsUrlsOpenBrowser(window: BrowserWindow) {
@@ -155,3 +148,10 @@ function getWindowSize(idealWidth: number, idealHeight: number) {
height = Math.min(height, idealHeight);
return { width, height };
}
function setupLogger(): void {
log.transports.file.level = 'silly';
if (!isDevelopment) {
Object.assign(console, log.functions); // override console.log, console.warn etc.
}
}

View File

@@ -0,0 +1,14 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
export function convertPlatformToOs(platform: NodeJS.Platform): OperatingSystem | undefined {
switch (platform) {
case 'darwin':
return OperatingSystem.macOS;
case 'win32':
return OperatingSystem.Windows;
case 'linux':
return OperatingSystem.Linux;
default:
return undefined;
}
}

View File

@@ -0,0 +1,14 @@
import { createNodeSystemOperations } from '@/infrastructure/Environment/SystemOperations/NodeSystemOperations';
import { WindowVariables } from '@/infrastructure/Environment/WindowVariables';
import { convertPlatformToOs } from './NodeOsMapper';
export function provideWindowVariables(
createSystem = createNodeSystemOperations,
convertToOs = convertPlatformToOs,
): WindowVariables {
return {
system: createSystem(),
isDesktop: true,
os: convertToOs(process.platform),
};
}

View File

@@ -0,0 +1,23 @@
// This file is used to securely expose Electron APIs to the application.
import { contextBridge } from 'electron';
import log from 'electron-log';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { provideWindowVariables } from './WindowVariablesProvider';
validateRuntimeSanity({
// Validate metadata as a preventive measure for fail-fast,
// even if it's not currently used in the preload script.
validateMetadata: true,
// The preload script cannot access variables on the window object.
validateEnvironment: false,
});
const windowVariables = provideWindowVariables();
Object.entries(windowVariables).forEach(([key, value]) => {
contextBridge.exposeInMainWorld(key, value);
});
// Do not remove [PRELOAD_INIT]; it's a marker used in tests.
log.info('[PRELOAD_INIT] Preload script successfully initialized and executed.');

View File

@@ -0,0 +1,38 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Privacy is sexy 🍑🍆 - Enforce privacy & security on Windows, macOS and Linux</title>
<meta name="robots" content="index,follow" />
<meta name="description"
content="Web tool to generate scripts for enforcing privacy & security best-practices such as stopping data collection of Windows and different softwares on it." />
<link rel="icon" href="/favicon.ico">
</head>
<body>
<noscript>
<style>
#javascriptDisabled {
background: #eceef1;
margin: 5rem auto;
max-width: 800px;
font-size: 7px;
padding: 3rem;
border: 1px solid#333a45;
font-size: 1.5rem;
line-height: 150%;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', 'Consolas', 'source-code-pro', monospace;
}
</style>
<div id="javascriptDisabled">
<h1>Problem loading page</h1>
<p>The page does not work without JavaScript enabled. Please enable it to use privacy.sexy. There's no shady stuff
as 100% of the website is open source.</p>
</div>
</noscript>
<div id="app"></div>
<script type="module" src="/main.ts"></script>
</body>
</html>

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