├── .devcontainer └── devcontainer.json ├── .editorconfig ├── .github ├── CODEOWNERS ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── config.yml │ └── feature-request.yml ├── dependabot.yml ├── release.yml └── workflows │ ├── deploy-website-cf-challenge-test.yml │ ├── deploy-website-index.yml │ ├── deploy-website-sandbox.yml │ ├── release.yml │ └── tests.yml ├── .gitignore ├── LICENSE ├── Makefile ├── PRIVACY_POLICY.md ├── README.md ├── eslint.config.js ├── manifest.json ├── package-lock.json ├── package.json ├── prettier.config.js ├── src ├── entrypoints │ ├── background │ │ ├── api │ │ │ ├── filters.ts │ │ │ ├── index.ts │ │ │ ├── renew-user-agent.ts │ │ │ └── update-remote-useragent-list.ts │ │ ├── hooks │ │ │ ├── http-requests.ts │ │ │ ├── index.ts │ │ │ └── scripting.ts │ │ ├── hotkeys │ │ │ ├── hotkeys.ts │ │ │ └── index.ts │ │ ├── index.ts │ │ ├── persistent │ │ │ ├── current-user-agent.ts │ │ │ ├── index.ts │ │ │ ├── latest-browser-versions.ts │ │ │ ├── remote-useragent-list.ts │ │ │ ├── settings.test.ts │ │ │ ├── settings.ts │ │ │ ├── storage-area.test.ts │ │ │ └── storage-area.ts │ │ ├── timer │ │ │ ├── index.ts │ │ │ └── timer.ts │ │ └── ui │ │ │ ├── extension-icon.ts │ │ │ └── index.ts │ ├── content │ │ ├── content.ts │ │ └── inject.ts │ ├── onboard │ │ ├── components │ │ │ ├── button │ │ │ │ ├── component.module.css │ │ │ │ ├── component.tsx │ │ │ │ └── index.ts │ │ │ └── container │ │ │ │ ├── component.module.css │ │ │ │ ├── component.tsx │ │ │ │ └── index.ts │ │ ├── index.css │ │ ├── index.html │ │ └── index.tsx │ ├── options │ │ ├── index.css │ │ ├── index.html │ │ ├── index.tsx │ │ ├── pages │ │ │ ├── blacklist │ │ │ │ ├── index.ts │ │ │ │ └── page.tsx │ │ │ ├── general │ │ │ │ ├── index.ts │ │ │ │ └── page.tsx │ │ │ ├── generator │ │ │ │ ├── index.ts │ │ │ │ ├── page.module.css │ │ │ │ └── page.tsx │ │ │ ├── layout.module.css │ │ │ └── layout.tsx │ │ ├── routes │ │ │ ├── index.ts │ │ │ └── routes.tsx │ │ └── shared │ │ │ ├── components │ │ │ ├── button │ │ │ │ ├── component.module.css │ │ │ │ ├── component.tsx │ │ │ │ └── index.ts │ │ │ ├── grid │ │ │ │ ├── component.module.css │ │ │ │ ├── component.tsx │ │ │ │ └── index.ts │ │ │ ├── index.ts │ │ │ ├── input │ │ │ │ ├── component.module.css │ │ │ │ ├── component.tsx │ │ │ │ └── index.ts │ │ │ ├── selector │ │ │ │ ├── component.module.css │ │ │ │ ├── component.tsx │ │ │ │ └── index.ts │ │ │ └── switch │ │ │ │ ├── component.module.css │ │ │ │ ├── component.tsx │ │ │ │ └── index.ts │ │ │ ├── debug.ts │ │ │ ├── errors.ts │ │ │ ├── hooks │ │ │ ├── error-boundary.tsx │ │ │ ├── index.ts │ │ │ ├── notification-provider │ │ │ │ ├── provider.module.css │ │ │ │ └── provider.tsx │ │ │ ├── use-renew-user-agent.ts │ │ │ ├── use-save-settings.ts │ │ │ └── use-title.ts │ │ │ └── index.ts │ └── popup │ │ ├── components │ │ ├── actions │ │ │ ├── component.module.css │ │ │ ├── component.tsx │ │ │ └── index.ts │ │ ├── active-user-agent │ │ │ ├── component.module.css │ │ │ ├── component.tsx │ │ │ └── index.ts │ │ ├── enabled-on-domain │ │ │ ├── component.module.css │ │ │ ├── component.tsx │ │ │ └── index.ts │ │ ├── feedback │ │ │ ├── component.module.css │ │ │ ├── component.tsx │ │ │ └── index.ts │ │ ├── footer │ │ │ ├── component.module.css │ │ │ ├── component.tsx │ │ │ └── index.ts │ │ ├── header │ │ │ ├── component.module.css │ │ │ ├── component.tsx │ │ │ └── index.ts │ │ ├── index.ts │ │ └── quick-select │ │ │ ├── component.module.css │ │ │ ├── component.tsx │ │ │ └── index.ts │ │ ├── index.css │ │ ├── index.html │ │ ├── index.tsx │ │ └── shared │ │ └── components │ │ └── checkbox │ │ ├── component.module.css │ │ ├── component.tsx │ │ └── index.ts ├── i18n │ ├── i18n.test.ts │ ├── i18n.ts │ ├── index.ts │ ├── locales.ts │ ├── readme.md │ └── types.d.ts ├── shared │ ├── assets │ │ ├── icons │ │ │ ├── browsers │ │ │ │ ├── chrome.svg │ │ │ │ ├── edge.svg │ │ │ │ ├── firefox.svg │ │ │ │ ├── opera.svg │ │ │ │ └── safari.svg │ │ │ ├── controls │ │ │ │ ├── pause.svg │ │ │ │ ├── refresh.svg │ │ │ │ ├── settings.svg │ │ │ │ └── unpause.svg │ │ │ ├── index.ts │ │ │ └── os │ │ │ │ ├── android.svg │ │ │ │ ├── ios.svg │ │ │ │ ├── linux.svg │ │ │ │ ├── macos.svg │ │ │ │ └── windows.svg │ │ ├── index.ts │ │ ├── logo-inactive.svg │ │ └── logo.svg │ ├── client-hint │ │ ├── client-hints.test.ts │ │ ├── client-hints.ts │ │ └── index.ts │ ├── components │ │ └── icon │ │ │ ├── icon.tsx │ │ │ └── index.ts │ ├── detect-browser.ts │ ├── domains.test.ts │ ├── domains.ts │ ├── freeze.ts │ ├── generator-type-helpers.test.ts │ ├── generator-type-helpers.ts │ ├── index.ts │ ├── messaging │ │ ├── index.ts │ │ ├── runtime.test.ts │ │ └── runtime.ts │ ├── permissions.ts │ ├── types │ │ ├── content-script-payload.d.ts │ │ ├── index.ts │ │ ├── settings.d.ts │ │ └── user-agent-state.d.ts │ └── user-agent │ │ ├── browser-versions.test.ts │ │ ├── browser-versions.ts │ │ ├── generator.test.ts │ │ ├── generator.ts │ │ └── index.ts ├── theme │ └── theme.css └── types │ ├── generics.d.ts │ ├── globals.d.ts │ └── index.ts ├── static └── assets │ └── icons │ ├── 128-gray.png │ ├── 128.png │ ├── 16-gray.png │ ├── 16.png │ ├── 32-gray.png │ ├── 32.png │ ├── 48-gray.png │ └── 48.png ├── tsconfig.json ├── vite.config.ts └── website ├── cf-challenge-test ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── index.html ├── robots.txt └── safari-pinned-tab.svg ├── index ├── apple-touch-icon.png ├── assets │ ├── browsers │ │ ├── chrome.svg │ │ ├── edge.svg │ │ ├── firefox.svg │ │ ├── opera.svg │ │ └── safari.svg │ ├── logo.svg │ └── og-card.png ├── browserconfig.xml ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── index.html ├── mstile-150x150.png ├── robots.txt └── site.webmanifest └── sandbox ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── index.html └── robots.txt /.devcontainer/devcontainer.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/devcontainers/spec/main/schemas/devContainer.base.schema.json", 3 | "name": "RUA dev-container", 4 | "image": "node:22-bookworm", 5 | "features": { 6 | "ghcr.io/devcontainers/features/github-cli:1": {}, 7 | "ghcr.io/devcontainers/features/sshd:1": {} 8 | }, 9 | "customizations": { 10 | "vscode": { 11 | "extensions": [ 12 | "streetsidesoftware.code-spell-checker" 13 | ] 14 | } 15 | }, 16 | "postCreateCommand": "npm install" 17 | } 18 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig docs: 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | insert_final_newline = true 9 | indent_style = space 10 | indent_size = 2 11 | trim_trailing_whitespace = true 12 | 13 | [{*.yml,*.yaml}] 14 | ij_any_spaces_within_braces = false 15 | ij_any_spaces_within_brackets = false 16 | 17 | [Makefile] 18 | indent_style = tab 19 | -------------------------------------------------------------------------------- /.github/CODEOWNERS: -------------------------------------------------------------------------------- 1 | # docs: https://help.github.com/en/articles/about-code-owners 2 | 3 | * @tarampampam 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json 2 | # docs: https://git.io/JR5E4 3 | 4 | name: 🐞 Bug report 5 | description: File a bug/issue 6 | labels: ['type:bug'] 7 | assignees: [tarampampam] 8 | body: 9 | - type: checkboxes 10 | attributes: 11 | label: Is there an existing issue for this? 12 | description: Please search to see if an issue already exists for the bug you encountered 13 | options: 14 | - label: I have searched the existing issues 15 | required: true 16 | 17 | - type: textarea 18 | attributes: 19 | label: Describe the bug 20 | description: A clear and concise description of what the bug is 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | attributes: 26 | label: Steps to reproduce 27 | description: Steps to reproduce the behavior 28 | placeholder: | 29 | 1. Go to ... 30 | 2. Click on ... 31 | 3. See error 32 | 33 | - type: dropdown 34 | id: browser 35 | attributes: 36 | label: Browser 37 | multiple: true 38 | options: 39 | - Firefox 40 | - Chrome 41 | - Opera 42 | - Microsoft Edge 43 | - Other 44 | validations: 45 | required: true 46 | 47 | - type: input 48 | id: version 49 | attributes: 50 | label: Extension version 51 | placeholder: 4.x.x 52 | 53 | - type: textarea 54 | attributes: 55 | label: Anything else? 56 | description: Links? References? Anything that will give us more context about the issue you are encountering! 57 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-config.json 2 | # docs: https://git.io/JP3tm 3 | 4 | blank_issues_enabled: false 5 | 6 | contact_links: 7 | - name: 🗣 Ask a Question, Discuss 8 | url: https://github.com/tarampampam/random-user-agent/discussions 9 | about: Feel free to ask anything 10 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-issue-forms.json 2 | # docs: https://git.io/JR5E4 3 | 4 | name: 💡 Feature request 5 | description: Suggest an idea for this browser extension 6 | labels: ['type:feature_request'] 7 | assignees: [tarampampam] 8 | body: 9 | - type: checkboxes 10 | attributes: 11 | label: Is there an existing issue for this? 12 | description: Please search to see if an issue already exists for the bug you encountered 13 | options: 14 | - label: I have searched the existing issues 15 | required: true 16 | 17 | - type: textarea 18 | attributes: 19 | label: Describe the problem to be solved 20 | description: Please present a concise description of the problem to be addressed by this feature request 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | attributes: 26 | label: Suggest a solution 27 | description: A concise description of your preferred solution 28 | placeholder: If there are multiple solutions, please present each one separately 29 | 30 | - type: textarea 31 | attributes: 32 | label: Additional context 33 | description: Add any other context about the feature request 34 | placeholder: You can attach images or log files by clicking this area to highlight it and then dragging files in 35 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/dependabot-2.0.json 2 | # docs: https://docs.github.com/en/free-pro-team@latest/github/administering-a-repository/customizing-dependency-updates 3 | 4 | version: 2 5 | 6 | updates: 7 | - package-ecosystem: npm 8 | directory: / 9 | open-pull-requests-limit: 15 10 | groups: 11 | npm-production: {dependency-type: production, update-types: [minor, patch]} 12 | npm-development: {dependency-type: development, update-types: [minor, patch]} 13 | schedule: {interval: monthly} 14 | 15 | - package-ecosystem: github-actions 16 | directory: / 17 | groups: 18 | github-actions: {patterns: ['*']} 19 | schedule: {interval: monthly} 20 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-release-config.json 2 | # docs: https://docs.github.com/en/repositories/releasing-projects-on-github/automatically-generated-release-notes 3 | 4 | changelog: 5 | categories: 6 | - title: 🛠 Fixes 7 | labels: [type:fix, type:bug] 8 | - title: 🚀 Features 9 | labels: [type:feature, type:feature_request] 10 | - title: 📦 Dependency updates 11 | labels: [dependencies] 12 | - title: Other Changes 13 | labels: ['*'] 14 | -------------------------------------------------------------------------------- /.github/workflows/deploy-website-cf-challenge-test.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | # docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions 3 | 4 | name: 🚀 Deploy the CF challenge test website 5 | 6 | on: 7 | workflow_dispatch: {} 8 | push: 9 | branches: [master, main] 10 | tags-ignore: ['**'] 11 | paths: [website/cf-challenge-test/**, .github/workflows/deploy-website-cf-challenge-test.yml] 12 | 13 | concurrency: 14 | group: ${{ github.ref }}-cf-challenge-test-website 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | publish: 19 | name: Publish the site 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: cloudflare/wrangler-action@v3 25 | env: 26 | PROJECT_NAME: random-user-agent-cf-challenge-test 27 | DIST_DIR: ./website/cf-challenge-test 28 | CF_BRANCH_NAME: main # to deploy as "Production" environment on Cloudflare Pages 29 | with: 30 | apiToken: ${{ secrets.CLOUDFLARE_PAGES_DEPLOY_TOKEN }} 31 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 32 | command: 33 | pages deploy ${{ env.DIST_DIR }} 34 | --project-name=${{ env.PROJECT_NAME }} 35 | --branch ${{ env.CF_BRANCH_NAME }} 36 | --commit-dirty=true 37 | -------------------------------------------------------------------------------- /.github/workflows/deploy-website-index.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | # docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions 3 | 4 | name: 🚀 Deploy the website index 5 | 6 | on: 7 | workflow_dispatch: {} 8 | push: 9 | branches: [master, main] 10 | tags-ignore: ['**'] 11 | paths: [website/index/**, .github/workflows/deploy-website-index.yml] 12 | 13 | concurrency: 14 | group: ${{ github.ref }}-website-index 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | publish: 19 | name: Publish the site 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: cloudflare/wrangler-action@v3 25 | env: 26 | PROJECT_NAME: random-user-agent-index 27 | DIST_DIR: ./website/index 28 | CF_BRANCH_NAME: main # to deploy as "Production" environment on Cloudflare Pages 29 | with: 30 | apiToken: ${{ secrets.CLOUDFLARE_PAGES_DEPLOY_TOKEN }} 31 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 32 | command: 33 | pages deploy ${{ env.DIST_DIR }} 34 | --project-name=${{ env.PROJECT_NAME }} 35 | --branch ${{ env.CF_BRANCH_NAME }} 36 | --commit-dirty=true 37 | -------------------------------------------------------------------------------- /.github/workflows/deploy-website-sandbox.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | # docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions 3 | 4 | name: 🚀 Deploy the sandbox website 5 | 6 | on: 7 | workflow_dispatch: {} 8 | push: 9 | branches: [master, main] 10 | tags-ignore: ['**'] 11 | paths: [website/sandbox/**, .github/workflows/deploy-website-sandbox.yml] 12 | 13 | concurrency: 14 | group: ${{ github.ref }}-sandbox-website 15 | cancel-in-progress: true 16 | 17 | jobs: 18 | publish: 19 | name: Publish the site 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | 24 | - uses: cloudflare/wrangler-action@v3 25 | env: 26 | PROJECT_NAME: random-user-agent-ua-test-sandbox 27 | DIST_DIR: ./website/sandbox 28 | CF_BRANCH_NAME: main # to deploy as "Production" environment on Cloudflare Pages 29 | with: 30 | apiToken: ${{ secrets.CLOUDFLARE_PAGES_DEPLOY_TOKEN }} 31 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 32 | command: 33 | pages deploy ${{ env.DIST_DIR }} 34 | --project-name=${{ env.PROJECT_NAME }} 35 | --branch ${{ env.CF_BRANCH_NAME }} 36 | --commit-dirty=true 37 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | # docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions 3 | 4 | name: 🚀 Release 5 | 6 | on: 7 | release: # Docs: 8 | types: [published] 9 | 10 | jobs: 11 | build: 12 | name: Build the extension 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - {uses: gacts/github-slug@v1, id: slug} 17 | - {uses: actions/setup-node@v4, with: {node-version-file: ./package.json, cache: 'npm'}} 18 | - run: npm install 19 | - run: > 20 | npm version --no-git-tag-version 21 | "${{ steps.slug.outputs.version-major }}.${{ steps.slug.outputs.version-minor }}.${{ steps.slug.outputs.version-patch }}" 22 | - run: npm run build 23 | - uses: actions/upload-artifact@v4 24 | with: {name: chrome.zip, path: ./dist/chrome.zip, if-no-files-found: error, retention-days: 7} 25 | - uses: actions/upload-artifact@v4 26 | with: {name: firefox.zip, path: ./dist/firefox.zip, if-no-files-found: error, retention-days: 7} 27 | 28 | upload-to-release: 29 | name: Upload to release 30 | runs-on: ubuntu-latest 31 | needs: [build] 32 | steps: 33 | - {uses: actions/download-artifact@v4, with: {name: chrome.zip, path: dist}} 34 | - uses: svenstaro/upload-release-action@v2 35 | with: 36 | repo_token: ${{ secrets.GITHUB_TOKEN }} 37 | file: ./dist/chrome.zip 38 | asset_name: random-user-agent-chrome.zip 39 | tag: ${{ github.ref }} 40 | - {uses: actions/download-artifact@v4, with: {name: firefox.zip, path: dist}} 41 | - uses: svenstaro/upload-release-action@v2 42 | with: 43 | repo_token: ${{ secrets.GITHUB_TOKEN }} 44 | file: ./dist/firefox.zip 45 | asset_name: random-user-agent-firefox.zip 46 | tag: ${{ github.ref }} 47 | 48 | chrome: 49 | name: Publish on Chrome Web Store 50 | runs-on: ubuntu-latest 51 | needs: [build] 52 | steps: 53 | - {uses: actions/download-artifact@v4, with: {name: chrome.zip, path: dist}} 54 | - uses: mnao305/chrome-extension-upload@v5.0.0 55 | with: 56 | file-path: dist/chrome.zip 57 | extension-id: einpaelgookohagofgnnkcfjbkkgepnp 58 | client-id: ${{ secrets.CHROME_WEBSTORE_CLIENT_ID }} 59 | client-secret: ${{ secrets.CHROME_WEBSTORE_CLIENT_SECRET }} 60 | refresh-token: ${{ secrets.CHROME_WEBSTORE_REFRESH_TOKEN }} 61 | publish: false 62 | 63 | mozilla: 64 | name: Publish on Mozilla Add-ons 65 | runs-on: ubuntu-latest 66 | needs: [build] 67 | steps: 68 | - {uses: actions/download-artifact@v4, with: {name: firefox.zip, path: dist}} 69 | - uses: wdzeng/firefox-addon@v1 70 | with: 71 | addon-guid: '{b43b974b-1d3a-4232-b226-eaa2ac6ebb69}' 72 | xpi-path: dist/firefox.zip 73 | jwt-issuer: ${{ secrets.MOZILLA_ADDONS_JWT_ISSUER }} 74 | jwt-secret: ${{ secrets.MOZILLA_ADDONS_JWT_SECRET }} 75 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json 2 | # docs: https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions 3 | 4 | name: 🧪 Tests 5 | 6 | on: 7 | workflow_dispatch: {} 8 | push: 9 | branches: [master, main] 10 | tags-ignore: ['**'] 11 | paths-ignore: ['**.md', 'website/**', 'static/**'] 12 | pull_request: 13 | paths-ignore: ['**.md', 'website/**', 'static/**'] 14 | 15 | concurrency: 16 | group: ${{ github.ref }}-tests 17 | cancel-in-progress: true 18 | 19 | env: {FORCE_COLOR: 'true'} 20 | 21 | jobs: 22 | gitleaks: 23 | name: Check for GitLeaks 24 | runs-on: ubuntu-latest 25 | steps: 26 | - {uses: actions/checkout@v4, with: {fetch-depth: 0}} 27 | - uses: gacts/gitleaks@v1 28 | 29 | unit-tests: 30 | name: Run unit tests 31 | runs-on: ubuntu-latest 32 | steps: 33 | - uses: actions/checkout@v4 34 | - {uses: actions/setup-node@v4, with: {node-version-file: ./package.json, cache: 'npm'}} 35 | - run: npm install 36 | - run: npm run test 37 | 38 | eslint: 39 | name: Run code linter 40 | runs-on: ubuntu-latest 41 | steps: 42 | - uses: actions/checkout@v4 43 | - {uses: actions/setup-node@v4, with: {node-version-file: ./package.json, cache: 'npm'}} 44 | - run: npm install 45 | - run: npm run lint 46 | 47 | build: 48 | name: Build extension 49 | runs-on: ubuntu-latest 50 | steps: 51 | - uses: actions/checkout@v4 52 | - {uses: actions/setup-node@v4, with: {node-version-file: ./package.json, cache: 'npm'}} 53 | - run: npm install 54 | - run: npm run build 55 | - uses: actions/upload-artifact@v4 56 | with: {name: chrome.zip, path: ./dist/chrome.zip, if-no-files-found: error, retention-days: 30} 57 | - uses: actions/upload-artifact@v4 58 | with: {name: firefox.zip, path: ./dist/firefox.zip, if-no-files-found: error, retention-days: 30} 59 | 60 | addons-linter: 61 | name: Run addon linter (mozilla) 62 | runs-on: ubuntu-latest 63 | needs: [build] 64 | steps: 65 | - {uses: actions/download-artifact@v4, with: {name: firefox.zip, path: dist}} 66 | - {uses: actions/setup-node@v4, with: {node-version: 21}} 67 | - run: npm install -g addons-linter 68 | - run: addons-linter ./dist/firefox.zip 69 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## IDEs 2 | /.vscode 3 | /.idea 4 | 5 | ## dependencies 6 | /node_modules 7 | 8 | ## build 9 | /*.zip 10 | /crx 11 | /dist* 12 | 13 | ## etc 14 | /coverage 15 | /temp 16 | /tmp 17 | .vite 18 | *.log 19 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | #!/usr/bin/make 2 | # Makefile readme: 3 | 4 | .DEFAULT_GOAL := build 5 | .MAIN := build 6 | 7 | NODE_IMAGE = node:22-alpine 8 | RUN_ARGS = --rm -v "$(shell pwd):/src:rw" \ 9 | -t --workdir "/src" \ 10 | -u "$(shell id -u):$(shell id -g)" \ 11 | -e "NPM_CONFIG_UPDATE_NOTIFIER=false" \ 12 | -e PATH="$$PATH:/src/node_modules/.bin" $(NODE_IMAGE) 13 | 14 | .PHONY: install 15 | install: ## Install all dependencies 16 | docker run $(RUN_ARGS) npm install 17 | 18 | .PHONY: shell 19 | shell: ## Start shell into a container with node 20 | docker run -e "PS1=\[\033[1;34m\]\w\[\033[0;35m\] \[\033[1;36m\]# \[\033[0m\]" -i $(RUN_ARGS) sh 21 | 22 | .PHONY: build 23 | build: install ## Build the extension and pack it into a zip file 24 | docker run $(RUN_ARGS) npm run build 25 | 26 | .PHONY: fmt 27 | fmt: ## Run prettier 28 | docker run $(RUN_ARGS) npm run fmt 29 | 30 | .PHONY: test 31 | test: ## Run lint and tests 32 | docker run $(RUN_ARGS) npm run lint 33 | docker run $(RUN_ARGS) npm run test 34 | 35 | .PHONY: watch 36 | watch: ## Start watch mode 37 | docker run $(RUN_ARGS) npm run watch 38 | -------------------------------------------------------------------------------- /PRIVACY_POLICY.md: -------------------------------------------------------------------------------- 1 | # Privacy Policy 2 | 3 | Last updated: April 18, 2024 4 | 5 | ## Personal data 6 | 7 | Random User-Agent respects your privacy and is committed to protecting any information you share with us. We do not 8 | collect any personal data or browsing history. 9 | 10 | In the future, Random User-Agent may collect anonymized user information, such as the version of the extension 11 | installed, whether the extension is enabled, and similar data. This information is used solely to understand how 12 | users interact with the extension and to improve its features. Rest assured that this data cannot be used to 13 | identify individual users or track them in any way. 14 | 15 | Any collection of data will occur only with your explicit consent. 16 | 17 | ## Third party services 18 | 19 | Random User-Agent utilizes Chrome (Chromium) or WebExtensions Storage Sync API for storing user settings and the 20 | browser's Web Storage API (localStorage) for storing Developer Tools fixes. 21 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import { fixupConfigRules, fixupPluginRules } from '@eslint/compat' 2 | import typescriptEslint from '@typescript-eslint/eslint-plugin' 3 | import reactRefresh from 'eslint-plugin-react-refresh' 4 | import react from 'eslint-plugin-react' 5 | import globals from 'globals' 6 | import tsParser from '@typescript-eslint/parser' 7 | import path from 'node:path' 8 | import { fileURLToPath } from 'node:url' 9 | import js from '@eslint/js' 10 | import { FlatCompat } from '@eslint/eslintrc' 11 | 12 | const __filename = fileURLToPath(import.meta.url) 13 | const __dirname = path.dirname(__filename) 14 | const compat = new FlatCompat({ 15 | baseDirectory: __dirname, 16 | recommendedConfig: js.configs.recommended, 17 | allConfig: js.configs.all, 18 | }) 19 | 20 | export default [ 21 | { 22 | ignores: ['dist/*'], 23 | }, 24 | ...fixupConfigRules( 25 | compat.extends( 26 | 'eslint:recommended', 27 | 'plugin:@typescript-eslint/recommended', 28 | 'plugin:react/recommended', 29 | 'plugin:react-hooks/recommended' 30 | ) 31 | ), 32 | { 33 | plugins: { 34 | '@typescript-eslint': fixupPluginRules(typescriptEslint), 35 | 'react-refresh': reactRefresh, 36 | react: fixupPluginRules(react), 37 | }, 38 | languageOptions: { 39 | globals: { 40 | ...globals.browser, 41 | }, 42 | parser: tsParser, 43 | ecmaVersion: 2022, 44 | sourceType: 'module', 45 | parserOptions: { 46 | project: true, 47 | }, 48 | }, 49 | settings: { 50 | react: { 51 | version: 'detect', 52 | }, 53 | }, 54 | rules: { 55 | 'react/react-in-jsx-scope': 'off', 56 | 'no-case-declarations': 'off', 57 | }, 58 | }, 59 | ] 60 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/chrome-manifest.json", 3 | "$docs": "https://developer.chrome.com/docs/extensions/reference/manifest", 4 | "manifest_version": 3, 5 | "version": "0.0.0", 6 | "name": "__MSG_manifest_name__", 7 | "description": "__MSG_manifest_description__", 8 | "author": "https://github.com/tarampampam", 9 | "homepage_url": "https://random-user-agent.com/", 10 | "default_locale": "en", 11 | "icons": { 12 | "16": "assets/icons/16.png", 13 | "32": "assets/icons/32.png", 14 | "48": "assets/icons/48.png", 15 | "128": "assets/icons/128.png" 16 | }, 17 | "options_ui": { 18 | "page": "options/index.html", 19 | "open_in_tab": true 20 | }, 21 | "background": { 22 | "service_worker": "background.js", 23 | "type": "module" 24 | }, 25 | "action": { 26 | "default_popup": "popup/index.html", 27 | "default_icon": { 28 | "16": "assets/icons/16.png", 29 | "32": "assets/icons/32.png", 30 | "48": "assets/icons/48.png", 31 | "128": "assets/icons/128.png" 32 | }, 33 | "default_title": "__MSG_manifest_action_default_title__" 34 | }, 35 | "commands": { 36 | "renew-useragent": { 37 | "description": "__MSG_manifest_command_renew_useragent__", 38 | "suggested_key": { 39 | "default": "Ctrl+Shift+U" 40 | } 41 | } 42 | }, 43 | "host_permissions": [ 44 | "" 45 | ], 46 | "permissions": [ 47 | "tabs", 48 | "alarms", 49 | "storage", 50 | "scripting", 51 | "declarativeNetRequest" 52 | ], 53 | "$what_the_specified_permissions_are_for": { 54 | "tabs": "Obtaining the current tab URL to verify if the extension is enabled for the specific URL in the popup", 55 | "alarms": "Scheduling user-agent renewal and other periodic tasks", 56 | "storage": "Managing and synchronizing user settings across browser sessions", 57 | "scripting": "Injecting user-agent modification code into web pages", 58 | "declarativeNetRequest": "Modifying HTTP headers" 59 | }, 60 | "incognito": "spanning", 61 | "minimum_chrome_version": "120" 62 | } 63 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "random-user-agent", 3 | "version": "0.0.0", 4 | "type": "module", 5 | "author": { 6 | "name": "paramtamtam", 7 | "url": "https://github.com/tarampampam" 8 | }, 9 | "license": "MIT", 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/tarampampam/random-user-agent.git" 13 | }, 14 | "bugs": { 15 | "url": "https://github.com/tarampampam/random-user-agent/issues" 16 | }, 17 | "homepage": "https://github.com/tarampampam/random-user-agent#readme", 18 | "scripts": { 19 | "fmt": "prettier --write ./src ./*.ts && npm run lint:es -- --fix", 20 | "lint": "npm run lint:ts && npm run lint:es", 21 | "lint:ts": "tsc --noEmit", 22 | "lint:es": "eslint ./src/**/*.{ts,tsx}", 23 | "test": "vitest --run --logHeapUsage --dir ./src", 24 | "test:cover": "vitest --run --dir ./src --coverage --coverage.provider=v8 --coverage.reporter=clover --coverage.reporter=text-summary", 25 | "watch": "vite build --watch --minify=false", 26 | "build": "tsc --noEmit && vite build" 27 | }, 28 | "dependencies": { 29 | "@ungap/structured-clone": "^1.3.0", 30 | "ipaddr.js": "^2.2.0", 31 | "punycode": "^2.3.1", 32 | "randexp": "^0.5.3", 33 | "react": "^19.1.0", 34 | "react-dom": "^19.1.0", 35 | "react-router-dom": "^7.6.1", 36 | "ua-parser-js": "^2.0.3" 37 | }, 38 | "devDependencies": { 39 | "@eslint/compat": "^1.2.9", 40 | "@eslint/eslintrc": "^3.3.1", 41 | "@eslint/js": "^9.28.0", 42 | "@types/archiver": "^6.0.3", 43 | "@types/chrome": "^0.0.326", 44 | "@types/node": "^22.15.29", 45 | "@types/randomstring": "^1.3.0", 46 | "@types/react": "^19.1.6", 47 | "@types/react-dom": "^19.1.5", 48 | "@types/ua-parser-js": "^0.7.39", 49 | "@types/ungap__structured-clone": "^1.2.0", 50 | "@typescript-eslint/eslint-plugin": "^8.26.0", 51 | "@typescript-eslint/parser": "^8.33.0", 52 | "@vitejs/plugin-react": "^4.5.0", 53 | "@vitest/coverage-v8": "^3.1.4", 54 | "archiver": "^7.0.1", 55 | "eslint": "^9.28.0", 56 | "eslint-plugin-react": "^7.37.5", 57 | "eslint-plugin-react-hooks": "^5.2.0", 58 | "eslint-plugin-react-refresh": "^0.4.20", 59 | "globals": "^16.2.0", 60 | "jest-environment-jsdom": "^29.7.0", 61 | "npm": "^11.1.0", 62 | "prettier": "^3.5.3", 63 | "randomstring": "^1.3.1", 64 | "typescript": "^5.8.3", 65 | "user-agent-data-types": "^0.4.2", 66 | "vite": "^6.3.5", 67 | "vitest": "^3.0.7" 68 | }, 69 | "volta": { 70 | "node": "22" 71 | }, 72 | "engines": { 73 | "node": ">=22" 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * @see https://prettier.io/docs/en/configuration.html 3 | * @type {import("prettier").Config} 4 | */ 5 | const config = { 6 | semi: false, 7 | tabWidth: 2, 8 | singleQuote: true, 9 | printWidth: 120, 10 | trailingComma: 'es5', 11 | } 12 | 13 | export default config 14 | -------------------------------------------------------------------------------- /src/entrypoints/background/api/filters.ts: -------------------------------------------------------------------------------- 1 | import Rule = chrome.declarativeNetRequest.Rule 2 | import type { ReadonlySettingsState, ReadonlyUserAgentState } from '~/shared/types' 3 | import { setRequestHeaders, unsetRequestHeaders } from '../hooks' 4 | 5 | /** Returns true if the extension is applicable for the given domain name. */ 6 | export async function isApplicableForDomain(settings: ReadonlySettingsState, domain: string): Promise { 7 | const isInList = settings.blacklist.domains.some((item): boolean => item === domain || domain.endsWith(`.${item}`)) 8 | 9 | switch (settings.blacklist.mode) { 10 | case 'blacklist': 11 | return !isInList 12 | 13 | case 'whitelist': 14 | return isInList 15 | } 16 | } 17 | 18 | /** Reloads the request headers based on the current settings and user-agent. */ 19 | export async function reloadRequestHeaders( 20 | settings: ReadonlySettingsState, 21 | current: ReadonlyUserAgentState | undefined 22 | ): Promise | void> { 23 | if (settings.enabled && current) { 24 | // if the extension is disabled or current user-agent is not set, we do not need to update the 25 | // browser request headers 26 | return await setRequestHeaders( 27 | current, 28 | settings.blacklist.mode === 'blacklist' 29 | ? { exceptDomains: settings.blacklist.domains } 30 | : { applyToDomains: settings.blacklist.domains }, 31 | settings.jsProtection.enabled 32 | ) 33 | } 34 | 35 | // otherwise, we need to unset the request headers 36 | await unsetRequestHeaders() 37 | } 38 | -------------------------------------------------------------------------------- /src/entrypoints/background/api/index.ts: -------------------------------------------------------------------------------- 1 | export { isApplicableForDomain, reloadRequestHeaders } from './filters' 2 | export { default as renewUserAgent } from './renew-user-agent' 3 | export { default as updateRemoteUserAgentList } from './update-remote-useragent-list' 4 | -------------------------------------------------------------------------------- /src/entrypoints/background/api/update-remote-useragent-list.ts: -------------------------------------------------------------------------------- 1 | import { RemoteUserAgentList } from '../persistent' 2 | 3 | export default async function ( 4 | remote: RemoteUserAgentList, 5 | clearBefore?: boolean 6 | ): Promise< 7 | Readonly<{ 8 | url: string 9 | gotListSize: number 10 | }> 11 | > { 12 | if (!remote.url) { 13 | throw new Error('Remote list URL is not set') 14 | } 15 | 16 | if (clearBefore) { 17 | await remote.clear() 18 | } 19 | 20 | await remote.update() 21 | 22 | return Object.freeze({ 23 | url: remote.url.toString(), 24 | gotListSize: (await remote.get(false))?.length || 0, 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /src/entrypoints/background/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { setRequestHeaders, unsetRequestHeaders } from './http-requests' 2 | export { registerContentScripts } from './scripting' 3 | -------------------------------------------------------------------------------- /src/entrypoints/background/hooks/scripting.ts: -------------------------------------------------------------------------------- 1 | import RegisteredContentScript = chrome.scripting.RegisteredContentScript 2 | 3 | // the common properties for the content scripts 4 | const common: Omit = { 5 | matches: [''], 6 | allFrames: true, 7 | runAt: 'document_start', 8 | } 9 | 10 | // properties for the content script that will be executed in the isolated world (as a content script) 11 | const content: RegisteredContentScript = { ...common, id: 'content', js: ['content.js'] } 12 | 13 | // properties for the content script that will be executed in the main world (as an injected script) 14 | const inject: RegisteredContentScript = { ...common, id: 'inject', js: [__UNIQUE_INJECT_FILENAME__] } 15 | 16 | /** Register the content scripts */ 17 | export async function registerContentScripts() { 18 | // first, unregister (probably) previously registered content scripts 19 | await chrome.scripting.unregisterContentScripts() 20 | 21 | try { 22 | await chrome.scripting.registerContentScripts([ 23 | { ...content, world: 'ISOLATED' }, 24 | { ...inject, world: 'MAIN' }, 25 | ]) 26 | } catch (err) { 27 | if ( 28 | err instanceof Error && 29 | err.message.toLowerCase().includes('unexpected property') && 30 | err.message.includes('world') 31 | ) { 32 | // if so, it means that the "world" property is not supported by the current browser (FireFox at this moment) 33 | // so we need to register the content scripts without the "world" property 34 | // 35 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/RegisteredContentScript#browser_compatibility 36 | return await chrome.scripting.registerContentScripts([content]) 37 | } 38 | 39 | throw err 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/entrypoints/background/hotkeys/hotkeys.ts: -------------------------------------------------------------------------------- 1 | export default function (handlers: { renewUserAgent: () => Promise | void }) { 2 | if (!chrome?.commands?.onCommand) { 3 | return 4 | } 5 | 6 | chrome.commands.onCommand.addListener(async (command) => { 7 | switch (command) { 8 | case 'renew-useragent': // command name, defined in manifest.json 9 | await handlers.renewUserAgent() 10 | break 11 | } 12 | }) 13 | } 14 | -------------------------------------------------------------------------------- /src/entrypoints/background/hotkeys/index.ts: -------------------------------------------------------------------------------- 1 | export { default as registerHotkeys } from './hotkeys' 2 | -------------------------------------------------------------------------------- /src/entrypoints/background/persistent/current-user-agent.ts: -------------------------------------------------------------------------------- 1 | import structuredClone from '@ungap/structured-clone' 2 | import { deepFreeze } from '~/shared' 3 | import type { DeepWriteable } from '~/types' 4 | import type { ReadonlyUserAgentState } from '~/shared/types' 5 | import type StorageArea from './storage-area' 6 | 7 | type UserAgentState = DeepWriteable 8 | 9 | export default class { 10 | private readonly storage: StorageArea 11 | 12 | /** A list of change listeners */ 13 | private changeListeners: Array<(newState: ReadonlyUserAgentState) => void> = [] 14 | 15 | constructor(storage: StorageArea) { 16 | this.storage = storage 17 | } 18 | 19 | /** Adds a change listener. */ 20 | onChange(callback: (newState: ReadonlyUserAgentState) => void): void { 21 | this.changeListeners.push(callback) 22 | } 23 | 24 | /** 25 | * Retrieves the current user-agent state. 26 | * 27 | * @throws {Error} If the state cannot be loaded. 28 | */ 29 | async get(): Promise { 30 | const loaded = await this.storage.get() 31 | 32 | if (loaded) { 33 | // the previous version of the extension used the info field, but now we save the entire data directly 34 | // in the root. therefore, we need to migrate the data 35 | if ('info' in loaded && typeof loaded.info === 'object') { 36 | // the legacy user-agent state is stored in a different format 37 | type LegacyUseragentInfo = { 38 | info?: { 39 | useragent: string 40 | engine: 'webkit' | 'blink' | 'gecko' | 'unknown' 41 | osType: 'windows' | 'linux' | 'macOS' | 'iOS' | 'android' | 'unknown' 42 | browser: 'chrome' | 'firefox' | 'opera' | 'safari' | 'edge' | 'unknown' 43 | browserVersion: { major: number; full: string } 44 | brandBrowserVersion?: { major: number; full: string } 45 | } 46 | } 47 | 48 | const legacy = loaded as LegacyUseragentInfo 49 | 50 | return deepFreeze({ 51 | userAgent: legacy.info?.useragent || '', 52 | browser: legacy.info?.browser || 'unknown', 53 | os: legacy.info?.osType || 'unknown', 54 | version: { 55 | browser: legacy.info?.brandBrowserVersion || { major: 0, full: '0.0.0' }, 56 | underHood: legacy.info?.browserVersion || undefined, 57 | }, 58 | }) 59 | } 60 | 61 | return deepFreeze(loaded) 62 | } 63 | } 64 | 65 | /** 66 | * Update the current user-agent state. Listeners are notified about the changes. 67 | * 68 | * @throws {Error} If the state cannot be updated. 69 | */ 70 | async update(updated: UserAgentState): Promise { 71 | const current = await this.storage.get() 72 | 73 | if (JSON.stringify(current) !== JSON.stringify(updated)) { 74 | await this.storage.set(updated) 75 | 76 | const clone = deepFreeze(structuredClone(updated)) 77 | 78 | this.changeListeners.forEach((listener) => queueMicrotask(() => listener(clone))) 79 | 80 | return clone 81 | } 82 | 83 | return deepFreeze(structuredClone(updated)) 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/entrypoints/background/persistent/index.ts: -------------------------------------------------------------------------------- 1 | export { default as StorageArea } from './storage-area' 2 | export { default as Settings } from './settings.ts' 3 | export { default as CurrentUserAgent } from './current-user-agent' 4 | export { default as RemoteUserAgentList } from './remote-useragent-list' 5 | export { default as LatestBrowserVersions, type ReadonlyVersionsState } from './latest-browser-versions' 6 | -------------------------------------------------------------------------------- /src/entrypoints/background/persistent/latest-browser-versions.ts: -------------------------------------------------------------------------------- 1 | import type { DeepReadonly } from '~/types' 2 | import type StorageArea from './storage-area.ts' 3 | 4 | type State = { 5 | versions: Partial<{ 6 | chrome: number 7 | firefox: number 8 | opera: number 9 | safari: number 10 | edge: number 11 | }> 12 | updatedAt: number 13 | } 14 | 15 | export type ReadonlyVersionsState = DeepReadonly 16 | 17 | type MDNReleaseRecord = Readonly<{ 18 | release_date?: string // YYYY-MM-DD, e.g. "2012-11-20", n/a for planned (beta) releases 19 | status: 'retired' | 'current' | 'beta' | 'nightly' | 'planned' 20 | release_notes?: string // URL, e.g. "https://blogs.opera.com/desktop/2023/06/opera-100-0-4815-30-stable-update/" 21 | engine_version?: string // e.g. "614.4.6" or "84" 22 | }> 23 | 24 | export default class { 25 | private readonly storage: StorageArea 26 | 27 | constructor(storage: StorageArea) { 28 | this.storage = storage 29 | } 30 | 31 | /** Fetches the latest browser versions and stores it in the storage. */ 32 | async update(): Promise { 33 | // fetch the data for all browsers in parallel and extract the latest stable version 34 | const [chrome, firefox, opera, safari, edge] = ( 35 | await Promise.all([ 36 | this.fetchBrowserData('chrome'), 37 | this.fetchBrowserData('firefox'), 38 | this.fetchBrowserData('opera'), 39 | this.fetchBrowserData('safari'), 40 | this.fetchBrowserData('edge'), 41 | ]) 42 | ).map((records: Array | null): MDNReleaseRecord | null => { 43 | if (!records) { 44 | return null // no data = no record 45 | } 46 | 47 | // try to find the latest stable (current) release 48 | const current = records.filter((release) => release.status === 'current').sort(this.sortByDate) 49 | if (current.length) { 50 | return current[0] 51 | } 52 | 53 | // if there is no stable release, return the latest public release 54 | return records.sort(this.sortByDate).find((release) => release.status === 'retired') || records[0] 55 | }) 56 | 57 | /** Since the Opera is a bit special, we need to extract the version from the release notes link :D */ 58 | const operaVersionFromReleaseNotes = (releaseNotes: string | undefined): string | undefined => { 59 | if (!releaseNotes) { 60 | return undefined 61 | } 62 | 63 | const match = releaseNotes.match(/opera-(\d+)/) 64 | if (!match) { 65 | return undefined 66 | } 67 | 68 | return match[1] 69 | } 70 | 71 | // read the current state and update the versions to use them if the new data is missing 72 | const current = await this.storage.get() 73 | 74 | await this.storage.set({ 75 | versions: { 76 | chrome: this.extractMajor(chrome?.engine_version) ?? current?.versions?.chrome, 77 | firefox: this.extractMajor(firefox?.engine_version) ?? current?.versions?.firefox, 78 | opera: this.extractMajor(operaVersionFromReleaseNotes(opera?.release_notes)) ?? current?.versions?.opera, 79 | safari: this.extractMajor(safari?.engine_version) ?? current?.versions?.safari, 80 | edge: this.extractMajor(edge?.engine_version) ?? current?.versions?.edge, 81 | }, 82 | updatedAt: Date.now(), 83 | }) 84 | } 85 | 86 | async get(): Promise | undefined]> { 87 | const state = await this.storage.get() 88 | 89 | return [state?.versions, state?.updatedAt ? new Date(state.updatedAt) : undefined] 90 | } 91 | 92 | async clear(): Promise { 93 | await this.storage.clear() 94 | } 95 | 96 | /** Fetch the browser versions data from MDN. */ 97 | private async fetchBrowserData< 98 | T extends 99 | | 'chrome' 100 | | 'firefox' 101 | | 'opera' 102 | | 'safari' 103 | | 'edge' 104 | | 'safari_ios' 105 | | 'webview_android' 106 | | 'chrome_android' 107 | | 'firefox_android' 108 | | 'opera_android', 109 | >(browser: T): Promise | null> { 110 | // the filename is the same as the browser name (e.g. "chrome.json") in current major version (v5) of the MDN data: 111 | // https://cdn.jsdelivr.net/gh/mdn/browser-compat-data@5/browsers/ - list of all available files 112 | // https://github.com/mdn/browser-compat-data/tree/main/browsers - GitHub repository 113 | // 114 | // about the caching on the jsdelivr side - the data is cached for 7 days: 115 | // https://www.jsdelivr.com/documentation#id-caching 116 | const resp = await fetch(`https://cdn.jsdelivr.net/gh/mdn/browser-compat-data@5/browsers/${browser}.json`) 117 | if (resp.ok) { 118 | const releases = (await resp.json())?.browsers[browser]?.releases 119 | if (releases) { 120 | return Object.values(releases) 121 | } 122 | } 123 | 124 | return null 125 | } 126 | 127 | /** Sorts the records by the release date. */ 128 | private sortByDate = (a: T, b: T) => 129 | (a.release_date ? new Date(a.release_date).getTime() : 0) - 130 | (b.release_date ? new Date(b.release_date).getTime() : 0) 131 | 132 | /** Extract the major version from a string. If the string is empty or doesn't contain a number, return undefined. */ 133 | private extractMajor = (str: string | undefined): number | undefined => { 134 | if (!str) { 135 | return undefined 136 | } 137 | 138 | const major = parseInt(str.replaceAll(/[^\d.]/g, '').split('.')[0]) 139 | 140 | return isNaN(major) ? undefined : major 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/entrypoints/background/persistent/remote-useragent-list.ts: -------------------------------------------------------------------------------- 1 | import type StorageArea from './storage-area' 2 | 3 | type UserAgentListState = { 4 | list: Array 5 | } 6 | 7 | export default class { 8 | private readonly storage: StorageArea 9 | private remoteListUrl: URL | undefined 10 | 11 | constructor(storage: StorageArea) { 12 | this.storage = storage 13 | } 14 | 15 | /** Sets the remote list URL. If the URL is invalid, it will be ignored and the method will return false. */ 16 | setUrl(url: string | URL): boolean { 17 | let newUrl: URL | undefined 18 | 19 | switch (true) { 20 | case typeof url === 'string': 21 | try { 22 | newUrl = new URL(url) // validate the URL 23 | } catch { 24 | // do nothing 25 | } 26 | break 27 | 28 | case url instanceof URL: 29 | newUrl = url 30 | break 31 | } 32 | 33 | if (newUrl && ['http:', 'https:'].includes(newUrl.protocol)) { 34 | this.remoteListUrl = newUrl 35 | 36 | return true 37 | } 38 | 39 | return false 40 | } 41 | 42 | /** Returns the remote list URL. */ 43 | get url(): URL | undefined { 44 | return this.remoteListUrl 45 | } 46 | 47 | /** Clears the user agent list. */ 48 | async clear(): Promise { 49 | await this.storage.clear() 50 | } 51 | 52 | /** 53 | * Fetches the remote list and stores it in the storage. 54 | * 55 | * @throws {Error} If the remote list URL is not set or the fetch fails. 56 | */ 57 | async update(): Promise { 58 | if (!this.remoteListUrl) { 59 | throw new Error('Remote list URL is not set') 60 | } 61 | 62 | const response = await fetch(this.remoteListUrl, { 63 | method: 'GET', 64 | redirect: 'follow', 65 | referrerPolicy: 'no-referrer', 66 | }) 67 | 68 | if (!response.ok) { 69 | throw new Error(`Failed to fetch remote list: ${response.statusText} (${this.remoteListUrl.toString()})`) 70 | } 71 | 72 | const text = await response.text() 73 | 74 | if (text.length) { 75 | const list = text 76 | .split('\n') 77 | .map((s): string => s.trim()) 78 | .filter((s): boolean => { 79 | if (s.startsWith('#') || s.startsWith('//')) { 80 | return false // skip comments 81 | } 82 | 83 | return s.length !== 0 // skip empty lines 84 | }) 85 | .filter(Boolean) 86 | 87 | if (list.length) { 88 | await this.storage.set({ list }) 89 | } 90 | } 91 | } 92 | 93 | /** 94 | * Retrieves the user agent list from the storage. 95 | * 96 | * If random is true, returns a random user agent. Otherwise, returns the whole list. 97 | * If the list is empty, returns undefined. 98 | */ 99 | async get(random: true): Promise | undefined> 100 | async get(random: false): Promise> | undefined> 101 | async get(random: boolean): Promise | Readonly> | undefined> { 102 | const state = await this.storage.get() 103 | 104 | if (state?.list.length) { 105 | return Object.freeze(random ? state.list[Math.floor(Math.random() * state.list.length)] : state.list) 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/entrypoints/background/persistent/settings.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, test } from 'vitest' 2 | import type { SettingsState } from '~/shared/types/settings' 3 | import Settings from './settings' 4 | import StorageArea from './storage-area' 5 | 6 | describe('settings', () => { 7 | // this is mock of the browser's storage area that does nothing 8 | class StorageAreaMock extends StorageArea { 9 | private state?: Record 10 | 11 | async clear(): Promise { 12 | this.state = undefined 13 | } 14 | 15 | async set(v: T): Promise { 16 | this.state = JSON.parse(JSON.stringify(v)) as Record 17 | } 18 | 19 | async get(): Promise { 20 | return this.state as T | undefined 21 | } 22 | } 23 | 24 | test('update root boolean property', async () => { 25 | const settings = new Settings(new StorageAreaMock('some-key', 'local')) 26 | 27 | await settings.update({ enabled: true }) 28 | expect((await settings.get()).enabled).toBeTruthy() 29 | 30 | await settings.update({ enabled: undefined }) 31 | expect((await settings.get()).enabled).toBeTruthy() 32 | 33 | await settings.update({ enabled: false }) 34 | expect((await settings.get()).enabled).toBeFalsy() 35 | 36 | await settings.update({ enabled: undefined }) 37 | expect((await settings.get()).enabled).toBeFalsy() 38 | }) 39 | 40 | test('update nested property', async () => { 41 | const settings = new Settings(new StorageAreaMock('some-key', 'local')) 42 | 43 | await settings.update({ blacklist: { domains: ['foo'] }, renew: { intervalMillis: 30001 } }) 44 | expect((await settings.get()).blacklist.domains).toEqual(['foo']) 45 | expect((await settings.get()).renew.intervalMillis).toEqual(30001) 46 | 47 | await settings.update({ blacklist: { domains: undefined }, renew: { intervalMillis: undefined } }) 48 | expect((await settings.get()).blacklist.domains).toEqual(['foo']) 49 | expect((await settings.get()).renew.intervalMillis).toEqual(30001) 50 | 51 | await settings.update({ blacklist: { domains: ['bar', 'baz'] }, renew: { intervalMillis: 666 } }) 52 | expect((await settings.get()).blacklist.domains).toEqual(['bar', 'baz']) 53 | expect((await settings.get()).renew.intervalMillis).toEqual(30000) 54 | }) 55 | 56 | test('partial from storage', async () => { 57 | const area: StorageArea = new StorageAreaMock('some-key', 'local') 58 | const settings = new Settings(area) 59 | 60 | await settings.update({ enabled: false, remoteUseragentList: { uri: 'foo' } }) // force to save 61 | 62 | // read the current state and remove some key 63 | { 64 | const current = await area.get() 65 | if (!current) { 66 | throw new Error('current is undefined') 67 | } 68 | 69 | await area.set(current) 70 | } 71 | 72 | let executed = false 73 | 74 | settings.onChange((s) => { 75 | expect(s.remoteUseragentList.uri).toEqual('foo') 76 | expect(s.enabled).toBeTruthy() 77 | 78 | executed = true 79 | }) 80 | 81 | await settings.update({ enabled: true }) // force to save 82 | 83 | expect(executed).toBeTruthy() 84 | }) 85 | 86 | test('onChange listener', async () => { 87 | const settings = new Settings(new StorageAreaMock('some-key', 'local')) 88 | let execCount = 0 89 | 90 | settings.onChange(() => { 91 | execCount++ 92 | }) 93 | 94 | const defaults = await settings.get() 95 | await settings.update({}) 96 | expect(execCount).toBe(0) 97 | 98 | await settings.update({ enabled: defaults.enabled }) 99 | expect(execCount).toBe(0) 100 | 101 | await settings.update({ enabled: !defaults.enabled }) 102 | expect(execCount).toBe(1) 103 | 104 | await settings.update({ enabled: undefined, generator: { types: undefined } }) 105 | expect(execCount).toBe(1) // not changed 106 | 107 | await settings.update({ blacklist: { domains: ['foo'] } }) 108 | expect(execCount).toBe(2) 109 | 110 | await settings.update({ blacklist: { domains: ['bar'] } }) 111 | expect(execCount).toBe(3) 112 | }) 113 | }) 114 | -------------------------------------------------------------------------------- /src/entrypoints/background/persistent/storage-area.ts: -------------------------------------------------------------------------------- 1 | import BrowserStorageArea = chrome.storage.StorageArea 2 | 3 | type AreaName = 'sync' | 'local' 4 | 5 | /** 6 | * This class provides a simple way to store and retrieve data in the browser's storage. It's important to note 7 | * that this is distinct from local or session storage; it specifically refers to browser storage. 8 | * 9 | * @link https://developer.chrome.com/docs/extensions/reference/api/storage 10 | */ 11 | export default class = Record> { 12 | /** The key used to store the data */ 13 | private readonly key: string 14 | /** The storage area to use and fallback to if the main area is not available */ 15 | private readonly areaName: { main: AreaName; fallback?: AreaName } 16 | /** Do not use this property directly, use getStorage() method instead */ 17 | private storage?: BrowserStorageArea 18 | 19 | constructor(key: string, mainArea: AreaName, fallbackArea?: AreaName) { 20 | this.key = key 21 | this.areaName = { main: mainArea, fallback: fallbackArea } 22 | } 23 | 24 | /** 25 | * Returns the storage area (sync or local, depending on the availability). 26 | * 27 | * @throws {Error} If neither sync nor local storage is available. 28 | */ 29 | private async getStorage(): Promise { 30 | if (this.storage) { 31 | return this.storage // return cached storage area 32 | } 33 | 34 | try { 35 | const main = this.areaName.main === 'sync' ? chrome.storage.sync : chrome.storage.local 36 | 37 | await main.get(null) // try to get main storage 38 | 39 | if (!chrome.runtime.lastError) { 40 | this.storage = main 41 | 42 | return this.storage // return main storage 43 | } 44 | // eslint-disable-next-line @typescript-eslint/no-unused-vars 45 | } catch (syncErr) { 46 | // ignore any errors, proceed to fallback storage 47 | } 48 | 49 | if (this.areaName.fallback && this.areaName.fallback !== this.areaName.main) { 50 | const fallback = this.areaName.fallback === 'sync' ? chrome.storage.sync : chrome.storage.local 51 | 52 | try { 53 | await fallback.get(null) // try to get fallback storage 54 | } catch (err) { 55 | throw new Error(String(err)) 56 | } 57 | 58 | if (!chrome.runtime.lastError) { 59 | this.storage = fallback 60 | 61 | return this.storage // return fallback storage 62 | } 63 | 64 | throw new Error('Neither main nor fallback storage is available') 65 | } 66 | 67 | throw new Error('Main storage is not available') 68 | } 69 | 70 | /** 71 | * Retrieves the data from the storage. If the data is not found, it returns `undefined`. 72 | * 73 | * @throws {Error} If the data cannot be retrieved. 74 | */ 75 | async get(): Promise { 76 | const items = await (await this.getStorage()).get(this.key) 77 | const lastError = chrome.runtime.lastError 78 | 79 | if (lastError) { 80 | throw new Error(lastError.message) 81 | } 82 | 83 | if (items && this.key in items) { 84 | return items[this.key] 85 | } 86 | 87 | return undefined // storage does not contains expected data 88 | } 89 | 90 | /** 91 | * Sets the data in the storage. 92 | * 93 | * @throws {Error} If the data cannot be set. 94 | */ 95 | async set(value: TState): Promise { 96 | await (await this.getStorage()).set({ [this.key]: value }) 97 | const lastError = chrome.runtime.lastError 98 | 99 | if (lastError) { 100 | throw new Error(lastError.message) 101 | } 102 | } 103 | 104 | /** 105 | * Removes the data from the storage, associated with the key only. 106 | * 107 | * @throws {Error} If the data cannot be removed. 108 | */ 109 | async clear(): Promise { 110 | await (await this.getStorage()).remove(this.key) 111 | const lastError = chrome.runtime.lastError 112 | 113 | if (lastError) { 114 | throw new Error(lastError.message) 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/entrypoints/background/timer/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Timer } from './timer' 2 | -------------------------------------------------------------------------------- /src/entrypoints/background/timer/timer.ts: -------------------------------------------------------------------------------- 1 | export default class Timer { 2 | // Chrome 120: Starting in Chrome 120, the minimum alarm interval has been reduced from 1 minute to 30 seconds 3 | // for an alarm to trigger in 30 seconds, set periodInMinutes: 0.5 4 | // https://developer.chrome.com/docs/extensions/reference/api/alarms 5 | // 6 | // for FireFox, the minimum interval is 1 minute 7 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/alarms/create#alarminfo 8 | private readonly minDelayInSeconds: number = 30 9 | 10 | // is set only after the timer is started 11 | private listener: ((alarm: chrome.alarms.Alarm) => void) | undefined 12 | private intervalSec: number 13 | 14 | private readonly name: string 15 | private readonly handler: (timer: this) => void 16 | 17 | /** 18 | * Create a new timer. Keep in mind that the minimum interval is 30 seconds, and the timer should be started 19 | * manually. The name of the timer should be unique. 20 | */ 21 | constructor(name: string, intervalSec: number, handler: (timer: Timer) => void) { 22 | this.intervalSec = Math.max(this.minDelayInSeconds, intervalSec) 23 | 24 | this.name = name 25 | this.handler = handler 26 | } 27 | 28 | /** Start the timer */ 29 | async start(): Promise { 30 | await chrome.alarms.create(this.name, { periodInMinutes: this.intervalSec / 60 }) 31 | 32 | this.listener = (alarm) => { 33 | if (alarm.name === this.name) { 34 | this.handler(this) 35 | } 36 | } 37 | 38 | chrome.alarms.onAlarm.addListener(this.listener) 39 | } 40 | 41 | /** Stop the timer */ 42 | async stop(): Promise { 43 | if (this.listener) { 44 | chrome.alarms.onAlarm.removeListener(this.listener) 45 | 46 | this.listener = undefined 47 | } 48 | 49 | return await chrome.alarms.clear(this.name) 50 | } 51 | 52 | /** Restart the timer */ 53 | async restart(): Promise { 54 | await this.stop() 55 | await this.start() 56 | } 57 | 58 | /** Change the timer interval (in seconds) */ 59 | async changeInterval(intervalSec: number): Promise { 60 | this.intervalSec = Math.max(this.minDelayInSeconds, intervalSec) 61 | 62 | if (this.listener) { 63 | await this.restart() 64 | } 65 | } 66 | 67 | /** Get the timer interval (in seconds) */ 68 | get getIntervalSec(): number { 69 | return this.intervalSec 70 | } 71 | 72 | /** Is the timer started? */ 73 | get isStarted(): boolean { 74 | return !!this.listener 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /src/entrypoints/background/ui/extension-icon.ts: -------------------------------------------------------------------------------- 1 | import type { ReadonlyUserAgentState } from '~/shared/types' 2 | 3 | /** 4 | * Set the extension icon, depending on the state. Can be limited to a specific tab. 5 | * 6 | * @link https://developer.chrome.com/docs/extensions/reference/api/action#method-setIcon 7 | * @link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/setIcon 8 | */ 9 | export const setExtensionIcon = async (active: boolean, tabId?: number): Promise => { 10 | await chrome.action.setIcon({ 11 | path: active 12 | ? { 13 | 16: '/assets/icons/16.png', 14 | 32: '/assets/icons/32.png', 15 | 48: '/assets/icons/48.png', 16 | 128: '/assets/icons/128.png', 17 | } 18 | : { 19 | 16: '/assets/icons/16-gray.png', 20 | 32: '/assets/icons/32-gray.png', 21 | 48: '/assets/icons/48-gray.png', 22 | 128: '/assets/icons/128-gray.png', 23 | }, 24 | tabId, 25 | }) 26 | } 27 | 28 | /** 29 | * Set the extension icon title. Can be limited to a specific tab. 30 | * 31 | * @link https://developer.chrome.com/docs/extensions/reference/api/action#method-setTitle 32 | * @link https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/browserAction/setTitle 33 | */ 34 | export const setExtensionTitle = async (title: ReadonlyUserAgentState | string, tabId?: number): Promise => { 35 | if (typeof title === 'string') { 36 | return await chrome.action.setTitle({ title, tabId }) 37 | } 38 | 39 | return await chrome.action.setTitle({ 40 | title: [ 41 | ((): string => { 42 | switch (title.browser) { 43 | case 'chrome': 44 | return '🌐 Chrome' 45 | case 'firefox': 46 | return '🦊 FireFox' 47 | case 'opera': 48 | return '⭕ Opera' 49 | case 'safari': 50 | return '🧭 Safari' 51 | case 'edge': 52 | return '🛸 Edge' 53 | default: 54 | return '👻 Browser' 55 | } 56 | })(), 57 | `(v${title.version.browser.major})`, 58 | ((): string => { 59 | switch (title.os) { 60 | case 'windows': 61 | return '🖥 Windows' 62 | case 'linux': 63 | return '🐧 Linux' 64 | case 'macOS': 65 | return '🍏 macOS' 66 | case 'iOS': 67 | return '🍏 iOS' 68 | case 'android': 69 | return '📱 Android' 70 | default: 71 | return '🧮 Device' 72 | } 73 | })(), 74 | ].join(' '), 75 | tabId, 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /src/entrypoints/background/ui/index.ts: -------------------------------------------------------------------------------- 1 | export { setExtensionIcon, setExtensionTitle } from './extension-icon' 2 | -------------------------------------------------------------------------------- /src/entrypoints/content/content.ts: -------------------------------------------------------------------------------- 1 | // ⚠ DO NOT IMPORT ANYTHING EXCEPT TYPES HERE DUE THE `import()` ERRORS ⚠ 2 | 3 | // wrap everything to avoid polluting the global scope 4 | 5 | ;(() => { 6 | try { 7 | // Important Note: 8 | // 9 | // Chromium-based browsers (like Chrome, Edge, Opera, etc.) support the `world` property in the 10 | // `chrome.scripting.registerContentScripts` API. However, FireFox does not. Therefore, I need to ensure that the 11 | // "inject" script code is executed in both environments. 12 | // 13 | // https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/scripting/RegisteredContentScript 14 | 15 | const script = document.createElement('script') 16 | const parent = document.head || document.documentElement 17 | 18 | script.type = 'module' 19 | script.setAttribute('id', __UNIQUE_INJECT_FILENAME__) 20 | script.src = chrome.runtime.getURL(__UNIQUE_INJECT_FILENAME__) 21 | 22 | parent.prepend(script) 23 | } catch (err) { 24 | console.warn('🧨 RUA: An error occurred in the content script', err) 25 | } 26 | })() 27 | -------------------------------------------------------------------------------- /src/entrypoints/onboard/components/button/component.module.css: -------------------------------------------------------------------------------- 1 | .button { 2 | display: block; 3 | font-size: 1.5em; 4 | margin: 0.7em 1em; 5 | text-align: center; 6 | cursor: pointer; 7 | 8 | background-color: var(--color-bg-info); 9 | color: var(--color-text-info); 10 | padding: 0.5em 1em; 11 | border-radius: 0.3em; 12 | border: 2px solid var(--color-ui-border-info); 13 | 14 | animation: pulse 2s infinite; 15 | 16 | @keyframes pulse { 17 | 0% { 18 | box-shadow: 0 0 0 0 rgba(125, 125, 125, 0.25); 19 | } 20 | 70% { 21 | box-shadow: 0 0 0 17px rgba(125, 125, 125, 0); 22 | } 23 | 100% { 24 | box-shadow: 0 0 0 0 rgba(125, 125, 125, 0); 25 | } 26 | } 27 | 28 | &:hover { 29 | text-decoration: none; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/entrypoints/onboard/components/button/component.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react' 2 | import styles from './component.module.css' 3 | 4 | export default function Button({ title, onClick }: { title: string; onClick: () => void }): React.JSX.Element { 5 | return ( 6 | 9 | ) 10 | } 11 | -------------------------------------------------------------------------------- /src/entrypoints/onboard/components/button/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './component' 2 | -------------------------------------------------------------------------------- /src/entrypoints/onboard/components/container/component.module.css: -------------------------------------------------------------------------------- 1 | .center { 2 | text-align: center; 3 | } 4 | 5 | .container { 6 | display: flex; 7 | width: 100vw; 8 | height: 100vh; 9 | justify-content: center; 10 | align-items: center; 11 | font-size: 1.3em; 12 | 13 | .wrapper { 14 | width: 100%; 15 | max-width: 1100px; 16 | display: flex; 17 | flex-direction: row; 18 | 19 | .left { 20 | box-sizing: border-box; 21 | display: flex; 22 | justify-content: center; 23 | align-items: center; 24 | 25 | width: 42%; 26 | min-width: 100px; 27 | padding-right: 2em; 28 | 29 | .logo { 30 | width: 100%; 31 | } 32 | } 33 | 34 | .options { 35 | width: 100%; 36 | min-width: 300px; 37 | 38 | li { 39 | margin-bottom: 0.6em; 40 | } 41 | } 42 | } 43 | } 44 | 45 | .embeddedContent { 46 | display: flex; 47 | justify-content: center; 48 | } 49 | 50 | @media (max-width: 850px) { 51 | .container { 52 | font-size: 1em; 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/entrypoints/onboard/components/container/component.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react' 2 | import i18n from '~/i18n/i18n' 3 | import logo from '~/shared/assets/logo.svg' 4 | import styles from './component.module.css' 5 | 6 | export default function Container({ children = null }: { children?: React.ReactNode }): React.JSX.Element { 7 | return ( 8 |
9 |
10 |
11 | logo 12 |
13 |
14 |

{i18n('manifest_name')}

15 | 16 |

{i18n('why_we_need_permissions')}:

17 | 18 |
    19 |
  • 20 | {i18n('read_and_modify_data') + ' '}({i18n('read_and_modify_data_reason')}) 21 |
  • 22 |
23 | 24 |
{children}
25 |
26 |
27 |
28 | ) 29 | } 30 | -------------------------------------------------------------------------------- /src/entrypoints/onboard/components/container/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './component' 2 | -------------------------------------------------------------------------------- /src/entrypoints/onboard/index.css: -------------------------------------------------------------------------------- 1 | @import '~/theme/theme.css'; 2 | 3 | html, 4 | body { 5 | background-color: var(--color-bg-primary); 6 | color: var(--color-text-primary); 7 | margin: 0; 8 | padding: 0; 9 | font-family: 10 | 'Open Sans', 11 | -apple-system, 12 | BlinkMacSystemFont, 13 | 'Segoe UI', 14 | Roboto, 15 | Ubuntu, 16 | Arial, 17 | sans-serif; 18 | font-weight: 400; 19 | } 20 | 21 | body { 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | height: 100vh; 26 | font-size: 16px; 27 | } 28 | -------------------------------------------------------------------------------- /src/entrypoints/onboard/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Random User-Agent - Onboarding 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/entrypoints/onboard/index.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react' 2 | import { StrictMode, useEffect } from 'react' 3 | import { createRoot } from 'react-dom/client' 4 | import { i18n } from '~/i18n' 5 | import { askForPermissions, checkPermissions } from '~/shared/permissions' 6 | import Button from './components/button' 7 | import Container from './components/container' 8 | import '~/theme/theme.css' 9 | import './index.css' 10 | 11 | const App = (): React.JSX.Element => { 12 | // watch for permissions and close the window when granted automatically 13 | useEffect(() => { 14 | const check = () => { 15 | checkPermissions().then((has) => { 16 | if (has) { 17 | window.close() 18 | } 19 | }) 20 | } 21 | 22 | const t = setInterval(check, 1000) 23 | 24 | check() 25 | 26 | return () => clearInterval(t) 27 | }, []) 28 | 29 | return ( 30 | 31 | 29 | ) 30 | } 31 | 32 | render() { 33 | return 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/entrypoints/options/shared/components/button/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './component' 2 | -------------------------------------------------------------------------------- /src/entrypoints/options/shared/components/grid/component.module.css: -------------------------------------------------------------------------------- 1 | .grid { 2 | display: block; 3 | 4 | /* background-color: rgba(255,0,0,0.5); // for debugging */ 5 | } 6 | 7 | .row { 8 | display: flex; 9 | justify-content: space-between; 10 | padding: 1.5em 0; 11 | 12 | &:not(:last-child) { 13 | border: solid var(--color-ui-border-light); 14 | border-width: 0 0 1px 0; 15 | } 16 | 17 | /* background-color: rgba(0,255,0,0.5); // for debugging */ 18 | } 19 | 20 | .column { 21 | display: flex; 22 | flex-direction: column; 23 | justify-content: center; 24 | 25 | /* background-color: rgba(0,0,255,0.5); // for debugging */ 26 | } 27 | 28 | .hint { 29 | opacity: 0.7; 30 | font-size: 0.8em; 31 | margin: 1em 0; 32 | } 33 | -------------------------------------------------------------------------------- /src/entrypoints/options/shared/components/grid/component.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import styles from './component.module.css' 3 | 4 | export default class Grid extends React.Component<{ children: React.ReactNode }> { 5 | static Row = ({ children, style }: { children: React.ReactNode; style?: React.CSSProperties }): React.JSX.Element => { 6 | return ( 7 |
8 | {children} 9 |
10 | ) 11 | } 12 | 13 | static Column = ({ 14 | children, 15 | fullWidth, 16 | style, 17 | }: { 18 | children: React.ReactNode 19 | fullWidth?: boolean 20 | style?: { 21 | inner?: React.CSSProperties 22 | outer?: React.CSSProperties 23 | } 24 | }): React.JSX.Element => { 25 | return ( 26 |
27 |
{children}
28 |
29 | ) 30 | } 31 | 32 | static Hint = ({ 33 | children, 34 | style, 35 | }: { 36 | children: React.ReactNode 37 | style?: React.CSSProperties 38 | }): React.JSX.Element => { 39 | return ( 40 |
41 | {children} 42 |
43 | ) 44 | } 45 | 46 | render() { 47 | return
{this.props.children}
48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/entrypoints/options/shared/components/grid/index.ts: -------------------------------------------------------------------------------- 1 | export { default } from './component' 2 | -------------------------------------------------------------------------------- /src/entrypoints/options/shared/components/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Button } from './button' 2 | export { default as Grid } from './grid' 3 | export { default as Input } from './input' 4 | export { default as Selector } from './selector' 5 | export { default as Switch } from './switch' 6 | -------------------------------------------------------------------------------- /src/entrypoints/options/shared/components/input/component.module.css: -------------------------------------------------------------------------------- 1 | .number { 2 | padding: 0.5em; 3 | margin: 0; 4 | background-color: var(--color-ui-bg-primary); 5 | border: 1px solid var(--color-ui-border-primary); 6 | color: var(--color-text-primary); 7 | box-sizing: border-box; 8 | 9 | &::placeholder { 10 | color: var(--color-text-light); 11 | } 12 | 13 | &:focus, 14 | &:focus-visible { 15 | outline: none; 16 | } 17 | 18 | &::-webkit-inner-spin-button, 19 | &::-webkit-outer-spin-button { 20 | opacity: 1; 21 | } 22 | 23 | &:disabled { 24 | background-color: var(--color-ui-bg-disabled); 25 | cursor: not-allowed; 26 | } 27 | } 28 | 29 | .text { 30 | padding: 0.5em; 31 | margin: 0; 32 | background-color: var(--color-ui-bg-primary); 33 | border: 1px solid var(--color-ui-border-primary); 34 | color: var(--color-text-primary); 35 | box-sizing: border-box; 36 | 37 | &::placeholder { 38 | color: var(--color-text-light); 39 | } 40 | 41 | &:focus, 42 | &:focus-visible { 43 | outline: none; 44 | } 45 | 46 | &:disabled { 47 | background-color: var(--color-ui-bg-disabled); 48 | cursor: not-allowed; 49 | } 50 | } 51 | 52 | .textarea { 53 | display: block; 54 | width: 100%; 55 | min-height: 4em; 56 | resize: vertical; 57 | padding: 0.7em 0.5em; 58 | 59 | box-sizing: border-box; 60 | background-color: var(--color-ui-bg-primary); 61 | border: 1px solid var(--color-ui-border-primary); 62 | color: var(--color-text-primary); 63 | 64 | &::placeholder { 65 | color: var(--color-text-light); 66 | } 67 | 68 | &:focus, 69 | &:focus-visible { 70 | outline: none; 71 | } 72 | 73 | &:disabled { 74 | background-color: var(--color-ui-bg-disabled); 75 | cursor: not-allowed; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/entrypoints/options/shared/components/input/component.tsx: -------------------------------------------------------------------------------- 1 | import type React from 'react' 2 | import { useId } from 'react' 3 | import styles from './component.module.css' 4 | 5 | export default class Input { 6 | static Number = ({ 7 | id, 8 | value = 0, 9 | size, 10 | min, 11 | max, 12 | step, 13 | placeholder, 14 | disabled = false, 15 | style, 16 | onChange, 17 | }: { 18 | id?: string 19 | value?: number 20 | size?: number 21 | min?: number 22 | max?: number 23 | step?: number 24 | placeholder?: string 25 | disabled?: boolean 26 | style?: React.CSSProperties 27 | onChange?: (newValue: number) => void 28 | }): React.JSX.Element => { 29 | return ( 30 | { 44 | let newValue = e.currentTarget.valueAsNumber 45 | 46 | if (min && newValue < min) { 47 | newValue = min 48 | } else if (max && newValue > max) { 49 | newValue = max 50 | } else if (isNaN(newValue)) { 51 | newValue = min ?? 0 52 | } 53 | 54 | onChange?.(newValue) 55 | }} 56 | /> 57 | ) 58 | } 59 | 60 | static Text = ({ 61 | id, 62 | value = '', 63 | size, 64 | maxLength, 65 | placeholder, 66 | disabled = false, 67 | style, 68 | onChange, 69 | }: { 70 | id?: string 71 | value?: string 72 | size?: number 73 | maxLength?: number 74 | placeholder?: string 75 | disabled?: boolean 76 | style?: React.CSSProperties 77 | onChange?: (newValue: string) => void 78 | }) => { 79 | return ( 80 | { 92 | let newValue = e.currentTarget.value 93 | 94 | if (maxLength && newValue.length > maxLength) { 95 | newValue = newValue.slice(0, maxLength) 96 | } 97 | 98 | onChange?.(newValue) 99 | }} 100 | /> 101 | ) 102 | } 103 | 104 | static Textarea = ({ 105 | id, 106 | value, 107 | placeholder, 108 | maxLength, 109 | cols, 110 | rows, 111 | disabled = false, 112 | style, 113 | onChange, 114 | }: { 115 | id?: string 116 | value?: string 117 | placeholder?: string 118 | maxLength?: number 119 | cols?: number 120 | rows?: number 121 | disabled?: boolean 122 | style?: React.CSSProperties 123 | onChange?: (newValue: string) => void 124 | }): React.JSX.Element => { 125 | return ( 126 |