Compare commits

...

85 Commits

Author SHA1 Message Date
undergroundwires
cfe5704328 Add more macOS scripts
TODO: https://github.com/usnistgov/macos_security/tree/main/rules
2022-10-12 17:08:53 +02:00
undergroundwires
d16846fa3c Add donation information 2022-03-03 00:27:48 +01:00
undergroundwires
112e79a64c Fix Microsoft Defender alert for uninstaller #114
Microsoft Defender considers the uninstaller virus. It's a
false-psoitive caused by `electron-builder` used to build NSIS package.

This commit solves the issue by explicitly adding `electron-builder` as
dependency. This way, `vue-cli-plugin-electron-builder` always resolves
to the desired version. Now the version used for `electron-builder` is
more controlled. New uninstaller generated by latest specified patch
does not trigger an alert, which solves the false-positive issue.

See also:
  - electron-userland/electron-builder#4793,
  - electron-userland/electron-builder#4878.
2022-02-27 14:55:11 +01:00
undergroundwires
eeb1d5b0c4 Refactor to use version object #59
Enable writing safer version aware logic.
2022-02-26 17:15:30 +01:00
undergroundwires
d6bc33ec86 Fix Windows 11 being detected as Windows 10
The logic was expecting major version in Windows 11 to be 11. However,
in Windows 11, major version is not changed and it is till 10. This
commit corrects logic to check build number that's guaranteed to be
higher in Windows 11.
2022-02-23 06:52:46 +01:00
undergroundwires
956052c8ff Fix error when reverting Windows Defender setting 2022-02-21 17:20:34 +01:00
undergroundwires
3785e410db Document WpnService breaking on Windows 10 #110 2022-02-20 15:44:08 +01:00
undergroundwires
481a02afd5 Refactor to remove hardcoding of aliases
Unify definition of  aliases in single place.

Make TypeScript configuration file (`tsconfig.json`) source of truth
regarding aliases.

Both webpack (through `vue.config.js`) and ESLint (through
`.eslintrc.js`) now reads the alias configuration from `tsconfig.json`.
2022-02-02 19:28:12 +01:00
undergroundwires
5bbbb9cecc Refactor to remove code coupling with Webpack
Remove using Webpack import syntax such as: `js-yaml-loader!@/..`. It's
a non-standard syntax that couples the code to Webpack.

Configure instead by specifying Webpack loader in Vue configuration
file.

Enable related ESLint rules.

Remove unused dependency `raw-loader` and refactor
`NoUnintendedInlining` test to load files using file system (dropping
webpack dependency).

Refactor to use `import type` for type imports to show the indent
clearly and satisfy failing ESLint rules.
2022-01-31 17:22:34 +01:00
undergroundwires
db47440d47 Improve existing documentation
Improve documentation for different markdown documentation files. Goal
is to simplify and make the language and content more clear.
2022-01-30 13:15:34 +01:00
undergroundwires
1bcc6c8b2b Improve documentation for architecture
- Simplify `README.md` by creating and moving some documentation to
`architecture.md`.
- Add more documentation for state handling between layers.
- Improve some documentation to use clearer language.
2022-01-29 15:47:44 +01:00
undergroundwires
3c3ec80525 Improve documentation for developing
Move existing documentation to `docs/development.md` to have simpler
`README.md` but more dedicated and extensive documentation for
development.

Improve existing documentation for different commands for the project.

Document VSCode recommendations in `extensions.json` file and add
exception in `.gitignore` to be able to add it to repository.
2022-01-26 08:14:25 +01:00
undergroundwires
803ef2bb3e Move stubs from ./stubs to ./shared/Stubs
Gathers all shared test code in single place.
2022-01-25 08:37:03 +01:00
undergroundwires
43ce834750 Fix Windows DoSvc not being disabled #115
- Disable DoSvc using registry to support newer Windows versions.
- Add more documentation for DoSvc.
2022-01-22 23:57:57 +01:00
undergroundwires
44d79e2c9a Add more and unify tests for absent object cases
- Unify test data for nonexistence of an object/string and collection.
- Introduce more test through adding missing test data to existing tests.
- Improve logic for checking absence of values to match tests.
- Add missing tests for absent value validation.
- Update documentation to include shared test functionality.
2022-01-21 22:34:11 +01:00
undergroundwires
0e52a99efa Transpile dependencies for wider browser support
This commit ensures that all dependencies in `node_modules` will be
transpiled by Babel.

Dependencies are not transpiled by babel as default. `babel-loader`
ignores all files inside `node_modules`.

Not using it may allow packages using newer JavaScript (such as ES6) to
cause unintended crashed on older browsers.

This configuration is the default in projects created by newer versions
of Vue CLI.
2022-01-20 20:07:49 +01:00
undergroundwires
834ce8cf9e Add AirBnb TypeScript overrides for linting
AirBnb only imports JavaScript rules and some fail for TypeScript files.
This commit overrides those rules with TypeScript equivalents.

