Compare commits

..

20 Commits

Author SHA1 Message Date
undergroundwires
e95b2ba217 win: add scripts to postpone auto-updates #272
This commit adds Windows update postponement techniques.

This provides users more control over the update process, aiming to
prevent automatic re-enabling of updates without user consent.

These scripts are tested and validated on Windows 10 (22H2 onwards) and
Windows 11 (22H3 onwards), introducing registry modifications for
sustained pause durations.
2023-12-16 04:10:02 +01:00
undergroundwires
20633972e9 Fix touch-enabled Chromium highlight on tree nodes
This commit resolves issues with the touch highlight behavior on tree
nodes in touch-enabled Chromium browsers (such as Google Chrome).

The fix addresses two issues:

1. Dual color transition issue during tapping actions on tree nodes.
2. Not highlighting full visible width of the node on keyboard focus.

Other changes include:

- Create `InteractableNode.vue` to centralize click styling and logic.
- Remove redundant click/hover/touch styling from `LeafTreeNode.vue` and
  `HierarchicalTreeNode.vue`.
2023-12-15 08:00:46 +01:00
undergroundwires
3457fe18cf Fix OS switching not working on tree view UI
This commit resolves a rendering bug in the tree view component.
Previously, updating the tree collection prior to node updates led to
rendering errors due to the presence of non-existent nodes in the new
collection.

Changes:

- Implement manual control over the rendering process in tree view. This
  includes clearing the rendering queue and currently rendered nodes
  before updates, aligning the rendering process with the updated
  collection.
- Add Cypress E2E tests to test switching between all operating systems
  and script views, ensuring no uncaught errors and preventing
  regression.
- Replace hardcoded operating system lists in the download URL list view
  with a unified `getSupportedOsList()` method from the application,
  reducing duplication and simplifying future updates.
- Rename `initial-nodes` to `nodes` in `TreeView.vue` to reflect their
  mutable nature.
- Centralize the function for getting operating system names into
  `OperatingSystemNames.ts`, improving reusability in E2E tests.
2023-12-14 09:51:42 +01:00
undergroundwires
fe3de498c8 win: improve disabling of Application Experience
This commit improves disabling of Application Experience component by
improving the categorization, documentation, existing scripts and adding
new scripts. It renames the scripts to be more user-friendly but still
technically accurate.

- Rename scripts to make them easier for non-technical users to
  understand.
- Improve existing documentation and add more documentation.
- Add new scripts for:
  - 'Disable "MareBackup" task'
  - 'Disable "SdbinstMergeDbTask" task'
  - 'Disable "PcaPatchDbTask" task'
- Improve `CompatTelRunner.exe` disabling to soft-delete the file.
2023-12-13 09:14:01 +01:00
undergroundwires
15134ea04b Fix tree view alignment and padding issues
This commit addresses issues with the tree view not fully utilizing the
available width (appearing squeezed on the left) on bigger screens, and
inconsistent padding during searches.

The changes centralize padding and script tree rendering logic to
enforce consistency and prevent regression.

Changes:

- Fix tree view width utilization.
- Refactor SCSS variables for better IDE support.
- Unify padding and tree background color logic for consistent padding
  and coloring around the tree component.
- Fix no padding around the tree in tree view.
- Centralize color SCSS variable for script background for consistent
  application theming.
2023-12-12 03:44:02 +01:00
undergroundwires
a9851272ae Fix touch state not being activated in iOS Safari
This commit resolves the issue with the `:active` pseudo-class not
activating in mobile Safari on iOS devices. It introduces a workaround
specifically for mobile Safari on iOS/iPadOS to enable the `:active`
pseudo-class. This ensures a consistent and responsive user interface
in response to touch states on mobile Safari.

Other supporting changes:

- Introduce new test utility functions such as `createWindowEventSpies`
  and `formatAssertionMessage` to improve code reusability and
  maintainability.
- Improve browser detection:
  - Add detection for iPadOS and Windows 10 Mobile.
  - Add touch support detection to correctly determine iPadOS vs macOS.
  - Fix misidentification of some Windows 10 Mobile platforms as Windows
    Phone.
  - Improve test coverage and refactor tests.
2023-12-11 05:24:27 +01:00
undergroundwires
916c9d62d9 Fix tooltip overflow on smaller screens
This commit addresses three main issues related to tooltips on devices
with smaller screens:

1. Fix tooltip overflow: On mobile devices, tooltips associated with
   script selection options (None, Standard, Strict, All) were
   overflowing due to inherited `white-space: nowrap` styling. This
   styling caused tooltips to render beyond screen limits. The fix
   involves applying `white-space: initial` to the tooltip overlay,
   preventing this style propagation and resolving the overflow issue.
2. Corrects tooltip arrow placement: Previously, when tooltips shifted
   from their default top position to the bottom on smaller screens,
   their arrows did not reposition accordingly. This issue was caused by
   using an incorrect reference for tooltip placement calculation. By
   updating the reference used to the one from `useFloating` function,
   the tooltip arrow now correctly aligns with the adjusted position.
3. Uniform margin handling: Enhances the margin settings around tooltips
   to maintain a consistent gap between the tooltip and the document
   edges, visible particularly on smaller screens.

Additionaly, the `DevToolkit` component has been improved for easier
testing. It is now non-interactable (except for its buttons) to prevent
it from getting in the way when testing on smaller screens. A new close
button has been added, allowing developers/testers to completely hide
the DevToolkit if desired.
2023-12-10 19:53:19 +01:00
undergroundwires
47b4823bc5 win: improve disabling update healing #272
This commit strengthens user control over the Windows Update Medic
Service (`WaaSMedicSvc`) and related components. These changes aim to
provide users with more control over Windows updates and telemetry data
shared with Microsoft, addressing privacy concerns.

Updates include:

- Soft deletion of various Windows Update Medic Service files and
  remediation files to prevent automatic re-enabling of Windows updates.
- Termination of `upfc.exe` to stop it from reactivating Windows Update
  Medic Service, thereby allowing users to maintain their desired update
  settings.
- Improving documentation with cautionary notes to guide users through
  poential impacts of these changes on system stability and update
  integrity.
- Including rationale behind the exclusion of `sedsvc`.
- Better documentation and output messages of `DisableService` function.
2023-12-09 19:30:33 +01:00
undergroundwires
c72f9f5016 win: discourage XboxIdentityProvider #64, #79 #181
Recommending the script that removes "Xbox Identity Provider" app
(`Microsoft.XboxIdentityProvider`) at the "Standard" level has led to
unforeseen consequences for Windows users using Xbox sign-in.

This commit introduces additional documentation and reduces the
recommendation level to mitigate these issues.

- Change recommendation level from "Standard" to "Strict".
- Improve documentation to outline the impact of uninstalling the "Xbox
  Identity Provider" app.
- Update script title to warn users about the breaking behavior.
2023-12-08 13:11:12 +01:00
undergroundwires
e747ee5cbc win: document and discourage admin shares #249
- Reduce recommendation level from "Standard" to "Strict" due to its
  potential breaking behavior.
- Add detailed documentation.
- Simplify script title for broader accessibility while maintaining
  technical accuracy.
- Note potential impact on remote system management in the script title.
- Adjust revert code align with recent Windows OS version.
2023-12-07 12:59:37 +01:00
undergroundwires
ba5b29a35d Improve security and privacy with strict meta tags
This commit introduces two meta tags to strengthen the application's
security posture and enhance user privacy, following best practices and
OWASP recommendations.

- Add Content-Security-Policy (CSP) to strictly to strictly control
  which resources the application is allowed, mitigating the risk of
  code injection attacks such as Cross-Site Scripting (XSS).
- Add `referrer` meta tag to prevent the users' browser from sending the
  page's address, or referrer, when navigating to another site, thereby
  enhancing user privacy.
2023-12-06 15:08:58 +01:00
undergroundwires
daa6230fc9 win: fix Win 11 Windows Security app removal #195
This commit fixes the issue of Windows Security app not being removed in
Windows 11. It addresses the problem by extending the app uninstallation
process to cover the new app package specific to Windows 11. It improves
the overall design of templated functions for store app removal to
implement the fix.

- Improve Windows Security removal script:
  - Add support for removing `Microsoft.SecHealthUI` in Windows 11.
  - Revise script documentation for clarity and correct typos.
- Redesign uninstallion of Store apps:
  - Change `UninstallSystemApp` to `UninstallNonRemovableStoreApp` for
    wider usage. This change is due to `Microsoft.SecHealthUI` being
    non-removable yet not a system app.
  - Refactor app data cleanup into two distinct functions
    (`ClearStoreAppDataBeforeUninstallation` and
    `ClearStoreAppDataAfterUninstallation`) for better clarity and
    maintainability. This also helps in testing by allowing easier
    reordering of operations.
  - Seperate between simple non-removable app uninstallation and
    uninstallation with cleanup in separate functions, highlighting that
    the latter is more invasive and should be used cautiously. This
    addresses permission issues encountered with `SecHealthUI` app
    removal during cleanup on Windows 11.
  - Separate uninstalling app and uninstalling app with cleanup to
    different functions, document that cleanup should no longer be
    prefered as it's invasive and too aggresive. Cleanup logic
    introduces permission issues/errors for `SecHealthUI` in Windows 11.
  - Extend app soft-deletion to include the default Windows app folder,
    this ensures that the cleanup covers any kind of Store apps (not
    only system apps).
2023-12-05 17:35:03 +01:00
undergroundwires
4765752ee3 Improve security and reliability of macOS updates
This commit introduces several improvements to the macOS update process,
primarily focusing on enhancing security and reliability:

- Add data integrity checks to ensure downloaded updates haven't been
  tampered with.
- Optimize update progress logging in `streamWithProgress` by limiting
  amount of logs during the download process.
- Improve resource management by ensuring proper closure of file
  read/write streams.
- Add retry logic with exponential back-off during file access to handle
  occassionally seen file system preparation delays on macOS.
- Improve decision-making based on user responses.
- Improve clarity and informativeness of log messages.
- Update error dialogs for better user guidance when updates fail to
  download, unexpected errors occur or the installer can't be opened.
- Add handling for unexpected errors during the update process.
- Move to asynchronous functions for more efficient operation.
- Move to scoped imports for better code clarity.
- Update `Readable` stream type to a more modern variant in Node.
- Refactor `ManualUpdater` for improved separation of concerns.
- Document the secure update process, and log directory locations.
- Rename files to more accurately reflect their purpose.
- Add `.DS_Store` in `.gitignore` to avoid unintended files in commits.
2023-12-04 18:28:43 +01:00
undergroundwires
25e23c89c3 win: fix revert and improve docs for SAM enum #255
- Rename script for simplicity.
- Add documentation.
- Fix default value not matching default OS state.
- Fix wrong registry path.
2023-12-03 17:07:49 +01:00
undergroundwires
08dbfead7c Centralize log file and refactor desktop logging
- Migrate to `electron-log` v5.X.X, centralizing log files to adhere to
  best-practices.
- Add critical event logging in the log file.
- Replace `ElectronLog` type with `LogFunctions` for better abstraction.
- Unify log handling in `desktop-runtime-error` by removing
  `renderer.log` due to `electron-log` v5 changes.
- Update and extend logger interfaces, removing 'I' prefix and adding
  common log levels to abstract `electron-log` completely.
- Move logger interfaces to the application layer as it's cross-cutting
  concern, meanwhile keeping the implementations in the infrastructure
  layer.
- Introduce `useLogger` hook for easier logging in Vue components.
- Simplify `WindowVariables` by removing nullable properties.
- Improve documentation to clearly differentiate between desktop and web
  versions, outlining specific features of each.
2023-12-02 11:50:25 +01:00
undergroundwires
8f5d7ed3cf win: improve documentation for "Get Help" app #280
- Update script name to mention breaking behavior.
- Add documentation to explain what the app does and how it impacts
  system functionality.
2023-12-01 14:49:24 +01:00
undergroundwires
807ae6a8f8 win: fix logic for terminating processes
This commit fixes and improves the process termination functionality in
related functions.

`KillProcessWhenItStarts` shared function:

- Fix registry key values configured by removing unnecessary single
  quotes.
- Rename to `TerminateExecutableOnLaunch` for clarity.
- Rename parameter `processName` to `executableNameWithExtension` for
  clarity.
- Add code comments.
- Document the function.
- Rename `%windir` to `%WINDIR%` for consistency in environment variable
  naming across scripts.
- Integrate `KillProcess` for robustness.
- Suppress errors in revert code to prevent false negatives.

`KillProcess` shared function to be able to support the termination:

- Rename to `TerminateRunningProcess` for clarity.
- Rename parameters for clarity and consistency:
  - `processName` to `executableNameWithExtension`.
  - `processStartPath` to `revertExecutablePath`.
  - `processStartArgs` to `revertExecutableArgs`.
- Make revert logic optional.
- Add code comments.
2023-11-30 08:15:24 +01:00
undergroundwires
5a7d7d88ff mac: improve clearing privacy permissions
- Improve the service permissions reset logic:
  - Implement more intuitive and user-friendly messages.
  - Ensure graceful handling when `tccutil` is unavailable.
  - Avoid treating unsupported service IDs as errors.
  - Introduce atemplated shared function.
- Rename 'Clear all privacy permissions for applications' to
  'Clear application privacy permissions' to enhance clarity.
- Add additional documentation.
- Introduce support for missing service permissions.
- Fix a bug where clearing "contacts" permissions inadvertently affected
  "full disk access" permissions.
- Move the option to clear all application permissions to top for
  improved accessibility.
- Standardize naming across scripts to maintain consistency and clarity.
2023-11-29 13:07:41 +01:00
undergroundwires
40ae8a8add win: improve docs and category of jump lists #146
- Add more documentation and improve existing documetation.
- Rename 'Clear most recently used (MRU) lists' to 'Clear recent
  activity logs' for simplicity.
- Move 'clearing recent activity logs' outside of 'Clear
  third-application data' to directy under 'Privacy cleanup' as these
  recent activities are not always necessarily from third-party
  applications.
- Fix dead link.

Co-authored-by: NerdyGamerB0i <85419060+NerdyGamerB0i@users.noreply.github.com>
2023-11-28 12:17:21 +01:00
undergroundwires-bot
6488e81901 ⬆️ bump everywhere to 0.12.8 2023-11-27 10:32:33 +00:00
119 changed files with 5224 additions and 1771 deletions

3
.gitignore vendored
View File

@@ -11,3 +11,6 @@ node_modules
# draw.io
*.bkp
*.dtmp
# macOS
.DS_Store

View File

