Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e95b2ba217 | ||
|
|
20633972e9 | ||
|
|
3457fe18cf | ||
|
|
fe3de498c8 | ||
|
|
15134ea04b | ||
|
|
a9851272ae | ||
|
|
916c9d62d9 | ||
|
|
47b4823bc5 | ||
|
|
c72f9f5016 | ||
|
|
e747ee5cbc | ||
|
|
ba5b29a35d | ||
|
|
daa6230fc9 | ||
|
|
4765752ee3 | ||
|
|
25e23c89c3 | ||
|
|
08dbfead7c | ||
|
|
8f5d7ed3cf | ||
|
|
807ae6a8f8 | ||
|
|
5a7d7d88ff | ||
|
|
40ae8a8add | ||
|
|
6488e81901 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -11,3 +11,6 @@ node_modules
|
||||
# draw.io
|
||||
*.bkp
|
||||
*.dtmp
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
25
CHANGELOG.md
25
CHANGELOG.md
@@ -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)
|
||||
|
||||
10
README.md
10
README.md
@@ -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.
|
||||
|
||||
[](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).
|
||||
|
||||
44
SECURITY.md
44
SECURITY.md
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
54
docs/desktop-vs-web-features.md
Normal file
54
docs/desktop-vs-web-features.md
Normal 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
21
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
6
src/application/Common/Log/Logger.ts
Normal file
6
src/application/Common/Log/Logger.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export interface Logger {
|
||||
info(...params: unknown[]): void;
|
||||
warn(...params: unknown[]): void;
|
||||
error(...params: unknown[]): void;
|
||||
debug(...params: unknown[]): void;
|
||||
}
|
||||
5
src/application/Common/Log/LoggerFactory.ts
Normal file
5
src/application/Common/Log/LoggerFactory.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Logger } from '@/application/Common/Log/Logger';
|
||||
|
||||
export interface LoggerFactory {
|
||||
readonly logger: Logger;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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'];
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1,3 +0,0 @@
|
||||
export interface ILogger {
|
||||
info (...params: unknown[]): void;
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ILogger } from './ILogger';
|
||||
|
||||
export interface ILoggerFactory {
|
||||
readonly logger: ILogger;
|
||||
}
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
import { OperatingSystem } from '@/domain/OperatingSystem';
|
||||
|
||||
export interface IBrowserOsDetector {
|
||||
detect(userAgent: string): OperatingSystem | undefined;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
8
src/presentation/components/Shared/Hooks/UseLogger.ts
Normal file
8
src/presentation/components/Shared/Hooks/UseLogger.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
15
src/presentation/components/Shared/OperatingSystemNames.ts
Normal file
15
src/presentation/components/Shared/OperatingSystemNames.ts
Normal 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)',
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
122
src/presentation/electron/main/Update/ManualUpdater/Dialogs.ts
Normal file
122
src/presentation/electron/main/Update/ManualUpdater/Dialogs.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
46
src/presentation/electron/main/Update/UpdateInitializer.ts
Normal file
46
src/presentation/electron/main/Update/UpdateInitializer.ts
Normal 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();
|
||||
}
|
||||
@@ -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}% ...`;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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]?.();
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}`,
|
||||
]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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'));
|
||||
]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
72
tests/e2e/operating-system-selector.cy.ts
Normal file
72
tests/e2e/operating-system-selector.cy.ts
Normal 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;
|
||||
@@ -13,6 +13,6 @@
|
||||
"sourceMap": false,
|
||||
},
|
||||
"include": [
|
||||
"**/*.ts"
|
||||
"**/*.ts",
|
||||
]
|
||||
}
|
||||
@@ -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,
|
||||
})));
|
||||
}
|
||||
@@ -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.'}"`,
|
||||
]));
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}));
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
37
tests/integration/shared/TestCases/TouchSupportOptions.ts
Normal file
37
tests/integration/shared/TestCases/TouchSupportOptions.ts
Normal 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]}`);
|
||||
}
|
||||
}
|
||||
7
tests/shared/FormatAssertionMessage.ts
Normal file
7
tests/shared/FormatAssertionMessage.ts
Normal 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');
|
||||
}
|
||||
67
tests/shared/Spies/WindowEventSpies.ts
Normal file
67
tests/shared/Spies/WindowEventSpies.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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'));
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}`,
|
||||
]));
|
||||
}
|
||||
|
||||
@@ -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(', '),
|
||||
]));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}"`;
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
];
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user