Changes here can be mostly replaced when Vue natively support TypeScript
for Airbnb (vuejs/eslint-config-airbnb#23).

Enables @typescript-eslint/indent even though it's broken and it will
not be fixed typescript-eslint/typescript-eslint#1824 until prettifier
is used, because it is still useful.

Change broken rules with TypeScript variants:
  - `no-useless-constructor`
      eslint/eslint#14118
      typescript-eslint/typescript-eslint#873
  - `no-shadow`
      eslint/eslint#13044
      typescript-eslint/typescript-eslint#2483
      typescript-eslint/typescript-eslint#325
      typescript-eslint/typescript-eslint#2552
      typescript-eslint/typescript-eslint#2484
      typescript-eslint/typescript-eslint#2466
2022-01-19 22:28:33 +01:00
undergroundwires
2354f0ba9f Fix reverting of Windows NVIDIA telemetry service
- Fix revert logic deleting the service instead of enabling it.
- Use unified "DisableService" function to improve enable/disable logic.
- Separate disabling of service from opting out.
- Add documentation reference.
2022-01-18 21:23:30 +01:00
undergroundwires
8e96c19126 Improve performance of selecting scripts
Increase performance by only notifying GUI about changes in selection
when there really is a change. It removes extra processing from all
event listeners that act on selection state change.
2022-01-07 22:06:05 +01:00
undergroundwires-bot
99fb4c73f5 ⬆️ bump everywhere to 0.11.3 2022-01-06 20:53:52 +00:00
undergroundwires
d11a674a3c win: unrecommend and document Live ID service #100
Rename service to its newer name. Mention breaking behavior in its name
and add more documentation.

Unrecommended from "Standard" pool because it breaks a lot of
functionality, but still recomended in "Stricts" because it's used to
identify personal information that leads to less privacy.
2022-01-05 19:26:30 +01:00
undergroundwires
31f70913a2 Refactor to improve iterations
- Use function abstractions (such as map, reduce, filter etc.) over
  for-of loops to gain benefits of having less side effects and easier
  readability.
- Enable `downLevelIterations` for writing modern code with lazy evaluation.
- Refactor for of loops to named abstractions to clearly express their
  intentions without needing to analyse the loop itself.
- Add missing cases for changes that had no tests.
2022-01-04 21:45:22 +01:00
undergroundwires
bd23faa28f Fix mutated line endings on Windows
It prevents Git from modifying files on checkout. By default, it
converts LF line-endings to CRLF on Windows which leads to inconsistency
and ESLint `linebreak-style` with `unix` (LF) value to fail.

It also solves failed builds in GitHub actions agents actions/checkout#135.
2022-01-03 21:28:24 +01:00
undergroundwires
5b1fbe1e2f Refactor code to comply with ESLint rules
Major refactoring using ESLint with rules from AirBnb and Vue.

Enable most of the ESLint rules and do necessary linting in the code.
Also add more information for rules that are disabled to describe what
they are and why they are disabled.

Allow logging (`console.log`) in test files, and in development mode
(e.g. when working with `npm run serve`), but disable it when
environment is production (as pre-configured by Vue). Also add flag
(`--mode production`) in `lint:eslint` command so production linting is
executed earlier in lifecycle.

Disable rules that requires a separate work. Such as ESLint rules that
are broken in TypeScript: no-useless-constructor (eslint/eslint#14118)
and no-shadow (eslint/eslint#13014).
2022-01-02 18:20:14 +01:00
undergroundwires
96265b75de Upgrade to Vue CLI 5 (and webpack 5)
Upgrade to v5.x using `vue upgrade --next`.

Update `vue.config.js` to import and use `defineConfig`, because it
provides type safety and created by Vue CLI 5 as default.

Vue CLI 5.x upgrades from webpack 4 to 5. It causes some issues that this
commit attemps to fix:

1. Fail due to webpack resolving of Ace.
   Third-party dependency (code editor) Ace uses legacy `file-loader`
   for webpack resolving. It's not supported in webpack 5. So change it
   with manual imports.
   Refs: ajaxorg/ace-builds#211, ajaxorg/ace-builds#221.

2. Wehpack drops polyfilling node core modules (`path`, `fs`, etc.).
   Webpack does not polyfill those modules by default anymore. This is
   good because they did not need browser polyfilling as they are
   used in desktop version only and resolved already by Electron.
   To resolve errors (using webpack recommendations):
    - Add typeof check around `process` variable.
    - Tell webpack explicitly to ignore used node modules.

3. Fail due to legacy dependency of vue-cli-plugin-electron-builder.
   This plugin is used for electron builds and development. It still
   uses webpack 4 that leads to failed builds.
   Downgrading `ts-loader` to latest version which has support for
   `loader-utils` solves the problem (typestrong/ts-loader#1288).
   Related issue: nklayman/vue-cli-plugin-electron-builder#1625

4. Compilation fails due to webpack loading of `fsevents` on macOS.
   This happens only when running `vue-cli-service test:unit` command
   (used in integration tests and unit tests). Other builds work fine.
   Refs: yan-foto/electron-reload#71,
     nklayman/vue-cli-plugin-electron-builder#712,
     nklayman/vue-cli-plugin-electron-builder#1333
2021-12-31 21:23:36 +01:00
undergroundwires
17298f0b2c Add build checks and improve existing CI/CD checks
Run quality checks for every possible OS because behavior of linting
rules may change per OS (e.g. `linebreak-style` ESLint assertment varies
by Unix-like vs Windows).

Add a new check to ensure project can be built:
  1. As both web and desktop applications.
    Different jobs are used due to nonidentical environment/mode support.
    Reference: nklayman/vue-cli-plugin-electron-builder#1627.
  2. Targeting all possible modes.
    The modes are configured using `--mode` but electron CLI checks
    `NODE_ENV` so it's set as well.
    Reference: nklayman/vue-cli-plugin-electron-builder#1626.

  3. On and for different operating systems.

Fix typo "Run units" instead of "Run unit tests".

Link to specific GitHub actions page for workflow runs.

Update documentation to match new structure, and change nontransparent
icons from the diagram and consistently use imperative for actions.

Rename `release-site` , `release-git`, `release-desktop` to
`site-release` , `git-release` and `desktop-release` to not be Yoda.
2021-12-31 00:39:06 +01:00
undergroundwires
61b475fa8d Migrate from TSLint to ESLint
TSLint deprecated and is being replaced by ESLint.

Add Vue CLI plugin (@vue/cli-plugin-eslint) using:
`vue add @vue/cli-plugin-eslint`. It also adds `.eslintrc.js` manually
for Cypress since Vue CLI for ESLint misses it (vuejs/vue-cli#6892).

Also rename `npm run lint:vue` to `npm run lint:eslint` for better
clarification.

This commit disables all rules that the current code is not compliant
with. This allows for enabling them gradually and separating commits
instead of mixing ESLint introduction with other code changes.

AirBnb is chosen as base configuration.

"Standard" is not chosen due to its poor defaults. It makes code cleaner
but harder to maintain:
  - It converts interfaces to types which is harder to read.
  - Removes semicolons that helps to eliminate some ambigious code.

"Airbnb" on the other hand helps for easier future changes and
maintinability:
  - Includes more useful rules.
  - Keeps the semicolons and interfaces.
  - Enforces trailing commas that makes it easier to delete lines later on.
  - Delete branches: standard, prettier.
2021-12-27 22:42:27 +01:00
undergroundwires
455084c17b Document and unrecommend AAD app removal #24, #54
- Document breaking of night light settings (#54).
- Document breaking of language selection from taskbar (#24).
- Unrecommend from strict pool.
2021-12-25 19:12:25 +01:00
undergroundwires
c3c5b897f3 Refactor to add readonly interfaces
Using more granular interfaces adds to expressiveness of the code.
Knowing what needs to mutate the state explicitly helps easier
understanding of the code and therefore increases the maintainability.
2021-12-24 21:14:27 +01:00
Weigurde
a1871a2982 Fix typos in privacy modal #109 2021-12-22 18:24:59 +01:00
undergroundwires
87de017afd Fix failing of functions without revert code
Add missing empty/undefined handling to fix a bug where defining new
functions without `revertCode:` fails.
2021-12-21 06:01:16 +01:00
undergroundwires
5a2c263af3 Restructure pipelines and badges
- Seperate test pipeline into E2E, integration and unit test pipelines.
- Improve documenetation for pipelines (ci-cd.md).
- Introduce naming convention for worklow files and names.
- Center badges with multiple files on README file.
2021-12-19 01:48:29 +01:00
undergroundwires
ddd2e704db Add initial e2e testing with cypress
Add e2e tests with cypress using `vue add e2e-cypress`.

Vue CLI does not support creating typescript tests at this moment
(vuejs/vue-cli#1350).
2021-12-16 22:37:58 +01:00
undergroundwires
9b5e0b0591 Document and unrecommend Cloud Experience Host
Removing Cloud Experience Host has caused many unexpected issues
for users (see #99, #64, #67). It's now excluded from "Strict"
recommendation pool until a better warning mechanism is implemented.
2021-12-13 23:11:13 +01:00
undergroundwires
9b6636e21a Fix clearing Windows product key showing dialog
It's now handled silently instead of prompting user.
2021-12-12 12:21:50 +01:00
undergroundwires
a8358b8e7a Fix OS desktop detection tests and edge cases
- Fix test cases not running for desktop OS detection.
- Fixes application throwing error when user agent is undefined.
- Refactor by making os property optional in Environment to explicit
describe its potential undefined state.
2021-12-11 11:55:43 +01:00
undergroundwires
5f091bb6ab Fix double backlashes in Windows vscode scripts 2021-12-06 20:07:42 +01:00
undergroundwires-bot
17b334aaad ⬆️ bump everywhere to 0.11.2 2021-12-04 16:06:15 +00:00
undergroundwires
c65209e6a9 Unrecommend and complete Windows Push Notif. #101
- Add more script documentation in code and reference URLs.
- Unrecommend as "Standard" recommend as "Strict" due to lack of
  documentation for its privacy intrusive behavior.
- Add mising WpnUserService for disabling it completely.
2021-12-03 01:08:55 +01:00
undergroundwires
d2518b11a7 Improve Windows defender docs and errors #104
- Improve error messages with cause of the problem and suggested solution.
- Document:
  * Disabling `WinDefend` breaks `Set-MpPreference` and Microsoft Store
    (as reported in #104).
  * Document services that `netsh advfirewall` depends on.
- Fix some bad whitespace character in documentation.
2021-11-27 20:22:18 +01:00
undergroundwires
70cdf3865a Improve and unify disabling of Windows services
Refactor, unify and improve the logic to to start/stop and
enable/disable services, and also add more documentation.

Rework functions:
  - Unify way of disabling Windows services using templating.
  - Capitalize as `startupMode` (where startup is single word) everywhere.
  - Use also text parameters (automatic, manual..) instead of numeric
    values (2,3...) when providing parameters to any service disable
    function.

Improve documentation:
  - Add reference URLs about disabled services.
  - Add more code documentation for querying status and allowed values.

Logic improvements include:
  - Check if service is running before stopping/starting the service.
  - Do not start the service it's not an Automatic service.
  - Check whether service is already disabled.
  - When reverting, start the service if it has Automatic startup. But
    do not start the service it has different startup (e.g. manual).
    Also starts the service even though start up is configured as
    desired (before it quit before doing service start).

Improve outputs (logs):
  - Remove false-positive error messages.
  - When a service cannot be stopped/start; mention in output that the
    service will be started/stopped after reboot.
  - Show success message once service is enabled/disabled.
  - Fix reboot messages when enabling/disabling services,
  - Do not write stderr if service cannot be stopped/started as it's not
    not the main goal of the function.

Add missing revert code for the ones missing them:
  - Disable diagnostics telemetry
  - Disable Windows Media Player Network Sharing Service

> Function: DisableServiceInRegistry
- Fix not exitting if service does not exist when reverting
- Show success message once service is enabled/disabled
- Fix double "Enabled.." messages
- Fix unintended registry addition

> Function: DisablePerUserService
- Change implementation to call DisableServiceInRegistry.
- Fix both services are skipped if one of them fails.
- Fix reverting a service sets wrong startup mode.
2021-11-25 21:34:15 +01:00
undergroundwires
7c02ffb6c9 Fix Defender error due to non-english Windows #104
German edition of Windows returns German output for `schtasks.exe`
commands. So checking for "Running" fails immediately as reported #104.

Revert recent change from using `Get-ScheduledTask` and
`Unregister-ScheduledTask` to `schtasks.exe`. Also remove unused
`$powershellFile` variable.
2021-11-21 13:14:58 +01:00
undergroundwires
f2d9881382 Fix unintendedly inlined Windows scripts
- Fix reverting "Disable SQM OS key".
- Fix applying "Disable Visual Studio Code data collection" scripts.
- Fix reverting "Do not show recently used files in Quick Access".
- Add unit tests for automatically checking similar issues in future.
2021-11-19 21:07:22 +01:00
undergroundwires
d7761ab30e Fix Defender features errors in Windows #104
- Refactor to use `Set-MpPreference` in a function instead.
- Better support for both Windows and Windows 11 with platform-specific
  logic, due to poor `Remove-MpPreference` used in Windows 10:
     * Use `Remove-MpPreference` on Windows 11, but switch to
       `Set-MpPreference` for some edge cases using a flag.
     * Use `Set-MpPreference` on Windows 10 by default, and use
       `Remove-MpPreference` for only small amount of cases where it is
       supported.
- Set default value instead of `Remove-MpPreference` on Windows 10 when
  it does not work as expected.
- Improve error messages when:
  * Command name (cmdlet) is not supported
  * Command parameter is not support
  * Failing due to Defender service not working
  * Argument is not supported (e.g. for 'Broad')
- Skip if a parameter or argument is not supported instead of failing.
- Set OS defaults when using `Set-MpPreference` when `Remove-MpPreference`
  does not set the OS defaults.
- Skip setting the setting if it already is as desired.
- Remove redundant scripts in "Disable remediation actions" setting
  `LowThreatDefaultAction`, `ModerateThreatDefaultAction`,
  `HighThreatDefaultAction` and `SevereThreatDefaultAction`. As they are
  all controlled by and limited to value of `UnknownThreatDefaultAction`.
- Fix registry policies not matching cmdlet behavior:
     > CheckForSignaturesBeforeRunningScan
     > SignatureUpdateCatchupInterval
- Fix reverting registry policies (`reg delete` command and error
  output):
    > Disable Malicious Software Reporting tool diagnostic data
    > Turn off block at first sight
- Fix DisableCatchupQuickScan MpPreference command being in wrong
  category by moving it to its right category and adding its correct
  equivalent.
2021-11-17 00:03:59 +01:00
undergroundwires
bf83c58982 Refactor Saas naming, structure and modules
- Add more documentation.
- Use `main.scss` instead of importing components individually. This
  improves productivity without compilation errors due to missing
  imports and allows for easier future file/folder changes and
  refactorings inside `./styles`.
- Use partials with underscored naming. Because it documents that the
  files should not be individually imported.
- Introduce `third-party-extensions` folder to group styles that
  overwrites third party components.
- Refactor variable names from generic to specific.
- Use Sass modules (`@use` and `@forward`) over depreciated `@import`
  syntax.
- Separate font assets from Sass files (`styles/`). Create `assets/`
  folder that will contain both.
- Create `_globals.css` for global styling of common element instead of
  using `App.vue`.
2021-11-14 17:48:49 +01:00
undergroundwires
2e082932c9 Fix disabling/enabling Defender on Windows #104
Change behavior of registry reverting from adding default value to
removing value that overrides. It then leaves the system in cleaner
state, removes "managed by your organization" warning, and makes the
scripts more future-proof providing compatibility with Microsoft patches
updating the defaults. This is implemented by using `reg delete` over
`reg add` and `Remove-MpPreference` over `Set-MpPreference`.

> Disable Windows Defender Scheduled Scan task
Surpress the error when reverting the script as the task may not exist
in some Windows versions.

> Limit catch-up security intelligence (signature) updates
Change to "Disable" instead of "Limit", and bring back its revert code.

Fix reverting of following scripts setting non-default values:
  > Turn off Windows Defender SpyNet reporting
  > Disable checking for signatures before scan
  > Limit CPU usage during idle scans to minumum
  > Disable scanning when not idle
  > Disable scanning on mapped network drives on full-scan

Fix following scripts setting unexpected behavior:
  > Disable running scheduled auto-remediation
  > Limit CPU usage during idle scans to minumum
  > Disable randomizing scheduled task times
  > Disable creating system restore point on a daily basis

Add more documentation for MpPreference module:
  - Add more reference URLs
  - Add status query as documentation
  - Add information regarding default values
  - Describe meaning of enumeration values
  - Document commands not doing expected in Windows 11
2021-11-12 17:26:22 +01:00
undergroundwires
2f90cac52a Improve tests for UserSelection
- Refactor for more logic reuse
- Adds more assertments for events
2021-11-09 21:49:56 +01:00
undergroundwires
20a0071c0d Fix Windows TrustedInstaller session errors
- Fix errors (stderr stream) not being logged.
- Use `schtasks /delete` instead of `Unregister-ScheduledTask` as
  PowerShell command sometimes fail for existing tasks.
- Refactor to use `-TaskName` to explicit describe parameter, and use
  linebreaks for `Register-ScheduledTask` call with many parameters.
2021-11-09 00:14:56 +01:00
undergroundwires-bot
a40f83d6b6 ⬆️ bump everywhere to 0.11.1 2021-11-06 17:47:52 +00:00
undergroundwires
0db8cc4206 Fix website not loading on Safari
It's caused by lookahead regex used in dash comment regex for inlining
PowerShell. This commit changes dash comment inlining.

- Change regex to one without lookahead.
- Add more test cases for inlining dash comment in tricky situations.
- Refactor makeInlineComment to be it's own function to easily test
  other regex options.
- Document all regex alternatives.
- Remove redundant null check (`||`) with adding safe navigation
  operator  (`?`) to allow variable before check to be null instead of
  throwing exception.
2021-11-04 18:42:44 +01:00
undergroundwires
97ddc027cb Fix dead URLs and use forks as GitHub references
Change all GitHub URLs with forks so they survive if their maintainer
decides to remove them.

Fix dead URLs in:
  - "Windows Push Notification Service" (#101)
  - "Limit CPU usage during scans to minimum"
  - "Disable NVIDIA telemetry"
2021-11-03 20:08:56 +01:00
undergroundwires
82c43ba2e3 Refactor to remove "Async" function name suffix
Remove convention where Async suffix is added to functions that returns
a Promise. It was a habit from C#, but is not widely used in JavaScript
/ TypeScript world, also bloats the code. The code is more consistent
with third party dependencies/frameworks without the suffix.
2021-11-01 19:02:22 +01:00
undergroundwires
799fb091b8 Fix failing URL status checking integration tests
Implement following redirects over `fetch` supporting cookies.
`node-fetch` does not support sending cookies during redirect. However,
this is needed to not end-up in a redirect loop for a sign-in callback.

Fix integration tests failing due to redirects and 403 errors:
  - Many redirects from `answers.microsoft.com` was throwing: throwing
    `FetchError: maximum redirect reached` error. It was caused by not
    having cookies when following redirects therefore having an infinite
    sign-in callback for the webpage.
  - Fixes integration tests failing due to additional referer header being
    sent by the application. It adds support for making exceptions to
    additional header sending through a list of regexes.

Add in-depth documentation for URL status checking.
2021-10-30 16:19:10 +01:00
undergroundwires
5ead1a087d Fix, document, unrecommend Windows browser cleanup
The main goal is to highlight and exclude scripts that clears user data
(such as Chrome bookmarks) from standard recommendation, thus allowing
more granular and intentional user selection. Because scripts that are
recommended as "standard" should be non-breaking.

Standard: Recommend only clearing data that would not be noticable by
user. E.g. caches and logs.
Strict	: Recommend clearing data that may be noticable by user, but
does not affect stored consciously data by user. E.g. cookies.
Do not recommend if data is stored consciously by user. E.g. favorites
/ bookmarks.

[General]
  - Change wording from "Clear xx traces" to "Clean xx history" to make
  it more clear and unify the naming with macOS scripts.
  - More documentation both in code and both as more references.

[Chrome]
  - Unrecommend deleting Chrome user profile.
  - Document what each chrome clean-up script is doing in more detail.

[Internet Explorer]
  - Document IE scripts better.
  - For Cookie cleanup, add solutions for later Windows version.
  - Unrecommend some from standard.
  - Remove undocumented `Local Settings\Traces` folder.
  - Take ownership before deleting Temporary Internet Files. Fixes
    permission error.
  - Remove `INetCookies\PrivacIE` script because it's undocumented and
    we already have cleanup for its parent folder (`INetCookies`).
  - Remove "%USERPROFILE%\Local Settings\Traces" due to lack of
    documentation.

[Safari]
  - Remove cleanup for undocumented traces folders `Safari\Traces`.
  - Document with subcategories and references.
  - Fix clearing all data not pointing to `localappdata`.
  - Unrecomend clearing all data.

[Opera]
  - Rename to "Clear all.." to show intent.
  - Unrecommend as it removes everything.
2021-10-28 17:43:04 +01:00
undergroundwires
64631a4552 Update dependencies
- Bump dependencies to latest.
- Remove unused inversify dependency.
- Lock sass-loader to a version that's compatible to 10. Because later
  versions (>=11) require Webpack v5 while Vue CLI v4 uses Webpack v4.
- Changes slashes as division to `math.div` as it's depreciated by SASS
  https://sass-lang.com/documentation/breaking-changes/slash
2021-10-23 20:25:03 +01:00
undergroundwires-bot
f47cb04860 ⬆️ bump everywhere to 0.11.0 2021-10-21 14:57:54 +00:00
undergroundwires
504fa056d7 Update screenshot 2021-10-21 15:48:43 +01:00
undergroundwires
739287ac71 Fix minor issues with Defender scripts
Suppress errors:
  - when deleting registry keys because the condition where key does
    not exist is not really an error,
  - when disabling a task that does not exist because absence of a task
    when trying to disable it is still a positive condition for goal to
    get rid of it,
  - when stopping or starting a service because goal is to
    disable/enable the service.

> Turn off SmartScreen App Install Control feature
- Add missing `/f` flag

> Remove "Scan with Windows Defender" option from context menu
- Add missing `/f` flag
- Remove redundant delete line in revert code
- Add missing whitespace before `/v` flag
- Instead of deleting with `/v ""`, use `/va` for explicit indent

> Disable Malicious Software Reporting tool diagnostic data
- Fix typo in revert scripts

> Turn off tamper protection
> Restrict threat history to administrators
Fix permission errors using TrustedInstaller session

> `Set-MpPreference` scripts
- Show warning when Set-MpPreference parameter is missing instead of an
  error The reasons include:
    - If the parameter is missing, functionality may be missing i.e.
      there's nothing to disable
    - In most cases different way of disabling registry is provided
    - Error is not clear and too verbose and should be implemented in a
      better way
- Add back "Limiting Defender definition updates" script due to being
  able to handle if aparameter is missing
- Add documentation for command in its script

> Disable the Potentially Unwanted Application (PUA) feature
- Fix revert code disabling another functionality

> Disable bidirectional scanning of incoming
- Fix typo

> Disable Microsoft Defender Antivirus
- Correct wrong comment regarding deprecation

> Disable Windows Defender Security Center Service
- Fix disabling not working on Windows 11
2021-10-21 15:37:09 +01:00
undergroundwires
ab8bce7686 Support disabling of protected services #74
Add new ways to disable Defender on Windows:
  1. Disable through renaming required files
  2. Disable using registry changes
  3. Disable using TrustedInstaller user

Add support for running code as TrustedInstaller 🥳. It allows running
commands in OS-protected areas. It is written in PowerShell and it uses
PowerShell syntax like backticks that are inlined in special way. So the
commit extends inlining support and allows writing PowerShell using:
  - Comments
  - Here-strings
  - Backticks

Add disabling of more Defender service

Improve documentation and categorization of services.
2021-10-20 21:12:47 +02:00
undergroundwires
e6152fa76f Improve security hardening for macOS
- Moves security improvements to its own action.
- Add more scripts and documentation for security improvements
- Move "Disable Spotlight indexing" out of wrong firewall category
- Add more documentation
2021-10-19 22:16:40 +02:00
undergroundwires
a8031d18d5 Change theme colors
Change almost all colors for better look.
2021-10-18 21:23:04 +01:00
undergroundwires
9aa8166891 Change PowerShell double quotes escape
It changes the way privacy.sexy escape double quotes inside batch
command when running PowerShell scripts as an argument to
PowerShell.exe. It uses more robust and stable way offering support for
wider use-cases.
2021-10-17 15:37:06 +01:00
undergroundwires
236a0f6c82 Add privacy over security scripts for macOS #83
It adds scripts to:
  - Disable OS security modules.
  - Clean quarantine data.
  - Disable auto-updates.
2021-10-16 19:49:41 +02:00
undergroundwires
2492f2d814 Add more ways to disable and clean Defender #74
Adds more scripts to:
 - disable Windows Defender functions,
 - remove it from the user interface,
 - clean its scan history.

Improves the documentation:
 - Adds more documentation reference URLs.
 - Restructures scripts in a way to better document their behavior. All
   Defender scripts are now under "Disable Windows Defender" and
   "Privacy over security".

Changes recommendations, and simply recommends less:
 - Defender cloud configurations are now only documented on "Strict"
   mode.
 - Watson event sending with "DisableGenericReports" is disabled due to
   lack of documentation and depreciation.
2021-10-15 17:21:37 +01:00
undergroundwires
410bcd8244 Add semi-automatic update support for macOS
For fully automatic macOS updates, electron-updater requires:
  1. Distributing macOS file as .zip (electron-userland/electron-builder#2199)
  2. Code signing for the application

privacy.sexy as of today lacks both the distribution and code signing.
This commit introduces auto-updates through automatically checking for
updates, downloading them but requiring user to drag application icons
to Applications by opening dmg file.

This commit also fixes:
  1. Progress state in update progress bar not being shown.
  2. Downloading updates were being triggered even though it was not
desired as downloads are being handled using different based on OS and
user choice.

In the end it refactors the code for handling updates of two different
kinds, and making message dialog use enums for results instead of
response integers as well as setting default and cancel button behavior.
Refactorings make behaviors more explicit and extends the control.
2021-10-13 21:25:09 +01:00
undergroundwires
b08a6b5cec Use a consistent color system
1. Renames color names in palette. Using names such as "primary" and
"secondary" that are in consistent with designs such as material,
bootstrap and metro UI palettes. It adds `color-` prefix on color
variables in line with Vue Design System.
2. Introduces necessary changes to follow the system color system
   everywhere without using any other color:
     - It changes tooltip background from black to darker primary
     colors.
     - It overrides unset styles from tree component
     - It ensures footer has same color as top menu.
3. Removes opacity CSS changes to have better control on choices. To
   achieve that:
     - It introduces new "light" variants of main colors
     - It switches to colors with different variants (e.g. in Dialogs it
       uses primary color as button as it has variants that can be
       activated on hover meanwhile on-surface color is single).
4. Styles a tags (anchor elements) globally for consistency
2021-10-11 19:33:34 +02:00
undergroundwires
37ad26a082 Remove integration tests from deployments #90
Integration tests may depend on third parties and can fail from time to
time. In some situations failing tests can be acceptable to go forward
with deployments. They should not be a requirement that blocks
deployments. They may lead to unintended lack of distributed packages as
seen in #90.
2021-10-09 12:39:05 +01:00
undergroundwires
0696ed8396 Improve disabling of SmartScreen #74
This commit renames "smart screen" to "SmartScreen" which is the
official name from Microsoft.

It categorizes scripts to document the behavior in a more clear way. It
adds structured depth. It moves all SmartScreen scripts under Defender,
as it's now part of Defender offering since latest branding.

In addition, the commit adds more documentation and more scripts such as
disabling SmartScreen for Edge.
2021-10-08 22:38:41 +02:00
undergroundwires
9942df16c8 Increase default screen width on desktop app
Goal is to show 3 card in a row as default. It gives more consistent
look to privacy.sexy across web and desktop.
2021-10-05 22:25:59 +01:00
undergroundwires
20b7d283b0 Add support for more depth in function calls
It allow pipes to be used in nested functions. Before, pipes were added
to a variable before variable content was evaluated/compiled by
another function. This commit ensures that the commits are evaluted in
expected order.

The issue is solved by stopping precompiling functions. It makes code
less complex. It adds to compile time of the script file but nothing
noticable and something optimizable.

The problem was that the call trees we're not executed in expected
order. E.g. let's say we have functionA that outputs something like
"Hello {{ $name| pipe }}", and we have function B calling with "name:
dear {{ $firstName}}", and at last we have a script that's calling
function B with "firstName: undergroundwires". Before, expressions were
evaluated directly, meaning that function A would become:
"Hello Dear {{ $firstName}}", as you see the pipe in function A
is lost here after being applied to function B and not reaching
$firstTime input value. Parsing expressions in the end allows for pipes
etc. to not get lost.

The commit also does necessary name refactorings and folder refactorings
to reflect logical changes. `FunctionCompiler` is renamed to
`SharedFunctionsParser` as precompiling is removed and it just simply
parses now. `/FunctionCall/` is moved to `/Function/Call`.

Finally, it improves documentation and adds more tests.
2021-10-04 18:13:25 +01:00
undergroundwires
f39ee76c0c Add script to remove Meet Now icon in Windows 2021-09-24 19:22:09 +01:00
undergroundwires
4b2390736a Support disabling per-user services in Windows #16
Some services in Windows have random characters appended to them. This
commit fixes the scripts that has been trying to disable them but
failing in newer Windows versions where they become per-user.
2021-09-20 23:05:15 +01:00
undergroundwires
c8cb7a5c28 Improve alignment, padding/margin issues on UI
1. It vertically centers top script menu (including selectors for view,
   OS and recommendation levels). Before, it did not utilize the empty
   space on smaller screens when of the menu items overflowed to a new
   line. This commit fixes it, also adds margin on top selectors on
   small screens.
2. It adds vertical margin between slider items on vertical view. It
   also refactors slider component so that the `v-deep` is no longer
   used, instead style is set through properties.
3. It ensures symmetrical margin on both sides of the handle in slider
   during horizontal view. Before, the left margin did not exist and
   right margin was too wide. This commit balances right and left margin
   of the arrow.
4. It changes the way margining is done for the card list. It removes
   internal margin from cards, because when they have them they also add
   that to the outer card list. This commit solves it in a way that
   unifies setting gap between cards and setting gap between cards.
   The styles are controlled on card list instead. This way same margins
   and paddings is also applied to non-card view (i.e. scripts tree).
   Before margining was done separately and those views looked
   diferently.
5. It improves styling of cards. It uses variables instead of hardcoded
   values and also refactors and renames variables for simpler
   understanding.
2021-09-19 15:33:40 +01:00
undergroundwires
5217b0b758 Add pipes to write pretty PowerShell #53
This commit introduces two pipes: `inlinePowerShell`,
`escapeDoubleQuotes`. The types when used together allows writing adding
clean and real PowerShell scripts as they are (without inlinining or
escaping them), removing the need to have hard-coded inlining/escaping.

It enables writing better PowerShell, makes it easier to maintain and
extend PowerShell scripts. Also allows writing more stable code with
less "unseen" bugs due to manual escaping/inlining. This commit
naturally reveals and fixes double quotes not being escaped in "Empty
trash bin" script.

This is solved by unifying the use of RunPowerShell function by all
scripts using PowerShell. The function inlines and escapes the scripts
as compile time to be send them to PowerShell.exe as an argument and
then invokes PowerShell.exe with generated ugly code.
2021-09-13 21:23:57 +01:00
undergroundwires
ddf417a16a Add new UX for optionally downloading updates
Before we used native method from electron for updating and notifying
(`checkForUpdatesAndNotify`). It simply checked if there's an update,
downloaded it, applied in the background and showed OS notification.

The flow is now updated. Updates will be checked, user will be asked to
confirm about whether to download and apply the updates, then a UI with
progress bar will be shown and user will be asked to restart the
application.

This commit also moves electron related logic to `/electron/` folder (as
there are now multiple files) to keep them structured. Also the electon
entrypoint `background.ts` is renamed to `main.ts`. The reason it was
named  `background.ts` by vue-cli-plugin-electron-builder was to remove
the confusion between `main.ts` of Vue itself. However, as they are
kept in different folders, but this is not the case for us.

Better than `checkForUpdatesAndNotify`.
Organizes electron desktop app logic in same folder to allow using
multiple files in a structured manner.
2021-09-11 11:04:08 +01:00
undergroundwires
2f0321f315 Bump node environment to 15.x
It updates node version to 15.x to be able to use
`String.prototype.replaceAll()`
2021-09-09 22:12:59 +01:00
undergroundwires
4d7ff7edc5 Add support for pipes in templates #53
The goal is to be able to modify values of variables used in templates.
It enables future functionality such as escaping, inlining etc.

It adds support applying predefined pipes to variables. Pipes
can be applied to variable substitution in with and parameter
substitution expressions. They work in similar way to piping in Unix
where each pipe applied to the compiled result of pipe before.

It adds support for using pipes in `with` and parameter substitution
expressions. It also refactors how their regex is build to reuse more of
the logic by abstracting regex building into a new class.

Finally, it separates and extends documentation for templating.
2021-09-08 18:58:30 +01:00
undergroundwires
862914b06e Add "with" expression for templating #53
Allows optionally rendering content if an argument is given. The
expression is designed to be used with `optional` parameters.

Goal is to allow using `RunPowerShell` function on every function that
consists of PowerShell code. Before this commit, they were all required
to provide revertCode, or none of them could be able to have it. It
would not work because some scripts can be reverted, meanwhile some are
one-way scripts that cannot be reverted (such as cleaning scripts). In
this case a way to optionally render revertCode was required. `with`
expression give each callee script ability to turn off `revertCode` if
not needed, therefore enables using `RunPowerShell` everywhere.

This commit also improves error message for script code for better
debugging and refactors parser tests for more code reuse. It also adds
more tests to parameter substitution, and renames some tests of both
expressions for consistency.
2021-09-06 21:02:41 +01:00
undergroundwires
6c3c2e6709 Improve macOS scripts for cleaning OS logs
Categorizes existing scripts on deeper level and adds more
documentation, also adds new scripts for other system log artifacts.
2021-09-05 21:51:33 +02:00
undergroundwires
c92dc1e253 Add scripts to disable, hide and opt-out from Siri 2021-09-04 23:39:14 +02:00
undergroundwires
e73c0ad1bf Do not collapse cards on links and code area #88
Detects clickable elements automatically and exempts them from
collapsing cards, also interacting with code area does no longer
collapse cards.

This commit also fixes subscribing to clicks on document every time card
list is loaded, but never unsubscribing. This impacts performance and
causes memory leaks. Now, registered event listener is removed every
time card list component is destroyed.
2021-09-03 19:44:44 +02:00
undergroundwires
6a89c6224b Add optionality for parameters
This commit allows for parameters that does not require any arguments to
be provided in function calls. It changes collection syntax where
parameters are list of objects instead of primitive strings. A
parameter has now 'name' and 'optional' properties. 'name' is required
and used in same way as older strings as parameter definitions.
'Optional' property is optional, 'false' is the default behavior if
undefined. It also adds additional validation to restrict parameter
names to alphanumeric strings to have a clear syntax in expressions.
2021-09-02 18:59:25 +01:00
undergroundwires
dcccb61781 Tighten parameter substitution tolerance
In collection templating syntax, do not tolerate whitespace after dollar sign. So while `{{ $param }}` is valid `{{ $ param }}` will be ignored.
2021-08-30 18:57:05 +01:00
undergroundwires
c0c475ff56 Change "grouping" to "view"
1. *Grouping* becomes *view*. Because *view* is more clear and extensible than *grouping*. It increases flexibility to extend by e.g. adding *flat* as a new view as discussed in #50, in this case "flat *view*" would make more sense than "flat *grouping*".
2. *None* becomes *tree*. Because *tree* is more descriptive than *none*.

Updates labels on top menu. As labels are updated, the file structure/names are refactored to follow the same concept. `TheScriptsList` is renamed to `TheScriptsView`. Also refactors `ViewChanger` so view types are presented in same way.
2021-08-29 11:33:16 +01:00
undergroundwires-bot
6dc768817f ⬆️ bump everywhere to 0.10.3 2021-08-28 10:22:47 +00:00
467 changed files with 51984 additions and 29189 deletions

7
.editorconfig Normal file
View File

@@ -0,0 +1,7 @@
[*.{js,jsx,ts,tsx,vue}]
indent_style = space
indent_size = 2
end_of_line = lf
trim_trailing_whitespace = true
insert_final_newline = true
max_line_length = 100

291
.eslintrc.js Normal file
View File

@@ -0,0 +1,291 @@
const { rules: baseBestPracticesRules } = require('eslint-config-airbnb-base/rules/best-practices');
const { rules: baseErrorsRules } = require('eslint-config-airbnb-base/rules/errors');
const { rules: baseES6Rules } = require('eslint-config-airbnb-base/rules/es6');
const { rules: baseImportsRules } = require('eslint-config-airbnb-base/rules/imports');
const { rules: baseStyleRules } = require('eslint-config-airbnb-base/rules/style');
const { rules: baseVariablesRules } = require('eslint-config-airbnb-base/rules/variables');
const tsconfigJson = require('./tsconfig.json');
module.exports = {
root: true,
env: {
node: true,
},
extends: [
// Vue specific rules, eslint-plugin-vue
// Added by Vue CLI
'plugin:vue/essential',
// Extends eslint-config-airbnb
// Added by Vue CLI
// Here until https://github.com/vuejs/eslint-config-airbnb/issues/23 is done
'@vue/airbnb',
// Extends @typescript-eslint/recommended
// Uses the recommended rules from the @typescript-eslint/eslint-plugin
// Added by Vue CLI
'@vue/typescript/recommended',
],
parserOptions: {
ecmaVersion: 'latest',
},
rules: {
...getOwnRules(),
...getTurnedOffBrokenRules(),
...getOpinionatedRuleOverrides(),
...getTodoRules(),
},
overrides: [
{
files: [
'**/__tests__/*.{j,t}s?(x)',
'**/tests/unit/**/*.spec.{j,t}s?(x)',
],
env: {
mocha: true,
},
},
{
files: ['**/*.ts?(x)', '**/*.d.ts'],
parserOptions: {
// Setting project is required for some rules such as @typescript-eslint/dot-notation,
// @typescript-eslint/return-await and @typescript-eslint/no-throw-literal.
// If this property is missing they fail due to missing parser.
project: ['./tsconfig.json'],
},
rules: {
...getTypeScriptOverrides(),
},
},
{
files: ['**/tests/**/*.{j,t}s?(x)'],
rules: {
'no-console': 'off',
},
},
],
};
function getOwnRules() {
return {
'no-console': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'no-debugger': process.env.NODE_ENV === 'production' ? 'warn' : 'off',
'linebreak-style': ['error', 'unix'], // This is also enforced in .editorconfig and .gitattributes files
'import/order': [ // Enforce strict import order taking account into aliases
'error',
{
groups: [ // Enforce more strict order than AirBnb
'builtin', 'external', 'internal', 'parent', 'sibling', 'index', 'object', 'type'],
pathGroups: [ // Fix manually configured paths being incorrectly grouped as "external"
...getAliasesFromTsConfig(),
'js-yaml-loader!@/**',
].map((pattern) => ({ pattern, group: 'internal' })),
},
],
};
}
function getTodoRules() { // Should be worked on separate future commits
return {
'import/no-extraneous-dependencies': 'off',
// Accessibility improvements:
'vuejs-accessibility/form-control-has-label': 'off',
'vuejs-accessibility/click-events-have-key-events': 'off',
'vuejs-accessibility/anchor-has-content': 'off',
'vuejs-accessibility/accessible-emoji': 'off',
};
}
function getTurnedOffBrokenRules() {
return {
// Broken in TypeScript
'no-useless-constructor': 'off', // Cannot interpret TypeScript constructors
'no-shadow': 'off', // Fails with TypeScript enums
};
}
function getOpinionatedRuleOverrides() {
return {
// https://erkinekici.com/articles/linting-trap#no-use-before-define
'no-use-before-define': 'off',
// https://erkinekici.com/articles/linting-trap#arrow-body-style
'arrow-body-style': 'off',
// https://erkinekici.com/articles/linting-trap#no-plusplus
'no-plusplus': 'off',
// https://erkinekici.com/articles/linting-trap#no-param-reassign
'no-param-reassign': 'off',
// https://erkinekici.com/articles/linting-trap#class-methods-use-this
'class-methods-use-this': 'off',
// https://erkinekici.com/articles/linting-trap#importprefer-default-export
'import/prefer-default-export': 'off',
// https://erkinekici.com/articles/linting-trap#disallowing-for-of
// Original: https://github.com/airbnb/javascript/blob/d8cb404da74c302506f91e5928f30cc75109e74d/packages/eslint-config-airbnb-base/rules/style.js#L333-L351
'no-restricted-syntax': [
baseStyleRules['no-restricted-syntax'][0],
...baseStyleRules['no-restricted-syntax'].slice(1).filter((rule) => rule.selector !== 'ForOfStatement'),
],
};
}
function getTypeScriptOverrides() {
/*
Here until Vue supports AirBnb Typescript overrides (vuejs/eslint-config-airbnb#23).
Based on `eslint-config-airbnb-typescript`.
Source: https://github.com/iamturns/eslint-config-airbnb-typescript/blob/v16.1.0/lib/shared.js
It cannot be used directly due to compilation errors.
*/
return {
'brace-style': 'off',
'@typescript-eslint/brace-style': baseStyleRules['brace-style'],
camelcase: 'off',
'@typescript-eslint/naming-convention': [
'error',
{ selector: 'variable', format: ['camelCase', 'PascalCase', 'UPPER_CASE'] },
{ selector: 'function', format: ['camelCase', 'PascalCase'] },
{ selector: 'typeLike', format: ['PascalCase'] },
],
'comma-dangle': 'off',
'@typescript-eslint/comma-dangle': [
baseStyleRules['comma-dangle'][0],
{
...baseStyleRules['comma-dangle'][1],
enums: baseStyleRules['comma-dangle'][1].arrays,
generics: baseStyleRules['comma-dangle'][1].arrays,
tuples: baseStyleRules['comma-dangle'][1].arrays,
},
],
'comma-spacing': 'off',
'@typescript-eslint/comma-spacing': baseStyleRules['comma-spacing'],
'default-param-last': 'off',
'@typescript-eslint/default-param-last': baseBestPracticesRules['default-param-last'],
'dot-notation': 'off',
'@typescript-eslint/dot-notation': baseBestPracticesRules['dot-notation'],
'func-call-spacing': 'off',
'@typescript-eslint/func-call-spacing': baseStyleRules['func-call-spacing'],
// ❌ Broken for some cases, but still useful.
// Here until Prettifier is used.
indent: 'off',
'@typescript-eslint/indent': baseStyleRules.indent,
'keyword-spacing': 'off',
'@typescript-eslint/keyword-spacing': baseStyleRules['keyword-spacing'],
'lines-between-class-members': 'off',
'@typescript-eslint/lines-between-class-members': baseStyleRules['lines-between-class-members'],
'no-array-constructor': 'off',
'@typescript-eslint/no-array-constructor': baseStyleRules['no-array-constructor'],
'no-dupe-class-members': 'off',
'@typescript-eslint/no-dupe-class-members': baseES6Rules['no-dupe-class-members'],
'no-empty-function': 'off',
'@typescript-eslint/no-empty-function': baseBestPracticesRules['no-empty-function'],
'no-extra-parens': 'off',
'@typescript-eslint/no-extra-parens': baseErrorsRules['no-extra-parens'],
'no-extra-semi': 'off',
'@typescript-eslint/no-extra-semi': baseErrorsRules['no-extra-semi'],
// ❌ Fails due to missing parser
// 'no-implied-eval': 'off',
// 'no-new-func': 'off',
// '@typescript-eslint/no-implied-eval': baseBestPracticesRules['no-implied-eval'],
'no-loss-of-precision': 'off',
'@typescript-eslint/no-loss-of-precision': baseErrorsRules['no-loss-of-precision'],
'no-loop-func': 'off',
'@typescript-eslint/no-loop-func': baseBestPracticesRules['no-loop-func'],
'no-magic-numbers': 'off',
'@typescript-eslint/no-magic-numbers': baseBestPracticesRules['no-magic-numbers'],
'no-redeclare': 'off',
'@typescript-eslint/no-redeclare': baseBestPracticesRules['no-redeclare'],
// ESLint variant does not work with TypeScript enums.
'no-shadow': 'off',
'@typescript-eslint/no-shadow': baseVariablesRules['no-shadow'],
'no-throw-literal': 'off',
'@typescript-eslint/no-throw-literal': baseBestPracticesRules['no-throw-literal'],
'no-unused-expressions': 'off',
'@typescript-eslint/no-unused-expressions': baseBestPracticesRules['no-unused-expressions'],
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': baseVariablesRules['no-unused-vars'],
// https://erkinekici.com/articles/linting-trap#no-use-before-define
// 'no-use-before-define': 'off',
// '@typescript-eslint/no-use-before-define': baseVariablesRules['no-use-before-define'],
// ESLint variant does not understand TypeScript constructors.
// eslint/eslint/#14118, typescript-eslint/typescript-eslint#873
'no-useless-constructor': 'off',
'@typescript-eslint/no-useless-constructor': baseES6Rules['no-useless-constructor'],
quotes: 'off',
'@typescript-eslint/quotes': baseStyleRules.quotes,
semi: 'off',
'@typescript-eslint/semi': baseStyleRules.semi,
'space-before-function-paren': 'off',
'@typescript-eslint/space-before-function-paren': baseStyleRules['space-before-function-paren'],
'require-await': 'off',
'@typescript-eslint/require-await': baseBestPracticesRules['require-await'],
'no-return-await': 'off',
'@typescript-eslint/return-await': baseBestPracticesRules['no-return-await'],
'space-infix-ops': 'off',
'@typescript-eslint/space-infix-ops': baseStyleRules['space-infix-ops'],
'object-curly-spacing': 'off',
'@typescript-eslint/object-curly-spacing': baseStyleRules['object-curly-spacing'],
'import/extensions': [
baseImportsRules['import/extensions'][0],
baseImportsRules['import/extensions'][1],
{
...baseImportsRules['import/extensions'][2],
ts: 'never',
tsx: 'never',
},
],
// Changes required is not yet implemented:
// 'import/no-extraneous-dependencies': [
// baseImportsRules['import/no-extraneous-dependencies'][0],
// {
// ...baseImportsRules['import/no-extraneous-dependencies'][1],
// devDependencies: baseImportsRules[
// 'import/no-extraneous-dependencies'
// ][1].devDependencies.reduce((result, devDep) => {
// const toAppend = [devDep];
// const devDepWithTs = devDep.replace(/\bjs(x?)\b/g, 'ts$1');
// if (devDepWithTs !== devDep) {
// toAppend.push(devDepWithTs);
// }
// return [...result, ...toAppend];
// }, []),
// },
// ],
};
}
function getAliasesFromTsConfig() {
return Object.keys(tsconfigJson.compilerOptions.paths)
.map((path) => `${path}*`);
}

6
.gitattributes vendored Normal file
View File

@@ -0,0 +1,6 @@
# Prevent Git from auto-converting to CRLF on Windows, and convert to LF on checkin.
# * : All files
# text=auto : If Git decides content it text, it converts CRLF to LF on checkin.
# eol=lf : forces Git to normalize line endings to LF on checkin and prevents conversion
# to CRLF when the file is checked out.
* text=auto eol=lf

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
github: undergroundwires

59
.github/workflows/checks.build.yaml vendored Normal file
View File

@@ -0,0 +1,59 @@
name: build-checks
on:
push:
pull_request:
jobs:
build-web:
strategy:
matrix:
os: [ macos, ubuntu, windows ]
mode: [ development, test, production ]
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: actions/setup-node@v1
with:
node-version: 15.x
-
name: Install dependencies
run: npm ci
-
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
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: actions/setup-node@v1
with:
node-version: 15.x
-
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: Build
run: |-
cross-env-shell NODE_ENV=${{ matrix.mode }}
npm run electron:build -- --publish never --mode ${{ matrix.mode }}

View File

@@ -1,4 +1,4 @@
name: Quality checks name: quality-checks
on: [ push, pull_request ] on: [ push, pull_request ]
@@ -8,19 +8,20 @@ jobs:
strategy: strategy:
matrix: matrix:
lint-command: lint-command:
- npm run lint:vue - npm run lint:eslint
- npm run lint:yaml - npm run lint:yaml
- npm run lint:md - npm run lint:md
- npm run lint:md:relative-urls - npm run lint:md:relative-urls
- npm run lint:md:consistency - npm run lint:md:consistency
fail-fast: false # So it continues with other commands if one fails os: [ macos, ubuntu, windows ]
fail-fast: false # Still interested to see results from other combinations
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v2 uses: actions/checkout@v2
- name: Setup node - name: Setup node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 15.x
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Lint - name: Lint

View File

@@ -1,4 +1,4 @@
name: Security checks name: security-checks
on: on:
push: push:
@@ -18,7 +18,7 @@ jobs:
name: Setup node name: Setup node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: 14.x node-version: 15.x
- -
name: NPM audit name: NPM audit
run: npm audit run: exit "$(npm audit)" # Since node 15.x, it does not fail with error if we don't explicitly exit

View File

@@ -1,4 +1,4 @@
name: Deploy desktop name: release-desktop
on: on:
release: release:
@@ -22,13 +22,11 @@ jobs:
- name: Setup node - name: Setup node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: '14.x' node-version: 15.x
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Run tests - name: Run unit tests
run: | run: npm run test:unit
npm run test:unit
npm run test:integration
- name: Publish desktop app - 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 run: npm run electron:build -- -p always # https://nklayman.github.io/vue-cli-plugin-electron-builder/guide/recipes.html#upload-release-to-github
env: env:

View File

@@ -1,4 +1,4 @@
name: Bump & release name: release-git
on: on:
push: # Ensure a new release is created for each new tag push: # Ensure a new release is created for each new tag

View File

@@ -1,4 +1,4 @@
name: Deploy site name: release-site
on: on:
release: release:
@@ -83,16 +83,14 @@ jobs:
name: "App: Setup node" name: "App: Setup node"
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: '14.x' node-version: 15.x
- -
name: "App: Install dependencies" name: "App: Install dependencies"
run: npm ci run: npm ci
working-directory: site working-directory: site
- -
name: "App: Run tests" name: "App: Run unit tests"
run: | run: npm run test:unit
npm run test:unit
npm run test:integration
working-directory: site working-directory: site
- -
name: "App: Build" name: "App: Build"

28
.github/workflows/tests.e2e.yaml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: e2e-tests
on:
push:
pull_request:
jobs:
run-tests:
strategy:
matrix:
os: [macos, ubuntu, windows]
fail-fast: false # So it still runs on other OSes if one of them fails
runs-on: ${{ matrix.os }}-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Setup node
uses: actions/setup-node@v1
with:
node-version: 15.x
-
name: Install dependencies
run: npm ci
-
name: Run e2e tests
run: npm run test:e2e -- --headless

View File

@@ -1,9 +1,9 @@
name: Test name: integration-tests
on: on:
push: push:
pull_request: pull_request:
schedule: # for integration tests schedule: # To get notified about problems from third party dependencies
- cron: '0 0 * * 0' # at 00:00 on every Sunday - cron: '0 0 * * 0' # at 00:00 on every Sunday
jobs: jobs:
@@ -21,13 +21,10 @@ jobs:
name: Setup node name: Setup node
uses: actions/setup-node@v1 uses: actions/setup-node@v1
with: with:
node-version: '14.x' node-version: 15.x
- -
name: Install dependencies name: Install dependencies
run: npm ci run: npm ci
-
name: Run unit tests
run: npm run test:unit
- -
name: Run integration tests name: Run integration tests
run: npm run test:integration run: npm run test:integration

28
.github/workflows/tests.unit.yaml vendored Normal file
View File

@@ -0,0 +1,28 @@
name: unit-tests
on:
push:
pull_request:
jobs:
run-tests:
strategy:
matrix:
os: [macos, ubuntu, windows]
fail-fast: false # So it still runs on other OSes if one of them fails
runs-on: ${{ matrix.os }}-latest
steps:
-
name: Checkout
uses: actions/checkout@v2
-
name: Setup node
uses: actions/setup-node@v1
with:
node-version: 15.x
-
name: Install dependencies
run: npm ci
-
name: Run unit tests
run: npm run test:unit

6
.gitignore vendored
View File

@@ -1,6 +1,10 @@
node_modules node_modules
dist/ dist/
.vs .vs
.vscode .vscode/**/*
!.vscode/extensions.json
#Electron-builder output #Electron-builder output
/dist_electron /dist_electron
# Cypress
/tests/e2e/screenshots
/tests/e2e/videos

23
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,23 @@
{
"recommendations": [
// Common
"editorconfig.editorconfig", // Applies .editorconfig to follow project style.
"wengerk.highlight-bad-chars", // Highlights bad chars.
"wayou.vscode-todo-highlight", // Highlights TODO.
"wix.vscode-import-cost", // Shows in KB how much a require include in code.
// Documentation
"davidanson.vscode-markdownlint", // Lints markdown.
// TypeScript / JavaScript
"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.
// Scripting
"timonwong.shellcheck", // Lints bash files.
"ms-vscode.powershell", // Lints PowerShell files.
"ms-python.python", // Lints Python files.
// Distribution
"ms-azuretools.vscode-docker" // Adds Docker support.
]
}

View File

@@ -1,5 +1,106 @@
# Changelog # Changelog
## 0.11.3 (2022-01-05)
* Fix double backlashes in Windows vscode scripts | [5f091bb](https://github.com/undergroundwires/privacy.sexy/commit/5f091bb6abed878271e2321cd784f34436c677bd)
* Fix OS desktop detection tests and edge cases | [a8358b8](https://github.com/undergroundwires/privacy.sexy/commit/a8358b8e7a93214f3d22a4488007ded5f623d845)
* Fix clearing Windows product key showing dialog | [9b6636e](https://github.com/undergroundwires/privacy.sexy/commit/9b6636e21a922a4750dc19f4854f8ae679187926)
* Document and unrecommend Cloud Experience Host | [9b5e0b0](https://github.com/undergroundwires/privacy.sexy/commit/9b5e0b0591fee56af52d83334a1f19180a49516f)
* Add initial e2e testing with cypress | [ddd2e70](https://github.com/undergroundwires/privacy.sexy/commit/ddd2e704dbd361cbd219f3dfe644b983ad254095)
* Restructure pipelines and badges | [5a2c263](https://github.com/undergroundwires/privacy.sexy/commit/5a2c263af35b8785e75ead6c43c3f17186dc15c8)
* Fix failing of functions without revert code | [87de017](https://github.com/undergroundwires/privacy.sexy/commit/87de017afd6e08acbd2deea150c6af9c7ee778fc)
* Fix typos in privacy modal #109 | [a1871a2](https://github.com/undergroundwires/privacy.sexy/commit/a1871a2982c9e3192193f836b97b1a6ccda5a2ab)
* Refactor to add readonly interfaces | [c3c5b89](https://github.com/undergroundwires/privacy.sexy/commit/c3c5b897f308f613c252182a02cdd4cfa7150fa3)
* Document and unrecommend AAD app removal #24, #54 | [455084c](https://github.com/undergroundwires/privacy.sexy/commit/455084c17b32d11d046515e8dc1447adf4bea4c3)
* Migrate from TSLint to ESLint | [61b475f](https://github.com/undergroundwires/privacy.sexy/commit/61b475fa8de433cdada2efa7eac197683aacd956)
* Add build checks and improve existing CI/CD checks | [17298f0](https://github.com/undergroundwires/privacy.sexy/commit/17298f0b2c51cb9becc0eb2ffe0d93d6a4c503a6)
* Upgrade to Vue CLI 5 (and webpack 5) | [96265b7](https://github.com/undergroundwires/privacy.sexy/commit/96265b75deafb85978b16460138fb4a814c07cfe)
* Refactor code to comply with ESLint rules | [5b1fbe1](https://github.com/undergroundwires/privacy.sexy/commit/5b1fbe1e2fb1354a5f060f8c8e3794ce756e16a7)
* Fix mutated line endings on Windows | [bd23faa](https://github.com/undergroundwires/privacy.sexy/commit/bd23faa28f6d781581a33d5b780f4b33f7e2cd8b)
* Refactor to improve iterations | [31f7091](https://github.com/undergroundwires/privacy.sexy/commit/31f70913a2f30baf5a9d6690f192e6a63da50114)
* win: unrecommend and document Live ID service #100 | [d11a674](https://github.com/undergroundwires/privacy.sexy/commit/d11a674a3c4ad8f4972a870c2f0977ac53297273)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.11.2...0.11.3)
## 0.11.2 (2021-12-03)
* Fix Windows TrustedInstaller session errors | [20a0071](https://github.com/undergroundwires/privacy.sexy/commit/20a0071c0d3d769a8f31218abdbfc4cafa25c6ff)
* Improve tests for `UserSelection` | [2f90cac](https://github.com/undergroundwires/privacy.sexy/commit/2f90cac52ab9e57615aeec41f9daa842bce770a5)
* Fix disabling/enabling Defender on Windows #104 | [2e08293](https://github.com/undergroundwires/privacy.sexy/commit/2e082932c952b0849ab2b8709ff0c75293b88e95)
* Refactor Saas naming, structure and modules | [bf83c58](https://github.com/undergroundwires/privacy.sexy/commit/bf83c58982ffa178facc6d35e50c7f1eac7ff236)
* Fix Defender features errors in Windows #104 | [d7761ab](https://github.com/undergroundwires/privacy.sexy/commit/d7761ab30e7f1e10a2919c196804d67511d6163a)
* Fix unintendedly inlined Windows scripts | [f2d9881](https://github.com/undergroundwires/privacy.sexy/commit/f2d988138257ff184884e4adc83c39e3bc247e9b)
* Fix Defender error due to non-english Windows #104 | [7c02ffb](https://github.com/undergroundwires/privacy.sexy/commit/7c02ffb6c95382b94f0b05e6f259cc418ec91c93)
* Improve and unify disabling of Windows services | [70cdf38](https://github.com/undergroundwires/privacy.sexy/commit/70cdf3865a0de3214fc9e26fbdada4b0cb413c46)
* Improve Windows defender docs and errors #104 | [d2518b1](https://github.com/undergroundwires/privacy.sexy/commit/d2518b11a7774ec58b9b46a691e2f013855bf0f9)
* Unrecommend and complete Windows Push Notif. #101 | [c65209e](https://github.com/undergroundwires/privacy.sexy/commit/c65209e6a99230f15ace8955e8d5a6f3333d146b)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.11.1...0.11.2)
## 0.11.1 (2021-11-04)
* Update dependencies | [64631a4](https://github.com/undergroundwires/privacy.sexy/commit/64631a4552fad7f7b06286aba8d3ca2d731f9342)
* Fix, document, unrecommend Windows browser cleanup | [5ead1a0](https://github.com/undergroundwires/privacy.sexy/commit/5ead1a087d91948890bc4ae6fea176123f18c285)
* Fix failing URL status checking integration tests | [799fb09](https://github.com/undergroundwires/privacy.sexy/commit/799fb091b8eb06c70ac0c67f2ef5385dce73501f)
* Refactor to remove "Async" function name suffix | [82c43ba](https://github.com/undergroundwires/privacy.sexy/commit/82c43ba2e37fb6e7f62ccd9bec8c5f48575f0613)
* Fix dead URLs and use forks as GitHub references | [97ddc02](https://github.com/undergroundwires/privacy.sexy/commit/97ddc027cb5395a74991cabc1d8c875ee945636d)
* Fix website not loading on Safari | [0db8cc4](https://github.com/undergroundwires/privacy.sexy/commit/0db8cc420655e01cbbed57c4658489b761a15899)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.11.0...0.11.1)
## 0.11.0 (2021-10-21)
* Change "grouping" to "view" | [c0c475f](https://github.com/undergroundwires/privacy.sexy/commit/c0c475ff564b23a4dabcc03ac2909207a8eb61ce)
* Tighten parameter substitution tolerance | [dcccb61](https://github.com/undergroundwires/privacy.sexy/commit/dcccb617813625c224a28242c5b965bb4cd6f189)
* Add optionality for parameters | [6a89c62](https://github.com/undergroundwires/privacy.sexy/commit/6a89c6224bdef5eb96980471f3b3935b9351b197)
* Do not collapse cards on links and code area #88 | [e73c0ad](https://github.com/undergroundwires/privacy.sexy/commit/e73c0ad1bf922b1dd3360fc5aafc3434951fa63c)
* Add scripts to disable, hide and opt-out from Siri | [c92dc1e](https://github.com/undergroundwires/privacy.sexy/commit/c92dc1e25387c65a3a41ca64d2a23cf8131b4c86)
* Improve macOS scripts for cleaning OS logs | [6c3c2e6](https://github.com/undergroundwires/privacy.sexy/commit/6c3c2e6709ec84f8e0411f19c024bab2c7e5753b)
* Add "with" expression for templating #53 | [862914b](https://github.com/undergroundwires/privacy.sexy/commit/862914b06ea9ef74c4b58a9a4164a10a38273638)
* Add support for pipes in templates #53 | [4d7ff7e](https://github.com/undergroundwires/privacy.sexy/commit/4d7ff7edc5a96cc0d99d3c1ca4fdf9bbdace3fd2)
* Bump node environment to 15.x | [2f0321f](https://github.com/undergroundwires/privacy.sexy/commit/2f0321f315ac0da8c713dd50e37032f1de194942)
* Add new UX for optionally downloading updates | [ddf417a](https://github.com/undergroundwires/privacy.sexy/commit/ddf417a16a79551b43576befab0541ea08487969)
* Add pipes to write pretty PowerShell #53 | [5217b0b](https://github.com/undergroundwires/privacy.sexy/commit/5217b0b7587ccfe509ba8adc3a7748b9bae14d7a)
* Improve alignment, padding/margin issues on UI | [c8cb7a5](https://github.com/undergroundwires/privacy.sexy/commit/c8cb7a5c28420557319606da82f56b011e88f470)
* Support disabling per-user services in Windows #16 | [4b23907](https://github.com/undergroundwires/privacy.sexy/commit/4b2390736ac1f9de2d5176b7b07da0e827112f9a)
* Add script to remove Meet Now icon in Windows | [f39ee76](https://github.com/undergroundwires/privacy.sexy/commit/f39ee76c0cda95f54502b19d5c49390fd0f12b5e)
* Add support for more depth in function calls | [20b7d28](https://github.com/undergroundwires/privacy.sexy/commit/20b7d283b02dd751dfbde18ef1fe334c6bf76e2b)
* Increase default screen width on desktop app | [9942df1](https://github.com/undergroundwires/privacy.sexy/commit/9942df16c8334ff041fb92f432a3a29e351c88df)
* Improve disabling of SmartScreen #74 | [0696ed8](https://github.com/undergroundwires/privacy.sexy/commit/0696ed8396e298a358bec17adb91c9145dd90418)
* Remove integration tests from deployments #90 | [37ad26a](https://github.com/undergroundwires/privacy.sexy/commit/37ad26a082851c02497c36e7fce40555b9480e11)
* Use a consistent color system | [b08a6b5](https://github.com/undergroundwires/privacy.sexy/commit/b08a6b5cecf4a53023053695292146edbd24b960)
* Add semi-automatic update support for macOS | [410bcd8](https://github.com/undergroundwires/privacy.sexy/commit/410bcd82445097c29c9fcf0eabf7af9ebcb93c1e)
* Add more ways to disable and clean Defender #74 | [2492f2d](https://github.com/undergroundwires/privacy.sexy/commit/2492f2d8141b3abdf590ccad59680b1f50ecb59e)
* Add privacy over security scripts for macOS #83 | [236a0f6](https://github.com/undergroundwires/privacy.sexy/commit/236a0f6c8241294fc397194cd1b20bdeccbbb50b)
* Change PowerShell double quotes escape | [9aa8166](https://github.com/undergroundwires/privacy.sexy/commit/9aa816689146ee6cd86d8262112677c38651c6bd)
* Change theme colors | [a8031d1](https://github.com/undergroundwires/privacy.sexy/commit/a8031d18d520dd3b0567f7b8cfe2dcd694b65073)
* Improve security hardening for macOS | [e6152fa](https://github.com/undergroundwires/privacy.sexy/commit/e6152fa76f5e7d23b0f79d5dd98713daaecbff90)
* Support disabling of protected services #74 | [ab8bce7](https://github.com/undergroundwires/privacy.sexy/commit/ab8bce768650a10677f0a13b3a9fae93c83802ff)
* Fix minor issues with Defender scripts | [739287a](https://github.com/undergroundwires/privacy.sexy/commit/739287ac71b3f8b04348fc101f1fa06f2d7d86a2)
* Update screenshot | [504fa05](https://github.com/undergroundwires/privacy.sexy/commit/504fa056d7d8b17fc20afd398f9a557495fca7e8)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.10.3...0.11.0)
## 0.10.3 (2021-08-27)
* unrecommend VSS and document its breaking behavior | [7714898](https://github.com/undergroundwires/privacy.sexy/commit/77148980e08859f89c15c6604e55b56ce4f74358)
* fix incorrect modification of Desktop folder on ThisPC (#71) | [eb9ac35](https://github.com/undergroundwires/privacy.sexy/commit/eb9ac35a923325cc2c9983ef71c0d904337a58f5)
* add initial integration tests | [49600c5](https://github.com/undergroundwires/privacy.sexy/commit/49600c5f37ca33c1687885fdf02a71ef7d3e6e8c)
* unify usage of sleepAsync and add tests | [36f0805](https://github.com/undergroundwires/privacy.sexy/commit/36f08055909f371fd9cbe3480ea813b963aea22b)
* fix broken URLs and automate broken URL checks #70 | [db62ed7](https://github.com/undergroundwires/privacy.sexy/commit/db62ed7f3ac63e9f2d762eb946060595eb9f5626)
* fix hiding recent files in quick access | [b976b92](https://github.com/undergroundwires/privacy.sexy/commit/b976b920318dba55b32d39f148fdca4f6be3cce3)
* bump dependencies to latest #75, #69 | [0a857aa](https://github.com/undergroundwires/privacy.sexy/commit/0a857aa09ee703d34ad0422bd1731158017a9a58)
* Fix NTP configuration before running the service (#72) | [71e70e5](https://github.com/undergroundwires/privacy.sexy/commit/71e70e50c51249bb10f6203414948b325acc2b2a)
* Fix typo on main page (#82) | [487001a](https://github.com/undergroundwires/privacy.sexy/commit/487001af485fdbb958615d7b52c09c2e386ddaf2)
* Improve issue templates | [f2935e4](https://github.com/undergroundwires/privacy.sexy/commit/f2935e4008f1231ef174f8932290e11715564d20)
* Fix infinitely subscribing to state changes | [ea5f9ec](https://github.com/undergroundwires/privacy.sexy/commit/ea5f9ec27df7cec6ac575e23fef18948d2b8e68a)
* Fix select options being clickable when disabled | [1c6b305](https://github.com/undergroundwires/privacy.sexy/commit/1c6b3057ea6e45125cadf374f20a905712ccdf3c)
* Fix tests for `ParameterSubstitutionParser` | [2a08855](https://github.com/undergroundwires/privacy.sexy/commit/2a08855e5d1bdf74354fd692cbfebd1a48e495ac)
* Fix excessive highlighting on hover | [ec0c972](https://github.com/undergroundwires/privacy.sexy/commit/ec0c972d348ffd5897f115d201031b704875b56a)
* Fix dead URLs | [439cd30](https://github.com/undergroundwires/privacy.sexy/commit/439cd303ff3db96a53664e5f44fefe12b95c5e6c)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.10.2...0.10.3)
## 0.10.2 (2021-04-19) ## 0.10.2 (2021-04-19)
* in CI/CD, run other tests/check even if one of them fails | [5c43965](https://github.com/undergroundwires/privacy.sexy/commit/5c43965f0bc44f991ada7d3bad68937a80665dc3) * in CI/CD, run other tests/check even if one of them fails | [5c43965](https://github.com/undergroundwires/privacy.sexy/commit/5c43965f0bc44f991ada7d3bad68937a80665dc3)

View File

@@ -1,34 +1,51 @@
# Contributing # Contributing
- Love your input! Contributing to this project should be as easy and transparent as possible, whether it's: Love your input! Contributing to this project should be as easy and transparent as possible, whether it's:
- Reporting a bug
- Discussing the current state of the code - reporting a bug,
- Submitting a fix - discussing the current state of the code,
- Proposing new features - submitting a fix,
- Becoming a maintainer - proposing new features,
- or becoming a maintainer.
As a small open source project with small community, it can sometimes take a long time to address the issues so please be patient.
## Pull request process ## Pull request process
- [GitHub flow](https://guides.github.com/introduction/flow/index.html) with [GitOps](./img/architecture/gitops.png) is used Your pull requests are actively welcomed. We collaborate using [GitHub flow](https://docs.github.com/en/get-started/quickstart/github-flow).
- Your pull requests are actively welcomed.
- The steps: The steps:
1. Fork the repo and create your branch from master.
2. If you've added code that should be tested, add tests. 1. Fork the repo and create your branch from master.
3. If you've changed APIs, update the documentation. 2. If you've added code that requires testing, add tests. See [tests.md](./docs/tests.md).
4. Ensure the test suite passes. 3. If you've done a major change, update the documentation. See [docs/](./docs/).
5. Make sure your code lints. 4. Ensure the test suite passes. See [development.md | Testing](./docs/development.md#testing) for commands.
6. Issue that pull request! 5. Make sure your code lints.See [development.md | Linting](./docs/development.md#linting) for commands.
- 🙏 DO 6. Issue that pull request!
- Document your changes in the pull request
- ❗ DON'T **🙏 DO:**
- Do not update the versions, current version is only [set by the maintainer](./img/architecture/gitops.png) and updated automatically by [bump-everywhere](https://github.com/undergroundwires/bump-everywhere)
- Document why (what you're trying to solve) rather than what in the pull request.
**❗ DON'T:**
- Do not update the versions, current version is [set by the maintainer](./docs/ci-cd.md#gitops) and updated automatically by [bump-everywhere](https://github.com/undergroundwires/bump-everywhere).
Automated pipelines will run to control your PR and they will publish your code once the maintainer merges your PR.
📖 You can read more in [ci-cd.md](./docs/ci-cd.md).
## Extend scripts
Here's quick information for you who want to add more scripts.
You have two alternatives:
1. [Create an issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose) and ask for someone else to add the script for you.
2. Or send a PR yourself. This would make it faster to get your code into the project. You need to add scripts to related OS in [collections](src/application/collections/) folder. Then you'd sent a pull request, see [pull request process](#pull-request-process).
- 📖 If you're unsure about the syntax, check [collection-files.md](docs/collection-files.md).
- 📖 If you wish to use templates, use [templating.md](./docs/templating.md).
## License ## License
By contributing, you agree that your contributions will be licensed under its [GNU General Public License v3.0](./LICENSE). By contributing, you agree that your [GNU General Public License v3.0](./LICENSE) will be the license for your contributions.
## Read more
- See [tests](./docs/tests.md) for testing
- See [extend script](./README.md#extend-scripts) for quick steps to extend scripts
- See [architecture overview](./README.md#architecture-overview) to deep dive into privacy.sexy codebase

188
README.md
View File

@@ -2,86 +2,140 @@
> Enforce privacy & security best-practices on Windows and macOS, because privacy is sexy 🍑🍆 > Enforce privacy & security best-practices on Windows and macOS, because privacy is sexy 🍑🍆
[![contributions welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat)](./CONTRIBUTING.md) <!-- markdownlint-disable MD033 -->
[![Language grade: JavaScript](https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18)](https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript) <p align="center">
[![Maintainability](https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability)](https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability) <a href="https://undergroundwires.dev/donate?project=privacy.sexy">
[![Tests status](https://github.com/undergroundwires/privacy.sexy/workflows/Test/badge.svg)](https://github.com/undergroundwires/privacy.sexy/actions) <img
[![Quality checks status](https://github.com/undergroundwires/privacy.sexy/workflows/Quality%20checks/badge.svg)](https://github.com/undergroundwires/privacy.sexy/actions) alt="donation badge"
[![Security checks status](https://github.com/undergroundwires/privacy.sexy/workflows/Security%20checks/badge.svg)](https://github.com/undergroundwires/privacy.sexy/actions) src="https://undergroundwires.dev/img/badges/donate/flat.svg"
[![Bump & release status](https://github.com/undergroundwires/privacy.sexy/workflows/Bump%20&%20release/badge.svg)](https://github.com/undergroundwires/privacy.sexy/actions) />
[![Deploy status](https://github.com/undergroundwires/privacy.sexy/workflows/Build%20&%20deploy/badge.svg)](https://github.com/undergroundwires/privacy.sexy/actions) </a>
[![Auto-versioned by bump-everywhere](https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true)](https://github.com/undergroundwires/bump-everywhere) <a href="https://github.com/undergroundwires/privacy.sexy/blob/master/CONTRIBUTING.md">
<img
alt="contributions are welcome"
src="https://img.shields.io/badge/contributions-welcome-brightgreen.svg?style=flat"
/>
</a>
<!-- Code quality -->
<br />
<a href="https://lgtm.com/projects/g/undergroundwires/privacy.sexy/context:javascript">
<img
alt="Language grade: JavaScript/TypeScript"
src="https://img.shields.io/lgtm/grade/javascript/g/undergroundwires/privacy.sexy.svg?logo=lgtm&logoWidth=18"
/>
</a>
<a href="https://codeclimate.com/github/undergroundwires/privacy.sexy/maintainability">
<img
alt="Maintainability"
src="https://api.codeclimate.com/v1/badges/3a70b7ef602e2264342c/maintainability"
/>
</a>
<!-- Tests -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.unit.yaml">
<img
alt="Unit tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/unit-tests/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.integration.yaml">
<img
alt="Integration tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/integration-tests/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/tests.e2e.yaml">
<img
alt="E2E tests status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/e2e-tests/badge.svg"
/>
</a>
<!-- Checks -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.quality.yaml">
<img
alt="Quality checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/quality-checks/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.security.yaml">
<img
alt="Security checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/security-checks/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/checks.build.yaml">
<img
alt="Build checks status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/build-checks/badge.svg"
/>
</a>
<!-- Release -->
<br />
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.git.yaml">
<img
alt="Git release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-git/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.site.yaml">
<img
alt="Site release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-site/badge.svg"
/>
</a>
<a href="https://github.com/undergroundwires/privacy.sexy/actions/workflows/release.desktop.yaml">
<img
alt="Desktop application release status"
src="https://github.com/undergroundwires/privacy.sexy/workflows/release-desktop/badge.svg"
/>
</a>
<!-- Others -->
<br />
<a href="https://github.com/undergroundwires/bump-everywhere">
<img
alt="Auto-versioned by bump-everywhere"
src="https://github.com/undergroundwires/bump-everywhere/blob/master/badge.svg?raw=true"
/>
</a>
</p>
<!-- markdownlint-restore -->
## Get started ## Get started
- Online version at [https://privacy.sexy](https://privacy.sexy) - 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 💡 No need to run any compiled software on your computer. - 🖥️ **Offline**: Check [releases page](https://github.com/undergroundwires/privacy.sexy/releases), or download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.11.2/privacy.sexy-Setup-0.11.2.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.11.2/privacy.sexy-0.11.2.dmg), [Linux](https://github.com/undergroundwires/pr.vacy.sexy/releases/download/0.11.2/privacy.sexy-0.11.2.AppImage).
- Alternatively download offline version for [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.10.2/privacy.sexy-Setup-0.10.2.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.10.2/privacy.sexy-0.10.2.dmg) or [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.10.2/privacy.sexy-0.10.2.AppImage).
- 💡 Single click to execute your script.
- ❗ Come back regularly to apply latest version for stronger privacy and security.
[![privacy.sexy application](img/screenshot.png?raw=true)](https://privacy.sexy) Online version does not require to run any software on your computer. Offline version has more functions such as running the scripts directly.
## Why 💡 You should apply your configuration from time to time (more than once). It would strengthen your privacy and security control because privacy.sexy and its scripts get better and stronger in every new version.
- Rich tweak pool to harden security & privacy of the OS and other software on it [![privacy.sexy application](img/screenshot.png?raw=true )](https://privacy.sexy)
- Free (both free as in beer and free as in speech)
- No need to run any compiled software that has access to your system, just run the generated scripts
- Have full visibility into what the tweaks do as you enable them
- Ability to revert (undo) applied scripts
- Everything is transparent: both application and its infrastructure are open-source and automated
- Easily extendable
## Extend scripts ## Features
- You can either [create an issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose) - **Rich**: Hundreds of scripts that aims to give you control of your data.
- Or send a PR: - **Free**: Both free as in "beer" and free as in "speech".
1. Fork the repository - **Transparent**. Have full visibility into what the tweaks do as you enable them.
2. Add more scripts in respective script collection in [collections](src/application/collections/) folder. - **Reversible**. Revert if something feels wrong.
- 📖 If you're unsure about the syntax you can refer to the [collection files | documentation](docs/collection-files.md). - **Accessible**. No need to run any compiled software on your computer with web version.
- 🙏 For any new script, please add `revertCode` and `docs` values if possible. - **Open**. What you see as code in this repository is what you get. The application itself, its infrastructure and deployments are open-source and automated thanks to [bump-everywhere](https://github.com/undergroundwires/bump-everywhere).
3. Send a pull request 👌 - **Tested.** A lot of tests. Automated and manual. Community-testing and verification. Stability improvements comes before new features.
- **Extensible**. Effortlessly [extend scripts](./CONTRIBUTING.md#extend-scripts) with a custom designed [templating language](./docs/templating.md).
- **Portable and simple**. Every script is independently executable without cross-dependencies.
## Commands ## Support
- Project setup: `npm install` **Sponsor 💕**. This project is free, and it might not be tempting to donate since you don't have to pay. But your donations will ensure that this project stays alive. A monthly coffee from you would make a difference. Recurring donations allow me to spend more time and resources on this project. Consider sponsoring on [GitHub Sponsors](https://github.com/sponsors/undergroundwires), or you can donate using [other ways such as crypto or a coffee](https://undergroundwires.dev/donate).
- Testing
- Run unit tests: `npm run test:unit`
- Run integration tests: `npm run test:integration`
- Lint: `npm run lint`
- **Desktop app**
- Development: `npm run electron:serve`
- Production: `npm run electron:build` to build an executable
- **Webpage**
- Development: `npm run serve` to compile & hot-reload for development.
- Production: `npm run build` to prepare files for distribution.
- Or run using Docker:
1. Build: `docker build -t undergroundwires/privacy.sexy:0.10.2 .`
2. Run: `docker run -it -p 8080:80 --rm --name privacy.sexy-0.10.2 undergroundwires/privacy.sexy:0.10.2`
## Architecture overview **Star 🤩**. I know that not everyone can afford donating a coffee to show support. In this case, feel free to give it a star ⭐ . It helps me to see that you appreciate the project.
### Application **Contribute 👷**. Contributions of any type are welcome. See [CONTRIBUTING.md](./CONTRIBUTING.md) as the starting point. It includes useful information like [how to add new scripts](./CONTRIBUTING.md#extend-scripts).
- Powered by **TypeScript**, **Vue.js** and **Electron** 💪 ## Development
- and driven by **Domain-driven design**, **Event-driven architecture**, **Data-driven programming** concepts.
- Application uses highly decoupled models & services in different DDD layers.
- 📖 Read more on • [Presentation](./docs/presentation.md) • [Application](./docs/application.md)
![DDD + vue.js](img/architecture/app-ddd.png) Refer to [development.md](./docs/development.md) for Docker usage and reading more about setting up your development environment.
### AWS Infrastructure Check [architecture.md](./docs/architecture.md) for an overview of design and how different parts and layers work together. You can refer to [application.md](./docs/application.md) for a closer look at application layer codebase and [presentation.md](./docs/presentation.md) for code related to GUI layer. [collection-files.md](./docs/collection-files.md) explains the YAML files that are the core of the application and [templating.md](./docs/templating.md) documents how to use templating language in those files. In [ci-cd.md](./docs/ci-cd.md), you can read more about the pipelines that automates maintenance tasks and ensures you get what see.
[![AWS solution](img/architecture/aws-solution.png)](https://github.com/undergroundwires/aws-static-site-with-cd) [docs/](./docs/) folder includes all other documentation.
- It uses infrastructure from the following repository: [aws-static-site-with-cd](https://github.com/undergroundwires/aws-static-site-with-cd)
- Runs on AWS 100% serverless and automatically provisioned using [GitHub Actions](.github/workflows/).
- Maximum security & automation and minimum AWS costs are the highest priorities of the design.
#### GitOps: CI/CD to AWS
- CI/CD is fully automated for this repo using different GIT events & GitHub actions.
- Versioning, tagging, creation of `CHANGELOG.md` and releasing is automated using [bump-everywhere](https://github.com/undergroundwires/bump-everywhere) action
- Everything that's merged in the master goes directly to production.
[![CI/CD to AWS with GitHub Actions](img/architecture/gitops.png)](.github/workflows/)

View File

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

3
cypress.json Normal file
View File

@@ -0,0 +1,3 @@
{
"pluginsFile": "tests/e2e/plugins/index.js"
}

View File

@@ -1,44 +1,45 @@
# Application # Application
- It's mainly responsible for Application layer is mainly responsible for:
- creating and event based [application state](#application-state)
- [parsing](#parsing) and [compiling](#compiling) [application data](#application-data) - creating an event-based and mutable [application state](#application-state),
- Consumed by [presentation layer](./presentation.md) - [parsing and compiling](#parsing-and-compiling) the [application data](#application-data).
📖 Refer to [architecture.md | Layered Application](./architecture.md#layered-application) to read more about the layered architecture.
## Structure ## Structure
- [`/src/` **`application/`**](./../src/application/): Contains all application related code. Application layer code exists in [`/src/application`](./../src/application/) and includes following structure:
- [**`collections/`**](./../src/application/collections/): Holds [collection files](./collection-files.md)
- [**`Common/`**](./../src/application/Common/): Contains common functionality that is shared in application layer. - [**`collections/`**](./../src/application/collections/): Holds [collection files](./collection-files.md).
- `..`: other classes are categorized using folders-by-feature structure - [**`Common/`**](./../src/application/Common/): Contains common functionality in application layer.
- `...`: rest of the application layer source code organized using folders-by-feature structure.
## Application state ## Application state
- [ApplicationContext.ts](./../src/application/Context/ApplicationContext.ts) holds the [CategoryCollectionState](./../src/application/Context/State/CategoryCollectionState.ts) for each OS It uses [state pattern](https://en.wikipedia.org/wiki/State_pattern) with context and state objects. [`ApplicationContext.ts`](./../src/application/Context/ApplicationContext.ts) the "Context" of state pattern provides an instance of [`CategoryCollectionState.ts`](./../src/application/Context/State/CategoryCollectionState.ts) (the "State" of the state pattern) for every supported collection.
- Uses [state pattern](https://en.wikipedia.org/wiki/State_pattern)
- Same instance is shared throughout the application to ensure consistent state Presentation layer uses a singleton (same instance of) [`ApplicationContext.ts`](./../src/application/Context/ApplicationContext.ts) throughout the application to ensure consistent state.
- 📖 See [Application State | Presentation layer](./presentation.md#application-state) to read more about how the state should be managed by the presentation layer.
- 📖 See [ApplicationContext.ts](./../src/application/Context/ApplicationContext.ts) to start diving into the state code. 📖 Refer to [architecture.md | Application State](./architecture.md#application-state) to get an overview of event handling and [presentation.md | Application State](./presentation.md#application-state) for deeper look into how the presentation layer manages state.
## Application data ## Application data
- Compiled to [`Application`](./../src/domain/Application.ts) domain object. Application data is collection files using YAML. You can refer to [collection-files.md](./collection-files.md) to read more about the scheme and structure of application data files. You can also check the source code [collection yaml files](./../src/application/collections/) to directly see the application data using that scheme.
- The scripts are defined and controlled in different data files per OS
- Enables [data-driven programming](https://en.wikipedia.org/wiki/Data-driven_programming) and easier contributions
- Application data is defined in collection files and
- 📖 See [Application data | Presentation layer](./presentation.md#application-data) to read how the application data is read by the presentation layer.
- 📖 See [collection files documentation](./collection-files.md) to read more about how the data files are structured/defined and see [collection yaml files](./../src/application/collections/) to directly check the code.
## Parsing Application layer [parses and compiles](#parsing-and-compiling) application data into [`Application`](./../src/domain/Application.ts)). Once parsed, application layer provides the necessary functionality to presentation layer based on the application data. You can read more about how presentation layer consumes the application data in [presentation.md | Application Data](./presentation.md#application-data).
- Application data is parsed to domain object [`Application.ts`](./../src/domain/Application.ts) Application layer enables [data-driven programming](https://en.wikipedia.org/wiki/Data-driven_programming) by leveraging the data to the rest of the source code. It makes it easy for community to contribute on the project by using a declarative language used in collection files.
- Steps
1. (Compile time) Load application data from [collection yaml files](./../src/application/collections/) using webpack loader
2. (Runtime) Parse and compile application and make it available to presentation layer by [`ApplicationFactory.ts`](./../src/application/ApplicationFactory.ts)
### Compiling ### Parsing and compiling
- Parsing the application files includes compiling scripts using [collection file defined functions](./collection-files.md#function) Application layer parses the application data to compile the domain object [`Application.ts`](./../src/domain/Application.ts).
- To extend the syntax:
1. Add a new parser under [SyntaxParsers](./../src/application/Parser/Script/Compiler/Expressions/SyntaxParsers) where you can look at other parsers to understand more. 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.
2. Register your in [CompositeExpressionParser](./../src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts)
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).
The steps to extend the templating syntax:
1. Add a new parser under [SyntaxParsers](./../src/application/Parser/Script/Compiler/Expressions/SyntaxParsers) where you can look at other parsers to understand more.
2. Register your in [CompositeExpressionParser](./../src/application/Parser/Script/Compiler/Expressions/Parser/CompositeExpressionParser.ts).

66
docs/architecture.md Normal file
View File

@@ -0,0 +1,66 @@
# Architecture overview
This repository consists of:
- A [layered application](#layered-application).
- [AWS infrastructure](#aws-infrastructure) as code and instructions to host the website.
- [GitOps](#gitops) practices for development, maintenance and deployment.
## Layered application
Application is
- powered by **TypeScript**, **Vue.js** and **Electron** 💪,
- and driven by **Domain-driven design**, **Event-driven architecture**, **Data-driven programming** concepts.
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 depends on and consumes domain layer. [Presentation layer](./presentation.md) consumes and depends on application layer along with domain layer. Application and presentation layers can communicate through domain model.
![DDD + vue.js](./../img/architecture/app-ddd.png)
### Application state
State handling uses an event-driven subscription model to signal state changes and special functions to register changes. It does not depend on third party packages.
Each layer treat application layer differently.
![State](./../img/architecture/app-state.png)
*[Presentation layer](./presentation.md)*:
- Each component holds their own state about presentation-related data.
- Components register shared state changes into application state using functions.
- Components listen to shared state changes using event subscriptions.
- 📖 Read more: [presentation.md | Application state](./presentation.md#application-state).
*[Application layer](./application.md)*:
- Stores the application-specific state.
- The state it exposed for read with getter functions and set using setter functions, setter functions also fire application events that allows other parts of application and the view in presentation layer to react.
- So state is mutable, and fires related events when mutated.
- 📖 Read more: [application.md | Application state](./application.md#application-state).
It's comparable with flux ([`redux`](https://redux.js.org/)) or flux-like ([`vuex`](https://vuex.vuejs.org/)) patterns. Flux component "view" is [presentation layer](./presentation.md) in Vue. Flux functions "dispatcher", "store" and "action creation" functions lie in the [application layer](./application.md). A difference is that application state in privacy.sexy is mutable and lies in single flux "store" that holds app state and logic. The "actions" mutate the state directly which in turns act as dispatcher to notify its own event subscriptions (callbacks).
## AWS infrastructure
The web-site runs on serverless AWS infrastructure. Infrastructure is open-source and deployed as code. [aws-static-site-with-cd](https://github.com/undergroundwires/aws-static-site-with-cd) project includes the source code.
[![AWS solution](../img/architecture/aws-solution.png)](https://github.com/undergroundwires/aws-static-site-with-cd)
The design priorities highest security then minimizing cloud infrastructure costs.
This project includes [GitHub Actions](../.github/workflows/) to automatically provision the infrastructure with zero-touch and without any "hidden" steps, ensuring everything is open-source and transparent. Git repositories includes all necessary instructions and automation with [GitOps](#gitops) practices.
## GitOps
CI/CD pipelines automate operational tasks based on different Git events. [bump-everywhere](https://github.com/undergroundwires/bump-everywhere) enables this automation.
📖 Read more in [`ci-cd.md`](./ci-cd.md#gitops).
[![CI/CD using GitHub Actions](../img/architecture/gitops.png)](../.github/workflows/)

45
docs/ci-cd.md Normal file
View File

@@ -0,0 +1,45 @@
# CI/CD overview
## GitOps
CI/CD is fully automated using different Git events and GitHub actions. This repository uses [bump-everywhere](https://github.com/undergroundwires/bump-everywhere) to automate versioning, tagging, creation of `CHANGELOG.md` and GitHub releases. A dedicated workflow [release.desktop.yaml](./../.github/workflows/release.desktop.yaml) creates desktop installers and executables and attaches them into GitHub releases.
Everything that's merged in the master goes directly to production.
[![CI/CD using GitHub Actions](./../img/architecture/gitops.png)](../.github/workflows/)
## Pipeline files
privacy.sexy uses [GitHub actions](https://github.com/features/actions) to define and run pipelines as code.
GitHub workflows i.e. pipelines exist in [`/.github/.workflows/`](./../.github/workflows/) folder without any subfolders due to GitHub actions requirements [1] .
[1]: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#about-yaml-syntax-for-workflows
## Pipeline types
We categorize pipelines into different categories. We use these names in convention when naming files and actions, see [naming conventions](#naming-conventions).
The categories consist of:
- `tests`: Different types of tests to verify functionality.
- `checks`: Other controls such as vulnerability scans or styling checks.
- `release`: Pipelines used for release of deployment such as building and testing.
## Naming conventions
Convention for naming pipeline files: **`<type>.<name>.yaml`**.
**`type`**:
- Sub-folders do not work for GitHub workflows [1] so we use `<type>.` prefix to organize them.
- See also [pipeline types](#pipeline-types) for list of all usable types.
**`name`**:
- We name workflows using kebab-case.
- E.g. file name `tests.unit.yaml`, pipeline file should set the naem as: `name: unit-tests`.
- Kebab-case allows to have better URL references to them.
- [README.md](./../README.md) uses URL references to show status badges for actions.
[1]: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#about-yaml-syntax-for-workflows

View File

@@ -45,9 +45,11 @@
### `Script` ### `Script`
- Script represents a single tweak. - Script represents a single tweak.
- A script must include either: - A script can be of two different types (just like [functions](#function)):
- A `code` and `revertCode` 1. Inline script; a script with an inline code
- Or `call` to call YAML-defined functions - Must define `code` property and optionally `revertCode` but not `call`
2. Caller script; a script that calls other functions
- Must define `call` property but not `code` or `revertCode`
- 🙏 For any new script, please add `revertCode` and `docs` values if possible. - 🙏 For any new script, please add `revertCode` and `docs` values if possible.
#### `Script` syntax #### `Script` syntax
@@ -80,7 +82,7 @@
### `FunctionCall` ### `FunctionCall`
- Describes a single call to a function by optionally providing values to its parameters. - Describes a single call to a function by optionally providing values to its parameters.
- 👀 See [parameter substitution](#parameter-substitution) for an example usage - 👀 See [parameter substitution](./templating.md#parameter-substitution) for an example usage
#### `FunctionCall` syntax #### `FunctionCall` syntax
@@ -98,52 +100,18 @@
appName: Microsoft.WindowsFeedbackHub appName: Microsoft.WindowsFeedbackHub
``` ```
- 💡 [Expressions (templating)](./templating.md#expressions) can be used as parameter value
### `Function` ### `Function`
- Functions allow re-usable code throughout the defined scripts. - Functions allow re-usable code throughout the defined scripts.
- Functions are templates compiled by privacy.sexy and uses special [expressions](#expressions). - Functions are templates compiled by privacy.sexy and uses special expression expressions.
- Functions can call other functions by defining `call` property instead of `code` - A function can be of two different types (just like [scripts](#script)):
- 👀 See [parameter substitution](#parameter-substitution) for an example usage 1. Inline function: a function with an inline code.
- Must define `code` property and optionally `revertCode` but not `call`.
#### Expressions 2. Caller function: a function that calls other functions.
- Must define `call` property but not `code` or `revertCode`.
- Expressions are defined inside mustaches (double brackets, `{{` and `}}`) - 👀 Read more on [Templating](./templating.md) for function expressions and [example usages](./templating.md#parameter-substitution).
##### Parameter substitution
A simple function example
```yaml
function: EchoArgument
parameters: [ 'argument' ]
code: Hello {{ $argument }} !
```
It would print "Hello world" if it's called in a [script](#script) as following:
```yaml
script: Echo script
call:
function: EchoArgument
parameters:
argument: World
```
A function can call other functions such as:
```yaml
-
function: CallerFunction
parameters: [ 'value' ]
call:
function: EchoArgument
parameters:
argument: {{ $value }}
-
function: EchoArgument
parameters: [ 'argument' ]
code: Hello {{ $argument }} !
```
#### `Function` syntax #### `Function` syntax
@@ -152,24 +120,43 @@ A function can call other functions such as:
- Convention is to use camelCase, and be verbs. - Convention is to use camelCase, and be verbs.
- E.g. `uninstallStoreApp` - E.g. `uninstallStoreApp`
- ❗ Function names must be unique - ❗ Function names must be unique
- `parameters`: `[` *`string`* `, ... ]` - `parameters`: `[` ***[`FunctionParameter`](#FunctionParameter)*** `, ... ]`
- Name of the parameters that the function has. - List of parameters that function code refers to.
- Parameter values are provided by a [Script](#script) through a [FunctionCall](#FunctionCall) - ❗ Must be defined to be able use in [`FunctionCall`](#functioncall) or [expressions (templating)](./templating.md#expressions)
- Parameter names must be defined to be used in [expressions](#expressions)
- ❗ Parameter names must be unique
`code`: *`string`* (**required** if `call` is undefined) `code`: *`string`* (**required** if `call` is undefined)
- Batch file commands that will be executed - Batch file commands that will be executed
- 💡 [Expressions (templating)](./templating.md#expressions) can be used in its value
- 💡 If defined, best practice to also define `revertCode` - 💡 If defined, best practice to also define `revertCode`
- ❗ If not defined `call` must be defined - ❗ If not defined `call` must be defined
- `revertCode`: *`string`* - `revertCode`: *`string`*
- Code that'll undo the change done by `code` property. - Code that'll undo the change done by `code` property.
- E.g. let's say `code` sets an environment variable as `setx POWERSHELL_TELEMETRY_OPTOUT 1` - E.g. let's say `code` sets an environment variable as `setx POWERSHELL_TELEMETRY_OPTOUT 1`
- then `revertCode` should be doing `setx POWERSHELL_TELEMETRY_OPTOUT 0` - then `revertCode` should be doing `setx POWERSHELL_TELEMETRY_OPTOUT 0`
- 💡 [Expressions (templating)](./templating.md#expressions) can be used in code
- `call`: ***[`FunctionCall`](#FunctionCall)*** | `[` ***[`FunctionCall`](#FunctionCall)*** `, ... ]` (may be **required**) - `call`: ***[`FunctionCall`](#FunctionCall)*** | `[` ***[`FunctionCall`](#FunctionCall)*** `, ... ]` (may be **required**)
- A shared function or sequence of functions to call (called in order) - A shared function or sequence of functions to call (called in order)
- The parameter values that are sent can use [expressions](#expressions) - The parameter values that are sent can use [expressions (templating)](./templating.md#expressions)
- ❗ If not defined `code` must be defined - ❗ If not defined `code` must be defined
### `FunctionParameter`
- Defines a parameter that function requires optionally or mandatory.
- Its arguments are provided by a [Script](#script) through a [FunctionCall](#FunctionCall).
#### `FunctionParameter` syntax
- `name`: *`string`* (**required**)
- Name of the parameters that the function has.
- Parameter names must be defined to be used in [expressions (templating)](./templating.md#expressions).
- ❗ Parameter names must be unique and include alphanumeric characters only.
- `optional`: *`boolean`* (default: `false`)
- Specifies whether the caller [Script](#script) must provide any value for the parameter.
- If set to `false` i.e. an argument value is not optional then it expects a non-empty value for the variable;
- Otherwise it throws.
- 💡 Set it to `true` if a parameter is used conditionally;
- Or else set it to `false` for verbosity or do not define it as default value is `false` anyway.
- 💡 Can be used in conjunction with [`with` expression](./templating.md#with).
### `ScriptingDefinition` ### `ScriptingDefinition`
- Defines global properties for scripting that's used throughout its parent [Collection](#collection). - Defines global properties for scripting that's used throughout its parent [Collection](#collection).
@@ -180,7 +167,7 @@ A function can call other functions such as:
- 📖 See [ScriptingLanguage.ts](./../src/domain/ScriptingLanguage.ts) enumeration for allowed values. - 📖 See [ScriptingLanguage.ts](./../src/domain/ScriptingLanguage.ts) enumeration for allowed values.
- `startCode:` *`string`* (**required**) - `startCode:` *`string`* (**required**)
- Code that'll be inserted on top of user created script. - Code that'll be inserted on top of user created script.
- Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!` - Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](./templating.md#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!`
- `endCode:` *`string`* (**required**) - `endCode:` *`string`* (**required**)
- Code that'll be inserted at the end of user created script. - Code that'll be inserted at the end of user created script.
- Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!` - Global variables such as `$homepage`, `$version`, `$date` can be used using [parameter substitution](./templating.md#parameter-substitution) code syntax such as `Welcome to {{ $homepage }}!`

53
docs/development.md Normal file
View File

@@ -0,0 +1,53 @@
# Development
Before your commit, a good practice is to:
1. [Run unit tests](#testing)
2. [Lint your code](#linting)
You could run other types of tests as well, but they may take longer time and overkill for your changes. Automated actions executes the tests for a pull request or change in the main branch. See [ci-cd.md](./ci-cd.md) for more information.
## Commands
### Prerequisites
- Install node >15.x.
- Install dependencies using `npm install`.
### Testing
- 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`
📖 Read more about testing in [tests](./tests.md).
### Linting
- Lint all (recommended 💡): `npm run lint`
- Markdown: `npm run lint:md`
- Markdown consistency `npm run lint:md:consistency`
- Markdown relative URLs: `npm run lint:md:relative-urls`
- JavaScript/TypeScript: `npm run lint:eslint`
- Yaml: `npm run lint:yaml`
### Running
- Run in local server: `npm run serve`
- 💡 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`
### Building
- Build web application: `npm run build`
- Build desktop application: `npm run electron:build`
## Recommended extensions
You should use EditorConfig to follow project style.
For Visual Studio Code, [`.vscode/extensions.json`](./../.vscode/extensions.json) includes list of recommended extensions.

View File

@@ -1,47 +1,63 @@
# Presentation layer # Presentation layer
- Consists of Vue.js components and other UI-related code. Presentation layer consists of UI-related code. It uses Vue.js as JavaScript framework and includes Vue.js components. It also includes [Electron](https://www.electronjs.org/) to provide functionality to desktop application.
- Desktop application is created using [Electron](https://www.electronjs.org/).
- Event driven as in components simply listens to events from the state and act accordingly. It's designed event-driven from bottom to top. It listens user events (from top) and state events (from bottom) to update state or the GUI.
📖 Refer to [architecture.md (Layered Application)](./architecture.md#layered-application) to read more about the layered architecture.
## Structure ## Structure
- [`/src/` **`presentation/`**](./../src/presentation/): Contains all presentation related code including Vue and Electron configurations - [`/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. - [**`bootstrapping/`**](./../src/presentation/bootstrapping/): Registers Vue global objects including components and plugins.
- [**`components/`**](./../src/presentation/components/): Contains all Vue components and their helper classes. - [**`components/`**](./../src/presentation/components/): Contains all Vue components and their helper classes.
- [**`Shared/`**](./../src/presentation/components/Shared): Contains Vue components and component helpers that are shared across other components. - [**`Shared/`**](./../src/presentation/components/Shared): Contains Vue components and component helpers that other components share.
- [**`styles/`**](./../src/presentation/styles/): Contains shared styles used throughout different components. - [**`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. - [**`main.ts`**](./../src/presentation/main.ts): Application entry point that mounts and starts Vue application.
- [**`background.ts`**](./../src/presentation/background.ts): Main process of Electron, started as first thing when app starts. - [**`electron/`**](./../src/presentation/electron/): Electron configuration for the desktop application.
- [**`/public/`**](./../public/): Contains static assets that will simply be copied and not go through webpack. - [**`main.ts`**](./../src/presentation/main.ts): Main process of Electron, started as first thing when app starts.
- [**`/vue.config.js`**](./../vue.config.js): Global Vue CLI configurations loaded by `@vue/cli-service` - [**`/public/`**](./../public/): Contains static assets that are directly copied and do not go through webpack.
- [**`/postcss.config.js`**](./../postcss.config.js): PostCSS configurations that are used by Vue CLI internally - [**`/vue.config.js`**](./../vue.config.js): Global Vue CLI configurations loaded by `@vue/cli-service`.
- [**`/babel.config.js`**](./../babel.config.js): Babel configurations for polyfills used by `@vue/cli-plugin-babel` - [**`/postcss.config.js`**](./../postcss.config.js): PostCSS configurations used by Vue CLI internally.
- [**`/babel.config.js`**](./../babel.config.js): Babel configurations for polyfills used by `@vue/cli-plugin-babel`.
## Application data ## Application data
- Components and should use [ApplicationFactory](./../src/application/ApplicationFactory.ts) singleton to reach the application domain. Components (should) use [ApplicationFactory](./../src/application/ApplicationFactory.ts) singleton to reach the application domain to avoid [parsing and compiling](./application.md#parsing-and-compiling) the application again.
- [Application.ts](../src/domain/Application.ts) domain model is the stateless application representation including
- available scripts, collections as defined in [collection files](./collection-files.md) [Application.ts](../src/domain/Application.ts) is an immutable domain model that represents application state. It includes:
- package information as defined in [`package.json`](./../package.json)
- 📖 See [Application data | Application layer](./presentation.md#application-data) where application data is parsed and compiled. - available scripts, collections as defined in [collection files](./collection-files.md),
- package information as defined in [`package.json`](./../package.json).
You can read more about how application layer provides application data to he presentation in [application.md | Application data](./application.md#application-data).
## Application state ## Application state
- Stateful components mutate or/and react to state changes in [ApplicationContext](./../src/application/Context/ApplicationContext.ts). Inheritance of a Vue components marks whether it uses application state . Components that does not handle application state extends `Vue`. Stateful components mutate or/and react to state changes (such as user selection or search queries) in [ApplicationContext](./../src/application/Context/ApplicationContext.ts) extend [`StatefulVue`](./../src/presentation/components/Shared/StatefulVue.ts) class to access the context / state.
- Stateless components that does not handle state extends `Vue`
- Stateful components that depends on the collection state such as user selection, search queries and more extends [`StatefulVue`](./../src/presentation/components/Shared/StatefulVue.ts) [`StatefulVue`](./../src/presentation/components/Shared/StatefulVue.ts) functions include:
- The single source of truth is a singleton of the state created and made available to presentation layer by [`StatefulVue`](./../src/presentation/components/Shared/StatefulVue.ts)
- `StatefulVue` includes abstract `handleCollectionState` that is fired once the component is loaded and also each time [collection](./collection-files.md) is changed. - Creating a singleton of the state and makes it available to presentation layer as single source of truth.
- Do not forget to subscribe from events when component is destroyed or if needed [collection](./collection-files.md) is changed. - Providing virtual abstract `handleCollectionState` callback that it calls when
- 💡 `events` in base class [`StatefulVue`](./../src/presentation/components/Shared/StatefulVue.ts) makes lifecycling easier - the Vue loads the component,
- 📖 See [Application state | Application layer](./presentation.md#application-state) where the state is implemented using using state pattern. - and also every time when state changes.
- Providing `events` member to make lifecycling of state subscriptions events easier because it ensures that components unsubscribe from listening to state events when
- the component is no longer used (destroyed),
- an if [ApplicationContext](./../src/application/Context/ApplicationContext.ts) changes the active [collection](./collection-files.md) to a different one.
📖 Refer to [architecture.md | Application State](./architecture.md#application-state) to get an overview of event handling and [application.md | Application State](./presentation.md#application-state) for deeper look into how the application layer manages state.
## Modals ## Modals
- [Dialog.vue](./../src/presentation/components/Shared/Dialog.vue) is a shared component that can be used to show modal windows [Dialog.vue](./../src/presentation/components/Shared/Dialog.vue) is a shared component that other components used to show modal windows.
- Simply wrap the content inside of its slot and call `.show()` method on its reference.
- Example: You can use it by wrapping the content inside of its `slot` and call `.show()` function on its reference. For example:
```html ```html
<Dialog ref="testDialog"> <Dialog ref="testDialog">
@@ -49,3 +65,18 @@
</Dialog> </Dialog>
<div @click="$refs.testDialog.show()">Show dialog</div> <div @click="$refs.testDialog.show()">Show dialog</div>
``` ```
## Sass naming convention
- Use lowercase for variables/functions/mixins, e.g.:
- Variable: `$variable: value;`
- Function: `@function function() {}`
- Mixin: `@mixin mixin() {}`
- Use - for a phrase/compound word, e.g.:
- Variable: `$some-variable: value;`
- Function: `@function some-function() {}`
- Mixin: `@mixin some-mixin() {}`
- Grouping and name variables from generic to specific, e.g.:
- ✅ `$border-blue`, `$border-blue-light`, `$border-blue-lightest`, `$border-red`
- ❌ `$blue-border`, `$light-blue-border`, `$lightest-blue-border`, `$red-border`

91
docs/templating.md Normal file
View File

@@ -0,0 +1,91 @@
# Templating
## Benefits of templating
- Generating scripts by sharing code to increase best-practice usage and maintainability.
- Creating self-contained scripts without cross-dependencies.
- Use of pipes for writing cleaner code and letting pipes do dirty work.
## Expressions
- Expressions start and end with mustaches (double brackets, `{{` and `}}`).
- E.g. `Hello {{ $name }} !`
- Syntax is close to [Go Templates ❤️](https://pkg.go.dev/text/template) that has inspired this templating language.
- Functions enables usage of expressions.
- In script definition parts of a function, see [`Function`](./collection-files.md#Function).
- When doing a call as argument values, see [`FunctionCall`](./collection-files.md#Function).
### Parameter substitution
A simple function example:
```yaml
function: EchoArgument
parameters:
- name: 'argument'
code: Hello {{ $argument }} !
```
It would print "Hello world" if it's called in a [script](./collection-files.md#script) as following:
```yaml
script: Echo script
call:
function: EchoArgument
parameters:
argument: World
```
A function can call other functions such as:
```yaml
-
function: CallerFunction
parameters:
- name: 'value'
call:
function: EchoArgument
parameters:
argument: {{ $value }}
-
function: EchoArgument
parameters:
- name: 'argument'
code: Hello {{ $argument }} !
```
### with
Skips its "block" if the variable is absent or empty. Its "block" is between `with` start (`{{ with .. }}`) and end (`{{ end }`}) expressions. E.g. `{{ with $parameterName }} Hi, I'm a block! {{ end }}`.
Binds its context (`.`) value of provided argument for the parameter if provided one. E.g. `{{ with $parameterName }} Parameter value is {{ . }} here {{ end }}`.
💡 Declare parameters used for `with` condition as optional. Set `optional: true` for the argument if you use it like `{{ with $argument }} .. {{ end }}`.
Example:
```yaml
function: FunctionThatOutputsConditionally
parameters:
- name: 'argument'
optional: true
code: |-
{{ with $argument }}
Value is: {{ . }}
{{ end }}
```
### Pipes
- Pipes are functions available for handling text.
- Allows stacking actions one after another also known as "chaining".
- Like [Unix pipelines](https://en.wikipedia.org/wiki/Pipeline_(Unix)), the concept is simple: each pipeline's output becomes the input of the following pipe.
- You cannot create pipes. [A dedicated compiler](./application.md#parsing-and-compiling) provides pre-defined pipes to consume in collection files.
- You can combine pipes with other expressions such as [parameter substitution](#parameter-substitution) and [with](#with) syntax.
- ❗ Pipe names must be camelCase without any space or special characters.
- **Existing pipes**
- `inlinePowerShell`: Converts a multi-lined PowerShell script to a single line.
- `escapeDoubleQuotes`: Escapes `"` characters, allows you to use them inside double quotes (`"`).
- **Example usages**
- `{{ with $code }} echo "{{ . | inlinePowerShell }}" {{ end }}`
- `{{ with $code }} echo "{{ . | inlinePowerShell | escapeDoubleQuotes }}" {{ end }}`

View File

@@ -1,43 +1,81 @@
# Tests # Tests
- There are two different types of tests executed: There are different types of tests executed:
1. [Unit tests](#unit-tests)
2. [Integration tests](#integration-tests) 1. [Unit tests](#unit-tests)
- 💡 You can use path/module alias `@/tests` in import statements. 2. [Integration tests](#integration-tests)
3. [End-to-end (E2E) tests](#e2e-tests)
Common aspects for all 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
- Tests each component in isolation - Unit tests test each component in isolation.
- Defined in [`./tests/unit`](./../tests/unit) - All unit tests goes under [`./tests/unit`](./../tests/unit).
- They follow same folder structure as [`./src`](./../src) - They rely on [stubs](./../tests/unit/shared/Stubs) for isolation.
### Naming ### Unit tests structure
- Each test suite first describe the system under test - [`./src/`](./../src/)
- E.g. tests for class `Application` is categorized under `Application` - Includes source code that unit tests will test.
- Tests for specific methods are categorized under method name (if applicable) - [`./tests/unit/`](./../tests/unit/)
- E.g. test for `run()` is categorized under `run` - Includes test code.
- Tests follow same folder structure as [`./src/`](./../src).
- E.g. if system under test lies in [`./src/application/ApplicationFactory.ts`](./../src/application/ApplicationFactory.ts) then its tests would be in test would be at [`./tests/unit/application/ApplicationFactory.spec.ts`](./../tests/unit/application/ApplicationFactory.spec.ts).
- [`shared/`](./../tests/unit/shared/)
- Includes common functionality that's shared across unit tests.
- [`Assertions/`](./../tests/unit/shared/Assertions):
- Common assertions that extend [Chai Assertion Library](https://www.chaijs.com/).
- Asserting functions should start with `expect` prefix.
- [`TestCases/`](./../tests/unit/shared/TestCases/)
- Shared test cases.
- Functions that calls `it()` from [Mocha test framework](https://mochajs.org/) should have `it` prefix.
- E.g. `itEachAbsentCollectionValue()`.
- [`Stubs/`](./../tests/unit/shared/Stubs)
- Includes stubs to be able to test components in isolation.
- Stubs have minimal and dummy behavior to be functional, they may also have spying or mocking functions.
### Unit tests naming
- Each test suite first describe the system under test.
- E.g. tests for class `Application.ts` are all inside `Application.spec.ts`.
- `describe` blocks tests for same function (if applicable).
- E.g. test for `run()` are inside `describe('run', () => ..)`.
### Act, arrange, assert ### Act, arrange, assert
- Tests use act, arrange and assert (AAA) pattern when applicable - Tests use act, arrange and assert (AAA) pattern when applicable.
- **Arrange** - **Arrange**
- Should set up the test case - Sets up the test case.
- Starts with comment line `// arrange` - Starts with comment line `// arrange`.
- **Act** - **Act**
- Should cover the main thing to be tested - Executes the actual test.
- Starts with comment line `// act` - Starts with comment line `// act`.
- **Assert** - **Assert**
- Should elicit some sort of response - Elicit some sort of expectation.
- Starts with comment line `// assert` - Starts with comment line `// assert`.
### Stubs
- Stubs are defined in [`./tests/stubs`](./../tests/unit/stubs)
- They implement dummy behavior to be functional
## Integration tests ## Integration tests
- Tests functionality of a component in combination with others (not isolated) - Tests functionality of a component in combination with others (not isolated).
- Ensure dependencies to third parties work as expected - Ensure dependencies to third parties work as expected.
- Defined in [`./tests/integration`](./../tests/integration) - Defined in [./tests/integration](./../tests/integration).
## 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.json`](./../cypress.json): 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.

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 579 KiB

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 89 KiB

After

Width:  |  Height:  |  Size: 98 KiB

43460
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "privacy.sexy", "name": "privacy.sexy",
"version": "0.10.2", "version": "0.11.3",
"private": true, "private": true,
"description": "Enforce privacy & security best-practices on Windows and macOS, because privacy is sexy 🍑🍆", "description": "Enforce privacy & security best-practices on Windows and macOS, because privacy is sexy 🍑🍆",
"author": "undergroundwires", "author": "undergroundwires",
@@ -8,67 +8,86 @@
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"test:unit": "vue-cli-service test:unit", "test:unit": "vue-cli-service test:unit",
"test:integration": "vue-cli-service test:unit \"tests/integration/**/*.spec.ts\"", "test:e2e": "vue-cli-service test:e2e",
"lint": "npm run lint:vue && npm run lint:yaml && npm run lint:md && npm run lint:md:relative-urls && npm run lint:md:consistency", "lint": "npm run lint:md && npm run lint:md:consistency && npm run lint:md:relative-urls && npm run lint:eslint && npm run lint:yaml",
"electron:build": "vue-cli-service electron:build", "electron:build": "vue-cli-service electron:build",
"electron:serve": "vue-cli-service electron:serve", "electron:serve": "vue-cli-service electron:serve",
"lint:md": "markdownlint **/*.md --ignore node_modules", "lint:md": "markdownlint **/*.md --ignore node_modules",
"lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent", "lint:md:consistency": "remark . --frail --use remark-preset-lint-consistent",
"lint:md:relative-urls": "remark . --frail --use remark-validate-links", "lint:md:relative-urls": "remark . --frail --use remark-validate-links",
"lint:vue": "vue-cli-service lint --no-fix", "lint:eslint": "vue-cli-service lint --no-fix --mode production",
"lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml", "lint:yaml": "yamllint **/*.yaml --ignore=node_modules/**/*.yaml",
"postinstall": "electron-builder install-app-deps", "postinstall": "electron-builder install-app-deps",
"postuninstall": "electron-builder install-app-deps" "postuninstall": "electron-builder install-app-deps",
"test:integration": "vue-cli-service test:unit \"tests/integration/**/*.spec.ts\""
}, },
"main": "background.js", "main": "background.js",
"dependencies": { "dependencies": {
"@fortawesome/fontawesome-svg-core": "^1.2.35", "@fortawesome/fontawesome-svg-core": "^1.2.36",
"@fortawesome/free-brands-svg-icons": "^5.15.3", "@fortawesome/free-brands-svg-icons": "^5.15.4",
"@fortawesome/free-regular-svg-icons": "^5.15.3", "@fortawesome/free-regular-svg-icons": "^5.15.4",
"@fortawesome/free-solid-svg-icons": "^5.15.3", "@fortawesome/free-solid-svg-icons": "^5.15.4",
"@fortawesome/vue-fontawesome": "^2.0.2", "@fortawesome/vue-fontawesome": "^2.0.6",
"@juggle/resize-observer": "^3.3.1", "@juggle/resize-observer": "^3.3.1",
"ace-builds": "^1.4.12", "ace-builds": "^1.4.13",
"core-js": "^3.12.1", "core-js": "^3.18.3",
"cross-fetch": "^3.1.4", "cross-fetch": "^3.1.4",
"electron-progressbar": "^2.0.1",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
"inversify": "^5.1.1", "install": "^0.13.0",
"liquor-tree": "^0.2.70", "liquor-tree": "^0.2.70",
"npm": "^8.1.1",
"v-tooltip": "2.1.3", "v-tooltip": "2.1.3",
"vue": "^2.6.12", "vue": "^2.6.14",
"vue-class-component": "^7.2.6", "vue-class-component": "^7.2.6",
"vue-js-modal": "^2.0.0-rc.6", "vue-js-modal": "^2.0.1",
"vue-property-decorator": "^9.1.2" "vue-property-decorator": "^9.1.2"
}, },
"devDependencies": { "devDependencies": {
"@types/ace": "0.0.45", "@types/ace": "0.0.47",
"@types/chai": "^4.2.18", "@types/chai": "^4.2.22",
"@types/file-saver": "^2.0.2", "@types/file-saver": "^2.0.3",
"@types/mocha": "^8.2.2", "@types/mocha": "^9.0.0",
"@vue/cli-plugin-babel": "^4.5.13", "@typescript-eslint/eslint-plugin": "^5.4.0",
"@vue/cli-plugin-typescript": "^4.5.13", "@typescript-eslint/parser": "^5.4.0",
"@vue/cli-plugin-unit-mocha": "^4.5.13", "@vue/cli-plugin-babel": "~5.0.0-rc.1",
"@vue/cli-service": "^4.5.13", "@vue/cli-plugin-e2e-cypress": "~5.0.0-rc.1",
"@vue/test-utils": "1.2.0", "@vue/cli-plugin-eslint": "~5.0.0-rc.1",
"@vue/cli-plugin-typescript": "~5.0.0-rc.1",
"@vue/cli-plugin-unit-mocha": "~5.0.0-rc.1",
"@vue/cli-service": "~5.0.0-rc.1",
"@vue/eslint-config-airbnb": "^6.0.0",
"@vue/eslint-config-typescript": "^9.1.0",
"@vue/test-utils": "1.2.2",
"chai": "^4.3.4", "chai": "^4.3.4",
"electron": "^12.0.7", "cypress": "^8.3.0",
"electron": "^15.3.0",
"electron-builder": "^22.14.13",
"electron-devtools-installer": "^3.2.0", "electron-devtools-installer": "^3.2.0",
"electron-log": "^4.3.5", "electron-log": "^4.4.1",
"electron-updater": "^4.3.8", "electron-updater": "^4.3.9",
"eslint": "^7.32.0",
"eslint-plugin-import": "^2.25.3",
"eslint-plugin-vue": "^8.0.3",
"eslint-plugin-vuejs-accessibility": "^1.1.0",
"js-yaml-loader": "^1.2.2", "js-yaml-loader": "^1.2.2",
"markdownlint-cli": "^0.27.1", "markdownlint-cli": "^0.29.0",
"remark-cli": "^9.0.0", "remark-cli": "^10.0.0",
"remark-lint-no-dead-urls": "^1.1.0", "remark-lint-no-dead-urls": "^1.1.0",
"remark-preset-lint-consistent": "^4.0.0", "remark-preset-lint-consistent": "^5.1.0",
"remark-validate-links": "^10.0.4", "remark-validate-links": "^11.0.1",
"sass": "^1.32.12", "sass": "^1.43.3",
"sass-loader": "^10.0.1", "sass-loader": "10.2.0",
"tslib": "^2.2.0", "ts-loader": "9.0.1",
"typescript": "^4.2.4", "tslib": "^2.3.1",
"vue-cli-plugin-electron-builder": "^2.0.0-rc.6", "typescript": "^4.4.4",
"vue-template-compiler": "^2.6.12", "vue-cli-plugin-electron-builder": "^2.1.1",
"vue-template-compiler": "^2.6.14",
"yaml-lint": "^1.2.4" "yaml-lint": "^1.2.4"
}, },
"//devDependencies": {
"ts-loader": "Here as workaround for vue-cli-plugin-electron-builder using older webpack 4"
},
"homepage": "https://privacy.sexy", "homepage": "https://privacy.sexy",
"repository": { "repository": {
"type": "git", "type": "git",

View File

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

View File

@@ -3,19 +3,22 @@ import { AsyncLazy } from '@/infrastructure/Threading/AsyncLazy';
import { IApplicationFactory } from './IApplicationFactory'; import { IApplicationFactory } from './IApplicationFactory';
import { parseApplication } from './Parser/ApplicationParser'; import { parseApplication } from './Parser/ApplicationParser';
export type ApplicationGetter = () => IApplication; export type ApplicationGetterType = () => IApplication;
const ApplicationGetter: ApplicationGetter = parseApplication; const ApplicationGetter: ApplicationGetterType = parseApplication;
export class ApplicationFactory implements IApplicationFactory { export class ApplicationFactory implements IApplicationFactory {
public static readonly Current: IApplicationFactory = new ApplicationFactory(ApplicationGetter); public static readonly Current: IApplicationFactory = new ApplicationFactory(ApplicationGetter);
private readonly getter: AsyncLazy<IApplication>;
protected constructor(costlyGetter: ApplicationGetter) { private readonly getter: AsyncLazy<IApplication>;
if (!costlyGetter) {
throw new Error('undefined getter'); protected constructor(costlyGetter: ApplicationGetterType) {
} if (!costlyGetter) {
this.getter = new AsyncLazy<IApplication>(() => Promise.resolve(costlyGetter())); throw new Error('missing getter');
}
public getAppAsync(): Promise<IApplication> {
return this.getter.getValueAsync();
} }
this.getter = new AsyncLazy<IApplication>(() => Promise.resolve(costlyGetter()));
}
public getApp(): Promise<IApplication> {
return this.getter.getValue();
}
} }

View File

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

View File

@@ -1,54 +1,63 @@
// Because we cannot do "T extends enum" 😞 https://github.com/microsoft/TypeScript/issues/30611 // Because we cannot do "T extends enum" 😞 https://github.com/microsoft/TypeScript/issues/30611
export type EnumType = number | string; export type EnumType = number | string;
export type EnumVariable<T extends EnumType, TEnumValue extends EnumType> = { [key in T]: TEnumValue }; export type EnumVariable<T extends EnumType, TEnumValue extends EnumType>
= { [key in T]: TEnumValue };
export interface IEnumParser<TEnum> { export interface IEnumParser<TEnum> {
parseEnum(value: string, propertyName: string): TEnum; parseEnum(value: string, propertyName: string): TEnum;
}
export function createEnumParser<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>): IEnumParser<TEnumValue> {
return {
parseEnum: (value, propertyName) => parseEnumValue(value, propertyName, enumVariable),
};
}
function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
value: string,
enumName: string,
enumVariable: EnumVariable<T, TEnumValue>): TEnumValue {
if (!value) {
throw new Error(`undefined ${enumName}`);
}
if (typeof value !== 'string') {
throw new Error(`unexpected type of ${enumName}: "${typeof value}"`);
}
const casedValue = getEnumNames(enumVariable)
.find((enumValue) => enumValue.toLowerCase() === value.toLowerCase());
if (!casedValue) {
throw new Error(`unknown ${enumName}: "${value}"`);
}
return enumVariable[casedValue as keyof typeof enumVariable];
} }
export function getEnumNames<T extends EnumType, TEnumValue extends EnumType>( export function createEnumParser<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>): string[] { enumVariable: EnumVariable<T, TEnumValue>,
return Object ): IEnumParser<TEnumValue> {
.values(enumVariable) return {
.filter((enumMember) => typeof enumMember === 'string') as string[]; parseEnum: (value, propertyName) => parseEnumValue(value, propertyName, enumVariable),
};
}
function parseEnumValue<T extends EnumType, TEnumValue extends EnumType>(
value: string,
enumName: string,
enumVariable: EnumVariable<T, TEnumValue>,
): TEnumValue {
if (!value) {
throw new Error(`missing ${enumName}`);
}
if (typeof value !== 'string') {
throw new Error(`unexpected type of ${enumName}: "${typeof value}"`);
}
const casedValue = getEnumNames(enumVariable)
.find((enumValue) => enumValue.toLowerCase() === value.toLowerCase());
if (!casedValue) {
throw new Error(`unknown ${enumName}: "${value}"`);
}
return enumVariable[casedValue as keyof typeof enumVariable];
}
export function getEnumNames
<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>,
): string[] {
return Object
.values(enumVariable)
.filter((enumMember) => typeof enumMember === 'string') as string[];
} }
export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>( export function getEnumValues<T extends EnumType, TEnumValue extends EnumType>(
enumVariable: EnumVariable<T, TEnumValue>): TEnumValue[] { enumVariable: EnumVariable<T, TEnumValue>,
return getEnumNames(enumVariable) ): TEnumValue[] {
.map((level) => enumVariable[level]) as TEnumValue[]; return getEnumNames(enumVariable)
.map((level) => enumVariable[level]) as TEnumValue[];
} }
export function assertInRange<T extends EnumType, TEnumValue extends EnumType>( export function assertInRange<T extends EnumType, TEnumValue extends EnumType>(
value: TEnumValue, value: TEnumValue,
enumVariable: EnumVariable<T, TEnumValue>) { enumVariable: EnumVariable<T, TEnumValue>,
if (value === undefined) { ) {
throw new Error('undefined enum value'); if (value === undefined || value === null) {
} throw new Error('absent enum value');
if (!(value in enumVariable)) { }
throw new RangeError(`enum value "${value}" is out of range`); if (!(value in enumVariable)) {
} throw new RangeError(`enum value "${value}" is out of range`);
}
} }

View File

@@ -1,5 +1,5 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
export interface IScriptingLanguageFactory<T> { export interface IScriptingLanguageFactory<T> {
create(language: ScriptingLanguage): T; create(language: ScriptingLanguage): T;
} }

View File

@@ -1,31 +1,30 @@
import { ScriptingLanguage } from '@/domain/ScriptingLanguage'; import { ScriptingLanguage } from '@/domain/ScriptingLanguage';
import { IScriptingLanguageFactory } from './IScriptingLanguageFactory';
import { assertInRange } from '@/application/Common/Enum'; import { assertInRange } from '@/application/Common/Enum';
import { IScriptingLanguageFactory } from './IScriptingLanguageFactory';
type Getter<T> = () => T; type Getter<T> = () => T;
export abstract class ScriptingLanguageFactory<T> implements IScriptingLanguageFactory<T> { export abstract class ScriptingLanguageFactory<T> implements IScriptingLanguageFactory<T> {
private readonly getters = new Map<ScriptingLanguage, Getter<T>>(); private readonly getters = new Map<ScriptingLanguage, Getter<T>>();
public create(language: ScriptingLanguage): T { public create(language: ScriptingLanguage): T {
assertInRange(language, ScriptingLanguage); assertInRange(language, ScriptingLanguage);
if (!this.getters.has(language)) { if (!this.getters.has(language)) {
throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`); throw new RangeError(`unknown language: "${ScriptingLanguage[language]}"`);
}
const getter = this.getters.get(language);
const instance = getter();
return instance;
} }
const getter = this.getters.get(language);
const instance = getter();
return instance;
}
protected registerGetter(language: ScriptingLanguage, getter: Getter<T>) { protected registerGetter(language: ScriptingLanguage, getter: Getter<T>) {
assertInRange(language, ScriptingLanguage); assertInRange(language, ScriptingLanguage);
if (!getter) { if (!getter) {
throw new Error('undefined getter'); throw new Error('missing getter');
}
if (this.getters.has(language)) {
throw new Error(`${ScriptingLanguage[language]} is already registered`);
}
this.getters.set(language, getter);
} }
if (this.getters.has(language)) {
throw new Error(`${ScriptingLanguage[language]} is already registered`);
}
this.getters.set(language, getter);
}
} }

View File

@@ -1,60 +1,64 @@
import { IApplicationContext, IApplicationContextChangedEvent } from './IApplicationContext';
import { ICategoryCollectionState } from './State/ICategoryCollectionState';
import { CategoryCollectionState } from './State/CategoryCollectionState';
import { IApplication } from '@/domain/IApplication'; import { IApplication } from '@/domain/IApplication';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { EventSource } from '@/infrastructure/Events/EventSource'; import { EventSource } from '@/infrastructure/Events/EventSource';
import { assertInRange } from '@/application/Common/Enum'; import { assertInRange } from '@/application/Common/Enum';
import { CategoryCollectionState } from './State/CategoryCollectionState';
import { ICategoryCollectionState } from './State/ICategoryCollectionState';
import { IApplicationContext, IApplicationContextChangedEvent } from './IApplicationContext';
type StateMachine = Map<OperatingSystem, ICategoryCollectionState>; type StateMachine = Map<OperatingSystem, ICategoryCollectionState>;
export class ApplicationContext implements IApplicationContext { export class ApplicationContext implements IApplicationContext {
public readonly contextChanged = new EventSource<IApplicationContextChangedEvent>(); public readonly contextChanged = new EventSource<IApplicationContextChangedEvent>();
public collection: ICategoryCollection;
public currentOs: OperatingSystem;
public get state(): ICategoryCollectionState { public collection: ICategoryCollection;
return this.states[this.collection.os];
}
private readonly states: StateMachine; public currentOs: OperatingSystem;
public constructor(
public readonly app: IApplication,
initialContext: OperatingSystem) {
validateApp(app);
assertInRange(initialContext, OperatingSystem);
this.states = initializeStates(app);
this.changeContext(initialContext);
}
public changeContext(os: OperatingSystem): void { public get state(): ICategoryCollectionState {
if (this.currentOs === os) { return this.states[this.collection.os];
return; }
}
this.collection = this.app.getCollection(os); private readonly states: StateMachine;
if (!this.collection) {
throw new Error(`os "${OperatingSystem[os]}" is not defined in application`); public constructor(
} public readonly app: IApplication,
const event: IApplicationContextChangedEvent = { initialContext: OperatingSystem,
newState: this.states[os], ) {
oldState: this.states[this.currentOs], validateApp(app);
}; this.states = initializeStates(app);
this.contextChanged.notify(event); this.changeContext(initialContext);
this.currentOs = os; }
public changeContext(os: OperatingSystem): void {
assertInRange(os, OperatingSystem);
if (this.currentOs === os) {
return;
} }
this.collection = this.app.getCollection(os);
if (!this.collection) {
throw new Error(`os "${OperatingSystem[os]}" is not defined in application`);
}
const event: IApplicationContextChangedEvent = {
newState: this.states[os],
oldState: this.states[this.currentOs],
};
this.contextChanged.notify(event);
this.currentOs = os;
}
} }
function validateApp(app: IApplication) { function validateApp(app: IApplication) {
if (!app) { if (!app) {
throw new Error('undefined app'); throw new Error('missing app');
} }
} }
function initializeStates(app: IApplication): StateMachine { function initializeStates(app: IApplication): StateMachine {
const machine = new Map<OperatingSystem, ICategoryCollectionState>(); const machine = new Map<OperatingSystem, ICategoryCollectionState>();
for (const collection of app.collections) { for (const collection of app.collections) {
machine[collection.os] = new CategoryCollectionState(collection); machine[collection.os] = new CategoryCollectionState(collection);
} }
return machine; return machine;
} }

View File

@@ -1,31 +1,32 @@
import { ApplicationContext } from './ApplicationContext';
import { IApplicationContext } from '@/application/Context/IApplicationContext'; import { IApplicationContext } from '@/application/Context/IApplicationContext';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { Environment } from '../Environment/Environment';
import { IApplication } from '@/domain/IApplication'; import { IApplication } from '@/domain/IApplication';
import { Environment } from '../Environment/Environment';
import { IEnvironment } from '../Environment/IEnvironment'; import { IEnvironment } from '../Environment/IEnvironment';
import { IApplicationFactory } from '../IApplicationFactory'; import { IApplicationFactory } from '../IApplicationFactory';
import { ApplicationFactory } from '../ApplicationFactory'; import { ApplicationFactory } from '../ApplicationFactory';
import { ApplicationContext } from './ApplicationContext';
export async function buildContextAsync( export async function buildContext(
factory: IApplicationFactory = ApplicationFactory.Current, factory: IApplicationFactory = ApplicationFactory.Current,
environment = Environment.CurrentEnvironment): Promise<IApplicationContext> { environment = Environment.CurrentEnvironment,
if (!factory) { throw new Error('undefined factory'); } ): Promise<IApplicationContext> {
if (!environment) { throw new Error('undefined environment'); } if (!factory) { throw new Error('missing factory'); }
const app = await factory.getAppAsync(); if (!environment) { throw new Error('missing environment'); }
const os = getInitialOs(app, environment); const app = await factory.getApp();
return new ApplicationContext(app, os); const os = getInitialOs(app, environment);
return new ApplicationContext(app, os);
} }
function getInitialOs(app: IApplication, environment: IEnvironment): OperatingSystem { function getInitialOs(app: IApplication, environment: IEnvironment): OperatingSystem {
const currentOs = environment.os; const currentOs = environment.os;
const supportedOsList = app.getSupportedOsList(); const supportedOsList = app.getSupportedOsList();
if (supportedOsList.includes(currentOs)) { if (supportedOsList.includes(currentOs)) {
return currentOs; return currentOs;
} }
supportedOsList.sort((os1, os2) => { supportedOsList.sort((os1, os2) => {
const getPriority = (os: OperatingSystem) => app.getCollection(os).totalScripts; const getPriority = (os: OperatingSystem) => app.getCollection(os).totalScripts;
return getPriority(os2) - getPriority(os1); return getPriority(os2) - getPriority(os1);
}); });
return supportedOsList[0]; return supportedOsList[0];
} }

View File

@@ -1,16 +1,20 @@
import { ICategoryCollectionState } from './State/ICategoryCollectionState';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { IEventSource } from '@/infrastructure/Events/IEventSource'; import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { IApplication } from '@/domain/IApplication'; import { IApplication } from '@/domain/IApplication';
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from './State/ICategoryCollectionState';
export interface IApplicationContext { export interface IReadOnlyApplicationContext {
readonly app: IApplication; readonly app: IApplication;
readonly state: ICategoryCollectionState; readonly state: IReadOnlyCategoryCollectionState;
readonly contextChanged: IEventSource<IApplicationContextChangedEvent>; readonly contextChanged: IEventSource<IApplicationContextChangedEvent>;
changeContext(os: OperatingSystem): void; }
export interface IApplicationContext extends IReadOnlyApplicationContext {
readonly state: ICategoryCollectionState;
changeContext(os: OperatingSystem): void;
} }
export interface IApplicationContextChangedEvent { export interface IApplicationContextChangedEvent {
readonly newState: ICategoryCollectionState; readonly newState: ICategoryCollectionState;
readonly oldState: ICategoryCollectionState; readonly oldState: ICategoryCollectionState;
} }

View File

@@ -1,3 +1,5 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { UserFilter } from './Filter/UserFilter'; import { UserFilter } from './Filter/UserFilter';
import { IUserFilter } from './Filter/IUserFilter'; import { IUserFilter } from './Filter/IUserFilter';
import { ApplicationCode } from './Code/ApplicationCode'; import { ApplicationCode } from './Code/ApplicationCode';
@@ -5,19 +7,20 @@ import { UserSelection } from './Selection/UserSelection';
import { IUserSelection } from './Selection/IUserSelection'; import { IUserSelection } from './Selection/IUserSelection';
import { ICategoryCollectionState } from './ICategoryCollectionState'; import { ICategoryCollectionState } from './ICategoryCollectionState';
import { IApplicationCode } from './Code/IApplicationCode'; import { IApplicationCode } from './Code/IApplicationCode';
import { ICategoryCollection } from '../../../domain/ICategoryCollection';
import { OperatingSystem } from '@/domain/OperatingSystem';
export class CategoryCollectionState implements ICategoryCollectionState { export class CategoryCollectionState implements ICategoryCollectionState {
public readonly os: OperatingSystem; public readonly os: OperatingSystem;
public readonly code: IApplicationCode;
public readonly selection: IUserSelection;
public readonly filter: IUserFilter;
public constructor(readonly collection: ICategoryCollection) { public readonly code: IApplicationCode;
this.selection = new UserSelection(collection, []);
this.code = new ApplicationCode(this.selection, collection.scripting); public readonly selection: IUserSelection;
this.filter = new UserFilter(collection);
this.os = collection.os; public readonly filter: IUserFilter;
}
public constructor(readonly collection: ICategoryCollection) {
this.selection = new UserSelection(collection, []);
this.code = new ApplicationCode(this.selection, collection.scripting);
this.filter = new UserFilter(collection);
this.os = collection.os;
}
} }

View File

@@ -1,39 +1,41 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IReadOnlyUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { CodeChangedEvent } from './Event/CodeChangedEvent'; import { CodeChangedEvent } from './Event/CodeChangedEvent';
import { CodePosition } from './Position/CodePosition'; import { CodePosition } from './Position/CodePosition';
import { ICodeChangedEvent } from './Event/ICodeChangedEvent'; import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { UserScriptGenerator } from './Generation/UserScriptGenerator'; import { UserScriptGenerator } from './Generation/UserScriptGenerator';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { IApplicationCode } from './IApplicationCode'; import { IApplicationCode } from './IApplicationCode';
import { IUserScriptGenerator } from './Generation/IUserScriptGenerator'; import { IUserScriptGenerator } from './Generation/IUserScriptGenerator';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
export class ApplicationCode implements IApplicationCode { export class ApplicationCode implements IApplicationCode {
public readonly changed = new EventSource<ICodeChangedEvent>(); public readonly changed = new EventSource<ICodeChangedEvent>();
public current: string;
private scriptPositions = new Map<SelectedScript, CodePosition>(); public current: string;
constructor( private scriptPositions = new Map<SelectedScript, CodePosition>();
userSelection: IUserSelection,
private readonly scriptingDefinition: IScriptingDefinition,
private readonly generator: IUserScriptGenerator = new UserScriptGenerator()) {
if (!userSelection) { throw new Error('userSelection is null or undefined'); }
if (!scriptingDefinition) { throw new Error('scriptingDefinition is null or undefined'); }
if (!generator) { throw new Error('generator is null or undefined'); }
this.setCode(userSelection.selectedScripts);
userSelection.changed.on((scripts) => {
this.setCode(scripts);
});
}
private setCode(scripts: ReadonlyArray<SelectedScript>): void { constructor(
const oldScripts = Array.from(this.scriptPositions.keys()); userSelection: IReadOnlyUserSelection,
const code = this.generator.buildCode(scripts, this.scriptingDefinition); private readonly scriptingDefinition: IScriptingDefinition,
this.current = code.code; private readonly generator: IUserScriptGenerator = new UserScriptGenerator(),
this.scriptPositions = code.scriptPositions; ) {
const event = new CodeChangedEvent(code.code, oldScripts, code.scriptPositions); if (!userSelection) { throw new Error('missing userSelection'); }
this.changed.notify(event); if (!scriptingDefinition) { throw new Error('missing scriptingDefinition'); }
} if (!generator) { throw new Error('missing generator'); }
this.setCode(userSelection.selectedScripts);
userSelection.changed.on((scripts) => {
this.setCode(scripts);
});
}
private setCode(scripts: ReadonlyArray<SelectedScript>): void {
const oldScripts = Array.from(this.scriptPositions.keys());
const code = this.generator.buildCode(scripts, this.scriptingDefinition);
this.current = code.code;
this.scriptPositions = code.scriptPositions;
const event = new CodeChangedEvent(code.code, oldScripts, code.scriptPositions);
this.changed.notify(event);
}
} }

View File

@@ -1,64 +1,71 @@
import { ICodeChangedEvent } from './ICodeChangedEvent';
import { SelectedScript } from '../../Selection/SelectedScript';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { SelectedScript } from '../../Selection/SelectedScript';
import { ICodeChangedEvent } from './ICodeChangedEvent';
export class CodeChangedEvent implements ICodeChangedEvent { export class CodeChangedEvent implements ICodeChangedEvent {
public readonly code: string; public readonly code: string;
public readonly addedScripts: ReadonlyArray<IScript>;
public readonly removedScripts: ReadonlyArray<IScript>;
public readonly changedScripts: ReadonlyArray<IScript>;
private readonly scripts: Map<IScript, ICodePosition>; public readonly addedScripts: ReadonlyArray<IScript>;
constructor( public readonly removedScripts: ReadonlyArray<IScript>;
code: string,
oldScripts: ReadonlyArray<SelectedScript>,
scripts: Map<SelectedScript, ICodePosition>) {
ensureAllPositionsExist(code, Array.from(scripts.values()));
this.code = code;
const newScripts = Array.from(scripts.keys());
this.addedScripts = selectIfNotExists(newScripts, oldScripts);
this.removedScripts = selectIfNotExists(oldScripts, newScripts);
this.changedScripts = getChangedScripts(oldScripts, newScripts);
this.scripts = new Map<IScript, ICodePosition>();
scripts.forEach((position, selection) => {
this.scripts.set(selection.script, position);
});
}
public isEmpty(): boolean { public readonly changedScripts: ReadonlyArray<IScript>;
return this.scripts.size === 0;
}
public getScriptPositionInCode(script: IScript): ICodePosition { private readonly scripts: Map<IScript, ICodePosition>;
return this.scripts.get(script);
} constructor(
code: string,
oldScripts: ReadonlyArray<SelectedScript>,
scripts: Map<SelectedScript, ICodePosition>,
) {
ensureAllPositionsExist(code, Array.from(scripts.values()));
this.code = code;
const newScripts = Array.from(scripts.keys());
this.addedScripts = selectIfNotExists(newScripts, oldScripts);
this.removedScripts = selectIfNotExists(oldScripts, newScripts);
this.changedScripts = getChangedScripts(oldScripts, newScripts);
this.scripts = new Map<IScript, ICodePosition>();
scripts.forEach((position, selection) => {
this.scripts.set(selection.script, position);
});
}
public isEmpty(): boolean {
return this.scripts.size === 0;
}
public getScriptPositionInCode(script: IScript): ICodePosition {
return this.scripts.get(script);
}
} }
function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) { function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) {
const totalLines = script.split(/\r\n|\r|\n/).length; const totalLines = script.split(/\r\n|\r|\n/).length;
for (const position of positions) { const missingPositions = positions.filter((position) => position.endLine > totalLines);
if (position.endLine > totalLines) { if (missingPositions.length > 0) {
throw new Error(`script end line (${position.endLine}) is out of range.` + throw new Error(
`(total code lines: ${totalLines}`); `Out of range script end line: "${missingPositions.map((pos) => pos.endLine).join('", "')}"`
} + `(total code lines: ${totalLines}).`,
} );
}
} }
function getChangedScripts( function getChangedScripts(
oldScripts: ReadonlyArray<SelectedScript>, oldScripts: ReadonlyArray<SelectedScript>,
newScripts: ReadonlyArray<SelectedScript>): ReadonlyArray<IScript> { newScripts: ReadonlyArray<SelectedScript>,
return newScripts ): ReadonlyArray<IScript> {
.filter((newScript) => oldScripts.find((oldScript) => oldScript.id === newScript.id return newScripts
&& oldScript.revert !== newScript.revert )) .filter((newScript) => oldScripts.find((oldScript) => oldScript.id === newScript.id
.map((selection) => selection.script); && oldScript.revert !== newScript.revert))
.map((selection) => selection.script);
} }
function selectIfNotExists( function selectIfNotExists(
selectableContainer: ReadonlyArray<SelectedScript>, selectableContainer: ReadonlyArray<SelectedScript>,
test: ReadonlyArray<SelectedScript>) { test: ReadonlyArray<SelectedScript>,
return selectableContainer ) {
.filter((script) => !test.find((oldScript) => oldScript.id === script.id)) return selectableContainer
.map((selection) => selection.script); .filter((script) => !test.find((oldScript) => oldScript.id === script.id))
.map((selection) => selection.script);
} }

View File

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

View File

@@ -4,64 +4,65 @@ const NewLine = '\n';
const TotalFunctionSeparatorChars = 58; const TotalFunctionSeparatorChars = 58;
export abstract class CodeBuilder implements ICodeBuilder { export abstract class CodeBuilder implements ICodeBuilder {
private readonly lines = new Array<string>(); private readonly lines = new Array<string>();
// Returns current line starting from 0 (no lines), or 1 (have single line) // Returns current line starting from 0 (no lines), or 1 (have single line)
public get currentLine(): number { public get currentLine(): number {
return this.lines.length; return this.lines.length;
}
public appendLine(code?: string): CodeBuilder {
if (!code) {
this.lines.push('');
return this;
} }
const lines = code.match(/[^\r\n]+/g);
this.lines.push(...lines);
return this;
}
public appendLine(code?: string): CodeBuilder { public appendTrailingHyphensCommentLine(
if (!code) { totalRepeatHyphens: number = TotalFunctionSeparatorChars,
this.lines.push(''); ): CodeBuilder {
return this; return this.appendCommentLine('-'.repeat(totalRepeatHyphens));
} }
const lines = code.match(/[^\r\n]+/g);
for (const line of lines) { public appendCommentLine(commentLine?: string): CodeBuilder {
this.lines.push(line); this.lines.push(`${this.getCommentDelimiter()} ${commentLine}`);
} return this;
return this; }
public appendFunction(name: string, code: string): CodeBuilder {
if (!name) { throw new Error('name cannot be empty or null'); }
if (!code) { throw new Error('code cannot be empty or null'); }
return this
.appendCommentLineWithHyphensAround(name)
.appendLine(this.writeStandardOut(`--- ${name}`))
.appendLine(code)
.appendTrailingHyphensCommentLine();
}
public appendCommentLineWithHyphensAround(
sectionName: string,
totalRepeatHyphens: number = TotalFunctionSeparatorChars,
): CodeBuilder {
if (!sectionName) { throw new Error('sectionName cannot be empty or null'); }
if (sectionName.length >= totalRepeatHyphens) {
return this.appendCommentLine(sectionName);
} }
const firstHyphens = '-'.repeat(Math.floor((totalRepeatHyphens - sectionName.length) / 2));
const secondHyphens = '-'.repeat(Math.ceil((totalRepeatHyphens - sectionName.length) / 2));
return this
.appendTrailingHyphensCommentLine()
.appendCommentLine(firstHyphens + sectionName + secondHyphens)
.appendTrailingHyphensCommentLine(TotalFunctionSeparatorChars);
}
public appendTrailingHyphensCommentLine( public toString(): string {
totalRepeatHyphens: number = TotalFunctionSeparatorChars): CodeBuilder { return this.lines.join(NewLine);
return this.appendCommentLine('-'.repeat(totalRepeatHyphens)); }
}
public appendCommentLine(commentLine?: string): CodeBuilder { protected abstract getCommentDelimiter(): string;
this.lines.push(`${this.getCommentDelimiter()} ${commentLine}`);
return this;
}
public appendFunction(name: string, code: string): CodeBuilder { protected abstract writeStandardOut(text: string): string;
if (!name) { throw new Error('name cannot be empty or null'); }
if (!code) { throw new Error('code cannot be empty or null'); }
return this
.appendCommentLineWithHyphensAround(name)
.appendLine(this.writeStandardOut(`--- ${name}`))
.appendLine(code)
.appendTrailingHyphensCommentLine();
}
public appendCommentLineWithHyphensAround(
sectionName: string,
totalRepeatHyphens: number = TotalFunctionSeparatorChars): CodeBuilder {
if (!sectionName) { throw new Error('sectionName cannot be empty or null'); }
if (sectionName.length >= totalRepeatHyphens) {
return this.appendCommentLine(sectionName);
}
const firstHyphens = '-'.repeat(Math.floor((totalRepeatHyphens - sectionName.length) / 2));
const secondHyphens = '-'.repeat(Math.ceil((totalRepeatHyphens - sectionName.length) / 2));
return this
.appendTrailingHyphensCommentLine()
.appendCommentLine(firstHyphens + sectionName + secondHyphens)
.appendTrailingHyphensCommentLine(TotalFunctionSeparatorChars);
}
public toString(): string {
return this.lines.join(NewLine);
}
protected abstract getCommentDelimiter(): string;
protected abstract writeStandardOut(text: string): string;
} }

View File

@@ -5,10 +5,12 @@ import { BatchBuilder } from './Languages/BatchBuilder';
import { ShellBuilder } from './Languages/ShellBuilder'; import { ShellBuilder } from './Languages/ShellBuilder';
import { ICodeBuilderFactory } from './ICodeBuilderFactory'; import { ICodeBuilderFactory } from './ICodeBuilderFactory';
export class CodeBuilderFactory extends ScriptingLanguageFactory<ICodeBuilder> implements ICodeBuilderFactory { export class CodeBuilderFactory
constructor() { extends ScriptingLanguageFactory<ICodeBuilder>
super(); implements ICodeBuilderFactory {
this.registerGetter(ScriptingLanguage.shellscript, () => new ShellBuilder()); constructor() {
this.registerGetter(ScriptingLanguage.batchfile, () => new BatchBuilder()); super();
} this.registerGetter(ScriptingLanguage.shellscript, () => new ShellBuilder());
this.registerGetter(ScriptingLanguage.batchfile, () => new BatchBuilder());
}
} }

View File

@@ -1,9 +1,9 @@
export interface ICodeBuilder { export interface ICodeBuilder {
currentLine: number; currentLine: number;
appendLine(code?: string): ICodeBuilder; appendLine(code?: string): ICodeBuilder;
appendTrailingHyphensCommentLine(totalRepeatHyphens: number): ICodeBuilder; appendTrailingHyphensCommentLine(totalRepeatHyphens: number): ICodeBuilder;
appendCommentLine(commentLine?: string): ICodeBuilder; appendCommentLine(commentLine?: string): ICodeBuilder;
appendCommentLineWithHyphensAround(sectionName: string, totalRepeatHyphens: number): ICodeBuilder; appendCommentLineWithHyphensAround(sectionName: string, totalRepeatHyphens: number): ICodeBuilder;
appendFunction(name: string, code: string): ICodeBuilder; appendFunction(name: string, code: string): ICodeBuilder;
toString(): string; toString(): string;
} }

View File

@@ -1,5 +1,4 @@
import { ICodeBuilder } from './ICodeBuilder';
import { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory'; import { IScriptingLanguageFactory } from '@/application/Common/ScriptingLanguage/IScriptingLanguageFactory';
import { ICodeBuilder } from './ICodeBuilder';
export interface ICodeBuilderFactory extends IScriptingLanguageFactory<ICodeBuilder> { export type ICodeBuilderFactory = IScriptingLanguageFactory<ICodeBuilder>;
}

View File

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

View File

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

View File

@@ -1,16 +1,17 @@
import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder'; import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder';
export class BatchBuilder extends CodeBuilder { export class BatchBuilder extends CodeBuilder {
protected getCommentDelimiter(): string { protected getCommentDelimiter(): string {
return '::'; return '::';
} }
protected writeStandardOut(text: string): string {
return `echo ${escapeForEcho(text)}`; protected writeStandardOut(text: string): string {
} return `echo ${escapeForEcho(text)}`;
}
} }
function escapeForEcho(text: string) { function escapeForEcho(text: string) {
return text return text
.replace(/&/g, '^&') .replace(/&/g, '^&')
.replace(/%/g, '%%'); .replace(/%/g, '%%');
} }

View File

@@ -1,15 +1,16 @@
import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder'; import { CodeBuilder } from '@/application/Context/State/Code/Generation/CodeBuilder';
export class ShellBuilder extends CodeBuilder { export class ShellBuilder extends CodeBuilder {
protected getCommentDelimiter(): string { protected getCommentDelimiter(): string {
return '#'; return '#';
} }
protected writeStandardOut(text: string): string {
return `echo '${escapeForEcho(text)}'`; protected writeStandardOut(text: string): string {
} return `echo '${escapeForEcho(text)}'`;
}
} }
function escapeForEcho(text: string) { function escapeForEcho(text: string) {
return text return text
.replace(/'/g, '\'\\\'\''); .replace(/'/g, '\'\\\'\'');
} }

View File

@@ -1,71 +1,75 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript'; import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserScriptGenerator } from './IUserScriptGenerator';
import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition'; import { ICodePosition } from '@/application/Context/State/Code/Position/ICodePosition';
import { CodePosition } from '../Position/CodePosition';
import { IUserScript } from './IUserScript';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { CodePosition } from '../Position/CodePosition';
import { IUserScriptGenerator } from './IUserScriptGenerator';
import { IUserScript } from './IUserScript';
import { ICodeBuilder } from './ICodeBuilder'; import { ICodeBuilder } from './ICodeBuilder';
import { ICodeBuilderFactory } from './ICodeBuilderFactory'; import { ICodeBuilderFactory } from './ICodeBuilderFactory';
import { CodeBuilderFactory } from './CodeBuilderFactory'; import { CodeBuilderFactory } from './CodeBuilderFactory';
export class UserScriptGenerator implements IUserScriptGenerator { export class UserScriptGenerator implements IUserScriptGenerator {
constructor(private readonly codeBuilderFactory: ICodeBuilderFactory = new CodeBuilderFactory()) { constructor(private readonly codeBuilderFactory: ICodeBuilderFactory = new CodeBuilderFactory()) {
}
public buildCode(
selectedScripts: ReadonlyArray<SelectedScript>,
scriptingDefinition: IScriptingDefinition,
): IUserScript {
if (!selectedScripts) { throw new Error('missing scripts'); }
if (!scriptingDefinition) { throw new Error('missing definition'); }
if (!selectedScripts.length) {
return { code: '', scriptPositions: new Map<SelectedScript, ICodePosition>() };
} }
public buildCode( let builder = this.codeBuilderFactory.create(scriptingDefinition.language);
selectedScripts: ReadonlyArray<SelectedScript>, builder = initializeCode(scriptingDefinition.startCode, builder);
scriptingDefinition: IScriptingDefinition): IUserScript { const scriptPositions = selectedScripts.reduce((result, selection) => {
if (!selectedScripts) { throw new Error('undefined scripts'); } return appendSelection(selection, result, builder);
if (!scriptingDefinition) { throw new Error('undefined definition'); } }, new Map<SelectedScript, ICodePosition>());
let scriptPositions = new Map<SelectedScript, ICodePosition>(); const code = finalizeCode(builder, scriptingDefinition.endCode);
if (!selectedScripts.length) { return { code, scriptPositions };
return { code: '', scriptPositions }; }
}
let builder = this.codeBuilderFactory.create(scriptingDefinition.language);
builder = initializeCode(scriptingDefinition.startCode, builder);
for (const selection of selectedScripts) {
scriptPositions = appendSelection(selection, scriptPositions, builder);
}
const code = finalizeCode(builder, scriptingDefinition.endCode);
return { code, scriptPositions };
}
} }
function initializeCode(startCode: string, builder: ICodeBuilder): ICodeBuilder { function initializeCode(startCode: string, builder: ICodeBuilder): ICodeBuilder {
if (!startCode) { if (!startCode) {
return builder; return builder;
} }
return builder return builder
.appendLine(startCode) .appendLine(startCode)
.appendLine(); .appendLine();
} }
function finalizeCode(builder: ICodeBuilder, endCode: string): string { function finalizeCode(builder: ICodeBuilder, endCode: string): string {
if (!endCode) { if (!endCode) {
return builder.toString(); return builder.toString();
} }
return builder.appendLine() return builder.appendLine()
.appendLine(endCode) .appendLine(endCode)
.toString(); .toString();
} }
function appendSelection( function appendSelection(
selection: SelectedScript, selection: SelectedScript,
scriptPositions: Map<SelectedScript, ICodePosition>, scriptPositions: Map<SelectedScript, ICodePosition>,
builder: ICodeBuilder): Map<SelectedScript, ICodePosition> { builder: ICodeBuilder,
const startPosition = builder.currentLine + 1; // Because first line will be empty to separate scripts ): Map<SelectedScript, ICodePosition> {
builder = appendCode(selection, builder); // Start from next line because first line will be empty to separate scripts
const endPosition = builder.currentLine - 1; const startPosition = builder.currentLine + 1;
builder.appendLine(); appendCode(selection, builder);
const position = new CodePosition(startPosition, endPosition); const endPosition = builder.currentLine - 1;
scriptPositions.set(selection, position); builder.appendLine();
return scriptPositions; const position = new CodePosition(startPosition, endPosition);
scriptPositions.set(selection, position);
return scriptPositions;
} }
function appendCode(selection: SelectedScript, builder: ICodeBuilder): ICodeBuilder { function appendCode(selection: SelectedScript, builder: ICodeBuilder): ICodeBuilder {
const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name; const { script } = selection;
const scriptCode = selection.revert ? selection.script.code.revert : selection.script.code.execute; const name = selection.revert ? `${script.name} (revert)` : script.name;
return builder const scriptCode = selection.revert ? script.code.revert : script.code.execute;
.appendLine() return builder
.appendFunction(name, scriptCode); .appendLine()
.appendFunction(name, scriptCode);
} }

View File

@@ -1,7 +1,7 @@
import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
import { IEventSource } from '@/infrastructure/Events/IEventSource'; import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
export interface IApplicationCode { export interface IApplicationCode {
readonly changed: IEventSource<ICodeChangedEvent>; readonly changed: IEventSource<ICodeChangedEvent>;
readonly current: string; readonly current: string;
} }

View File

@@ -1,24 +1,25 @@
import { ICodePosition } from './ICodePosition'; import { ICodePosition } from './ICodePosition';
export class CodePosition implements ICodePosition { export class CodePosition implements ICodePosition {
public get totalLines(): number { public get totalLines(): number {
return this.endLine - this.startLine; return this.endLine - this.startLine;
} }
constructor( constructor(
public readonly startLine: number, public readonly startLine: number,
public readonly endLine: number) { public readonly endLine: number,
if (startLine < 0) { ) {
throw new Error('Code cannot start in a negative line'); if (startLine < 0) {
} throw new Error('Code cannot start in a negative line');
if (endLine < 0) {
throw new Error('Code cannot end in a negative line');
}
if (endLine === startLine) {
throw new Error('Empty code');
}
if (endLine < startLine) {
throw new Error('End line cannot be less than start line');
}
} }
if (endLine < 0) {
throw new Error('Code cannot end in a negative line');
}
if (endLine === startLine) {
throw new Error('Empty code');
}
if (endLine < startLine) {
throw new Error('End line cannot be less than start line');
}
}
} }

View File

@@ -1,5 +1,5 @@
export interface ICodePosition { export interface ICodePosition {
readonly startLine: number; readonly startLine: number;
readonly endLine: number; readonly endLine: number;
readonly totalLines: number; readonly totalLines: number;
} }

View File

@@ -1,18 +1,20 @@
import { IFilterResult } from './IFilterResult';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory'; import { ICategory } from '@/domain/ICategory';
import { IFilterResult } from './IFilterResult';
export class FilterResult implements IFilterResult { export class FilterResult implements IFilterResult {
constructor( constructor(
public readonly scriptMatches: ReadonlyArray<IScript>, public readonly scriptMatches: ReadonlyArray<IScript>,
public readonly categoryMatches: ReadonlyArray<ICategory>, public readonly categoryMatches: ReadonlyArray<ICategory>,
public readonly query: string) { public readonly query: string,
if (!query) { throw new Error('Query is empty or undefined'); } ) {
if (!scriptMatches) { throw new Error('Script matches is undefined'); } if (!query) { throw new Error('Query is empty or undefined'); }
if (!categoryMatches) { throw new Error('Category matches is undefined'); } if (!scriptMatches) { throw new Error('Script matches is undefined'); }
} if (!categoryMatches) { throw new Error('Category matches is undefined'); }
public hasAnyMatches(): boolean { }
return this.scriptMatches.length > 0
|| this.categoryMatches.length > 0; public hasAnyMatches(): boolean {
} return this.scriptMatches.length > 0
|| this.categoryMatches.length > 0;
}
} }

View File

@@ -1,8 +1,8 @@
import { IScript, ICategory } from '@/domain/ICategory'; import { IScript, ICategory } from '@/domain/ICategory';
export interface IFilterResult { export interface IFilterResult {
readonly categoryMatches: ReadonlyArray<ICategory>; readonly categoryMatches: ReadonlyArray<ICategory>;
readonly scriptMatches: ReadonlyArray<IScript>; readonly scriptMatches: ReadonlyArray<IScript>;
readonly query: string; readonly query: string;
hasAnyMatches(): boolean; hasAnyMatches(): boolean;
} }

View File

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

View File

@@ -1,52 +1,56 @@
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { FilterResult } from './FilterResult'; import { FilterResult } from './FilterResult';
import { IFilterResult } from './IFilterResult'; import { IFilterResult } from './IFilterResult';
import { IUserFilter } from './IUserFilter'; import { IUserFilter } from './IUserFilter';
import { EventSource } from '@/infrastructure/Events/EventSource';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
export class UserFilter implements IUserFilter { export class UserFilter implements IUserFilter {
public readonly filtered = new EventSource<IFilterResult>(); public readonly filtered = new EventSource<IFilterResult>();
public readonly filterRemoved = new EventSource<void>();
public currentFilter: IFilterResult | undefined;
constructor(private collection: ICategoryCollection) { public readonly filterRemoved = new EventSource<void>();
public currentFilter: IFilterResult | undefined;
constructor(private collection: ICategoryCollection) {
}
public setFilter(filter: string): void {
if (!filter) {
throw new Error('Filter must be defined and not empty. Use removeFilter() to remove the filter');
} }
const filterLowercase = filter.toLocaleLowerCase();
const filteredScripts = this.collection.getAllScripts().filter(
(script) => isScriptAMatch(script, filterLowercase),
);
const filteredCategories = this.collection.getAllCategories().filter(
(category) => category.name.toLowerCase().includes(filterLowercase),
);
const matches = new FilterResult(
filteredScripts,
filteredCategories,
filter,
);
this.currentFilter = matches;
this.filtered.notify(matches);
}
public setFilter(filter: string): void { public removeFilter(): void {
if (!filter) { this.currentFilter = undefined;
throw new Error('Filter must be defined and not empty. Use removeFilter() to remove the filter'); this.filterRemoved.notify();
} }
const filterLowercase = filter.toLocaleLowerCase();
const filteredScripts = this.collection.getAllScripts().filter(
(script) => isScriptAMatch(script, filterLowercase));
const filteredCategories = this.collection.getAllCategories().filter(
(category) => category.name.toLowerCase().includes(filterLowercase));
const matches = new FilterResult(
filteredScripts,
filteredCategories,
filter,
);
this.currentFilter = matches;
this.filtered.notify(matches);
}
public removeFilter(): void {
this.currentFilter = undefined;
this.filterRemoved.notify();
}
} }
function isScriptAMatch(script: IScript, filterLowercase: string) { function isScriptAMatch(script: IScript, filterLowercase: string) {
if (script.name.toLowerCase().includes(filterLowercase)) { if (script.name.toLowerCase().includes(filterLowercase)) {
return true; return true;
} }
if (script.code.execute.toLowerCase().includes(filterLowercase)) { if (script.code.execute.toLowerCase().includes(filterLowercase)) {
return true; return true;
} }
if (script.code.revert) { if (script.code.revert) {
return script.code.revert.toLowerCase().includes(filterLowercase); return script.code.revert.toLowerCase().includes(filterLowercase);
} }
return false; return false;
} }

View File

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

View File

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

View File

@@ -2,13 +2,13 @@ import { BaseEntity } from '@/infrastructure/Entity/BaseEntity';
import { IScript } from '@/domain/IScript'; import { IScript } from '@/domain/IScript';
export class SelectedScript extends BaseEntity<string> { export class SelectedScript extends BaseEntity<string> {
constructor( constructor(
public readonly script: IScript, public readonly script: IScript,
public readonly revert: boolean, public readonly revert: boolean,
) { ) {
super(script.id); super(script.id);
if (revert && !script.canRevert()) { if (revert && !script.canRevert()) {
throw new Error('cannot revert an irreversible script'); throw new Error('cannot revert an irreversible script');
}
} }
}
} }

View File

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

View File

@@ -3,52 +3,55 @@ import { DetectorBuilder } from './DetectorBuilder';
import { IBrowserOsDetector } from './IBrowserOsDetector'; import { IBrowserOsDetector } from './IBrowserOsDetector';
export class BrowserOsDetector implements IBrowserOsDetector { export class BrowserOsDetector implements IBrowserOsDetector {
private readonly detectors = BrowserDetectors; private readonly detectors = BrowserDetectors;
public detect(userAgent: string): OperatingSystem | undefined {
if (!userAgent) { public detect(userAgent: string): OperatingSystem | undefined {
return undefined; if (!userAgent) {
} return undefined;
for (const detector of this.detectors) {
const os = detector.detect(userAgent);
if (os !== undefined) {
return os;
}
}
return undefined;
} }
for (const detector of this.detectors) {
const os = detector.detect(userAgent);
if (os !== undefined) {
return os;
}
}
return undefined;
}
} }
// Reference: https://github.com/keithws/browser-report/blob/master/index.js#L304 // Reference: https://github.com/keithws/browser-report/blob/master/index.js#L304
const BrowserDetectors = const BrowserDetectors = [
[ define(OperatingSystem.KaiOS, (b) => b
define(OperatingSystem.KaiOS, (b) => .mustInclude('KAIOS')),
b.mustInclude('KAIOS')), define(OperatingSystem.ChromeOS, (b) => b
define(OperatingSystem.ChromeOS, (b) => .mustInclude('CrOS')),
b.mustInclude('CrOS')), define(OperatingSystem.BlackBerryOS, (b) => b
define(OperatingSystem.BlackBerryOS, (b) => .mustInclude('BlackBerry')),
b.mustInclude('BlackBerry')), define(OperatingSystem.BlackBerryTabletOS, (b) => b
define(OperatingSystem.BlackBerryTabletOS, (b) => .mustInclude('RIM Tablet OS')),
b.mustInclude('RIM Tablet OS')), define(OperatingSystem.BlackBerry, (b) => b
define(OperatingSystem.BlackBerry, (b) => .mustInclude('BB10')),
b.mustInclude('BB10')), define(OperatingSystem.Android, (b) => b
define(OperatingSystem.Android, (b) => .mustInclude('Android').mustNotInclude('Windows Phone')),
b.mustInclude('Android').mustNotInclude('Windows Phone')), define(OperatingSystem.Android, (b) => b
define(OperatingSystem.Android, (b) => .mustInclude('Adr').mustNotInclude('Windows Phone')),
b.mustInclude('Adr').mustNotInclude('Windows Phone')), define(OperatingSystem.iOS, (b) => b
define(OperatingSystem.iOS, (b) => .mustInclude('like Mac OS X')),
b.mustInclude('like Mac OS X')), define(OperatingSystem.Linux, (b) => b
define(OperatingSystem.Linux, (b) => .mustInclude('Linux').mustNotInclude('Android').mustNotInclude('Adr')),
b.mustInclude('Linux').mustNotInclude('Android').mustNotInclude('Adr')), define(OperatingSystem.Windows, (b) => b
define(OperatingSystem.Windows, (b) => .mustInclude('Windows').mustNotInclude('Windows Phone')),
b.mustInclude('Windows').mustNotInclude('Windows Phone')), define(OperatingSystem.WindowsPhone, (b) => b
define(OperatingSystem.WindowsPhone, (b) => .mustInclude('Windows Phone')),
b.mustInclude('Windows Phone')), define(OperatingSystem.macOS, (b) => b
define(OperatingSystem.macOS, (b) => .mustInclude('OS X').mustNotInclude('Android').mustNotInclude('like Mac OS X')),
b.mustInclude('OS X').mustNotInclude('Android').mustNotInclude('like Mac OS X')),
]; ];
function define(os: OperatingSystem, applyRules: (builder: DetectorBuilder) => DetectorBuilder): IBrowserOsDetector { function define(
const builder = new DetectorBuilder(os); os: OperatingSystem,
applyRules(builder); applyRules: (builder: DetectorBuilder) => DetectorBuilder,
return builder.build(); ): IBrowserOsDetector {
const builder = new DetectorBuilder(os);
applyRules(builder);
return builder.build();
} }

View File

@@ -1,53 +1,54 @@
import { IBrowserOsDetector } from './IBrowserOsDetector';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { IBrowserOsDetector } from './IBrowserOsDetector';
export class DetectorBuilder { export class DetectorBuilder {
private readonly existingPartsInUserAgent = new Array<string>(); private readonly existingPartsInUserAgent = new Array<string>();
private readonly notExistingPartsInUserAgent = new Array<string>();
constructor(private readonly os: OperatingSystem) { } private readonly notExistingPartsInUserAgent = new Array<string>();
public mustInclude(str: string): DetectorBuilder { constructor(private readonly os: OperatingSystem) { }
return this.add(str, this.existingPartsInUserAgent);
public mustInclude(str: string): DetectorBuilder {
return this.add(str, this.existingPartsInUserAgent);
}
public mustNotInclude(str: string): DetectorBuilder {
return this.add(str, this.notExistingPartsInUserAgent);
}
public build(): IBrowserOsDetector {
if (!this.existingPartsInUserAgent.length) {
throw new Error('Must include at least a part');
} }
return {
detect: (agent) => this.detect(agent),
};
}
public mustNotInclude(str: string): DetectorBuilder { private detect(userAgent: string): OperatingSystem {
return this.add(str, this.notExistingPartsInUserAgent); if (!userAgent) {
throw new Error('missing userAgent');
} }
if (this.existingPartsInUserAgent.some((part) => !userAgent.includes(part))) {
return undefined;
}
if (this.notExistingPartsInUserAgent.some((part) => userAgent.includes(part))) {
return undefined;
}
return this.os;
}
public build(): IBrowserOsDetector { private add(part: string, array: string[]): DetectorBuilder {
if (!this.existingPartsInUserAgent.length) { if (!part) {
throw new Error('Must include at least a part'); throw new Error('part is empty or undefined');
}
return {
detect: (agent) => this.detect(agent),
};
} }
if (this.existingPartsInUserAgent.includes(part)) {
private detect(userAgent: string): OperatingSystem { throw new Error(`part ${part} is already included as existing part`);
if (!userAgent) {
throw new Error('User agent is null or undefined');
}
if (this.existingPartsInUserAgent.some((part) => !userAgent.includes(part))) {
return undefined;
}
if (this.notExistingPartsInUserAgent.some((part) => userAgent.includes(part))) {
return undefined;
}
return this.os;
} }
if (this.notExistingPartsInUserAgent.includes(part)) {
private add(part: string, array: string[]): DetectorBuilder { throw new Error(`part ${part} is already included as not existing part`);
if (!part) {
throw new Error('part is empty or undefined');
}
if (this.existingPartsInUserAgent.includes(part)) {
throw new Error(`part ${part} is already included as existing part`);
}
if (this.notExistingPartsInUserAgent.includes(part)) {
throw new Error(`part ${part} is already included as not existing part`);
}
array.push(part);
return this;
} }
array.push(part);
return this;
}
} }

View File

@@ -1,5 +1,5 @@
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
export interface IBrowserOsDetector { export interface IBrowserOsDetector {
detect(userAgent: string): OperatingSystem | undefined; detect(userAgent: string): OperatingSystem | undefined;
} }

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
import { IApplication } from '@/domain/IApplication'; import { IApplication } from '@/domain/IApplication';
export interface IApplicationFactory { export interface IApplicationFactory {
getAppAsync(): Promise<IApplication>; getApp(): Promise<IApplication>;
} }

View File

@@ -1,38 +1,41 @@
import type { CollectionData } from '@/application/collections/';
import { IApplication } from '@/domain/IApplication'; import { IApplication } from '@/domain/IApplication';
import { IProjectInformation } from '@/domain/IProjectInformation'; import { IProjectInformation } from '@/domain/IProjectInformation';
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { parseCategoryCollection } from './CategoryCollectionParser'; import WindowsData from '@/application/collections/windows.yaml';
import WindowsData from 'js-yaml-loader!@/application/collections/windows.yaml'; import MacOsData from '@/application/collections/macos.yaml';
import MacOsData from 'js-yaml-loader!@/application/collections/macos.yaml';
import { CollectionData } from 'js-yaml-loader!@/*';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser'; import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { Application } from '@/domain/Application'; import { Application } from '@/domain/Application';
import { parseCategoryCollection } from './CategoryCollectionParser';
export function parseApplication( export function parseApplication(
parser = CategoryCollectionParser, parser = CategoryCollectionParser,
processEnv: NodeJS.ProcessEnv = process.env, processEnv: NodeJS.ProcessEnv = process.env,
collectionsData = PreParsedCollections): IApplication { collectionsData = PreParsedCollections,
validateCollectionsData(collectionsData); ): IApplication {
const information = parseProjectInformation(processEnv); validateCollectionsData(collectionsData);
const collections = collectionsData.map((collection) => parser(collection, information)); const information = parseProjectInformation(processEnv);
const app = new Application(information, collections); const collections = collectionsData.map((collection) => parser(collection, information));
return app; const app = new Application(information, collections);
return app;
} }
export type CategoryCollectionParserType export type CategoryCollectionParserType
= (file: CollectionData, info: IProjectInformation) => ICategoryCollection; = (file: CollectionData, info: IProjectInformation) => ICategoryCollection;
const CategoryCollectionParser: CategoryCollectionParserType const CategoryCollectionParser: CategoryCollectionParserType = (file, info) => {
= (file, info) => parseCategoryCollection(file, info); return parseCategoryCollection(file, info);
};
const PreParsedCollections: readonly CollectionData [] const PreParsedCollections: readonly CollectionData [] = [
= [ WindowsData, MacOsData ]; WindowsData, MacOsData,
];
function validateCollectionsData(collections: readonly CollectionData[]) { function validateCollectionsData(collections: readonly CollectionData[]) {
if (!collections.length) { if (!collections || !collections.length) {
throw new Error('no collection provided'); throw new Error('missing collections');
} }
if (collections.some((collection) => !collection)) { if (collections.some((collection) => !collection)) {
throw new Error('undefined collection provided'); throw new Error('missing collection provided');
} }
} }

View File

@@ -1,40 +1,37 @@
import { Category } from '@/domain/Category'; import type { CollectionData } from '@/application/collections/';
import { CollectionData } from 'js-yaml-loader!@/*';
import { parseCategory } from './CategoryParser';
import { OperatingSystem } from '@/domain/OperatingSystem'; import { OperatingSystem } from '@/domain/OperatingSystem';
import { createEnumParser } from '../Common/Enum';
import { ICategoryCollection } from '@/domain/ICategoryCollection'; import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { CategoryCollection } from '@/domain/CategoryCollection'; import { CategoryCollection } from '@/domain/CategoryCollection';
import { IProjectInformation } from '@/domain/IProjectInformation'; import { IProjectInformation } from '@/domain/IProjectInformation';
import { createEnumParser } from '../Common/Enum';
import { parseCategory } from './CategoryParser';
import { CategoryCollectionParseContext } from './Script/CategoryCollectionParseContext'; import { CategoryCollectionParseContext } from './Script/CategoryCollectionParseContext';
import { ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser'; import { ScriptingDefinitionParser } from './ScriptingDefinition/ScriptingDefinitionParser';
export function parseCategoryCollection( export function parseCategoryCollection(
content: CollectionData, content: CollectionData,
info: IProjectInformation, info: IProjectInformation,
osParser = createEnumParser(OperatingSystem)): ICategoryCollection { osParser = createEnumParser(OperatingSystem),
validate(content); ): ICategoryCollection {
const scripting = new ScriptingDefinitionParser() validate(content);
.parse(content.scripting, info); const scripting = new ScriptingDefinitionParser()
const context = new CategoryCollectionParseContext(content.functions, scripting); .parse(content.scripting, info);
const categories = new Array<Category>(); const context = new CategoryCollectionParseContext(content.functions, scripting);
for (const action of content.actions) { const categories = content.actions.map((action) => parseCategory(action, context));
const category = parseCategory(action, context); const os = osParser.parseEnum(content.os, 'os');
categories.push(category); const collection = new CategoryCollection(
} os,
const os = osParser.parseEnum(content.os, 'os'); categories,
const collection = new CategoryCollection( scripting,
os, );
categories, return collection;
scripting);
return collection;
} }
function validate(content: CollectionData): void { function validate(content: CollectionData): void {
if (!content) { if (!content) {
throw new Error('content is null or undefined'); throw new Error('missing content');
} }
if (!content.actions || content.actions.length <= 0) { if (!content.actions || content.actions.length <= 0) {
throw new Error('content does not define any action'); throw new Error('content does not define any action');
} }
} }

View File

@@ -1,71 +1,86 @@
import { CategoryData, ScriptData, CategoryOrScriptData } from 'js-yaml-loader!@/*'; import type {
CategoryData, ScriptData, CategoryOrScriptData, InstructionHolder,
} from '@/application/collections/';
import { Script } from '@/domain/Script'; import { Script } from '@/domain/Script';
import { Category } from '@/domain/Category'; import { Category } from '@/domain/Category';
import { parseDocUrls } from './DocumentationParser'; import { parseDocUrls } from './DocumentationParser';
import { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext'; import { ICategoryCollectionParseContext } from './Script/ICategoryCollectionParseContext';
import { parseScript } from './Script/ScriptParser'; import { parseScript } from './Script/ScriptParser';
let categoryIdCounter: number = 0; let categoryIdCounter = 0;
interface ICategoryChildren { export function parseCategory(
subCategories: Category[]; category: CategoryData,
subScripts: Script[]; context: ICategoryCollectionParseContext,
} ): Category {
if (!context) { throw new Error('missing context'); }
export function parseCategory(category: CategoryData, context: ICategoryCollectionParseContext): Category { ensureValid(category);
if (!context) { throw new Error('undefined context'); } const children: ICategoryChildren = {
ensureValid(category); subCategories: new Array<Category>(),
const children: ICategoryChildren = { subScripts: new Array<Script>(),
subCategories: new Array<Category>(), };
subScripts: new Array<Script>(), for (const data of category.children) {
}; parseCategoryChild(data, children, category, context);
for (const data of category.children) { }
parseCategoryChild(data, children, category, context); return new Category(
} /* id: */ categoryIdCounter++,
return new Category( /* name: */ category.category,
/*id*/ categoryIdCounter++, /* docs: */ parseDocUrls(category),
/*name*/ category.category, /* categories: */ children.subCategories,
/*docs*/ parseDocUrls(category), /* scripts: */ children.subScripts,
/*categories*/ children.subCategories, );
/*scripts*/ children.subScripts,
);
} }
function ensureValid(category: CategoryData) { function ensureValid(category: CategoryData) {
if (!category) { if (!category) {
throw Error('category is null or undefined'); throw Error('missing category');
} }
if (!category.children || category.children.length === 0) { if (!category.children || category.children.length === 0) {
throw Error(`category has no children: "${category.category}"`); throw Error(`category has no children: "${category.category}"`);
} }
if (!category.category || category.category.length === 0) { if (!category.category || category.category.length === 0) {
throw Error('category has no name'); throw Error('category has no name');
} }
}
interface ICategoryChildren {
subCategories: Category[];
subScripts: Script[];
} }
function parseCategoryChild( function parseCategoryChild(
data: CategoryOrScriptData, data: CategoryOrScriptData,
children: ICategoryChildren, children: ICategoryChildren,
parent: CategoryData, parent: CategoryData,
context: ICategoryCollectionParseContext) { context: ICategoryCollectionParseContext,
if (isCategory(data)) { ) {
const subCategory = parseCategory(data as CategoryData, context); if (isCategory(data)) {
children.subCategories.push(subCategory); const subCategory = parseCategory(data as CategoryData, context);
} else if (isScript(data)) { children.subCategories.push(subCategory);
const scriptData = data as ScriptData; } else if (isScript(data)) {
const script = parseScript(scriptData, context); const scriptData = data as ScriptData;
children.subScripts.push(script); const script = parseScript(scriptData, context);
} else { children.subScripts.push(script);
throw new Error(`Child element is neither a category or a script. } else {
throw new Error(`Child element is neither a category or a script.
Parent: ${parent.category}, element: ${JSON.stringify(data)}`); Parent: ${parent.category}, element: ${JSON.stringify(data)}`);
} }
} }
function isScript(data: any): boolean { function isScript(data: CategoryOrScriptData): data is ScriptData {
return (data.code && data.code.length > 0) const holder = (data as InstructionHolder);
|| data.call; return hasCode(holder) || hasCall(holder);
} }
function isCategory(data: any): boolean { function isCategory(data: CategoryOrScriptData): data is CategoryData {
return data.category && data.category.length > 0; const { category } = data as CategoryData;
return category && category.length > 0;
}
function hasCode(holder: InstructionHolder): boolean {
return holder.code && holder.code.length > 0;
}
function hasCall(holder: InstructionHolder) {
return holder.call !== undefined;
} }

View File

@@ -1,61 +1,64 @@
import { DocumentableData, DocumentationUrlsData } from 'js-yaml-loader!@/*'; import type { DocumentableData, DocumentationUrlsData } from '@/application/collections/';
export function parseDocUrls(documentable: DocumentableData): ReadonlyArray<string> { export function parseDocUrls(documentable: DocumentableData): ReadonlyArray<string> {
if (!documentable) { if (!documentable) {
throw new Error('documentable is null or undefined'); throw new Error('missing documentable');
} }
const docs = documentable.docs; const { docs } = documentable;
if (!docs || !docs.length) { if (!docs || !docs.length) {
return []; return [];
} }
let result = new DocumentationUrlContainer(); let result = new DocumentationUrlContainer();
result = addDocs(docs, result); result = addDocs(docs, result);
return result.getAll(); return result.getAll();
} }
function addDocs(docs: DocumentationUrlsData, urls: DocumentationUrlContainer): DocumentationUrlContainer { function addDocs(
if (docs instanceof Array) { docs: DocumentationUrlsData,
urls.addUrls(docs); urls: DocumentationUrlContainer,
} else if (typeof docs === 'string') { ): DocumentationUrlContainer {
urls.addUrl(docs); if (docs instanceof Array) {
} else { urls.addUrls(docs);
throw new Error('Docs field (documentation url) must a string or array of strings'); } else if (typeof docs === 'string') {
} urls.addUrl(docs);
return urls; } else {
throw new Error('Docs field (documentation url) must a string or array of strings');
}
return urls;
} }
class DocumentationUrlContainer { class DocumentationUrlContainer {
private readonly urls = new Array<string>(); private readonly urls = new Array<string>();
public addUrl(url: string) { public addUrl(url: string) {
validateUrl(url); validateUrl(url);
this.urls.push(url); this.urls.push(url);
} }
public addUrls(urls: readonly any[]) { public addUrls(urls: readonly string[]) {
for (const url of urls) { for (const url of urls) {
if (typeof url !== 'string') { if (typeof url !== 'string') {
throw new Error('Docs field (documentation url) must be an array of strings'); throw new Error('Docs field (documentation url) must be an array of strings');
} }
this.addUrl(url); this.addUrl(url);
}
} }
}
public getAll(): ReadonlyArray<string> { public getAll(): ReadonlyArray<string> {
return this.urls; return this.urls;
} }
} }
function validateUrl(docUrl: string): void { function validateUrl(docUrl: string): void {
if (!docUrl) { if (!docUrl) {
throw new Error('Documentation url is null or empty'); throw new Error('Documentation url is null or empty');
} }
if (docUrl.includes('\n')) { if (docUrl.includes('\n')) {
throw new Error('Documentation url cannot be multi-lined.'); throw new Error('Documentation url cannot be multi-lined.');
} }
const res = docUrl.match( const validUrlRegex = /(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_+.~#?&//=]*)/g;
/(http(s)?:\/\/.)?(www\.)?[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)/g); const res = docUrl.match(validUrlRegex);
if (res == null) { if (res == null) {
throw new Error(`Invalid documentation url: ${docUrl}`); throw new Error(`Invalid documentation url: ${docUrl}`);
} }
} }

View File

@@ -1,12 +1,15 @@
import { IProjectInformation } from '@/domain/IProjectInformation'; import { IProjectInformation } from '@/domain/IProjectInformation';
import { ProjectInformation } from '@/domain/ProjectInformation'; import { ProjectInformation } from '@/domain/ProjectInformation';
import { Version } from '@/domain/Version';
export function parseProjectInformation( export function parseProjectInformation(
environment: NodeJS.ProcessEnv): IProjectInformation { environment: NodeJS.ProcessEnv,
return new ProjectInformation( ): IProjectInformation {
environment.VUE_APP_NAME, const version = new Version(environment.VUE_APP_VERSION);
environment.VUE_APP_VERSION, return new ProjectInformation(
environment.VUE_APP_REPOSITORY_URL, environment.VUE_APP_NAME,
environment.VUE_APP_HOMEPAGE_URL, version,
); environment.VUE_APP_REPOSITORY_URL,
environment.VUE_APP_HOMEPAGE_URL,
);
} }

View File

@@ -1,6 +1,6 @@
import type { FunctionData } from '@/application/collections/';
import { IScriptingDefinition } from '@/domain/IScriptingDefinition'; import { IScriptingDefinition } from '@/domain/IScriptingDefinition';
import { ILanguageSyntax } from '@/domain/ScriptCode'; import { ILanguageSyntax } from '@/domain/ScriptCode';
import { FunctionData } from 'js-yaml-loader!@/*';
import { IScriptCompiler } from './Compiler/IScriptCompiler'; import { IScriptCompiler } from './Compiler/IScriptCompiler';
import { ScriptCompiler } from './Compiler/ScriptCompiler'; import { ScriptCompiler } from './Compiler/ScriptCompiler';
import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext'; import { ICategoryCollectionParseContext } from './ICategoryCollectionParseContext';
@@ -8,15 +8,17 @@ import { SyntaxFactory } from './Syntax/SyntaxFactory';
import { ISyntaxFactory } from './Syntax/ISyntaxFactory'; import { ISyntaxFactory } from './Syntax/ISyntaxFactory';
export class CategoryCollectionParseContext implements ICategoryCollectionParseContext { export class CategoryCollectionParseContext implements ICategoryCollectionParseContext {
public readonly compiler: IScriptCompiler; public readonly compiler: IScriptCompiler;
public readonly syntax: ILanguageSyntax;
constructor( public readonly syntax: ILanguageSyntax;
functionsData: ReadonlyArray<FunctionData> | undefined,
scripting: IScriptingDefinition, constructor(
syntaxFactory: ISyntaxFactory = new SyntaxFactory()) { functionsData: ReadonlyArray<FunctionData> | undefined,
if (!scripting) { throw new Error('undefined scripting'); } scripting: IScriptingDefinition,
this.syntax = syntaxFactory.create(scripting.language); syntaxFactory: ISyntaxFactory = new SyntaxFactory(),
this.compiler = new ScriptCompiler(functionsData, this.syntax); ) {
} if (!scripting) { throw new Error('missing scripting'); }
this.syntax = syntaxFactory.create(scripting.language);
this.compiler = new ScriptCompiler(functionsData, this.syntax);
}
} }

View File

@@ -1,35 +1,65 @@
import { FunctionParameterCollection } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameterCollection';
import { IReadOnlyFunctionCallArgumentCollection } from '../../Function/Call/Argument/IFunctionCallArgumentCollection';
import { IReadOnlyFunctionParameterCollection } from '../../Function/Parameter/IFunctionParameterCollection';
import { FunctionCallArgumentCollection } from '../../Function/Call/Argument/FunctionCallArgumentCollection';
import { IExpression } from './IExpression';
import { ExpressionPosition } from './ExpressionPosition'; import { ExpressionPosition } from './ExpressionPosition';
import { ExpressionArguments, IExpression } from './IExpression'; import { ExpressionEvaluationContext, IExpressionEvaluationContext } from './ExpressionEvaluationContext';
export type ExpressionEvaluator = (args?: ExpressionArguments) => string; export type ExpressionEvaluator = (context: IExpressionEvaluationContext) => string;
export class Expression implements IExpression { export class Expression implements IExpression {
constructor( public readonly parameters: IReadOnlyFunctionParameterCollection;
public readonly position: ExpressionPosition,
public readonly evaluator: ExpressionEvaluator, constructor(
public readonly parameters: readonly string[] = new Array<string>()) { public readonly position: ExpressionPosition,
if (!position) { public readonly evaluator: ExpressionEvaluator,
throw new Error('undefined position'); parameters?: IReadOnlyFunctionParameterCollection,
} ) {
if (!evaluator) { if (!position) {
throw new Error('undefined evaluator'); throw new Error('missing position');
}
} }
public evaluate(args?: ExpressionArguments): string { if (!evaluator) {
args = filterUnusedArguments(this.parameters, args); throw new Error('missing evaluator');
return this.evaluator(args);
} }
this.parameters = parameters ?? new FunctionParameterCollection();
}
public evaluate(context: IExpressionEvaluationContext): string {
if (!context) {
throw new Error('missing context');
}
validateThatAllRequiredParametersAreSatisfied(this.parameters, context.args);
const args = filterUnusedArguments(this.parameters, context.args);
const filteredContext = new ExpressionEvaluationContext(args, context.pipelineCompiler);
return this.evaluator(filteredContext);
}
}
function validateThatAllRequiredParametersAreSatisfied(
parameters: IReadOnlyFunctionParameterCollection,
args: IReadOnlyFunctionCallArgumentCollection,
) {
const requiredParameterNames = parameters
.all
.filter((parameter) => !parameter.isOptional)
.map((parameter) => parameter.name);
const missingParameterNames = requiredParameterNames
.filter((parameterName) => !args.hasArgument(parameterName));
if (missingParameterNames.length) {
throw new Error(
`argument values are provided for required parameters: "${missingParameterNames.join('", "')}"`,
);
}
} }
function filterUnusedArguments( function filterUnusedArguments(
parameters: readonly string[], args: ExpressionArguments): ExpressionArguments { parameters: IReadOnlyFunctionParameterCollection,
let result: ExpressionArguments = {}; allFunctionArgs: IReadOnlyFunctionCallArgumentCollection,
for (const parameter of Object.keys(args)) { ): IReadOnlyFunctionCallArgumentCollection {
if (parameters.includes(parameter)) { const specificCallArgs = new FunctionCallArgumentCollection();
result = { parameters.all
...result, .filter((parameter) => allFunctionArgs.hasArgument(parameter.name))
[parameter]: args[parameter], .map((parameter) => allFunctionArgs.getArgument(parameter.name))
}; .forEach((argument) => specificCallArgs.addArgument(argument));
} return specificCallArgs;
}
return result;
} }

View File

@@ -0,0 +1,19 @@
import { IReadOnlyFunctionCallArgumentCollection } from '../../Function/Call/Argument/IFunctionCallArgumentCollection';
import { IPipelineCompiler } from '../Pipes/IPipelineCompiler';
import { PipelineCompiler } from '../Pipes/PipelineCompiler';
export interface IExpressionEvaluationContext {
readonly args: IReadOnlyFunctionCallArgumentCollection;
readonly pipelineCompiler: IPipelineCompiler;
}
export class ExpressionEvaluationContext implements IExpressionEvaluationContext {
constructor(
public readonly args: IReadOnlyFunctionCallArgumentCollection,
public readonly pipelineCompiler: IPipelineCompiler = new PipelineCompiler(),
) {
if (!args) {
throw new Error('missing args, send empty collection instead.');
}
}
}

View File

@@ -1,15 +1,16 @@
export class ExpressionPosition { export class ExpressionPosition {
constructor( constructor(
public readonly start: number, public readonly start: number,
public readonly end: number) { public readonly end: number,
if (start === end) { ) {
throw new Error(`no length (start = end = ${start})`); if (start === end) {
} throw new Error(`no length (start = end = ${start})`);
if (start > end) {
throw Error(`start (${start}) after end (${end})`);
}
if (start < 0) {
throw Error(`negative start position: ${start}`);
}
} }
if (start > end) {
throw Error(`start (${start}) after end (${end})`);
}
if (start < 0) {
throw Error(`negative start position: ${start}`);
}
}
} }

View File

@@ -1,12 +1,9 @@
import { IReadOnlyFunctionParameterCollection } from '../../Function/Parameter/IFunctionParameterCollection';
import { ExpressionPosition } from './ExpressionPosition'; import { ExpressionPosition } from './ExpressionPosition';
import { IExpressionEvaluationContext } from './ExpressionEvaluationContext';
export interface IExpression { export interface IExpression {
readonly position: ExpressionPosition; readonly position: ExpressionPosition;
readonly parameters?: readonly string[]; readonly parameters: IReadOnlyFunctionParameterCollection;
evaluate(args?: ExpressionArguments): string; evaluate(context: IExpressionEvaluationContext): string;
} }
export interface ExpressionArguments {
readonly [parameter: string]: string;
}

View File

@@ -1,52 +1,85 @@
import { IExpressionsCompiler, ParameterValueDictionary } from './IExpressionsCompiler'; import { IExpressionEvaluationContext, ExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
import { IReadOnlyFunctionCallArgumentCollection } from '../Function/Call/Argument/IFunctionCallArgumentCollection';
import { IExpressionsCompiler } from './IExpressionsCompiler';
import { IExpression } from './Expression/IExpression'; import { IExpression } from './Expression/IExpression';
import { IExpressionParser } from './Parser/IExpressionParser'; import { IExpressionParser } from './Parser/IExpressionParser';
import { CompositeExpressionParser } from './Parser/CompositeExpressionParser'; import { CompositeExpressionParser } from './Parser/CompositeExpressionParser';
export class ExpressionsCompiler implements IExpressionsCompiler { export class ExpressionsCompiler implements IExpressionsCompiler {
public constructor(private readonly extractor: IExpressionParser = new CompositeExpressionParser()) { } public constructor(
public compileExpressions(code: string, parameters?: ParameterValueDictionary): string { private readonly extractor: IExpressionParser = new CompositeExpressionParser(),
const expressions = this.extractor.findExpressions(code); ) { }
const requiredParameterNames = expressions.map((e) => e.parameters).filter((p) => p).flat();
const uniqueParameterNames = Array.from(new Set(requiredParameterNames));
ensureRequiredArgsProvided(uniqueParameterNames, parameters);
return compileExpressions(expressions, code, parameters);
}
}
function compileExpressions(expressions: IExpression[], code: string, parameters?: ParameterValueDictionary) { public compileExpressions(
let compiledCode = ''; code: string | undefined,
expressions = expressions args: IReadOnlyFunctionCallArgumentCollection,
.slice() // copy the array to not mutate the parameter ): string {
.sort((a, b) => b.position.start - a.position.start); if (!args) {
let index = 0; throw new Error('missing args, send empty collection instead.');
while (index !== code.length) {
const nextExpression = expressions.pop();
if (nextExpression) {
compiledCode += code.substring(index, nextExpression.position.start);
const expressionCode = nextExpression.evaluate(parameters);
compiledCode += expressionCode;
index = nextExpression.position.end;
} else {
compiledCode += code.substring(index, code.length);
break;
}
} }
if (!code) {
return code;
}
const expressions = this.extractor.findExpressions(code);
ensureParamsUsedInCodeHasArgsProvided(expressions, args);
const context = new ExpressionEvaluationContext(args);
const compiledCode = compileExpressions(expressions, code, context);
return compiledCode; return compiledCode;
}
} }
function ensureRequiredArgsProvided(parameters: readonly string[], args: ParameterValueDictionary) { function compileExpressions(
parameters = parameters || []; expressions: readonly IExpression[],
args = args || {}; code: string,
if (!parameters.length) { context: IExpressionEvaluationContext,
return; ) {
} let compiledCode = '';
const notProvidedParameters = parameters.filter((parameter) => !Boolean(args[parameter])); const sortedExpressions = expressions
if (notProvidedParameters.length) { .slice() // copy the array to not mutate the parameter
throw new Error(`parameter value(s) not provided for: ${printList(notProvidedParameters)}`); .sort((a, b) => b.position.start - a.position.start);
let index = 0;
while (index !== code.length) {
const nextExpression = sortedExpressions.pop();
if (nextExpression) {
compiledCode += code.substring(index, nextExpression.position.start);
const expressionCode = nextExpression.evaluate(context);
compiledCode += expressionCode;
index = nextExpression.position.end;
} else {
compiledCode += code.substring(index, code.length);
break;
} }
}
return compiledCode;
}
function extractRequiredParameterNames(
expressions: readonly IExpression[],
): string[] {
return expressions
.map((e) => e.parameters.all
.filter((p) => !p.isOptional)
.map((p) => p.name))
.filter(Boolean) // Remove empty or undefined
.flat()
.filter((name, index, array) => array.indexOf(name) === index); // Remove duplicates
}
function ensureParamsUsedInCodeHasArgsProvided(
expressions: readonly IExpression[],
providedArgs: IReadOnlyFunctionCallArgumentCollection,
): void {
const usedParameterNames = extractRequiredParameterNames(expressions);
if (!usedParameterNames?.length) {
return;
}
const notProvidedParameters = usedParameterNames
.filter((parameterName) => !providedArgs.hasArgument(parameterName));
if (notProvidedParameters.length) {
throw new Error(`parameter value(s) not provided for: ${printList(notProvidedParameters)} but used in code`);
}
} }
function printList(list: readonly string[]): string { function printList(list: readonly string[]): string {
return `"${list.join('", "')}"`; return `"${list.join('", "')}"`;
} }

View File

@@ -1,5 +1,7 @@
export interface ParameterValueDictionary { [parameterName: string]: string; } import { IReadOnlyFunctionCallArgumentCollection } from '../Function/Call/Argument/IFunctionCallArgumentCollection';
export interface IExpressionsCompiler { export interface IExpressionsCompiler {
compileExpressions(code: string, parameters?: ParameterValueDictionary): string; compileExpressions(
code: string | undefined,
args: IReadOnlyFunctionCallArgumentCollection): string;
} }

View File

@@ -1,23 +1,26 @@
import { IExpression } from '../Expression/IExpression'; import { IExpression } from '../Expression/IExpression';
import { IExpressionParser } from './IExpressionParser';
import { ParameterSubstitutionParser } from '../SyntaxParsers/ParameterSubstitutionParser'; import { ParameterSubstitutionParser } from '../SyntaxParsers/ParameterSubstitutionParser';
import { WithParser } from '../SyntaxParsers/WithParser';
import { IExpressionParser } from './IExpressionParser';
const parsers = [ const Parsers = [
new ParameterSubstitutionParser(), new ParameterSubstitutionParser(),
new WithParser(),
]; ];
export class CompositeExpressionParser implements IExpressionParser { export class CompositeExpressionParser implements IExpressionParser {
public constructor(private readonly leafs: readonly IExpressionParser[] = parsers) { public constructor(private readonly leafs: readonly IExpressionParser[] = Parsers) {
if (leafs.some((leaf) => !leaf)) { throw new Error('undefined leaf'); } if (!leafs) {
throw new Error('missing leafs');
} }
public findExpressions(code: string): IExpression[] { if (leafs.some((leaf) => !leaf)) {
const expressions = new Array<IExpression>(); throw new Error('missing leaf');
for (const parser of this.leafs) {
const newExpressions = parser.findExpressions(code);
if (newExpressions && newExpressions.length) {
expressions.push(...newExpressions);
}
}
return expressions;
} }
}
public findExpressions(code: string): IExpression[] {
return this.leafs.flatMap(
(parser) => parser.findExpressions(code) || [],
);
}
} }

View File

@@ -1,5 +1,5 @@
import { IExpression } from '../Expression/IExpression'; import { IExpression } from '../Expression/IExpression';
export interface IExpressionParser { export interface IExpressionParser {
findExpressions(code: string): IExpression[]; findExpressions(code: string): IExpression[];
} }

View File

@@ -0,0 +1,60 @@
export class ExpressionRegexBuilder {
private readonly parts = new Array<string>();
public expectCharacters(characters: string) {
return this.addRawRegex(
characters
.replaceAll('$', '\\$')
.replaceAll('.', '\\.'),
);
}
public expectOneOrMoreWhitespaces() {
return this
.addRawRegex('\\s+');
}
public matchPipeline() {
return this
.expectZeroOrMoreWhitespaces()
.addRawRegex('(\\|\\s*.+?)?');
}
public matchUntilFirstWhitespace() {
return this
.addRawRegex('([^|\\s]+)');
}
public matchAnythingExceptSurroundingWhitespaces() {
return this
.expectZeroOrMoreWhitespaces()
.addRawRegex('(.+?)')
.expectZeroOrMoreWhitespaces();
}
public expectExpressionStart() {
return this
.expectCharacters('{{')
.expectZeroOrMoreWhitespaces();
}
public expectExpressionEnd() {
return this
.expectZeroOrMoreWhitespaces()
.expectCharacters('}}');
}
public buildRegExp(): RegExp {
return new RegExp(this.parts.join(''), 'g');
}
private expectZeroOrMoreWhitespaces() {
return this
.addRawRegex('\\s*');
}
private addRawRegex(regex: string) {
this.parts.push(regex);
return this;
}
}

View File

@@ -0,0 +1,59 @@
import { IExpressionParser } from '../IExpressionParser';
import { ExpressionPosition } from '../../Expression/ExpressionPosition';
import { IExpression } from '../../Expression/IExpression';
import { Expression, ExpressionEvaluator } from '../../Expression/Expression';
import { IFunctionParameter } from '../../../Function/Parameter/IFunctionParameter';
import { FunctionParameterCollection } from '../../../Function/Parameter/FunctionParameterCollection';
export abstract class RegexParser implements IExpressionParser {
protected abstract readonly regex: RegExp;
public findExpressions(code: string): IExpression[] {
return Array.from(this.findRegexExpressions(code));
}
protected abstract buildExpression(match: RegExpMatchArray): IPrimitiveExpression;
private* findRegexExpressions(code: string): Iterable<IExpression> {
if (!code) {
throw new Error('missing code');
}
const matches = code.matchAll(this.regex);
for (const match of matches) {
const primitiveExpression = this.buildExpression(match);
const position = this.doOrRethrow(() => createPosition(match), 'invalid script position', code);
const parameters = createParameters(primitiveExpression);
const expression = new Expression(position, primitiveExpression.evaluator, parameters);
yield expression;
}
}
private doOrRethrow<T>(action: () => T, errorText: string, code: string): T {
try {
return action();
} catch (error) {
throw new Error(`[${this.constructor.name}] ${errorText}: ${error.message}\nRegex: ${this.regex}\nCode: ${code}`);
}
}
}
function createPosition(match: RegExpMatchArray): ExpressionPosition {
const startPos = match.index;
const endPos = startPos + match[0].length;
return new ExpressionPosition(startPos, endPos);
}
function createParameters(
expression: IPrimitiveExpression,
): FunctionParameterCollection {
return (expression.parameters || [])
.reduce((parameters, parameter) => {
parameters.addParameter(parameter);
return parameters;
}, new FunctionParameterCollection());
}
export interface IPrimitiveExpression {
evaluator: ExpressionEvaluator;
parameters?: readonly IFunctionParameter[];
}

View File

@@ -1,35 +0,0 @@
import { IExpressionParser } from './IExpressionParser';
import { ExpressionPosition } from '../Expression/ExpressionPosition';
import { IExpression } from '../Expression/IExpression';
import { Expression, ExpressionEvaluator } from '../Expression/Expression';
export abstract class RegexParser implements IExpressionParser {
protected abstract readonly regex: RegExp;
public findExpressions(code: string): IExpression[] {
return Array.from(this.findRegexExpressions(code));
}
protected abstract buildExpression(match: RegExpMatchArray): IPrimitiveExpression;
private* findRegexExpressions(code: string): Iterable<IExpression> {
const matches = Array.from(code.matchAll(this.regex));
for (const match of matches) {
const startPos = match.index;
const endPos = startPos + match[0].length;
let position: ExpressionPosition;
try {
position = new ExpressionPosition(startPos, endPos);
} catch (error) {
throw new Error(`[${this.constructor.name}] invalid script position: ${error.message}\nRegex ${this.regex}\nCode: ${code}`);
}
const primitiveExpression = this.buildExpression(match);
const expression = new Expression(position, primitiveExpression.evaluator, primitiveExpression.parameters);
yield expression;
}
}
}
export interface IPrimitiveExpression {
evaluator: ExpressionEvaluator;
parameters?: readonly string[];
}

View File

@@ -0,0 +1,4 @@
export interface IPipe {
readonly name: string;
apply(input: string): string;
}

View File

@@ -0,0 +1,3 @@
export interface IPipelineCompiler {
compile(value: string, pipeline: string): string;
}

View File

@@ -0,0 +1,33 @@
import { IPipe } from '../IPipe';
export class EscapeDoubleQuotes implements IPipe {
public readonly name: string = 'escapeDoubleQuotes';
public apply(raw: string): string {
if (!raw) {
return raw;
}
return raw.replaceAll('"', '"^""');
/* eslint-disable max-len */
/*
"^"" is the most robust and stable choice.
Other options:
""
Breaks, because it is fundamentally unsupported
""""
Does not work with consecutive double quotes.
E.g. `PowerShell -Command "$name='aq'; Write-Host """"Disabled `""""$name`"""""""";"`
Works when using: `PowerShell -Command "$name='aq'; Write-Host "^""Disabled `"^""$name`"^"" "^"";"`
\"
May break as they are interpreted by cmd.exe as metacharacters breaking the command
E.g. `PowerShell -Command "Write-Host 'Hello \"w&orld\"'"` does not work due to unescaped "&"
Works when using: `PowerShell -Command "Write-Host 'Hello "^""w&orld"^""'"`
\""
Normalizes interior whitespace
E.g. `PowerShell -Command "\""a& c\"".length"`, outputs 4 and discards one of two whitespaces
Works when using "^"": `PowerShell -Command ""^""a& c"^"".length"`
A good explanation: https://stackoverflow.com/a/31413730
*/
/* eslint-enable max-len */
}
}

View File

@@ -0,0 +1,166 @@
import { IPipe } from '../IPipe';
export class InlinePowerShell implements IPipe {
public readonly name: string = 'inlinePowerShell';
public apply(code: string): string {
if (!code || !hasLines(code)) {
return code;
}
const processor = new Array<(data: string) => string>(...[ // for broken ESlint "indent"
inlineComments,
mergeLinesWithBacktick,
mergeHereStrings,
mergeNewLines,
]).reduce((a, b) => (data) => b(a(data)));
const newCode = processor(code);
return newCode;
}
}
function hasLines(text: string) {
return text.includes('\n') || text.includes('\r');
}
/*
Line comments using "#" are replaced with inline comment syntax <# comment.. #>
Otherwise single # comments out rest of the code
*/
function inlineComments(code: string): string {
const makeInlineComment = (comment: string) => {
const value = comment?.trim();
if (!value) {
return '<##>';
}
return `<# ${value} #>`;
};
return code.replaceAll(/<#.*?#>|#(.*)/g, (match, captureComment) => {
if (captureComment === undefined) {
return match;
}
return makeInlineComment(captureComment);
});
/*
Other alternatives considered:
--------------------------
/#(?<!<#)(?![<>])(.*)$/gm
-------------------------
✅ Simple, yet matches and captures only what's necessary
❌ Fails to match some cases
❌ `Write-Host "hi" # Comment ending line inline comment but not one #>`
❌ `Write-Host "hi" <#Comment starting like inline comment start but not one`
❌ `Write-Host "hi" #>Comment starting like inline comment end but not one`
❌ Uses lookbehind
Safari does not yet support lookbehind and syntax, leading application to not
load and throw "Invalid regular expression: invalid group specifier name"
https://caniuse.com/js-regexp-lookbehind
⏩ Usage
return code.replaceAll(/#(?<!<#)(?![<>])(.*)$/gm, (match, captureComment) => {
return makeInlineComment(captureComment)
});
----------------
/<#.*?#>|#(.*)/g
----------------
✅ Simple yet affective
❌ Matches all comments, but only captures dash comments
❌ Fails to match some cases
❌ `Write-Host "hi" # Comment ending line inline comment but not one #>`
❌ `Write-Host "hi" <#Comment starting like inline comment start but not one`
⏩ Usage
return code.replaceAll(/<#.*?#>|#(.*)/g, (match, captureComment) => {
if (captureComment === undefined) {
return match;
}
return makeInlineComment(captureComment);
});
------------------------------------
/(^(?:<#.*?#>|[^#])*)(?:(#)(.*))?/gm
------------------------------------
✅ Covers all cases
❌ Matches every line, three capture groups are used to build result
⏩ Usage
return code.replaceAll(/(^(?:<#.*?#>|[^#])*)(?:(#)(.*))?/gm,
(match, captureLeft, captureDash, captureComment) => {
if (!captureDash) {
return match;
}
return captureLeft + makeInlineComment(captureComment);
});
*/
}
function getLines(code: string): string[] {
return (code?.split(/\r\n|\r|\n/) || []);
}
/*
Merges inline here-strings to a single lined string with Windows line terminator (\r\n)
https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_quoting_rules#here-strings
*/
function mergeHereStrings(code: string) {
const regex = /@(['"])\s*(?:\r\n|\r|\n)((.|\n|\r)+?)(\r\n|\r|\n)\1@/g;
return code.replaceAll(regex, (_$, quotes, scope) => {
const newString = getHereStringHandler(quotes);
const escaped = scope.replaceAll(quotes, newString.escapedQuotes);
const lines = getLines(escaped);
const inlined = lines.join(newString.separator);
const quoted = `${newString.quotesAround}${inlined}${newString.quotesAround}`;
return quoted;
});
}
interface IInlinedHereString {
readonly quotesAround: string;
readonly escapedQuotes: string;
readonly separator: string;
}
function getHereStringHandler(quotes: string): IInlinedHereString {
/*
We handle @' and @" differently.
Single quotes are interpreted literally and doubles are expandable.
*/
const expandableNewLine = '`r`n';
switch (quotes) {
case '\'':
return {
quotesAround: '\'',
escapedQuotes: '\'\'',
separator: `'+"${expandableNewLine}"+'`,
};
case '"':
return {
quotesAround: '"',
escapedQuotes: '`"',
separator: expandableNewLine,
};
default:
throw new Error(`expected quotes: ${quotes}`);
}
}
/*
Input ->
Get-Service * `
Sort-Object StartType `
Format-Table Name, ServiceType, Status -AutoSize
Output ->
Get-Service * | Sort-Object StartType | Format-Table -AutoSize
*/
function mergeLinesWithBacktick(code: string) {
/*
The regex actually wraps any whitespace character after backtick and before newline
However, this is not always the case for PowerShell.
I see two behaviors:
1. If inside string, it's accepted (inside " or ')
2. If part of a command, PowerShell throws "An empty pipe element is not allowed"
However we don't need to be so robust and handle this complexity (yet), so for easier regex
we wrap it anyway
*/
return code.replaceAll(/ +`\s*(?:\r\n|\r|\n)\s*/g, ' ');
}
function mergeNewLines(code: string) {
return getLines(code)
.map((line) => line.trim())
.filter((line) => line.length > 0)
.join('; ');
}

View File

@@ -0,0 +1,53 @@
import { IPipe } from './IPipe';
import { InlinePowerShell } from './PipeDefinitions/InlinePowerShell';
import { EscapeDoubleQuotes } from './PipeDefinitions/EscapeDoubleQuotes';
const RegisteredPipes = [
new EscapeDoubleQuotes(),
new InlinePowerShell(),
];
export interface IPipeFactory {
get(pipeName: string): IPipe;
}
export class PipeFactory implements IPipeFactory {
private readonly pipes = new Map<string, IPipe>();
constructor(pipes: readonly IPipe[] = RegisteredPipes) {
if (!pipes) {
throw new Error('missing pipes');
}
if (pipes.some((pipe) => !pipe)) {
throw new Error('missing pipe in list');
}
for (const pipe of pipes) {
this.registerPipe(pipe);
}
}
public get(pipeName: string): IPipe {
validatePipeName(pipeName);
if (!this.pipes.has(pipeName)) {
throw new Error(`Unknown pipe: "${pipeName}"`);
}
return this.pipes.get(pipeName);
}
private registerPipe(pipe: IPipe): void {
validatePipeName(pipe.name);
if (this.pipes.has(pipe.name)) {
throw new Error(`Pipe name must be unique: "${pipe.name}"`);
}
this.pipes.set(pipe.name, pipe);
}
}
function validatePipeName(name: string) {
if (!name) {
throw new Error('empty pipe name');
}
if (!/^[a-z][A-Za-z]*$/.test(name)) {
throw new Error(`Pipe name should be camelCase: "${name}"`);
}
}

View File

@@ -0,0 +1,31 @@
import { IPipeFactory, PipeFactory } from './PipeFactory';
import { IPipelineCompiler } from './IPipelineCompiler';
export class PipelineCompiler implements IPipelineCompiler {
constructor(private readonly factory: IPipeFactory = new PipeFactory()) { }
public compile(value: string, pipeline: string): string {
ensureValidArguments(value, pipeline);
const pipeNames = extractPipeNames(pipeline);
const pipes = pipeNames.map((pipeName) => this.factory.get(pipeName));
return pipes.reduce((previousValue, pipe) => {
return pipe.apply(previousValue);
}, value);
}
}
function extractPipeNames(pipeline: string): string[] {
return pipeline
.trim()
.split('|')
.slice(1)
.map((p) => p.trim());
}
function ensureValidArguments(value: string, pipeline: string) {
if (!value) { throw new Error('missing value'); }
if (!pipeline) { throw new Error('missing pipeline'); }
if (!pipeline.trimStart().startsWith('|')) {
throw new Error('pipeline does not start with pipe');
}
}

View File

@@ -1,12 +1,28 @@
import { RegexParser, IPrimitiveExpression } from '../Parser/RegexParser'; import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
export class ParameterSubstitutionParser extends RegexParser { export class ParameterSubstitutionParser extends RegexParser {
protected readonly regex = /{{\s*\$\s*([^}| ]+)\s*}}/g; protected readonly regex = new ExpressionRegexBuilder()
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression { .expectExpressionStart()
const parameterName = match[1]; .expectCharacters('$')
return { .matchUntilFirstWhitespace() // First match: Parameter name
parameters: [ parameterName ], .matchPipeline() // Second match: Pipeline
evaluator: (args) => args[parameterName], .expectExpressionEnd()
}; .buildRegExp();
}
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
const parameterName = match[1];
const pipeline = match[2];
return {
parameters: [new FunctionParameter(parameterName, false)],
evaluator: (context) => {
const { argumentValue } = context.args.getArgument(parameterName);
if (!pipeline) {
return argumentValue;
}
return context.pipelineCompiler.compile(argumentValue, pipeline);
},
};
}
} }

View File

@@ -0,0 +1,59 @@
import { FunctionParameter } from '@/application/Parser/Script/Compiler/Function/Parameter/FunctionParameter';
import { RegexParser, IPrimitiveExpression } from '../Parser/Regex/RegexParser';
import { ExpressionRegexBuilder } from '../Parser/Regex/ExpressionRegexBuilder';
export class WithParser extends RegexParser {
protected readonly regex = new ExpressionRegexBuilder()
// {{ with $parameterName }}
.expectExpressionStart()
.expectCharacters('with')
.expectOneOrMoreWhitespaces()
.expectCharacters('$')
.matchUntilFirstWhitespace() // First match: parameter name
.expectExpressionEnd()
// ...
.matchAnythingExceptSurroundingWhitespaces() // Second match: Scope text
// {{ end }}
.expectExpressionStart()
.expectCharacters('end')
.expectExpressionEnd()
.buildRegExp();
protected buildExpression(match: RegExpMatchArray): IPrimitiveExpression {
const parameterName = match[1];
const scopeText = match[2];
return {
parameters: [new FunctionParameter(parameterName, true)],
evaluator: (context) => {
const argumentValue = context.args.hasArgument(parameterName)
? context.args.getArgument(parameterName).argumentValue
: undefined;
if (!argumentValue) {
return '';
}
return replaceEachScopeSubstitution(scopeText, (pipeline) => {
if (!pipeline) {
return argumentValue;
}
return context.pipelineCompiler.compile(argumentValue, pipeline);
});
},
};
}
}
const ScopeSubstitutionRegEx = new ExpressionRegexBuilder()
// {{ . | pipeName }}
.expectExpressionStart()
.expectCharacters('.')
.matchPipeline() // First match: pipeline
.expectExpressionEnd()
.buildRegExp();
function replaceEachScopeSubstitution(scopeText: string, replacer: (pipeline: string) => string) {
// Not using /{{\s*.\s*(?:(\|\s*[^{}]*?)\s*)?}}/g for not matching brackets,
// but instead letting the pipeline compiler to fail on those.
return scopeText.replaceAll(ScopeSubstitutionRegEx, (_$, match1) => {
return replacer(match1);
});
}

View File

@@ -0,0 +1,14 @@
import { ensureValidParameterName } from '../../Shared/ParameterNameValidator';
import { IFunctionCallArgument } from './IFunctionCallArgument';
export class FunctionCallArgument implements IFunctionCallArgument {
constructor(
public readonly parameterName: string,
public readonly argumentValue: string,
) {
ensureValidParameterName(parameterName);
if (!argumentValue) {
throw new Error(`missing argument value for "${parameterName}"`);
}
}
}

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