@@ -1,5 +1,30 @@
# Changelog
## 0.12.8 (2023-11-27)
* Remove duplicated `index.html` file | [aab0f7e](https://github.com/undergroundwires/privacy.sexy/commit/aab0f7ea4680f377c610066bd0e99011eed8b506)
* Refactor DI for simplicity and type safety | [7770a9b](https://github.com/undergroundwires/privacy.sexy/commit/7770a9b5211d7208cfb2bfa5f737d46dc90b7946)
* Refactor user selection state handling using hook | [58cd551](https://github.com/undergroundwires/privacy.sexy/commit/58cd551a304a03e42637e6858982f8c5dfd9f598)
* Refactor watch sources for reliability | [7ab16ec](https://github.com/undergroundwires/privacy.sexy/commit/7ab16ecccb31b2d54e5b634520a8246fbbc248c1)
* Refactor to enforce strictNullChecks | [949fac1](https://github.com/undergroundwires/privacy.sexy/commit/949fac1a7cbc962ed63058e6a896695cfb4d35c8)
* Fix icon tooltip alignment on instructions modal | [bd383ed](https://github.com/undergroundwires/privacy.sexy/commit/bd383ed273ca95c10ea1cce765c0aa6836ec508c)
* Fix mobile layout overflow caused by tooltips | [e541a35](https://github.com/undergroundwires/privacy.sexy/commit/e541a35e86c0eff83f84dd002b46de7c55ebbcac)
* win: improve disabling of scheduled tasks | [3864f04](https://github.com/undergroundwires/privacy.sexy/commit/3864f042180f62afe469fdfe36010b018f84f4b3)
* Fix card list UI layout shifts (jumps) on load | [bf3426f](https://github.com/undergroundwires/privacy.sexy/commit/bf3426f91b6b7dbcad58d58507222559a8d14242)
* Refactor to Vue 3 recommended ESLint rules | [4531645](https://github.com/undergroundwires/privacy.sexy/commit/4531645b4c0c5143f15240652368bb9b9ddb48a4)
* Fix code highlighting and optimize category select | [cb42f11](https://github.com/undergroundwires/privacy.sexy/commit/cb42f11b9785e74719338a0a80a50d81dfccb4b6)
* Fix layout jumps/shifts and overflow on modals | [e299d40](https://github.com/undergroundwires/privacy.sexy/commit/e299d40fa1d71d921d4dac37e469fe299c9da3af)
* win: fix and improve Store app categorization #190 | [094dbb0](https://github.com/undergroundwires/privacy.sexy/commit/094dbb01b83bce9925fafab778b922f64390c2be)
* win: fix persistent update disabling /w tasks #272 | [dee3279](https://github.com/undergroundwires/privacy.sexy/commit/dee3279f85c99a9c62201a093b1afa41ec2412ec)
* win: discourage IntelliCode disabling #267, #286 | [7f7a84e](https://github.com/undergroundwires/privacy.sexy/commit/7f7a84e3ba259fade22d4838563d16129a1585e6)
* Fix spacing in documentation for readability | [1442f62](https://github.com/undergroundwires/privacy.sexy/commit/1442f626335e30e3a8d74e4e13e561c41f073ef8)
* win: fix system app removal affecting updates #287 | [7c632f7](https://github.com/undergroundwires/privacy.sexy/commit/7c632f738853b32fd90952bb4ca1ac924f962eb0)
* Fix rendering of inline code blocks for docs | [9845a7c](https://github.com/undergroundwires/privacy.sexy/commit/9845a7cd68a9920c96da739b58238bb1fdb1251d)
* linux: fix Firefox settings not reverting #282 | [bcad357](https://github.com/undergroundwires/privacy.sexy/commit/bcad357017d9f29ce77e706ca943107dd9caefb6)
* Fix incorrect URL rendering in documentation texts | [d328f08](https://github.com/undergroundwires/privacy.sexy/commit/d328f0895244d998e885ad8df335b6444b9ac66b)
[compare](https://github.com/undergroundwires/privacy.sexy/compare/0.12.7...0.12.8)
## 0.12.7 (2023-11-07)
* Add winget download instructions | [b2ffc90](https://github.com/undergroundwires/privacy.sexy/commit/b2ffc90da70367b9e65c82556e8f440f865ceb98)

View File

@@ -122,11 +122,11 @@
## Get started
- 🌍️ **Online**: [https://privacy.sexy](https://privacy.sexy).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.7/privacy.sexy-Setup-0.12.7.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.7/privacy.sexy-0.12.7.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.7/privacy.sexy-0.12.7.AppImage). For more options, see [here](#additional-install-options).
- 🖥️ **Offline**: Download directly for: [Windows](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.8/privacy.sexy-Setup-0.12.8.exe), [macOS](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.8/privacy.sexy-0.12.8.dmg), [Linux](https://github.com/undergroundwires/privacy.sexy/releases/download/0.12.8/privacy.sexy-0.12.8.AppImage). For more options, see [here](#additional-install-options).
Online version does not require to run any software on your computer. Offline version has more functions such as running the scripts directly.
For a detailed comparison of features between the desktop and web versions of privacy.sexy, see [Desktop vs. Web Features](./docs/desktop-vs-web-features.md).
💡 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.
💡 Regularly applying your configuration with privacy.sexy is recommended, especially after each new release and major operating system updates. Each version updates scripts to enhance stability, privacy, and security.
[![privacy.sexy application](img/screenshot.png?raw=true )](https://privacy.sexy)
@@ -179,4 +179,6 @@ Check [architecture.md](./docs/architecture.md) for an overview of design and ho
## Security
Security is a top priority at privacy.sexy. An extensive commitment to security verification ensures this priority. For any security concerns or vulnerabilities, please consult the [Security Policy](./SECURITY.md).
Security is a top priority at privacy.sexy.
An extensive commitment to security verification ensures this priority.
For any security concerns or vulnerabilities, please consult the [Security Policy](./SECURITY.md).

View File

@@ -1,6 +1,7 @@
# Security Policy
privacy.sexy takes security seriously. Commitment is made to address all security issues with urgency. Responsible reporting of any discovered vulnerabilities in the project is highly encouraged.
Security is a top priority at privacy.sexy.
Please report any discovered vulnerabilities responsibly.
## Reporting a Vulnerability
@@ -11,20 +12,45 @@ Efforts to responsibly disclose findings are greatly appreciated. To report a se
## Security Report Handling
Upon receipt of a security report, the following actions will be taken:
Upon receiving a security report, the process involves:
- The report will be confirmed, identifying the affected components.
- The impact and severity of the issue will be assessed.
- Work on a fix and plan a release to address the vulnerability will be initiated.
- The reporter will be kept updated about the progress.
- Confirming the report and identifying affected components.
- Assessing the impact and severity of the issue.
- Fixing the vulnerability and planning a release to address it.
- Keeping the reporter informed about progress.
## Testing
## Security Practices
Regular and extensive testing is conducted to ensure robust security in the project. Information about testing practices can be found in the [Testing Documentation](./docs/tests.md).
### Application Security
privacy.sexy adopts a defense in depth strategy to protect users on multiple layers:
- **Link Protection:**
privacy.sexy ensures each external link has special attributes for your privacy and security.
These attributes block the new site from accessing the privacy.sexy page, increasing your online safety and privacy.
- **Content Security Policies (CSP):**
privacy.sexy actively follows security guidelines from the Open Web Application Security Project (OWASP) at strictest level.
This approach protects against attacks like Cross Site Scripting (XSS) and data injection.
- **Context Isolation:**
The desktop application isolates different code sections based on their access level.
This separation prevents attackers from introducing harmful code into the app, known as injection attacks.
### Update Security and Integrity
privacy.sexy benefits from automated update processes including security tests. Automated deployments from source code ensure immediate and secure updates, mirroring the latest source code. This aligns the deployed application with the expected source code, enhancing transparency and trust. For more details, see [CI/CD Documentation](./docs/ci-cd.md).
Every desktop update undergoes a thorough verification process. Updates are cryptographically signed to ensure authenticity and integrity, preventing tampered versions from reaching your device. Version checks are conducted to prevent downgrade attacks.
### Testing
privacy.sexy's testing approach includes a mix of automated and community-driven tests.
Details on testing practices are available in the [Testing Documentation](./docs/tests.md).
## Support
For additional assistance or any unanswered questions, [submit a GitHub issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose). Security concerns are a priority, and necessary support to address them is assured.
For help or any questions, [submit a GitHub issue](https://github.com/undergroundwires/privacy.sexy/issues/new/choose). Addressing security concerns is a priority, and we ensure the necessary support.
Support privacy.sexy's commitment to security by [making a donation ❤️](https://github.com/sponsors/undergroundwires). Your contributions aid in maintaining and enhancing the project's security features.
---

View File

@@ -0,0 +1,54 @@
# Desktop vs. Web Features
This table highlights differences between the desktop and web versions of `privacy.sexy`.
| Feature | Desktop | Web |
| ------- | ------- | --- |
| [Usage without installation](#usage-without-installation) | 🔴 Not available | 🟢 Available |
| [Offline usage](#offline-usage) | 🟢 Available | 🟡 Partially available |
| [Auto-updates](#auto-updates) | 🟢 Available | 🟢 Available |
| [Logging](#logging) | 🟢 Available | 🔴 Not available |
| [Script execution](#script-execution) | 🟢 Available | 🔴 Not available |
## Feature descriptions
### Usage without installation
You can use the web version directly in a browser without installation.
The desktop version requires download and installation.
> **Note for Linux users:** On Linux, privacy.sexy is available as an `AppImage`, a portable format that doesn't need traditional installation.
> This allows Linux users to use the desktop version without full installation, akin to the web version.
### Offline usage
The web version, once loaded, supports offline use.
Desktop version inherently allows offline usage.
### Auto-updates
Both the desktop and web versions of privacy.sexy provide timely access to the latest features and security improvements. The updates are automatically deployed from source code, reflecting the latest changes for enhanced security and reliability. For more details, see [CI/CD documentation](./ci-cd.md).
The desktop version ensures secure delivery through cryptographic signatures and version checks.
[Security is a top priority](./../SECURITY.md#update-security-and-integrity) at privacy.sexy.
> **Note for macOS users:** On macOS, the desktop version's auto-update process involves manual steps due to Apple's code signing costs.
> Users get notified about updates but might need to complete the installation manually.
> Consider [donating](https://github.com/sponsors/undergroundwires) to help improve this process ❤️.
### Logging
The desktop version supports logging of activities to aid in troubleshooting.
This feature is not available in the web version.
Log file locations vary by operating system:
- macOS: `$HOME/Library/Logs/privacy.sexy`
- Linux: `$HOME/.config/privacy.sexy/logs`
- Windows: `%APPDATA%\privacy.sexy\logs`
### Script execution
Direct execution of scripts is possible in the desktop version, offering a more integrated experience.
This functionality is not present in the web version due to browser limitations.

21
package-lock.json generated
View File

@@ -1,19 +1,19 @@
{
"name": "privacy.sexy",
"version": "0.12.7",
"version": "0.12.8",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "privacy.sexy",
"version": "0.12.7",
"version": "0.12.8",
"hasInstallScript": true,
"dependencies": {
"@floating-ui/vue": "^1.0.2",
"@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.30.0",
"cross-fetch": "^4.0.0",
"electron-log": "^4.4.8",
"electron-log": "^5.0.1",
"electron-progressbar": "^2.1.0",
"electron-updater": "^6.1.4",
"file-saver": "^2.0.5",
@@ -6699,9 +6699,12 @@
}
},
"node_modules/electron-log": {
"version": "4.4.8",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.8.tgz",
"integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.0.1.tgz",
"integrity": "sha512-x4wnwHg00h/onWQgjmvcdLV7Mrd9TZjxNs8LmXVpqvANDf4FsSs5wLlzOykWLcaFzR3+5hdVEQ8ctmrUxgHlPA==",
"engines": {
"node": ">= 14"
}
},
"node_modules/electron-progressbar": {
"version": "2.1.0",
@@ -24483,9 +24486,9 @@
}
},
"electron-log": {
"version": "4.4.8",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-4.4.8.tgz",
"integrity": "sha512-QQ4GvrXO+HkgqqEOYbi+DHL7hj5JM+nHi/j+qrN9zeeXVKy8ZABgbu4CnG+BBqDZ2+tbeq9tUC4DZfIWFU5AZA=="
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/electron-log/-/electron-log-5.0.1.tgz",
"integrity": "sha512-x4wnwHg00h/onWQgjmvcdLV7Mrd9TZjxNs8LmXVpqvANDf4FsSs5wLlzOykWLcaFzR3+5hdVEQ8ctmrUxgHlPA=="
},
"electron-progressbar": {
"version": "2.1.0",

View File

@@ -1,6 +1,6 @@
{
"name": "privacy.sexy",
"version": "0.12.7",
"version": "0.12.8",
"private": true,
"slogan": "Now you have the choice",
"description": "Enforce privacy & security best-practices on Windows, macOS and Linux, because privacy is sexy 🍑🍆",
@@ -37,7 +37,7 @@
"@juggle/resize-observer": "^3.4.0",
"ace-builds": "^1.30.0",
"cross-fetch": "^4.0.0",
"electron-log": "^4.4.8",
"electron-log": "^5.0.1",
"electron-progressbar": "^2.1.0",
"electron-updater": "^6.1.4",
"file-saver": "^2.0.5",

View File

@@ -0,0 +1,6 @@
export interface Logger {
info(...params: unknown[]): void;
warn(...params: unknown[]): void;
error(...params: unknown[]): void;
debug(...params: unknown[]): void;
}

View File

@@ -0,0 +1,5 @@
import { Logger } from '@/application/Common/Log/Logger';
export interface LoggerFactory {
readonly logger: Logger;
}

View File

@@ -3241,6 +3241,8 @@ functions:
revertCode: '{{ with $revertCode }}{{ . }}{{ end }}'
-
name: RunIfCommandExists # Skips if command does not exist
# Marked: refactor-with-partials
# Same function as macOS
parameters:
- name: command
- name: code

View File

@@ -444,47 +444,285 @@ actions:
recommend: standard
code: sudo purge
-
category: Clear all privacy permissions for applications
category: Clear application privacy permissions
docs: |-
This category provides scripts to reset privacy permissions for a variety of applications on your device,
helping you to re-establish control over your personal data. Each script targets a specific permission type such
as camera, microphone, contacts, or accessibility services enabling you to revoke permissions that have previously
been granted to applications.
By resetting these permissions, you not only enhance your privacy but also improve your device's security. After
running these scripts, applications will require your explicit permission again to access these services or
information. This means the next time an app attempts to use a service like your camera or access your contacts,
you'll be prompted to grant or deny permission. It's a proactive step to ensure that your sensitive information
or system services are accessed only with your current and informed consent.
children:
# Main documentation: https://archive.ph/26Hlq (https://developer.apple.com/documentation/devicemanagement/privacypreferencespolicycontrol/services)
-
name: Clear "camera" permissions
code: tccutil reset Camera
name: Clear **"All"** permissions
docs: |-
This script resets all permissions for applications.
It revokes all previously granted permissions, enhancing privacy and security by ensuring no application has unauthorized access to system services or user data.
call:
function: ResetServicePermissions
parameters:
serviceId: All
-
name: Clear "microphone" permissions
code: tccutil reset Microphone
name: Clear "Camera" permissions
docs: |-
This script resets permissions for camera access [1].
It ensures no application can access the system camera without explicit user permission, protecting against unauthorized surveillance and data breaches.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: Camera
-
name: Clear "accessibility" permissions
code: tccutil reset Accessibility
name: Clear "Microphone" permissions
docs: |-
This script resets permissions for microphone access [1].
It revokes all granted access to the microphone, protecting against eavesdropping and unauthorized audio recording by applications.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: Microphone
-
name: Clear "screen capture" permissions
code: tccutil reset ScreenCapture
name: Clear "Accessibility" permissions
docs: |-
This script resets permissions for accessibility features [1].
It revokes application access to accessibility services, preventing misuse and ensuring these features are used only with user consent.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: Accessibility
-
name: Clear "reminders" permissions
code: tccutil reset Reminders
name: Clear "Screen Capture" permissions
docs: |-
This script resets permissions for screen capture [1].
It ensures applications cannot capture screen content without user authorization, protecting sensitive information displayed on the screen.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: ScreenCapture
-
name: Clear "photos" permissions
code: tccutil reset Photos
name: Clear "Reminders" permissions
docs: |-
This script resets permissions for accessing reminders information managed by the Reminders app [1].
It ensures applications cannot access or modify reminders data without explicit user permission, maintaining the privacy of personal reminders.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: Reminders
-
name: Clear "calendar" permissions
code: tccutil reset Calendar
name: Clear "Photos" permissions
docs: |-
This script resets permissions for accessing the pictures managed by the Photos app [1].
It revokes all permissions granted to applications, safeguarding personal photos and media from unauthorized access.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: Photos
-
name: Clear "full disk access" permissions
code: tccutil reset SystemPolicyAllFiles
name: Clear "Calendar" permissions
docs: |-
This script resets permissions for accessing the calendar information managed by the Calendar app [1].
It ensures that applications cannot access calendar data without user consent, protecting personal and sensitive calendar information.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: Calendar
-
name: Clear "contacts" permissions
code: tccutil reset SystemPolicyAllFiles
name: Clear "Full Disk Access" permissions
docs: |-
This script resets permissions for full disk access.
Full disk access allows the application access to all protected files, including system administration files [1].
It revokes broad file access from applications, significantly reducing the risk of data exposure and enhancing overall system security.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: SystemPolicyAllFiles
-
name: Clear "desktop folder" permissions
code: tccutil reset SystemPolicyDesktopFolder
name: Clear "Contacts" permissions
docs: |-
This script resets permissions for accessing contacts.
The contact information managed by the Contacts app [1].
It ensures that applications cannot access the user's contact list without explicit permission, maintaining the confidentiality of personal contacts.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: AddressBook
-
name: Clear "documents folder" permissions
code: tccutil reset SystemPolicyDocumentsFolder
name: Clear "Desktop Folder" permissions
docs: |-
This script resets permissions for accessing the Desktop folder [1].
It revokes application access to files on the desktop, protecting personal and work-related documents from unauthorized access.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: SystemPolicyDesktopFolder
-
name: Clear "downloads" permissions
code: tccutil reset SystemPolicyDownloadsFolder
name: Clear "Documents Folder" permissions
docs: |-
This script resets permissions for accessing the Documents folder [1].
It prevents applications from accessing files in this folder without user consent, safeguarding important and private documents.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: SystemPolicyDocumentsFolder
-
name: Clear all app permissions
code: tccutil reset All
name: Clear "Downloads Folder" permissions
docs: |-
This script resets permissions for accessing the Downloads folder [1].
It ensures that applications cannot access downloaded files without user authorization, protecting downloaded content from misuse.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: SystemPolicyDownloadsFolder
-
name: Clear "Apple Events" permissions
docs: |-
This script resets permissions for Apple Events [1].
It revokes permissions for applications to send restricted Apple Events to other processes [1], enhancing privacy and security.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: AppleEvents
-
name: Clear "File Provider Presence" permissions
docs: |-
This script resets permissions for File Provider Presence [1].
It revokes the ability of File Provider applications to know when the user is accessing their managed files [1], enhancing user privacy.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: FileProviderPresence
-
name: Clear "Listen Events" permissions
docs: |-
This script resets "ListenEvent" permissions [1].
It revokes application access to listen to system events [1], preventing unauthorized monitoring of user interactions with the system.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: ListenEvent
-
name: Clear "Media Library" permissions
docs: |-
This script resets permissions for accessing the Media Library [1].
It ensures that applications cannot access Apple Music, music and video activity, and the media library [1] without user consent.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: MediaLibrary
-
name: Clear "Post Event" permissions
docs: |-
This script resets permissions for sending "PostEvent" [1].
It prevents applications from using CoreGraphics APIs to send system events [1], safeguarding against potential misuse.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: PostEvent
-
name: Clear "Speech Recognition" permissions
recommend: strict
docs: |-
This script resets permissions for using Speech Recognition [1].
It revokes application access to the speech recognition facility and sending speech data to Apple [1], protecting user privacy.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: SpeechRecognition
-
name: Clear "App Modification" permissions
docs: |-
This script resets permissions for modifying other apps [1].
It prevents applications from updating or deleting other apps [1], maintaining system integrity and user control.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: SystemPolicyAppBundles
-
name: Clear "Application Data" permissions
docs: |-
This script resets permissions for accessing application data [1].
It revokes application access to specific application data, enhancing privacy and data security.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: SystemPolicyAppData
-
name: Clear "Network Volumes" permissions
docs: |-
This script resets permissions for accessing files on network volumes [1].
It ensures applications cannot access network files without user authorization.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: SystemPolicyNetworkVolumes
-
name: Clear "Removable Volumes" permissions
docs: |-
This script resets permissions for accessing files on removable volumes [1].
It protects data on external drives from unauthorized application access.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: SystemPolicyRemovableVolumes
-
name: Clear "System Administration Files" permissions
docs: |-
This script resets permissions for accessing system administration files [1].
It enhances system security by restricting application access to critical system files.
[1]: https://archive.ph/26Hlq "PrivacyPreferencesPolicyControl.Services | Apple Developer Documentation | apple.com"
call:
function: ResetServicePermissions
parameters:
serviceId: SystemPolicySysAdminFiles
-
category: Configure programs
children:
@@ -1268,3 +1506,55 @@ functions:
echo "[$profile_file] No need for any action, configuration does not exist"
fi
done
-
name: RunIfCommandExists # Skips if command does not exist
# Marked: refactor-with-partials
# Same function as Linux
parameters:
- name: command
- name: code
- name: revertCode
optional: true
code: |-
if ! command -v '{{ $command }}' &> /dev/null; then
echo 'Skipping because "{{ $command }}" is not found.'
else
{{ $code }}
fi
revertCode: |-
{{ with $revertCode }}
if ! command -v '{{ $command }}' &> /dev/null; then
>&2 echo 'Cannot revert because "{{ $command }}" is not found.'
else
{{ . }}
fi
{{ end }}
-
name: ResetServicePermissions
parameters:
- name: serviceId # Specifies the service ID for which to reset permissions
docs: |-
This function resets the specified service ID permissions.
The `serviceId` parameter allows you to define the specific service ID (e.g., Camera, Microphone,
Accessibility) for which you want to reset all user-granted permissions.
call:
function: RunIfCommandExists
parameters:
command: tccutil
code: |-
declare serviceId='{{ $serviceId }}'
declare reset_output reset_exit_code
{
reset_output=$(tccutil reset "$serviceId" 2>&1)
reset_exit_code=$?
}
if [ $reset_exit_code -eq 0 ]; then
echo "Successfully reset permissions for \"${serviceId}\"."
elif [ $reset_exit_code -eq 70 ]; then
echo "Skipping, service ID \"${serviceId}\" is not supported on your operating system version."
elif [ $reset_exit_code -ne 0 ]; then
>&2 echo "Failed to reset permissions for \"${serviceId}\". Exit code: $reset_exit_code."
if [ -n "$reset_output" ]; then
echo "Output from \`tccutil\`: $reset_output."
fi
fi

File diff suppressed because it is too large Load Diff

View File

@@ -4,10 +4,34 @@ export enum OperatingSystem {
Linux,
KaiOS,
ChromeOS,
BlackBerryOS,
BlackBerry,
BlackBerryTabletOS,
Android,
iOS,
iPadOS,
/**
* Legacy: Released in 1999, discontinued in 2013, succeeded by BlackBerry10.
*/
BlackBerryOS,
/**
* Legacy: Released in 2013, discontinued in 2015, succeeded by {@link OperatingSystem.Android}.
*/
BlackBerry10,
/**
* Legacy: Released in 2010, discontinued in 2017,
* succeeded by {@link OperatingSystem.Windows10Mobile}.
*/
WindowsPhone,
/**
* Legacy: Released in 2015, discontinued in 2017, succeeded by {@link OperatingSystem.Android}.
*/
Windows10Mobile,
/**
* Also known as "BlackBerry PlayBook OS"
* Legacy: Released in 2011, discontinued in 2014, succeeded by {@link OperatingSystem.Android}.
*/
BlackBerryTabletOS,
}

View File

@@ -1,17 +1,32 @@
import { ILogger } from './ILogger';
import { Logger } from '@/application/Common/Log/Logger';
export class ConsoleLogger implements ILogger {
constructor(private readonly consoleProxy: Partial<Console> = console) {
export class ConsoleLogger implements Logger {
constructor(private readonly consoleProxy: ConsoleLogFunctions = globalThis.console) {
if (!consoleProxy) { // do not trust strictNullChecks for global objects
throw new Error('missing console');
}
}
public info(...params: unknown[]): void {
const logFunction = this.consoleProxy?.info;
if (!logFunction) {
throw new Error('missing "info" function');
}
logFunction.call(this.consoleProxy, ...params);
this.consoleProxy.info(...params);
}
public warn(...params: unknown[]): void {
this.consoleProxy.warn(...params);
}
public error(...params: unknown[]): void {
this.consoleProxy.error(...params);
}
public debug(...params: unknown[]): void {
this.consoleProxy.debug(...params);
}
}
interface ConsoleLogFunctions extends Partial<Console> {
readonly info: Console['info'];
readonly warn: Console['warn'];
readonly error: Console['error'];
readonly debug: Console['debug'];
}

View File

@@ -1,17 +1,15 @@
import { ElectronLog } from 'electron-log';
import { ILogger } from './ILogger';
import log from 'electron-log/main';
import { Logger } from '@/application/Common/Log/Logger';
import type { LogFunctions } from 'electron-log';
// Using plain-function rather than class so it can be used in Electron's context-bridging.
export function createElectronLogger(logger: Partial<ElectronLog>): ILogger {
if (!logger) {
throw new Error('missing logger');
}
export function createElectronLogger(logger: LogFunctions = log): Logger {
return {
info: (...params) => {
if (!logger.info) {
throw new Error('missing "info" function');
}
logger.info(...params);
},
info: (...params) => logger.info(...params),
debug: (...params) => logger.debug(...params),
warn: (...params) => logger.warn(...params),
error: (...params) => logger.error(...params),
};
}
export const ElectronLogger = createElectronLogger();

View File

@@ -1,3 +0,0 @@
export interface ILogger {
info (...params: unknown[]): void;
}

View File

@@ -1,5 +0,0 @@
import { ILogger } from './ILogger';
export interface ILoggerFactory {
readonly logger: ILogger;
}

View File

@@ -1,5 +1,11 @@
import { ILogger } from './ILogger';
import { Logger } from '@/application/Common/Log/Logger';
export class NoopLogger implements ILogger {
export class NoopLogger implements Logger {
public info(): void { /* NOOP */ }
public warn(): void { /* NOOP */ }
public error(): void { /* NOOP */ }
public debug(): void { /* NOOP */ }
}

View File

@@ -1,11 +1,11 @@
import { Logger } from '@/application/Common/Log/Logger';
import { WindowVariables } from '../WindowVariables/WindowVariables';
import { ILogger } from './ILogger';
export class WindowInjectedLogger implements ILogger {
private readonly logger: ILogger;
export class WindowInjectedLogger implements Logger {
private readonly logger: Logger;
constructor(windowVariables: WindowVariables | undefined | null = window) {
if (!windowVariables) { // do not trust strict null checks for global objects
if (!windowVariables) { // do not trust strictNullChecks for global objects
throw new Error('missing window');
}
if (!windowVariables.log) {
@@ -17,4 +17,16 @@ export class WindowInjectedLogger implements ILogger {
public info(...params: unknown[]): void {
this.logger.info(...params);
}
public warn(...params: unknown[]): void {
this.logger.warn(...params);
}
public debug(...params: unknown[]): void {
this.logger.debug(...params);
}
public error(...params: unknown[]): void {
this.logger.error(...params);
}
}

View File

@@ -0,0 +1,16 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
export enum TouchSupportExpectation {
MustExist,
MustNotExist,
}
export interface BrowserCondition {
readonly operatingSystem: OperatingSystem;
readonly existingPartsInSameUserAgent: readonly string[];
readonly notExistingPartsInUserAgent?: readonly string[];
readonly touchSupport?: TouchSupportExpectation;
}

View File

@@ -0,0 +1,86 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { BrowserCondition, TouchSupportExpectation } from './BrowserCondition';
// They include "Android", "iPhone" in their user agents.
const WindowsMobileIdentifiers: readonly string[] = [
'Windows Phone',
'Windows Mobile',
] as const;
export const BrowserConditions: readonly BrowserCondition[] = [
{
operatingSystem: OperatingSystem.KaiOS,
existingPartsInSameUserAgent: ['KAIOS'],
},
{
operatingSystem: OperatingSystem.ChromeOS,
existingPartsInSameUserAgent: ['CrOS'],
},
{
operatingSystem: OperatingSystem.BlackBerryOS,
existingPartsInSameUserAgent: ['BlackBerry'],
},
{
operatingSystem: OperatingSystem.BlackBerryTabletOS,
existingPartsInSameUserAgent: ['RIM Tablet OS'],
},
{
operatingSystem: OperatingSystem.BlackBerry10,
existingPartsInSameUserAgent: ['BB10'],
},
{
operatingSystem: OperatingSystem.Android,
existingPartsInSameUserAgent: ['Android'],
notExistingPartsInUserAgent: [...WindowsMobileIdentifiers],
},
{
operatingSystem: OperatingSystem.Android,
existingPartsInSameUserAgent: ['Adr'],
notExistingPartsInUserAgent: [...WindowsMobileIdentifiers],
},
{
operatingSystem: OperatingSystem.iOS,
existingPartsInSameUserAgent: ['iPhone'],
notExistingPartsInUserAgent: [...WindowsMobileIdentifiers],
},
{
operatingSystem: OperatingSystem.iOS,
existingPartsInSameUserAgent: ['iPod'],
},
{
operatingSystem: OperatingSystem.iPadOS,
existingPartsInSameUserAgent: ['iPad'],
// On Safari, only for older iPads running ≤ iOS 12 reports `iPad`
// Other browsers report `iPad` both for older devices (≤ iOS 12) and newer (≥ iPadOS 13)
// We detect all as `iPadOS` for simplicity.
},
{
operatingSystem: OperatingSystem.iPadOS,
existingPartsInSameUserAgent: ['Macintosh'], // Reported by Safari on iPads running ≥ iPadOS 13
touchSupport: TouchSupportExpectation.MustExist, // Safari same user agent as desktop macOS
},
{
operatingSystem: OperatingSystem.Linux,
existingPartsInSameUserAgent: ['Linux'],
notExistingPartsInUserAgent: ['Android', 'Adr'],
},
{
operatingSystem: OperatingSystem.Windows,
existingPartsInSameUserAgent: ['Windows'],
notExistingPartsInUserAgent: [...WindowsMobileIdentifiers],
},
...['Windows Phone OS', 'Windows Phone 8'].map((userAgentPart) => ({
operatingSystem: OperatingSystem.WindowsPhone,
existingPartsInSameUserAgent: [userAgentPart],
})),
...['Windows Mobile', 'Windows Phone 10'].map((userAgentPart) => ({
operatingSystem: OperatingSystem.Windows10Mobile,
existingPartsInSameUserAgent: [userAgentPart],
})),
{
operatingSystem: OperatingSystem.macOS,
existingPartsInSameUserAgent: ['Macintosh'],
notExistingPartsInUserAgent: ['like Mac OS X'], // Eliminate iOS and iPadOS for Safari
touchSupport: TouchSupportExpectation.MustNotExist, // Distinguish from iPadOS for Safari
},
] as const;

View File

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

View File

@@ -0,0 +1,92 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { assertInRange } from '@/application/Common/Enum';
import { BrowserEnvironment, BrowserOsDetector } from './BrowserOsDetector';
import { BrowserCondition, TouchSupportExpectation } from './BrowserCondition';
import { BrowserConditions } from './BrowserConditions';
export class ConditionBasedOsDetector implements BrowserOsDetector {
constructor(private readonly conditions: readonly BrowserCondition[] = BrowserConditions) {
validateConditions(conditions);
}
public detect(environment: BrowserEnvironment): OperatingSystem | undefined {
if (!environment.userAgent) {
return undefined;
}
for (const condition of this.conditions) {
if (satisfiesCondition(condition, environment)) {
return condition.operatingSystem;
}
}
return undefined;
}
}
function satisfiesCondition(
condition: BrowserCondition,
browserEnvironment: BrowserEnvironment,
): boolean {
const { userAgent } = browserEnvironment;
if (condition.touchSupport !== undefined) {
if (!satisfiesTouchExpectation(condition.touchSupport, browserEnvironment)) {
return false;
}
}
if (condition.existingPartsInSameUserAgent.some((part) => !userAgent.includes(part))) {
return false;
}
if (condition.notExistingPartsInUserAgent?.some((part) => userAgent.includes(part))) {
return false;
}
return true;
}
function satisfiesTouchExpectation(
expectation: TouchSupportExpectation,
browserEnvironment: BrowserEnvironment,
): boolean {
switch (expectation) {
case TouchSupportExpectation.MustExist:
if (!browserEnvironment.isTouchSupported) {
return false;
}
break;
case TouchSupportExpectation.MustNotExist:
if (browserEnvironment.isTouchSupported) {
return false;
}
break;
default:
throw new Error(`Unsupported touch support expectation: ${TouchSupportExpectation[expectation]}`);
}
return true;
}
function validateConditions(conditions: readonly BrowserCondition[]) {
if (!conditions.length) {
throw new Error('empty conditions');
}
for (const condition of conditions) {
validateCondition(condition);
}
}
function validateCondition(condition: BrowserCondition) {
if (!condition.existingPartsInSameUserAgent.length) {
throw new Error('Each condition must include at least one identifiable part of the user agent string.');
}
const duplicates = getDuplicates([
...condition.existingPartsInSameUserAgent,
...(condition.notExistingPartsInUserAgent ?? []),
]);
if (duplicates.length > 0) {
throw new Error(`Found duplicate entries in user agent parts: ${duplicates.join(', ')}. Each part should be unique.`);
}
if (condition.touchSupport !== undefined) {
assertInRange(condition.touchSupport, TouchSupportExpectation);
}
}
function getDuplicates(texts: readonly string[]): string[] {
return texts.filter((text, index) => texts.indexOf(text) !== index);
}

View File

@@ -1,54 +0,0 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { IBrowserOsDetector } from './IBrowserOsDetector';
export class DetectorBuilder {
private readonly existingPartsInUserAgent = new Array<string>();
private readonly notExistingPartsInUserAgent = new Array<string>();
constructor(private readonly os: OperatingSystem) { }
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),
};
}
private detect(userAgent: string): OperatingSystem | undefined {
if (!userAgent) {
return undefined;
}
if (this.existingPartsInUserAgent.some((part) => !userAgent.includes(part))) {
return undefined;
}
if (this.notExistingPartsInUserAgent.some((part) => userAgent.includes(part))) {
return undefined;
}
return this.os;
}
private add(part: string, array: string[]): DetectorBuilder {
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;
}
}

View File

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

View File

@@ -2,9 +2,10 @@ import { OperatingSystem } from '@/domain/OperatingSystem';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { EnvironmentVariablesFactory } from '@/infrastructure/EnvironmentVariables/EnvironmentVariablesFactory';
import { BrowserOsDetector } from './BrowserOs/BrowserOsDetector';
import { IBrowserOsDetector } from './BrowserOs/IBrowserOsDetector';
import { ConditionBasedOsDetector } from './BrowserOs/ConditionBasedOsDetector';
import { BrowserEnvironment, BrowserOsDetector } from './BrowserOs/BrowserOsDetector';
import { IRuntimeEnvironment } from './IRuntimeEnvironment';
import { isTouchEnabledDevice } from './TouchSupportDetection';
export class RuntimeEnvironment implements IRuntimeEnvironment {
public static readonly CurrentEnvironment: IRuntimeEnvironment = new RuntimeEnvironment(window);
@@ -18,11 +19,10 @@ export class RuntimeEnvironment implements IRuntimeEnvironment {
protected constructor(
window: Partial<Window>,
environmentVariables: IEnvironmentVariables = EnvironmentVariablesFactory.Current.instance,
browserOsDetector: IBrowserOsDetector = new BrowserOsDetector(),
browserOsDetector: BrowserOsDetector = new ConditionBasedOsDetector(),
touchDetector = isTouchEnabledDevice,
) {
if (!window) {
throw new Error('missing window');
}
if (!window) { throw new Error('missing window'); } // do not trust strictNullChecks for global objects
this.isNonProduction = environmentVariables.isNonProduction;
this.isDesktop = isDesktop(window);
if (this.isDesktop) {
@@ -31,7 +31,11 @@ export class RuntimeEnvironment implements IRuntimeEnvironment {
this.os = undefined;
const userAgent = getUserAgent(window);
if (userAgent) {
this.os = browserOsDetector.detect(userAgent);
const browserEnvironment: BrowserEnvironment = {
userAgent,
isTouchSupported: touchDetector(),
};
this.os = browserOsDetector.detect(browserEnvironment);
}
}
}

View File

@@ -0,0 +1,52 @@
export function isTouchEnabledDevice(
browserTouchAccessor: BrowserTouchSupportAccessor = GlobalTouchSupportAccessor,
): boolean {
return TouchSupportChecks.some(
(check) => check(browserTouchAccessor),
);
}
export interface BrowserTouchSupportAccessor {
navigatorMaxTouchPoints: () => number | undefined;
windowMatchMediaMatches: (query: string) => boolean;
documentOntouchend: () => undefined | unknown;
windowTouchEvent: () => undefined | unknown;
}
const TouchSupportChecks: ReadonlyArray<(accessor: BrowserTouchSupportAccessor) => boolean> = [
/*
✅ Mobile: Chrome, Safari, Firefox on iOS and Android
❌ Touch-enabled Windows laptop: Chrome
(Chromium has removed ontouch* events on desktop since Chrome 70+.)
❌ Touch-enabled Windows laptop: Firefox
*/
(accessor) => accessor.documentOntouchend() !== undefined,
/*
✅ Mobile: Chrome, Safari, Firefox on iOS and Android
✅ Touch-enabled Windows laptop: Chrome
❌ Touch-enabled Windows laptop: Firefox
*/
(accessor) => {
const maxTouchPoints = accessor.navigatorMaxTouchPoints();
return maxTouchPoints !== undefined && maxTouchPoints > 0;
},
/*
✅ Mobile: Chrome, Safari, Firefox on iOS and Android
✅ Touch-enabled Windows laptop: Chrome
❌ Touch-enabled Windows laptop: Firefox
*/
(accessor) => accessor.windowMatchMediaMatches('(any-pointer: coarse)'),
/*
✅ Mobile: Chrome, Safari, Firefox on iOS and Android
✅ Touch-enabled Windows laptop: Chrome
❌ Touch-enabled Windows laptop: Firefox
*/
(accessor) => accessor.windowTouchEvent() !== undefined,
];
const GlobalTouchSupportAccessor: BrowserTouchSupportAccessor = {
navigatorMaxTouchPoints: () => navigator.maxTouchPoints,
windowMatchMediaMatches: (query: string) => window.matchMedia(query)?.matches,
documentOntouchend: () => document.ontouchend,
windowTouchEvent: () => window.TouchEvent,
} as const;

View File

@@ -1,11 +1,11 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ISystemOperations } from '@/infrastructure/SystemOperations/ISystemOperations';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
/* Primary entry point for platform-specific injections */
export interface WindowVariables {
readonly isDesktop?: boolean;
readonly isDesktop: boolean;
readonly system?: ISystemOperations;
readonly os?: OperatingSystem;
readonly log?: ILogger;
readonly log: Logger;
}

View File

@@ -1,7 +1,10 @@
/*
Colors used throughout the application
Inspired by material color system: https://material.io/design/color/the-color-system.html, https://material.io/develop/web/theming/color
Colors are named using Vue Design System: https://github.com/viljamis/vue-design-system/wiki/Naming-of-Things#naming-colors
Inspired by the material color system (https://material.io/design/color/the-color-system.html, https://material.io/develop/web/theming/color).
Colors are named according to the Vue Design System guidelines (https://github.com/viljamis/vue-design-system/wiki/Naming-of-Things#naming-colors):
- Default: The default base color is `color-{name}`.
- Darker than default: Shades are named as `color-{name}-dark`, `color-{name}-darker`, or `color-{name}-darkest`.
- Lighter than default: Tints are named as `color-{name}-light`, `color-{name}-lighter`, or `color-{name}-lightest`.
*/
// --- Primary | The color displayed most frequently across screens and components
@@ -26,3 +29,12 @@ $color-on-surface : #4d5156;
// Background | Appears behind scrollable content.
$color-background : #e6ecf4;
/*
Application-specific colors:
These are tailored to the specific needs of the application and derived from the above theme colors.
Use these colors to ensure consistent styling across components. When adding new colors, reference existing theme colors.
This approach maintains a cohesive look and feel and simplifies theme adjustments.
*/
$color-scripts-bg: $color-primary-darker;

View File

@@ -1,34 +1,34 @@
@mixin hover-or-touch($selector-suffix: '', $selector-prefix: '&') {
@media (hover: hover) {
/* We only do this if hover is truly supported; otherwise the emulator in mobile
keeps hovered style in-place even after touching, making it sticky. */
/*
Only apply hover styles if the device truly supports hover; otherwise the
emulator in mobile keeps hovered style in-place even after touching, making it sticky.
*/
#{$selector-prefix}:hover #{$selector-suffix} {
@content;
}
}
@media (hover: none) {
/* We only do this if hover is not supported,otherwise the desktop behavior is not
as desired; it does not get activated on hover but only during click/touch. */
/*
Apply active styles on touch or click, ensuring interactive feedback on devices without hover capability.
*/
#{$selector-prefix}:active #{$selector-suffix} {
@content;
}
}
}
/*
This mixin removes the default blue tap highlight seen in mobile WebKit browsers (e.g., Chrome, Safari, Edge).
The mixin by itself may reduce accessibility by hiding this interactive cue. Therefore, it is recommended
to use this mixin in conjunction with the `hover-or-touch` mixin to provide necessary visual feedback
for interactive elements during hover or touch interactions.
*/
@mixin clickable($cursor: 'pointer') {
cursor: #{$cursor};
user-select: none;
/*
It removes (blue) background during touch as seen in mobile webkit browsers (Chrome, Safari, Edge).
The default behavior is that any element (or containing element) that has cursor:pointer
explicitly set and is clicked will flash blue momentarily.
Removing it could have accessibility issue since that hides an interactive cue. But as we still provide
response to user actions through :active by `hover-or-touch` mixin.
*/
-webkit-tap-highlight-color: transparent;
-webkit-tap-highlight-color: transparent; // Removes blue tap highlight
}
@mixin fade-transition($name) {
@@ -64,4 +64,4 @@
margin: 0;
padding: 0;
list-style: none;
}
}

View File

@@ -2,6 +2,7 @@ import { Bootstrapper } from './Bootstrapper';
import { RuntimeSanityValidator } from './Modules/RuntimeSanityValidator';
import { AppInitializationLogger } from './Modules/AppInitializationLogger';
import { DependencyBootstrapper } from './Modules/DependencyBootstrapper';
import { MobileSafariActivePseudoClassEnabler } from './Modules/MobileSafariActivePseudoClassEnabler';
import type { App } from 'vue';
export class ApplicationBootstrapper implements Bootstrapper {
@@ -19,6 +20,7 @@ export class ApplicationBootstrapper implements Bootstrapper {
new RuntimeSanityValidator(),
new DependencyBootstrapper(),
new AppInitializationLogger(),
new MobileSafariActivePseudoClassEnabler(),
];
}
}

View File

@@ -1,15 +1,15 @@
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment';
import { ConsoleLogger } from '@/infrastructure/Log/ConsoleLogger';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { ILoggerFactory } from '@/infrastructure/Log/ILoggerFactory';
import { Logger } from '@/application/Common/Log/Logger';
import { LoggerFactory } from '@/application/Common/Log/LoggerFactory';
import { NoopLogger } from '@/infrastructure/Log/NoopLogger';
import { WindowInjectedLogger } from '@/infrastructure/Log/WindowInjectedLogger';
export class ClientLoggerFactory implements ILoggerFactory {
public static readonly Current: ILoggerFactory = new ClientLoggerFactory();
export class ClientLoggerFactory implements LoggerFactory {
public static readonly Current: LoggerFactory = new ClientLoggerFactory();
public readonly logger: ILogger;
public readonly logger: Logger;
protected constructor(environment: IRuntimeEnvironment = RuntimeEnvironment.CurrentEnvironment) {
if (environment.isDesktop) {

View File

@@ -12,6 +12,7 @@ import {
} from '@/presentation/injectionSymbols';
import { PropertyKeys } from '@/TypeHelpers';
import { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
export function provideDependencies(
context: IApplicationContext,
@@ -57,6 +58,10 @@ export function provideDependencies(
return useUserSelectionState(state, events);
},
),
useLogger: (di) => di.provide(
InjectionKeys.useLogger,
useLogger,
),
};
registerAll(Object.values(resolvers), api);
}

View File

@@ -1,10 +1,10 @@
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
import { Bootstrapper } from '../Bootstrapper';
import { ClientLoggerFactory } from '../ClientLoggerFactory';
export class AppInitializationLogger implements Bootstrapper {
constructor(
private readonly logger: ILogger = ClientLoggerFactory.Current.logger,
private readonly logger: Logger = ClientLoggerFactory.Current.logger,
) { }
public async bootstrap(): Promise<void> {

View File

@@ -0,0 +1,104 @@
import { IRuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/IRuntimeEnvironment';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { Bootstrapper } from '../Bootstrapper';
export class MobileSafariActivePseudoClassEnabler implements Bootstrapper {
constructor(
private readonly currentEnvironment = RuntimeEnvironment.CurrentEnvironment,
private readonly browser: BrowserAccessor = GlobalBrowserAccessor,
) {
}
public async bootstrap(): Promise<void> {
if (!isMobileSafari(this.currentEnvironment, this.browser.getNavigatorUserAgent())) {
return;
}
/*
Workaround to fix issue with `:active` pseudo-class not working on mobile Safari.
This is required so `hover-or-touch` mixin works properly.
Last tested: iPhone with iOS 17.1.1
See:
- Source: https://stackoverflow.com/a/33681490
- Snapshot 1: https://web.archive.org/web/20231112151701/https://stackoverflow.com/questions/3885018/active-pseudo-class-doesnt-work-in-mobile-safari/33681490#33681490
- Snapshot 2: tps://archive.ph/r1zpJ
*/
this.browser.addWindowEventListener('touchstart', () => {}, {
/*
- Setting to `true` removes the need for scrolling to block on touch and wheel
event listeners.
- If set to `true`, it indicates that the function specified by listener will
never call `preventDefault`.
- Defaults to `true` on Safari for `touchstart`.
*/
passive: true,
});
}
}
export interface BrowserAccessor {
getNavigatorUserAgent(): string;
addWindowEventListener(...args: Parameters<typeof window.addEventListener>): void;
}
function isMobileSafari(environment: IRuntimeEnvironment, userAgent: string): boolean {
if (!isMobileAppleOperatingSystem(environment)) {
return false;
}
return isSafari(userAgent);
}
function isMobileAppleOperatingSystem(environment: IRuntimeEnvironment): boolean {
if (environment.os === undefined) {
return false;
}
if (![OperatingSystem.iOS, OperatingSystem.iPadOS].includes(environment.os)) {
return false;
}
return true;
}
function isSafari(userAgent: string): boolean {
if (!userAgent) {
return false;
}
return SafariUserAgentIdentifiers.every((i) => userAgent.includes(i))
&& NonSafariBrowserIdentifiers.every((i) => !userAgent.includes(i));
}
const GlobalBrowserAccessor: BrowserAccessor = {
getNavigatorUserAgent: () => navigator.userAgent,
addWindowEventListener: (...args) => window.addEventListener(...args),
} as const;
const SafariUserAgentIdentifiers = [
'Safari',
] as const;
const NonSafariBrowserIdentifiers = [
// Chrome:
'Chrome',
'CriOS',
// Firefox:
'FxiOS',
// Opera:
'OPiOS',
'OPR', // Opera Desktop and Android
'Opera', // Opera Mini
'OPT',
// Edge:
'EdgiOS', // Edge on iOS/iPadOS
'Edg', // Edge on macOS
'EdgA', // Edge on Android
'Edge', // Microsoft Edge Legacy
// UC Browser:
'UCBrowser',
// Baidu:
'BaiduHD',
'BaiduBrowser',
'baiduboxapp',
'baidubrowser',
// QQ Browser:
'MQQBrowser',
] as const;

View File

@@ -1,37 +1,62 @@
<template>
<div class="dev-toolkit">
<div class="title">
Tools
<div v-if="isOpen" class="dev-toolkit-container">
<div class="dev-toolkit">
<div class="toolkit-header">
<div class="title">
Tools
</div>
<button type="button" class="close-button" @click="close">
<AppIcon icon="xmark" />
</button>
</div>
<hr />
<div class="action-buttons">
<button
v-for="action in devActions"
:key="action.name"
type="button"
class="action-button"
@click="action.handler"
>
{{ action.name }}
</button>
</div>
</div>
<hr />
<button
v-for="action in devActions"
:key="action.name"
type="button"
@click="action.handler"
>
{{ action.name }}
</button>
</div>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import { defineComponent, ref } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import { dumpNames } from './DumpNames';
export default defineComponent({
components: {
AppIcon,
},
setup() {
const { log } = injectKey((keys) => keys.useLogger);
const isOpen = ref(true);
const devActions: readonly DevAction[] = [
{
name: 'Log script/category names',
handler: async () => {
const names = await dumpNames();
console.log(names);
log.info(names);
},
},
];
function close() {
isOpen.value = false;
}
return {
devActions,
isOpen,
close,
};
},
});
@@ -45,7 +70,7 @@ interface DevAction {
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.dev-toolkit {
.dev-toolkit-container {
position: fixed;
top: 0;
right: 0;
@@ -54,19 +79,59 @@ interface DevAction {
padding: 10px;
z-index: 10000;
display:flex;
flex-direction: column;
/* Minimize interaction, so it does not interfere with events targeting elements behind it to allow easier tests */
pointer-events: none;
* > button {
pointer-events: initial;
}
}
.dev-toolkit {
display:flex;
flex-direction: column;
hr {
width: 100%;
}
.toolkit-header {
display:flex;
flex-direction: row;
align-items: center;
.title {
flex: 1;
}
.close-button {
flex-shrink: 0;
}
}
.title {
font-weight: bold;
text-align: center;
}
.action-buttons {
display: flex;
flex-direction: column;
gap: 10px;
}
button {
display: block;
margin-bottom: 10px;
padding: 5px 10px;
background-color: $color-primary;
color: $color-on-primary;
border: none;
cursor: pointer;
@include hover-or-touch {
background-color: $color-secondary;
color: $color-on-secondary;
}
}
}
</style>

View File

@@ -14,10 +14,11 @@
import { defineComponent, computed } from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames';
import MenuOptionList from './MenuOptionList.vue';
import MenuOptionListItem from './MenuOptionListItem.vue';
interface IOsViewModel {
interface OperatingSystemOption {
readonly name: string;
readonly os: OperatingSystem;
}
@@ -31,12 +32,12 @@ export default defineComponent({
const { modifyCurrentContext, currentState } = injectKey((keys) => keys.useCollectionState);
const { application } = injectKey((keys) => keys.useApplication);
const allOses = computed<ReadonlyArray<IOsViewModel>>(
const allOses = computed<ReadonlyArray<OperatingSystemOption>>(
() => application
.getSupportedOsList()
.map((os) : IOsViewModel => ({
.map((os) : OperatingSystemOption => ({
os,
name: renderOsName(os),
name: getOperatingSystemDisplayName(os),
})),
);
@@ -57,13 +58,4 @@ export default defineComponent({
};
},
});
function renderOsName(os: OperatingSystem): string {
switch (os) {
case OperatingSystem.Windows: return 'Windows';
case OperatingSystem.macOS: return 'macOS';
case OperatingSystem.Linux: return 'Linux (preview)';
default: throw new RangeError(`Cannot render os name: ${OperatingSystem[os]}`);
}
}
</script>

View File

@@ -37,7 +37,10 @@
/>
</div>
<div class="card__expander__content">
<ScriptsTree :category-id="categoryId" />
<ScriptsTree
:category-id="categoryId"
:has-top-padding="false"
/>
</div>
</div>
</div>
@@ -159,27 +162,26 @@ $card-horizontal-gap : $card-gap;
&:after {
transition: all 0.3s ease-in-out;
}
&__title {
.card__inner__title {
display: flex;
flex-direction: column;
flex: 1;
justify-content: center;
}
&__selection_indicator {
.card__inner__selection_indicator {
height: $card-inner-padding;
margin-right: -$card-inner-padding;
padding-right: 10px;
display: flex;
justify-content: flex-end;
}
&__expand-icon {
.card__inner__expand-icon {
width: 100%;
margin-top: .25em;
vertical-align: middle;
}
}
&__expander {
.card__expander {
transition: all 0.2s ease-in-out;
position: relative;
background-color: $color-primary-darker;
@@ -189,17 +191,14 @@ $card-horizontal-gap : $card-gap;
align-items: center;
flex-direction: column;
&__content {
.card__expander__content {
display: flex;
justify-content: center;
word-break: break-word;
margin-bottom: 1em;
margin-left: 0.5em;
margin-right: 0.5em;
max-width: 100%; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
}
&__close-button {
.card__expander__close-button {
font-size: 1.5em;
align-self: flex-end;
margin-right: 0.25em;

View File

@@ -5,9 +5,7 @@
<CardList />
</template>
<template v-else-if="currentView === ViewType.Tree">
<div class="tree">
<ScriptsTree />
</div>
<ScriptsTree />
</template>
</template>
<template v-else>
@@ -30,8 +28,8 @@
</div>
</div>
</div>
<div v-if="searchHasMatches" class="tree tree--searching">
<ScriptsTree />
<div v-if="searchHasMatches">
<ScriptsTree :has-top-padding="false" />
</div>
</template>
</div>
@@ -139,29 +137,20 @@ $margin-inner: 4px;
overflow: auto;
max-height: 70vh;
}
.tree {
padding-left: 3%;
padding-top: 15px;
padding-bottom: 15px;
&--searching {
background-color: $color-primary-darker;
padding-top: 0px;
}
}
}
.search {
display: flex;
flex-direction: column;
background-color: $color-primary-darker;
&__query {
background-color: $color-scripts-bg;
.search__query {
display: flex;
justify-content: center;
flex-direction: row;
align-items: center;
margin-top: 1em;
color: $color-primary;
&__close-button {
.search__query__close-button {
@include clickable;
font-size: 1.25em;
margin-left: 0.25rem;
@@ -170,7 +159,7 @@ $margin-inner: 4px;
}
}
}
&-no-matches {
.search-no-matches {
display:flex;
flex-direction: column;
word-break:break-word;

View File

@@ -167,7 +167,7 @@ $base-spacing: $text-size;
@include no-margin('blockquote');
@include no-margin('pre');
/* Add spacing between elements using `margin-bottom` only (bottom-out instead of top-down strategy). */
/* Add spacing between elements using `margin-bottom` only (bottom-up instead of top-down strategy). */
$small-vertical-spacing: math.div($base-vertical-spacing, 2);
@include bottom-margin('p', $base-vertical-spacing);
@include bottom-margin('h1, h2, h3, h4, h5, h6', $base-vertical-spacing);

View File

@@ -1,8 +1,13 @@
<template>
<div class="scripts-tree-container">
<template v-if="initialNodes.length">
<div
class="scripts-tree-container"
:class="{
'top-padding': hasTopPadding,
}"
>
<template v-if="nodes.length">
<TreeView
:initial-nodes="initialNodes"
:nodes="nodes"
:selected-leaf-node-ids="selectedScriptNodeIds"
:latest-filter-event="latestFilterEvent"
@node-state-changed="handleNodeChangedEvent($event)"
@@ -39,6 +44,10 @@ export default defineComponent({
type: [Number],
default: undefined,
},
hasTopPadding: {
type: Boolean,
default: true,
},
},
setup(props) {
const useUserCollectionStateHook = injectKey((keys) => keys.useUserSelectionState);
@@ -52,7 +61,7 @@ export default defineComponent({
}
return {
initialNodes: treeViewInputNodes,
nodes: treeViewInputNodes,
selectedScriptNodeIds,
latestFilterEvent,
handleNodeChangedEvent,
@@ -62,8 +71,22 @@ export default defineComponent({
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
$padding: 20px;
.scripts-tree-container {
display: flex; // We could provide `block`, but `flex` is more versatile.
overflow: auto; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
/* Set background color in consistent way so it has similar look when searching, on tree view, in cards etc. */
background: $color-scripts-bg;
padding-bottom: $padding;
padding-left: $padding;
padding-right: $padding;
&.top-padding {
padding-top: $padding;
}
}
</style>

View File

@@ -1,15 +1,17 @@
<template>
<div class="wrapper">
<div
<InteractableNode
class="expansible-node"
:style="{
'padding-left': `${currentNode.hierarchy.depthInTree * 24}px`,
}"
:node-id="nodeId"
:tree-root="treeRoot"
>
<div
class="expand-collapse-arrow"
:class="{
expanded: expanded,
expanded: isExpanded,
'has-children': hasChildren,
}"
@click.stop="toggleExpand"
@@ -24,10 +26,10 @@
</template>
</LeafTreeNode>
</div>
</div>
</InteractableNode>
<transition name="children-transition">
<ul
v-if="hasChildren && expanded"
v-if="hasChildren && isExpanded"
class="children"
>
<HierarchicalTreeNode
@@ -54,12 +56,14 @@ import { NodeRenderingStrategy } from '../Rendering/Scheduling/NodeRenderingStra
import { useNodeState } from './UseNodeState';
import { TreeNode } from './TreeNode';
import LeafTreeNode from './LeafTreeNode.vue';
import InteractableNode from './InteractableNode.vue';
import type { PropType } from 'vue';
export default defineComponent({
name: 'HierarchicalTreeNode', // Needed due to recursion
components: {
LeafTreeNode,
InteractableNode,
},
props: {
nodeId: {
@@ -82,7 +86,7 @@ export default defineComponent({
);
const { state } = useNodeState(currentNode);
const expanded = computed<boolean>(() => state.value.isExpanded);
const isExpanded = computed<boolean>(() => state.value.isExpanded);
const renderedNodeIds = computed<readonly string[]>(
() => currentNode.value
@@ -96,18 +100,13 @@ export default defineComponent({
currentNode.value.state.toggleExpand();
}
function toggleCheck() {
currentNode.value.state.toggleCheck();
}
const hasChildren = computed<boolean>(
() => currentNode.value.hierarchy.isBranchNode,
);
return {
renderedNodeIds,
expanded,
toggleCheck,
isExpanded,
toggleExpand,
currentNode,
hasChildren,
@@ -123,7 +122,6 @@ export default defineComponent({
.wrapper {
display: flex;
flex-direction: column;
cursor: pointer;
.children {
@include reset-ul;
@@ -140,16 +138,15 @@ export default defineComponent({
flex-direction: row;
align-items: center;
@include hover-or-touch {
background: $color-node-highlight-bg;
}
.expand-collapse-arrow {
flex-shrink: 0;
height: 30px;
cursor: pointer;
margin-left: 30px;
width: 0;
@include clickable;
&:after {
position: absolute;
display: block;

View File

@@ -0,0 +1,83 @@
<template>
<div
class="clickable-node focusable-node"
tabindex="-1"
:class="{
'keyboard-focus': hasKeyboardFocus,
}"
@click.stop="toggleCheckState"
@focus="onNodeFocus"
>
<slot />
</div>
</template>
<script lang="ts">
import { defineComponent, computed, toRef } from 'vue';
import { TreeRoot } from '../TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { useNodeState } from './UseNodeState';
import { useKeyboardInteractionState } from './UseKeyboardInteractionState';
import { TreeNode } from './TreeNode';
import type { PropType } from 'vue';
export default defineComponent({
props: {
nodeId: {
type: String,
required: true,
},
treeRoot: {
type: Object as PropType<TreeRoot>,
required: true,
},
},
setup(props) {
const { isKeyboardBeingUsed } = useKeyboardInteractionState();
const { nodes } = useCurrentTreeNodes(toRef(props, 'treeRoot'));
const currentNode = computed<TreeNode>(() => nodes.value.getNodeById(props.nodeId));
const { state } = useNodeState(currentNode);
const hasKeyboardFocus = computed<boolean>(() => {
if (!isKeyboardBeingUsed.value) {
return false;
}
return state.value.isFocused;
});
const onNodeFocus = () => {
props.treeRoot.focus.setSingleFocus(currentNode.value);
};
function toggleCheckState() {
currentNode.value.state.toggleCheck();
}
return {
onNodeFocus,
toggleCheckState,
currentNode,
hasKeyboardFocus,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
@use "./../tree-colors" as *;
.clickable-node {
@include clickable;
@include hover-or-touch {
background: $color-node-highlight-bg;
}
}
.focusable-node {
outline: none; // We handle keyboard focus through own styling
&.keyboard-focus {
background: $color-node-highlight-bg;
}
}
</style>

View File

@@ -1,27 +1,25 @@
<template>
<li
class="node focusable"
tabindex="-1"
:class="{
'keyboard-focus': hasKeyboardFocus,
}"
@click.stop="toggleCheckState"
@focus="onNodeFocus"
>
<div class="node__layout">
<div class="node__checkbox">
<NodeCheckbox
:node-id="nodeId"
:tree-root="treeRoot"
/>
<li>
<InteractableNode
:node-id="nodeId"
:tree-root="treeRoot"
class="node"
>
<div class="node__layout">
<div class="node__checkbox">
<NodeCheckbox
:node-id="nodeId"
:tree-root="treeRoot"
/>
</div>
<div class="node__content content">
<slot
name="node-content"
:node-metadata="currentNode.metadata"
/>
</div>
</div>
<div class="node__content content">
<slot
name="node-content"
:node-metadata="currentNode.metadata"
/>
</div>
</div>
</InteractableNode>
</li>
</template>
@@ -29,15 +27,15 @@
import { defineComponent, computed, toRef } from 'vue';
import { TreeRoot } from '../TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { useNodeState } from './UseNodeState';
import { useKeyboardInteractionState } from './UseKeyboardInteractionState';
import { TreeNode } from './TreeNode';
import NodeCheckbox from './NodeCheckbox.vue';
import InteractableNode from './InteractableNode.vue';
import type { PropType } from 'vue';
export default defineComponent({
components: {
NodeCheckbox,
InteractableNode,
},
props: {
nodeId: {
@@ -50,31 +48,11 @@ export default defineComponent({
},
},
setup(props) {
const { isKeyboardBeingUsed } = useKeyboardInteractionState();
const { nodes } = useCurrentTreeNodes(toRef(props, 'treeRoot'));
const currentNode = computed<TreeNode>(() => nodes.value.getNodeById(props.nodeId));
const { state } = useNodeState(currentNode);
const hasKeyboardFocus = computed<boolean>(() => {
if (!isKeyboardBeingUsed.value) {
return false;
}
return state.value.isFocused;
});
const onNodeFocus = () => {
props.treeRoot.focus.setSingleFocus(currentNode.value);
};
function toggleCheckState() {
currentNode.value.state.toggleCheck();
}
return {
onNodeFocus,
toggleCheckState,
currentNode,
hasKeyboardFocus,
};
},
});
@@ -97,27 +75,14 @@ export default defineComponent({
overflow: auto; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
}
}
.focusable {
outline: none; // We handle keyboard focus through own styling
}
.node {
margin-bottom: 3px;
margin-top: 3px;
padding-bottom: 3px;
padding-top: 3px;
padding-right: 6px;
cursor: pointer;
box-sizing: border-box;
&.keyboard-focus {
background: $color-node-highlight-bg;
}
@include hover-or-touch {
background: $color-node-highlight-bg;
}
.content {
display: flex; // We could provide `block`, but `flex` is more versatile.
color: $color-node-fg;

View File

@@ -5,12 +5,19 @@ import { ReadOnlyTreeNode } from '../Node/TreeNode';
import { useNodeStateChangeAggregator } from '../UseNodeStateChangeAggregator';
import { TreeRoot } from '../TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { QueryableNodes } from '../TreeRoot/NodeCollection/Query/QueryableNodes';
import { NodeRenderingStrategy } from './Scheduling/NodeRenderingStrategy';
import { DelayScheduler } from './DelayScheduler';
import { TimeoutDelayScheduler } from './Scheduling/TimeoutDelayScheduler';
import { RenderQueueOrderer } from './Ordering/RenderQueueOrderer';
import { CollapsedParentOrderer } from './Ordering/CollapsedParentOrderer';
export interface NodeRenderingControl {
readonly renderingStrategy: NodeRenderingStrategy;
clearRenderingStates(): void;
notifyRenderingUpdates(): void;
}
/**
* Renders tree nodes gradually to prevent UI freeze when loading large amounts of nodes.
*/
@@ -22,7 +29,7 @@ export function useGradualNodeRendering(
initialBatchSize = 30,
subsequentBatchSize = 5,
orderer: RenderQueueOrderer = new CollapsedParentOrderer(),
): NodeRenderingStrategy {
): NodeRenderingControl {
const nodesToRender = new Set<ReadOnlyTreeNode>();
const nodesBeingRendered = shallowRef(new Set<ReadOnlyTreeNode>());
let isRenderingInProgress = false;
@@ -31,6 +38,10 @@ export function useGradualNodeRendering(
const { onNodeStateChange } = useChangeAggregator(treeRootRef);
const { nodes } = useTreeNodes(treeRootRef);
function notifyRenderingUpdates() {
triggerRef(nodesBeingRendered);
}
function updateNodeRenderQueue(node: ReadOnlyTreeNode, isVisible: boolean) {
if (isVisible
&& !nodesToRender.has(node)
@@ -43,16 +54,20 @@ export function useGradualNodeRendering(
}
if (nodesBeingRendered.value.has(node)) {
nodesBeingRendered.value.delete(node);
triggerRef(nodesBeingRendered);
notifyRenderingUpdates();
}
}
}
watch(nodes, (newNodes) => {
function clearRenderingStates() {
nodesToRender.clear();
nodesBeingRendered.value.clear();
}
function initializeAndRenderNodes(newNodes: QueryableNodes) {
clearRenderingStates();
if (!newNodes || newNodes.flattenedNodes.length === 0) {
triggerRef(nodesBeingRendered);
notifyRenderingUpdates();
return;
}
newNodes
@@ -60,6 +75,10 @@ export function useGradualNodeRendering(
.filter((node) => node.state.current.isVisible)
.forEach((node) => nodesToRender.add(node));
beginRendering();
}
watch(nodes, (newNodes) => {
initializeAndRenderNodes(newNodes);
}, { immediate: true });
onNodeStateChange((change) => {
@@ -91,7 +110,7 @@ export function useGradualNodeRendering(
nodesToRender.delete(node);
nodesBeingRendered.value.add(node);
});
triggerRef(nodesBeingRendered);
notifyRenderingUpdates();
scheduler.scheduleNext(
() => renderNextBatch(subsequentBatchSize),
renderingDelayInMs,
@@ -103,6 +122,10 @@ export function useGradualNodeRendering(
}
return {
shouldRender: shouldNodeBeRendered,
renderingStrategy: {
shouldRender: shouldNodeBeRendered,
},
clearRenderingStates,
notifyRenderingUpdates,
};
}

View File

@@ -3,7 +3,7 @@
ref="treeContainerElement"
class="tree"
>
<TreeRoot :tree-root="tree" :rendering-strategy="nodeRenderingScheduler">
<TreeRoot :tree-root="tree" :rendering-strategy="renderingStrategy">
<template #default="slotProps">
<slot name="node-content" v-bind="slotProps" />
</template>
@@ -15,6 +15,7 @@
import {
defineComponent, onMounted, watch,
shallowRef, toRef, shallowReadonly,
nextTick,
} from 'vue';
import { TreeRootManager } from './TreeRoot/TreeRootManager';
import TreeRoot from './TreeRoot/TreeRoot.vue';
@@ -27,7 +28,7 @@ import { useLeafNodeCheckedStateUpdater } from './UseLeafNodeCheckedStateUpdater
import { TreeNodeStateChangedEmittedEvent } from './Bindings/TreeNodeStateChangedEmittedEvent';
import { useAutoUpdateParentCheckState } from './UseAutoUpdateParentCheckState';
import { useAutoUpdateChildrenCheckState } from './UseAutoUpdateChildrenCheckState';
import { useGradualNodeRendering } from './Rendering/UseGradualNodeRendering';
import { useGradualNodeRendering, NodeRenderingControl } from './Rendering/UseGradualNodeRendering';
import type { PropType } from 'vue';
export default defineComponent({
@@ -35,7 +36,7 @@ export default defineComponent({
TreeRoot,
},
props: {
initialNodes: {
nodes: {
type: Array as PropType<readonly TreeInputNodeData[]>,
default: () => [],
},
@@ -65,7 +66,7 @@ export default defineComponent({
useLeafNodeCheckedStateUpdater(treeRef, toRef(props, 'selectedLeafNodeIds'));
useAutoUpdateParentCheckState(treeRef);
useAutoUpdateChildrenCheckState(treeRef);
const nodeRenderingScheduler = useGradualNodeRendering(treeRef);
const nodeRenderer = useGradualNodeRendering(treeRef);
const { onNodeStateChange } = useNodeStateChangeAggregator(treeRef);
@@ -78,18 +79,44 @@ export default defineComponent({
});
onMounted(() => {
watch(() => props.initialNodes, (nodes) => {
tree.collection.updateRootNodes(nodes);
watch(() => props.nodes, async (nodes) => {
await forceRerenderNodes(
nodeRenderer,
() => tree.collection.updateRootNodes(nodes),
);
}, { immediate: true });
});
return {
treeContainerElement,
nodeRenderingScheduler,
renderingStrategy: nodeRenderer.renderingStrategy,
tree,
};
},
});
/**
* This function is used to manually trigger a re-render of the tree nodes.
* In Vue, manually controlling the rendering process is typically an anti-pattern,
* as Vue's reactivity system is designed to handle updates efficiently. However,
* in this specific case, it's necessary to ensure the correct order of rendering operations.
* This function first clears the rendering queue and the currently rendered nodes,
* ensuring that UI elements relying on outdated node states are removed. This is needed
* in scenarios where the collection is updated before the nodes, which can lead to errors
* if nodes that no longer exist in the collection are still being rendered.
* Using this function, we ensure a clean state before updating the nodes, aligning with
* the updated collection.
*/
async function forceRerenderNodes(
renderer: NodeRenderingControl,
nodeUpdater: () => void,
) {
renderer.clearRenderingStates();
renderer.notifyRenderingUpdates();
await nextTick();
nodeUpdater();
}
</script>
<style scoped lang="scss">
@@ -98,5 +125,6 @@ export default defineComponent({
.tree {
background: $color-tree-bg;
overflow: auto; // Prevents horizontal expansion of inner content (e.g., when a code block is shown)
flex: 1; // Expands the node horizontally, allowing its content to utilize full width for child item alignment, such as icons and text.
}
</style>

View File

@@ -1,7 +1,7 @@
@use "@/presentation/assets/styles/main" as *;
/* Tree colors, based on global colors */
$color-tree-bg : $color-primary-darker;
$color-tree-bg : $color-scripts-bg;
$color-node-arrow : $color-on-primary;
$color-node-fg : $color-on-primary;
$color-node-highlight-bg : $color-primary-dark;

View File

@@ -0,0 +1,8 @@
import { LoggerFactory } from '@/application/Common/Log/LoggerFactory';
import { ClientLoggerFactory } from '@/presentation/bootstrapping/ClientLoggerFactory';
export function useLogger(factory: LoggerFactory = ClientLoggerFactory.Current) {
return {
log: factory.logger,
};
}

View File

@@ -0,0 +1,15 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
export function getOperatingSystemDisplayName(os: OperatingSystem): string {
const displayName = OperatingSystemNames[os];
if (!displayName) {
throw new RangeError(`Unsupported operating system ID: ${os}`);
}
return displayName;
}
const OperatingSystemNames: Partial<Record<OperatingSystem, string>> = {
[OperatingSystem.Windows]: 'Windows',
[OperatingSystem.macOS]: 'macOS',
[OperatingSystem.Linux]: 'Linux (preview)',
};

View File

@@ -13,6 +13,7 @@
<div class="tooltip__overlay">
<div
ref="tooltipDisplayElement"
class="tooltip__display"
:style="displayStyles"
>
<div class="tooltip__content">
@@ -38,28 +39,26 @@ import type { CSSProperties } from 'vue';
const GAP_BETWEEN_TOOLTIP_AND_TRIGGER_IN_PX = 2;
const ARROW_SIZE_IN_PX = 4;
const MARGIN_FROM_DOCUMENT_EDGE_IN_PX = 2;
const DEFAULT_PLACEMENT: Placement = 'top';
export default defineComponent({
setup() {
const tooltipDisplayElement = shallowRef<HTMLElement | undefined>();
const triggeringElement = shallowRef<HTMLElement | undefined>();
const arrowElement = shallowRef<HTMLElement | undefined>();
const placement = shallowRef<Placement>('top');
useResizeObserverPolyfill();
const { floatingStyles, middlewareData } = useFloating(
const { floatingStyles, middlewareData, placement } = useFloating(
triggeringElement,
tooltipDisplayElement,
{
placement,
placement: DEFAULT_PLACEMENT,
middleware: [
offset(ARROW_SIZE_IN_PX + GAP_BETWEEN_TOOLTIP_AND_TRIGGER_IN_PX),
/* Shifts the element along the specified axes in order to keep it in view. */
shift({
padding: MARGIN_FROM_DOCUMENT_EDGE_IN_PX,
}),
shift(),
/* Changes the placement of the floating element in order to keep it in view,
with the ability to flip to any placement. */
flip(),
@@ -68,6 +67,7 @@ export default defineComponent({
whileElementsMounted: autoUpdate,
},
);
const arrowStyles = computed<CSSProperties>(() => {
if (!middlewareData.value.arrow) {
return {
@@ -129,6 +129,7 @@ function getCounterpartBoxOffsetProperty(placement: Placement): keyof CSSPropert
</script>
<style scoped lang="scss">
@use 'sass:math';
@use "@/presentation/assets/styles/main" as *;
$color-tooltip-background: $color-primary-darkest;
@@ -146,14 +147,14 @@ $color-tooltip-background: $color-primary-darkest;
- Using the `display` property doesn't support smooth transitions (e.g., fading out).
- Keeping invisible tooltips in the DOM is a best practice for accessibility (screen readers).
*/
$animation-duration: 0.5s;
transition: opacity $animation-duration, visibility $animation-duration;
@if $isVisible {
visibility: visible;
opacity: 1;
transition: opacity .15s, visibility .15s;
} @else {
visibility: hidden;
opacity: 0;
transition: opacity .15s, visibility .15s;
}
}
@@ -175,10 +176,12 @@ $color-tooltip-background: $color-primary-darkest;
- Causes screen shaking on Chromium browsers.
*/
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
overflow: hidden;
> * { // Restore styles in children
@@ -190,13 +193,19 @@ $color-tooltip-background: $color-primary-darkest;
.tooltip__overlay {
@include set-visibility(false);
@include fixed-fullscreen;
/*
Reset white-space to the default value to prevent inheriting styles from the trigger element.
This prevents unintentional layout issues or overflow.
*/
white-space: normal;
}
.tooltip__trigger {
@include hover-or-touch {
+ .tooltip__overlay {
z-index: 10000;
@include set-visibility(true);
z-index: 10000;
}
}
}
@@ -206,6 +215,15 @@ $color-tooltip-background: $color-primary-darkest;
color: $color-on-primary;
border-radius: 16px;
padding: 5px 10px 4px;
/*
This margin creates a visual buffer between the tooltip and the edges of the document.
It prevents the tooltip from appearing too close to the edges, ensuring a visually pleasing
and balanced layout.
Avoiding setting vertical margin as it disrupts the arrow rendering.
*/
margin-left: 2px;
margin-right: 2px;
}
.tooltip__arrow {

View File

@@ -24,17 +24,10 @@
<script lang="ts">
import { defineComponent } from 'vue';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { injectKey } from '@/presentation/injectionSymbols';
import AppIcon from '@/presentation/components/Shared/Icon/AppIcon.vue';
import DownloadUrlListItem from './DownloadUrlListItem.vue';
const supportedOperativeSystems: readonly OperatingSystem[] = [
OperatingSystem.Windows,
OperatingSystem.Linux,
OperatingSystem.macOS,
];
export default defineComponent({
components: {
DownloadUrlListItem,
@@ -42,8 +35,12 @@ export default defineComponent({
},
setup() {
const { os: currentOs } = injectKey((keys) => keys.useRuntimeEnvironment);
const { application } = injectKey((keys) => keys.useApplication);
const supportedOperativeSystems = application.getSupportedOsList();
const supportedDesktops = [
...supportedOperativeSystems,
...application.getSupportedOsList(),
].sort((os) => (os === currentOs ? 0 : 1));
const hasCurrentOsDesktopVersion = currentOs === undefined

View File

@@ -16,6 +16,7 @@ import {
} from 'vue';
import { injectKey } from '@/presentation/injectionSymbols';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames';
export default defineComponent({
props: {
@@ -33,7 +34,7 @@ export default defineComponent({
});
const operatingSystemName = computed<string>(() => {
return getOperatingSystemName(props.operatingSystem);
return getOperatingSystemDisplayName(props.operatingSystem);
});
const hasCurrentOsDesktopVersion = computed<boolean>(() => {
@@ -58,20 +59,6 @@ function hasDesktopVersion(os: OperatingSystem): boolean {
|| os === OperatingSystem.Linux
|| os === OperatingSystem.macOS;
}
function getOperatingSystemName(os: OperatingSystem): string {
switch (os) {
case OperatingSystem.Linux:
return 'Linux (preview)';
case OperatingSystem.macOS:
return 'macOS';
case OperatingSystem.Windows:
return 'Windows';
default:
throw new Error(`Unsupported os: ${OperatingSystem[os]}`);
}
}
</script>
<style scoped lang="scss">

View File

@@ -1,7 +1,7 @@
import { app, dialog } from 'electron';
import { autoUpdater, UpdateInfo } from 'electron-updater';
import { ProgressInfo } from 'electron-builder';
import log from 'electron-log';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { UpdateProgressBar } from './UpdateProgressBar';
export async function handleAutoUpdate() {
@@ -23,11 +23,11 @@ function startHandlingUpdateProgress() {
On macOS, download-progress event is not called.
So the indeterminate progress will continue until download is finished.
*/
log.debug('@download-progress@\n', progress);
ElectronLogger.debug('@download-progress@\n', progress);
progressBar.showProgress(progress);
});
autoUpdater.on('update-downloaded', async (info: UpdateInfo) => {
log.info('@update-downloaded@\n', info);
ElectronLogger.info('@update-downloaded@\n', info);
progressBar.close();
await handleUpdateDownloaded();
});

View File

@@ -1,150 +0,0 @@
import fs from 'fs';
import path from 'path';
import { app, dialog, shell } from 'electron';
import { UpdateInfo } from 'electron-updater';
import log from 'electron-log';
import fetch from 'cross-fetch';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { Version } from '@/domain/Version';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { UpdateProgressBar } from './UpdateProgressBar';
export function requiresManualUpdate(): boolean {
return process.platform === 'darwin';
}
export async function handleManualUpdate(info: UpdateInfo) {
const result = await askForVisitingWebsiteForManualUpdate();
if (result === ManualDownloadDialogResult.NoAction) {
return;
}
const project = getTargetProject(info.version);
if (result === ManualDownloadDialogResult.VisitReleasesPage) {
await shell.openExternal(project.releaseUrl);
} else if (result === ManualDownloadDialogResult.UpdateNow) {
await download(info, project);
}
}
function getTargetProject(targetVersion: string) {
const existingProject = parseProjectInformation();
const targetProject = new ProjectInformation(
existingProject.name,
new Version(targetVersion),
existingProject.slogan,
existingProject.repositoryUrl,
existingProject.homepage,
);
return targetProject;
}
enum ManualDownloadDialogResult {
NoAction = 0,
UpdateNow = 1,
VisitReleasesPage = 2,
}
async function askForVisitingWebsiteForManualUpdate(): Promise<ManualDownloadDialogResult> {
const visitPageResult = await dialog.showMessageBox({
type: 'info',
buttons: [
'Not now', // First button is shown at bottom after some space in macOS and has default cancel behavior
'Download and manually update',
'Visit releases page',
],
message: 'Update available\n\nWould you like to update manually?',
detail:
'There are new updates available.'
+ ' privacy.sexy does not support fully auto-update for macOS due to code signing costs.'
+ ' Please manually update your version, because newer versions fix issues and improve privacy and security.',
defaultId: ManualDownloadDialogResult.UpdateNow,
cancelId: ManualDownloadDialogResult.NoAction,
});
return visitPageResult.response;
}
async function download(info: UpdateInfo, project: ProjectInformation) {
log.info('Downloading update manually');
const progressBar = new UpdateProgressBar();
progressBar.showIndeterminateState();
try {
const filePath = `${path.dirname(app.getPath('temp'))}/privacy.sexy/${info.version}-installer.dmg`;
const parentFolder = path.dirname(filePath);
if (fs.existsSync(filePath)) {
log.info('Update is already downloaded');
await fs.promises.unlink(filePath);
log.info(`Deleted ${filePath}`);
} else {
await fs.promises.mkdir(parentFolder, { recursive: true });
}
const dmgFileUrl = project.getDownloadUrl(OperatingSystem.macOS);
await downloadFileWithProgress(
dmgFileUrl,
filePath,
(percentage) => { progressBar.showPercentage(percentage); },
);
await shell.openPath(filePath);
progressBar.close();
app.quit();
} catch (e) {
progressBar.showError(e);
}
}
type ProgressCallback = (progress: number) => void;
async function downloadFileWithProgress(
url: string,
filePath: string,
progressHandler: ProgressCallback,
) {
// We don't download through autoUpdater as it cannot download DMG but requires distributing ZIP
log.info(`Fetching ${url}`);
const response = await fetch(url);
if (!response.ok) {
throw Error(`Unable to download, server returned ${response.status} ${response.statusText}`);
}
const contentLengthString = response.headers.get('content-length');
if (contentLengthString === null || contentLengthString === undefined) {
log.error('Content-Length header is missing');
}
const contentLength = +(contentLengthString ?? 0);
const writer = fs.createWriteStream(filePath);
log.info(`Writing to ${filePath}, content length: ${contentLength}`);
if (Number.isNaN(contentLength) || contentLength <= 0) {
log.error('Unknown content-length', Array.from(response.headers.entries()));
progressHandler = () => { /* do nothing */ };
}
const reader = getReader(response);
if (!reader) {
throw new Error('No response body');
}
await streamWithProgress(contentLength, reader, writer, progressHandler);
}
async function streamWithProgress(
totalLength: number,
readStream: NodeJS.ReadableStream,
writeStream: fs.WriteStream,
progressHandler: ProgressCallback,
): Promise<void> {
let receivedLength = 0;
for await (const chunk of readStream) {
if (!chunk) {
throw Error('Empty chunk received during download');
}
writeStream.write(Buffer.from(chunk));
receivedLength += chunk.length;
const percentage = Math.floor((receivedLength / totalLength) * 100);
progressHandler(percentage);
log.debug(`Received ${receivedLength} of ${totalLength}`);
}
log.info('Downloaded successfully');
}
function getReader(response: Response): NodeJS.ReadableStream | undefined {
// On browser, we could use browser API response.body.getReader()
// But here, we use cross-fetch that gets node-fetch on a node application
// This API is node-fetch specific, see https://github.com/node-fetch/node-fetch#streams
return response.body as unknown as NodeJS.ReadableStream;
}

View File

@@ -0,0 +1,122 @@
import { dialog } from 'electron';
export enum ManualUpdateChoice {
NoAction = 0,
UpdateNow = 1,
VisitReleasesPage = 2,
}
export async function promptForManualUpdate(): Promise<ManualUpdateChoice> {
const visitPageResult = await dialog.showMessageBox({
type: 'info',
buttons: [
'Not Now',
'Download Update',
'Visit Release Page',
],
message: [
'A new version is available.',
'Would you like to download it now?',
].join('\n\n'),
detail: [
'Updates are highly recommended because they improve your privacy, security and safety.',
'\n\n',
'Auto-updates are not fully supported on macOS due to code signing costs.',
'Consider donating ❤️.',
].join(' '),
defaultId: ManualUpdateChoice.UpdateNow,
cancelId: ManualUpdateChoice.NoAction,
});
return visitPageResult.response;
}
export enum IntegrityCheckChoice {
Cancel = 0,
RetryDownload = 1,
ContinueAnyway = 2,
}
export async function promptIntegrityCheckFailure(): Promise<IntegrityCheckChoice> {
const integrityResult = await dialog.showMessageBox({
type: 'error',
buttons: [
'Cancel Update',
'Retry Download',
'Continue Anyway',
],
message: 'Integrity check failed',
detail:
'The integrity check for the installer has failed,'
+ ' which means the file may be corrupted or has been tampered with.'
+ ' It is recommended to retry the download or cancel the installation for your safety.'
+ '\n\nContinuing the installation might put your system at risk.',
defaultId: IntegrityCheckChoice.RetryDownload,
cancelId: IntegrityCheckChoice.Cancel,
noLink: true,
});
return integrityResult.response;
}
export enum InstallerErrorChoice {
Cancel = 0,
RetryDownload = 1,
RetryOpen = 2,
}
export async function promptInstallerOpenError(): Promise<InstallerErrorChoice> {
const result = await dialog.showMessageBox({
type: 'error',
buttons: [
'Cancel Update',
'Retry Download',
'Retry Installation',
],
message: 'Installation Error',
detail: 'The installer could not be launched. Please try again.',
defaultId: InstallerErrorChoice.RetryOpen,
cancelId: InstallerErrorChoice.Cancel,
noLink: true,
});
return result.response;
}
export enum DownloadErrorChoice {
Cancel = 0,
RetryDownload = 1,
}
export async function promptDownloadError(): Promise<DownloadErrorChoice> {
const result = await dialog.showMessageBox({
type: 'error',
buttons: [
'Cancel Update',
'Retry Download',
],
message: 'Download Error',
detail: 'Unable to download the update. Check your internet connection or try again later.',
defaultId: DownloadErrorChoice.RetryDownload,
cancelId: DownloadErrorChoice.Cancel,
noLink: true,
});
return result.response;
}
export enum UnexpectedErrorChoice {
Cancel = 0,
RetryUpdate = 1,
}
export async function showUnexpectedError(): Promise<UnexpectedErrorChoice> {
const result = await dialog.showMessageBox({
type: 'error',
buttons: [
'Cancel',
'Retry Update',
],
message: 'Unexpected Error',
detail: 'An unexpected error occurred. Would you like to retry updating?',
defaultId: UnexpectedErrorChoice.RetryUpdate,
cancelId: UnexpectedErrorChoice.Cancel,
noLink: true,
});
return result.response;
}

View File

@@ -0,0 +1,223 @@
import { existsSync, createWriteStream } from 'fs';
import { unlink, mkdir } from 'fs/promises';
import path from 'path';
import { app } from 'electron';
import { UpdateInfo } from 'electron-updater';
import fetch from 'cross-fetch';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { UpdateProgressBar } from '../UpdateProgressBar';
import { retryFileSystemAccess } from './RetryFileSystemAccess';
import type { WriteStream } from 'fs';
import type { Readable } from 'stream';
const MAX_PROGRESS_LOG_ENTRIES = 10;
const UNKNOWN_SIZE_LOG_INTERVAL_BYTES = 10 * 1024 * 1024; // 10 MB
export type DownloadUpdateResult = {
readonly success: false;
} | {
readonly success: true;
readonly installerPath: string;
};
export async function downloadUpdate(
info: UpdateInfo,
remoteFileUrl: string,
progressBar: UpdateProgressBar,
): Promise<DownloadUpdateResult> {
ElectronLogger.info('Starting manual update download.');
progressBar.showIndeterminateState();
try {
const { filePath } = await downloadInstallerFile(
info.version,
remoteFileUrl,
(percentage) => { progressBar.showPercentage(percentage); },
);
return {
success: true,
installerPath: filePath,
};
} catch (e) {
progressBar.showError(e);
return {
success: false,
};
}
}
async function downloadInstallerFile(
version: string,
remoteFileUrl: string,
progressHandler: ProgressCallback,
): Promise<{ readonly filePath: string; }> {
const filePath = `${path.dirname(app.getPath('temp'))}/privacy.sexy/${version}-installer.dmg`;
if (!await ensureFilePathReady(filePath)) {
throw new Error(`Failed to prepare the file path for the installer: ${filePath}`);
}
await downloadFileWithProgress(
remoteFileUrl,
filePath,
progressHandler,
);
return { filePath };
}
async function ensureFilePathReady(filePath: string): Promise<boolean> {
return retryFileSystemAccess(async () => {
try {
const parentFolder = path.dirname(filePath);
if (existsSync(filePath)) {
ElectronLogger.info(`Existing update file found and will be replaced: ${filePath}`);
await unlink(filePath);
} else {
await mkdir(parentFolder, { recursive: true });
}
return true;
} catch (error) {
ElectronLogger.error(`Failed to prepare file path for update: ${filePath}`, error);
return false;
}
});
}
type ProgressCallback = (progress: number) => void;
async function downloadFileWithProgress(
url: string,
filePath: string,
progressHandler: ProgressCallback,
) {
// autoUpdater cannot handle DMG files, requiring manual download management for these file types.
ElectronLogger.info(`Retrieving update from ${url}.`);
const response = await fetch(url);
if (!response.ok) {
throw Error(`Download failed: Server responded with ${response.status} ${response.statusText}.`);
}
const contentLength = getContentLengthFromResponse(response);
await withWriteStream(filePath, async (writer) => {
ElectronLogger.info(contentLength.isValid
? `Saving file to ${filePath} (Size: ${contentLength.totalLength} bytes).`
: `Saving file to ${filePath}.`);
await withReadableStream(response, async (reader) => {
await streamWithProgress(contentLength, reader, writer, progressHandler);
});
});
}
type ResponseContentLength = {
readonly isValid: true;
readonly totalLength: number;
} | {
readonly isValid: false;
};
function getContentLengthFromResponse(response: Response): ResponseContentLength {
const contentLengthString = response.headers.get('content-length');
const headersInfo = Array.from(response.headers.entries());
if (!contentLengthString) {
ElectronLogger.warn('Missing \'Content-Length\' header in the response.', headersInfo);
return { isValid: false };
}
const contentLength = Number(contentLengthString);
if (Number.isNaN(contentLength) || contentLength <= 0) {
ElectronLogger.error('Unable to determine download size from server response.', headersInfo);
return { isValid: false };
}
return { totalLength: contentLength, isValid: true };
}
async function withReadableStream(
response: Response,
handler: (readStream: Readable) => Promise<void>,
) {
const reader = createReader(response);
try {
await handler(reader);
} finally {
reader.destroy();
}
}
async function withWriteStream(
filePath: string,
handler: (writeStream: WriteStream) => Promise<void>,
) {
const writer = createWriteStream(filePath);
try {
await handler(writer);
} finally {
writer.end();
}
}
async function streamWithProgress(
contentLength: ResponseContentLength,
readStream: Readable,
writeStream: WriteStream,
progressHandler: ProgressCallback,
): Promise<void> {
let receivedLength = 0;
let logThreshold = 0;
for await (const chunk of readStream) {
if (!chunk) {
throw Error('Received empty data chunk during download.');
}
writeStream.write(Buffer.from(chunk));
receivedLength += chunk.length;
notifyProgress(contentLength, receivedLength, progressHandler);
const progressLog = logProgress(receivedLength, contentLength, logThreshold);
logThreshold = progressLog.nextLogThreshold;
}
ElectronLogger.info('Update download completed successfully.');
}
function logProgress(
receivedLength: number,
contentLength: ResponseContentLength,
logThreshold: number,
): { readonly nextLogThreshold: number; } {
const {
shouldLog, nextLogThreshold,
} = shouldLogProgress(receivedLength, contentLength, logThreshold);
if (shouldLog) {
ElectronLogger.debug(`Download progress: ${receivedLength} bytes received.`);
}
return { nextLogThreshold };
}
function notifyProgress(
contentLength: ResponseContentLength,
receivedLength: number,
progressHandler: ProgressCallback,
) {
if (!contentLength.isValid) {
return;
}
const percentage = Math.floor((receivedLength / contentLength.totalLength) * 100);
progressHandler(percentage);
}
function shouldLogProgress(
receivedLength: number,
contentLength: ResponseContentLength,
previousLogThreshold: number,
): { shouldLog: boolean, nextLogThreshold: number } {
const logInterval = contentLength.isValid
? Math.ceil(contentLength.totalLength / MAX_PROGRESS_LOG_ENTRIES)
: UNKNOWN_SIZE_LOG_INTERVAL_BYTES;
if (receivedLength >= previousLogThreshold + logInterval) {
return { shouldLog: true, nextLogThreshold: previousLogThreshold + logInterval };
}
return { shouldLog: false, nextLogThreshold: previousLogThreshold };
}
function createReader(response: Response): Readable {
if (!response.body) {
throw new Error('Response body is empty, cannot proceed with download.');
}
// On browser, we could use browser API response.body.getReader()
// But here, we use cross-fetch that gets node-fetch on a node application
// This API is node-fetch specific, see https://github.com/node-fetch/node-fetch#streams
return response.body as unknown as Readable;
}

View File

@@ -0,0 +1,16 @@
import { app, shell } from 'electron';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { retryFileSystemAccess } from './RetryFileSystemAccess';
export async function startInstallation(filePath: string): Promise<boolean> {
return retryFileSystemAccess(async () => {
ElectronLogger.info(`Attempting to open the installer at: ${filePath}.`);
const error = await shell.openPath(filePath);
if (!error) {
app.quit();
return true;
}
ElectronLogger.error(`Failed to open the installer at ${filePath}.`, error);
return false;
});
}

View File

@@ -0,0 +1,38 @@
import { createHash } from 'crypto';
import { createReadStream } from 'fs';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { retryFileSystemAccess } from './RetryFileSystemAccess';
export async function checkIntegrity(
filePath: string,
base64Sha512: string,
): Promise<boolean> {
return retryFileSystemAccess(
async () => {
const hash = await computeSha512(filePath);
if (hash === base64Sha512) {
ElectronLogger.info(`Integrity check passed for file: ${filePath}.`);
return true;
}
ElectronLogger.warn([
`Integrity check failed for file: ${filePath}`,
`Expected hash: ${base64Sha512}, but found: ${hash}`,
].join('\n'));
return false;
},
);
}
async function computeSha512(filePath: string): Promise<string> {
try {
const hash = createHash('sha512');
const stream = createReadStream(filePath);
for await (const chunk of stream) {
hash.update(chunk);
}
return hash.digest('base64');
} catch (error) {
ElectronLogger.error(`Failed to compute SHA512 hash for file: ${filePath}`, error);
throw error; // Rethrow to handle it in the calling context if necessary
}
}

View File

@@ -0,0 +1,154 @@
import { shell } from 'electron';
import { UpdateInfo } from 'electron-updater';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { ProjectInformation } from '@/domain/ProjectInformation';
import { Version } from '@/domain/Version';
import { parseProjectInformation } from '@/application/Parser/ProjectInformationParser';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { UpdateProgressBar } from '../UpdateProgressBar';
import {
promptForManualUpdate, promptInstallerOpenError,
promptIntegrityCheckFailure, promptDownloadError,
DownloadErrorChoice, InstallerErrorChoice, IntegrityCheckChoice,
ManualUpdateChoice, showUnexpectedError, UnexpectedErrorChoice,
} from './Dialogs';
import { DownloadUpdateResult, downloadUpdate } from './Downloader';
import { checkIntegrity } from './Integrity';
import { startInstallation } from './Installer';
export function requiresManualUpdate(): boolean {
return process.platform === 'darwin';
}
export async function startManualUpdateProcess(info: UpdateInfo) {
try {
const updateAction = await promptForManualUpdate();
if (updateAction === ManualUpdateChoice.NoAction) {
ElectronLogger.info('User cancelled the update.');
return;
}
const { releaseUrl, downloadUrl } = getRemoteUpdateUrls(info.version);
if (updateAction === ManualUpdateChoice.VisitReleasesPage) {
ElectronLogger.info(`Navigating to release page: ${releaseUrl}`);
await shell.openExternal(releaseUrl);
} else if (updateAction === ManualUpdateChoice.UpdateNow) {
ElectronLogger.info('Initiating update download and installation.');
await downloadAndInstallUpdate(downloadUrl, info);
}
} catch (err) {
ElectronLogger.error('Unexpected error during updates', err);
await handleUnexpectedError(info);
}
}
async function downloadAndInstallUpdate(fileUrl: string, info: UpdateInfo) {
let download: DownloadUpdateResult | undefined;
await withProgressBar(async (progressBar) => {
download = await downloadUpdate(info, fileUrl, progressBar);
});
if (!download?.success) {
await handleFailedDownload(info);
return;
}
if (await isIntegrityPreserved(download.installerPath, fileUrl, info)) {
await openInstaller(download.installerPath, info);
return;
}
const userAction = await promptIntegrityCheckFailure();
if (userAction === IntegrityCheckChoice.RetryDownload) {
await startManualUpdateProcess(info);
} else if (userAction === IntegrityCheckChoice.ContinueAnyway) {
ElectronLogger.warn('Proceeding to install with failed integrity check.');
await openInstaller(download.installerPath, info);
}
}
async function handleFailedDownload(info: UpdateInfo) {
const userAction = await promptDownloadError();
if (userAction === DownloadErrorChoice.Cancel) {
ElectronLogger.info('Update download canceled.');
} else if (userAction === DownloadErrorChoice.RetryDownload) {
ElectronLogger.info('Retrying update download.');
await startManualUpdateProcess(info);
}
}
async function handleUnexpectedError(info: UpdateInfo) {
const userAction = await showUnexpectedError();
if (userAction === UnexpectedErrorChoice.Cancel) {
ElectronLogger.info('Unexpected error handling canceled.');
} else if (userAction === UnexpectedErrorChoice.RetryUpdate) {
ElectronLogger.info('Retrying the update process.');
await startManualUpdateProcess(info);
}
}
async function openInstaller(installerPath: string, info: UpdateInfo) {
if (await startInstallation(installerPath)) {
return;
}
const userAction = await promptInstallerOpenError();
if (userAction === InstallerErrorChoice.RetryDownload) {
await startManualUpdateProcess(info);
} else if (userAction === InstallerErrorChoice.RetryOpen) {
await openInstaller(installerPath, info);
}
}
async function withProgressBar(
action: (progressBar: UpdateProgressBar) => Promise<void>,
) {
const progressBar = new UpdateProgressBar();
await action(progressBar);
progressBar.close();
}
async function isIntegrityPreserved(
filePath: string,
fileUrl: string,
info: UpdateInfo,
): Promise<boolean> {
const sha512Hash = getRemoteSha512Hash(info, fileUrl);
if (!sha512Hash) {
return false;
}
const integrityCheckResult = await checkIntegrity(filePath, sha512Hash);
return integrityCheckResult;
}
function getRemoteSha512Hash(info: UpdateInfo, fileUrl: string): string | undefined {
const fileInfos = info.files.filter((file) => fileUrl.includes(file.url));
if (!fileInfos.length) {
ElectronLogger.error(`Remote hash not found for the URL: ${fileUrl}`, info.files);
if (info.files.length > 0) {
const firstHash = info.files[0].sha512;
ElectronLogger.info(`Selecting the first available hash: ${firstHash}`);
return firstHash;
}
return undefined;
}
if (fileInfos.length > 1) {
ElectronLogger.error(`Found multiple file entries for the URL: ${fileUrl}`, fileInfos);
}
return fileInfos[0].sha512;
}
interface UpdateUrls {
readonly releaseUrl: string;
readonly downloadUrl: string;
}
function getRemoteUpdateUrls(targetVersion: string): UpdateUrls {
const existingProject = parseProjectInformation();
const targetProject = new ProjectInformation(
existingProject.name,
new Version(targetVersion),
existingProject.slogan,
existingProject.repositoryUrl,
existingProject.homepage,
);
return {
releaseUrl: targetProject.releaseUrl,
downloadUrl: targetProject.getDownloadUrl(OperatingSystem.macOS),
};
}

View File

@@ -0,0 +1,39 @@
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
export function retryFileSystemAccess(
fileOperation: () => Promise<boolean>,
): Promise<boolean> {
return retryWithExponentialBackoff(
fileOperation,
TOTAL_RETRIES,
INITIAL_DELAY_MS,
);
}
// These values provide a balanced approach for handling transient file system
// issues without excessive waiting.
const INITIAL_DELAY_MS = 500;
const TOTAL_RETRIES = 3;
async function retryWithExponentialBackoff(
operation: () => Promise<boolean>,
maxAttempts: number,
delayInMs: number,
currentAttempt = 1,
): Promise<boolean> {
const result = await operation();
if (result || currentAttempt === maxAttempts) {
return result;
}
ElectronLogger.info(`Attempting retry (${currentAttempt}/${TOTAL_RETRIES}) in ${delayInMs} ms.`);
await sleep(delayInMs);
const exponentialDelayInMs = delayInMs * 2;
const nextAttempt = currentAttempt + 1;
return retryWithExponentialBackoff(
operation,
maxAttempts,
exponentialDelayInMs,
nextAttempt,
);
}

View File

@@ -0,0 +1,46 @@
import { autoUpdater, UpdateInfo } from 'electron-updater';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { requiresManualUpdate, startManualUpdateProcess } from './ManualUpdater/ManualUpdateCoordinator';
import { handleAutoUpdate } from './AutomaticUpdateCoordinator';
interface Updater {
checkForUpdates(): Promise<void>;
}
export function setupAutoUpdater(): Updater {
autoUpdater.logger = ElectronLogger;
// Auto-downloads are disabled to allow separate handling of 'check' and 'download' actions,
// which vary based on the specific platform and user preferences.
autoUpdater.autoDownload = false;
autoUpdater.on('error', (error: Error) => {
ElectronLogger.error('@error@\n', error);
});
let isAlreadyHandled = false;
autoUpdater.on('update-available', async (info: UpdateInfo) => {
ElectronLogger.info('@update-available@\n', info);
if (isAlreadyHandled) {
ElectronLogger.info('Available updates is already handled');
return;
}
isAlreadyHandled = true;
await handleAvailableUpdate(info);
});
return {
checkForUpdates: async () => {
// autoUpdater.emit('update-available'); // For testing
await autoUpdater.checkForUpdates();
},
};
}
async function handleAvailableUpdate(info: UpdateInfo) {
if (requiresManualUpdate()) {
await startManualUpdateProcess(info);
return;
}
await handleAutoUpdate();
}

View File

@@ -1,7 +1,7 @@
import ProgressBar from 'electron-progressbar';
import { ProgressInfo } from 'electron-builder';
import { app, BrowserWindow } from 'electron';
import log from 'electron-log';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
export class UpdateProgressBar {
private progressBar: ProgressBar | undefined;
@@ -81,7 +81,7 @@ const progressBarFactory = {
progressBar.detail = 'Download completed.';
})
.on('aborted', (value: number) => {
log.info(`progress aborted... ${value}`);
ElectronLogger.info(`Progress aborted... ${value}`);
})
.on('progress', (value: number) => {
progressBar.detail = `${value}% ...`;

View File

@@ -1,46 +0,0 @@
import { autoUpdater, UpdateInfo } from 'electron-updater';
import log from 'electron-log';
import { handleManualUpdate, requiresManualUpdate } from './ManualUpdater';
import { handleAutoUpdate } from './AutoUpdater';
interface IUpdater {
checkForUpdates(): Promise<void>;
}
export function setupAutoUpdater(): IUpdater {
autoUpdater.logger = log;
// Disable autodownloads because "checking" and "downloading" are handled separately based on the
// current platform and user's choice.
autoUpdater.autoDownload = false;
autoUpdater.on('error', (error: Error) => {
log.error('@error@\n', error);
});
let isAlreadyHandled = false;
autoUpdater.on('update-available', async (info: UpdateInfo) => {
log.info('@update-available@\n', info);
if (isAlreadyHandled) {
log.info('Available updates is already handled');
return;
}
isAlreadyHandled = true;
await handleAvailableUpdate(info);
});
return {
checkForUpdates: async () => {
// autoUpdater.emit('update-available'); // For testing
await autoUpdater.checkForUpdates();
},
};
}
async function handleAvailableUpdate(info: UpdateInfo) {
if (requiresManualUpdate()) {
await handleManualUpdate(info);
return;
}
await handleAutoUpdate();
}

View File

@@ -3,10 +3,11 @@
import {
app, protocol, BrowserWindow, shell, screen,
} from 'electron';
import log from 'electron-log/main';
import installExtension, { VUEJS_DEVTOOLS } from 'electron-devtools-installer';
import log from 'electron-log';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { setupAutoUpdater } from './Update/Updater';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { setupAutoUpdater } from './Update/UpdateInitializer';
import {
APP_ICON_PATH, PRELOADER_SCRIPT_PATH, RENDERER_HTML_PATH, RENDERER_URL,
} from './ElectronConfig';
@@ -23,6 +24,7 @@ protocol.registerSchemesAsPrivileged([
]);
setupLogger();
validateRuntimeSanity({
// Metadata is used by manual updates.
validateEnvironmentVariables: true,
@@ -89,7 +91,7 @@ app.on('ready', async () => {
try {
await installExtension(VUEJS_DEVTOOLS);
} catch (e) {
log.error('Vue Devtools failed to install:', e.toString());
ElectronLogger.error('Vue Devtools failed to install:', e.toString());
}
}
createWindow();
@@ -123,7 +125,7 @@ function loadApplication(window: BrowserWindow) {
updater.checkForUpdates();
}
// Do not remove [WINDOW_INIT]; it's a marker used in tests.
log.info('[WINDOW_INIT] Main window initialized and content loading.');
ElectronLogger.info('[WINDOW_INIT] Main window initialized and content loading.');
}
function configureExternalsUrlsOpenBrowser(window: BrowserWindow) {
@@ -150,5 +152,7 @@ function getWindowSize(idealWidth: number, idealHeight: number) {
}
function setupLogger(): void {
// log.initialize(); ← We inject logger to renderer through preloader, this is not needed.
log.transports.file.level = 'silly';
log.eventLogger.startLogging();
}

View File

@@ -1,13 +1,12 @@
import log from 'electron-log';
import { createNodeSystemOperations } from '@/infrastructure/SystemOperations/NodeSystemOperations';
import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
import { WindowVariables } from '@/infrastructure/WindowVariables/WindowVariables';
import { convertPlatformToOs } from './NodeOsMapper';
export function provideWindowVariables(
createSystem = createNodeSystemOperations,
createLogger: () => ILogger = () => createElectronLogger(log),
createLogger: () => Logger = () => createElectronLogger(),
convertToOs = convertPlatformToOs,
): WindowVariables {
return {

View File

@@ -1,8 +1,8 @@
// This file is used to securely expose Electron APIs to the application.
import { contextBridge } from 'electron';
import log from 'electron-log';
import { validateRuntimeSanity } from '@/infrastructure/RuntimeSanity/SanityChecks';
import { ElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { provideWindowVariables } from './WindowVariablesProvider';
validateRuntimeSanity({
@@ -20,4 +20,4 @@ Object.entries(windowVariables).forEach(([key, value]) => {
});
// Do not remove [PRELOAD_INIT]; it's a marker used in tests.
log.info('[PRELOAD_INIT] Preload script successfully initialized and executed.');
ElectronLogger.info('[PRELOAD_INIT] Preload script successfully initialized and executed.');

View File

@@ -9,6 +9,21 @@
<meta name="description"
content="Web tool to generate scripts for enforcing privacy & security best-practices such as stopping data collection of Windows and different softwares on it." />
<link rel="icon" href="/favicon.ico">
<!-- Security meta tags based on OWASP recommendations, see https://owasp.org/www-project-secure-headers/ci/headers_add.json -->
<meta
http-equiv="Content-Security-Policy"
content="
default-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
form-action 'self';
object-src 'none';
upgrade-insecure-requests;
block-all-mixed-content;
"
>
<meta name="referrer" content="no-referrer">
</head>
<body>

View File

@@ -6,6 +6,7 @@ import type { useClipboard } from '@/presentation/components/Shared/Hooks/Clipbo
import type { useCurrentCode } from '@/presentation/components/Shared/Hooks/UseCurrentCode';
import type { useAutoUnsubscribedEvents } from '@/presentation/components/Shared/Hooks/UseAutoUnsubscribedEvents';
import type { useUserSelectionState } from '@/presentation/components/Shared/Hooks/UseUserSelectionState';
import type { useLogger } from '@/presentation/components/Shared/Hooks/UseLogger';
export const InjectionKeys = {
useCollectionState: defineTransientKey<ReturnType<typeof useCollectionState>>('useCollectionState'),
@@ -15,6 +16,7 @@ export const InjectionKeys = {
useClipboard: defineTransientKey<ReturnType<typeof useClipboard>>('useClipboard'),
useCurrentCode: defineTransientKey<ReturnType<typeof useCurrentCode>>('useCurrentCode'),
useUserSelectionState: defineTransientKey<ReturnType<typeof useUserSelectionState>>('useUserSelectionState'),
useLogger: defineTransientKey<ReturnType<typeof useLogger>>('useLogger'),
};
export interface InjectionKeyWithLifetime<T> {

View File

@@ -5,33 +5,28 @@ import { exists } from '../utils/io';
import { SupportedPlatform, CURRENT_PLATFORM } from '../utils/platform';
import { getAppName } from '../utils/npm';
const LOG_FILE_NAMES = ['main', 'renderer'];
export async function clearAppLogFiles(
projectDir: string,
): Promise<void> {
if (!projectDir) { throw new Error('missing project directory'); }
await Promise.all(LOG_FILE_NAMES.map(async (logFileName) => {
const logPath = await determineLogPath(projectDir, logFileName);
if (!logPath || !await exists(logPath)) {
log(`Skipping clearing logs, log file does not exist: ${logPath}.`);
return;
}
try {
await unlink(logPath);
log(`Successfully cleared the log file at: ${logPath}.`);
} catch (error) {
die(`Failed to clear the log file at: ${logPath}. Reason: ${error}`);
}
}));
const logPath = await determineLogPath(projectDir);
if (!logPath || !await exists(logPath)) {
log(`Skipping clearing logs, log file does not exist: ${logPath}.`);
return;
}
try {
await unlink(logPath);
log(`Successfully cleared the log file at: ${logPath}.`);
} catch (error) {
die(`Failed to clear the log file at: ${logPath}. Reason: ${error}`);
}
}
export async function readAppLogFile(
projectDir: string,
logFileName: string,
): Promise<AppLogFileResult> {
if (!projectDir) { throw new Error('missing project directory'); }
const logPath = await determineLogPath(projectDir, logFileName);
const logPath = await determineLogPath(projectDir);
if (!logPath || !await exists(logPath)) {
log(`No log file at: ${logPath}`, LogLevel.Warn);
return {
@@ -52,10 +47,9 @@ interface AppLogFileResult {
async function determineLogPath(
projectDir: string,
logFileName: string,
): Promise<string> {
if (!projectDir) { throw new Error('missing project directory'); }
if (!LOG_FILE_NAMES.includes(logFileName)) { throw new Error(`unknown log file name: ${logFileName}`); }
const logFileName = 'main.log';
const appName = await getAppName(projectDir);
if (!appName) {
return die('App name not found.');
@@ -67,19 +61,19 @@ async function determineLogPath(
if (!process.env.HOME) {
throw new Error('HOME environment variable is not defined');
}
return join(process.env.HOME, 'Library', 'Logs', appName, `${logFileName}.log`);
return join(process.env.HOME, 'Library', 'Logs', appName, logFileName);
},
[SupportedPlatform.Linux]: () => {
if (!process.env.HOME) {
throw new Error('HOME environment variable is not defined');
}
return join(process.env.HOME, '.config', appName, 'logs', `${logFileName}.log`);
return join(process.env.HOME, '.config', appName, 'logs', logFileName);
},
[SupportedPlatform.Windows]: () => {
if (!process.env.USERPROFILE) {
throw new Error('USERPROFILE environment variable is not defined');
}
return join(process.env.USERPROFILE, 'AppData', 'Roaming', appName, 'logs', `${logFileName}.log`);
return join(process.env.USERPROFILE, 'AppData', 'Roaming', appName, 'logs', logFileName);
},
};
const logFilePath = logFilePaths[CURRENT_PLATFORM]?.();

View File

@@ -1,4 +1,4 @@
import { splitTextIntoLines, indentText, filterEmpty } from '../utils/text';
import { splitTextIntoLines, indentText } from '../utils/text';
import { log, die } from '../utils/log';
import { readAppLogFile } from './app-logs';
import { STDERR_IGNORE_PATTERNS } from './error-ignore-patterns';
@@ -11,8 +11,6 @@ const EXPECTED_LOG_MARKERS = [
'[APP_INIT]',
];
type ProcessType = 'main' | 'renderer';
export async function checkForErrors(
stderr: string,
windowTitles: readonly string[],
@@ -31,13 +29,11 @@ async function gatherErrors(
projectDir: string,
): Promise<ExecutionError[]> {
if (!projectDir) { throw new Error('missing project directory'); }
const { logFileContent: mainLogs, logFilePath: mainLogFile } = await readAppLogFile(projectDir, 'main');
const { logFileContent: rendererLogs, logFilePath: rendererLogFile } = await readAppLogFile(projectDir, 'renderer');
const allLogs = filterEmpty([mainLogs, rendererLogs, stderr]).join('\n');
const { logFileContent: mainLogs, logFilePath: mainLogFile } = await readAppLogFile(projectDir);
const allLogs = [mainLogs, stderr].filter(Boolean).join('\n');
return [
verifyStdErr(stderr),
verifyApplicationLogsExist('main', mainLogs, mainLogFile),
verifyApplicationLogsExist('renderer', rendererLogs, rendererLogFile),
verifyApplicationLogsExist(mainLogs, mainLogFile),
...EXPECTED_LOG_MARKERS.map(
(marker) => verifyLogMarkerExistsInLogs(allLogs, marker),
),
@@ -72,13 +68,12 @@ function formatError(error: ExecutionError): string {
}
function verifyApplicationLogsExist(
processType: ProcessType,
logContent: string | undefined,
logFilePath: string,
): ExecutionError | undefined {
if (!logContent?.length) {
return describeError(
`Missing application (${processType}) logs`,
'Missing application logs',
'Application logs are empty not were not found.'
+ `\nLog path: ${logFilePath}`,
);

View File

@@ -13,7 +13,6 @@ export async function retryWithExponentialBackOff(
if (shouldRetry(status)) {
if (currentRetry <= maxTries) {
const exponentialBackOffInMs = getRetryTimeoutInMs(currentRetry, baseRetryIntervalInMs);
// tslint:disable-next-line: no-console
console.log(`Retrying (${currentRetry}) in ${exponentialBackOffInMs / 1000} seconds`, status);
await sleep(exponentialBackOffInMs);
return retryWithExponentialBackOff(action, baseRetryIntervalInMs, currentRetry + 1);

View File

@@ -1,4 +1,5 @@
// eslint-disable-next-line max-classes-per-file
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { getHeaderBrandTitle } from './support/interactions/header';
import { ViewportTestScenarios } from './support/scenarios/viewport-test-scenarios';
@@ -49,11 +50,12 @@ describe('card list layout stability', () => {
// assert
const widthToleranceInPx = 0;
const widthsInPx = dimensions.getUniqueWidths();
expect(isWithinTolerance(widthsInPx, widthToleranceInPx)).to.equal(true, [
`Unique width values over time: ${[...widthsInPx].join(', ')}`,
`Height changes are more than ${widthToleranceInPx}px tolerance`,
`Captured metrics: ${dimensions.toString()}`,
].join('\n\n'));
expect(isWithinTolerance(widthsInPx, widthToleranceInPx))
.to.equal(true, formatAssertionMessage([
`Unique width values over time: ${[...widthsInPx].join(', ')}`,
`Height changes are more than ${widthToleranceInPx}px tolerance`,
`Captured metrics: ${dimensions.toString()}`,
]));
const heightToleranceInPx = 100; // Set in relation to card sizes.
// Tolerance allows for minor layout shifts without (e.g. for icon or font loading)
@@ -61,11 +63,12 @@ describe('card list layout stability', () => {
// cards per row changes, avoiding failures for shifts less than the smallest card
// size (~175px).
const heightsInPx = dimensions.getUniqueHeights();
expect(isWithinTolerance(heightsInPx, heightToleranceInPx)).to.equal(true, [
`Unique height values over time: ${[...heightsInPx].join(', ')}`,
`Height changes are more than ${heightToleranceInPx}px tolerance`,
`Captured metrics: ${dimensions.toString()}`,
].join('\n\n'));
expect(isWithinTolerance(heightsInPx, heightToleranceInPx))
.to.equal(true, formatAssertionMessage([
`Unique height values over time: ${[...heightsInPx].join(', ')}`,
`Height changes are more than ${heightToleranceInPx}px tolerance`,
`Captured metrics: ${dimensions.toString()}`,
]));
});
});
});

View File

@@ -2,8 +2,8 @@ import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { openCard } from './support/interactions/card';
describe('script selection highlighting', () => {
// Regression test for a bug where selecting multiple scripts only highlighted the last one.
it('highlights more when multiple scripts are selected', () => {
// Regression test for a bug where selecting multiple scripts only highlighted the last one.
cy.visit('/');
selectLastScript();
getCurrentHighlightRange((lastScriptHighlightRange) => {

View File

@@ -1,3 +1,4 @@
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { ViewportTestScenarios } from './support/scenarios/viewport-test-scenarios';
describe('Modal interaction and layout stability', () => {
@@ -24,10 +25,10 @@ describe('Modal interaction and layout stability', () => {
captureViewportMetrics((metrics) => {
const metricsAfterModal = metrics;
expect(metricsBeforeModal).to.deep.equal(metricsAfterModal, [
expect(metricsBeforeModal).to.deep.equal(metricsAfterModal, formatAssertionMessage([
`Expected (initial metrics before modal): ${JSON.stringify(metricsBeforeModal)}`,
`Actual (metrics after modal is opened): ${JSON.stringify(metricsAfterModal)}`,
].join('\n'));
]));
});
});
});

View File

@@ -1,3 +1,5 @@
import { formatAssertionMessage } from '../shared/FormatAssertionMessage';
describe('has no unintended overflow', () => {
it('fits the content without horizontal scroll', () => {
// arrange
@@ -6,7 +8,7 @@ describe('has no unintended overflow', () => {
cy.visit('/');
// assert
cy.window().then((win) => {
expect(win.document.documentElement.scrollWidth, [
expect(win.document.documentElement.scrollWidth, formatAssertionMessage([
`Window inner dimensions: ${win.innerWidth}x${win.innerHeight}`,
`Window outer dimensions: ${win.outerWidth}x${win.outerHeight}`,
`Body scrollWidth: ${win.document.body.scrollWidth}`,
@@ -17,7 +19,7 @@ describe('has no unintended overflow', () => {
`Meta viewport content: ${win.document.querySelector('meta[name="viewport"]')?.getAttribute('content')}`,
`Device Pixel Ratio: ${win.devicePixelRatio}`,
`Cypress Viewport: ${Cypress.config('viewportWidth')}x${Cypress.config('viewportHeight')}`,
].join('\n')).to.be.lte(win.innerWidth);
])).to.be.lte(win.innerWidth);
});
});
});

View File

@@ -0,0 +1,72 @@
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { getEnumValues } from '@/application/Common/Enum';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames';
describe('operating system selector', () => {
// Regression test for a bug where switching between operating systems caused uncaught exceptions.
describe('allows user to switch between supported operating systems', () => {
getEnumValues(ViewType).forEach((viewType) => {
it(`switches to ${ViewType[viewType]} view successfully`, () => {
// arrange
cy.visit('/');
selectViewType(viewType);
getSupportedOperatingSystemsList().forEach((operatingSystem) => {
// act
selectOperatingSystem(operatingSystem);
// assert
assertExpectedActions();
});
});
});
});
});
function getSupportedOperatingSystemsList() {
/*
Marked: refactor-with-aot-compilation
The operating systems list is hardcoded due to the challenge of loading
the application within Cypress, as its compilation is tightly coupled with Vite.
Ideally, this should dynamically fetch the list from the compiled application.
*/
return [
OperatingSystem.Windows,
OperatingSystem.Linux,
OperatingSystem.macOS,
];
}
function assertExpectedActions() {
/*
Marked: refactor-with-aot-compilation
Assertions are currently hardcoded due to the inability to load the application within
Cypress, as compilation is tightly coupled with Vite. Future refactoring should dynamically
assert the visibility of all actions (e.g., `actions.map((a) => cy.contains(a.title))`)
once the application's compilation process is decoupled from Vite.
*/
cy.contains('Privacy cleanup');
}
function selectOperatingSystem(operatingSystem: OperatingSystem) {
const operatingSystemLabel = getOperatingSystemDisplayName(operatingSystem);
if (!operatingSystemLabel) {
throw new Error(`Label does not exist for operating system: ${OperatingSystem[operatingSystem]}`);
}
cy.log(`Visiting operating system: ${operatingSystemLabel}`);
cy
.contains('span', operatingSystemLabel)
.click();
}
function selectViewType(viewType: ViewType): void {
const viewTypeLabel = ViewTypeLabels[viewType];
cy.log(`Selecting view: ${ViewType[viewType]}`);
cy
.contains('span', viewTypeLabel)
.click();
}
const ViewTypeLabels: Record<ViewType, string> = {
[ViewType.Cards]: 'Cards',
[ViewType.Tree]: 'Tree',
} as const;

View File

@@ -13,6 +13,6 @@
"sourceMap": false,
},
"include": [
"**/*.ts"
"**/*.ts",
]
}

View File

@@ -0,0 +1,253 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { determineTouchSupportOptions } from '@tests/integration/shared/TestCases/TouchSupportOptions';
interface BrowserOsTestCase {
readonly userAgent: string;
readonly platformTouchSupport: boolean;
readonly expectedOs: OperatingSystem;
}
export const BrowserOsTestCases: ReadonlyArray<BrowserOsTestCase> = [
...createTests({
operatingSystem: OperatingSystem.Windows,
userAgents: [
// Internet Explorer:
'Mozilla/5.0 (Windows NT 6.3; Win64, x64; Trident/7.0; rv:11.0) like Gecko',
'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; Win64; x64; Trident/6.0)',
'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)',
'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)',
// Edge (Legacy):
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/18.17763',
'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/17.17134',
'Mozilla/5.0 (Windows NT 10.0; WebView/3.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299',
// Edge (Chromium):
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0',
// Firefox:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/119.0',
'Mozilla/5.0 (Windows NT 6.4; WOW64; rv:32.0) Gecko/20100101 Firefox/32.0',
// Chrome/Brave/QQ Browser:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/114.0.0.0 Safari/537.36',
// Opera:
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 OPR/105.0.0.0',
'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.52 Safari/537.36 OPR/15.0.1147.100',
'Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14',
'Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.15 Version/10.10',
'Opera/9.27 (Windows NT 5.1; U; en)',
'Opera/9.80 (Windows NT 6.1; Opera Tablet/15165; U; en) Presto/2.8.149 Version/11.1',
// UC Browser:
'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/50.0.2661.102 UBrowser/6.0.1308.1016 Safari/537.36',
],
}),
...createTests({
operatingSystem: OperatingSystem.macOS,
userAgents: [
// Firefox:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:109.0) Gecko/20100101 Firefox/119.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:66.0) Gecko/20100101 Firefox/66.0',
// Chrome/Brave:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36',
// Safari:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0.3 Safari/605.1.15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',
// Opera:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 OPR/105.0.0.0',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.82 Safari/537.36 OPR/29.0.1795.41 (Edition beta)',
// Edge:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36 Edg/119.0.0.0',
],
}),
...createTests({
operatingSystem: OperatingSystem.Linux,
userAgents: [
// Firefox:
'Mozilla/5.0 (X11; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/116.0',
'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:66.0) Gecko/20100101 Firefox/66.0',
// Chrome:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
// Edge:
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36 Edg/115.0.1901.188',
],
}),
...createTests({
operatingSystem: OperatingSystem.iOS,
userAgents: [
...[ // iPhone
// Safari:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1',
// Chrome:
'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.1',
// Firefox:
'Mozilla/5.0 (iPhone; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) FxiOS/1.0 Mobile/12F69 Safari/600.1.4',
// Firefox Focus:
'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/7.0.4 Mobile/16B91 Safari/605.1.15',
// Opera Mini:
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPiOS/16.0.15.124050 Mobile/15E148 Safari/9537.53',
// Opera Touch (discontinued):
'Mozilla/5.0 (iPhone; CPU iPhone OS 16_7_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.7 Mobile/15E148 Safari/604.1 OPT/4.3.2',
'Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPT/3.3.3 Mobile/15E148',
],
...[ // iPod
// Safari:
'Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_2 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8H7 Safari/6533.18.5',
'Mozilla/5.0 (iPod; CPU iPhone OS 9_3 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13E233 Safari/601.1',
// Chrome:
'Mozilla/5.0 (iPod; CPU iPhone OS 14_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/87.0.4280.77 Mobile/15E148 Safari/604.1',
],
],
}),
...createTests({
operatingSystem: OperatingSystem.iPadOS,
userAgents: [
/*
`iPad` might be included in user agents on older iPad that's running iOS (not iPadOS),
to avoid additional complexity, we just detect them as iPadOS.
*/
// Safari on iPad (running iOS):
'Mozilla/5.0 (iPad; U; CPU OS 3_2 like Mac OS X; en-us) AppleWebKit/531.21.10 (KHTML, like Gecko) Version/4.0.4 Mobile/7B367 Safari/531.21.10',
// Edge on iOS:
'Mozilla/5.0 (iPad; CPU OS 17_0_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 EdgiOS/46.2.0 Mobile/15E148 Safari/605.1.15',
// Safari on iPad Mini (running iPadOS):
'Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1',
],
}),
...createTests({
operatingSystem: OperatingSystem.iPadOS,
// Safari on (≥ iPadOS 13) and iPhone on desktop mode reports user agents identical to macOS
userAgents: [
// Safari:
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10156) AppleWebKit/605.1.15 (KHTML, like Gecko)',
],
}),
...createTests({
operatingSystem: OperatingSystem.ChromeOS,
userAgents: [
// Chrome:
'Mozilla/5.0 (X11; CrOS x86_64 11316.165.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.122 Safari/537.36',
'Mozilla/5.0 (X11; CrOS armv7l 4537.56.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.38 Safari/537.36',
],
}),
...createTests({
operatingSystem: OperatingSystem.Android,
userAgents: [
// Opera Mini:
'Opera/9.80 (Android; Opera Mini/32.0/88.150; U; sr) Presto/2.12 Version/12.16',
'Opera/9.80 (Android; Opera Mini/8.0.1807/36.1609; U; en) Presto/2.12.423 Version/12.16',
'Opera/9.80 (Android 2.2; Opera Mobi/-2118645896; U; pl) Presto/2.7.60 Version/10.5',
// Chrome:
'Mozilla/5.0 (Linux; Android 4.4.4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.52 Safari/537.36 Mobile OPR/15.0.1147.100',
'Mozilla/5.0 (Linux; Android 9; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 6.0; CAM-L03) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.99 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 4.2.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 4.3; Nexus 10 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Safari/537.36',
'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/46.0.2490.76 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 9; ONEPLUS A6003) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Mobile Safari/537.36',
// Firefox:
'Mozilla/5.0 (Android 4.4; Tablet; rv:41.0) Gecko/41.0 Firefox/41.0',
'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0',
'Mozilla/5.0 (Android; Mobile; rv:40.0) Gecko/40.0 Firefox/40.0',
'Mozilla/5.0 (Android; Tablet; rv:40.0) Gecko/40.0 Firefox/40.0',
// Firefox Focus:
'Mozilla/5.0 (Linux; Android 7.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Focus/1.0 Chrome/59.0.3029.83 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 7.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Focus/1.0 Chrome/59.0.3029.83 Safari/537.36',
'Mozilla/5.0 (Android 7.0; Mobile; rv:62.0) Gecko/62.0 Firefox/62.0',
// Firefox Klar (german edition):
'Mozilla/5.0 (Linux; Android 7.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Klar/1.0 Chrome/58.0.3029.83 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 7.0) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Focus/4.1 Chrome/62.0.3029.83 Mobile Safari/537.36',
'Mozilla/5.0 (Android 7.0; Mobile; rv:62.0) Gecko/62.0 Firefox/62.0',
// UC Browser:
'Mozilla/5.0 (Linux; U; Android 6.0; en-US; CPH1609 Build/MRA58K) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/12.10.2.1164 Mobile Safari/537.36',
'UCWEB/2.0 (Linux; U; Adr 5.1; en-US; Lenovo Z90a40 Build/LMY47O) U2/1.0.0 UCBrowser/11.1.5.890 U2/1.0.0 Mobile',
'Mozilla/5.0 (Linux; U; Android 5.1; en-US; Lenovo Z90a40 Build/LMY47O) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 UCBrowser/11.1.5.890 U3/0.8.0 Mobile Safari/534.30',
'Mozilla/5.0 (Linux; U; Android 2.3; zh-CN; MI-ONEPlus) AppleWebKit/534.13 (KHTML, like Gecko) UCBrowser/8.6.0.199 U3/0.8.0 Mobile Safari/534.13',
'UCWEB/2.0 (Linux; U; Adr 2.3; en-US; MI-ONEPlus) U2/1.0.0 UCBrowser/8.6.0.199 U2/1.0.0 Mobile',
// Opera:
'Mozilla/5.0 (Linux; Android 2.3.4; MT11i Build/4.0.2.A.0.62) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1364.123 Mobile Safari/537.22 OPR/14.0.1025.52315',
// Opera Touch (discontinued):
'Mozilla/5.0 (Linux; Android 8.1.0; BBF100-6 Build/OPM1.171019.026) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/68.0.3440.91 Mobile Safari/537.36 OPT/6B8575B',
// Samsung Browser:
'Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G965F Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.0 Chrome/67.0.3396.87 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; Android 8.0.0; SAMSUNG SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/8.2 Chrome/63.0.3239.111 Mobile Safari/537.36',
// QQ Browser:
'Mozilla/5.0 (Linux; U; Android 8.1.0; zh-cn; vivo X21A Build/OPM1.171019.011) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.132 MQQBrowser/9.1 Mobile Safari/537.36',
'Mozilla/5.0 (Linux; U; Android 4.4.2; zh-cn; GT-I9500 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko)Version/4.0 MQQBrowser/5.0 QQ-URL-Manager Mobile Safari/537.36',
// Vivo Browser:
'Mozilla/5.0 (Linux; Android 10; V1990A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/87.0.4280.141 Mobile Safari/537.36 VivoBrowser/10.3.10.0',
// Android Generic Webkit based:
'Mozilla/5.0 (Linux; U; Android 4.4.4; pt-br; SM-G530BT Build/KTU84P) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
'Mozilla/5.0 (Linux; U; Android 4.4.2; zh-cn; Q40; Android/4.4.2; Release/12.15.2015) AppleWebKit/534.30 (KHTML, like Gecko) Mobile Safari/534.30',
'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
],
}),
...createTests({
operatingSystem: OperatingSystem.BlackBerry10,
userAgents: [
// BlackBerry Browser:
'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.1.0.1429 Mobile Safari/537.10+',
],
}),
...createTests({
operatingSystem: OperatingSystem.BlackBerryTabletOS,
userAgents: [
// BlackBerry Browser:
'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.0.0; en-US) AppleWebKit/535.8+ (KHTML, like Gecko) Version/7.2.0.0 Safari/535.8+',
],
}),
...createTests({
operatingSystem: OperatingSystem.BlackBerryOS,
userAgents: [
// BlackBerry Browser:
'Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en-US) AppleWebKit/534.8+ (KHTML, like Gecko) Version/6.0.0.466 Mobile Safari/534.8+',
],
}),
...createTests({
operatingSystem: OperatingSystem.WindowsPhone,
userAgents: [
// Internet Explorer Mobile:
'Mozilla/5.0 (Mobile; Windows Phone 8.1; Android 4.0; ARM; Trident/7.0; Touch; rv:11.0; IEMobile/11.0; NOKIA; Lumia 625) like iPhone OS 7_0_3 Mac OS X AppleWebKit/537 (KHTML, like Gecko) Mobile Safari/537',
'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 920)',
'Mozilla/5.0 (Windows Phone 8.1; ARM; Trident/7.0; Touch; rv:11.0; IEMobile/11.0; NOKIA; id313-3) like Gecko',
'Mozilla/5.0 (compatible; MSIE 9.0; Windows Phone OS 7.5; Trident/5.0; IEMobile/9.0; NOKIA; Lumia 900)',
],
}),
...createTests({
operatingSystem: OperatingSystem.Windows10Mobile,
userAgents: [
// Chrome:
'Mozilla/5.0 (Windows Mobile 13; Android 10.0; Microsoft; Lumia 950XL) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Mobile Safari/537.36 Safari/537.36',
// Opera Mini:
'Opera/9.80 (Windows Mobile; Opera Mini/103.1.21595/25.657; U; en) Presto/2.5.25 Version/10.54',
// Edge 100:
'Mozilla/5.0 (compatible; Android 10.0;SM-G973F; Windows Mobile 10.0; Chrome/106.0.5249.126 ) AppleWebKit/535.1 (KHTML, like Gecko) EdgA/100/0.1185.50 Mobile Safari/535.1 3gpp-gba',
// Edge legacy:
'Mozilla/5.0 (Windows Phone 10.0; Android 5.1.1; NOKIA; Lumia 1520) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/13.10586',
// Firefox:
'Mozilla/5.0 (Windows Phone 10.0; Mobile; rv:107.0) Gecko/107.0 Firefox/107.0',
],
}),
...createTests({
operatingSystem: OperatingSystem.KaiOS,
userAgents: [
// Firefox:
'Mozilla/5.0 (Mobile; LYF/F90M/LYF_F90M_000-03-12-110119; Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5',
],
}),
];
function createTests(testScenario: {
readonly operatingSystem: OperatingSystem,
readonly userAgents: readonly string[],
}): BrowserOsTestCase[] {
return determineTouchSupportOptions(testScenario.operatingSystem)
.flatMap((hasTouch): readonly BrowserOsTestCase[] => testScenario
.userAgents.map((userAgent): BrowserOsTestCase => ({
userAgent,
platformTouchSupport: hasTouch,
expectedOs: testScenario.operatingSystem,
})));
}

View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { ConditionBasedOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector';
import { BrowserEnvironment } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { BrowserOsTestCases } from './BrowserOsTestCases';
describe('ConditionBasedOsDetector', () => {
describe('detect', () => {
it('detects as expected', () => {
BrowserOsTestCases.forEach((testCase) => {
// arrange
const sut = new ConditionBasedOsDetector();
const environment: BrowserEnvironment = {
userAgent: testCase.userAgent,
isTouchSupported: testCase.platformTouchSupport,
};
// act
const actual = sut.detect(environment);
// assert
expect(actual).to.equal(testCase.expectedOs, formatAssertionMessage([
`Expected: "${OperatingSystem[testCase.expectedOs]}"`,
`Actual: "${actual === undefined ? 'undefined' : OperatingSystem[actual]}"`,
`User agent: "${testCase.userAgent}"`,
`Touch support: "${testCase.platformTouchSupport ? 'Yes, supported' : 'No, unsupported.'}"`,
]));
});
});
});
});

View File

@@ -1,4 +1,4 @@
import { describe } from 'vitest';
import { describe, it } from 'vitest';
import { createApp } from 'vue';
import { ApplicationBootstrapper } from '@/presentation/bootstrapping/ApplicationBootstrapper';
import { expectDoesNotThrowAsync } from '@tests/shared/Assertions/ExpectThrowsAsync';

View File

@@ -0,0 +1,66 @@
import { describe, it, afterEach } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { MobileSafariActivePseudoClassEnabler } from '@/presentation/bootstrapping/Modules/MobileSafariActivePseudoClassEnabler';
import { EventName, createWindowEventSpies } from '@tests/shared/Spies/WindowEventSpies';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { isTouchEnabledDevice } from '@/infrastructure/RuntimeEnvironment/TouchSupportDetection';
import { MobileSafariDetectionTestCases } from './MobileSafariDetectionTestCases';
describe('MobileSafariActivePseudoClassEnabler', () => {
describe('bootstrap', () => {
MobileSafariDetectionTestCases.forEach(({
description, userAgent, supportsTouch, expectedResult,
}) => {
it(description, () => {
// arrange
const expectedEvent: EventName = 'touchstart';
patchUserAgent(userAgent, afterEach);
const { isAddEventCalled, currentListeners } = createWindowEventSpies(afterEach);
const patchedEnvironment = new ConstructibleRuntimeEnvironment(supportsTouch);
const sut = new MobileSafariActivePseudoClassEnabler(patchedEnvironment);
// act
sut.bootstrap();
// assert
const isSet = isAddEventCalled(expectedEvent);
expect(isSet).to.equal(expectedResult, formatAssertionMessage([
`Expected result\t\t: ${expectedResult ? 'true (mobile Safari)' : 'false (not mobile Safari)'}`,
`Actual result\t\t: ${isSet ? 'true (mobile Safari)' : 'false (not mobile Safari)'}`,
`User agent\t\t: ${navigator.userAgent}`,
`Touch supported\t\t: ${supportsTouch}`,
`Current OS\t\t: ${patchedEnvironment.os === undefined ? 'unknown' : OperatingSystem[patchedEnvironment.os]}`,
`Is desktop?\t\t: ${patchedEnvironment.isDesktop ? 'Yes (Desktop app)' : 'No (Browser)'}`,
`Listeners (${currentListeners.length})\t\t: ${JSON.stringify(currentListeners)}`,
]));
});
});
});
});
function patchUserAgent(
userAgent: string,
restoreCallback: (restoreFunc: () => void) => void,
) {
const originalNavigator = window.navigator;
const userAgentGetter = { get: () => userAgent };
window.navigator = Object.create(navigator, {
userAgent: userAgentGetter,
});
restoreCallback(() => {
Object.assign(window, {
navigator: originalNavigator,
});
});
}
function getTouchDetectorMock(
isTouchEnabled: boolean,
): typeof isTouchEnabledDevice {
return () => isTouchEnabled;
}
class ConstructibleRuntimeEnvironment extends RuntimeEnvironment {
public constructor(isTouchEnabled: boolean) {
super(window, undefined, undefined, getTouchDetectorMock(isTouchEnabled));
}
}

View File

@@ -0,0 +1,216 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
import { determineTouchSupportOptions } from '@tests/integration/shared/TestCases/TouchSupportOptions';
interface PlatformTestCase {
readonly description: string;
readonly userAgent: string;
readonly supportsTouch: boolean;
readonly expectedResult: boolean;
}
export const MobileSafariDetectionTestCases: ReadonlyArray<PlatformTestCase> = [
...createBrowserTestCases({
browserName: 'Safari',
expectedResult: true,
userAgents: [
{
deviceInfo: 'Safari on iPad (≥ 13)',
operatingSystem: OperatingSystem.iPadOS,
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15', // same as macOS (desktop)
supportsTouch: true,
},
{
deviceInfo: 'Safari on iPad (< 13)',
operatingSystem: OperatingSystem.iPadOS,
userAgent: 'Mozilla/5.0 (iPad; CPU OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B14 3 Safari/601.1',
},
{
deviceInfo: 'Safari on iPhone',
operatingSystem: OperatingSystem.iOS,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
},
{
deviceInfo: 'Safari on iPod touch',
operatingSystem: OperatingSystem.iOS,
userAgent: 'Mozila/5.0 (iPod; U; CPU like Mac OS X; en) AppleWebKit/420.1 (KHTML, like Geckto) Version/3.0 Mobile/3A101a Safari/419.3',
// https://web.archive.org/web/20231112165804/https://www.cnet.com/tech/mobile/safari-for-ipod-touch-has-different-user-agent-string-may-not-go-directly-to-iphone-optimized-sites/null/
},
],
}),
...createBrowserTestCases({
browserName: 'Safari',
expectedResult: false,
userAgents: [
{
deviceInfo: 'Safari on macOS',
operatingSystem: OperatingSystem.macOS,
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',
supportsTouch: false,
},
],
}),
...createBrowserTestCases({
browserName: 'Chrome',
expectedResult: false,
userAgents: [
{
deviceInfo: 'macOS',
operatingSystem: OperatingSystem.macOS,
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14_1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
},
{
deviceInfo: 'iPad (iPadOS 17)',
operatingSystem: OperatingSystem.iPadOS,
userAgent: 'Mozilla/5.0 (iPad; CPU OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.1',
},
{
deviceInfo: 'iPhone (iOS 17)',
operatingSystem: OperatingSystem.iOS,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/119.0.6045.109 Mobile/15E148 Safari/604.1',
},
{
deviceInfo: 'iPhone (iOS 12)',
operatingSystem: OperatingSystem.iOS,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/70.0.3538.75 Mobile/15E148 Safari/605.1',
},
{
deviceInfo: 'iPod Touch (iOS 12)',
operatingSystem: OperatingSystem.iOS,
userAgent: 'Mozilla/5.0 (iPod; CPU iPhone OS 12_5 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) CriOS/86.0.4240.93 Mobile/15E148 Safari/604.1',
},
],
}),
...createBrowserTestCases({
browserName: 'Firefox',
expectedResult: false,
userAgents: [
{
deviceInfo: 'macOS',
operatingSystem: OperatingSystem.macOS,
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 14.1; rv:119.0) Gecko/20100101 Firefox/119.0',
},
{
deviceInfo: 'iPad (iPadOS 13)',
operatingSystem: OperatingSystem.iPadOS,
userAgent: 'Mozilla/5.0 (iPad; CPU OS 13_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/19.1b16203 Mobile/15E148 Safari/605.1.15',
},
{
deviceInfo: 'iPhone (iOS 17)',
operatingSystem: OperatingSystem.iOS,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0_3 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) FxiOS/117.2 Mobile/15E148 Safari/605.1.15',
},
],
}),
...createBrowserTestCases({
browserName: 'Edge',
expectedResult: false,
userAgents: [
{
deviceInfo: 'macOS',
operatingSystem: OperatingSystem.macOS,
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.0.0 Safari/537.36 Edg/117.0.2045.55',
},
{
deviceInfo: 'iPad (iPadOS 15)',
operatingSystem: OperatingSystem.iPadOS,
userAgent: 'Mozilla/5.0 (iPad; CPU OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/96.0.1054.61 Version/15.0 Mobile/15E148 Safari/604.1',
},
{
deviceInfo: 'iPhone (iOS 17)',
operatingSystem: OperatingSystem.iOS,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/118.0.2088.81 Version/17.0 Mobile/15E148 Safari/604.1',
},
],
}),
...createBrowserTestCases({
browserName: 'Opera',
expectedResult: false,
userAgents: [
{
deviceInfo: 'Opera Mini on iPhone',
operatingSystem: OperatingSystem.iOS,
userAgent: 'Opera/9.80 (iPhone; Opera Mini/5.0.0176/764; U; en) Presto/2.4.15',
// https://web.archive.org/web/20140221034354/http://my.opera.com/haavard/blog/2010/04/16/iphone-user-agent
},
{
deviceInfo: 'Opera Mini (Opera Turbo) on iPhone',
operatingSystem: OperatingSystem.iOS,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 7_1_1 like Mac OS X) AppleWebKit/537.51.2 (KHTML, like Gecko) OPiOS/8.0.0.78129 Mobile/11D201 Safari/9537.53',
// https://web.archive.org/web/20231112164709/https://dev.opera.com/blog/opera-mini-8-for-ios/
},
{
deviceInfo: 'Opera on macOS',
operatingSystem: OperatingSystem.macOS,
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/108.0.0.0 Safari/537.36 OPR/94.0.0.0',
// https://web.archive.org/web/20231112164741/https://forums.opera.com/topic/59600/have-the-user-agent-browser-identification-match-with-the-mac-os-version-the-browser-is-running-on
},
{
deviceInfo: 'Opera on macOS (legacy)',
operatingSystem: OperatingSystem.macOS,
userAgent: 'Opera/9.80 (Macintosh; Intel Mac OS X 10.8; U; ru) Presto/2.10 Version/12.00',
// https://web.archive.org/web/20231112164741/https://forums.opera.com/topic/59600/have-the-user-agent-browser-identification-match-with-the-mac-os-version-the-browser-is-running-on
},
{
deviceInfo: 'Opera Touch on iPhone',
operatingSystem: OperatingSystem.iOS,
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 15_6 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) OPT/3.3.3 Mobile/15E148',
},
{
deviceInfo: 'Opera Mini on iPhone',
operatingSystem: OperatingSystem.iOS,
userAgent: 'Opera/9.80 (iPhone; Opera Mini/14.0.0/37.8603; U; en) Presto/2.12.423 Version/12.16',
},
{
deviceInfo: 'Opera Mini on iPad',
operatingSystem: OperatingSystem.iPadOS,
userAgent: 'Opera/9.80 (iPad; Opera Mini/7.0.5/191.320; U; id) Presto/2.12.423 Version/12.16',
},
],
}),
...createBrowserTestCases({
browserName: 'Vivo Browser', // Runs only Vivo (Android) devices
expectedResult: false,
userAgents: [
{
deviceInfo: 'VivoBrowser on Android',
operatingSystem: OperatingSystem.Android,
userAgent: 'Mozilla/5.0 (Linux; Android 10; V1990A; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/87.0.4280.141 Mobile Safari/537.36 VivoBrowser/10.3.10.0',
},
],
}),
];
interface UserAgentTestScenario {
readonly userAgent: string;
readonly operatingSystem: OperatingSystem;
readonly deviceInfo: string;
readonly supportsTouch?: boolean;
}
interface BrowserTestScenario {
readonly browserName: string;
readonly expectedResult: boolean;
readonly userAgents: readonly UserAgentTestScenario[];
}
function createBrowserTestCases(
scenario: BrowserTestScenario,
): PlatformTestCase[] {
return scenario.userAgents.flatMap((agentInfo): readonly PlatformTestCase[] => {
const touchCases = agentInfo.supportsTouch === undefined
? determineTouchSupportOptions(agentInfo.operatingSystem)
: [agentInfo.supportsTouch];
return touchCases.map((hasTouch): PlatformTestCase => ({
description: [
scenario.expectedResult ? '[POSITIVE]' : '[NEGATIVE]',
scenario.browserName,
OperatingSystem[agentInfo.operatingSystem],
agentInfo.deviceInfo,
hasTouch === true ? '[TOUCH]' : '[NO TOUCH]',
].join(' | '),
userAgent: agentInfo.userAgent,
supportsTouch: hasTouch,
expectedResult: scenario.expectedResult,
}));
});
}

View File

@@ -0,0 +1,24 @@
import {
describe, it, expect,
} from 'vitest';
import { ApplicationFactory } from '@/application/ApplicationFactory';
import { getOperatingSystemDisplayName } from '@/presentation/components/Shared/OperatingSystemNames';
import { OperatingSystem } from '@/domain/OperatingSystem';
describe('OperatingSystemNames', () => {
describe('getOperatingSystemDisplayName', () => {
describe('retrieving display names for supported operating systems', async () => {
// arrange
const application = await ApplicationFactory.Current.getApp();
const supportedOperatingSystems = application.getSupportedOsList();
supportedOperatingSystems.forEach((supportedOperatingSystem) => {
it(`should return a non-empty name for ${OperatingSystem[supportedOperatingSystem]}`, () => {
// act
const displayName = getOperatingSystemDisplayName(supportedOperatingSystem);
// assert
expect(displayName).to.have.length.greaterThanOrEqual(1);
});
});
});
});
});

View File

@@ -9,23 +9,43 @@ import { provideDependencies } from '@/presentation/bootstrapping/DependencyProv
import { ApplicationContextStub } from '@tests/unit/shared/Stubs/ApplicationContextStub';
describe('TreeView', () => {
it('should render all provided root nodes correctly', async () => {
it('renders all provided root nodes correctly', async () => {
// arrange
const nodes = createSampleNodes();
const wrapper = createTreeViewWrapper(nodes);
// act
await waitForStableDom(wrapper.element);
// assert
const expectedTotalRootNodes = nodes.length;
expect(wrapper.findAll('.node').length).to.equal(expectedTotalRootNodes, wrapper.html());
const rootNodeTexts = nodes.map((node) => node.data.label);
const rootNodeTexts = nodes.map((node) => (node.data as TreeInputMetadata).label);
rootNodeTexts.forEach((label) => {
expect(wrapper.text()).to.include(label);
});
});
// Regression test for a bug where updating the nodes prop caused uncaught exceptions.
it('updates nodes correctly when props change', async () => {
// arrange
const firstNodeLabel = 'Node 1';
const secondNodeLabel = 'Node 2';
const initialNodes: TreeInputNodeDataWithMetadata[] = [{ id: 'node1', data: { label: firstNodeLabel } }];
const updatedNodes: TreeInputNodeDataWithMetadata[] = [{ id: 'node2', data: { label: secondNodeLabel } }];
const wrapper = createTreeViewWrapper(initialNodes);
// act
await wrapper.setProps({ nodes: updatedNodes });
await waitForStableDom(wrapper.element);
// assert
expect(wrapper.text()).toContain(secondNodeLabel);
expect(wrapper.text()).not.toContain(firstNodeLabel);
});
});
function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeData[]) {
function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeDataWithMetadata[]) {
return mount(defineComponent({
components: {
TreeView,
@@ -33,26 +53,34 @@ function createTreeViewWrapper(initialNodeData: readonly TreeInputNodeData[]) {
setup() {
provideDependencies(new ApplicationContextStub());
const initialNodes = shallowRef(initialNodeData);
const nodes = shallowRef(initialNodeData);
const selectedLeafNodeIds = shallowRef<readonly string[]>([]);
return {
initialNodes,
nodes,
selectedLeafNodeIds,
};
},
template: `
<TreeView
:initialNodes="initialNodes"
:selectedLeafNodeIds="selectedLeafNodeIds"
>
<template v-slot:node-content="{ nodeMetadata }">
{{ nodeMetadata.label }}
</template>
</TreeView>`,
<TreeView
:nodes="nodes"
:selectedLeafNodeIds="selectedLeafNodeIds"
>
<template v-slot:node-content="{ nodeMetadata }">
{{ nodeMetadata.label }}
</template>
</TreeView>
`,
}));
}
function createSampleNodes() {
interface TreeInputMetadata {
readonly label: string;
}
type TreeInputNodeDataWithMetadata = TreeInputNodeData & { readonly data?: TreeInputMetadata };
function createSampleNodes(): TreeInputNodeDataWithMetadata[] {
return [
{
id: 'root1',
@@ -93,7 +121,7 @@ function createSampleNodes() {
function waitForStableDom(rootElement, timeout = 3000, interval = 200): Promise<void> {
return new Promise((resolve, reject) => {
let lastTimeoutId;
let lastTimeoutId: ReturnType<typeof setTimeout>;
const observer = new MutationObserver(() => {
if (lastTimeoutId) {
clearTimeout(lastTimeoutId);

View File

@@ -0,0 +1,37 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
enum TouchSupportState {
AlwaysSupported,
MayBeSupported,
NeverSupported,
}
const TouchSupportPerOperatingSystem: Record<OperatingSystem, TouchSupportState> = {
[OperatingSystem.Android]: TouchSupportState.AlwaysSupported,
[OperatingSystem.iOS]: TouchSupportState.AlwaysSupported,
[OperatingSystem.iPadOS]: TouchSupportState.AlwaysSupported,
[OperatingSystem.ChromeOS]: TouchSupportState.AlwaysSupported,
[OperatingSystem.KaiOS]: TouchSupportState.MayBeSupported,
[OperatingSystem.BlackBerry10]: TouchSupportState.AlwaysSupported,
[OperatingSystem.BlackBerryOS]: TouchSupportState.AlwaysSupported,
[OperatingSystem.BlackBerryTabletOS]: TouchSupportState.AlwaysSupported,
[OperatingSystem.WindowsPhone]: TouchSupportState.AlwaysSupported,
[OperatingSystem.Windows10Mobile]: TouchSupportState.AlwaysSupported,
[OperatingSystem.Windows]: TouchSupportState.MayBeSupported,
[OperatingSystem.Linux]: TouchSupportState.MayBeSupported,
[OperatingSystem.macOS]: TouchSupportState.NeverSupported, // Consider Touch Bar as a special case
};
export function determineTouchSupportOptions(os: OperatingSystem): boolean[] {
const state = TouchSupportPerOperatingSystem[os];
switch (state) {
case TouchSupportState.AlwaysSupported:
return [true];
case TouchSupportState.MayBeSupported:
return [true, false];
case TouchSupportState.NeverSupported:
return [false];
default:
throw new Error(`Unknown state: ${TouchSupportState[state]}`);
}
}

View File

@@ -0,0 +1,7 @@
export function formatAssertionMessage(lines: readonly string[]) {
return [ // Using many newlines so `vitest` output looks good
'\n---',
...lines,
'---\n\n',
].join('\n');
}

View File

@@ -0,0 +1,67 @@
export type EventName = keyof WindowEventMap;
export function createWindowEventSpies(restoreCallback: (restoreFunc: () => void) => void) {
const originalAddEventListener = window.addEventListener;
const originalRemoveEventListener = window.removeEventListener;
const currentListeners = new Array<Parameters<typeof window.addEventListener>>();
const addEventListenerCalls = new Array<Parameters<typeof window.addEventListener>>();
const removeEventListenerCalls = new Array<Parameters<typeof window.removeEventListener>>();
window.addEventListener = (
...args: Parameters<typeof window.addEventListener>
): ReturnType<typeof window.addEventListener> => {
addEventListenerCalls.push(args);
currentListeners.push(args);
return originalAddEventListener.call(window, ...args);
};
window.removeEventListener = (
...args: Parameters<typeof window.removeEventListener>
): ReturnType<typeof window.removeEventListener> => {
removeEventListenerCalls.push(args);
const [type, listener] = args;
const registeredListener = findCurrentListener(type as EventName, listener);
if (registeredListener) {
const index = currentListeners.indexOf(registeredListener);
if (index > -1) {
currentListeners.splice(index, 1);
}
}
return originalRemoveEventListener.call(window, ...args);
};
function findCurrentListener(
type: EventName,
listener: EventListenerOrEventListenerObject,
): Parameters<typeof window.addEventListener> | undefined {
return currentListeners.find((args) => {
const [eventType, eventListener] = args;
return eventType === type && listener === eventListener;
});
}
restoreCallback(() => {
window.addEventListener = originalAddEventListener;
window.removeEventListener = originalRemoveEventListener;
});
return {
isAddEventCalled(eventType: EventName): boolean {
const call = addEventListenerCalls.find((args) => {
const [type] = args;
return type === eventType;
});
return call !== undefined;
},
isRemoveEventCalled(eventType: EventName) {
const call = removeEventListenerCalls.find((args) => {
const [type] = args;
return type === eventType;
});
return call !== undefined;
},
currentListeners,
};
}

View File

@@ -1,4 +1,5 @@
import { SelectedScript } from '@/application/Context/State/Selection/Script/SelectedScript';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
export function expectEqualSelectedScripts(
actual: readonly SelectedScript[],
@@ -14,11 +15,11 @@ function expectSameScriptIds(
) {
const existingScriptIds = expected.map((script) => script.id).sort();
const expectedScriptIds = actual.map((script) => script.id).sort();
expect(existingScriptIds).to.deep.equal(expectedScriptIds, [
expect(existingScriptIds).to.deep.equal(expectedScriptIds, formatAssertionMessage([
'Unexpected script IDs.',
`Expected: ${expectedScriptIds.join(', ')}`,
`Actual: ${existingScriptIds.join(', ')}`,
].join('\n'));
]));
}
function expectSameRevertStates(
@@ -33,7 +34,7 @@ function expectSameRevertStates(
}
return script.revert !== other.revert;
});
expect(scriptsWithDifferentRevertStates).to.have.lengthOf(0, [
expect(scriptsWithDifferentRevertStates).to.have.lengthOf(0, formatAssertionMessage([
'Scripts with different revert states:',
scriptsWithDifferentRevertStates
.map((s) => [
@@ -42,5 +43,5 @@ function expectSameRevertStates(
`Expected revert state: "${expected.find((existing) => existing.id === s.id)?.revert ?? 'unknown'}"`,
].map((line) => `\t${line}`).join('\n'))
.join('\n---\n'),
].join('\n'));
]));
}

View File

@@ -12,6 +12,7 @@ import { IReadOnlyFunctionParameterCollection } from '@/application/Parser/Scrip
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { IExpressionEvaluationContext } from '@/application/Parser/Script/Compiler/Expressions/Expression/ExpressionEvaluationContext';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
describe('Expression', () => {
describe('ctor', () => {
@@ -116,11 +117,10 @@ describe('Expression', () => {
// arrange
const actual = sut.evaluate(context);
// assert
expect(expected).to.equal(actual, printMessage());
function printMessage(): string {
return `\nGiven arguments: ${JSON.stringify(givenArguments)}\n`
+ `\nExpected parameter names: ${JSON.stringify(expectedParameterNames)}\n`;
}
expect(expected).to.equal(actual, formatAssertionMessage([
`Given arguments: ${JSON.stringify(givenArguments)}`,
`Expected parameter names: ${JSON.stringify(expectedParameterNames)}`,
]));
});
it('sends pipeline compiler as it is', () => {
// arrange

View File

@@ -1,6 +1,7 @@
import {
CallFunctionBody, CodeFunctionBody, FunctionBodyType, SharedFunctionBody,
} from '@/application/Parser/Script/Compiler/Function/ISharedFunction';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
export function expectCodeFunctionBody(
body: SharedFunctionBody,
@@ -16,14 +17,9 @@ export function expectCallsFunctionBody(
function expectBodyType(body: SharedFunctionBody, expectedType: FunctionBodyType) {
const actualType = body.type;
expect(actualType).to.equal(
expectedType,
[
'\n---',
`Actual: ${FunctionBodyType[actualType]}`,
`Expected: ${FunctionBodyType[expectedType]}`,
`Body: ${JSON.stringify(body)}`,
'---\n\n',
].join('\n'),
);
expect(actualType).to.equal(expectedType, formatAssertionMessage([
`Actual: ${FunctionBodyType[actualType]}`,
`Expected: ${FunctionBodyType[expectedType]}`,
`Body: ${JSON.stringify(body)}`,
]));
}

View File

@@ -1,6 +1,7 @@
import { readdirSync, readFileSync } from 'fs';
import { resolve, join, basename } from 'path';
import { describe, it, expect } from 'vitest';
import { formatAssertionMessage } from '@tests/shared/FormatAssertionMessage';
/*
A common mistake when working with yaml files to forget mentioning that a value should
@@ -24,11 +25,10 @@ describe('collection files to have no unintended inlining', () => {
// act
const lines = await findBadLineNumbers(testCase.content);
// assert
expect(lines).to.be.have.lengthOf(0, printMessage());
function printMessage(): string {
return 'Did you intend to have multi-lined string in lines: ' // eslint-disable-line prefer-template
+ lines.map(((line) => line.toString())).join(', ');
}
expect(lines).to.be.have.lengthOf(0, formatAssertionMessage([
'Did you intend to have multi-lined string in lines: ',
lines.map(((line) => line.toString())).join(', '),
]));
});
}
});

View File

@@ -69,7 +69,7 @@ describe('Application', () => {
value: [
new CategoryCollectionStub().withOs(OperatingSystem.Windows),
new CategoryCollectionStub().withOs(OperatingSystem.Windows),
new CategoryCollectionStub().withOs(OperatingSystem.BlackBerry),
new CategoryCollectionStub().withOs(OperatingSystem.BlackBerry10),
],
},
];

View File

@@ -32,21 +32,6 @@ describe('ConsoleLogger', () => {
expect(consoleMock.callHistory[0].args).to.deep.equal(expectedParams);
});
});
describe('throws if log function is missing', () => {
itEachLoggingMethod((functionName, testParameters) => {
// arrange
const expectedError = `missing "${functionName}" function`;
const consoleMock = {} as Partial<Console>;
consoleMock[functionName] = undefined;
const logger = new ConsoleLogger(consoleMock);
// act
const act = () => logger[functionName](...testParameters);
// assert
expect(act).to.throw(expectedError);
});
});
});
class MockConsole
@@ -58,4 +43,25 @@ class MockConsole
args,
});
}
public warn(...args: unknown[]) {
this.registerMethodCall({
methodName: 'warn',
args,
});
}
public debug(...args: unknown[]) {
this.registerMethodCall({
methodName: 'debug',
args,
});
}
public error(...args: unknown[]) {
this.registerMethodCall({
methodName: 'error',
args,
});
}
}

View File

@@ -1,42 +1,15 @@
import { describe, expect } from 'vitest';
import { ElectronLog } from 'electron-log';
import { StubWithObservableMethodCalls } from '@tests/unit/shared/Stubs/StubWithObservableMethodCalls';
import { createElectronLogger } from '@/infrastructure/Log/ElectronLogger';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { itEachLoggingMethod } from './LoggerTestRunner';
import type { LogFunctions } from 'electron-log';
describe('ElectronLogger', () => {
describe('throws if logger is missing', () => {
itEachAbsentObjectValue((absentValue) => {
// arrange
const expectedError = 'missing logger';
const electronLog = absentValue as never;
// act
const act = () => createElectronLogger(electronLog);
// assert
expect(act).to.throw(expectedError);
}, { excludeUndefined: true });
});
describe('throws if log function is missing', () => {
itEachLoggingMethod((functionName, testParameters) => {
// arrange
const expectedError = `missing "${functionName}" function`;
const electronLogMock = {} as Partial<ElectronLog>;
electronLogMock[functionName] = undefined;
const logger = createElectronLogger(electronLogMock);
// act
const act = () => logger[functionName](...testParameters);
// assert
expect(act).to.throw(expectedError);
});
});
describe('methods log the provided params', () => {
itEachLoggingMethod((functionName, testParameters) => {
// arrange
const expectedParams = testParameters;
const electronLogMock = new MockElectronLog();
const electronLogMock = new ElectronLogStub();
const logger = createElectronLogger(electronLogMock);
// act
@@ -50,9 +23,51 @@ describe('ElectronLogger', () => {
});
});
class MockElectronLog
extends StubWithObservableMethodCalls<ElectronLog>
implements Partial<ElectronLog> {
class ElectronLogStub
extends StubWithObservableMethodCalls<LogFunctions>
implements LogFunctions {
public error(...args: unknown[]) {
this.registerMethodCall({
methodName: 'error',
args,
});
}
public warn(...args: unknown[]) {
this.registerMethodCall({
methodName: 'warn',
args,
});
}
public verbose(...args: unknown[]): void {
this.registerMethodCall({
methodName: 'verbose',
args,
});
}
public debug(...args: unknown[]) {
this.registerMethodCall({
methodName: 'debug',
args,
});
}
public silly(...args: unknown[]) {
this.registerMethodCall({
methodName: 'silly',
args,
});
}
public log(...args: unknown[]) {
this.registerMethodCall({
methodName: 'log',
args,
});
}
public info(...args: unknown[]) {
this.registerMethodCall({
methodName: 'info',

View File

@@ -1,23 +1,25 @@
import { it } from 'vitest';
import { FunctionKeys } from '@/TypeHelpers';
import { ILogger } from '@/infrastructure/Log/ILogger';
type TestParameters = [string, number, { some: string }];
import { Logger } from '@/application/Common/Log/Logger';
export function itEachLoggingMethod(
handler: (
functionName: keyof ILogger,
testParameters: TestParameters,
functionName: keyof Logger,
testParameters: readonly unknown[]
) => void,
) {
const testParameters: TestParameters = ['test', 123, { some: 'object' }];
const loggerMethods: Array<FunctionKeys<ILogger>> = [
'info',
];
loggerMethods
.forEach((functionKey) => {
const testScenarios: {
readonly [FunctionName in keyof Logger]: Parameters<Logger[FunctionName]>;
} = {
info: ['single-string'],
warn: ['with number', 123],
debug: ['with simple object', { some: 'object' }],
error: ['with error object', new Error('error')],
};
Object.entries(testScenarios)
.forEach(([functionKey, testParameters]) => {
it(functionKey, () => {
handler(functionKey, testParameters);
handler(functionKey as keyof Logger, testParameters);
});
});
}

View File

@@ -1,6 +1,6 @@
import { describe, expect } from 'vitest';
import { NoopLogger } from '@/infrastructure/Log/NoopLogger';
import { ILogger } from '@/infrastructure/Log/ILogger';
import { Logger } from '@/application/Common/Log/Logger';
import { itEachLoggingMethod } from './LoggerTestRunner';
describe('NoopLogger', () => {
@@ -8,7 +8,7 @@ describe('NoopLogger', () => {
itEachLoggingMethod((functionName, testParameters) => {
// arrange
const randomParams = testParameters;
const logger: ILogger = new NoopLogger();
const logger: Logger = new NoopLogger();
// act
const act = () => logger[functionName](...randomParams);

View File

@@ -1,35 +0,0 @@
import { describe, it, expect } from 'vitest';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { BrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector';
import { itEachAbsentStringValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { BrowserOsTestCases } from './BrowserOsTestCases';
describe('BrowserOsDetector', () => {
describe('returns undefined when user agent is absent', () => {
itEachAbsentStringValue((absentValue) => {
// arrange
const expected = undefined;
const userAgent = absentValue;
const sut = new BrowserOsDetector();
// act
const actual = sut.detect(userAgent);
// assert
expect(actual).to.equal(expected);
}, { excludeNull: true, excludeUndefined: true });
});
it('detects as expected', () => {
BrowserOsTestCases.forEach((testCase) => {
// arrange
const sut = new BrowserOsDetector();
// act
const actual = sut.detect(testCase.userAgent);
// assert
expect(actual).to.equal(testCase.expectedOs, printMessage());
function printMessage(): string {
return `Expected: "${OperatingSystem[testCase.expectedOs]}"\n`
+ `Actual: "${actual === undefined ? 'undefined' : OperatingSystem[actual]}"\n`
+ `UserAgent: "${testCase.userAgent}"`;
}
});
});
});

View File

@@ -1,337 +0,0 @@
import { OperatingSystem } from '@/domain/OperatingSystem';
interface IBrowserOsTestCase {
userAgent: string;
expectedOs: OperatingSystem;
}
export const BrowserOsTestCases: ReadonlyArray<IBrowserOsTestCase> = [
{
userAgent: 'Mozilla/5.0 (Windows NT 6.3; Win64, x64; Trident/7.0; rv:11.0) like Gecko',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows NT 6.2; Win64; x64; Trident/6.0)',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.1; Trident/5.0)',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (compatible; MSIE 9.0; Windows NT 6.0; Trident/5.0)',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 6.0; Trident/4.0)',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/4.0 (compatible; MSIE 7.0; Windows NT 6.0)',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.1)',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/18.17763',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/64.0.3282.140 Safari/537.36 Edge/17.17134',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0; WebView/3.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/58.0.3029.110 Safari/537.36 Edge/16.16299',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/52.0.2743.116 Safari/537.36 Edge/15.15063',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/48.0.2564.82 Safari/537.36 Edge/14.14316',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Safari/537.36 Edge/13.10586',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Windows Phone 10.0; Android 5.1.1; NOKIA; Lumia 1520) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2486.0 Mobile Safari/537.36 Edge/13.10586',
expectedOs: OperatingSystem.WindowsPhone,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.135 Safari/537.36 Edge/12.10136',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:66.0) Gecko/20100101 Firefox/66.0',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:66.0) Gecko/20100101 Firefox/66.0',
expectedOs: OperatingSystem.macOS,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.86 Safari/537.36',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Safari/537.36',
expectedOs: OperatingSystem.macOS,
},
{
userAgent: 'Mozilla/5.0 (X11; CrOS x86_64 11316.165.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.122 Safari/537.36',
expectedOs: OperatingSystem.ChromeOS,
},
{
userAgent: 'Mozilla/5.0 (X11; CrOS x86_64 8872.76.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/55.0.2883.105 Safari/537.36',
expectedOs: OperatingSystem.ChromeOS,
},
{
userAgent: 'Mozilla/5.0 (X11; CrOS armv7l 4537.56.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/30.0.1599.38 Safari/537.36',
expectedOs: OperatingSystem.ChromeOS,
},
{
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0.3 Safari/605.1.15',
expectedOs: OperatingSystem.macOS,
},
{
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_12_6) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/11.1.2 Safari/605.1.15',
expectedOs: OperatingSystem.macOS,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36 OPR/58.0.3135.114',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.170 Safari/537.36 OPR/53.0.2907.68',
expectedOs: OperatingSystem.macOS,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2393.94 Safari/537.36 OPR/42.0.2393.94',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_10_2) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/42.0.2311.82 Safari/537.36 OPR/29.0.1795.41 (Edition beta)',
expectedOs: OperatingSystem.macOS,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.52 Safari/537.36 OPR/15.0.1147.100',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Opera/9.80 (Windows NT 6.0) Presto/2.12.388 Version/12.14',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Opera/9.80 (Windows NT 6.0; U; en) Presto/2.2.15 Version/10.10',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Opera/9.27 (Windows NT 5.1; U; en)',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1',
expectedOs: OperatingSystem.iOS,
},
{
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 (KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1',
expectedOs: OperatingSystem.iOS,
},
{
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 10_0 like Mac OS X) AppleWebKit/602.1.38 (KHTML, like Gecko) Version/10.0 Mobile/14A300 Safari/602.1',
expectedOs: OperatingSystem.iOS,
},
{
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1',
expectedOs: OperatingSystem.iOS,
},
{
userAgent: 'Opera/9.80 (Android; Opera Mini/32.0/88.150; U; sr) Presto/2.12 Version/12.16',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Opera/9.80 (Android; Opera Mini/8.0.1807/36.1609; U; en) Presto/2.12.423 Version/12.16',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; U; Android 4.4.4; pt-br; SM-G530BT Build/KTU84P) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; U; Android 4.4.2; zh-cn; Q40; Android/4.4.2; Release/12.15.2015) AppleWebKit/534.30 (KHTML, like Gecko) Mobile Safari/534.30',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.1.0.1429 Mobile Safari/537.10+',
expectedOs: OperatingSystem.BlackBerry,
},
{
userAgent: 'Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.0.0; en-US) AppleWebKit/535.8+ (KHTML, like Gecko) Version/7.2.0.0 Safari/535.8+',
expectedOs: OperatingSystem.BlackBerryTabletOS,
},
{
userAgent: 'Mozilla/5.0 (BlackBerry; U; BlackBerry 9800; en-US) AppleWebKit/534.8+ (KHTML, like Gecko) Version/6.0.0.466 Mobile Safari/534.8+',
expectedOs: OperatingSystem.BlackBerryOS,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 4.4.4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/28.0.1500.52 Safari/537.36 Mobile OPR/15.0.1147.100',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 2.3.4; MT11i Build/4.0.2.A.0.62) AppleWebKit/537.22 (KHTML, like Gecko) Chrome/25.0.1364.123 Mobile Safari/537.22 OPR/14.0.1025.52315',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Opera/9.80 (Windows NT 6.1; Opera Tablet/15165; U; en) Presto/2.8.149 Version/11.1',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Opera/9.80 (Android 2.2; Opera Mobi/-2118645896; U; pl) Presto/2.7.60 Version/10.5',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 9; SM-G960U) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 6.0; CAM-L03) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.99 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 5.1.1; Nexus 6 Build/LYZ28E) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 4.4.2; Nexus 4 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 4.2.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 4.3; Nexus 10 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/46.0.2490.76 Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; U; Android 4.4.2; en-us; LGMS323 Build/KOT49I.MS32310c) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/46.0.2490.76 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Android 9; Mobile; rv:64.0) Gecko/64.0 Firefox/64.0',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Android 4.4; Mobile; rv:41.0) Gecko/41.0 Firefox/41.0',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 8_3 like Mac OS X) AppleWebKit/600.1.4 (KHTML, like Gecko) FxiOS/1.0 Mobile/12F69 Safari/600.1.4',
expectedOs: OperatingSystem.iOS,
},
{
userAgent: 'Mozilla/5.0 (Mobile; Windows Phone 8.1; Android 4.0; ARM; Trident/7.0; Touch; rv:11.0; IEMobile/11.0; NOKIA; Lumia 625) like iPhone OS 7_0_3 Mac OS X AppleWebKit/537 (KHTML, like Gecko) Mobile Safari/537',
expectedOs: OperatingSystem.WindowsPhone,
},
{
userAgent: 'Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 520)',
expectedOs: OperatingSystem.WindowsPhone,
},
{
userAgent: 'Mozilla/5.0 (Linux; U; Android 6.0; en-US; CPH1609 Build/MRA58K) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.108 UCBrowser/12.10.2.1164 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'UCWEB/2.0 (Linux; U; Adr 5.1; en-US; Lenovo Z90a40 Build/LMY47O) U2/1.0.0 UCBrowser/11.1.5.890 U2/1.0.0 Mobile',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; U; Android 5.1; en-US; Lenovo Z90a40 Build/LMY47O) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 UCBrowser/11.1.5.890 U3/0.8.0 Mobile Safari/534.30',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'UCWEB/2.0 (Linux; U; Adr 2.3; en-US; MI-ONEPlus) U2/1.0.0 UCBrowser/8.6.0.199 U2/1.0.0 Mobile',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; U; Android 2.3; zh-CN; MI-ONEPlus) AppleWebKit/534.13 (KHTML, like Gecko) UCBrowser/8.6.0.199 U3/0.8.0 Mobile Safari/534.13',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 9; SAMSUNG SM-G965F Build/PPR1.180610.011) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/9.0 Chrome/67.0.3396.87 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 8.0.0; SAMSUNG SM-G955U Build/R16NW) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/8.2 Chrome/63.0.3239.111 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 7.0; SAMSUNG SM-J330FN Build/NRD90M) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/7.2 Chrome/59.0.3071.125 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 5.0.2; SAMSUNG SM-G925F Build/LRX22G) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/4.0 Chrome/44.0.2403.133 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 5.0.2; SAMSUNG SM-G925F Build/LRX22G) AppleWebKit/537.36 (KHTML, like Gecko) SamsungBrowser/4.0 Chrome/44.0.2403.133 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; U; Android 8.1.0; zh-cn; vivo X21A Build/OPM1.171019.011) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/57.0.2987.132 MQQBrowser/9.1 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; U; Android 4.4.2; zh-cn; GT-I9500 Build/KOT49H) AppleWebKit/537.36 (KHTML, like Gecko)Version/4.0 MQQBrowser/5.0 QQ-URL-Manager Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Linux; Android 9; ONEPLUS A6003) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/73.0.3683.75 Mobile Safari/537.36',
expectedOs: OperatingSystem.Android,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (Windows NT 6.4; WOW64; rv:32.0) Gecko/20100101 Firefox/32.0',
expectedOs: OperatingSystem.Windows,
},
{
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 12_1_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/12.0 Mobile/15E148 Safari/604.1',
expectedOs: OperatingSystem.iOS,
},
{
userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.121 Safari/537.36',
expectedOs: OperatingSystem.macOS,
},
{
userAgent: 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:66.0) Gecko/20100101 Firefox/66.0',
expectedOs: OperatingSystem.Linux,
},
{
userAgent: 'Mozilla/5.0 (X11; CrOS x86_64 11316.165.0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/72.0.3626.122 Safari/537.36',
expectedOs: OperatingSystem.ChromeOS,
},
{
userAgent: 'Mozilla/5.0 (Mobile; LYF/F90M/LYF_F90M_000-03-12-110119; Android; rv:48.0) Gecko/48.0 Firefox/48.0 KAIOS/2.5',
expectedOs: OperatingSystem.KaiOS,
},
];

View File

@@ -0,0 +1,260 @@
import { describe, it, expect } from 'vitest';
import { BrowserCondition, TouchSupportExpectation } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserCondition';
import { ConditionBasedOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/ConditionBasedOsDetector';
import { getAbsentStringTestCases, itEachAbsentCollectionValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { BrowserEnvironmentStub } from '@tests/unit/shared/Stubs/BrowserEnvironmentStub';
import { BrowserConditionStub } from '@tests/unit/shared/Stubs/BrowserConditionStub';
import { EnumRangeTestRunner } from '@tests/unit/application/Common/EnumRangeTestRunner';
describe('ConditionBasedOsDetector', () => {
describe('constructor', () => {
describe('throws when given no conditions', () => {
itEachAbsentCollectionValue<BrowserCondition>((absentCollection) => {
// arrange
const expectedError = 'empty conditions';
const conditions = absentCollection;
// act
const act = () => new ConditionBasedOsDetectorBuilder()
.withConditions(conditions)
.build();
// assert
expect(act).to.throw(expectedError);
}, { excludeUndefined: true, excludeNull: true });
});
it('throws if user agent part is missing', () => {
// arrange
const expectedError = 'Each condition must include at least one identifiable part of the user agent string.';
const invalidCondition = new BrowserConditionStub().withExistingPartsInSameUserAgent([]);
// act
const act = () => new ConditionBasedOsDetectorBuilder()
.withConditions([invalidCondition])
.build();
// assert
expect(act).to.throw(expectedError);
});
describe('validates touch support expectation range', () => {
// arrange
const validValue = TouchSupportExpectation.MustExist;
// act
const act = (touchSupport: TouchSupportExpectation) => new ConditionBasedOsDetectorBuilder()
.withConditions([new BrowserConditionStub().withTouchSupport(touchSupport)])
.build();
// assert
new EnumRangeTestRunner(act)
.testOutOfRangeThrows()
.testValidValueDoesNotThrow(validValue);
});
it('throws if duplicate parts exist in user agent', () => {
// arrange
const expectedError = 'Found duplicate entries in user agent parts: Windows. Each part should be unique.';
const invalidCondition = {
operatingSystem: OperatingSystem.Windows,
existingPartsInSameUserAgent: ['Windows', 'Windows'],
};
// act
const act = () => new ConditionBasedOsDetectorBuilder()
.withConditions([invalidCondition])
.build();
// assert
expect(act).toThrowError(expectedError);
});
it('throws if duplicate non-existing parts exist in user agent', () => {
// arrange
const expectedError = 'Found duplicate entries in user agent parts: Linux. Each part should be unique.';
const invalidCondition = {
operatingSystem: OperatingSystem.Linux,
existingPartsInSameUserAgent: ['Linux'],
notExistingPartsInUserAgent: ['Linux'],
};
// act
const act = () => new ConditionBasedOsDetectorBuilder()
.withConditions([invalidCondition])
.build();
// assert
expect(act).toThrowError(expectedError);
});
it('throws if duplicates found in any user agent parts', () => {
// arrange
const expectedError = 'Found duplicate entries in user agent parts: Android. Each part should be unique.';
const invalidCondition = {
operatingSystem: OperatingSystem.Android,
existingPartsInSameUserAgent: ['Android'],
notExistingPartsInUserAgent: ['iOS', 'Android'],
};
// act
const act = () => new ConditionBasedOsDetectorBuilder()
.withConditions([invalidCondition])
.build();
// assert
expect(act).toThrowError(expectedError);
});
});
describe('detect', () => {
it('detects the correct OS when multiple conditions match', () => {
// arrange
const expectedOperatingSystem = OperatingSystem.Linux;
const testUserAgent = 'test-user-agent';
const expectedCondition = new BrowserConditionStub()
.withOperatingSystem(expectedOperatingSystem)
.withExistingPartsInSameUserAgent([testUserAgent]);
const conditions = [
expectedCondition,
new BrowserConditionStub()
.withExistingPartsInSameUserAgent(['unrelated user agent'])
.withOperatingSystem(OperatingSystem.Android),
new BrowserConditionStub()
.withNotExistingPartsInUserAgent([testUserAgent])
.withOperatingSystem(OperatingSystem.macOS),
];
const environment = new BrowserEnvironmentStub()
.withUserAgent(testUserAgent);
const detector = new ConditionBasedOsDetectorBuilder()
.withConditions(conditions)
.build();
// act
const actualOperatingSystem = detector.detect(environment);
// assert
expect(actualOperatingSystem).to.equal(expectedOperatingSystem);
});
describe('user agent checks', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly buildEnvironment: (environment: BrowserEnvironmentStub) => BrowserEnvironmentStub;
readonly buildCondition: (condition: BrowserConditionStub) => BrowserConditionStub;
readonly detects: boolean;
}> = [
...getAbsentStringTestCases({ excludeUndefined: true, excludeNull: true })
.map((testCase) => ({
description: `does not detect when user agent is empty (${testCase.valueName})`,
buildEnvironment: (environment) => environment.withUserAgent(testCase.absentValue),
buildCondition: (condition) => condition,
detects: false,
})),
{
description: 'detects when user agent matches completely',
buildEnvironment: (environment) => environment.withUserAgent('test-user-agent'),
buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['test-user-agent']),
detects: true,
},
{
description: 'detects when substring of user agent exists',
buildEnvironment: (environment) => environment.withUserAgent('test-user-agent'),
buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['test']),
detects: true,
},
{
description: 'does not detect when no part of user agent exists',
buildEnvironment: (environment) => environment.withUserAgent('unrelated-user-agent'),
buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['lorem-ipsum']),
detects: false,
},
{
description: 'detects when non-existing parts do not match',
buildEnvironment: (environment) => environment.withUserAgent('1-3'),
buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['1']).withNotExistingPartsInUserAgent(['2']),
detects: true,
},
{
description: 'does not detect when non-existing and existing parts match',
buildEnvironment: (environment) => environment.withUserAgent('1-2'),
buildCondition: (condition) => condition.withExistingPartsInSameUserAgent(['1']).withNotExistingPartsInUserAgent(['2']),
detects: false,
},
];
testScenarios.forEach(({
description, buildEnvironment, buildCondition, detects,
}) => {
it(description, () => {
// arrange
const environment = buildEnvironment(new BrowserEnvironmentStub());
const condition = buildCondition(
new BrowserConditionStub().withOperatingSystem(OperatingSystem.Linux),
);
const detector = new ConditionBasedOsDetectorBuilder()
.withConditions([condition])
.build();
// act
const actualOperatingSystem = detector.detect(environment);
// assert
expect(actualOperatingSystem !== undefined).to.equal(detects);
});
});
});
describe('touch support checks', () => {
const testScenarios: ReadonlyArray<{
readonly description: string;
readonly expectation: TouchSupportExpectation;
readonly isTouchSupportInEnvironment: boolean;
readonly detects: boolean;
}> = [
{
description: 'detects when touch support exists and is expected',
expectation: TouchSupportExpectation.MustExist,
isTouchSupportInEnvironment: true,
detects: true,
},
{
description: 'does not detect when touch support does not exists but is expected',
expectation: TouchSupportExpectation.MustExist,
isTouchSupportInEnvironment: false,
detects: false,
},
{
description: 'detects when touch support does not exist and is not expected',
expectation: TouchSupportExpectation.MustNotExist,
isTouchSupportInEnvironment: false,
detects: true,
},
{
description: 'does not detect when touch support exists but is not expected',
expectation: TouchSupportExpectation.MustNotExist,
isTouchSupportInEnvironment: true,
detects: false,
},
];
testScenarios.forEach(({
description, expectation, isTouchSupportInEnvironment, detects,
}) => {
it(description, () => {
// arrange
const userAgent = 'iPhone';
const environment = new BrowserEnvironmentStub()
.withUserAgent(userAgent)
.withIsTouchSupported(isTouchSupportInEnvironment);
const conditionWithTouchSupport = new BrowserConditionStub()
.withExistingPartsInSameUserAgent([userAgent])
.withTouchSupport(expectation);
const detector = new ConditionBasedOsDetectorBuilder()
.withConditions([conditionWithTouchSupport])
.build();
// act
const actualOperatingSystem = detector.detect(environment);
// assert
expect(actualOperatingSystem !== undefined)
.to.equal(detects);
});
});
});
});
});
class ConditionBasedOsDetectorBuilder {
private conditions: readonly BrowserCondition[] = [{
operatingSystem: OperatingSystem.iOS,
existingPartsInSameUserAgent: ['iPhone'],
}];
public withConditions(conditions: readonly BrowserCondition[]): this {
this.conditions = conditions;
return this;
}
public build(): ConditionBasedOsDetector {
return new ConditionBasedOsDetector(
this.conditions,
);
}
}

View File

@@ -1,11 +1,13 @@
// eslint-disable-next-line max-classes-per-file
import { describe, it, expect } from 'vitest';
import { IBrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/IBrowserOsDetector';
import { BrowserOsDetector } from '@/infrastructure/RuntimeEnvironment/BrowserOs/BrowserOsDetector';
import { OperatingSystem } from '@/domain/OperatingSystem';
import { RuntimeEnvironment } from '@/infrastructure/RuntimeEnvironment/RuntimeEnvironment';
import { itEachAbsentObjectValue } from '@tests/unit/shared/TestCases/AbsentTests';
import { BrowserOsDetectorStub } from '@tests/unit/shared/Stubs/BrowserOsDetectorStub';
import { IEnvironmentVariables } from '@/infrastructure/EnvironmentVariables/IEnvironmentVariables';
import { EnvironmentVariablesStub } from '@tests/unit/shared/Stubs/EnvironmentVariablesStub';
import { expectExists } from '@tests/shared/Assertions/ExpectExists';
describe('RuntimeEnvironment', () => {
describe('ctor', () => {
@@ -22,6 +24,22 @@ describe('RuntimeEnvironment', () => {
expect(act).to.throw(expectedError);
});
});
it('uses browser OS detector with current touch support', () => {
// arrange
const expectedTouchSupport = true;
const osDetector = new BrowserOsDetectorStub();
// act
createEnvironment({
window: { os: undefined, navigator: { userAgent: 'Forcing touch detection' } } as Partial<Window>,
isTouchSupported: expectedTouchSupport,
browserOsDetector: osDetector,
});
// assert
const actualCall = osDetector.callHistory.find((c) => c.methodName === 'detect');
expectExists(actualCall);
const [{ isTouchSupported: actualTouchSupport }] = actualCall.args;
expect(actualTouchSupport).to.equal(expectedTouchSupport);
});
});
describe('isDesktop', () => {
it('returns true when window property isDesktop is true', () => {
@@ -54,7 +72,7 @@ describe('RuntimeEnvironment', () => {
it('returns undefined if user agent is missing', () => {
// arrange
const expected = undefined;
const browserDetectorMock: IBrowserOsDetector = {
const browserDetectorMock: BrowserOsDetector = {
detect: () => {
throw new Error('should not reach here');
},
@@ -76,9 +94,9 @@ describe('RuntimeEnvironment', () => {
userAgent: givenUserAgent,
},
};
const browserDetectorMock: IBrowserOsDetector = {
detect: (agent) => {
if (agent !== givenUserAgent) {
const browserDetectorMock: BrowserOsDetector = {
detect: (environment) => {
if (environment.userAgent !== givenUserAgent) {
throw new Error('Unexpected user agent');
}
return expected;
@@ -155,23 +173,31 @@ describe('RuntimeEnvironment', () => {
});
interface EnvironmentOptions {
window: Partial<Window>;
browserOsDetector?: IBrowserOsDetector;
environmentVariables?: IEnvironmentVariables;
readonly window?: Partial<Window>;
readonly browserOsDetector?: BrowserOsDetector;
readonly environmentVariables?: IEnvironmentVariables;
readonly isTouchSupported?: boolean;
}
function createEnvironment(options: Partial<EnvironmentOptions> = {}): TestableRuntimeEnvironment {
const defaultOptions: EnvironmentOptions = {
const defaultOptions: Required<EnvironmentOptions> = {
window: {},
browserOsDetector: new BrowserOsDetectorStub(),
environmentVariables: new EnvironmentVariablesStub(),
isTouchSupported: false,
};
return new TestableRuntimeEnvironment({ ...defaultOptions, ...options });
}
class TestableRuntimeEnvironment extends RuntimeEnvironment {
public constructor(options: EnvironmentOptions) {
super(options.window, options.environmentVariables, options.browserOsDetector);
/* Using a separate object instead of `ConstructorParameter<..>` */
public constructor(options: Required<EnvironmentOptions>) {
super(
options.window,
options.environmentVariables,
options.browserOsDetector,
() => options.isTouchSupported,
);
}
}

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