mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-25 13:50:51 +02:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2087817a39 | |||
| 9624ec846d | |||
| f7daf68b52 | |||
| 8fe38453d4 | |||
| a71dad735e | |||
| a4ed5c855a | |||
| 32fcd2328c | |||
| f84dc3f959 | |||
| bf0d0d59a7 | |||
| f1664b2950 | |||
| 4f7910dd23 | |||
| e1c9ce6525 | |||
| cef522649a | |||
| f95816d70d | |||
| b15231d752 | |||
| af792745fc | |||
| 22f976442b | |||
| 809a95c729 | |||
| cea4ece698 | |||
| ac03b70f94 | |||
| 95f84248ab | |||
| dd5357d6c3 | |||
| 7b7849a54a | |||
| cb0ec75d37 | |||
| ae8afbb158 | |||
| 53db00a85a | |||
| eeb5c816bf | |||
| 86d58717b4 | |||
| 06e34527b6 | |||
| 97f1f52a6d | |||
| f95e6332fa | |||
| 86671ceed6 | |||
| 0e5a4608d7 | |||
| 745a4da17c | |||
| 435092de30 | |||
| 9d5983cf55 | |||
| fc7da8af36 | |||
| bb46ea2d1f | |||
| 9796b092cd | |||
| 575700a67f | |||
| 4eb364653d | |||
| 7d85106f22 | |||
| 891eba6a47 | |||
| d8c1a51d4a | |||
| 7249515c8e | |||
| 0b3857b361 | |||
| 23859333c6 | |||
| 23dab4c8e4 | |||
| 6f0ffc79ee | |||
| eb6ded2772 | |||
| 95189c7c6c | |||
| 63a1f4c92a | |||
| 78803ab289 | |||
| 8162ad5a82 | |||
| b507bf0af5 | |||
| 15a7647e74 | |||
| 862831764d | |||
| 5c6d05a62e | |||
| c91536325f | |||
| 8b1629c7db | |||
| 5a46d0e266 | |||
| 2c4163383d | |||
| 203f6a9fc8 | |||
| 88413524b5 | |||
| d69ba6ff6c | |||
| 1057634692 | |||
| e54bc1192d |
@@ -2,6 +2,11 @@ name: Bug Report
|
||||
description: Something isn't working
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Do not include passwords, access tokens, proxy credentials, personal information, or other secrets. Automated triage sends the issue title and body to OpenRouter after removing the logs/screenshots field and redacting common sensitive-data patterns.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
@@ -41,15 +46,12 @@ body:
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: browser
|
||||
- type: input
|
||||
id: wayfern_version
|
||||
attributes:
|
||||
label: Which browser is affected?
|
||||
options:
|
||||
- Wayfern
|
||||
- Camoufox
|
||||
- Both
|
||||
- Not browser-specific
|
||||
label: Wayfern version
|
||||
description: Settings → About, or the version shown when creating a profile. Use "unknown" if not browser-specific.
|
||||
placeholder: e.g. 138.0.7204.50 or unknown
|
||||
validations:
|
||||
required: true
|
||||
|
||||
@@ -57,7 +59,7 @@ body:
|
||||
id: logs
|
||||
attributes:
|
||||
label: Error logs or screenshots
|
||||
description: Run from terminal to get logs. Paste errors, screenshots, or screen recordings.
|
||||
description: Use Settings → Advanced → Copy logs for a redacted log bundle. Review it before posting. Never include credentials or personal information.
|
||||
placeholder: Paste logs here or drag screenshots
|
||||
validations:
|
||||
required: false
|
||||
|
||||
@@ -2,6 +2,11 @@ name: Feature Request
|
||||
description: Suggest a new feature
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Do not include passwords, access tokens, personal information, or other secrets. Automated triage sends the issue title and body to OpenRouter after redacting common sensitive-data patterns.
|
||||
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
name: App E2E Tests
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
smoke_only:
|
||||
description: Run the cross-platform pull-request smoke matrix
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
secrets:
|
||||
TAURI_WEBDRIVER_TOKEN:
|
||||
description: Read-only token for the test-driver repository
|
||||
required: false
|
||||
WAYFERN_TEST_TOKEN:
|
||||
description: Token used by the full Wayfern integration suite
|
||||
required: false
|
||||
RESIDENTIAL_PROXY_URL_ONE_SOCKS:
|
||||
description: SOCKS5 residential proxy used by the network suite
|
||||
required: false
|
||||
RESIDENTIAL_PROXY_URL_ONE_HTTP:
|
||||
description: HTTP residential proxy used by the network suite
|
||||
required: false
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
suite:
|
||||
description: E2E suite to run
|
||||
required: false
|
||||
default: full
|
||||
type: choice
|
||||
options:
|
||||
- full
|
||||
- smoke
|
||||
- ui
|
||||
- entities
|
||||
- network
|
||||
- integrations
|
||||
- sync
|
||||
- browser
|
||||
push:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "17 3 * * 1"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
smoke:
|
||||
name: Smoke (${{ matrix.os }})
|
||||
if: ${{ inputs.smoke_only == true }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-22.04, windows-latest]
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Disable git core.autocrlf on Windows
|
||||
if: runner.os == 'Windows'
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout Donut Browser
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
path: donutbrowser
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout test driver
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY }}
|
||||
token: ${{ secrets.TAURI_WEBDRIVER_TOKEN || github.token }}
|
||||
path: tauri-cross-platform-webdriver
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: donutbrowser/.node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: donutbrowser/pnpm-lock.yaml
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: |
|
||||
donutbrowser/src-tauri -> target
|
||||
donutbrowser/e2e/app -> target
|
||||
tauri-cross-platform-webdriver -> target
|
||||
|
||||
- name: Install Tauri dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev xvfb openvpn
|
||||
|
||||
- name: Install app dependencies
|
||||
working-directory: donutbrowser
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run isolated smoke suite
|
||||
working-directory: donutbrowser
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${RUNNER_OS}" == "Linux" ]]; then
|
||||
xvfb-run -a pnpm e2e:smoke
|
||||
else
|
||||
pnpm e2e:smoke
|
||||
fi
|
||||
|
||||
- name: Upload E2E diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: app-e2e-smoke-${{ matrix.os }}
|
||||
path: ${{ runner.temp }}/donut-e2e-*/diagnostics/**
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
full:
|
||||
name: Full UI, sync, encryption, and Wayfern
|
||||
if: ${{ inputs.smoke_only != true && inputs.suite != 'network' }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 40
|
||||
|
||||
steps:
|
||||
- name: Checkout Donut Browser
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
path: donutbrowser
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout test driver
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY }}
|
||||
token: ${{ secrets.TAURI_WEBDRIVER_TOKEN || github.token }}
|
||||
path: tauri-cross-platform-webdriver
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: donutbrowser/.node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: donutbrowser/pnpm-lock.yaml
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: |
|
||||
donutbrowser/src-tauri -> target
|
||||
donutbrowser/e2e/app -> target
|
||||
tauri-cross-platform-webdriver -> target
|
||||
|
||||
- name: Install app dependencies
|
||||
working-directory: donutbrowser
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run full isolated app suite
|
||||
working-directory: donutbrowser
|
||||
env:
|
||||
DONUT_E2E_SUITE: ${{ inputs.suite || 'full' }}
|
||||
DONUT_E2E_SKIP_NETWORK_TEST: "1"
|
||||
WAYFERN_TEST_TOKEN: ${{ secrets.WAYFERN_TEST_TOKEN }}
|
||||
run: node e2e/run.mjs "--suite=${DONUT_E2E_SUITE}"
|
||||
|
||||
- name: Upload E2E diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: app-e2e-full-macos
|
||||
path: ${{ runner.temp }}/donut-e2e-*/diagnostics/**
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
|
||||
network:
|
||||
name: Real proxy and local WireGuard browser traffic
|
||||
if: ${{ inputs.smoke_only != true && (inputs.suite == '' || inputs.suite == 'full' || inputs.suite == 'network') }}
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout Donut Browser
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
path: donutbrowser
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout test driver
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY }}
|
||||
token: ${{ secrets.TAURI_WEBDRIVER_TOKEN || github.token }}
|
||||
path: tauri-cross-platform-webdriver
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version-file: donutbrowser/.node-version
|
||||
cache: pnpm
|
||||
cache-dependency-path: donutbrowser/pnpm-lock.yaml
|
||||
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
|
||||
with:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||
with:
|
||||
workspaces: |
|
||||
donutbrowser/src-tauri -> target
|
||||
donutbrowser/e2e/app -> target
|
||||
tauri-cross-platform-webdriver -> target
|
||||
|
||||
- name: Install Tauri dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev xvfb openvpn
|
||||
|
||||
- name: Install app dependencies
|
||||
working-directory: donutbrowser
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run isolated real-network suite
|
||||
working-directory: donutbrowser
|
||||
env:
|
||||
WAYFERN_TEST_TOKEN: ${{ secrets.WAYFERN_TEST_TOKEN }}
|
||||
RESIDENTIAL_PROXY_URL_ONE_SOCKS: ${{ secrets.RESIDENTIAL_PROXY_URL_ONE_SOCKS }}
|
||||
RESIDENTIAL_PROXY_URL_ONE_HTTP: ${{ secrets.RESIDENTIAL_PROXY_URL_ONE_HTTP }}
|
||||
run: xvfb-run -a pnpm e2e:network
|
||||
|
||||
- name: Upload E2E diagnostics
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: app-e2e-network-linux
|
||||
path: ${{ runner.temp }}/donut-e2e-*/diagnostics/**
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -2,6 +2,12 @@ name: "CodeQL"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout_ref:
|
||||
description: Optional commit to check out instead of the triggering ref
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
@@ -31,7 +37,9 @@ jobs:
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout_ref }}
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
@@ -39,7 +47,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
||||
env:
|
||||
|
||||
@@ -30,7 +30,8 @@ jobs:
|
||||
name: Lint JavaScript/TypeScript
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -38,7 +39,8 @@ jobs:
|
||||
name: Lint Rust
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -46,7 +48,8 @@ jobs:
|
||||
name: CodeQL
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
@@ -57,7 +60,8 @@ jobs:
|
||||
name: Spell Check
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/spellcheck.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ on:
|
||||
description: "Docker tag (e.g., v1.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME:
|
||||
required: true
|
||||
DOCKERHUB_TOKEN:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -30,39 +35,45 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 #v4.1.0
|
||||
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c #v4.2.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee #v4.2.0
|
||||
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 #v4.5.1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Determine tags
|
||||
id: tags
|
||||
env:
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
COMMIT_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
TAGS=""
|
||||
INPUT_TAG="${{ inputs.tag }}"
|
||||
|
||||
if [ -n "$INPUT_TAG" ]; then
|
||||
# Called from release workflow or manual dispatch
|
||||
if [[ ! "$INPUT_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then
|
||||
echo "Invalid Docker tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${INPUT_TAG}"
|
||||
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||
elif [ "${{ github.event_name }}" = "push" ]; then
|
||||
elif [ "$EVENT_NAME" = "push" ]; then
|
||||
# Push to main (nightly): tag with nightly and commit SHA
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
SHORT_SHA=${COMMIT_SHA:0:7}
|
||||
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly"
|
||||
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${SHORT_SHA}"
|
||||
fi
|
||||
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
echo "Tags: ${TAGS}"
|
||||
printf 'tags=%s\n' "$TAGS" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf #v7.2.0
|
||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a #v7.3.0
|
||||
with:
|
||||
context: .
|
||||
file: ./donut-sync/Dockerfile
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #v31
|
||||
|
||||
@@ -22,15 +22,15 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Gather context
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
||||
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
|
||||
printf '%s' "$ISSUE_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/issue-title.txt
|
||||
printf '%s' "${ISSUE_BODY:-}" | node scripts/redact-sensitive-text.mjs --issue-body > /tmp/issue-body.txt
|
||||
|
||||
- name: Build prompt
|
||||
run: |
|
||||
@@ -92,11 +92,9 @@ jobs:
|
||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||
echo "::warning::Model returned non-JSON; treating as compliant"
|
||||
cat /tmp/raw.txt
|
||||
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
|
||||
fi
|
||||
echo "Result:"
|
||||
cat /tmp/result.json
|
||||
echo "Compliance response validated"
|
||||
|
||||
- name: Build comment
|
||||
id: build
|
||||
|
||||
@@ -14,7 +14,6 @@ permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
id-token: write
|
||||
|
||||
env:
|
||||
# Single source of truth for the model used by both triage and composer.
|
||||
@@ -27,7 +26,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -49,8 +48,9 @@ jobs:
|
||||
env:
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
node <<'EOF'
|
||||
const fs = require('node:fs');
|
||||
node --input-type=module <<'EOF'
|
||||
import fs from 'node:fs';
|
||||
import { redactIssueBody, redactSensitiveText } from './scripts/redact-sensitive-text.mjs';
|
||||
const body = process.env.ISSUE_BODY || '';
|
||||
// GitHub issue templates render fields as `### Heading\nValue` blocks.
|
||||
// Split on `###` at line start to recover them.
|
||||
@@ -61,29 +61,27 @@ jobs:
|
||||
if (nl < 0) continue;
|
||||
const heading = section.slice(0, nl).trim();
|
||||
const value = section.slice(nl + 1).trim();
|
||||
fields[heading] = value === '_No response_' ? '' : value;
|
||||
const normalized = value === '_No response_' ? '' : value;
|
||||
fields[heading] = heading === 'Error logs or screenshots'
|
||||
? '[omitted from automated processing]'
|
||||
: redactSensitiveText(normalized);
|
||||
}
|
||||
fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2));
|
||||
// Convenience extractions for the prompt — empty string if missing.
|
||||
const get = (k) => fields[k] || '';
|
||||
fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
|
||||
fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
|
||||
fs.writeFileSync('/tmp/issue-browser.txt', get('Which browser is affected?'));
|
||||
fs.writeFileSync('/tmp/issue-wayfern-version.txt', get('Wayfern version'));
|
||||
fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce'));
|
||||
fs.writeFileSync('/tmp/issue-logs.txt', get('Error logs or screenshots'));
|
||||
fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?'));
|
||||
fs.writeFileSync('/tmp/issue-body.txt', redactIssueBody(body));
|
||||
EOF
|
||||
echo "Parsed fields:"
|
||||
cat /tmp/issue-fields.json
|
||||
|
||||
- name: Build repo context
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
cp CLAUDE.md /tmp/repo-context.txt
|
||||
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
||||
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
|
||||
printf '%s' "$ISSUE_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/issue-title.txt
|
||||
|
||||
# List all source files for the AI to choose from
|
||||
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
|
||||
@@ -102,17 +100,7 @@ jobs:
|
||||
its API, MCP server, and the bundled `donut-sync` self-hosted server.
|
||||
- **Wayfern** — a Chromium fork maintained by zhom (the same maintainer). Wayfern
|
||||
bugs are in-scope here unless they are obviously upstream Chromium issues.
|
||||
- **Camoufox** — a Firefox fork by daijro, used by Donut but maintained in a
|
||||
separate repository. Bugs about Camoufox's *internal* behavior are outside
|
||||
the scope of this project.
|
||||
- Bugs about Camoufox's *internal* behavior (page rendering, JS engine,
|
||||
dropdowns, form widgets, fingerprinting *as Camoufox implements it*,
|
||||
checkbox/radio quirks) are out of scope here. Ask the user to first
|
||||
search https://github.com/daijro/camoufox/issues for a matching report,
|
||||
and if they don't find one, to open it there themselves.
|
||||
- Bugs about how Donut *launches, configures, or downloads* Camoufox are
|
||||
in-scope here.
|
||||
- **Forks of Wayfern or Camoufox** (e.g. CloverLabsAI, VulpineOS) are NOT
|
||||
- **Forks of Wayfern** (e.g. CloverLabsAI, VulpineOS) are NOT
|
||||
supported. Feature requests asking for them are out of scope.
|
||||
|
||||
# PAID vs FREE FEATURES
|
||||
@@ -121,7 +109,7 @@ jobs:
|
||||
|
||||
## Free (no account required)
|
||||
- Unlimited local profiles
|
||||
- Chromium (Wayfern) and Firefox (Camoufox) browser engines
|
||||
- Chromium (Wayfern) anti-detect browser engine
|
||||
- Proxy support (HTTP/SOCKS5)
|
||||
- VPN support (WireGuard)
|
||||
- Profile Management API & MCP (list / create / launch / kill / config)
|
||||
@@ -147,13 +135,8 @@ jobs:
|
||||
differently ("worked in 0.21", "went from 2 to 8 false positives"). Do NOT
|
||||
dismiss as "known issue" / "expected" / "false positive in Tauri apps". Ask
|
||||
which exact version was the last working one and what changed.
|
||||
- **Out-of-scope (upstream Camoufox)**: report is about Camoufox's own
|
||||
behavior. Tell the user it's outside the scope of this project and ask
|
||||
them to search the Camoufox repo and, if no matching issue exists, file
|
||||
one there. Do NOT say the maintainer doesn't contribute / can't fix it
|
||||
— keep it strictly about project scope. Do not collect logs.
|
||||
- **Fork-support request**: asks the maintainer to support an alternative
|
||||
Wayfern/Camoufox fork. Acknowledge in one neutral sentence — do NOT call it
|
||||
Wayfern fork. Acknowledge in one neutral sentence — do NOT call it
|
||||
"clear", "reasonable", "well-thought-out", etc.
|
||||
- **AI-generated / template-violating report**: report doesn't follow the
|
||||
template, may cite "official documentation" via context7, deepwiki, or any
|
||||
@@ -214,7 +197,7 @@ jobs:
|
||||
Return ONLY valid JSON. No preamble, no code fences. Schema:
|
||||
{
|
||||
"language": "en" or ISO 639-1 code,
|
||||
"classification": one of ["bug-in-scope", "bug-upstream-camoufox", "bug-template-violation", "feature-request", "fork-request", "regression", "ai-generated-junk", "question", "other"],
|
||||
"classification": one of ["bug-in-scope", "bug-template-violation", "feature-request", "fork-request", "regression", "automated-content", "question", "other"],
|
||||
"operating_system": "macos" | "windows" | "linux" | "unknown",
|
||||
"is_paid_feature": true | false,
|
||||
"user_followed_template": true | false,
|
||||
@@ -225,13 +208,12 @@ jobs:
|
||||
}
|
||||
|
||||
Classification guidance:
|
||||
- "bug-upstream-camoufox": Camoufox-internal behavior (rendering, dropdowns, JS, fingerprint impl). NOT how Donut launches it.
|
||||
- "bug-template-violation": missing or filled-in nonsense for required template fields.
|
||||
- "ai-generated-junk": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has the polished AI-spam shape (long, structured, fabricated certainty).
|
||||
- "automated-content": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has a highly structured automated-submission pattern with fabricated certainty.
|
||||
- "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
|
||||
- "regression": user names a prior version that worked.
|
||||
|
||||
File selection: pick files that an experienced reviewer would actually look at to act on this issue. If the issue is upstream-Camoufox, fork-request, or junk, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.
|
||||
File selection: pick files that an experienced reviewer would actually look at to act on this issue. For a fork request or automated-content classification, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.
|
||||
TRIAGE_TAIL
|
||||
} > /tmp/triage-system.txt
|
||||
wc -c /tmp/triage-system.txt
|
||||
@@ -254,7 +236,7 @@ jobs:
|
||||
messages: [
|
||||
{ role: "system", content: $system_prompt },
|
||||
{ role: "user",
|
||||
content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
|
||||
content: ("Issue title: " + $title + "\n\nSanitized body:\n" + $body + "\n\nSanitized template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
|
||||
]
|
||||
}')
|
||||
|
||||
@@ -265,14 +247,12 @@ jobs:
|
||||
|
||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt
|
||||
|
||||
# Strip ```json fences if the model couldn't help itself.
|
||||
# Normalize optional markdown fences before parsing.
|
||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json
|
||||
|
||||
# Validate; if the model returned junk, fall back to a minimal stub so the
|
||||
# composer still gets called and produces SOMETHING.
|
||||
# Fall back to a safe classification when the response is not JSON.
|
||||
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
|
||||
echo "::warning::Triage returned non-JSON; using fallback classification"
|
||||
cat /tmp/triage-raw.txt
|
||||
jq -n '{
|
||||
language: "en",
|
||||
classification: "bug-in-scope",
|
||||
@@ -286,17 +266,16 @@ jobs:
|
||||
}' > /tmp/triage.json
|
||||
fi
|
||||
|
||||
echo "Triage result:"
|
||||
cat /tmp/triage.json
|
||||
echo "Triage response validated"
|
||||
|
||||
- name: Read files chosen by triage
|
||||
run: |
|
||||
: > /tmp/file-context.txt
|
||||
# files_to_read may be empty (e.g. upstream Camoufox) — that's fine.
|
||||
# An empty file list is valid for classifications that need no source context.
|
||||
jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do
|
||||
filepath=$(echo "$filepath" | xargs)
|
||||
[ -z "$filepath" ] && continue
|
||||
# Reject paths that escape the repo or look fishy
|
||||
# Reject paths that escape the repository.
|
||||
case "$filepath" in
|
||||
/*|*..*|*$'\n'*) continue ;;
|
||||
esac
|
||||
@@ -329,7 +308,7 @@ jobs:
|
||||
|
||||
## Output shape
|
||||
- One sentence acknowledging the report.
|
||||
- Then **Missing information** — only if there is anything actually missing. Skip this section if the user already provided OS, version, browser, repro steps, and any logs the situation calls for.
|
||||
- Then **Missing information** — only if there is anything actually missing. Skip this section if the user already provided OS, Donut Browser version, Wayfern version, repro steps, and any logs the situation calls for.
|
||||
- Maximum 15 lines.
|
||||
- No labels, no `Label:` line, no markdown headings other than `**Missing information**`.
|
||||
- No closing pleasantries ("please let me know", "happy to help", etc.).
|
||||
@@ -347,8 +326,7 @@ jobs:
|
||||
The triage classification (`triage.classification`) determines the response shape:
|
||||
|
||||
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
|
||||
- `bug-upstream-camoufox`: redirect ONLY. One sentence acknowledging, then say this is outside the scope of this project — ask the user to first search https://github.com/daijro/camoufox/issues for a matching report and, if none exists, to open one there themselves. Do NOT phrase it as "the maintainer does not contribute" or anything personal — keep it strictly about scope. Do NOT ask for Donut logs. Stop after that.
|
||||
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Which browser, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
|
||||
- `bug-template-violation` or `automated-content`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Wayfern version, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
|
||||
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
|
||||
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
|
||||
- `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user's environment between then and now, and the specific delta in symptoms.
|
||||
@@ -425,8 +403,8 @@ jobs:
|
||||
+ "Title: " + $title
|
||||
+ "\nAuthor: " + $author
|
||||
+ "\n\n## Triage result\n" + $triage
|
||||
+ "\n\n## Parsed template fields\n" + $fields
|
||||
+ "\n\n## Raw issue body\n" + $body
|
||||
+ "\n\n## Sanitized template fields\n" + $fields
|
||||
+ "\n\n## Sanitized issue body\n" + $body
|
||||
+ "\n\n## Source files (selected by triage)\n" + $files) }
|
||||
]
|
||||
}')
|
||||
@@ -440,8 +418,6 @@ jobs:
|
||||
|
||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||
echo "::error::Composer returned empty response"
|
||||
echo "Raw response:"
|
||||
echo "$RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -479,7 +455,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -501,8 +477,9 @@ jobs:
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
run: |
|
||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
|
||||
gh api --paginate "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \
|
||||
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
|
||||
> /tmp/pr-files.txt
|
||||
|
||||
@@ -516,14 +493,27 @@ jobs:
|
||||
cp CLAUDE.md /tmp/repo-context.txt
|
||||
|
||||
: > /tmp/related-file-contents.txt
|
||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
|
||||
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
|
||||
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
|
||||
cat "$filepath" >> /tmp/related-file-contents.txt
|
||||
echo "" >> /tmp/related-file-contents.txt
|
||||
gh api --paginate "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \
|
||||
--jq '.[] | select(.status != "removed") | [.filename, .sha] | @tsv' |
|
||||
while IFS=$'\t' read -r filepath blob_sha; do
|
||||
case "$filepath" in
|
||||
/*|*..*|*$'\n'*) continue ;;
|
||||
esac
|
||||
blob_file=$(mktemp)
|
||||
if gh api "/repos/$HEAD_REPOSITORY/git/blobs/$blob_sha" --jq .content \
|
||||
| tr -d '\n' | base64 --decode > "$blob_file" 2>/dev/null \
|
||||
&& file --mime "$blob_file" | grep -q "text/"; then
|
||||
echo "=== $filepath (head revision) ===" >> /tmp/related-file-contents.txt
|
||||
cat "$blob_file" >> /tmp/related-file-contents.txt
|
||||
echo "" >> /tmp/related-file-contents.txt
|
||||
fi
|
||||
rm -f "$blob_file"
|
||||
done
|
||||
head -c 100000 /tmp/related-file-contents.txt > /tmp/pr-file-context.txt
|
||||
node scripts/redact-sensitive-text.mjs < /tmp/pr-diff.txt > /tmp/pr-diff.safe.txt
|
||||
mv /tmp/pr-diff.safe.txt /tmp/pr-diff.txt
|
||||
node scripts/redact-sensitive-text.mjs < /tmp/pr-file-context.txt > /tmp/pr-file-context.safe.txt
|
||||
mv /tmp/pr-file-context.safe.txt /tmp/pr-file-context.txt
|
||||
|
||||
- name: Analyze PR with AI
|
||||
env:
|
||||
@@ -542,8 +532,8 @@ jobs:
|
||||
GREETING='This is a first-time contributor. Start your comment with: "Thanks for your first PR!"'
|
||||
fi
|
||||
|
||||
printf '%s' "$PR_TITLE" > /tmp/pr-title.txt
|
||||
printf '%s' "${PR_BODY:-}" > /tmp/pr-body.txt
|
||||
printf '%s' "$PR_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/pr-title.txt
|
||||
printf '%s' "${PR_BODY:-}" | node scripts/redact-sensitive-text.mjs > /tmp/pr-body.txt
|
||||
printf '%s' "$PR_AUTHOR" > /tmp/pr-author.txt
|
||||
printf '%s' "$PR_BASE" > /tmp/pr-base.txt
|
||||
printf '%s' "$PR_HEAD" > /tmp/pr-head.txt
|
||||
@@ -567,7 +557,7 @@ jobs:
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: ("You are a code review bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nContributing guidelines:\n" + $contributing + "\n\nYou have access to the full changed files and the diff. Use them to give a substantive review.\n\nReview this PR and produce a single comment. Format:\n\n1. One sentence summarizing what this PR does and whether the approach is sound.\n2. **Code review** - Specific observations about the actual code changes. Mention file names and what you see in the diff. Look for:\n - Bugs or logic errors in the changed code\n - Security issues (SQL injection, path traversal, XSS, command injection)\n - Missing error handling or edge cases\n - Breaking changes to existing APIs or behavior\n - If UI text was added/changed, check if all 7 translation files (en, es, fr, ja, pt, ru, zh) in src/i18n/locales/ were updated\n - If Tauri commands were added/removed, the unused-commands test in lib.rs needs updating\n3. **Suggestions** - Concrete improvements if any. Skip if the PR looks good.\n\nRules:\n- Be substantive. Review the actual diff, not just the description.\n- Do NOT nitpick formatting or style — the project has automated linting (biome + clippy + rustfmt).\n- Do NOT just summarize the PR description back to the user — they wrote it, they know what it says.\n- If the PR is good, say so briefly.\n- Never exceed 20 lines.")
|
||||
content: ("You are a code review bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nContributing guidelines:\n" + $contributing + "\n\nYou have access to sanitized head-revision contents for changed files and a sanitized diff. Use them to give a substantive review.\n\nReview this PR and produce a single comment. Format:\n\n1. One sentence summarizing what this PR does and whether the approach is sound.\n2. **Code review** - Specific observations about the actual code changes. Mention file names and what you see in the diff. Look for:\n - Bugs or logic errors in the changed code\n - Security issues (SQL injection, path traversal, XSS, command injection)\n - Missing error handling or edge cases\n - Breaking changes to existing APIs or behavior\n - If UI text was added or changed, verify the key exists in every JSON file under src/i18n/locales/\n - If Tauri commands were added or removed, verify e2e/coverage-map.mjs is updated exactly once per command\n3. **Suggestions** - Concrete improvements if any. Skip if the PR looks good.\n\nRules:\n- Be substantive. Review the actual diff, not just the description.\n- Do NOT nitpick formatting or style — the project has automated linting (biome + clippy + rustfmt).\n- Do NOT just summarize the PR description back to the user — they wrote it, they know what it says.\n- If the PR is good, say so briefly.\n- Never exceed 20 lines.")
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
@@ -594,8 +584,6 @@ jobs:
|
||||
|
||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||
echo "::error::AI response was empty"
|
||||
echo "Raw response:"
|
||||
echo "$RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -610,6 +598,9 @@ jobs:
|
||||
if: |
|
||||
github.repository == 'zhom/donutbrowser' &&
|
||||
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
(github.event.comment.author_association == 'OWNER' ||
|
||||
github.event.comment.author_association == 'MEMBER' ||
|
||||
github.event.comment.author_association == 'COLLABORATOR') &&
|
||||
(contains(github.event.comment.body, ' /oc') ||
|
||||
startsWith(github.event.comment.body, '/oc') ||
|
||||
contains(github.event.comment.body, ' /opencode') ||
|
||||
@@ -617,10 +608,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@11e47f91496005aab4d7c5a2d0a7da5d2651b4ac #v1.17.8
|
||||
uses: anomalyco/opencode/github@e5cc278dec9294a627a7b05f47ce6a564408c1a2 #v1.18.5
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -4,6 +4,12 @@ name: Lint Node.js
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout_ref:
|
||||
description: Optional commit to check out instead of the triggering ref
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -34,7 +40,9 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout_ref }}
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
@@ -42,7 +50,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -4,6 +4,12 @@ name: Lint Rust
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout_ref:
|
||||
description: Optional commit to check out instead of the triggering ref
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -41,7 +47,9 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout_ref }}
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
@@ -49,7 +57,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
github.event.workflow_run.conclusion == 'success')
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
@@ -15,14 +15,12 @@ jobs:
|
||||
lint-js:
|
||||
name: Lint JavaScript/TypeScript
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
lint-rust:
|
||||
name: Lint Rust
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -41,19 +39,27 @@ jobs:
|
||||
sync-e2e:
|
||||
name: Sync E2E Tests
|
||||
uses: ./.github/workflows/sync-e2e.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
app-e2e:
|
||||
name: Cross-platform App E2E
|
||||
uses: ./.github/workflows/app-e2e.yml
|
||||
secrets:
|
||||
TAURI_WEBDRIVER_TOKEN: ${{ secrets.TAURI_WEBDRIVER_TOKEN }}
|
||||
WAYFERN_TEST_TOKEN: ${{ secrets.WAYFERN_TEST_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
pr-status:
|
||||
name: PR Status Check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-js, lint-rust, security-scan, sync-e2e]
|
||||
needs: [lint-js, lint-rust, security-scan, sync-e2e, app-e2e]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check all jobs succeeded
|
||||
run: |
|
||||
if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" ]]; then
|
||||
if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" || "${{ needs.app-e2e.result }}" != "success" ]]; then
|
||||
echo "One or more checks failed"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -24,24 +24,31 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Determine release tag
|
||||
id: tag
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
INPUT_TAG: ${{ inputs.tag }}
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||
REPOSITORY: ${{ github.repository }}
|
||||
run: |
|
||||
if [[ -n "${INPUT_TAG:-}" ]]; then
|
||||
echo "tag=${INPUT_TAG}" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
||||
TAG="$INPUT_TAG"
|
||||
elif [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
||||
# The Release workflow is triggered by a tag push (v*),
|
||||
# so head_branch is the tag name
|
||||
echo "tag=${{ github.event.workflow_run.head_branch }}" >> "$GITHUB_OUTPUT"
|
||||
TAG="$WORKFLOW_HEAD_BRANCH"
|
||||
else
|
||||
TAG=$(gh release view --repo "${{ github.repository }}" --json tagName -q .tagName)
|
||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
||||
TAG=$(gh release view --repo "$REPOSITORY" --json tagName -q .tagName)
|
||||
fi
|
||||
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
echo "Invalid release tag" >&2
|
||||
exit 1
|
||||
fi
|
||||
printf 'tag=%s\n' "$TAG" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Install tools
|
||||
run: |
|
||||
@@ -59,19 +66,12 @@ jobs:
|
||||
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
|
||||
run: |
|
||||
# GitHub injects secrets verbatim. If a value was pasted with
|
||||
# surrounding quotes or a trailing newline — the local .env wraps all
|
||||
# four R2_* values in double quotes — it reaches the script malformed:
|
||||
# e.g. an endpoint of https://"host" yields
|
||||
# `Could not connect to the endpoint URL`, and a quoted key yields
|
||||
# `Unauthorized`. The local run is unaffected because publish-repo.sh
|
||||
# sources .env through bash, which strips the quotes; CI has no .env,
|
||||
# so strip here. No-op when the secrets are already clean. The script
|
||||
# itself is intentionally left untouched.
|
||||
# Normalize accidental quotes and whitespace in configured secrets.
|
||||
strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; }
|
||||
export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
|
||||
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
|
||||
export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
|
||||
export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
|
||||
bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
|
||||
bash scripts/publish-repo.sh "$RELEASE_TAG"
|
||||
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
@@ -37,7 +37,6 @@ jobs:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Lint JavaScript/TypeScript
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -45,7 +44,6 @@ jobs:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Lint Rust
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -53,7 +51,6 @@ jobs:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: CodeQL
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
@@ -64,7 +61,6 @@ jobs:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Spell Check
|
||||
uses: ./.github/workflows/spellcheck.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -105,7 +101,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
@@ -113,7 +109,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
@@ -160,6 +156,8 @@ jobs:
|
||||
- name: Build sidecar binaries
|
||||
shell: bash
|
||||
working-directory: ./src-tauri
|
||||
env:
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
run: |
|
||||
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
|
||||
|
||||
@@ -208,7 +206,7 @@ jobs:
|
||||
rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 #v0.6.2
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f #v1.0.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
@@ -216,6 +214,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
# tauri-action invokes `pnpm tauri build`, which runs
|
||||
# `beforeBuildCommand` from tauri.conf.json. That rebuilds the
|
||||
# frontend in its own subprocess, so the env var MUST be forwarded
|
||||
@@ -280,6 +279,29 @@ jobs:
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||
rm -f $RUNNER_TEMP/build_certificate.p12 || true
|
||||
|
||||
# Runs after every matrix leg (including the portable ZIP upload) so the
|
||||
# sums cover the complete, final asset set. The app self-updater refuses to
|
||||
# install a release it cannot verify against this file.
|
||||
checksums:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Generate and upload SHA256SUMS.txt
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
ASSETS_DIR="/tmp/release-assets"
|
||||
mkdir -p "$ASSETS_DIR"
|
||||
gh release download "$TAG" --repo "$GITHUB_REPOSITORY" --dir "$ASSETS_DIR"
|
||||
cd "$ASSETS_DIR"
|
||||
sha256sum Donut* > SHA256SUMS.txt
|
||||
cat SHA256SUMS.txt
|
||||
gh release upload "$TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
changelog:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
@@ -288,7 +310,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -454,7 +476,7 @@ jobs:
|
||||
needs: [release, changelog]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
@@ -534,7 +556,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Cloudflare Pages deployment
|
||||
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
|
||||
env:
|
||||
DEPLOYMENT_HOOK: ${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}
|
||||
run: curl -fsSL -X POST "$DEPLOYMENT_HOOK"
|
||||
|
||||
docker:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
@@ -542,7 +566,9 @@ jobs:
|
||||
uses: ./.github/workflows/docker-sync.yml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
secrets: inherit
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
update-flake:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
@@ -552,7 +578,7 @@ jobs:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
|
||||
|
||||
@@ -5,6 +5,14 @@ on:
|
||||
branches:
|
||||
- main
|
||||
|
||||
# Serialize runs: the rolling `nightly` release is deleted and recreated at the
|
||||
# end of each run, and overlapping runs could interleave those steps (or leave
|
||||
# a checksums file describing another run's assets). Queue instead of cancel so
|
||||
# an in-flight delete/create is never aborted halfway.
|
||||
concurrency:
|
||||
group: rolling-release
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
security-events: write
|
||||
@@ -36,7 +44,6 @@ jobs:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Lint JavaScript/TypeScript
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -44,7 +51,6 @@ jobs:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Lint Rust
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -52,7 +58,6 @@ jobs:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: CodeQL
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
@@ -63,7 +68,6 @@ jobs:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Spell Check
|
||||
uses: ./.github/workflows/spellcheck.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -104,7 +108,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
@@ -112,7 +116,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
@@ -156,9 +160,25 @@ jobs:
|
||||
echo "Checking from src-tauri perspective:"
|
||||
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"
|
||||
|
||||
- name: Generate nightly timestamp
|
||||
id: timestamp
|
||||
shell: bash
|
||||
run: |
|
||||
# Committer date, not wall clock: every job in this run (including
|
||||
# update-nightly-release, which runs much later) must derive the
|
||||
# exact same tag, or a run straddling midnight UTC splits the
|
||||
# release from its checksums.
|
||||
TIMESTAMP=$(git show -s --format=%cs HEAD)
|
||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
||||
|
||||
- name: Build sidecar binaries
|
||||
shell: bash
|
||||
working-directory: ./src-tauri
|
||||
env:
|
||||
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
run: |
|
||||
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
|
||||
|
||||
@@ -206,17 +226,8 @@ jobs:
|
||||
|
||||
rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH
|
||||
|
||||
- name: Generate nightly timestamp
|
||||
id: timestamp
|
||||
shell: bash
|
||||
run: |
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d")
|
||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 #v0.6.2
|
||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f #v1.0.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
@@ -226,6 +237,7 @@ jobs:
|
||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||
TARGET: ${{ matrix.target }}
|
||||
# tauri-action's inner `pnpm tauri build` re-runs beforeBuildCommand
|
||||
# which rebuilds dist/ in a subprocess. The env var must be here too.
|
||||
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
|
||||
@@ -284,12 +296,14 @@ jobs:
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Generate nightly tag
|
||||
id: tag
|
||||
run: |
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d")
|
||||
# Committer date — must match the tag the build matrix computed (see
|
||||
# the timestamp step there), even when this job runs past midnight.
|
||||
TIMESTAMP=$(git show -s --format=%cs HEAD)
|
||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||
|
||||
@@ -355,8 +369,16 @@ jobs:
|
||||
mkdir -p "$ASSETS_DIR"
|
||||
gh release download "$NIGHTLY_TAG" --dir "$ASSETS_DIR" --clobber
|
||||
|
||||
# Rename versioned filenames to stable nightly names
|
||||
# Checksums for the per-commit release (original filenames). The app
|
||||
# self-updater downloads from per-commit nightly releases and refuses
|
||||
# to install anything it cannot verify against this file.
|
||||
# --repo is required: ASSETS_DIR is outside the git checkout, so gh
|
||||
# cannot infer the repository from the working directory.
|
||||
cd "$ASSETS_DIR"
|
||||
sha256sum Donut* > SHA256SUMS.txt
|
||||
gh release upload "$NIGHTLY_TAG" SHA256SUMS.txt --clobber --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# Rename versioned filenames to stable nightly names
|
||||
for f in Donut_*_aarch64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.dmg; done
|
||||
for f in Donut_*_x64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_x64.dmg; done
|
||||
for f in Donut_*_x64-setup.exe; do [ -f "$f" ] && mv "$f" Donut_nightly_x64-setup.exe; done
|
||||
@@ -366,6 +388,12 @@ jobs:
|
||||
for f in Donut_*_arm64.deb; do [ -f "$f" ] && mv "$f" Donut_nightly_arm64.deb; done
|
||||
for f in Donut-*.x86_64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_x86_64.rpm; done
|
||||
for f in Donut-*.aarch64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.rpm; done
|
||||
for f in Donut_*_aarch64.app.tar.gz; do [ -f "$f" ] && mv "$f" Donut_aarch64.app.tar.gz; done
|
||||
for f in Donut_*_x64.app.tar.gz; do [ -f "$f" ] && mv "$f" Donut_x64.app.tar.gz; done
|
||||
|
||||
# Checksums for the rolling release (renamed filenames), restricted
|
||||
# to exactly the assets uploaded below.
|
||||
sha256sum Donut_nightly_* Donut_aarch64.app.tar.gz Donut_x64.app.tar.gz > SHA256SUMS.txt
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
|
||||
# Delete existing rolling nightly release and tag
|
||||
@@ -377,6 +405,7 @@ jobs:
|
||||
"$ASSETS_DIR"/Donut_nightly_* \
|
||||
"$ASSETS_DIR"/Donut_aarch64.app.tar.gz \
|
||||
"$ASSETS_DIR"/Donut_x64.app.tar.gz \
|
||||
"$ASSETS_DIR"/SHA256SUMS.txt \
|
||||
--title "Donut Browser Nightly" \
|
||||
--notes-file /tmp/nightly-notes.md \
|
||||
--prerelease
|
||||
@@ -387,7 +416,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Cloudflare Pages deployment
|
||||
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
|
||||
env:
|
||||
DEPLOYMENT_HOOK: ${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}
|
||||
run: curl -fsSL -X POST "$DEPLOYMENT_HOOK"
|
||||
|
||||
notify-discord:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
|
||||
@@ -5,6 +5,12 @@ permissions:
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout_ref:
|
||||
description: Optional commit to check out instead of the triggering ref
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
@@ -21,6 +27,8 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.checkout_ref }}
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@37bb98842b0d8c4ffebdb75301a13db0267cef89 #v1.47.2
|
||||
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 #v1.48.0
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
|
||||
|
||||
@@ -32,7 +32,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/e2e/app/target/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
Vendored
-1
@@ -21,7 +21,6 @@
|
||||
"Buildx",
|
||||
"busctl",
|
||||
"CAMOU",
|
||||
"camoufox",
|
||||
"catppuccin",
|
||||
"cdylib",
|
||||
"certifi",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ⛔ ABSOLUTE GIT RULE — READ FIRST (2026-06-11)
|
||||
|
||||
**NEVER run any git command that modifies git history OR the working tree, in ANY repo** (wayfern, wayfern-macos, wayfern-test, donutbrowser, build/src), **unless the user EXPLICITLY authorizes that exact command.** Forbidden without per-command authorization: `commit`, `revert`, `cherry-pick`, `restore`, `checkout` (files/branches), `reset`, `rebase`, `merge`, `stash`, `clean`, `apply`, `add`, `rm`, `push`, any force op. Only read-only git (`status`, `log`, `show`, `diff`, `ls-files`, `rev-parse`) is allowed without asking. **Authorization is per-command: 1 explicit authorization = exactly 1 command.** If a git mutation seems needed, STOP and ask for that one command.
|
||||
**NEVER run any git command that modifies git history OR the working tree, in ANY repo**, **unless the user EXPLICITLY authorizes that exact command.** Forbidden without per-command authorization: `commit`, `revert`, `cherry-pick`, `restore`, `checkout` (files/branches), `reset`, `rebase`, `merge`, `stash`, `clean`, `apply`, `add`, `rm`, `push`, any force op. Only read-only git (`status`, `log`, `show`, `diff`, `ls-files`, `rev-parse`) is allowed without asking. **Authorization is per-command: 1 explicit authorization = exactly 1 command.** If a git mutation seems needed, STOP and ask for that one command.
|
||||
|
||||
---
|
||||
|
||||
@@ -17,7 +17,7 @@ donutbrowser/
|
||||
│ ├── app/ # App router (page.tsx, layout.tsx)
|
||||
│ ├── components/ # 50+ React components (dialogs, tables, UI)
|
||||
│ ├── hooks/ # Event-driven React hooks
|
||||
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, vi, zh)
|
||||
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, tr, vi, zh)
|
||||
│ ├── lib/ # Utilities (themes, toast, browser-utils)
|
||||
│ └── types.ts # Shared TypeScript interfaces
|
||||
├── src-tauri/ # Rust backend (Tauri)
|
||||
@@ -38,6 +38,10 @@ donutbrowser/
|
||||
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
||||
│ │ ├── settings_manager.rs # App settings persistence
|
||||
│ │ ├── cookie_manager.rs # Cookie import/export
|
||||
│ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch)
|
||||
│ │ ├── fingerprint_consistency.rs # Launch-time proxy exit vs fingerprint timezone/language check
|
||||
│ │ ├── dns_blocklist.rs # Hagezi DNS blocklists + user custom lists/allowlist
|
||||
│ │ ├── traffic_stats.rs # Per-profile traffic stats + secure history erase
|
||||
│ │ ├── extension_manager.rs # Browser extension management
|
||||
│ │ ├── group_manager.rs # Profile group management
|
||||
│ │ ├── synchronizer.rs # Real-time profile synchronizer
|
||||
@@ -47,7 +51,10 @@ donutbrowser/
|
||||
│ └── Cargo.toml # Rust dependencies
|
||||
├── donut-sync/ # NestJS sync server (self-hostable)
|
||||
│ └── src/ # Controllers, services, auth, S3 sync
|
||||
├── docs/ # Documentation (self-hosting guide)
|
||||
├── e2e/ # Isolated native UI/sync/Wayfern E2E system
|
||||
│ ├── app/ # Test-only Tauri harness that injects the private driver
|
||||
│ ├── lib/ # WebDriver, CDP, fixtures, app-session helpers
|
||||
│ └── tests/ # Smoke, UI, entity, integration, sync, browser suites
|
||||
├── flake.nix # Nix development environment
|
||||
└── .github/workflows/ # CI/CD pipelines
|
||||
```
|
||||
@@ -60,6 +67,37 @@ donutbrowser/
|
||||
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
|
||||
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
|
||||
|
||||
### Native app E2E tests are mandatory for affected behavior
|
||||
|
||||
The native suites use a sibling private test-driver repository and launch an `e2e`-feature build.
|
||||
Every session gets its own temporary Donut data/cache/log root, home directory,
|
||||
WebView store, ports, and sync bucket. Never point a suite at production or development data.
|
||||
|
||||
After a behavior change, run the smallest affected subset below in addition to the standard
|
||||
format/lint/unit-test command. A code change is not considered verified until its affected native
|
||||
suite passes:
|
||||
|
||||
| Changed area | Required command |
|
||||
| --- | --- |
|
||||
| Startup, settings, persistence, window state, shortcuts, navigation | `pnpm e2e:smoke` |
|
||||
| React components, dialogs, themes/appearance, responsive layout, accessibility, onboarding | `pnpm e2e:ui` |
|
||||
| Profile/import/group/proxy/VPN/extension CRUD, DNS, cookies, passwords, traffic | `pnpm e2e:entities` |
|
||||
| Profile/group/proxy/VPN/extension UI, proxy routing, VPN routing, or their browser-launch integration | `pnpm e2e:network` |
|
||||
| REST API/OpenAPI, MCP, cloud/update contracts, team locks, real-time synchronizer | `pnpm e2e:integrations` |
|
||||
| Sync client/server, manifests, timestamps, deletion, encryption, password rollover | `pnpm e2e:sync` |
|
||||
| Wayfern download/terms/fingerprint, browser runner, CDP, automation endpoints, process cleanup | `pnpm e2e:browser` |
|
||||
| E2E harness, WebDriver plugin/driver, app isolation hooks, or changes spanning multiple rows | Run every affected row; use `pnpm e2e` for cross-cutting changes |
|
||||
|
||||
`e2e:browser` requires `WAYFERN_TEST_TOKEN` in the environment or local `.env`. `e2e:network`
|
||||
and the full suite additionally require Docker plus `RESIDENTIAL_PROXY_URL_ONE_HTTP` and
|
||||
`RESIDENTIAL_PROXY_URL_ONE_SOCKS`. Other individual suites must run without credentials. Use
|
||||
`--no-build` only when the frontend, Rust app, sidecar, and WebDriver binaries are already current.
|
||||
Keep failed artifacts and inspect the per-session app/driver logs and screenshot before changing
|
||||
assertions.
|
||||
|
||||
When adding a Tauri command, assign it exactly once in `e2e/coverage-map.mjs` and add executable
|
||||
evidence to the owning suite. `e2e:smoke` fails if command registration and the coverage map drift.
|
||||
|
||||
## Logs (when debugging a running app)
|
||||
|
||||
Three log surfaces, in order of usefulness:
|
||||
@@ -79,12 +117,12 @@ Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropria
|
||||
|
||||
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
|
||||
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
|
||||
- Adding a new string means adding the key to ALL nine locale files in `src/i18n/locales/` (en, es, fr, ja, ko, pt, ru, vi, zh) — not just `en.json`. The English version alone is incomplete work.
|
||||
- Adding a new string means adding the key to EVERY locale file in `src/i18n/locales/` — currently en, es, fr, ja, ko, pt, ru, tr, vi, zh — not just `en.json`. The English version alone is incomplete work. Don't trust this list: enumerate `src/i18n/locales/*.json` and update every file you find, because a newly added locale is exactly what a hardcoded list silently skips.
|
||||
- Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first.
|
||||
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
|
||||
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
|
||||
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
|
||||
- When adding or removing keys across all nine locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Nine sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
|
||||
- When adding or removing keys across the locales, use a one-shot Python script in the scratchpad dir that globs `src/i18n/locales/*.json`, mutates each, and writes it back. Sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away. Finish by diffing every locale's flattened key set against `en.json` — zero missing and zero extra, for all of them.
|
||||
|
||||
## Backend error codes (mandatory)
|
||||
|
||||
@@ -98,10 +136,34 @@ User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BA
|
||||
```
|
||||
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
|
||||
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
|
||||
4. Add `backendErrors.fooBar` to all nine locale files.
|
||||
4. Add `backendErrors.fooBar` to every locale file in `src/i18n/locales/`.
|
||||
|
||||
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
|
||||
|
||||
## REST API (`src-tauri/src/api_server.rs`) — endpoints must stay in the OpenAPI spec
|
||||
|
||||
The served `/openapi.json` comes from the hand-maintained `ApiDoc` derive (`#[derive(OpenApi)]` with `paths(...)`, `components(schemas(...))`, `tags(...)`) — NOT from the router. The `OpenApiRouter`-generated spec is discarded (`let (v1_routes, _) = ...`), so a handler registered on the router but missing from `ApiDoc` silently disappears from the spec (this happened to the extension and VPN-export endpoints once).
|
||||
|
||||
**Any endpoint modification — adding, removing, or changing a route, request/response schema, or status code — must be reflected in the OpenAPI spec in the same change:**
|
||||
|
||||
1. Keep the handler's `#[utoipa::path]` annotation accurate (path, request body, every reachable response status).
|
||||
2. Add/remove the handler in `ApiDoc`'s `paths(...)` list and any new schema types in `components(schemas(...))`.
|
||||
3. Extend the `openapi_*` regression tests in `api_server.rs::tests` (they assert spec coverage and that optional fields stay optional).
|
||||
4. `#[schema(value_type = Object)]` on an `Option<T>` field erases the optionality and wrongly marks it required — use `value_type = Option<Object>` (or drop the attribute for natively supported types).
|
||||
|
||||
### Error status conventions (known errors)
|
||||
|
||||
Handlers route manager errors through `manager_error_response`, which maps message content onto a consistent status and passes the text through as the response body:
|
||||
|
||||
- `401` — missing/invalid bearer token (auth middleware; empty body).
|
||||
- `402` — the five automation endpoints (`run`, `open-url`, `kill`, `batch/run`, `batch/stop`) without a paid plan, and expired-proxy (`PROXY_PAYMENT_REQUIRED`) checks.
|
||||
- `404` — entity not found (`… not found` / `*_NOT_FOUND`).
|
||||
- `400` — validation, duplicates, empty names, invalid/unsupported/unavailable input.
|
||||
- `409` — conflicts: browser version already being downloaded, profile locked by another team member (run), browser running during cookie import.
|
||||
- `500` — internal failures (IO, network, poisoned locks).
|
||||
|
||||
Error bodies are plain-text diagnostics; some are the JSON `{"code": ...}` strings shared with the Tauri commands (e.g. `NAME_CANNOT_BE_EMPTY`, `GROUP_ALREADY_EXISTS`). The translated-error rule above applies to Tauri commands, not to REST bodies.
|
||||
|
||||
## Sub-page Dialog mode
|
||||
|
||||
A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs:
|
||||
@@ -151,7 +213,7 @@ Reference implementations: `proxy-management-dialog.tsx`, `extension-management-
|
||||
|
||||
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
|
||||
|
||||
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all nine locales.
|
||||
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in every locale.
|
||||
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
|
||||
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
|
||||
- `matchesGroupDigit(event)` returns 1–9 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
|
||||
@@ -161,7 +223,7 @@ Dispatch: the global `keydown` listener and the `runShortcut` callback both live
|
||||
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
|
||||
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
|
||||
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
|
||||
4. Add `shortcuts.yourId` (label) to all nine locale files.
|
||||
4. Add `shortcuts.yourId` (label) to every locale file in `src/i18n/locales/`.
|
||||
|
||||
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
|
||||
|
||||
@@ -198,6 +260,8 @@ The command palette (Mod+K) is built on the shadcn `Command` primitive with a to
|
||||
|
||||
Debug builds (`cargo build`, `pnpm tauri dev`) write to the `DonutBrowserDev` sibling at the same root, and a `dev-{version}` `BUILD_VERSION` is injected via `build.rs`. Logs / screenshots referencing `DonutBrowserDev` therefore mean a local dev build is in play, not a release; useful when a bug report seems to disagree with what production users see.
|
||||
|
||||
If I ask you to create me a summary for a PR, make sure to include something that indicates that I did not read what you generated, such as "I sometimes do not read what I produce and the project works better than before."
|
||||
|
||||
## Publishing Linux Repositories
|
||||
|
||||
The `scripts/publish-repo.sh` script publishes DEB and RPM packages to Cloudflare R2 (served at `repo.donutbrowser.com`). It requires Linux tools, so run it in Docker on macOS:
|
||||
|
||||
+124
@@ -1,6 +1,130 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.28.2 (2026-07-12)
|
||||
|
||||
### Features
|
||||
|
||||
- sha256 checksum for self-updates
|
||||
- progress bar for extraction
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- properly handle location spoofing for socks5 proxies
|
||||
|
||||
### Refactoring
|
||||
|
||||
- api cleanup
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group with 2 updates
|
||||
- chore: update flake.nix for v0.28.1 [skip ci] (#493)
|
||||
|
||||
|
||||
## v0.28.1 (2026-07-09)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- do not use system proxy on windows
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.28.0 [skip ci] (#490)
|
||||
|
||||
|
||||
## v0.28.0 (2026-07-08)
|
||||
|
||||
### Features
|
||||
|
||||
- ipv6 support for wireguard
|
||||
- per-profile window color with id-derived default
|
||||
- emit extension sync-status events
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- background status/update loop and window-color command
|
||||
- sync engine correctness and manifest traversal guard
|
||||
- replace create-profile Back button with Close
|
||||
- don't start window drag on interactive controls
|
||||
- self-reap proxy worker off-runtime and redact upstream creds in logs
|
||||
- resolve VPN SOCKS5 domain CONNECT requests through the tunnel
|
||||
- persist imported session cookies so logins survive relaunch
|
||||
|
||||
### Refactoring
|
||||
|
||||
- handle newer wayfern versions
|
||||
- fully deprecate camoufox
|
||||
- cleanup
|
||||
- better handling of unstable connection during asset downloads
|
||||
- backend-authoritative team scope and config/input hardening
|
||||
|
||||
### Documentation
|
||||
|
||||
- readme
|
||||
- agents
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: rename macos artifacts in ci
|
||||
- chore: lint
|
||||
- chore: copy
|
||||
- chore: linux ci
|
||||
- chore: update dependencies
|
||||
- chore: migrate biome config and exclude build dirs
|
||||
- ci(deps): bump the github-actions group with 6 updates
|
||||
- ci(deps): bump anomalyco/opencode/github in the github-actions group (#480)
|
||||
- chore: update flake.nix for v0.27.1 [skip ci] (#464)
|
||||
|
||||
### Other
|
||||
|
||||
- security: restrict secret files to owner-only (0600)
|
||||
|
||||
|
||||
## v0.27.1 (2026-06-24)
|
||||
|
||||
### Features
|
||||
|
||||
- profile sorting
|
||||
- batch profile launch/stop for paid users
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- prevent stale sse token refresh
|
||||
- properly handle cmd
|
||||
- make SOCKS5 upstream username/password authentication reliable
|
||||
|
||||
### Refactoring
|
||||
|
||||
- improve location info generation for fresh profiles
|
||||
- improve profile creation api invalid 'browser' handling
|
||||
- cleanup
|
||||
- bound proxy connection
|
||||
- add robust proxy lifecycle management"
|
||||
|
||||
### Documentation
|
||||
|
||||
- cleanup
|
||||
- contrib-readme-action has updated readme
|
||||
- contributions
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: dependency update
|
||||
- ci(deps): bump the github-actions group with 3 updates
|
||||
- chore: update flake.nix for v0.27.0 [skip ci] (#448)
|
||||
|
||||
### Other
|
||||
|
||||
- style: improve responsiveness
|
||||
- style: interactive elements consistently have cursor pointer
|
||||
|
||||
|
||||
## v0.27.0 (2026-06-17)
|
||||
|
||||
### Features
|
||||
|
||||
+1
-1
@@ -92,7 +92,7 @@ codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust
|
||||
- **Backend**: Tauri (Rust), `src-tauri/src/`
|
||||
- **Proxy Worker**: Detached process for proxy tunneling, `src-tauri/src/bin/proxy_server.rs`
|
||||
- **Sync**: Cloud sync via S3-compatible storage, `src-tauri/src/sync/`, `donut-sync/`
|
||||
- **Browsers**: Camoufox (Firefox-based) and Wayfern (Chromium-based)
|
||||
- **Browsers**: Wayfern (Chromium-based anti-detect)
|
||||
|
||||
## Getting Help
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
- **VPN support**: WireGuard configs per profile
|
||||
- **Local API & MCP**: REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
||||
- **Profile groups**: organize profiles and apply bulk settings
|
||||
- **Import profiles**: migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers
|
||||
- **Import profiles**: migrate from Chrome, Edge, Brave, or other Chromium browsers
|
||||
- **Cookie & extension management**: import/export cookies, manage extensions per profile
|
||||
- **Default browser**: set Donut as your default browser and choose which profile opens each link
|
||||
- **Cloud sync**: sync profiles, proxies, and groups across devices (self-hostable)
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64.dmg) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_x64-portable.zip)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut-0.27.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut-0.27.0-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
@@ -135,6 +135,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>Hassiy</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/xenos1337">
|
||||
<img src="https://avatars.githubusercontent.com/u/66328734?v=4" width="100;" alt="xenos1337"/>
|
||||
<br />
|
||||
<sub><b>xenos</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/webees">
|
||||
<img src="https://avatars.githubusercontent.com/u/5155291?v=4" width="100;" alt="webees"/>
|
||||
@@ -156,6 +163,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>Huy Le</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/drunkod">
|
||||
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
||||
@@ -163,8 +172,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>drunkod</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/JorySeverijnse">
|
||||
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
|
||||
@@ -179,6 +186,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
<sub><b>Thiago Mafra</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/mchnkkc">
|
||||
<img src="https://avatars.githubusercontent.com/u/251900355?v=4" width="100;" alt="mchnkkc"/>
|
||||
<br />
|
||||
<sub><b>mchnkkc</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/liasica">
|
||||
<img src="https://avatars.githubusercontent.com/u/671431?v=4" width="100;" alt="liasica"/>
|
||||
|
||||
+1
-2
@@ -1,7 +1,6 @@
|
||||
[files]
|
||||
extend-exclude = [
|
||||
"src-tauri/src/camoufox/data/*.json",
|
||||
"src-tauri/src/camoufox/data/*.xml",
|
||||
"src-tauri/src/territory_info.xml",
|
||||
"src/i18n/locales/*.json",
|
||||
# Auto-generated from commit subjects by release.yml; typos here originate
|
||||
# in commit messages, which are immutable, so don't spell-check it.
|
||||
|
||||
+11
-3
@@ -1,12 +1,20 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"enabled": true,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
"useIgnoreFile": true
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false
|
||||
"ignoreUnknown": true,
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/target",
|
||||
"!**/node_modules",
|
||||
"!**/dist",
|
||||
"!**/.next",
|
||||
"!**/out"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
SYNC_TOKEN=secret-sync-token
|
||||
# REQUIRED: a long, random shared secret used to authenticate sync clients.
|
||||
# Generate one, e.g.: openssl rand -hex 32
|
||||
# The server refuses to start with this placeholder or a value shorter than 24 chars.
|
||||
SYNC_TOKEN=CHANGE_ME_generate_a_long_random_secret
|
||||
|
||||
PORT=12342
|
||||
|
||||
# REQUIRED S3 / S3-compatible (e.g. MinIO) connection. No defaults are assumed —
|
||||
# the server fails to start if endpoint / access key / secret key is missing.
|
||||
S3_ENDPOINT=http://localhost:8987
|
||||
S3_REGION=us-east-1
|
||||
S3_ACCESS_KEY_ID=minioadmin
|
||||
S3_SECRET_ACCESS_KEY=minioadmin
|
||||
S3_ACCESS_KEY_ID=CHANGE_ME
|
||||
S3_SECRET_ACCESS_KEY=CHANGE_ME
|
||||
S3_BUCKET=donut-sync
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1073.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1073.0",
|
||||
"@aws-sdk/client-s3": "^3.1081.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1081.0",
|
||||
"@nestjs/common": "^11.1.27",
|
||||
"@nestjs/config": "^4.0.4",
|
||||
"@nestjs/core": "^11.1.27",
|
||||
@@ -35,7 +35,7 @@
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"jest": "^30.4.2",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
||||
@@ -18,10 +18,22 @@ function safeEqual(a: string, b: string): boolean {
|
||||
return ab.length === bb.length && timingSafeEqual(ab, bb);
|
||||
}
|
||||
|
||||
type TeamScope = { ownerId: string; teamId: string; teamProfileLimit: number };
|
||||
|
||||
@Injectable()
|
||||
export class AuthGuard implements CanActivate {
|
||||
private readonly logger = new Logger(AuthGuard.name);
|
||||
private jwtPublicKey: string | null = null;
|
||||
private readonly backendInternalUrl: string | undefined;
|
||||
private readonly backendInternalKey: string | undefined;
|
||||
|
||||
// Short-lived cache of the per-user team scope so membership revocation takes
|
||||
// effect quickly (within TTL) without a backend round-trip on every request.
|
||||
private readonly teamScopeCache = new Map<
|
||||
string,
|
||||
{ value: TeamScope | null; expires: number }
|
||||
>();
|
||||
private static readonly TEAM_SCOPE_TTL_MS = 30_000;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const publicKey = this.configService.get<string>("SYNC_JWT_PUBLIC_KEY");
|
||||
@@ -29,9 +41,52 @@ export class AuthGuard implements CanActivate {
|
||||
this.jwtPublicKey = publicKey.replace(/\\n/g, "\n");
|
||||
this.logger.log("JWT public key configured — cloud auth enabled");
|
||||
}
|
||||
this.backendInternalUrl = this.configService.get<string>(
|
||||
"BACKEND_INTERNAL_URL",
|
||||
);
|
||||
this.backendInternalKey = this.configService.get<string>(
|
||||
"BACKEND_INTERNAL_KEY",
|
||||
);
|
||||
}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
/**
|
||||
* Resolve a cloud user's team scope via the backend (the ONLY authority for
|
||||
* team membership). Cached briefly. Throws on backend error so the caller can
|
||||
* fail closed (fall back to the user's own namespace, never a team one).
|
||||
*/
|
||||
private async resolveTeamScope(sub: string): Promise<TeamScope | null> {
|
||||
if (!this.backendInternalUrl || !this.backendInternalKey) return null;
|
||||
|
||||
const now = Date.now();
|
||||
const cached = this.teamScopeCache.get(sub);
|
||||
if (cached && cached.expires > now) return cached.value;
|
||||
|
||||
const resp = await fetch(
|
||||
`${this.backendInternalUrl}/api/auth/internal/team-scope`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-internal-key": this.backendInternalKey,
|
||||
},
|
||||
body: JSON.stringify({ userId: sub }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`team-scope resolver returned ${resp.status}`);
|
||||
}
|
||||
const value = (await resp.json()) as TeamScope | null;
|
||||
|
||||
// Bound the cache; a coarse clear is fine since entries are cheap to rebuild.
|
||||
if (this.teamScopeCache.size > 10_000) this.teamScopeCache.clear();
|
||||
this.teamScopeCache.set(sub, {
|
||||
value: value ?? null,
|
||||
expires: now + AuthGuard.TEAM_SCOPE_TTL_MS,
|
||||
});
|
||||
return value ?? null;
|
||||
}
|
||||
|
||||
async canActivate(context: ExecutionContext): Promise<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
@@ -49,9 +104,7 @@ export class AuthGuard implements CanActivate {
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "self-hosted",
|
||||
prefix: "",
|
||||
teamPrefix: null,
|
||||
profileLimit: 0,
|
||||
teamProfileLimit: 0,
|
||||
} satisfies UserContext;
|
||||
return true;
|
||||
}
|
||||
@@ -63,31 +116,46 @@ export class AuthGuard implements CanActivate {
|
||||
algorithms: ["RS256"],
|
||||
}) as jwt.JwtPayload;
|
||||
|
||||
// Validate the scope claims' SHAPE before trusting them as S3 key
|
||||
// prefixes. An empty/over-broad prefix would make validateKeyAccess
|
||||
// (`key.startsWith(prefix)`) authorize the entire bucket, so a signer
|
||||
// bug or permissive claim must not silently widen scope.
|
||||
const prefix = decoded.prefix || `users/${decoded.sub}/`;
|
||||
if (typeof prefix !== "string" || !/^users\/[^/]+\/$/.test(prefix)) {
|
||||
const sub = typeof decoded.sub === "string" ? decoded.sub : "";
|
||||
// Validate the prefix claim SHAPE before trusting it as an S3 key
|
||||
// prefix. An empty/over-broad prefix would make validateKeyAccess
|
||||
// (`key.startsWith(prefix)`) authorize the entire bucket.
|
||||
const ownPrefix = decoded.prefix || `users/${sub}/`;
|
||||
if (
|
||||
typeof ownPrefix !== "string" ||
|
||||
!/^users\/[^/]+\/$/.test(ownPrefix)
|
||||
) {
|
||||
throw new Error(`Invalid prefix claim: ${String(decoded.prefix)}`);
|
||||
}
|
||||
const teamPrefix =
|
||||
decoded.teamPrefix === undefined || decoded.teamPrefix === null
|
||||
? null
|
||||
: decoded.teamPrefix;
|
||||
if (
|
||||
teamPrefix !== null &&
|
||||
!/^teams\/[^/]+\/$/.test(String(teamPrefix))
|
||||
) {
|
||||
throw new Error(`Invalid teamPrefix claim: ${String(teamPrefix)}`);
|
||||
|
||||
// Resolve the EFFECTIVE namespace: a team member's requests are scoped
|
||||
// to the shared team owner namespace. The JWT carries no team data — the
|
||||
// backend is the sole authority. On any resolver error we fail CLOSED:
|
||||
// fall back to the user's own namespace, never widening to a team one.
|
||||
let effectivePrefix = ownPrefix;
|
||||
let effectiveProfileLimit =
|
||||
typeof decoded.profileLimit === "number" ? decoded.profileLimit : 0;
|
||||
try {
|
||||
const scope = sub ? await this.resolveTeamScope(sub) : null;
|
||||
if (scope && /^[^/]+$/.test(scope.ownerId)) {
|
||||
effectivePrefix = `users/${scope.ownerId}/`;
|
||||
if (scope.teamProfileLimit > 0) {
|
||||
effectiveProfileLimit = scope.teamProfileLimit;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.warn(
|
||||
`Team scope resolution failed for ${sub}; using own namespace: ${
|
||||
err instanceof Error ? err.message : err
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "cloud",
|
||||
prefix,
|
||||
teamPrefix,
|
||||
profileLimit: decoded.profileLimit || 0,
|
||||
teamProfileLimit: decoded.teamProfileLimit || 0,
|
||||
prefix: effectivePrefix,
|
||||
profileLimit: effectiveProfileLimit,
|
||||
sub,
|
||||
} satisfies UserContext;
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
export interface UserContext {
|
||||
mode: "self-hosted" | "cloud";
|
||||
prefix: string; // '' for self-hosted, 'users/{id}/' for cloud
|
||||
teamPrefix: string | null; // 'teams/{id}/' or null
|
||||
profileLimit: number; // 0 for unlimited (self-hosted)
|
||||
teamProfileLimit: number; // 0 for unlimited or non-team users
|
||||
// The EFFECTIVE namespace for this request: '' for self-hosted, and for cloud
|
||||
// either the user's own 'users/{sub}/' or, for a team member, the shared team
|
||||
// owner's 'users/{ownerId}/' — resolved server-side by the AuthGuard from the
|
||||
// backend (never carried in the JWT). All key scoping uses this directly.
|
||||
prefix: string;
|
||||
profileLimit: number; // 0 for unlimited (self-hosted); effective (team) limit for team members
|
||||
sub?: string; // the authenticated user id (cloud only)
|
||||
}
|
||||
|
||||
+17
-1
@@ -2,11 +2,27 @@ import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { AppModule } from "./app.module.js";
|
||||
|
||||
const INSECURE_DEFAULT_TOKENS = new Set([
|
||||
"secret-sync-token",
|
||||
"CHANGE_ME_generate_a_long_random_secret",
|
||||
"CHANGE_ME",
|
||||
]);
|
||||
|
||||
function validateEnv() {
|
||||
if (!process.env.SYNC_TOKEN && !process.env.SYNC_JWT_PUBLIC_KEY) {
|
||||
const token = process.env.SYNC_TOKEN;
|
||||
if (!token && !process.env.SYNC_JWT_PUBLIC_KEY) {
|
||||
console.error("Either SYNC_TOKEN or SYNC_JWT_PUBLIC_KEY must be set");
|
||||
process.exit(1);
|
||||
}
|
||||
// A static SYNC_TOKEN is the only credential on a self-hosted server that is
|
||||
// typically exposed on 0.0.0.0, so reject the shipped placeholders and any
|
||||
// token short enough to brute-force.
|
||||
if (token && (INSECURE_DEFAULT_TOKENS.has(token) || token.length < 24)) {
|
||||
console.error(
|
||||
"SYNC_TOKEN is a known default or too short. Set a long, random secret, e.g. `openssl rand -hex 32`.",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrap() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { timingSafeEqual } from "node:crypto";
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
@@ -38,9 +39,18 @@ export class InternalController {
|
||||
throw new UnauthorizedException("Invalid internal key");
|
||||
}
|
||||
|
||||
return this.syncService.cleanupExcessProfiles(
|
||||
body.userId,
|
||||
body.maxProfiles,
|
||||
);
|
||||
// The userId is interpolated into a destructive S3 delete prefix
|
||||
// (users/{userId}/profiles/), so constrain it to a plain id — no empty
|
||||
// value, no slashes/dots that could widen or redirect the prefix.
|
||||
const userId = body?.userId;
|
||||
if (typeof userId !== "string" || !/^[A-Za-z0-9_-]{1,128}$/.test(userId)) {
|
||||
throw new BadRequestException("Invalid userId");
|
||||
}
|
||||
const maxProfiles = body?.maxProfiles;
|
||||
if (!Number.isInteger(maxProfiles) || maxProfiles < 0) {
|
||||
throw new BadRequestException("Invalid maxProfiles");
|
||||
}
|
||||
|
||||
return this.syncService.cleanupExcessProfiles(userId, maxProfiles);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "@aws-sdk/client-s3";
|
||||
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
Logger,
|
||||
@@ -82,23 +83,34 @@ export class SyncService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
private s3Client: S3Client;
|
||||
private bucket: string;
|
||||
// Upper bound on presign batch array length (DoS guard).
|
||||
private static readonly MAX_BATCH_ITEMS = 1000;
|
||||
|
||||
private changeSubject = new Subject<SubscribeEventDto>();
|
||||
private s3Ready = false;
|
||||
private backendInternalUrl: string | undefined;
|
||||
private backendInternalKey: string | undefined;
|
||||
|
||||
constructor(private configService: ConfigService) {
|
||||
const endpoint =
|
||||
this.configService.get<string>("S3_ENDPOINT") || "http://localhost:8987";
|
||||
// Fail fast instead of silently falling back to insecure local dev defaults
|
||||
// (localhost / minioadmin) — a misconfigured server must not start pointed
|
||||
// at an unintended or public-default S3 backend.
|
||||
const requireEnv = (name: string): string => {
|
||||
const value = this.configService.get<string>(name);
|
||||
if (!value) {
|
||||
throw new Error(`Required environment variable ${name} is not set`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
const endpoint = requireEnv("S3_ENDPOINT");
|
||||
const region = this.configService.get<string>("S3_REGION") || "us-east-1";
|
||||
const accessKeyId =
|
||||
this.configService.get<string>("S3_ACCESS_KEY_ID") || "minioadmin";
|
||||
const secretAccessKey =
|
||||
this.configService.get<string>("S3_SECRET_ACCESS_KEY") || "minioadmin";
|
||||
const accessKeyId = requireEnv("S3_ACCESS_KEY_ID");
|
||||
const secretAccessKey = requireEnv("S3_SECRET_ACCESS_KEY");
|
||||
const forcePathStyle =
|
||||
this.configService.get<string>("S3_FORCE_PATH_STYLE") !== "false";
|
||||
|
||||
this.bucket = this.configService.get<string>("S3_BUCKET") || "donut-sync";
|
||||
this.bucket = requireEnv("S3_BUCKET");
|
||||
|
||||
this.s3Client = new S3Client({
|
||||
endpoint,
|
||||
@@ -181,7 +193,6 @@ export class SyncService implements OnModuleInit {
|
||||
*/
|
||||
private scopeKey(ctx: UserContext, key: string): string {
|
||||
if (ctx.mode === "self-hosted") return key;
|
||||
if (ctx.teamPrefix && key.startsWith(ctx.teamPrefix)) return key;
|
||||
return `${ctx.prefix}${key}`;
|
||||
}
|
||||
|
||||
@@ -192,9 +203,7 @@ export class SyncService implements OnModuleInit {
|
||||
*/
|
||||
private scopesFor(ctx: UserContext): string[] {
|
||||
if (ctx.mode === "self-hosted") return [""];
|
||||
const out = [ctx.prefix];
|
||||
if (ctx.teamPrefix) out.push(ctx.teamPrefix);
|
||||
return out;
|
||||
return [ctx.prefix];
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -243,9 +252,6 @@ export class SyncService implements OnModuleInit {
|
||||
*/
|
||||
private scopeForKey(ctx: UserContext, scopedKey: string): string | null {
|
||||
if (ctx.mode === "self-hosted") return "";
|
||||
if (ctx.teamPrefix && scopedKey.startsWith(ctx.teamPrefix)) {
|
||||
return ctx.teamPrefix;
|
||||
}
|
||||
if (scopedKey.startsWith(ctx.prefix)) return ctx.prefix;
|
||||
return null;
|
||||
}
|
||||
@@ -258,7 +264,6 @@ export class SyncService implements OnModuleInit {
|
||||
if (ctx.mode === "self-hosted") return;
|
||||
|
||||
if (key.startsWith(ctx.prefix)) return;
|
||||
if (ctx.teamPrefix && key.startsWith(ctx.teamPrefix)) return;
|
||||
|
||||
throw new ForbiddenException("Access denied to this key");
|
||||
}
|
||||
@@ -324,7 +329,16 @@ export class SyncService implements OnModuleInit {
|
||||
Metadata: metadata,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
const metadataHeaders = new Set(
|
||||
Object.keys(metadata ?? {}).map((name) => `x-amz-meta-${name}`),
|
||||
);
|
||||
const url = await getSignedUrl(this.s3Client, command, {
|
||||
expiresIn,
|
||||
// The AWS presigner otherwise hoists user metadata into the query string.
|
||||
// The client echoes the response metadata as headers, so those headers
|
||||
// must remain in the request and be covered by SignedHeaders.
|
||||
unhoistableHeaders: metadataHeaders,
|
||||
});
|
||||
|
||||
// Report profile usage after upload presign if key is under profiles/
|
||||
if (ctx.mode === "cloud" && dto.key.startsWith("profiles/")) {
|
||||
@@ -422,6 +436,9 @@ export class SyncService implements OnModuleInit {
|
||||
|
||||
async list(dto: ListRequestDto, ctx?: UserContext): Promise<ListResponseDto> {
|
||||
const prefix = ctx ? this.scopeKey(ctx, dto.prefix) : dto.prefix;
|
||||
// Enforce scope on the read side too, so a crafted absolute prefix can't
|
||||
// enumerate another tenant's objects.
|
||||
if (ctx) this.validateKeyAccess(ctx, prefix);
|
||||
|
||||
const response = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
@@ -433,15 +450,12 @@ export class SyncService implements OnModuleInit {
|
||||
);
|
||||
|
||||
const userPrefix = ctx?.prefix || "";
|
||||
const teamPrefix = ctx?.teamPrefix || "";
|
||||
const objects = (response.Contents || [])
|
||||
// Don't leak donut-sync's internal manifest object to clients.
|
||||
.filter((obj) => !(obj.Key || "").endsWith(MANIFEST_KEY))
|
||||
.map((obj) => {
|
||||
let key = obj.Key || "";
|
||||
if (teamPrefix && key.startsWith(teamPrefix)) {
|
||||
key = key.substring(teamPrefix.length);
|
||||
} else if (userPrefix && key.startsWith(userPrefix)) {
|
||||
if (userPrefix && key.startsWith(userPrefix)) {
|
||||
key = key.substring(userPrefix.length);
|
||||
}
|
||||
return {
|
||||
@@ -462,6 +476,16 @@ export class SyncService implements OnModuleInit {
|
||||
dto: PresignUploadBatchRequestDto,
|
||||
ctx: UserContext,
|
||||
): Promise<PresignUploadBatchResponseDto> {
|
||||
// Cap batch size: each item triggers a signing operation, so an unbounded
|
||||
// array is a CPU/memory amplification vector for an authenticated caller.
|
||||
if (
|
||||
!Array.isArray(dto.items) ||
|
||||
dto.items.length > SyncService.MAX_BATCH_ITEMS
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`items must be an array of at most ${SyncService.MAX_BATCH_ITEMS} entries`,
|
||||
);
|
||||
}
|
||||
// Check profile limit for cloud users
|
||||
if (ctx.mode === "cloud" && ctx.profileLimit > 0) {
|
||||
await this.checkProfileLimit(ctx);
|
||||
@@ -520,6 +544,14 @@ export class SyncService implements OnModuleInit {
|
||||
dto: PresignDownloadBatchRequestDto,
|
||||
ctx: UserContext,
|
||||
): Promise<PresignDownloadBatchResponseDto> {
|
||||
if (
|
||||
!Array.isArray(dto.keys) ||
|
||||
dto.keys.length > SyncService.MAX_BATCH_ITEMS
|
||||
) {
|
||||
throw new BadRequestException(
|
||||
`keys must be an array of at most ${SyncService.MAX_BATCH_ITEMS} entries`,
|
||||
);
|
||||
}
|
||||
const expiresIn = clampExpiresIn(dto.expiresIn);
|
||||
const expiresAt = new Date(Date.now() + expiresIn * 1000);
|
||||
|
||||
@@ -551,6 +583,15 @@ export class SyncService implements OnModuleInit {
|
||||
ctx: UserContext,
|
||||
): Promise<DeletePrefixResponseDto> {
|
||||
const prefix = this.scopeKey(ctx, dto.prefix);
|
||||
// Bulk delete is the highest-blast-radius op, yet it was the only mutating
|
||||
// path that skipped this check — so a client passing an absolute prefix
|
||||
// (one already starting with its own/team scope, which scopeKey returns
|
||||
// verbatim) could wipe an entire shared namespace. Enforce scope, and
|
||||
// refuse an empty scoped prefix (which would match the whole scope).
|
||||
this.validateKeyAccess(ctx, prefix);
|
||||
if (ctx.mode === "cloud" && prefix.length === 0) {
|
||||
throw new ForbiddenException("Refusing to delete an empty prefix");
|
||||
}
|
||||
let deletedCount = 0;
|
||||
let tombstoneCreated = false;
|
||||
let continuationToken: string | undefined;
|
||||
@@ -593,6 +634,7 @@ export class SyncService implements OnModuleInit {
|
||||
// Create tombstone if requested
|
||||
if (dto.tombstoneKey && deletedCount > 0) {
|
||||
const scopedTombstoneKey = this.scopeKey(ctx, dto.tombstoneKey);
|
||||
this.validateKeyAccess(ctx, scopedTombstoneKey);
|
||||
const tombstoneData = JSON.stringify({
|
||||
prefix: dto.prefix,
|
||||
deleted_at: dto.deletedAt || new Date().toISOString(),
|
||||
@@ -929,22 +971,9 @@ export class SyncService implements OnModuleInit {
|
||||
);
|
||||
count += userResult.CommonPrefixes?.length || 0;
|
||||
|
||||
if (ctx.teamPrefix && ctx.teamProfileLimit && ctx.teamProfileLimit > 0) {
|
||||
const teamResult = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: `${ctx.teamPrefix}profiles/`,
|
||||
Delimiter: "/",
|
||||
}),
|
||||
);
|
||||
const teamCount = teamResult.CommonPrefixes?.length || 0;
|
||||
if (teamCount >= ctx.teamProfileLimit) {
|
||||
throw new ForbiddenException(
|
||||
`Team profile limit reached (${ctx.teamProfileLimit}). Ask the team owner to upgrade.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ctx.prefix is already the effective namespace (the team owner's, for a
|
||||
// team member) and ctx.profileLimit the effective (team) limit, so this
|
||||
// single check covers both personal and team accounts.
|
||||
if (count >= ctx.profileLimit) {
|
||||
throw new ForbiddenException(
|
||||
`Profile limit reached (${ctx.profileLimit}). Upgrade your plan for more profiles.`,
|
||||
@@ -985,37 +1014,10 @@ export class SyncService implements OnModuleInit {
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
private async countTeamProfiles(ctx: UserContext): Promise<number> {
|
||||
if (!ctx.teamPrefix) return 0;
|
||||
const profilePrefix = `${ctx.teamPrefix}profiles/`;
|
||||
let count = 0;
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: profilePrefix,
|
||||
Delimiter: "/",
|
||||
MaxKeys: 1000,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
count += result.CommonPrefixes?.length || 0;
|
||||
continuationToken = result.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private extractTeamId(ctx: UserContext): string | null {
|
||||
if (!ctx.teamPrefix) return null;
|
||||
const match = ctx.teamPrefix.match(/^teams\/([^/]+)\/$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget: count profiles and report to backend.
|
||||
* Fire-and-forget: count profiles and report to backend. The count is for the
|
||||
* effective namespace (the team owner's, for a team member), reported against
|
||||
* that namespace's user id — i.e. the team account for teams.
|
||||
*/
|
||||
private reportProfileUsageAsync(ctx: UserContext): void {
|
||||
if (!this.backendInternalUrl || !this.backendInternalKey) return;
|
||||
@@ -1024,17 +1026,7 @@ export class SyncService implements OnModuleInit {
|
||||
if (!userId) return;
|
||||
|
||||
this.countProfiles(ctx)
|
||||
.then(async (count) => {
|
||||
await this.reportProfileUsage(userId, count);
|
||||
|
||||
if (ctx.teamPrefix) {
|
||||
const teamCount = await this.countTeamProfiles(ctx);
|
||||
const teamId = this.extractTeamId(ctx);
|
||||
if (teamId) {
|
||||
await this.reportProfileUsage(teamId, teamCount);
|
||||
}
|
||||
}
|
||||
})
|
||||
.then((count) => this.reportProfileUsage(userId, count))
|
||||
.catch((err) =>
|
||||
this.logger.warn(`Failed to report profile usage: ${err.message}`),
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
interface PresignResponse {
|
||||
url: string;
|
||||
expiresAt: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface ListResponse {
|
||||
@@ -34,6 +35,7 @@ interface StatResponse {
|
||||
exists: boolean;
|
||||
size?: number;
|
||||
lastModified?: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
describe("SyncController (e2e)", () => {
|
||||
@@ -112,6 +114,65 @@ describe("SyncController (e2e)", () => {
|
||||
expect(body.url).toContain("test/upload-key.txt");
|
||||
expect(body.expiresAt).toBeDefined();
|
||||
});
|
||||
|
||||
it("should sign and persist echoed object metadata", async () => {
|
||||
const testKey = `vpns/metadata-${Date.now()}.json`;
|
||||
const updatedAt = Math.floor(Date.now() / 1000).toString();
|
||||
|
||||
try {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-upload")
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({
|
||||
key: testKey,
|
||||
contentType: "application/json",
|
||||
metadata: {
|
||||
"updated-at": updatedAt,
|
||||
ignored: "not-allowed",
|
||||
},
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as PresignResponse;
|
||||
expect(body.metadata).toEqual({ "updated-at": updatedAt });
|
||||
|
||||
const uploadUrl = new URL(body.url);
|
||||
const signedHeaders =
|
||||
uploadUrl.searchParams.get("X-Amz-SignedHeaders")?.split(";") ?? [];
|
||||
expect(signedHeaders).toContain("x-amz-meta-updated-at");
|
||||
expect(uploadUrl.searchParams.has("x-amz-meta-updated-at")).toBe(false);
|
||||
|
||||
const uploadResult = await fetch(body.url, {
|
||||
method: "PUT",
|
||||
body: "{}",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-amz-meta-updated-at": updatedAt,
|
||||
},
|
||||
});
|
||||
if (!uploadResult.ok) {
|
||||
throw new Error(
|
||||
`Metadata upload failed with status ${uploadResult.status}: ${await uploadResult.text()}`,
|
||||
);
|
||||
}
|
||||
|
||||
const statResponse = await request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: testKey })
|
||||
.expect(200);
|
||||
|
||||
const statBody = statResponse.body as StatResponse;
|
||||
expect(statBody.exists).toBe(true);
|
||||
expect(statBody.metadata?.["updated-at"]).toBe(updatedAt);
|
||||
} finally {
|
||||
await request(app.getHttpServer())
|
||||
.post("/v1/objects/delete")
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: testKey })
|
||||
.expect(200);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("POST /v1/objects/presign-download", () => {
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
# Donut Browser native E2E tests
|
||||
|
||||
These tests exercise the actual Tauri application through a sibling native test driver. They do
|
||||
not replace Rust or React unit tests; they
|
||||
cover the process boundaries those tests cannot: WKWebView/WebView2/WebKitGTK UI, Tauri invokes,
|
||||
REST and MCP servers, two-device sync, S3 payload encryption, Wayfern, CDP, and child-process
|
||||
cleanup.
|
||||
|
||||
## Local setup
|
||||
|
||||
Place both repositories beside each other:
|
||||
|
||||
```text
|
||||
Code/
|
||||
├── donutbrowser/
|
||||
└── <test-driver-checkout>/
|
||||
```
|
||||
|
||||
Install Donut dependencies with `pnpm install`. The browser suite also needs
|
||||
`WAYFERN_TEST_TOKEN`. The runner reads it from the environment or Donut's ignored `.env` without
|
||||
printing it. When a local browser fixture is configured, the runner copies it into the test data
|
||||
root (using an isolated APFS clone on macOS); otherwise the browser suite downloads the current
|
||||
published build into that root.
|
||||
|
||||
Set `DONUT_E2E_WAYFERN_PATH` to use a local browser fixture. Without it, the runner uses an ignored
|
||||
cache fixture when present and otherwise downloads the published test build.
|
||||
|
||||
The real-network suite additionally requires Docker plus
|
||||
`RESIDENTIAL_PROXY_URL_ONE_HTTP` and `RESIDENTIAL_PROXY_URL_ONE_SOCKS`. It creates its own
|
||||
WireGuard server and tunnel-only HTTP target in a disposable container. It never connects a test
|
||||
profile to a developer or production VPN.
|
||||
|
||||
Run one suite:
|
||||
|
||||
```sh
|
||||
pnpm e2e:smoke
|
||||
pnpm e2e:ui
|
||||
pnpm e2e:entities
|
||||
pnpm e2e:network
|
||||
pnpm e2e:integrations
|
||||
pnpm e2e:sync
|
||||
pnpm e2e:browser
|
||||
```
|
||||
|
||||
Run everything with `pnpm e2e`. A normal run builds the Next frontend, `donut-proxy`, the
|
||||
private harness in `e2e/app`, and `tauri-wd`. The harness enables Donut's `e2e` feature and injects
|
||||
the sibling WebDriver plugin without making the production crate depend on a private filesystem
|
||||
path. Add `--no-build` to `node e2e/run.mjs --suite=<name>` only when all four outputs are current.
|
||||
`DONUT_E2E_KEEP_ARTIFACTS=1` retains successful local runs; failed runs are always retained and
|
||||
their location is printed. Raw screenshots, captured HTML, logs, and isolated app state stay local.
|
||||
The runner also creates a text-only `diagnostics/` directory whose logs are redacted and checked
|
||||
against active test secrets. CI uploads only that directory on failure. Disposable copied browser
|
||||
binaries are pruned so repeated failures do not consume gigabytes.
|
||||
|
||||
The suites deliberately distinguish visible behavior from command coverage. `e2e:entities`
|
||||
exercises isolated CRUD and persistence through Tauri commands. `e2e:network` visibly creates a
|
||||
profile group, HTTP proxy, WireGuard VPN, extension, extension group, and Wayfern profile; assigns
|
||||
the proxy and VPN in the profile table; validates both residential HTTP and SOCKS5 proxies; then
|
||||
launches Wayfern through the residential proxy and through the local WireGuard tunnel. Normal test
|
||||
sessions start with onboarding completed so the Welcome dialog cannot hide the feature under test.
|
||||
The onboarding and Wayfern-terms scenarios explicitly opt into fresh state and test those dialogs.
|
||||
`e2e:ui` selects predefined, preset, and manually customized themes through the native UI and
|
||||
asserts their persisted settings and rendered CSS variables across rail navigation and app restart.
|
||||
|
||||
## Isolation contract
|
||||
|
||||
Each app session receives a unique root under the operating-system test temp directory. The
|
||||
runner redirects:
|
||||
|
||||
- Donut data, cache, and logs with `DONUTBROWSER_DATA_ROOT`;
|
||||
- `HOME`, `USERPROFILE`, `CFFIXED_USER_HOME`, XDG paths, `APPDATA`, and `LOCALAPPDATA`;
|
||||
- `TMPDIR`, `TMP`, and `TEMP`;
|
||||
- the Tauri WebView store (incognito for WKWebView, whose persistent data-directory API is not
|
||||
honored);
|
||||
- all REST, MCP, WebDriver, fixture, MinIO, and sync-server ports;
|
||||
- each sync test to a new MinIO bucket and random token.
|
||||
|
||||
The E2E feature suppresses automatic updater/download traffic, but explicit browser tests still
|
||||
exercise published Wayfern downloads when no local fixture exists. Entitlement fallback from
|
||||
`WAYFERN_TEST_TOKEN` exists only in the feature-gated test binary. Production builds never include
|
||||
the WebDriver plugin or this fallback.
|
||||
|
||||
## CI
|
||||
|
||||
`.github/workflows/app-e2e.yml` runs smoke tests on macOS, Linux/Xvfb, and Windows for pull
|
||||
requests. Pushes to `main`, weekly schedules, and manual runs execute the full macOS suite,
|
||||
including MinIO-backed sync and real Wayfern automation, plus a Linux/Docker job for residential
|
||||
proxy and local WireGuard browser traffic.
|
||||
|
||||
CI needs a `TAURI_WEBDRIVER_TOKEN` secret with read-only access and a
|
||||
`TAURI_WEBDRIVER_REPOSITORY` repository variable identifying the test-driver checkout. The full
|
||||
job also requires the `WAYFERN_TEST_TOKEN` secret. The network job requires that secret plus
|
||||
`RESIDENTIAL_PROXY_URL_ONE_HTTP` and `RESIDENTIAL_PROXY_URL_ONE_SOCKS`.
|
||||
Generated
+9073
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "donutbrowser-e2e"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
donutbrowser-lib = { package = "donutbrowser", path = "../../src-tauri", features = ["e2e"] }
|
||||
tauri-plugin-cross-platform-webdriver = { path = "../../../tauri-cross-platform-webdriver/crates/tauri-plugin-cross-platform-webdriver" }
|
||||
|
||||
[patch.crates-io]
|
||||
wayland-scanner = { git = "https://github.com/Smithay/wayland-rs", rev = "d07c4f91f28b42e5a485823ffd9d8d5a210b1053" }
|
||||
@@ -0,0 +1,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
donutbrowser_lib::run_with_builder(|builder| {
|
||||
builder.plugin(tauri_plugin_cross_platform_webdriver::init())
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
/**
|
||||
* Auditable ownership for every Tauri command. The coverage test compares this
|
||||
* map to generate_handler!, so adding a backend capability without assigning it
|
||||
* to an E2E suite fails immediately.
|
||||
*
|
||||
* "integration" means the suite exercises the command with real isolated state.
|
||||
* "contract" means the command's safe/read-only or unauthenticated path is run.
|
||||
* "host-mutating" is reserved for operations whose purpose is to change the
|
||||
* machine outside Donut's data roots; their reason must remain explicit.
|
||||
*/
|
||||
export const commandCoverage = {
|
||||
lifecycle: {
|
||||
suite: "smoke",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"confirm_quit",
|
||||
"hide_to_tray",
|
||||
"update_tray_menu",
|
||||
"get_app_settings",
|
||||
"save_app_settings",
|
||||
"read_log_files",
|
||||
"get_table_sorting_settings",
|
||||
"save_table_sorting_settings",
|
||||
"get_system_language",
|
||||
"get_system_info",
|
||||
"dismiss_window_resize_warning",
|
||||
"get_window_resize_warning_dismissed",
|
||||
"get_onboarding_completed",
|
||||
"complete_onboarding",
|
||||
],
|
||||
},
|
||||
profileEntities: {
|
||||
suite: "entities",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"delete_profile",
|
||||
"clone_profile",
|
||||
"create_browser_profile_new",
|
||||
"list_browser_profiles",
|
||||
"get_all_tags",
|
||||
"update_profile_proxy",
|
||||
"update_profile_vpn",
|
||||
"update_profile_tags",
|
||||
"update_profile_note",
|
||||
"update_profile_clear_on_close",
|
||||
"update_profile_launch_hook",
|
||||
"update_profile_window_color",
|
||||
"update_profile_proxy_bypass_rules",
|
||||
"update_profile_dns_blocklist",
|
||||
"rename_profile",
|
||||
"detect_existing_profiles",
|
||||
"import_browser_profiles",
|
||||
"scan_folder_for_profiles",
|
||||
"scan_profile_archive",
|
||||
"cleanup_profile_import_scratch",
|
||||
"get_profile_groups",
|
||||
"get_groups_with_profile_counts",
|
||||
"create_profile_group",
|
||||
"update_profile_group",
|
||||
"delete_profile_group",
|
||||
"assign_profiles_to_group",
|
||||
"delete_selected_profiles",
|
||||
],
|
||||
},
|
||||
proxyEntities: {
|
||||
suite: "entities",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"create_stored_proxy",
|
||||
"get_stored_proxies",
|
||||
"update_stored_proxy",
|
||||
"delete_stored_proxy",
|
||||
"check_proxy_validity",
|
||||
"get_cached_proxy_check",
|
||||
"export_proxies",
|
||||
"import_proxies_json",
|
||||
"parse_txt_proxies",
|
||||
"import_proxies_from_parsed",
|
||||
],
|
||||
},
|
||||
extensions: {
|
||||
suite: "entities",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"list_extensions",
|
||||
"get_extension_icon",
|
||||
"add_extension",
|
||||
"update_extension",
|
||||
"delete_extension",
|
||||
"list_extension_groups",
|
||||
"create_extension_group",
|
||||
"update_extension_group",
|
||||
"delete_extension_group",
|
||||
"add_extension_to_group",
|
||||
"remove_extension_from_group",
|
||||
"assign_extension_group_to_profile",
|
||||
"get_extension_group_for_profile",
|
||||
],
|
||||
},
|
||||
vpn: {
|
||||
suite: "entities",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"import_vpn_config",
|
||||
"list_vpn_configs",
|
||||
"get_vpn_config",
|
||||
"delete_vpn_config",
|
||||
"create_vpn_config_manual",
|
||||
"update_vpn_config",
|
||||
"check_vpn_validity",
|
||||
"disconnect_vpn",
|
||||
"get_vpn_status",
|
||||
"list_active_vpn_connections",
|
||||
],
|
||||
},
|
||||
cookiesPasswordsAndTraffic: {
|
||||
suite: "entities",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"get_all_traffic_snapshots",
|
||||
"get_profile_traffic_snapshot",
|
||||
"clear_all_traffic_stats",
|
||||
"clear_profile_traffic_stats",
|
||||
"get_traffic_stats_for_period",
|
||||
"read_profile_cookies",
|
||||
"get_profile_cookie_stats",
|
||||
"copy_profile_cookies",
|
||||
"import_cookies_from_file",
|
||||
"export_profile_cookies",
|
||||
"set_profile_password",
|
||||
"change_profile_password",
|
||||
"remove_profile_password",
|
||||
"verify_profile_password",
|
||||
"unlock_profile",
|
||||
"lock_profile",
|
||||
"is_profile_locked",
|
||||
],
|
||||
},
|
||||
dns: {
|
||||
suite: "entities",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"dns_blocklist::get_dns_blocklist_cache_status",
|
||||
"dns_blocklist::refresh_dns_blocklists",
|
||||
"dns_blocklist::get_custom_dns_config",
|
||||
"dns_blocklist::set_custom_dns_config",
|
||||
"dns_blocklist::import_custom_dns_rules",
|
||||
"dns_blocklist::export_custom_dns_rules",
|
||||
],
|
||||
},
|
||||
browser: {
|
||||
suite: "browser",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"get_supported_browsers",
|
||||
"check_browser_exists",
|
||||
"is_browser_supported_on_platform",
|
||||
"download_browser",
|
||||
"cancel_download",
|
||||
"launch_browser_profile",
|
||||
"fetch_browser_versions_with_count",
|
||||
"fetch_browser_versions_cached_first",
|
||||
"fetch_browser_versions_with_count_cached_first",
|
||||
"get_downloaded_browser_versions",
|
||||
"get_browser_release_types",
|
||||
"check_browser_status",
|
||||
"kill_browser_profile",
|
||||
"open_url_with_profile",
|
||||
"check_missing_binaries",
|
||||
"check_missing_geoip_database",
|
||||
"ensure_all_binaries_exist",
|
||||
"ensure_active_browsers_downloaded",
|
||||
"update_wayfern_config",
|
||||
"generate_sample_fingerprint",
|
||||
"is_geoip_database_available",
|
||||
"download_geoip_database",
|
||||
"fingerprint_consistency::check_profile_fingerprint_consistency",
|
||||
"fingerprint_consistency::match_profile_fingerprint_to_exit",
|
||||
"check_wayfern_terms_accepted",
|
||||
"check_wayfern_downloaded",
|
||||
"accept_wayfern_terms",
|
||||
],
|
||||
},
|
||||
localIntegrations: {
|
||||
suite: "integrations",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"start_api_server",
|
||||
"stop_api_server",
|
||||
"get_api_server_status",
|
||||
"start_mcp_server",
|
||||
"stop_mcp_server",
|
||||
"get_mcp_server_status",
|
||||
"get_mcp_config",
|
||||
"list_mcp_agents",
|
||||
"add_mcp_to_agent",
|
||||
"remove_mcp_from_agent",
|
||||
"synchronizer::start_sync_session",
|
||||
"synchronizer::stop_sync_session",
|
||||
"synchronizer::remove_sync_follower",
|
||||
"synchronizer::get_sync_sessions",
|
||||
],
|
||||
},
|
||||
syncAndEncryption: {
|
||||
suite: "sync",
|
||||
level: "integration",
|
||||
commands: [
|
||||
"get_sync_settings",
|
||||
"save_sync_settings",
|
||||
"cloud_auth::restart_sync_service",
|
||||
"set_profile_sync_mode",
|
||||
"cancel_profile_sync",
|
||||
"request_profile_sync",
|
||||
"set_proxy_sync_enabled",
|
||||
"set_group_sync_enabled",
|
||||
"is_proxy_in_use_by_synced_profile",
|
||||
"is_group_in_use_by_synced_profile",
|
||||
"set_vpn_sync_enabled",
|
||||
"is_vpn_in_use_by_synced_profile",
|
||||
"set_extension_sync_enabled",
|
||||
"set_extension_group_sync_enabled",
|
||||
"get_unsynced_entity_counts",
|
||||
"enable_sync_for_all_entities",
|
||||
"set_e2e_password",
|
||||
"check_has_e2e_password",
|
||||
"verify_e2e_password",
|
||||
"delete_e2e_password",
|
||||
"rollover_encryption_for_all_entities",
|
||||
],
|
||||
},
|
||||
cloudContracts: {
|
||||
suite: "integrations",
|
||||
level: "contract",
|
||||
commands: [
|
||||
"get_commercial_trial_status",
|
||||
"acknowledge_trial_expiration",
|
||||
"has_acknowledged_trial_expiration",
|
||||
"cloud_auth::cloud_exchange_device_code",
|
||||
"cloud_auth::cloud_get_user",
|
||||
"cloud_auth::cloud_refresh_profile",
|
||||
"cloud_auth::cloud_logout",
|
||||
"cloud_auth::cloud_get_proxy_usage",
|
||||
"cloud_auth::cloud_get_countries",
|
||||
"cloud_auth::cloud_get_regions",
|
||||
"cloud_auth::cloud_get_cities",
|
||||
"cloud_auth::cloud_get_isps",
|
||||
"cloud_auth::create_cloud_location_proxy",
|
||||
"cloud_auth::cloud_get_wayfern_token",
|
||||
"cloud_auth::cloud_refresh_wayfern_token",
|
||||
"team_lock::get_team_locks",
|
||||
"team_lock::get_team_lock_status",
|
||||
],
|
||||
},
|
||||
updateContracts: {
|
||||
suite: "integrations",
|
||||
level: "contract",
|
||||
commands: [
|
||||
"clear_all_version_cache_and_refetch",
|
||||
"is_default_browser",
|
||||
"trigger_manual_version_update",
|
||||
"get_version_update_status",
|
||||
"check_for_browser_updates",
|
||||
"dismiss_update_notification",
|
||||
"complete_browser_update_with_auto_update",
|
||||
"check_for_app_updates",
|
||||
"check_for_app_updates_manual",
|
||||
"download_and_prepare_app_update",
|
||||
],
|
||||
},
|
||||
hostMutating: {
|
||||
suite: "full",
|
||||
level: "host-mutating",
|
||||
reason:
|
||||
"These commands intentionally change OS registration, launch external file managers, restart the test process, install an external MCP agent, or create a kernel VPN interface. Their surrounding UI and validation paths are automated, but success-path mutation is forbidden on developer and CI hosts.",
|
||||
commands: [
|
||||
"open_log_directory",
|
||||
"set_as_default_browser",
|
||||
"restart_application",
|
||||
"connect_vpn",
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
export function allCoveredCommands() {
|
||||
return Object.values(commandCoverage).flatMap((entry) => entry.commands);
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { WebDriverClient } from "./webdriver.mjs";
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
function isolatedEnvironment(root, extra = {}) {
|
||||
const home = path.join(root, "home");
|
||||
const temp = path.join(root, "tmp");
|
||||
return {
|
||||
DONUTBROWSER_DATA_ROOT: path.join(root, "donut"),
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
...(process.platform === "darwin" ? { CFFIXED_USER_HOME: home } : {}),
|
||||
TMPDIR: temp,
|
||||
TMP: temp,
|
||||
TEMP: temp,
|
||||
XDG_CONFIG_HOME: path.join(root, "xdg", "config"),
|
||||
XDG_CACHE_HOME: path.join(root, "xdg", "cache"),
|
||||
XDG_DATA_HOME: path.join(root, "xdg", "data"),
|
||||
APPDATA: path.join(root, "windows", "roaming"),
|
||||
LOCALAPPDATA: path.join(root, "windows", "local"),
|
||||
LANG: "en_US.UTF-8",
|
||||
LC_ALL: "en_US.UTF-8",
|
||||
NO_PROXY: "127.0.0.1,localhost",
|
||||
no_proxy: "127.0.0.1,localhost",
|
||||
HTTP_PROXY: "",
|
||||
HTTPS_PROXY: "",
|
||||
ALL_PROXY: "",
|
||||
http_proxy: "",
|
||||
https_proxy: "",
|
||||
all_proxy: "",
|
||||
RUST_BACKTRACE: "1",
|
||||
...extra,
|
||||
};
|
||||
}
|
||||
|
||||
export class AppSession {
|
||||
constructor({
|
||||
name,
|
||||
root,
|
||||
application,
|
||||
driverUrl,
|
||||
cwd,
|
||||
token,
|
||||
extraEnv = {},
|
||||
args = [],
|
||||
seedVersionCache = true,
|
||||
onboardingCompleted = true,
|
||||
wayfernTermsAccepted = true,
|
||||
}) {
|
||||
this.name = name;
|
||||
this.root = root;
|
||||
this.application = application;
|
||||
this.driver = new WebDriverClient(driverUrl);
|
||||
this.cwd = cwd;
|
||||
this.token = token;
|
||||
this.extraEnv = extraEnv;
|
||||
this.args = args;
|
||||
this.seedVersionCache = seedVersionCache;
|
||||
this.onboardingCompleted = onboardingCompleted;
|
||||
this.wayfernTermsAccepted = wayfernTermsAccepted;
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
get dataRoot() {
|
||||
return path.join(this.root, "donut");
|
||||
}
|
||||
|
||||
async start() {
|
||||
await Promise.all([
|
||||
mkdir(path.join(this.root, "home"), { recursive: true }),
|
||||
mkdir(path.join(this.root, "tmp"), { recursive: true }),
|
||||
mkdir(path.join(this.root, "artifacts"), { recursive: true }),
|
||||
]);
|
||||
if (this.onboardingCompleted) {
|
||||
const settingsFile = path.join(
|
||||
this.dataRoot,
|
||||
"data",
|
||||
"settings",
|
||||
"app_settings.json",
|
||||
);
|
||||
await mkdir(path.dirname(settingsFile), { recursive: true });
|
||||
await writeFile(
|
||||
settingsFile,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
language: "en",
|
||||
onboarding_completed: true,
|
||||
commercial_trial_acknowledged: true,
|
||||
window_resize_warning_dismissed: true,
|
||||
disable_auto_updates: true,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ flag: "wx" },
|
||||
).catch((error) => {
|
||||
if (error.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.wayfernTermsAccepted) {
|
||||
const termsFile =
|
||||
process.platform === "darwin"
|
||||
? path.join(
|
||||
this.root,
|
||||
"home",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
)
|
||||
: process.platform === "win32"
|
||||
? path.join(
|
||||
this.root,
|
||||
"windows",
|
||||
"roaming",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
)
|
||||
: path.join(
|
||||
this.root,
|
||||
"xdg",
|
||||
"config",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
await mkdir(path.dirname(termsFile), { recursive: true });
|
||||
await writeFile(termsFile, `${Math.floor(Date.now() / 1000)}\n`, {
|
||||
flag: "wx",
|
||||
}).catch((error) => {
|
||||
if (error.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.seedVersionCache) {
|
||||
const versionCache = path.join(
|
||||
this.root,
|
||||
"donut",
|
||||
"cache",
|
||||
"version_cache",
|
||||
"wayfern_versions.json",
|
||||
);
|
||||
await mkdir(path.dirname(versionCache), { recursive: true });
|
||||
await writeFile(
|
||||
versionCache,
|
||||
`${JSON.stringify({
|
||||
releases: [{ version: "150.0.7871.100", date: "2026-07-01" }],
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
})}\n`,
|
||||
{ flag: "wx" },
|
||||
).catch((error) => {
|
||||
if (error.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
}
|
||||
const env = isolatedEnvironment(this.root, {
|
||||
DONUT_E2E_DISABLE_STARTUP_NETWORK: "1",
|
||||
...(process.env.DONUT_E2E_FIXTURE_URL
|
||||
? {
|
||||
DONUT_E2E_DNS_BLOCKLIST_BASE_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/dns`,
|
||||
...(process.env.DONUT_E2E_GEOIP_FIXTURE_READY === "1"
|
||||
? {
|
||||
DONUT_E2E_GEOIP_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip.mmdb`,
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {}),
|
||||
...(this.token ? { WAYFERN_TEST_TOKEN: this.token } : {}),
|
||||
...this.extraEnv,
|
||||
});
|
||||
this.session = await this.driver.createSession({
|
||||
application: this.application,
|
||||
args: this.args,
|
||||
env,
|
||||
cwd: this.cwd,
|
||||
startupTimeout: 120_000,
|
||||
});
|
||||
await this.session.setTimeouts();
|
||||
await this.waitFor(
|
||||
async () => {
|
||||
const ready = await this.execute(
|
||||
"return document.readyState === 'complete' && Boolean(window.__TAURI_INTERNALS__);",
|
||||
);
|
||||
return ready === true;
|
||||
},
|
||||
{
|
||||
description: `${this.name} frontend and Tauri bridge`,
|
||||
timeoutMs: 60_000,
|
||||
},
|
||||
);
|
||||
return this;
|
||||
}
|
||||
|
||||
async restart() {
|
||||
await this.close();
|
||||
return this.start();
|
||||
}
|
||||
|
||||
async execute(script, args = []) {
|
||||
assert.ok(this.session, `${this.name} is not started`);
|
||||
return this.session.execute(script, args);
|
||||
}
|
||||
|
||||
async invoke(command, args = {}) {
|
||||
assert.ok(this.session, `${this.name} is not started`);
|
||||
const result = await this.session.executeAsync(
|
||||
`
|
||||
const done = arguments[arguments.length - 1];
|
||||
const command = arguments[0];
|
||||
const args = arguments[1];
|
||||
window.__TAURI_INTERNALS__.invoke(command, args)
|
||||
.then((value) => done({ ok: true, value }))
|
||||
.catch((error) => done({
|
||||
ok: false,
|
||||
error: typeof error === "string" ? error : (error?.message ?? JSON.stringify(error))
|
||||
}));
|
||||
`,
|
||||
[command, args],
|
||||
);
|
||||
if (!result?.ok) {
|
||||
throw new Error(
|
||||
`Tauri command ${command} failed: ${result?.error ?? "unknown error"}`,
|
||||
);
|
||||
}
|
||||
return result.value;
|
||||
}
|
||||
|
||||
async invokeError(command, args = {}) {
|
||||
try {
|
||||
await this.invoke(command, args);
|
||||
} catch (error) {
|
||||
return String(error);
|
||||
}
|
||||
throw new Error(`Expected Tauri command ${command} to fail`);
|
||||
}
|
||||
|
||||
async bodyText() {
|
||||
return this.execute("return document.body?.innerText ?? '';");
|
||||
}
|
||||
|
||||
async html() {
|
||||
return this.execute("return document.documentElement?.outerHTML ?? '';");
|
||||
}
|
||||
|
||||
async visibleTextIncludes(text) {
|
||||
return this.execute(
|
||||
`
|
||||
const wanted = arguments[0];
|
||||
return [...document.querySelectorAll("body *")].some((node) => {
|
||||
const style = getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||
rect.width > 0 && rect.height > 0 &&
|
||||
(node.innerText ?? "").trim().includes(wanted);
|
||||
});
|
||||
`,
|
||||
[text],
|
||||
);
|
||||
}
|
||||
|
||||
async waitFor(
|
||||
check,
|
||||
{ timeoutMs = 20_000, intervalMs = 100, description = "condition" } = {},
|
||||
) {
|
||||
const started = Date.now();
|
||||
let lastError;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const value = await check();
|
||||
if (value) {
|
||||
return value;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out after ${timeoutMs}ms waiting for ${description}${lastError ? `: ${lastError}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
async waitForText(text, timeoutMs = 20_000) {
|
||||
return this.waitFor(() => this.visibleTextIncludes(text), {
|
||||
timeoutMs,
|
||||
description: `visible text ${JSON.stringify(text)}`,
|
||||
});
|
||||
}
|
||||
|
||||
async clickText(
|
||||
text,
|
||||
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
|
||||
) {
|
||||
const element = await this.execute(
|
||||
`
|
||||
const wanted = arguments[0];
|
||||
const exact = arguments[1];
|
||||
const roles = new Set(arguments[2]);
|
||||
const candidates = [...document.querySelectorAll("button, a, [role], [data-slot='button']")];
|
||||
const visible = (node) => {
|
||||
const style = getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||
rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
return candidates.find((node) => {
|
||||
const role = node.getAttribute("role") || (node.tagName === "A" ? "link" : "button");
|
||||
const label = (node.getAttribute("aria-label") || node.innerText || node.textContent || "").trim();
|
||||
return roles.has(role) && visible(node) && (exact ? label === wanted : label.includes(wanted));
|
||||
}) ?? null;
|
||||
`,
|
||||
[text, exact, roles],
|
||||
);
|
||||
assert.ok(
|
||||
element,
|
||||
`No visible interactive element matched ${JSON.stringify(text)}`,
|
||||
);
|
||||
await this.session.click(element);
|
||||
}
|
||||
|
||||
async clickTextIn(
|
||||
containerSelector,
|
||||
text,
|
||||
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
|
||||
) {
|
||||
const element = await this.execute(
|
||||
`
|
||||
const containers = [...document.querySelectorAll(arguments[0])];
|
||||
const wanted = arguments[1];
|
||||
const exact = arguments[2];
|
||||
const roles = new Set(arguments[3]);
|
||||
const visible = (node) => {
|
||||
const style = getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||
rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
for (const container of containers.reverse()) {
|
||||
if (!visible(container)) continue;
|
||||
const candidates = [...container.querySelectorAll("button, a, [role], [data-slot='button']")];
|
||||
const match = candidates.find((node) => {
|
||||
const role = node.getAttribute("role") || (node.tagName === "A" ? "link" : "button");
|
||||
const label = (node.getAttribute("aria-label") || node.innerText || node.textContent || "").trim();
|
||||
return roles.has(role) && visible(node) && (exact ? label === wanted : label.includes(wanted));
|
||||
});
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
`,
|
||||
[containerSelector, text, exact, roles],
|
||||
);
|
||||
assert.ok(
|
||||
element,
|
||||
`No visible interactive element inside ${containerSelector} matched ${JSON.stringify(text)}`,
|
||||
);
|
||||
await this.session.click(element);
|
||||
}
|
||||
|
||||
async clickSelector(selector) {
|
||||
const element = await this.waitFor(
|
||||
() =>
|
||||
this.execute(
|
||||
`
|
||||
const node = document.querySelector(arguments[0]);
|
||||
if (!node) return null;
|
||||
const style = getComputedStyle(node);
|
||||
const rect = node.getBoundingClientRect();
|
||||
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||
rect.width > 0 && rect.height > 0 ? node : null;
|
||||
`,
|
||||
[selector],
|
||||
),
|
||||
{ description: `visible selector ${selector}` },
|
||||
);
|
||||
await this.session.click(element);
|
||||
}
|
||||
|
||||
async fillSelector(selector, value) {
|
||||
const element = await this.waitFor(
|
||||
() =>
|
||||
this.execute("return document.querySelector(arguments[0]);", [
|
||||
selector,
|
||||
]),
|
||||
{ description: `selector ${selector}` },
|
||||
);
|
||||
await this.session.clear(element);
|
||||
await this.session.sendKeys(element, value);
|
||||
}
|
||||
|
||||
async pressShortcut({
|
||||
key,
|
||||
meta = false,
|
||||
ctrl = false,
|
||||
alt = false,
|
||||
shift = false,
|
||||
}) {
|
||||
await this.execute(
|
||||
`
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", {
|
||||
key: arguments[0],
|
||||
code: arguments[1],
|
||||
metaKey: arguments[2],
|
||||
ctrlKey: arguments[3],
|
||||
altKey: arguments[4],
|
||||
shiftKey: arguments[5],
|
||||
bubbles: true,
|
||||
cancelable: true
|
||||
}));
|
||||
`,
|
||||
[
|
||||
key,
|
||||
key.length === 1 ? `Key${key.toUpperCase()}` : key,
|
||||
meta,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
async capture(label) {
|
||||
if (!this.session) {
|
||||
return;
|
||||
}
|
||||
const safe = label.replace(/[^a-z0-9_.-]+/gi, "-");
|
||||
try {
|
||||
const png = await this.session.screenshot();
|
||||
await writeFile(
|
||||
path.join(this.root, "artifacts", `${safe}.png`),
|
||||
Buffer.from(png, "base64"),
|
||||
);
|
||||
} catch {
|
||||
// Best-effort diagnostics must never hide the original test failure.
|
||||
}
|
||||
try {
|
||||
await writeFile(
|
||||
path.join(this.root, "artifacts", `${safe}.html`),
|
||||
await this.html(),
|
||||
);
|
||||
} catch {
|
||||
// Best-effort diagnostics must never hide the original test failure.
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (!this.session) {
|
||||
return;
|
||||
}
|
||||
const session = this.session;
|
||||
this.session = null;
|
||||
await session.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function appFromEnvironment(name, options = {}) {
|
||||
const runRoot = process.env.DONUT_E2E_RUN_ROOT;
|
||||
assert.ok(runRoot, "DONUT_E2E_RUN_ROOT is required");
|
||||
return new AppSession({
|
||||
name,
|
||||
root: options.root ?? path.join(runRoot, "sessions", name),
|
||||
application: process.env.DONUT_E2E_APP,
|
||||
driverUrl: process.env.DONUT_E2E_DRIVER_URL,
|
||||
cwd: process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
token: process.env.WAYFERN_TEST_TOKEN,
|
||||
extraEnv: options.extraEnv,
|
||||
args: options.args,
|
||||
seedVersionCache: options.seedVersionCache,
|
||||
onboardingCompleted: options.onboardingCompleted,
|
||||
wayfernTermsAccepted: options.wayfernTermsAccepted,
|
||||
});
|
||||
}
|
||||
|
||||
export async function withApp(name, callback, options = {}) {
|
||||
const app = appFromEnvironment(name, options);
|
||||
try {
|
||||
await app.start();
|
||||
return await callback(app);
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export class CdpClient {
|
||||
constructor(socket) {
|
||||
this.socket = socket;
|
||||
this.nextId = 1;
|
||||
this.pending = new Map();
|
||||
socket.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(String(event.data));
|
||||
if (message.id === undefined) return;
|
||||
const pending = this.pending.get(message.id);
|
||||
if (!pending) return;
|
||||
this.pending.delete(message.id);
|
||||
if (message.error) {
|
||||
pending.reject(
|
||||
new Error(
|
||||
`CDP ${pending.method} failed: ${JSON.stringify(message.error)}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
pending.resolve(message.result ?? {});
|
||||
}
|
||||
});
|
||||
socket.addEventListener("close", () => {
|
||||
for (const pending of this.pending.values()) {
|
||||
pending.reject(
|
||||
new Error(`CDP socket closed while waiting for ${pending.method}`),
|
||||
);
|
||||
}
|
||||
this.pending.clear();
|
||||
});
|
||||
}
|
||||
|
||||
static async connect(port, { timeoutMs = 30_000 } = {}) {
|
||||
assert.equal(
|
||||
typeof WebSocket,
|
||||
"function",
|
||||
"This E2E suite requires Node.js 22+ WebSocket",
|
||||
);
|
||||
const started = Date.now();
|
||||
let lastError;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const response = await fetch(`http://127.0.0.1:${port}/json`, {
|
||||
signal: AbortSignal.timeout(1_000),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const targets = await response.json();
|
||||
const target = targets.find(
|
||||
(item) => item.type === "page" && item.webSocketDebuggerUrl,
|
||||
);
|
||||
if (!target) throw new Error("no debuggable page target");
|
||||
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||
await new Promise((resolve, reject) => {
|
||||
const timeout = setTimeout(
|
||||
() => reject(new Error("CDP WebSocket open timed out")),
|
||||
5_000,
|
||||
);
|
||||
socket.addEventListener(
|
||||
"open",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
socket.addEventListener(
|
||||
"error",
|
||||
() => {
|
||||
clearTimeout(timeout);
|
||||
reject(new Error("CDP WebSocket failed to open"));
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
return new CdpClient(socket);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
await sleep(100);
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out connecting to Wayfern CDP on ${port}: ${lastError}`,
|
||||
);
|
||||
}
|
||||
|
||||
command(method, params = {}) {
|
||||
const id = this.nextId++;
|
||||
return new Promise((resolve, reject) => {
|
||||
this.pending.set(id, { resolve, reject, method });
|
||||
this.socket.send(JSON.stringify({ id, method, params }));
|
||||
});
|
||||
}
|
||||
|
||||
async evaluate(expression) {
|
||||
const result = await this.command("Runtime.evaluate", {
|
||||
expression,
|
||||
awaitPromise: true,
|
||||
returnByValue: true,
|
||||
userGesture: true,
|
||||
});
|
||||
if (result.exceptionDetails) {
|
||||
throw new Error(
|
||||
`CDP evaluation failed: ${JSON.stringify(result.exceptionDetails)}`,
|
||||
);
|
||||
}
|
||||
return result.result?.value;
|
||||
}
|
||||
|
||||
async waitFor(
|
||||
expression,
|
||||
{ timeoutMs = 20_000, description = expression } = {},
|
||||
) {
|
||||
const started = Date.now();
|
||||
let lastError;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
try {
|
||||
const value = await this.evaluate(expression);
|
||||
if (value) return value;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
throw new Error(
|
||||
`Timed out waiting for ${description}${lastError ? `: ${lastError}` : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.socket.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import {
|
||||
redactSensitiveText,
|
||||
sensitiveVariants,
|
||||
} from "../../scripts/redact-sensitive-text.mjs";
|
||||
|
||||
const MAX_LOG_BYTES = 512 * 1024;
|
||||
|
||||
async function logFiles(directory, fileNamePattern = /\.(?:log|txt)$/iu) {
|
||||
const entries = await readdir(directory, { withFileTypes: true }).catch(
|
||||
() => [],
|
||||
);
|
||||
return entries
|
||||
.filter((entry) => entry.isFile() && fileNamePattern.test(entry.name))
|
||||
.map((entry) => path.join(directory, entry.name))
|
||||
.sort();
|
||||
}
|
||||
|
||||
async function diagnosticSources(runRoot) {
|
||||
const sources = await logFiles(path.join(runRoot, "logs"));
|
||||
const sessions = await readdir(path.join(runRoot, "sessions"), {
|
||||
withFileTypes: true,
|
||||
}).catch(() => []);
|
||||
for (const session of sessions.filter((entry) => entry.isDirectory())) {
|
||||
const root = path.join(runRoot, "sessions", session.name);
|
||||
sources.push(...(await logFiles(path.join(root, "donut", "logs"))));
|
||||
sources.push(
|
||||
...(await logFiles(path.join(root, "tmp"), /^donut-proxy-.*\.log$/iu)),
|
||||
);
|
||||
}
|
||||
return sources;
|
||||
}
|
||||
|
||||
export async function assertSafeDiagnostics(
|
||||
diagnosticsRoot,
|
||||
sensitiveValues = [],
|
||||
) {
|
||||
const entries = await readdir(diagnosticsRoot, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
if (!entry.isFile() || !/\.(?:json|log)$/iu.test(entry.name)) {
|
||||
throw new Error(`Unsafe diagnostics entry: ${entry.name}`);
|
||||
}
|
||||
const content = await readFile(
|
||||
path.join(diagnosticsRoot, entry.name),
|
||||
"utf8",
|
||||
);
|
||||
for (const value of sensitiveVariants(sensitiveValues)) {
|
||||
if (content.includes(value)) {
|
||||
throw new Error(
|
||||
`Sensitive value survived diagnostics redaction in ${entry.name}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function createSafeDiagnostics(
|
||||
runRoot,
|
||||
{ suite, failed, sensitiveValues = [] },
|
||||
) {
|
||||
const diagnosticsRoot = path.join(runRoot, "diagnostics");
|
||||
await mkdir(diagnosticsRoot, { recursive: true, mode: 0o700 });
|
||||
await chmod(diagnosticsRoot, 0o700);
|
||||
|
||||
const sources = await diagnosticSources(runRoot);
|
||||
for (const [index, source] of sources.entries()) {
|
||||
const content = await readFile(source, "utf8").catch(() => "");
|
||||
const tail = content.slice(-MAX_LOG_BYTES);
|
||||
const destination = path.join(
|
||||
diagnosticsRoot,
|
||||
`${String(index + 1).padStart(3, "0")}.log`,
|
||||
);
|
||||
await writeFile(
|
||||
destination,
|
||||
redactSensitiveText(tail, { sensitiveValues }),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await chmod(destination, 0o600);
|
||||
}
|
||||
|
||||
const summaryPath = path.join(diagnosticsRoot, "summary.json");
|
||||
await writeFile(
|
||||
summaryPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
suite,
|
||||
status: failed ? "failed" : "passed",
|
||||
sanitized_log_files: sources.length,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
await chmod(summaryPath, 0o600);
|
||||
await assertSafeDiagnostics(diagnosticsRoot, sensitiveValues);
|
||||
return diagnosticsRoot;
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, copyFile, cp, mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
export const TEST_BROWSER_VERSION = "150.0.7871.100";
|
||||
|
||||
export function defaultWayfernPath(projectRoot) {
|
||||
if (process.env.DONUT_E2E_WAYFERN_PATH) {
|
||||
return path.resolve(process.env.DONUT_E2E_WAYFERN_PATH);
|
||||
}
|
||||
const fixtureRoot = path.join(projectRoot, ".cache", "e2e-wayfern-fixture");
|
||||
return process.platform === "darwin"
|
||||
? path.join(fixtureRoot, "Wayfern.app")
|
||||
: path.join(
|
||||
fixtureRoot,
|
||||
process.platform === "win32" ? "Wayfern.exe" : "wayfern",
|
||||
);
|
||||
}
|
||||
|
||||
export function wayfernExecutable(bundlePath) {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(bundlePath, "Contents", "MacOS", "Wayfern");
|
||||
}
|
||||
return bundlePath;
|
||||
}
|
||||
|
||||
export function inspectWayfern(bundlePath) {
|
||||
const executable = wayfernExecutable(bundlePath);
|
||||
assert.ok(
|
||||
existsSync(executable),
|
||||
`Wayfern executable is missing: ${executable}`,
|
||||
);
|
||||
const output =
|
||||
process.platform === "darwin"
|
||||
? execFileSync(
|
||||
"/usr/bin/plutil",
|
||||
[
|
||||
"-extract",
|
||||
"CFBundleShortVersionString",
|
||||
"raw",
|
||||
"-o",
|
||||
"-",
|
||||
path.join(bundlePath, "Contents", "Info.plist"),
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
).trim()
|
||||
: execFileSync(executable, ["--version"], {
|
||||
encoding: "utf8",
|
||||
timeout: 15_000,
|
||||
}).trim();
|
||||
const match = output.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||||
assert.ok(match, `Could not parse Wayfern version from: ${output}`);
|
||||
return { bundlePath, executable, version: match[1], output };
|
||||
}
|
||||
|
||||
async function cloneAppBundle(source, destination) {
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
try {
|
||||
execFileSync("/bin/cp", ["-cR", source, destination]);
|
||||
} catch (_error) {
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
errorOnExist: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export async function seedWayfern(dataRoot, wayfern) {
|
||||
const installDir = path.join(
|
||||
dataRoot,
|
||||
"data",
|
||||
"binaries",
|
||||
"wayfern",
|
||||
wayfern.version,
|
||||
);
|
||||
await mkdir(installDir, { recursive: true });
|
||||
if (process.platform === "darwin") {
|
||||
await cloneAppBundle(
|
||||
wayfern.bundlePath,
|
||||
path.join(installDir, "Wayfern.app"),
|
||||
);
|
||||
} else {
|
||||
const name = process.platform === "win32" ? "wayfern.exe" : "wayfern";
|
||||
const destination = path.join(installDir, name);
|
||||
await copyFile(wayfern.executable, destination);
|
||||
if (process.platform !== "win32") {
|
||||
await chmod(destination, 0o755);
|
||||
}
|
||||
}
|
||||
const registry = {
|
||||
browsers: {
|
||||
wayfern: {
|
||||
[wayfern.version]: {
|
||||
browser: "wayfern",
|
||||
version: wayfern.version,
|
||||
file_path: installDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const registryPath = path.join(
|
||||
dataRoot,
|
||||
"data",
|
||||
"data",
|
||||
"downloaded_browsers.json",
|
||||
);
|
||||
await mkdir(path.dirname(registryPath), { recursive: true });
|
||||
await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
|
||||
return installDir;
|
||||
}
|
||||
|
||||
export async function prepareWayfern(app, projectRoot) {
|
||||
const localBundle = defaultWayfernPath(projectRoot);
|
||||
if (existsSync(localBundle)) {
|
||||
const wayfern = inspectWayfern(localBundle);
|
||||
await seedWayfern(app.dataRoot, wayfern);
|
||||
return { version: wayfern.version, source: "local fixture" };
|
||||
}
|
||||
|
||||
if (!app.session) await app.start();
|
||||
const current = await app.invoke("fetch_browser_versions_with_count", {
|
||||
browserStr: "wayfern",
|
||||
});
|
||||
assert.ok(
|
||||
current.versions.length > 0,
|
||||
"No Wayfern build is published for this platform",
|
||||
);
|
||||
const version = current.versions[0];
|
||||
await app.invoke("download_browser", {
|
||||
browserStr: "wayfern",
|
||||
version,
|
||||
});
|
||||
return { version, source: "published download" };
|
||||
}
|
||||
|
||||
export function wireGuardFixture() {
|
||||
return [
|
||||
"[Interface]",
|
||||
"PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||
"Address = 10.88.0.2/32",
|
||||
"DNS = 1.1.1.1",
|
||||
"",
|
||||
"[Peer]",
|
||||
"PublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
|
||||
"Endpoint = 127.0.0.1:51820",
|
||||
"AllowedIPs = 0.0.0.0/0",
|
||||
"PersistentKeepalive = 25",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function extensionZipBase64() {
|
||||
// A deterministic Manifest V3 ZIP containing only manifest.json. Generated
|
||||
// once and kept inline so the suite has no archiver dependency.
|
||||
return "UEsDBBQAAAAAAE8K9Fxo1IfNawAAAGsAAAANAAAAbWFuaWZlc3QuanNvbnsibWFuaWZlc3RfdmVyc2lvbiI6MywibmFtZSI6IkRvbnV0IEUyRSBGaXh0dXJlIiwidmVyc2lvbiI6IjEuMC4wIiwiZGVzY3JpcHRpb24iOiJJc29sYXRlZCB0ZXN0IGV4dGVuc2lvbiJ9UEsBAhQDFAAAAAAATwr0XGjUh81rAAAAawAAAA0AAAAAAAAAAAAAAIABAAAAAG1hbmlmZXN0Lmpzb25QSwUGAAAAAAEAAQA7AAAAlgAAAAAA";
|
||||
}
|
||||
|
||||
export function currentHostOs() {
|
||||
return os.platform() === "darwin"
|
||||
? "macos"
|
||||
: os.platform() === "win32"
|
||||
? "windows"
|
||||
: "linux";
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
||||
|
||||
function abortAfter(timeoutMs) {
|
||||
return AbortSignal.timeout(timeoutMs);
|
||||
}
|
||||
|
||||
export class WebDriverClient {
|
||||
constructor(baseUrl) {
|
||||
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||
}
|
||||
|
||||
async request(method, pathname, body, timeoutMs = 330_000) {
|
||||
const response = await fetch(`${this.baseUrl}${pathname}`, {
|
||||
method,
|
||||
headers:
|
||||
body === undefined ? undefined : { "content-type": "application/json" },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
signal: abortAfter(timeoutMs),
|
||||
});
|
||||
const text = await response.text();
|
||||
let payload = null;
|
||||
if (text) {
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`WebDriver ${method} ${pathname} returned non-JSON HTTP ${response.status}: ${text.slice(0, 500)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const error = payload?.value?.error;
|
||||
if (!response.ok) {
|
||||
const message =
|
||||
payload?.value?.message ?? text ?? `HTTP ${response.status}`;
|
||||
throw new Error(
|
||||
`WebDriver ${method} ${pathname} failed (${error ?? response.status}): ${message}`,
|
||||
);
|
||||
}
|
||||
return payload?.value;
|
||||
}
|
||||
|
||||
async status() {
|
||||
return this.request("GET", "/status");
|
||||
}
|
||||
|
||||
async createSession({
|
||||
application,
|
||||
args = [],
|
||||
env = {},
|
||||
cwd,
|
||||
startupTimeout = 90_000,
|
||||
}) {
|
||||
const options = { application, args, env, startupTimeout };
|
||||
if (cwd) {
|
||||
options.cwd = cwd;
|
||||
}
|
||||
const value = await this.request(
|
||||
"POST",
|
||||
"/session",
|
||||
{
|
||||
capabilities: {
|
||||
alwaysMatch: {
|
||||
"tauri:options": options,
|
||||
},
|
||||
},
|
||||
},
|
||||
startupTimeout + 10_000,
|
||||
);
|
||||
assert.ok(value?.sessionId, "WebDriver did not return a session id");
|
||||
return new WebDriverSession(
|
||||
this,
|
||||
value.sessionId,
|
||||
value.capabilities ?? {},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class WebDriverSession {
|
||||
constructor(client, id, capabilities) {
|
||||
this.client = client;
|
||||
this.id = id;
|
||||
this.capabilities = capabilities;
|
||||
this.closed = false;
|
||||
}
|
||||
|
||||
path(suffix = "") {
|
||||
return `/session/${encodeURIComponent(this.id)}${suffix}`;
|
||||
}
|
||||
|
||||
async command(method, suffix, body, timeoutMs) {
|
||||
return this.client.request(method, this.path(suffix), body, timeoutMs);
|
||||
}
|
||||
|
||||
async execute(script, args = []) {
|
||||
return this.command("POST", "/execute/sync", { script, args });
|
||||
}
|
||||
|
||||
async executeAsync(script, args = [], timeoutMs = 330_000) {
|
||||
return this.command("POST", "/execute/async", { script, args }, timeoutMs);
|
||||
}
|
||||
|
||||
async setTimeouts({
|
||||
implicit = 0,
|
||||
pageLoad = 300_000,
|
||||
script = 300_000,
|
||||
} = {}) {
|
||||
await this.command("POST", "/timeouts", { implicit, pageLoad, script });
|
||||
}
|
||||
|
||||
async find(using, value) {
|
||||
const element = await this.command("POST", "/element", { using, value });
|
||||
assert.ok(
|
||||
element?.[ELEMENT_KEY],
|
||||
`Element not found using ${using}: ${value}`,
|
||||
);
|
||||
return element;
|
||||
}
|
||||
|
||||
async findCss(selector) {
|
||||
return this.find("css selector", selector);
|
||||
}
|
||||
|
||||
async findXpath(xpath) {
|
||||
return this.find("xpath", xpath);
|
||||
}
|
||||
|
||||
async click(element) {
|
||||
await this.command(
|
||||
"POST",
|
||||
`/element/${encodeURIComponent(element[ELEMENT_KEY])}/click`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
async sendKeys(element, text) {
|
||||
const chars = [...String(text)];
|
||||
await this.command(
|
||||
"POST",
|
||||
`/element/${encodeURIComponent(element[ELEMENT_KEY])}/value`,
|
||||
{
|
||||
text: String(text),
|
||||
value: chars,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async clear(element) {
|
||||
await this.command(
|
||||
"POST",
|
||||
`/element/${encodeURIComponent(element[ELEMENT_KEY])}/clear`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
async title() {
|
||||
return this.command("GET", "/title");
|
||||
}
|
||||
|
||||
async screenshot() {
|
||||
return this.command("GET", "/screenshot");
|
||||
}
|
||||
|
||||
async close() {
|
||||
if (this.closed) {
|
||||
return;
|
||||
}
|
||||
this.closed = true;
|
||||
try {
|
||||
await this.command("DELETE", "");
|
||||
} catch (error) {
|
||||
if (!String(error).includes("invalid session id")) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+794
@@ -0,0 +1,794 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { spawn, spawnSync } from "node:child_process";
|
||||
import {
|
||||
createReadStream,
|
||||
createWriteStream,
|
||||
existsSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import {
|
||||
chmod,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rename,
|
||||
rm,
|
||||
} from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import https from "node:https";
|
||||
import net from "node:net";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pipeline } from "node:stream/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createSafeDiagnostics } from "./lib/diagnostics.mjs";
|
||||
|
||||
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const projectRoot = path.resolve(dirname, "..");
|
||||
const webdriverRoot = path.resolve(
|
||||
projectRoot,
|
||||
"../tauri-cross-platform-webdriver",
|
||||
);
|
||||
const isWindows = process.platform === "win32";
|
||||
const executableSuffix = isWindows ? ".exe" : "";
|
||||
const appBinary = path.join(
|
||||
projectRoot,
|
||||
"e2e",
|
||||
"app",
|
||||
"target",
|
||||
"debug",
|
||||
`donutbrowser-e2e${executableSuffix}`,
|
||||
);
|
||||
const driverBinary = path.join(
|
||||
webdriverRoot,
|
||||
"target",
|
||||
"debug",
|
||||
`tauri-wd${executableSuffix}`,
|
||||
);
|
||||
|
||||
const suiteFiles = {
|
||||
smoke: ["diagnostics.test.mjs", "smoke.test.mjs", "coverage.test.mjs"],
|
||||
ui: ["ui.test.mjs"],
|
||||
entities: ["entities.test.mjs"],
|
||||
network: ["network.test.mjs"],
|
||||
integrations: ["integrations.test.mjs"],
|
||||
sync: ["sync.test.mjs"],
|
||||
browser: ["browser.test.mjs"],
|
||||
full: [
|
||||
"diagnostics.test.mjs",
|
||||
"coverage.test.mjs",
|
||||
"smoke.test.mjs",
|
||||
"ui.test.mjs",
|
||||
"entities.test.mjs",
|
||||
"network.test.mjs",
|
||||
"integrations.test.mjs",
|
||||
"sync.test.mjs",
|
||||
"browser.test.mjs",
|
||||
],
|
||||
};
|
||||
|
||||
function parseArgs(argv) {
|
||||
const options = {
|
||||
suite: "full",
|
||||
build: true,
|
||||
keep: process.env.DONUT_E2E_KEEP_ARTIFACTS === "1",
|
||||
verbose: process.env.DONUT_E2E_VERBOSE === "1",
|
||||
};
|
||||
for (const arg of argv) {
|
||||
if (arg.startsWith("--suite=")) {
|
||||
options.suite = arg.slice("--suite=".length);
|
||||
} else if (arg === "--no-build") {
|
||||
options.build = false;
|
||||
} else if (arg === "--keep") {
|
||||
options.keep = true;
|
||||
} else if (arg === "--verbose") {
|
||||
options.verbose = true;
|
||||
} else {
|
||||
throw new Error(`Unknown E2E option: ${arg}`);
|
||||
}
|
||||
}
|
||||
if (!suiteFiles[options.suite]) {
|
||||
throw new Error(
|
||||
`Unknown suite ${options.suite}; expected ${Object.keys(suiteFiles).join(", ")}`,
|
||||
);
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function log(message) {
|
||||
process.stdout.write(`[donut-e2e] ${message}\n`);
|
||||
}
|
||||
|
||||
function run(command, args, cwd, env = process.env) {
|
||||
log(`${command} ${args.join(" ")}`);
|
||||
const result = spawnSync(command, args, { cwd, env, stdio: "inherit" });
|
||||
if (result.error) {
|
||||
throw result.error;
|
||||
}
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${command} exited with status ${result.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function freePort() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.unref();
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
server.close(() => resolve(address.port));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function waitForUrl(url, timeoutMs, processRecord) {
|
||||
const started = Date.now();
|
||||
let lastError;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (processRecord?.process.exitCode !== null) {
|
||||
throw new Error(
|
||||
`${processRecord.name} exited early with ${processRecord.process.exitCode}; see ${processRecord.logPath}`,
|
||||
);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
lastError = new Error(`HTTP ${response.status}`);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
throw new Error(`Timed out waiting for ${url}: ${lastError}`);
|
||||
}
|
||||
|
||||
function startProcess(name, command, args, { cwd, env, runRoot, verbose }) {
|
||||
const logPath = path.join(runRoot, "logs", `${name}.log`);
|
||||
const stream = createWriteStream(logPath, { flags: "a" });
|
||||
const child = spawn(command, args, {
|
||||
cwd,
|
||||
env,
|
||||
detached: !isWindows,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
child.stdout.pipe(stream, { end: false });
|
||||
child.stderr.pipe(stream, { end: false });
|
||||
if (verbose) {
|
||||
child.stdout.on("data", (chunk) =>
|
||||
process.stdout.write(`[${name}] ${chunk}`),
|
||||
);
|
||||
child.stderr.on("data", (chunk) =>
|
||||
process.stderr.write(`[${name}] ${chunk}`),
|
||||
);
|
||||
}
|
||||
child.on("error", (error) => {
|
||||
process.stderr.write(`[donut-e2e] ${name} process error: ${error}\n`);
|
||||
});
|
||||
return { name, process: child, stream, logPath };
|
||||
}
|
||||
|
||||
async function stopProcess(record) {
|
||||
if (!record || record.process.exitCode !== null) {
|
||||
record?.stream.end();
|
||||
return;
|
||||
}
|
||||
if (isWindows) {
|
||||
spawnSync("taskkill", ["/PID", String(record.process.pid), "/T", "/F"], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
process.kill(-record.process.pid, "SIGTERM");
|
||||
} catch {
|
||||
// The process group may already be gone.
|
||||
}
|
||||
}
|
||||
await Promise.race([
|
||||
new Promise((resolve) => record.process.once("exit", resolve)),
|
||||
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
||||
]);
|
||||
if (record.process.exitCode === null && !isWindows) {
|
||||
try {
|
||||
process.kill(-record.process.pid, "SIGKILL");
|
||||
} catch {
|
||||
// The process group may already be gone.
|
||||
}
|
||||
}
|
||||
record.stream.end();
|
||||
}
|
||||
|
||||
function unquoteEnvValue(value) {
|
||||
const trimmed = value.trim();
|
||||
const quote = trimmed[0];
|
||||
if ((quote === '"' || quote === "'") && trimmed.at(-1) === quote) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
async function loadLocalValues(names) {
|
||||
const values = Object.fromEntries(
|
||||
names
|
||||
.filter((name) => process.env[name])
|
||||
.map((name) => [name, process.env[name]]),
|
||||
);
|
||||
for (const file of [path.join(projectRoot, ".env")]) {
|
||||
try {
|
||||
const content = await readFile(file, "utf8");
|
||||
for (const name of names) {
|
||||
if (values[name]) continue;
|
||||
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const match = content.match(
|
||||
new RegExp(
|
||||
`^\\s*(?:export\\s+)?${escapedName}\\s*=\\s*(.+?)\\s*$`,
|
||||
"m",
|
||||
),
|
||||
);
|
||||
if (match) {
|
||||
values[name] = unquoteEnvValue(match[1]);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Individual suites validate the secrets they require.
|
||||
}
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function buildAll() {
|
||||
if (!existsSync(webdriverRoot)) {
|
||||
throw new Error(`Missing sibling webdriver repository: ${webdriverRoot}`);
|
||||
}
|
||||
run("pnpm", ["build"], projectRoot);
|
||||
run("pnpm", ["copy-proxy-binary"], projectRoot);
|
||||
run(
|
||||
"cargo",
|
||||
["build", "--locked", "--manifest-path", "e2e/app/Cargo.toml"],
|
||||
projectRoot,
|
||||
);
|
||||
run(
|
||||
"cargo",
|
||||
["build", "--package", "tauri-cross-platform-webdriver"],
|
||||
webdriverRoot,
|
||||
);
|
||||
}
|
||||
|
||||
function startFixtureServer(geoIpFixture) {
|
||||
const server = http.createServer((request, response) => {
|
||||
const url = new URL(request.url, "http://127.0.0.1");
|
||||
if (url.pathname === "/health") {
|
||||
response.writeHead(200, { "content-type": "text/plain" });
|
||||
response.end("ok");
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/api/echo") {
|
||||
const chunks = [];
|
||||
request.on("data", (chunk) => chunks.push(chunk));
|
||||
request.on("end", () => {
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/json",
|
||||
"set-cookie": "donut_e2e=browser-ok; Path=/; SameSite=Lax",
|
||||
});
|
||||
response.end(
|
||||
JSON.stringify({
|
||||
method: request.method,
|
||||
body: Buffer.concat(chunks).toString("utf8"),
|
||||
userAgent: request.headers["user-agent"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname.startsWith("/dns/")) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/plain; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
response.end("ads.e2e.invalid\ntracker.e2e.invalid\n");
|
||||
return;
|
||||
}
|
||||
if (url.pathname === "/geoip.mmdb" && geoIpFixture) {
|
||||
response.writeHead(200, {
|
||||
"content-type": "application/octet-stream",
|
||||
"content-length": String(statSync(geoIpFixture).size),
|
||||
});
|
||||
createReadStream(geoIpFixture).pipe(response);
|
||||
return;
|
||||
}
|
||||
response.writeHead(200, {
|
||||
"content-type": "text/html; charset=utf-8",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
response.end(`<!doctype html>
|
||||
<html>
|
||||
<head><title>Donut E2E Browser Fixture</title></head>
|
||||
<body>
|
||||
<h1 id="fixture-title">Donut E2E Browser Fixture</h1>
|
||||
<p id="path">${url.pathname}</p>
|
||||
<button id="fixture-button" onclick="this.dataset.clicked='yes'; this.textContent='Clicked'">Click fixture</button>
|
||||
<script>window.__fixtureReady = true;</script>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
return new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
resolve({ server, port: server.address().port });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureGeoIpFixture() {
|
||||
if (process.env.DONUT_E2E_GEOIP_FIXTURE) {
|
||||
const fixture = path.resolve(process.env.DONUT_E2E_GEOIP_FIXTURE);
|
||||
if (!existsSync(fixture)) {
|
||||
throw new Error(`DONUT_E2E_GEOIP_FIXTURE does not exist: ${fixture}`);
|
||||
}
|
||||
return fixture;
|
||||
}
|
||||
const toolsDir = path.join(os.tmpdir(), "donut-e2e-tools");
|
||||
const fixture = path.join(toolsDir, "GeoLite2-City.mmdb");
|
||||
await mkdir(toolsDir, { recursive: true });
|
||||
if (existsSync(fixture)) return fixture;
|
||||
|
||||
log("Downloading GeoLite City E2E dependency");
|
||||
const releases = await fetch(
|
||||
"https://api.github.com/repos/P3TERX/GeoLite.mmdb/releases",
|
||||
{
|
||||
headers: { "user-agent": "donut-browser-e2e" },
|
||||
signal: AbortSignal.timeout(30_000),
|
||||
},
|
||||
).then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`GeoLite release lookup failed with HTTP ${response.status}`,
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
});
|
||||
const url = releases
|
||||
.flatMap((release) => release.assets ?? [])
|
||||
.find((asset) => asset.name.endsWith("-City.mmdb"))?.browser_download_url;
|
||||
if (!url) throw new Error("No GeoLite City MMDB asset was found");
|
||||
const temporary = `${fixture}.${process.pid}.tmp`;
|
||||
await download(url, temporary);
|
||||
await rename(temporary, fixture);
|
||||
return fixture;
|
||||
}
|
||||
|
||||
function minioUrl() {
|
||||
const arch = os.arch() === "arm64" ? "arm64" : "amd64";
|
||||
if (process.platform === "darwin") {
|
||||
return `https://dl.min.io/server/minio/release/darwin-${arch}/minio`;
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
return `https://dl.min.io/server/minio/release/linux-${arch}/minio`;
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return "https://dl.min.io/server/minio/release/windows-amd64/minio.exe";
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported MinIO platform ${process.platform}-${os.arch()}`,
|
||||
);
|
||||
}
|
||||
|
||||
async function download(url, destination) {
|
||||
const transport = url.startsWith("https:") ? https : http;
|
||||
await new Promise((resolve, reject) => {
|
||||
transport
|
||||
.get(url, (response) => {
|
||||
if ([301, 302, 307, 308].includes(response.statusCode)) {
|
||||
response.resume();
|
||||
download(
|
||||
new URL(response.headers.location, url).href,
|
||||
destination,
|
||||
).then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
if (response.statusCode !== 200) {
|
||||
response.resume();
|
||||
reject(
|
||||
new Error(`Failed to download ${url}: HTTP ${response.statusCode}`),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const output = createWriteStream(destination, { mode: 0o755 });
|
||||
pipeline(response, output).then(resolve, reject);
|
||||
})
|
||||
.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
async function ensureMinio() {
|
||||
if (process.env.DONUT_E2E_MINIO_BIN) {
|
||||
return path.resolve(process.env.DONUT_E2E_MINIO_BIN);
|
||||
}
|
||||
const existingHarnessBinary = path.join(
|
||||
projectRoot,
|
||||
".cache",
|
||||
"sync-test",
|
||||
isWindows ? "minio.exe" : "minio",
|
||||
);
|
||||
if (existsSync(existingHarnessBinary)) {
|
||||
return existingHarnessBinary;
|
||||
}
|
||||
const toolsDir = path.join(os.tmpdir(), "donut-e2e-tools");
|
||||
const binary = path.join(
|
||||
toolsDir,
|
||||
`minio-${process.platform}-${os.arch()}${executableSuffix}`,
|
||||
);
|
||||
await mkdir(toolsDir, { recursive: true });
|
||||
if (!existsSync(binary)) {
|
||||
log("Downloading isolated MinIO test dependency");
|
||||
const temporary = `${binary}.${process.pid}.tmp`;
|
||||
await download(minioUrl(), temporary);
|
||||
await chmod(temporary, 0o755);
|
||||
await rm(binary, { force: true });
|
||||
await import("node:fs/promises").then(({ rename }) =>
|
||||
rename(temporary, binary),
|
||||
);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
async function startSyncInfrastructure(runRoot, options, records) {
|
||||
const minioBinary = await ensureMinio();
|
||||
const minioPort = await freePort();
|
||||
const minioConsolePort = await freePort();
|
||||
const syncPort = await freePort();
|
||||
const syncToken = "donut-e2e-sync-token-0123456789abcdef";
|
||||
const minio = startProcess(
|
||||
"minio",
|
||||
minioBinary,
|
||||
[
|
||||
"server",
|
||||
path.join(runRoot, "minio-data"),
|
||||
"--address",
|
||||
`127.0.0.1:${minioPort}`,
|
||||
"--console-address",
|
||||
`127.0.0.1:${minioConsolePort}`,
|
||||
],
|
||||
{
|
||||
cwd: projectRoot,
|
||||
runRoot,
|
||||
verbose: options.verbose,
|
||||
env: {
|
||||
...process.env,
|
||||
MINIO_ROOT_USER: "minioadmin",
|
||||
MINIO_ROOT_PASSWORD: "minioadmin",
|
||||
MINIO_BROWSER: "off",
|
||||
},
|
||||
},
|
||||
);
|
||||
records.push(minio);
|
||||
await waitForUrl(
|
||||
`http://127.0.0.1:${minioPort}/minio/health/live`,
|
||||
30_000,
|
||||
minio,
|
||||
);
|
||||
|
||||
const syncRoot = path.join(projectRoot, "donut-sync");
|
||||
await rm(path.join(syncRoot, "tsconfig.build.tsbuildinfo"), { force: true });
|
||||
await rm(path.join(syncRoot, "dist"), { recursive: true, force: true });
|
||||
run("pnpm", ["build"], syncRoot);
|
||||
const sync = startProcess("donut-sync", "node", ["dist/main.js"], {
|
||||
cwd: syncRoot,
|
||||
runRoot,
|
||||
verbose: options.verbose,
|
||||
env: {
|
||||
...process.env,
|
||||
PORT: String(syncPort),
|
||||
SYNC_TOKEN: syncToken,
|
||||
S3_ENDPOINT: `http://127.0.0.1:${minioPort}`,
|
||||
S3_REGION: "us-east-1",
|
||||
S3_ACCESS_KEY_ID: "minioadmin",
|
||||
S3_SECRET_ACCESS_KEY: "minioadmin",
|
||||
S3_BUCKET: `donut-e2e-${process.pid}`,
|
||||
S3_FORCE_PATH_STYLE: "true",
|
||||
},
|
||||
});
|
||||
records.push(sync);
|
||||
await waitForUrl(`http://127.0.0.1:${syncPort}/health`, 30_000, sync);
|
||||
return {
|
||||
minioUrl: `http://127.0.0.1:${minioPort}`,
|
||||
syncUrl: `http://127.0.0.1:${syncPort}`,
|
||||
syncToken,
|
||||
};
|
||||
}
|
||||
|
||||
function runDocker(args, { allowFailure = false } = {}) {
|
||||
const result = spawnSync("docker", args, {
|
||||
encoding: "utf8",
|
||||
timeout: 120_000,
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
if (!allowFailure && (result.error || result.status !== 0)) {
|
||||
throw new Error(
|
||||
`docker ${args[0]} failed: ${result.error?.message ?? result.stderr?.trim() ?? `exit ${result.status}`}`,
|
||||
);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function dockerAvailable() {
|
||||
const result = runDocker(["version"], { allowFailure: true });
|
||||
return !result.error && result.status === 0;
|
||||
}
|
||||
|
||||
async function startWireGuardInfrastructure() {
|
||||
if (!dockerAvailable()) {
|
||||
throw new Error(
|
||||
"The network E2E suite requires a running Docker daemon for its local WireGuard peer",
|
||||
);
|
||||
}
|
||||
|
||||
const port = await freePort();
|
||||
const name = `donut-wg-e2e-${process.pid}-${Date.now()}`;
|
||||
const image =
|
||||
process.env.DONUT_E2E_WIREGUARD_IMAGE ??
|
||||
"lscr.io/linuxserver/wireguard:latest";
|
||||
log("Starting isolated local WireGuard peer");
|
||||
runDocker([
|
||||
"run",
|
||||
"-d",
|
||||
"--name",
|
||||
name,
|
||||
"--cap-add=NET_ADMIN",
|
||||
"-p",
|
||||
`${port}:51820/udp`,
|
||||
"-e",
|
||||
"PEERS=1",
|
||||
"-e",
|
||||
"SERVERURL=127.0.0.1",
|
||||
"-e",
|
||||
"SERVERPORT=51820",
|
||||
"-e",
|
||||
"PEERDNS=auto",
|
||||
"-e",
|
||||
"INTERNAL_SUBNET=10.64.0.0",
|
||||
image,
|
||||
]);
|
||||
|
||||
try {
|
||||
const deadline = Date.now() + 45_000;
|
||||
let config = "";
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
const configResult = runDocker(
|
||||
["exec", name, "cat", "/config/peer1/peer1.conf"],
|
||||
{ allowFailure: true },
|
||||
);
|
||||
const statusResult = runDocker(["exec", name, "wg", "show"], {
|
||||
allowFailure: true,
|
||||
});
|
||||
if (
|
||||
configResult.status === 0 &&
|
||||
statusResult.status === 0 &&
|
||||
statusResult.stdout.includes("listening port")
|
||||
) {
|
||||
config = configResult.stdout;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!config) {
|
||||
throw new Error("Local WireGuard peer did not become ready within 45s");
|
||||
}
|
||||
|
||||
const server = runDocker([
|
||||
"exec",
|
||||
"-d",
|
||||
name,
|
||||
"sh",
|
||||
"-c",
|
||||
'while true; do printf "HTTP/1.1 200 OK\\r\\nContent-Length: 13\\r\\nConnection: close\\r\\n\\r\\nWG-TUNNEL-OK\\n" | nc -l -p 8080 >> /tmp/donut-e2e-target-requests 2>/dev/null; done',
|
||||
]);
|
||||
if (server.status !== 0) {
|
||||
throw new Error("Failed to start the WireGuard tunnel target server");
|
||||
}
|
||||
config = config.replace(
|
||||
/^Endpoint\s*=.*$/m,
|
||||
`Endpoint = 127.0.0.1:${port}`,
|
||||
);
|
||||
return {
|
||||
name,
|
||||
config,
|
||||
targetUrl: "http://10.64.0.1:8080/donut-e2e-wireguard",
|
||||
};
|
||||
} catch (error) {
|
||||
runDocker(["rm", "-f", name], { allowFailure: true });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareRetainedArtifacts(
|
||||
runRoot,
|
||||
{ suite, failed, sensitiveValues },
|
||||
) {
|
||||
const sessionsRoot = path.join(runRoot, "sessions");
|
||||
const sessions = await readdir(sessionsRoot, {
|
||||
withFileTypes: true,
|
||||
}).catch(() => []);
|
||||
await Promise.all(
|
||||
sessions
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) =>
|
||||
rm(path.join(sessionsRoot, entry.name, "donut", "data", "binaries"), {
|
||||
recursive: true,
|
||||
force: true,
|
||||
}),
|
||||
),
|
||||
);
|
||||
await createSafeDiagnostics(runRoot, { suite, failed, sensitiveValues });
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const options = parseArgs(process.argv.slice(2));
|
||||
const runRoot = await mkdtemp(path.join(os.tmpdir(), "donut-e2e-"));
|
||||
await mkdir(path.join(runRoot, "logs"), { recursive: true });
|
||||
const records = [];
|
||||
let fixture;
|
||||
let wireGuard;
|
||||
let failed = false;
|
||||
const sensitiveValues = [
|
||||
"donut-e2e-sync-token-0123456789abcdef",
|
||||
"minioadmin",
|
||||
];
|
||||
const cleanup = async () => {
|
||||
await Promise.all(records.reverse().map(stopProcess));
|
||||
if (fixture) {
|
||||
await new Promise((resolve) => fixture.server.close(resolve));
|
||||
}
|
||||
if (wireGuard) {
|
||||
runDocker(["rm", "-f", wireGuard.name], { allowFailure: true });
|
||||
}
|
||||
if (!options.keep && !failed) {
|
||||
await rm(runRoot, { recursive: true, force: true });
|
||||
} else {
|
||||
await prepareRetainedArtifacts(runRoot, {
|
||||
suite: options.suite,
|
||||
failed,
|
||||
sensitiveValues,
|
||||
});
|
||||
log(`Artifacts retained at ${runRoot}`);
|
||||
}
|
||||
};
|
||||
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||
process.once(signal, () => {
|
||||
failed = true;
|
||||
cleanup().finally(() => process.exit(signal === "SIGINT" ? 130 : 143));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
log(`Run root: ${runRoot}`);
|
||||
if (options.build) {
|
||||
buildAll();
|
||||
} else if (!existsSync(appBinary) || !existsSync(driverBinary)) {
|
||||
throw new Error(
|
||||
"--no-build requested but the E2E app or driver binary is missing",
|
||||
);
|
||||
}
|
||||
|
||||
const driverPort = await freePort();
|
||||
const driver = startProcess(
|
||||
"tauri-wd",
|
||||
driverBinary,
|
||||
[
|
||||
"--port",
|
||||
String(driverPort),
|
||||
"--max-sessions",
|
||||
"4",
|
||||
"--startup-timeout",
|
||||
"120",
|
||||
"--command-timeout",
|
||||
"330",
|
||||
"--log",
|
||||
options.verbose ? "debug" : "info",
|
||||
],
|
||||
{
|
||||
cwd: webdriverRoot,
|
||||
env: process.env,
|
||||
runRoot,
|
||||
verbose: options.verbose,
|
||||
},
|
||||
);
|
||||
records.push(driver);
|
||||
await waitForUrl(`http://127.0.0.1:${driverPort}/status`, 15_000, driver);
|
||||
|
||||
const needsBrowser =
|
||||
options.suite === "browser" ||
|
||||
options.suite === "network" ||
|
||||
options.suite === "full";
|
||||
const networkEnabled =
|
||||
(options.suite === "network" || options.suite === "full") &&
|
||||
process.env.DONUT_E2E_SKIP_NETWORK_TEST !== "1";
|
||||
const geoIpFixture = needsBrowser ? await ensureGeoIpFixture() : null;
|
||||
fixture = await startFixtureServer(geoIpFixture);
|
||||
let sync = {};
|
||||
if (options.suite === "sync" || options.suite === "full") {
|
||||
sync = await startSyncInfrastructure(runRoot, options, records);
|
||||
}
|
||||
if (networkEnabled && process.env.DONUT_E2E_SKIP_VPN_TUNNEL !== "1") {
|
||||
wireGuard = await startWireGuardInfrastructure();
|
||||
}
|
||||
|
||||
const localValues = await loadLocalValues([
|
||||
"WAYFERN_TEST_TOKEN",
|
||||
"RESIDENTIAL_PROXY_URL_ONE_SOCKS",
|
||||
"RESIDENTIAL_PROXY_URL_ONE_HTTP",
|
||||
]);
|
||||
sensitiveValues.push(...Object.values(localValues));
|
||||
const token = localValues.WAYFERN_TEST_TOKEN ?? "";
|
||||
if (needsBrowser && !token) {
|
||||
throw new Error("WAYFERN_TEST_TOKEN is required by the browser suite");
|
||||
}
|
||||
if (wireGuard) {
|
||||
sensitiveValues.push(
|
||||
wireGuard.config,
|
||||
Buffer.from(wireGuard.config).toString("base64"),
|
||||
);
|
||||
}
|
||||
|
||||
const files = suiteFiles[options.suite]
|
||||
.filter((file) => networkEnabled || file !== "network.test.mjs")
|
||||
.map((file) => path.join(dirname, "tests", file));
|
||||
const testArgs = [
|
||||
"--test",
|
||||
"--test-concurrency=1",
|
||||
"--test-reporter=spec",
|
||||
...files,
|
||||
];
|
||||
const child = spawn(process.execPath, testArgs, {
|
||||
cwd: projectRoot,
|
||||
env: {
|
||||
...process.env,
|
||||
DONUT_E2E_RUN_ROOT: runRoot,
|
||||
DONUT_E2E_PROJECT_ROOT: projectRoot,
|
||||
DONUT_E2E_WEBDRIVER_ROOT: webdriverRoot,
|
||||
DONUT_E2E_APP: appBinary,
|
||||
DONUT_E2E_DRIVER_URL: `http://127.0.0.1:${driverPort}`,
|
||||
DONUT_E2E_FIXTURE_URL: `http://127.0.0.1:${fixture.port}`,
|
||||
DONUT_E2E_GEOIP_FIXTURE_READY: geoIpFixture ? "1" : "0",
|
||||
WAYFERN_TEST_TOKEN: token,
|
||||
RESIDENTIAL_PROXY_URL_ONE_SOCKS:
|
||||
localValues.RESIDENTIAL_PROXY_URL_ONE_SOCKS ?? "",
|
||||
RESIDENTIAL_PROXY_URL_ONE_HTTP:
|
||||
localValues.RESIDENTIAL_PROXY_URL_ONE_HTTP ?? "",
|
||||
DONUT_E2E_SYNC_URL: sync.syncUrl ?? "",
|
||||
DONUT_E2E_SYNC_TOKEN: sync.syncToken ?? "",
|
||||
DONUT_E2E_MINIO_URL: sync.minioUrl ?? "",
|
||||
DONUT_E2E_WIREGUARD_CONFIG_BASE64: wireGuard
|
||||
? Buffer.from(wireGuard.config).toString("base64")
|
||||
: "",
|
||||
DONUT_E2E_WIREGUARD_TARGET_URL: wireGuard?.targetUrl ?? "",
|
||||
DONUT_E2E_WIREGUARD_CONTAINER: wireGuard?.name ?? "",
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
const exitCode = await new Promise((resolve, reject) => {
|
||||
child.once("error", reject);
|
||||
child.once("exit", (code, signal) => {
|
||||
resolve(code ?? (signal ? 1 : 0));
|
||||
});
|
||||
});
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(
|
||||
`E2E suite ${options.suite} failed with status ${exitCode}`,
|
||||
);
|
||||
}
|
||||
log(`Suite ${options.suite} passed`);
|
||||
} catch (error) {
|
||||
failed = true;
|
||||
process.stderr.write(`[donut-e2e] ERROR: ${error.stack ?? error}\n`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
@@ -0,0 +1,426 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readFile, stat } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { appFromEnvironment } from "../lib/app.mjs";
|
||||
import { CdpClient } from "../lib/cdp.mjs";
|
||||
import { defaultWayfernPath, prepareWayfern } from "../lib/fixtures.mjs";
|
||||
|
||||
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
|
||||
|
||||
async function request(url, { method = "GET", token, body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
let value = null;
|
||||
if (text) {
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
value = text;
|
||||
}
|
||||
}
|
||||
return { response, value };
|
||||
}
|
||||
|
||||
function processExists(pid) {
|
||||
if (!pid) return false;
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForProcessExit(app, pid) {
|
||||
await app.waitFor(() => !processExists(pid), {
|
||||
timeoutMs: 20_000,
|
||||
description: `Wayfern process ${pid} to exit`,
|
||||
});
|
||||
}
|
||||
|
||||
function assertIdleResourceBounds(pid) {
|
||||
if (process.platform === "win32") return;
|
||||
const output = execFileSync("ps", ["-o", "rss=,%cpu=", "-p", String(pid)], {
|
||||
encoding: "utf8",
|
||||
}).trim();
|
||||
const [rssText, cpuText] = output.split(/\s+/);
|
||||
const rssKiB = Number(rssText);
|
||||
const cpuPercent = Number(cpuText);
|
||||
assert.ok(
|
||||
rssKiB > 0 && rssKiB < 2_000_000,
|
||||
`Wayfern main process RSS is ${rssKiB} KiB`,
|
||||
);
|
||||
assert.ok(
|
||||
cpuPercent >= 0 && cpuPercent < 200,
|
||||
`Wayfern main process CPU is ${cpuPercent}%`,
|
||||
);
|
||||
}
|
||||
|
||||
function realWayfernTermsPath() {
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(
|
||||
os.homedir(),
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(
|
||||
process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"),
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
}
|
||||
return path.join(
|
||||
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"),
|
||||
"Wayfern",
|
||||
"license-accepted",
|
||||
);
|
||||
}
|
||||
|
||||
async function snapshotFile(file) {
|
||||
try {
|
||||
const [contents, metadata] = await Promise.all([
|
||||
readFile(file),
|
||||
stat(file, { bigint: true }),
|
||||
]);
|
||||
return {
|
||||
exists: true,
|
||||
contents: contents.toString("base64"),
|
||||
size: metadata.size.toString(),
|
||||
mtime: metadata.mtimeNs.toString(),
|
||||
};
|
||||
} catch (error) {
|
||||
if (error.code === "ENOENT") return { exists: false };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function createRealProfile(app, version, name, fingerprint = null) {
|
||||
return app.invoke("create_browser_profile_new", {
|
||||
name,
|
||||
browserStr: "wayfern",
|
||||
version,
|
||||
releaseType: "stable",
|
||||
proxyId: null,
|
||||
vpnId: null,
|
||||
wayfernConfig: {
|
||||
fingerprint,
|
||||
randomize_fingerprint_on_launch: false,
|
||||
geoip: false,
|
||||
},
|
||||
groupId: null,
|
||||
ephemeral: false,
|
||||
dnsBlocklist: null,
|
||||
launchHook: null,
|
||||
});
|
||||
}
|
||||
|
||||
test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and process cleanup", async () => {
|
||||
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||
const realTermsFile = realWayfernTermsPath();
|
||||
const realTermsBefore = await snapshotFile(realTermsFile);
|
||||
const hasLocalWayfern = existsSync(
|
||||
defaultWayfernPath(process.env.DONUT_E2E_PROJECT_ROOT),
|
||||
);
|
||||
const app = appFromEnvironment("browser-wayfern", {
|
||||
seedVersionCache: hasLocalWayfern,
|
||||
wayfernTermsAccepted: false,
|
||||
});
|
||||
let cdp;
|
||||
let browserPid;
|
||||
try {
|
||||
const prepared = await prepareWayfern(
|
||||
app,
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
if (!app.session) await app.start();
|
||||
|
||||
assert.equal(await app.invoke("check_wayfern_downloaded"), true);
|
||||
assert.equal(await app.invoke("check_wayfern_terms_accepted"), false);
|
||||
await app.invoke("accept_wayfern_terms");
|
||||
assert.equal(await app.invoke("check_wayfern_terms_accepted"), true);
|
||||
assert.ok(
|
||||
(
|
||||
await app.invoke("get_downloaded_browser_versions", {
|
||||
browserStr: "wayfern",
|
||||
})
|
||||
).includes(prepared.version),
|
||||
);
|
||||
assert.equal(
|
||||
await app.invoke("check_browser_exists", {
|
||||
browserStr: "wayfern",
|
||||
version: prepared.version,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.deepEqual(await app.invoke("check_missing_binaries"), []);
|
||||
assert.deepEqual(await app.invoke("ensure_all_binaries_exist"), []);
|
||||
assert.deepEqual(await app.invoke("ensure_active_browsers_downloaded"), []);
|
||||
assert.deepEqual(await app.invoke("get_supported_browsers"), ["wayfern"]);
|
||||
assert.equal(
|
||||
await app.invoke("is_browser_supported_on_platform", {
|
||||
browserStr: "wayfern",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await app.invoke("fetch_browser_versions_cached_first", {
|
||||
browserStr: "wayfern",
|
||||
})
|
||||
).some((item) => item.version === prepared.version),
|
||||
);
|
||||
assert.ok(
|
||||
(
|
||||
await app.invoke("fetch_browser_versions_with_count_cached_first", {
|
||||
browserStr: "wayfern",
|
||||
})
|
||||
).versions.includes(prepared.version),
|
||||
);
|
||||
assert.equal(
|
||||
(await app.invoke("get_browser_release_types", { browserStr: "wayfern" }))
|
||||
.stable,
|
||||
prepared.version,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("cancel_download", {
|
||||
browserStr: "wayfern",
|
||||
version: prepared.version,
|
||||
}),
|
||||
/No active download/,
|
||||
);
|
||||
|
||||
const sample = await app.invoke("generate_sample_fingerprint", {
|
||||
browser: "wayfern",
|
||||
version: prepared.version,
|
||||
configJson: JSON.stringify({ geoip: false }),
|
||||
});
|
||||
const fingerprint = JSON.parse(sample);
|
||||
assert.ok(
|
||||
Object.keys(fingerprint).length >= 10,
|
||||
"Wayfern returned an incomplete fingerprint",
|
||||
);
|
||||
|
||||
const profile = await createRealProfile(
|
||||
app,
|
||||
prepared.version,
|
||||
`Real Wayfern (${prepared.source})`,
|
||||
);
|
||||
assert.ok(profile.wayfern_config.fingerprint);
|
||||
assert.ok(
|
||||
Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >= 10,
|
||||
);
|
||||
assert.equal(await app.invoke("check_missing_geoip_database"), true);
|
||||
assert.equal(await app.invoke("is_geoip_database_available"), false);
|
||||
await app.invoke("download_geoip_database");
|
||||
assert.equal(await app.invoke("is_geoip_database_available"), true);
|
||||
assert.equal(await app.invoke("check_missing_geoip_database"), false);
|
||||
await app.invoke("update_wayfern_config", {
|
||||
profileId: profile.id,
|
||||
config: profile.wayfern_config,
|
||||
});
|
||||
await app.invoke("match_profile_fingerprint_to_exit", {
|
||||
profileId: profile.id,
|
||||
exitIp: "8.8.8.8",
|
||||
});
|
||||
const consistency = await app.invoke(
|
||||
"check_profile_fingerprint_consistency",
|
||||
{
|
||||
profileId: profile.id,
|
||||
},
|
||||
);
|
||||
assert.equal(typeof consistency, "object");
|
||||
|
||||
const directProfile = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
);
|
||||
const directLaunch = await app.invoke("launch_browser_profile", {
|
||||
profile: directProfile,
|
||||
url: `${fixtureUrl}/direct-command`,
|
||||
});
|
||||
assert.ok(directLaunch.process_id);
|
||||
await app.invoke("open_url_with_profile", {
|
||||
profileId: profile.id,
|
||||
url: `${fixtureUrl}/direct-open`,
|
||||
});
|
||||
await app.invoke("kill_browser_profile", { profile: directLaunch });
|
||||
await waitForProcessExit(app, directLaunch.process_id);
|
||||
|
||||
const settings = await app.invoke("get_app_settings");
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...settings,
|
||||
api_enabled: true,
|
||||
api_port: 0,
|
||||
api_token: null,
|
||||
onboarding_completed: true,
|
||||
},
|
||||
});
|
||||
const port = await app.invoke("start_api_server", { port: 0 });
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
const launched = await request(`${base}/v1/profiles/${profile.id}/run`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { url: `${fixtureUrl}/wayfern`, headless: true },
|
||||
});
|
||||
assert.equal(launched.response.status, 200, JSON.stringify(launched.value));
|
||||
assert.equal(launched.value.headless, true);
|
||||
|
||||
cdp = await CdpClient.connect(launched.value.remote_debugging_port);
|
||||
await cdp.waitFor(`document.title === "Donut E2E Browser Fixture"`, {
|
||||
description: "fixture page title",
|
||||
});
|
||||
assert.equal(
|
||||
await cdp.evaluate("document.querySelector('#path').textContent"),
|
||||
"/wayfern",
|
||||
);
|
||||
assert.equal(
|
||||
await cdp.evaluate(
|
||||
"document.querySelector('#fixture-button').click(); document.querySelector('#fixture-button').dataset.clicked",
|
||||
),
|
||||
"yes",
|
||||
);
|
||||
const echo = await cdp.evaluate(
|
||||
`fetch(${JSON.stringify(`${fixtureUrl}/api/echo`)}, {
|
||||
method: "POST",
|
||||
body: "wayfern-cdp-body"
|
||||
}).then((response) => response.json())`,
|
||||
);
|
||||
assert.equal(echo.method, "POST");
|
||||
assert.equal(echo.body, "wayfern-cdp-body");
|
||||
assert.ok(echo.userAgent.length > 20);
|
||||
assert.match(await cdp.evaluate("document.cookie"), /donut_e2e=browser-ok/);
|
||||
|
||||
const runningProfile = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
);
|
||||
browserPid = runningProfile.process_id;
|
||||
assert.equal(
|
||||
await app.invoke("check_browser_status", { profile: runningProfile }),
|
||||
true,
|
||||
);
|
||||
assertIdleResourceBounds(browserPid);
|
||||
if (process.platform !== "win32") {
|
||||
const command = execFileSync(
|
||||
"ps",
|
||||
["-ww", "-o", "command=", "-p", String(browserPid)],
|
||||
{
|
||||
encoding: "utf8",
|
||||
},
|
||||
);
|
||||
assert.match(
|
||||
command,
|
||||
new RegExp(app.dataRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||
);
|
||||
}
|
||||
|
||||
const opened = await request(`${base}/v1/profiles/${profile.id}/open-url`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { url: `${fixtureUrl}/opened-via-api` },
|
||||
});
|
||||
assert.equal(opened.response.status, 200);
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
const targets = await fetch(
|
||||
`http://127.0.0.1:${launched.value.remote_debugging_port}/json`,
|
||||
).then((response) => response.json());
|
||||
return targets.some((target) => target.url.includes("/opened-via-api"));
|
||||
},
|
||||
{ timeoutMs: 20_000, description: "API-opened Wayfern target" },
|
||||
);
|
||||
|
||||
const killed = await request(`${base}/v1/profiles/${profile.id}/kill`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(killed.response.status, 204);
|
||||
cdp.close();
|
||||
cdp = null;
|
||||
await waitForProcessExit(app, browserPid);
|
||||
const stoppedProfile = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
);
|
||||
assert.equal(
|
||||
await app.invoke("check_browser_status", { profile: stoppedProfile }),
|
||||
false,
|
||||
);
|
||||
|
||||
const batchProfile = await createRealProfile(
|
||||
app,
|
||||
prepared.version,
|
||||
"Wayfern Batch Automation",
|
||||
sample,
|
||||
);
|
||||
const batchRun = await request(`${base}/v1/profiles/batch/run`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
profile_ids: [batchProfile.id],
|
||||
url: `${fixtureUrl}/batch`,
|
||||
headless: true,
|
||||
},
|
||||
});
|
||||
assert.equal(batchRun.response.status, 200);
|
||||
assert.equal(
|
||||
batchRun.value.results[0].ok,
|
||||
true,
|
||||
batchRun.value.results[0].error,
|
||||
);
|
||||
const batchCdp = await CdpClient.connect(
|
||||
batchRun.value.results[0].remote_debugging_port,
|
||||
);
|
||||
assert.equal(
|
||||
await batchCdp.waitFor("window.__fixtureReady === true"),
|
||||
true,
|
||||
);
|
||||
batchCdp.close();
|
||||
const batchStop = await request(`${base}/v1/profiles/batch/stop`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { profile_ids: [batchProfile.id] },
|
||||
});
|
||||
assert.equal(batchStop.response.status, 200);
|
||||
assert.equal(
|
||||
batchStop.value.results[0].ok,
|
||||
true,
|
||||
batchStop.value.results[0].error,
|
||||
);
|
||||
|
||||
await app.invoke("stop_api_server");
|
||||
await app.invoke("delete_profile", { profileId: profile.id });
|
||||
await app.invoke("delete_profile", { profileId: batchProfile.id });
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
cdp?.close();
|
||||
if (app.session && browserPid && processExists(browserPid)) {
|
||||
const profile = (
|
||||
await app.invoke("list_browser_profiles").catch(() => [])
|
||||
).find((item) => item.process_id === browserPid);
|
||||
if (profile)
|
||||
await app.invoke("kill_browser_profile", { profile }).catch(() => {});
|
||||
}
|
||||
await app.close();
|
||||
assert.deepEqual(
|
||||
await snapshotFile(realTermsFile),
|
||||
realTermsBefore,
|
||||
"the browser suite modified the real Wayfern terms marker",
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,145 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
lstat,
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import http from "node:http";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { allCoveredCommands, commandCoverage } from "../coverage-map.mjs";
|
||||
import { seedWayfern } from "../lib/fixtures.mjs";
|
||||
import { WebDriverClient } from "../lib/webdriver.mjs";
|
||||
|
||||
function registeredCommands(source) {
|
||||
const match = source.match(
|
||||
/invoke_handler\(tauri::generate_handler!\[(.*?)\]\)/s,
|
||||
);
|
||||
assert.ok(match, "Could not locate Tauri generate_handler! command registry");
|
||||
const withoutComments = match[1].replace(/\/\/[^\n]*/g, "");
|
||||
return [
|
||||
...withoutComments.matchAll(/([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\s*,/g),
|
||||
].map((item) => item[1]);
|
||||
}
|
||||
|
||||
function commandHasExecutableEvidence(source, command) {
|
||||
const name = command
|
||||
.split("::")
|
||||
.at(-1)
|
||||
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
return new RegExp(
|
||||
`(?:invoke|invokeError)\\(\\s*["']${name}["']|invokeContract\\(\\s*\\w+\\s*,\\s*["']${name}["']`,
|
||||
).test(source);
|
||||
}
|
||||
|
||||
test("every Tauri command has exactly one E2E owner and evidence level", async () => {
|
||||
const root =
|
||||
process.env.DONUT_E2E_PROJECT_ROOT ??
|
||||
path.resolve(import.meta.dirname, "../..");
|
||||
const source = await readFile(
|
||||
path.join(root, "src-tauri", "src", "lib.rs"),
|
||||
"utf8",
|
||||
);
|
||||
const registered = registeredCommands(source);
|
||||
const covered = allCoveredCommands();
|
||||
assert.deepEqual(
|
||||
[...new Set(covered)].sort(),
|
||||
covered.slice().sort(),
|
||||
"The E2E coverage map contains duplicate command ownership",
|
||||
);
|
||||
assert.deepEqual(covered.slice().sort(), registered.slice().sort());
|
||||
|
||||
for (const [name, entry] of Object.entries(commandCoverage)) {
|
||||
assert.ok(
|
||||
["integration", "contract", "host-mutating"].includes(entry.level),
|
||||
name,
|
||||
);
|
||||
assert.ok(entry.commands.length > 0, `${name} has no commands`);
|
||||
if (entry.level === "host-mutating") {
|
||||
assert.ok(
|
||||
entry.reason?.length > 80,
|
||||
`${name} needs an explicit safety reason`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const evidenceFiles = [
|
||||
path.join(root, "e2e", "tests", `${entry.suite}.test.mjs`),
|
||||
...(entry.suite === "browser"
|
||||
? [path.join(root, "e2e", "lib", "fixtures.mjs")]
|
||||
: []),
|
||||
];
|
||||
const suiteSource = (
|
||||
await Promise.all(evidenceFiles.map((file) => readFile(file, "utf8")))
|
||||
).join("\n");
|
||||
for (const command of entry.commands) {
|
||||
assert.equal(
|
||||
commandHasExecutableEvidence(suiteSource, command),
|
||||
true,
|
||||
`${command} is assigned to ${entry.suite} but has no executable invoke evidence`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("WebDriver client preserves application values that contain an error field", async () => {
|
||||
const server = http.createServer((_request, response) => {
|
||||
response.writeHead(200, { "content-type": "application/json" });
|
||||
response.end(
|
||||
JSON.stringify({ value: { ok: false, error: "application error" } }),
|
||||
);
|
||||
});
|
||||
await new Promise((resolve, reject) => {
|
||||
server.once("error", reject);
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
try {
|
||||
const address = server.address();
|
||||
const client = new WebDriverClient(`http://127.0.0.1:${address.port}`);
|
||||
assert.deepEqual(await client.request("GET", "/value"), {
|
||||
ok: false,
|
||||
error: "application error",
|
||||
});
|
||||
} finally {
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("Wayfern fixtures are copied into the isolated data root, never linked", async (t) => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "donut-wayfern-copy-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const source =
|
||||
process.platform === "darwin"
|
||||
? path.join(root, "source", "Wayfern.app", "Contents", "MacOS", "Wayfern")
|
||||
: path.join(
|
||||
root,
|
||||
"source",
|
||||
process.platform === "win32" ? "Wayfern.exe" : "wayfern",
|
||||
);
|
||||
await mkdir(path.dirname(source), { recursive: true });
|
||||
await writeFile(source, "source-fixture");
|
||||
const bundlePath =
|
||||
process.platform === "darwin"
|
||||
? path.join(root, "source", "Wayfern.app")
|
||||
: source;
|
||||
const installDir = await seedWayfern(path.join(root, "isolated"), {
|
||||
bundlePath,
|
||||
executable: source,
|
||||
version: "1.2.3.4",
|
||||
});
|
||||
const destination =
|
||||
process.platform === "darwin"
|
||||
? path.join(installDir, "Wayfern.app", "Contents", "MacOS", "Wayfern")
|
||||
: path.join(
|
||||
installDir,
|
||||
process.platform === "win32" ? "wayfern.exe" : "wayfern",
|
||||
);
|
||||
|
||||
assert.equal((await lstat(destination)).isSymbolicLink(), false);
|
||||
await writeFile(destination, "isolated-mutation");
|
||||
assert.equal(await readFile(source, "utf8"), "source-fixture");
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { after, test } from "node:test";
|
||||
import { redactIssueBody } from "../../scripts/redact-sensitive-text.mjs";
|
||||
import { createSafeDiagnostics } from "../lib/diagnostics.mjs";
|
||||
|
||||
const roots = [];
|
||||
after(async () => {
|
||||
await Promise.all(
|
||||
roots.map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test("shared E2E diagnostics contain only redacted text logs", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "donut-diagnostics-test-"));
|
||||
roots.push(root);
|
||||
const secretUrl = "http://real-user:real-password@proxy.example:8080";
|
||||
const token = ["github", "pat", "example", "token", "0123456789"].join("_");
|
||||
const logText = [
|
||||
`proxy=${secretUrl}`,
|
||||
`Authorization: Bearer ${token}`,
|
||||
"visited https://example.com/callback?code=private-code",
|
||||
"exit IP 203.0.113.42",
|
||||
"home /Users/private-person/Library/Application Support",
|
||||
"email private.person@example.com",
|
||||
"PrivateKey = wireguard-private-key",
|
||||
].join("\n");
|
||||
|
||||
await Promise.all([
|
||||
mkdir(path.join(root, "logs"), { recursive: true }),
|
||||
mkdir(path.join(root, "sessions", "network", "donut", "logs"), {
|
||||
recursive: true,
|
||||
}),
|
||||
mkdir(path.join(root, "sessions", "network", "donut", "data", "proxies"), {
|
||||
recursive: true,
|
||||
}),
|
||||
mkdir(path.join(root, "sessions", "network", "artifacts"), {
|
||||
recursive: true,
|
||||
}),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(path.join(root, "logs", "driver.log"), logText),
|
||||
writeFile(
|
||||
path.join(root, "sessions", "network", "donut", "logs", "app.log"),
|
||||
logText,
|
||||
),
|
||||
writeFile(
|
||||
path.join(
|
||||
root,
|
||||
"sessions",
|
||||
"network",
|
||||
"donut",
|
||||
"data",
|
||||
"proxies",
|
||||
"real.json",
|
||||
),
|
||||
JSON.stringify({ upstream_url: secretUrl, token }),
|
||||
),
|
||||
writeFile(
|
||||
path.join(root, "sessions", "network", "artifacts", "page.html"),
|
||||
`<html>${secretUrl}</html>`,
|
||||
),
|
||||
]);
|
||||
|
||||
const diagnostics = await createSafeDiagnostics(root, {
|
||||
suite: "network",
|
||||
failed: true,
|
||||
sensitiveValues: [secretUrl, token],
|
||||
});
|
||||
const files = await readdir(diagnostics);
|
||||
assert.deepEqual(files.sort(), ["001.log", "002.log", "summary.json"]);
|
||||
const combined = (
|
||||
await Promise.all(
|
||||
files.map((file) => readFile(path.join(diagnostics, file), "utf8")),
|
||||
)
|
||||
).join("\n");
|
||||
for (const value of [
|
||||
secretUrl,
|
||||
"real-user",
|
||||
"real-password",
|
||||
"proxy.example",
|
||||
token,
|
||||
"private-code",
|
||||
"203.0.113.42",
|
||||
"private-person",
|
||||
"private.person@example.com",
|
||||
"wireguard-private-key",
|
||||
]) {
|
||||
assert.ok(!combined.includes(value), `diagnostics leaked ${value}`);
|
||||
}
|
||||
assert.ok(
|
||||
!files.some(
|
||||
(file) => /\.(?:html|json)$/u.test(file) && file !== "summary.json",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("automated issue processing omits the complete log field", () => {
|
||||
const safe = redactIssueBody(
|
||||
`### What happened?\nA failure at user@example.com\n\n### Error logs or screenshots\nARBITRARY_PRIVATE_LOG_CONTENT\npassword=hunter2\n\n### Operating System\nLinux`,
|
||||
);
|
||||
assert.ok(!safe.includes("ARBITRARY_PRIVATE_LOG_CONTENT"));
|
||||
assert.ok(!safe.includes("hunter2"));
|
||||
assert.ok(!safe.includes("user@example.com"));
|
||||
assert.match(safe, /omitted from automated processing/u);
|
||||
assert.match(safe, /Operating System\nLinux/u);
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
|
||||
|
||||
async function createProfile(app, name = "Entity Profile") {
|
||||
return app.invoke("create_browser_profile_new", {
|
||||
name,
|
||||
browserStr: "wayfern",
|
||||
version: "150.0.7871.100",
|
||||
releaseType: "stable",
|
||||
proxyId: null,
|
||||
vpnId: null,
|
||||
// CRUD-focused suites use a deterministic stored fingerprint. The browser
|
||||
// suite separately exercises real Wayfern fingerprint generation.
|
||||
wayfernConfig: { fingerprint: "{}" },
|
||||
groupId: null,
|
||||
ephemeral: false,
|
||||
dnsBlocklist: null,
|
||||
launchHook: null,
|
||||
});
|
||||
}
|
||||
|
||||
test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", async () => {
|
||||
await withApp("entities-core", async (app) => {
|
||||
const group = await app.invoke("create_profile_group", {
|
||||
name: "Research",
|
||||
});
|
||||
assert.equal(group.name, "Research");
|
||||
const renamedGroup = await app.invoke("update_profile_group", {
|
||||
groupId: group.id,
|
||||
name: "Research Team",
|
||||
});
|
||||
assert.equal(renamedGroup.name, "Research Team");
|
||||
|
||||
const duplicateError = await app.invokeError("create_profile_group", {
|
||||
name: "Research Team",
|
||||
});
|
||||
assert.match(duplicateError, /GROUP_ALREADY_EXISTS|already exists/i);
|
||||
|
||||
const proxy = await app.invoke("create_stored_proxy", {
|
||||
name: "Local Dead Proxy",
|
||||
proxySettings: {
|
||||
proxy_type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 9,
|
||||
username: "e2e-user",
|
||||
password: "e2e-pass",
|
||||
},
|
||||
});
|
||||
assert.equal(proxy.proxy_settings.password, "e2e-pass");
|
||||
const updatedProxy = await app.invoke("update_stored_proxy", {
|
||||
proxyId: proxy.id,
|
||||
name: "Updated Proxy",
|
||||
proxySettings: {
|
||||
proxy_type: "socks5",
|
||||
host: "127.0.0.1",
|
||||
port: 9,
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
});
|
||||
assert.equal(updatedProxy.name, "Updated Proxy");
|
||||
assert.equal(updatedProxy.updated_at >= proxy.updated_at, true);
|
||||
|
||||
const parsed = await app.invoke("parse_txt_proxies", {
|
||||
content: [
|
||||
"http://one.example:8080",
|
||||
"two.example:1080:user:pass",
|
||||
"not a proxy",
|
||||
].join("\n"),
|
||||
});
|
||||
assert.equal(parsed.length, 3);
|
||||
assert.ok(parsed.some((result) => result.status === "parsed"));
|
||||
assert.ok(parsed.some((result) => result.status === "invalid"));
|
||||
const parsedProxy = parsed.find((result) => result.status === "parsed");
|
||||
const { status: _status, ...parsedProxyFields } = parsedProxy;
|
||||
const parsedImport = await app.invoke("import_proxies_from_parsed", {
|
||||
parsedProxies: [parsedProxyFields],
|
||||
namePrefix: "Parsed",
|
||||
});
|
||||
assert.equal(parsedImport.imported_count, 1);
|
||||
|
||||
const validityError = await app.invokeError("check_proxy_validity", {
|
||||
proxyId: proxy.id,
|
||||
proxySettings: null,
|
||||
});
|
||||
assert.match(validityError, /Proxy check failed|Could not connect/i);
|
||||
const cachedValidity = await app.invoke("get_cached_proxy_check", {
|
||||
proxyId: proxy.id,
|
||||
});
|
||||
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
|
||||
|
||||
const exported = JSON.parse(
|
||||
await app.invoke("export_proxies", { format: "json" }),
|
||||
);
|
||||
assert.equal(exported.proxies.length, 2);
|
||||
assert.ok(exported.proxies.some((item) => item.name === "Updated Proxy"));
|
||||
assert.ok(exported.proxies.some((item) => item.name === "Parsed Proxy 1"));
|
||||
const importResult = await app.invoke("import_proxies_json", {
|
||||
content: JSON.stringify({
|
||||
version: "1",
|
||||
source: "Donut Browser",
|
||||
exported_at: new Date().toISOString(),
|
||||
proxies: [
|
||||
{
|
||||
name: "Imported Proxy",
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 8081,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.equal(importResult.imported_count, 1);
|
||||
|
||||
const profile = await createProfile(app);
|
||||
assert.equal(profile.name, "Entity Profile");
|
||||
assert.equal(
|
||||
(
|
||||
await app.invoke("update_profile_proxy", {
|
||||
profileId: profile.id,
|
||||
proxyId: proxy.id,
|
||||
})
|
||||
).proxy_id,
|
||||
proxy.id,
|
||||
);
|
||||
await app.invoke("assign_profiles_to_group", {
|
||||
profileIds: [profile.id],
|
||||
groupId: group.id,
|
||||
});
|
||||
await app.invoke("rename_profile", {
|
||||
profileId: profile.id,
|
||||
newName: "Renamed Profile",
|
||||
});
|
||||
await app.invoke("update_profile_tags", {
|
||||
profileId: profile.id,
|
||||
tags: ["alpha", "automation"],
|
||||
});
|
||||
await app.invoke("update_profile_note", {
|
||||
profileId: profile.id,
|
||||
note: "Extensive E2E metadata",
|
||||
});
|
||||
await app.invoke("update_profile_window_color", {
|
||||
profileId: profile.id,
|
||||
windowColor: "#123456",
|
||||
});
|
||||
await app.invoke("update_profile_launch_hook", {
|
||||
profileId: profile.id,
|
||||
launchHook: `${process.env.DONUT_E2E_FIXTURE_URL}/launch-hook`,
|
||||
});
|
||||
const invalidHook = await app.invokeError("update_profile_launch_hook", {
|
||||
profileId: profile.id,
|
||||
launchHook: "file:///etc/passwd",
|
||||
});
|
||||
assert.match(invalidHook, /INVALID_LAUNCH_HOOK_URL/);
|
||||
await app.invoke("update_profile_proxy_bypass_rules", {
|
||||
profileId: profile.id,
|
||||
rules: ["localhost", "*.internal.example"],
|
||||
});
|
||||
await app.invoke("update_profile_dns_blocklist", {
|
||||
profileId: profile.id,
|
||||
dnsBlocklist: "light",
|
||||
});
|
||||
await app.invoke("update_profile_clear_on_close", {
|
||||
profileId: profile.id,
|
||||
clearOnClose: true,
|
||||
});
|
||||
|
||||
const profiles = await app.invoke("list_browser_profiles");
|
||||
const changed = profiles.find((item) => item.id === profile.id);
|
||||
assert.deepEqual(changed.tags, ["alpha", "automation"]);
|
||||
assert.equal(changed.note, "Extensive E2E metadata");
|
||||
assert.equal(changed.window_color, "#123456");
|
||||
assert.equal(changed.group_id, group.id);
|
||||
assert.deepEqual(changed.proxy_bypass_rules, [
|
||||
"localhost",
|
||||
"*.internal.example",
|
||||
]);
|
||||
assert.equal(changed.dns_blocklist, "light");
|
||||
assert.equal(changed.clear_on_close, true);
|
||||
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
|
||||
"alpha",
|
||||
"automation",
|
||||
]);
|
||||
|
||||
assert.ok(Array.isArray(await app.invoke("detect_existing_profiles")));
|
||||
const importRoot = path.join(app.root, "profile-import-fixture");
|
||||
const importProfile = path.join(importRoot, "Default");
|
||||
await mkdir(importProfile, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(importProfile, "Preferences"),
|
||||
JSON.stringify({ profile: { name: "Imported fixture" } }),
|
||||
);
|
||||
const scanned = await app.invoke("scan_folder_for_profiles", {
|
||||
folderPath: importRoot,
|
||||
});
|
||||
assert.equal(scanned.length, 1);
|
||||
assert.equal(scanned[0].mapped_browser, "wayfern");
|
||||
const importBatch = await app.invoke("import_browser_profiles", {
|
||||
items: [
|
||||
{
|
||||
source_path: scanned[0].path,
|
||||
browser_type: scanned[0].browser,
|
||||
new_profile_name: "Imported Profile",
|
||||
proxy_id: null,
|
||||
vpn_id: null,
|
||||
},
|
||||
],
|
||||
groupId: null,
|
||||
duplicateStrategy: "rename",
|
||||
wayfernConfig: null,
|
||||
});
|
||||
assert.equal(importBatch.imported_count + importBatch.failed_count, 1);
|
||||
const archivePath = path.join(app.root, "profile-import-fixture.zip");
|
||||
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
|
||||
const archiveScan = await app.invoke("scan_profile_archive", {
|
||||
archivePath,
|
||||
});
|
||||
assert.ok(Array.isArray(archiveScan.profiles));
|
||||
await app.invoke("cleanup_profile_import_scratch", {
|
||||
extractedDir: archiveScan.extracted_dir,
|
||||
});
|
||||
|
||||
const clone = await app.invoke("clone_profile", {
|
||||
profileId: profile.id,
|
||||
name: "Cloned Profile",
|
||||
});
|
||||
assert.notEqual(clone.id, profile.id);
|
||||
assert.equal(clone.name, "Cloned Profile");
|
||||
const counts = await app.invoke("get_groups_with_profile_counts");
|
||||
assert.equal(counts.find((item) => item.id === group.id).count, 2);
|
||||
assert.equal((await app.invoke("get_profile_groups")).length, 1);
|
||||
|
||||
await app.invoke("delete_selected_profiles", {
|
||||
profileIds: [profile.id, clone.id],
|
||||
});
|
||||
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
|
||||
await app.invoke("delete_profile_group", { groupId: group.id });
|
||||
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
|
||||
for (const importedProxy of (await app.invoke("get_stored_proxies")).filter(
|
||||
(item) =>
|
||||
item.name === "Imported Proxy" || item.name.startsWith("Parsed Proxy"),
|
||||
)) {
|
||||
await app.invoke("delete_stored_proxy", { proxyId: importedProxy.id });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("extensions, extension groups, VPN storage, DNS rules, and event-backed assignments", async () => {
|
||||
await withApp("entities-network-extension", async (app) => {
|
||||
const profile = await createProfile(app, "Assignment Profile");
|
||||
const extension = await app.invoke("add_extension", {
|
||||
name: "E2E Fixture Extension",
|
||||
fileName: "fixture.zip",
|
||||
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
||||
});
|
||||
assert.equal(extension.name, "Donut E2E Fixture");
|
||||
assert.equal(extension.version, "1.0.0");
|
||||
const extensionGroup = await app.invoke("create_extension_group", {
|
||||
name: "Automation Extensions",
|
||||
});
|
||||
const populated = await app.invoke("add_extension_to_group", {
|
||||
groupId: extensionGroup.id,
|
||||
extensionId: extension.id,
|
||||
});
|
||||
assert.deepEqual(populated.extension_ids, [extension.id]);
|
||||
await app.invoke("assign_extension_group_to_profile", {
|
||||
profileId: profile.id,
|
||||
extensionGroupId: extensionGroup.id,
|
||||
});
|
||||
assert.equal(
|
||||
(
|
||||
await app.invoke("get_extension_group_for_profile", {
|
||||
profileId: profile.id,
|
||||
})
|
||||
).id,
|
||||
extensionGroup.id,
|
||||
);
|
||||
const renamed = await app.invoke("update_extension", {
|
||||
extensionId: extension.id,
|
||||
name: "Renamed Fixture Extension",
|
||||
fileName: null,
|
||||
fileData: null,
|
||||
});
|
||||
assert.equal(renamed.name, "Renamed Fixture Extension");
|
||||
assert.equal(
|
||||
await app.invoke("get_extension_icon", { extensionId: extension.id }),
|
||||
null,
|
||||
);
|
||||
const changedGroup = await app.invoke("update_extension_group", {
|
||||
groupId: extensionGroup.id,
|
||||
name: "Renamed Extension Group",
|
||||
extensionIds: [extension.id],
|
||||
});
|
||||
assert.equal(changedGroup.name, "Renamed Extension Group");
|
||||
assert.equal((await app.invoke("list_extensions")).length, 1);
|
||||
assert.equal((await app.invoke("list_extension_groups")).length, 1);
|
||||
await app.invoke("remove_extension_from_group", {
|
||||
groupId: extensionGroup.id,
|
||||
extensionId: extension.id,
|
||||
});
|
||||
await app.invoke("assign_extension_group_to_profile", {
|
||||
profileId: profile.id,
|
||||
extensionGroupId: null,
|
||||
});
|
||||
await app.invoke("delete_extension_group", { groupId: extensionGroup.id });
|
||||
await app.invoke("delete_extension", { extensionId: extension.id });
|
||||
|
||||
const vpn = await app.invoke("create_vpn_config_manual", {
|
||||
name: "E2E WireGuard",
|
||||
vpnType: "WireGuard",
|
||||
configData: wireGuardFixture(),
|
||||
});
|
||||
assert.equal(vpn.name, "E2E WireGuard");
|
||||
assert.equal(
|
||||
(await app.invoke("get_vpn_config", { vpnId: vpn.id })).id,
|
||||
vpn.id,
|
||||
);
|
||||
assert.equal((await app.invoke("list_vpn_configs")).length, 1);
|
||||
const updatedVpn = await app.invoke("update_vpn_config", {
|
||||
vpnId: vpn.id,
|
||||
name: "Updated WireGuard",
|
||||
});
|
||||
assert.equal(updatedVpn.name, "Updated WireGuard");
|
||||
assert.equal(
|
||||
(await app.invoke("get_vpn_status", { vpnId: vpn.id })).connected,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await app.invoke("update_profile_vpn", {
|
||||
profileId: profile.id,
|
||||
vpnId: vpn.id,
|
||||
})
|
||||
).vpn_id,
|
||||
vpn.id,
|
||||
);
|
||||
assert.deepEqual(await app.invoke("list_active_vpn_connections"), []);
|
||||
await app.invoke("disconnect_vpn", { vpnId: vpn.id });
|
||||
const unknownVpnError = await app.invokeError("check_vpn_validity", {
|
||||
vpnId: "missing-vpn",
|
||||
});
|
||||
assert.match(unknownVpnError, /not found|Failed to start VPN worker/i);
|
||||
const importedVpn = await app.invoke("import_vpn_config", {
|
||||
content: wireGuardFixture(),
|
||||
filename: "imported.conf",
|
||||
name: "Imported WireGuard",
|
||||
});
|
||||
assert.equal(importedVpn.success, true);
|
||||
await app.invoke("delete_vpn_config", { vpnId: importedVpn.vpn_id });
|
||||
await app.invoke("delete_vpn_config", { vpnId: vpn.id });
|
||||
|
||||
const dns = await app.invoke("set_custom_dns_config", {
|
||||
sources: [`${process.env.DONUT_E2E_FIXTURE_URL}/dns.txt`],
|
||||
blockDomains: [" Ads.Example.com ", "tracker.example"],
|
||||
allowDomains: ["safe.example"],
|
||||
allowlistMode: false,
|
||||
});
|
||||
assert.deepEqual(dns.block_domains, ["ads.example.com", "tracker.example"]);
|
||||
const textExport = await app.invoke("export_custom_dns_rules", {
|
||||
format: "txt",
|
||||
});
|
||||
assert.match(textExport, /ads\.example\.com/);
|
||||
await app.invoke("import_custom_dns_rules", {
|
||||
format: "txt",
|
||||
content: "||malware.example^\n@@||allowed.example^\n",
|
||||
});
|
||||
const importedDns = await app.invoke("get_custom_dns_config");
|
||||
assert.ok(importedDns.block_domains.includes("malware.example"));
|
||||
assert.ok(importedDns.allow_domains.includes("allowed.example"));
|
||||
await app.invoke("refresh_dns_blocklists");
|
||||
const blocklistStatus = await app.invoke("get_dns_blocklist_cache_status");
|
||||
assert.equal(blocklistStatus.length, 5);
|
||||
assert.ok(
|
||||
blocklistStatus.every(
|
||||
(entry) => entry.is_cached && entry.is_fresh && entry.entry_count === 2,
|
||||
),
|
||||
);
|
||||
|
||||
await app.invoke("delete_profile", { profileId: profile.id });
|
||||
});
|
||||
});
|
||||
|
||||
test("cookie import/copy/export, profile encryption, and traffic-stat read/clear paths", async () => {
|
||||
await withApp("entities-cookies-password", async (app) => {
|
||||
const source = await createProfile(app, "Cookie Source");
|
||||
const target = await createProfile(app, "Cookie Target");
|
||||
const cookieJson = JSON.stringify([
|
||||
{
|
||||
name: "session",
|
||||
value: "isolated-secret-cookie",
|
||||
domain: "fixture.local",
|
||||
path: "/",
|
||||
secure: false,
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
expirationDate: 2_000_000_000,
|
||||
},
|
||||
]);
|
||||
const imported = await app.invoke("import_cookies_from_file", {
|
||||
profileId: source.id,
|
||||
content: cookieJson,
|
||||
});
|
||||
assert.equal(imported.cookies_imported, 1);
|
||||
const cookies = await app.invoke("read_profile_cookies", {
|
||||
profileId: source.id,
|
||||
});
|
||||
assert.equal(cookies.total_count, 1);
|
||||
assert.equal(cookies.domains[0].cookies[0].value, "isolated-secret-cookie");
|
||||
const stats = await app.invoke("get_profile_cookie_stats", {
|
||||
profileId: source.id,
|
||||
});
|
||||
assert.equal(stats.total_count, 1);
|
||||
const copied = await app.invoke("copy_profile_cookies", {
|
||||
request: {
|
||||
source_profile_id: source.id,
|
||||
target_profile_ids: [target.id],
|
||||
selected_cookies: [{ domain: "fixture.local", name: "session" }],
|
||||
},
|
||||
});
|
||||
assert.equal(copied[0].cookies_copied, 1);
|
||||
assert.match(
|
||||
await app.invoke("export_profile_cookies", {
|
||||
profileId: target.id,
|
||||
format: "json",
|
||||
}),
|
||||
/isolated-secret-cookie/,
|
||||
);
|
||||
assert.match(
|
||||
await app.invoke("export_profile_cookies", {
|
||||
profileId: target.id,
|
||||
format: "netscape",
|
||||
}),
|
||||
/fixture\.local/,
|
||||
);
|
||||
|
||||
await app.invoke("set_profile_password", {
|
||||
profileId: source.id,
|
||||
password: "correct horse battery staple",
|
||||
});
|
||||
assert.equal(
|
||||
await app.invoke("is_profile_locked", { profileId: source.id }),
|
||||
false,
|
||||
);
|
||||
const wrong = await app.invokeError("verify_profile_password", {
|
||||
profileId: source.id,
|
||||
password: "wrong password",
|
||||
});
|
||||
assert.match(wrong, /INCORRECT_PASSWORD/);
|
||||
await app.invoke("verify_profile_password", {
|
||||
profileId: source.id,
|
||||
password: "correct horse battery staple",
|
||||
});
|
||||
await app.invoke("change_profile_password", {
|
||||
profileId: source.id,
|
||||
oldPassword: "correct horse battery staple",
|
||||
newPassword: "new correct horse battery staple",
|
||||
});
|
||||
await app.invoke("lock_profile", { profileId: source.id });
|
||||
assert.equal(
|
||||
await app.invoke("is_profile_locked", { profileId: source.id }),
|
||||
true,
|
||||
);
|
||||
await app.invoke("unlock_profile", {
|
||||
profileId: source.id,
|
||||
password: "new correct horse battery staple",
|
||||
});
|
||||
await app.invoke("remove_profile_password", {
|
||||
profileId: source.id,
|
||||
password: "new correct horse battery staple",
|
||||
});
|
||||
assert.equal(
|
||||
await app.invoke("is_profile_locked", { profileId: source.id }),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.deepEqual(await app.invoke("get_all_traffic_snapshots"), []);
|
||||
assert.equal(
|
||||
await app.invoke("get_profile_traffic_snapshot", {
|
||||
profileId: source.id,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
await app.invoke("get_traffic_stats_for_period", {
|
||||
profileId: source.id,
|
||||
seconds: 3600,
|
||||
}),
|
||||
null,
|
||||
);
|
||||
await app.invoke("clear_profile_traffic_stats", { profileId: source.id });
|
||||
await app.invoke("clear_all_traffic_stats");
|
||||
|
||||
await app.invoke("delete_selected_profiles", {
|
||||
profileIds: [source.id, target.id],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,403 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
|
||||
async function jsonRequest(
|
||||
url,
|
||||
{ method = "GET", token, body, headers = {} } = {},
|
||||
) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
...headers,
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
let value = null;
|
||||
if (text) {
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
value = text;
|
||||
}
|
||||
}
|
||||
return { response, value };
|
||||
}
|
||||
|
||||
async function seedTerms(app) {
|
||||
const home = path.join(app.root, "home");
|
||||
const directory =
|
||||
process.platform === "darwin"
|
||||
? path.join(home, "Library", "Application Support", "Wayfern")
|
||||
: process.platform === "win32"
|
||||
? path.join(app.root, "windows", "roaming", "Wayfern")
|
||||
: path.join(app.root, "xdg", "config", "Wayfern");
|
||||
await mkdir(directory, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(directory, "license-accepted"),
|
||||
String(Math.floor(Date.now() / 1000)),
|
||||
);
|
||||
}
|
||||
|
||||
async function invokeContract(app, command, args = {}) {
|
||||
try {
|
||||
return { ok: true, value: await app.invoke(command, args) };
|
||||
} catch (error) {
|
||||
return { ok: false, error: String(error) };
|
||||
}
|
||||
}
|
||||
|
||||
test("authenticated REST API serves its complete OpenAPI contract and CRUD lifecycle", async () => {
|
||||
await withApp("integrations-rest", async (app) => {
|
||||
await seedTerms(app);
|
||||
const settings = await app.invoke("get_app_settings");
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...settings,
|
||||
api_enabled: true,
|
||||
api_port: 0,
|
||||
api_token: null,
|
||||
onboarding_completed: true,
|
||||
},
|
||||
});
|
||||
assert.ok(saved.api_token?.length >= 32);
|
||||
const port = await app.invoke("start_api_server", { port: 0 });
|
||||
assert.equal(await app.invoke("get_api_server_status"), port);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
|
||||
const openapi = await jsonRequest(`${base}/openapi.json`);
|
||||
assert.equal(openapi.response.status, 200);
|
||||
assert.equal(openapi.value.openapi.startsWith("3."), true);
|
||||
const paths = Object.keys(openapi.value.paths);
|
||||
for (const required of [
|
||||
"/v1/profiles",
|
||||
"/v1/profiles/{id}/run",
|
||||
"/v1/groups",
|
||||
"/v1/proxies",
|
||||
"/v1/vpns/{id}/export",
|
||||
"/v1/extensions",
|
||||
"/v1/browsers/{browser}/versions",
|
||||
]) {
|
||||
assert.ok(paths.includes(required), `OpenAPI is missing ${required}`);
|
||||
}
|
||||
|
||||
const unauthorized = await jsonRequest(`${base}/v1/profiles`);
|
||||
assert.equal(unauthorized.response.status, 401);
|
||||
const wrongToken = await jsonRequest(`${base}/v1/profiles`, {
|
||||
token: "wrong",
|
||||
});
|
||||
assert.equal(wrongToken.response.status, 401);
|
||||
|
||||
const groupsInitially = await jsonRequest(`${base}/v1/groups`, {
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(groupsInitially.response.status, 200);
|
||||
assert.deepEqual(groupsInitially.value, []);
|
||||
const createdGroup = await jsonRequest(`${base}/v1/groups`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Group" },
|
||||
});
|
||||
assert.equal(createdGroup.response.status, 200);
|
||||
assert.equal(createdGroup.value.name, "REST Group");
|
||||
const groupId = createdGroup.value.id;
|
||||
const updatedGroup = await jsonRequest(`${base}/v1/groups/${groupId}`, {
|
||||
method: "PUT",
|
||||
token: saved.api_token,
|
||||
body: { name: "REST Group Updated" },
|
||||
});
|
||||
assert.equal(updatedGroup.value.name, "REST Group Updated");
|
||||
|
||||
const createdProxy = await jsonRequest(`${base}/v1/proxies`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
name: "REST Proxy",
|
||||
proxy_settings: {
|
||||
proxy_type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 8080,
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(createdProxy.response.status, 200);
|
||||
assert.equal(createdProxy.value.proxy_settings.port, 8080);
|
||||
const proxyId = createdProxy.value.id;
|
||||
const fetchedProxy = await jsonRequest(`${base}/v1/proxies/${proxyId}`, {
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(fetchedProxy.value.name, "REST Proxy");
|
||||
const imported = await jsonRequest(`${base}/v1/proxies/import`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: {
|
||||
format: "txt",
|
||||
content: "http://127.0.0.1:8081",
|
||||
name_prefix: "API",
|
||||
},
|
||||
});
|
||||
assert.equal(imported.response.status, 200);
|
||||
assert.equal(imported.value.imported_count, 1);
|
||||
|
||||
const missing = await jsonRequest(`${base}/v1/groups/missing`, {
|
||||
token: saved.api_token,
|
||||
});
|
||||
assert.equal(missing.response.status, 404);
|
||||
const invalidProfile = await jsonRequest(`${base}/v1/profiles`, {
|
||||
method: "POST",
|
||||
token: saved.api_token,
|
||||
body: { name: "Bad", browser: "unsupported", version: "latest" },
|
||||
});
|
||||
assert.equal(invalidProfile.response.status, 400);
|
||||
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/proxies/${proxyId}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
})
|
||||
).response.status,
|
||||
204,
|
||||
);
|
||||
for (const importedProxy of imported.value.proxies) {
|
||||
await jsonRequest(`${base}/v1/proxies/${importedProxy.id}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
});
|
||||
}
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/v1/groups/${groupId}`, {
|
||||
method: "DELETE",
|
||||
token: saved.api_token,
|
||||
})
|
||||
).response.status,
|
||||
204,
|
||||
);
|
||||
await app.invoke("stop_api_server");
|
||||
assert.equal(await app.invoke("get_api_server_status"), null);
|
||||
});
|
||||
});
|
||||
|
||||
test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated agent install", async () => {
|
||||
await withApp("integrations-mcp", async (app) => {
|
||||
await seedTerms(app);
|
||||
const port = await app.invoke("start_mcp_server");
|
||||
assert.equal(await app.invoke("get_mcp_server_status"), true);
|
||||
const config = await app.invoke("get_mcp_config");
|
||||
assert.equal(config.port, port);
|
||||
assert.ok(config.token.length >= 32);
|
||||
const base = `http://127.0.0.1:${port}`;
|
||||
assert.equal((await fetch(`${base}/health`)).status, 200);
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/mcp`, {
|
||||
method: "POST",
|
||||
body: { jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
|
||||
})
|
||||
).response.status,
|
||||
401,
|
||||
);
|
||||
|
||||
const initialized = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 1,
|
||||
method: "initialize",
|
||||
params: {
|
||||
protocolVersion: "2025-11-25",
|
||||
capabilities: {},
|
||||
clientInfo: { name: "donut-e2e", version: "1" },
|
||||
},
|
||||
},
|
||||
});
|
||||
assert.equal(initialized.response.status, 200);
|
||||
assert.equal(initialized.value.result.serverInfo.name, "donut-browser");
|
||||
const sessionId = initialized.response.headers.get("mcp-session-id");
|
||||
assert.ok(sessionId);
|
||||
const mcpHeaders = { "mcp-session-id": sessionId };
|
||||
const notification = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders,
|
||||
body: { jsonrpc: "2.0", method: "notifications/initialized" },
|
||||
});
|
||||
assert.equal(notification.response.status, 202);
|
||||
const tools = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders,
|
||||
body: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
|
||||
});
|
||||
assert.equal(tools.response.status, 200);
|
||||
const names = tools.value.result.tools.map((tool) => tool.name);
|
||||
for (const name of [
|
||||
"list_profiles",
|
||||
"create_profile",
|
||||
"run_profile",
|
||||
"list_proxies",
|
||||
"get_page_content",
|
||||
"get_interactive_elements",
|
||||
]) {
|
||||
assert.ok(names.includes(name), `MCP is missing ${name}`);
|
||||
}
|
||||
const listed = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "POST",
|
||||
headers: mcpHeaders,
|
||||
body: {
|
||||
jsonrpc: "2.0",
|
||||
id: 3,
|
||||
method: "tools/call",
|
||||
params: { name: "list_profiles", arguments: {} },
|
||||
},
|
||||
});
|
||||
assert.equal(listed.response.status, 200);
|
||||
assert.equal(listed.value.error, undefined);
|
||||
assert.ok(listed.value.result);
|
||||
|
||||
const agents = await app.invoke("list_mcp_agents");
|
||||
assert.ok(agents.some((agent) => agent.id === "cursor"));
|
||||
await app.invoke("add_mcp_to_agent", { agentId: "cursor" });
|
||||
assert.equal(
|
||||
(await app.invoke("list_mcp_agents")).find(
|
||||
(agent) => agent.id === "cursor",
|
||||
).connected,
|
||||
true,
|
||||
);
|
||||
await app.invoke("remove_mcp_from_agent", { agentId: "cursor" });
|
||||
assert.equal(
|
||||
(await app.invoke("list_mcp_agents")).find(
|
||||
(agent) => agent.id === "cursor",
|
||||
).connected,
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
(
|
||||
await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||
method: "DELETE",
|
||||
headers: mcpHeaders,
|
||||
})
|
||||
).response.status,
|
||||
200,
|
||||
);
|
||||
await app.invoke("stop_mcp_server");
|
||||
assert.equal(await app.invoke("get_mcp_server_status"), false);
|
||||
});
|
||||
});
|
||||
|
||||
test("offline cloud, update, team-lock, trial, and synchronizer contracts are deterministic", async () => {
|
||||
await withApp("integrations-contracts", async (app) => {
|
||||
assert.equal(await app.invoke("cloud_get_user"), null);
|
||||
assert.equal(await app.invoke("cloud_get_proxy_usage"), null);
|
||||
assert.ok(await app.invoke("cloud_get_wayfern_token"));
|
||||
assert.deepEqual(await app.invoke("get_team_locks"), []);
|
||||
assert.equal(
|
||||
await app.invoke("get_team_lock_status", {
|
||||
profileId: "00000000-0000-0000-0000-000000000000",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.deepEqual(await app.invoke("get_sync_sessions"), []);
|
||||
const startResult = await invokeContract(app, "start_sync_session", {
|
||||
leaderProfileId: "00000000-0000-0000-0000-000000000001",
|
||||
followerProfileIds: ["00000000-0000-0000-0000-000000000002"],
|
||||
});
|
||||
assert.equal(startResult.ok, false);
|
||||
const stopError = await app.invokeError("stop_sync_session", {
|
||||
sessionId: "missing",
|
||||
});
|
||||
assert.match(stopError, /not found|session/i);
|
||||
const removeError = await app.invokeError("remove_sync_follower", {
|
||||
sessionId: "missing",
|
||||
followerProfileId: "missing",
|
||||
});
|
||||
assert.match(removeError, /not found|session/i);
|
||||
|
||||
assert.equal(await app.invoke("check_for_app_updates"), null);
|
||||
assert.equal(await app.invoke("check_for_app_updates_manual"), null);
|
||||
assert.ok(
|
||||
await invokeContract(app, "cloud_exchange_device_code", {
|
||||
code: "DONUT-E2E-INVALID-CODE",
|
||||
}),
|
||||
);
|
||||
assert.ok(await invokeContract(app, "cloud_refresh_profile"));
|
||||
assert.ok(await invokeContract(app, "cloud_get_countries"));
|
||||
assert.ok(
|
||||
await invokeContract(app, "cloud_get_regions", {
|
||||
country: "ZZ",
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
await invokeContract(app, "cloud_get_cities", {
|
||||
country: "ZZ",
|
||||
region: null,
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
await invokeContract(app, "cloud_get_isps", {
|
||||
country: "ZZ",
|
||||
region: null,
|
||||
city: null,
|
||||
}),
|
||||
);
|
||||
assert.ok(
|
||||
await invokeContract(app, "create_cloud_location_proxy", {
|
||||
name: "E2E unavailable cloud proxy",
|
||||
country: "ZZ",
|
||||
region: null,
|
||||
city: null,
|
||||
isp: null,
|
||||
}),
|
||||
);
|
||||
assert.ok(await invokeContract(app, "cloud_refresh_wayfern_token"));
|
||||
|
||||
assert.ok(await invokeContract(app, "trigger_manual_version_update"));
|
||||
assert.ok(await invokeContract(app, "clear_all_version_cache_and_refetch"));
|
||||
assert.ok(await invokeContract(app, "check_for_browser_updates"));
|
||||
await app.invoke("dismiss_update_notification", {
|
||||
notificationId: "missing-e2e-notification",
|
||||
});
|
||||
assert.deepEqual(
|
||||
await app.invoke("complete_browser_update_with_auto_update", {
|
||||
browser: "wayfern",
|
||||
newVersion: "150.0.7871.100",
|
||||
}),
|
||||
[],
|
||||
);
|
||||
const prepareError = await app.invokeError(
|
||||
"download_and_prepare_app_update",
|
||||
{
|
||||
updateInfo: {
|
||||
current_version: "0.0.0",
|
||||
new_version: "0.0.1-e2e",
|
||||
release_notes: "E2E invalid update contract",
|
||||
download_url: `${process.env.DONUT_E2E_FIXTURE_URL}/invalid-update.zip`,
|
||||
is_nightly: false,
|
||||
published_at: "2026-01-01T00:00:00Z",
|
||||
manual_update_required: false,
|
||||
release_page_url: null,
|
||||
repo_update: false,
|
||||
checksums_url: null,
|
||||
asset_digest: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
assert.match(prepareError, /checksum|verif|Failed to download/i);
|
||||
const versionStatus = await app.invoke("get_version_update_status");
|
||||
assert.ok(versionStatus && typeof versionStatus === "object");
|
||||
assert.equal(typeof (await app.invoke("is_default_browser")), "boolean");
|
||||
|
||||
const trial = await app.invoke("get_commercial_trial_status");
|
||||
assert.ok(trial && typeof trial === "object");
|
||||
await app.invoke("acknowledge_trial_expiration");
|
||||
assert.equal(await app.invoke("has_acknowledged_trial_expiration"), true);
|
||||
await app.invoke("cloud_logout");
|
||||
assert.equal(await app.invoke("cloud_get_user"), null);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,605 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||
import { isIP } from "node:net";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { appFromEnvironment } from "../lib/app.mjs";
|
||||
import { CdpClient } from "../lib/cdp.mjs";
|
||||
import {
|
||||
extensionZipBase64,
|
||||
prepareWayfern,
|
||||
wireGuardFixture,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
function proxySettings(raw, expectedKind) {
|
||||
assert.ok(raw, `${expectedKind} residential proxy URL is required`);
|
||||
const url = new URL(raw);
|
||||
const rawType = url.protocol.slice(0, -1).toLowerCase();
|
||||
const proxyType =
|
||||
rawType === "socks" || rawType === "socks5h" ? "socks5" : rawType;
|
||||
if (expectedKind === "HTTP") {
|
||||
assert.ok(
|
||||
proxyType === "http" || proxyType === "https",
|
||||
`Expected an HTTP proxy URL, got ${rawType}`,
|
||||
);
|
||||
} else {
|
||||
assert.equal(proxyType, "socks5");
|
||||
}
|
||||
const port = Number(url.port);
|
||||
assert.ok(url.hostname && port > 0 && port <= 65535);
|
||||
return {
|
||||
proxy_type: proxyType,
|
||||
host: url.hostname,
|
||||
port,
|
||||
username: url.username ? decodeURIComponent(url.username) : null,
|
||||
password: url.password ? decodeURIComponent(url.password) : null,
|
||||
};
|
||||
}
|
||||
|
||||
function wireGuardFields(config) {
|
||||
let section = "";
|
||||
const fields = new Map();
|
||||
for (const rawLine of config.split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith("#")) continue;
|
||||
if (line === "[Interface]") {
|
||||
section = "interface";
|
||||
continue;
|
||||
}
|
||||
if (line === "[Peer]") {
|
||||
section = "peer";
|
||||
continue;
|
||||
}
|
||||
const separator = line.indexOf("=");
|
||||
if (separator === -1) continue;
|
||||
fields.set(
|
||||
`${section}.${line.slice(0, separator).trim()}`,
|
||||
line.slice(separator + 1).trim(),
|
||||
);
|
||||
}
|
||||
return {
|
||||
privateKey: fields.get("interface.PrivateKey"),
|
||||
address: fields.get("interface.Address"),
|
||||
dns: fields.get("interface.DNS") ?? "",
|
||||
peerPublicKey: fields.get("peer.PublicKey"),
|
||||
peerEndpoint: fields.get("peer.Endpoint"),
|
||||
allowedIps: fields.get("peer.AllowedIPs") ?? "0.0.0.0/0",
|
||||
persistentKeepalive: fields.get("peer.PersistentKeepalive") ?? "",
|
||||
presharedKey: fields.get("peer.PresharedKey") ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
async function request(url, { method = "GET", token, body } = {}) {
|
||||
const response = await fetch(url, {
|
||||
method,
|
||||
headers: {
|
||||
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||
},
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
let value = text;
|
||||
if (text) {
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
// Plain-text responses are intentional for some endpoints.
|
||||
}
|
||||
}
|
||||
return { response, value };
|
||||
}
|
||||
|
||||
async function createGroupThroughUi(app) {
|
||||
await app.clickSelector('[aria-label="Groups"]');
|
||||
await app.waitForText("Profile groups");
|
||||
await app.clickSelector('[aria-label="Create"]');
|
||||
await app.waitForText("Create New Group");
|
||||
await app.fillSelector("#group-name", "Visible UI Group");
|
||||
await app.clickTextIn('[role="dialog"]', "Create", { roles: ["button"] });
|
||||
await app.waitForText("Visible UI Group");
|
||||
const groups = await app.invoke("get_profile_groups");
|
||||
return groups.find((group) => group.name === "Visible UI Group");
|
||||
}
|
||||
|
||||
async function createProxyThroughUi(app, settings) {
|
||||
await app.clickSelector('[aria-label="Network"]');
|
||||
await app.waitForText("New proxy");
|
||||
await app.clickSelector('[aria-label="New proxy"]');
|
||||
await app.waitForText("Add Proxy");
|
||||
await app.fillSelector("#proxy-name", "Visible Residential HTTP");
|
||||
await app.fillSelector("#proxy-host", settings.host);
|
||||
await app.fillSelector("#proxy-port", String(settings.port));
|
||||
if (settings.username)
|
||||
await app.fillSelector("#proxy-username", settings.username);
|
||||
if (settings.password)
|
||||
await app.fillSelector("#proxy-password", settings.password);
|
||||
await app.clickTextIn('[role="dialog"]', "Add Proxy", {
|
||||
roles: ["button"],
|
||||
});
|
||||
await app.waitForText("Visible Residential HTTP");
|
||||
const proxies = await app.invoke("get_stored_proxies");
|
||||
return proxies.find((proxy) => proxy.name === "Visible Residential HTTP");
|
||||
}
|
||||
|
||||
async function createVpnThroughUi(app, config) {
|
||||
const fields = wireGuardFields(config);
|
||||
assert.ok(
|
||||
fields.privateKey &&
|
||||
fields.address &&
|
||||
fields.peerPublicKey &&
|
||||
fields.peerEndpoint,
|
||||
"WireGuard fixture is missing required fields",
|
||||
);
|
||||
await app.clickText("VPNs", { exact: false, roles: ["tab"] });
|
||||
await app.clickSelector('[aria-label="New VPN"]');
|
||||
await app.waitForText("Create WireGuard VPN");
|
||||
await app.fillSelector("#wg-name", "Visible Local WireGuard");
|
||||
await app.fillSelector("#wg-private-key", fields.privateKey);
|
||||
await app.fillSelector("#wg-address", fields.address);
|
||||
if (fields.dns) await app.fillSelector("#wg-dns", fields.dns);
|
||||
await app.fillSelector("#wg-peer-public-key", fields.peerPublicKey);
|
||||
await app.fillSelector("#wg-peer-endpoint", fields.peerEndpoint);
|
||||
await app.fillSelector("#wg-allowed-ips", fields.allowedIps);
|
||||
if (fields.persistentKeepalive) {
|
||||
await app.fillSelector("#wg-keepalive", fields.persistentKeepalive);
|
||||
}
|
||||
if (fields.presharedKey) {
|
||||
await app.fillSelector("#wg-preshared-key", fields.presharedKey);
|
||||
}
|
||||
await app.clickTextIn('[role="dialog"]', "Create VPN", {
|
||||
roles: ["button"],
|
||||
});
|
||||
await app.waitForText("Visible Local WireGuard");
|
||||
const vpns = await app.invoke("list_vpn_configs");
|
||||
return vpns.find((vpn) => vpn.name === "Visible Local WireGuard");
|
||||
}
|
||||
|
||||
async function createExtensionsThroughUi(app) {
|
||||
const extensionFile = path.join(app.root, "visible-extension.zip");
|
||||
await writeFile(extensionFile, Buffer.from(extensionZipBase64(), "base64"));
|
||||
await app.clickSelector('[aria-label="Extensions"]');
|
||||
await app.waitForText("Upload");
|
||||
|
||||
await app.execute(`
|
||||
const input = document.querySelector("#ext-file-input");
|
||||
input.classList.remove("hidden");
|
||||
input.style.position = "fixed";
|
||||
input.style.left = "12px";
|
||||
input.style.bottom = "12px";
|
||||
`);
|
||||
const input = await app.session.findCss("#ext-file-input");
|
||||
await app.session.sendKeys(input, extensionFile);
|
||||
await app.waitForText("visible-extension.zip");
|
||||
await app.fillSelector(
|
||||
'input[placeholder="Extension name"]',
|
||||
"Visible UI Extension",
|
||||
);
|
||||
await app.clickText("Add", { roles: ["button"] });
|
||||
await app.waitForText("Donut E2E Fixture");
|
||||
|
||||
await app.clickText("Groups", { exact: false, roles: ["tab"] });
|
||||
await app.clickSelector('[aria-label="New group"]');
|
||||
await app.fillSelector(
|
||||
'input[placeholder="Group name"]',
|
||||
"Visible Extension Group",
|
||||
);
|
||||
await app.clickText("Create", { roles: ["button"] });
|
||||
await app.waitForText("Visible Extension Group");
|
||||
|
||||
let [extensions, groups] = await Promise.all([
|
||||
app.invoke("list_extensions"),
|
||||
app.invoke("list_extension_groups"),
|
||||
]);
|
||||
const extension = extensions.find(
|
||||
(item) => item.name === "Donut E2E Fixture",
|
||||
);
|
||||
let group = groups.find((item) => item.name === "Visible Extension Group");
|
||||
assert.ok(extension && group);
|
||||
|
||||
const editButton = await app.execute(
|
||||
`
|
||||
const row = [...document.querySelectorAll("tr")].find((candidate) =>
|
||||
(candidate.innerText || "").includes(arguments[0])
|
||||
);
|
||||
return row?.querySelector("td:last-child button") ?? null;
|
||||
`,
|
||||
[group.name],
|
||||
);
|
||||
assert.ok(editButton, "Extension group edit control was not visible");
|
||||
await app.session.click(editButton);
|
||||
await app.waitForText("Edit Group");
|
||||
const extensionPicker = await app.execute(`
|
||||
const dialogs = [...document.querySelectorAll('[role="dialog"]')];
|
||||
return dialogs.reverse().find(
|
||||
(dialog) => (dialog.innerText || "").includes("Edit Group")
|
||||
)?.querySelector('[role="combobox"]') ?? null;
|
||||
`);
|
||||
assert.ok(extensionPicker, "Extension picker was not visible");
|
||||
await app.session.click(extensionPicker);
|
||||
await app.clickText(extension.name, { roles: ["option"] });
|
||||
await app.clickTextIn('[role="dialog"]', "Save", { roles: ["button"] });
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
groups = await app.invoke("list_extension_groups");
|
||||
group = groups.find((item) => item.name === "Visible Extension Group");
|
||||
return group?.extension_ids.includes(extension.id);
|
||||
},
|
||||
{ description: "uploaded extension added to visible extension group" },
|
||||
);
|
||||
|
||||
return {
|
||||
extension,
|
||||
group,
|
||||
};
|
||||
}
|
||||
|
||||
async function createProfileThroughUi(app, groupName) {
|
||||
await app.clickSelector('[aria-label="Profiles"]');
|
||||
await app.clickText(groupName, { exact: false, roles: ["button"] });
|
||||
await app.clickText("New", { roles: ["button"] });
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
const text = await app.bodyText();
|
||||
return (
|
||||
text.includes("Create New Profile") ||
|
||||
text.includes("Create New Chromium Profile")
|
||||
);
|
||||
},
|
||||
{ description: "profile creation dialog" },
|
||||
);
|
||||
if (!(await app.visibleTextIncludes("Create New Chromium Profile"))) {
|
||||
await app.clickText("Chromium", { exact: false, roles: ["button"] });
|
||||
await app.waitForText("Create New Chromium Profile");
|
||||
}
|
||||
await app.fillSelector("#profile-name", "Visible Network Profile");
|
||||
await app.clickTextIn('[role="dialog"]', "Create", { roles: ["button"] });
|
||||
await app.waitForText("Visible Network Profile", 60_000);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
!(await app.execute(`
|
||||
return [...document.querySelectorAll('[role="dialog"]')].some(
|
||||
(dialog) =>
|
||||
(dialog.innerText || "").includes("Create New Chromium Profile")
|
||||
);
|
||||
`)),
|
||||
{ description: "profile creation dialog to unmount" },
|
||||
);
|
||||
const profiles = await app.invoke("list_browser_profiles");
|
||||
return profiles.find((profile) => profile.name === "Visible Network Profile");
|
||||
}
|
||||
|
||||
async function assignNetworkThroughUi(app, profileName, currentName, newName) {
|
||||
const trigger = await app.execute(
|
||||
`
|
||||
const row = [...document.querySelectorAll("tr")].find((candidate) =>
|
||||
(candidate.innerText || "").includes(arguments[0])
|
||||
);
|
||||
const expected = arguments[1].toLocaleLowerCase();
|
||||
return [...(row?.querySelectorAll('[aria-haspopup="dialog"]') ?? [])].find(
|
||||
(trigger) => (trigger.innerText || trigger.textContent || "")
|
||||
.toLocaleLowerCase()
|
||||
.includes(expected)
|
||||
) ?? null;
|
||||
`,
|
||||
[profileName, currentName],
|
||||
);
|
||||
assert.ok(trigger, `Network selector for ${profileName} was not visible`);
|
||||
await app.session.click(trigger);
|
||||
await app.clickText(newName, { exact: false, roles: ["option"] });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`
|
||||
return ![...document.querySelectorAll('[data-slot="popover-content"]')]
|
||||
.some((content) => (content.innerText || "").includes(arguments[0]));
|
||||
`,
|
||||
[newName],
|
||||
),
|
||||
{ description: `${newName} network picker to unmount` },
|
||||
);
|
||||
}
|
||||
|
||||
async function assignExtensionGroupThroughUi(
|
||||
app,
|
||||
profileName,
|
||||
currentName,
|
||||
newName,
|
||||
) {
|
||||
const trigger = await app.execute(
|
||||
`
|
||||
const row = [...document.querySelectorAll("tr")].find((candidate) =>
|
||||
(candidate.innerText || "").includes(arguments[0])
|
||||
);
|
||||
return [...(row?.querySelectorAll("button") ?? [])].find(
|
||||
(button) => (button.innerText || button.textContent || "")
|
||||
.trim()
|
||||
.includes(arguments[1])
|
||||
) ?? null;
|
||||
`,
|
||||
[profileName, currentName],
|
||||
);
|
||||
assert.ok(trigger, `Extension selector for ${profileName} was not visible`);
|
||||
await app.session.click(trigger);
|
||||
await app.clickText(newName, { exact: false, roles: ["option"] });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`
|
||||
return ![...document.querySelectorAll('[data-slot="popover-content"]')]
|
||||
.some((content) => (content.innerText || "").includes(arguments[0]));
|
||||
`,
|
||||
[newName],
|
||||
),
|
||||
{ description: `${newName} extension picker to unmount` },
|
||||
);
|
||||
}
|
||||
|
||||
async function runProfile(_app, base, token, profileId, url) {
|
||||
const launched = await request(`${base}/v1/profiles/${profileId}/run`, {
|
||||
method: "POST",
|
||||
token,
|
||||
body: { url, headless: true },
|
||||
});
|
||||
assert.equal(launched.response.status, 200, JSON.stringify(launched.value));
|
||||
const cdp = await CdpClient.connect(launched.value.remote_debugging_port);
|
||||
return { launched: launched.value, cdp };
|
||||
}
|
||||
|
||||
async function stopProfile(app, base, token, profileId, cdp) {
|
||||
cdp.close();
|
||||
const stopped = await request(`${base}/v1/profiles/${profileId}/kill`, {
|
||||
method: "POST",
|
||||
token,
|
||||
});
|
||||
assert.equal(stopped.response.status, 204);
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
const profile = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profileId,
|
||||
);
|
||||
return !profile?.process_id;
|
||||
},
|
||||
{ timeoutMs: 20_000, description: "network profile process cleanup" },
|
||||
);
|
||||
}
|
||||
|
||||
async function assertProxyWorkerLogsRedacted(app, settings) {
|
||||
const files = (await readdir(path.join(app.root, "tmp"))).filter(
|
||||
(file) => file.startsWith("donut-proxy-") && file.endsWith(".log"),
|
||||
);
|
||||
assert.ok(files.length > 0, "No proxy worker diagnostic logs were created");
|
||||
const contents = (
|
||||
await Promise.all(
|
||||
files.map((file) => readFile(path.join(app.root, "tmp", file), "utf8")),
|
||||
)
|
||||
).join("\n");
|
||||
for (const item of settings) {
|
||||
if (!item.username) continue;
|
||||
const rawAuth = `${item.username}:${item.password ?? ""}@`;
|
||||
const encodedAuth = `${encodeURIComponent(item.username)}:${encodeURIComponent(item.password ?? "")}@`;
|
||||
assert.equal(
|
||||
contents.includes(rawAuth) || contents.includes(encodedAuth),
|
||||
false,
|
||||
"Proxy worker logs exposed upstream credentials",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function wireGuardTargetWasReached() {
|
||||
const container = process.env.DONUT_E2E_WIREGUARD_CONTAINER;
|
||||
assert.ok(container, "WireGuard fixture container name is required");
|
||||
return (
|
||||
spawnSync(
|
||||
"docker",
|
||||
[
|
||||
"exec",
|
||||
container,
|
||||
"grep",
|
||||
"-q",
|
||||
"GET /donut-e2e-wireguard ",
|
||||
"/tmp/donut-e2e-target-requests",
|
||||
],
|
||||
{ stdio: "ignore", timeout: 2_000 },
|
||||
).status === 0
|
||||
);
|
||||
}
|
||||
|
||||
test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions, and extension groups", async () => {
|
||||
const httpSettings = proxySettings(
|
||||
process.env.RESIDENTIAL_PROXY_URL_ONE_HTTP,
|
||||
"HTTP",
|
||||
);
|
||||
const socksSettings = proxySettings(
|
||||
process.env.RESIDENTIAL_PROXY_URL_ONE_SOCKS,
|
||||
"SOCKS",
|
||||
);
|
||||
const realWireGuardConfig = process.env.DONUT_E2E_WIREGUARD_CONFIG_BASE64
|
||||
? Buffer.from(
|
||||
process.env.DONUT_E2E_WIREGUARD_CONFIG_BASE64,
|
||||
"base64",
|
||||
).toString("utf8")
|
||||
: null;
|
||||
const app = appFromEnvironment("network-visible-ui", {
|
||||
wayfernTermsAccepted: false,
|
||||
});
|
||||
let apiPort;
|
||||
let activeCdp;
|
||||
let activeVpnId;
|
||||
try {
|
||||
const prepared = await prepareWayfern(
|
||||
app,
|
||||
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||
);
|
||||
if (!app.session) await app.start();
|
||||
if (!(await app.invoke("check_wayfern_terms_accepted"))) {
|
||||
await app.invoke("accept_wayfern_terms");
|
||||
await app.restart();
|
||||
}
|
||||
assert.equal(
|
||||
await app.visibleTextIncludes("Welcome to Donut Browser"),
|
||||
false,
|
||||
"completed test sessions must not leave the Welcome dialog over the UI",
|
||||
);
|
||||
const group = await createGroupThroughUi(app);
|
||||
assert.ok(group);
|
||||
await app.capture("01-profile-group-created");
|
||||
|
||||
const httpProxy = await createProxyThroughUi(app, httpSettings);
|
||||
assert.ok(httpProxy);
|
||||
assert.equal(
|
||||
httpProxy.proxy_settings.proxy_type === httpSettings.proxy_type &&
|
||||
httpProxy.proxy_settings.host === httpSettings.host &&
|
||||
httpProxy.proxy_settings.port === httpSettings.port &&
|
||||
httpProxy.proxy_settings.username === httpSettings.username &&
|
||||
httpProxy.proxy_settings.password === httpSettings.password,
|
||||
true,
|
||||
"The HTTP proxy created through the UI did not preserve its settings",
|
||||
);
|
||||
const vpn = await createVpnThroughUi(
|
||||
app,
|
||||
realWireGuardConfig ?? wireGuardFixture(),
|
||||
);
|
||||
assert.ok(vpn);
|
||||
activeVpnId = vpn.id;
|
||||
await app.capture("02-proxy-and-vpn-created");
|
||||
|
||||
const extensionEntities = await createExtensionsThroughUi(app);
|
||||
assert.ok(extensionEntities.extension);
|
||||
assert.ok(extensionEntities.group);
|
||||
await app.capture("03-extension-and-group-created");
|
||||
|
||||
const profile = await createProfileThroughUi(app, group.name);
|
||||
assert.ok(profile);
|
||||
assert.equal(profile.version, prepared.version);
|
||||
assert.equal(profile.group_id, group.id);
|
||||
await assignExtensionGroupThroughUi(
|
||||
app,
|
||||
profile.name,
|
||||
"Default",
|
||||
extensionEntities.group.name,
|
||||
);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
)?.extension_group_id === extensionEntities.group.id,
|
||||
{ description: "extension group assignment persisted" },
|
||||
);
|
||||
await app.capture("04-profile-created");
|
||||
|
||||
const socksProxy = await app.invoke("create_stored_proxy", {
|
||||
name: "Residential SOCKS5",
|
||||
proxySettings: socksSettings,
|
||||
});
|
||||
const [httpCheck, socksCheck] = await Promise.all([
|
||||
app.invoke("check_proxy_validity", {
|
||||
proxyId: httpProxy.id,
|
||||
proxySettings: null,
|
||||
}),
|
||||
app.invoke("check_proxy_validity", {
|
||||
proxyId: socksProxy.id,
|
||||
proxySettings: null,
|
||||
}),
|
||||
]);
|
||||
assert.equal(httpCheck.is_valid, true);
|
||||
assert.equal(socksCheck.is_valid, true);
|
||||
assert.ok(isIP(httpCheck.ip));
|
||||
assert.ok(isIP(socksCheck.ip));
|
||||
|
||||
await assignNetworkThroughUi(
|
||||
app,
|
||||
profile.name,
|
||||
"Not selected",
|
||||
httpProxy.name,
|
||||
);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
)?.proxy_id === httpProxy.id,
|
||||
{ description: "HTTP proxy assignment persisted" },
|
||||
);
|
||||
|
||||
const settings = await app.invoke("get_app_settings");
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...settings,
|
||||
api_enabled: true,
|
||||
api_port: 0,
|
||||
api_token: null,
|
||||
},
|
||||
});
|
||||
apiPort = await app.invoke("start_api_server", { port: 0 });
|
||||
const base = `http://127.0.0.1:${apiPort}`;
|
||||
|
||||
const proxied = await runProfile(
|
||||
app,
|
||||
base,
|
||||
saved.api_token,
|
||||
profile.id,
|
||||
"https://api.ipify.org/",
|
||||
);
|
||||
activeCdp = proxied.cdp;
|
||||
const browserExitIp = await activeCdp.waitFor(
|
||||
`(() => {
|
||||
const value = document.body?.innerText?.trim() ?? "";
|
||||
return /^[0-9a-f:.]+$/i.test(value) ? value : false;
|
||||
})()`,
|
||||
{ timeoutMs: 30_000, description: "Wayfern residential proxy exit IP" },
|
||||
);
|
||||
assert.ok(isIP(browserExitIp));
|
||||
await stopProfile(app, base, saved.api_token, profile.id, activeCdp);
|
||||
activeCdp = null;
|
||||
await assertProxyWorkerLogsRedacted(app, [httpSettings, socksSettings]);
|
||||
|
||||
await assignNetworkThroughUi(app, profile.name, httpProxy.name, vpn.name);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
(await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
)?.vpn_id === vpn.id,
|
||||
{ description: "WireGuard assignment persisted" },
|
||||
);
|
||||
await app.capture("05-proxy-and-vpn-assigned");
|
||||
|
||||
if (realWireGuardConfig) {
|
||||
const tunneled = await runProfile(
|
||||
app,
|
||||
base,
|
||||
saved.api_token,
|
||||
profile.id,
|
||||
process.env.DONUT_E2E_WIREGUARD_TARGET_URL,
|
||||
);
|
||||
activeCdp = tunneled.cdp;
|
||||
await app.waitFor(wireGuardTargetWasReached, {
|
||||
timeoutMs: 30_000,
|
||||
description: "Wayfern GET through local WireGuard peer",
|
||||
});
|
||||
await stopProfile(app, base, saved.api_token, profile.id, activeCdp);
|
||||
activeCdp = null;
|
||||
}
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
activeCdp?.close();
|
||||
if (app.session) {
|
||||
if (apiPort) await app.invoke("stop_api_server").catch(() => {});
|
||||
for (const profile of await app
|
||||
.invoke("list_browser_profiles")
|
||||
.catch(() => [])) {
|
||||
if (profile.process_id) {
|
||||
await app.invoke("kill_browser_profile", { profile }).catch(() => {});
|
||||
}
|
||||
}
|
||||
if (activeVpnId) {
|
||||
await app
|
||||
.invoke("disconnect_vpn", { vpnId: activeVpnId })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,171 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { access, readFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { appFromEnvironment, withApp } from "../lib/app.mjs";
|
||||
|
||||
test("fresh app renders, completes onboarding, persists settings, and never touches real app roots", async () => {
|
||||
await withApp(
|
||||
"smoke-fresh",
|
||||
async (app) => {
|
||||
assert.equal(typeof (await app.session.title()), "string");
|
||||
assert.match(await app.bodyText(), /New/);
|
||||
await app.waitForText("No profiles yet");
|
||||
|
||||
const initial = await app.invoke("get_app_settings");
|
||||
assert.equal(typeof initial.onboarding_completed, "boolean");
|
||||
await app.invoke("complete_onboarding");
|
||||
assert.equal(await app.invoke("get_onboarding_completed"), true);
|
||||
await app.invoke("dismiss_window_resize_warning");
|
||||
assert.equal(
|
||||
await app.invoke("get_window_resize_warning_dismissed"),
|
||||
true,
|
||||
);
|
||||
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...initial,
|
||||
theme: "dark",
|
||||
language: "en",
|
||||
onboarding_completed: true,
|
||||
disable_auto_updates: true,
|
||||
},
|
||||
});
|
||||
assert.equal(saved.theme, "dark");
|
||||
assert.equal(saved.language, "en");
|
||||
|
||||
await app.invoke("save_table_sorting_settings", {
|
||||
sorting: { column: "browser", direction: "desc" },
|
||||
});
|
||||
assert.deepEqual(await app.invoke("get_table_sorting_settings"), {
|
||||
column: "browser",
|
||||
direction: "desc",
|
||||
});
|
||||
assert.ok((await app.invoke("get_system_language")).length >= 2);
|
||||
const system = await app.invoke("get_system_info");
|
||||
assert.ok(system && typeof system === "object");
|
||||
assert.equal(typeof (await app.invoke("read_log_files")), "string");
|
||||
|
||||
await app.restart();
|
||||
const afterRestart = await app.invoke("get_app_settings");
|
||||
assert.equal(afterRestart.theme, "dark");
|
||||
assert.equal(afterRestart.language, "en");
|
||||
assert.equal(afterRestart.onboarding_completed, true);
|
||||
|
||||
const settingsFile = path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"settings",
|
||||
"app_settings.json",
|
||||
);
|
||||
await access(settingsFile);
|
||||
const persisted = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||
assert.equal(persisted.api_token, null);
|
||||
assert.equal(persisted.mcp_token, null);
|
||||
},
|
||||
{ onboardingCompleted: false },
|
||||
);
|
||||
});
|
||||
|
||||
test("two isolated sessions run concurrently and do not share frontend or backend state", async () => {
|
||||
const first = appFromEnvironment("smoke-isolation-a");
|
||||
const second = appFromEnvironment("smoke-isolation-b");
|
||||
try {
|
||||
await Promise.all([first.start(), second.start()]);
|
||||
const firstSettings = await first.invoke("get_app_settings");
|
||||
await first.invoke("save_app_settings", {
|
||||
settings: { ...firstSettings, theme: "dark", onboarding_completed: true },
|
||||
});
|
||||
const secondSettings = await second.invoke("get_app_settings");
|
||||
assert.equal(secondSettings.theme, "system");
|
||||
assert.notEqual(secondSettings.theme, "dark");
|
||||
|
||||
await first.execute("localStorage.setItem('donut-e2e-only-a', 'yes');");
|
||||
assert.equal(
|
||||
await second.execute("return localStorage.getItem('donut-e2e-only-a');"),
|
||||
null,
|
||||
"native WebView data leaked across sessions",
|
||||
);
|
||||
} catch (error) {
|
||||
await Promise.all([first.capture("failure"), second.capture("failure")]);
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.all([first.close(), second.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("keyboard command palette and major navigation surfaces are operable through native WebDriver", async () => {
|
||||
await withApp("smoke-ui", async (app) => {
|
||||
const modifier =
|
||||
process.platform === "darwin" ? { meta: true } : { ctrl: true };
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
await app.pressShortcut({ key: "k", ...modifier });
|
||||
return app.execute(
|
||||
`return Boolean(document.querySelector("[cmdk-input][placeholder='Type a command or search...']"));`,
|
||||
);
|
||||
},
|
||||
{ description: "open command palette" },
|
||||
);
|
||||
|
||||
const input = await app.session.findCss("[cmdk-input]");
|
||||
await app.session.sendKeys(input, "settings");
|
||||
const body = await app.bodyText();
|
||||
assert.match(body, /Settings/i);
|
||||
|
||||
// Exercise native WebDriver element marshalling and click, not just script execution.
|
||||
const close = await app.execute(
|
||||
`return [...document.querySelectorAll("button")].find(
|
||||
(button) => /close/i.test(button.getAttribute("aria-label") || button.textContent || "")
|
||||
) ?? null;`,
|
||||
);
|
||||
if (close) {
|
||||
await app.session.click(close);
|
||||
} else {
|
||||
await app.pressShortcut({ key: "Escape" });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("tray labels, hide-to-tray, and confirmed quit follow the native lifecycle", async () => {
|
||||
const app = appFromEnvironment("smoke-lifecycle");
|
||||
try {
|
||||
await app.start();
|
||||
await app.invoke("update_tray_menu", {
|
||||
showLabel: "Show Donut E2E",
|
||||
quitLabel: "Quit Donut E2E",
|
||||
});
|
||||
await app.invoke("hide_to_tray");
|
||||
assert.equal(
|
||||
typeof (await app.invoke("get_onboarding_completed")),
|
||||
"boolean",
|
||||
);
|
||||
|
||||
await app.restart();
|
||||
const exitingSession = app.session;
|
||||
await app
|
||||
.execute(
|
||||
`window.__TAURI_INTERNALS__.invoke("confirm_quit").catch(() => {});
|
||||
return true;`,
|
||||
)
|
||||
.catch(() => {});
|
||||
await app.waitFor(
|
||||
async () => {
|
||||
try {
|
||||
await exitingSession.title();
|
||||
return false;
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
{ timeoutMs: 10_000, description: "confirmed app exit" },
|
||||
);
|
||||
app.session = null;
|
||||
await exitingSession.close().catch(() => {});
|
||||
} catch (error) {
|
||||
await app.capture("failure");
|
||||
throw error;
|
||||
} finally {
|
||||
await app.close();
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,577 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { appFromEnvironment } from "../lib/app.mjs";
|
||||
import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
|
||||
|
||||
const syncUrl = process.env.DONUT_E2E_SYNC_URL;
|
||||
const syncToken = process.env.DONUT_E2E_SYNC_TOKEN;
|
||||
|
||||
async function syncRequest(endpoint, body) {
|
||||
const response = await fetch(`${syncUrl}/v1/objects/${endpoint}`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
authorization: `Bearer ${syncToken}`,
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Sync ${endpoint} failed with HTTP ${response.status}: ${text}`,
|
||||
);
|
||||
}
|
||||
return text ? JSON.parse(text) : null;
|
||||
}
|
||||
|
||||
async function listRemote(prefix = "") {
|
||||
const result = await syncRequest("list", {
|
||||
prefix,
|
||||
maxKeys: 1000,
|
||||
continuationToken: null,
|
||||
});
|
||||
return result.objects;
|
||||
}
|
||||
|
||||
async function downloadRemote(key) {
|
||||
const presigned = await syncRequest("presign-download", {
|
||||
key,
|
||||
expiresIn: 300,
|
||||
});
|
||||
const response = await fetch(presigned.url);
|
||||
assert.equal(response.status, 200, `Could not download remote object ${key}`);
|
||||
return Buffer.from(await response.arrayBuffer());
|
||||
}
|
||||
|
||||
async function configureSync(app) {
|
||||
const saved = await app.invoke("save_sync_settings", {
|
||||
syncServerUrl: syncUrl,
|
||||
syncToken,
|
||||
});
|
||||
assert.equal(saved.sync_server_url, syncUrl);
|
||||
assert.equal(saved.sync_token, syncToken);
|
||||
assert.deepEqual(await app.invoke("get_sync_settings"), saved);
|
||||
await app.invoke("restart_sync_service");
|
||||
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||
}
|
||||
|
||||
async function createProfile(app, name) {
|
||||
return app.invoke("create_browser_profile_new", {
|
||||
name,
|
||||
browserStr: "wayfern",
|
||||
version: "150.0.7871.100",
|
||||
releaseType: "stable",
|
||||
proxyId: null,
|
||||
vpnId: null,
|
||||
// Keep sync tests deterministic and network-free; browser.test.mjs covers
|
||||
// generation through the real Wayfern binary.
|
||||
wayfernConfig: { fingerprint: "{}" },
|
||||
groupId: null,
|
||||
ephemeral: false,
|
||||
dnsBlocklist: null,
|
||||
launchHook: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function waitFor(app, callback, description, timeoutMs = 45_000) {
|
||||
return app.waitFor(callback, { description, timeoutMs, intervalMs: 250 });
|
||||
}
|
||||
|
||||
test("two real app devices reconcile profile files and every config entity with last-write-wins", async () => {
|
||||
assert.ok(syncUrl && syncToken, "Sync infrastructure was not started");
|
||||
const deviceA = appFromEnvironment("sync-regular-a");
|
||||
const deviceB = appFromEnvironment("sync-regular-b");
|
||||
try {
|
||||
await Promise.all([deviceA.start(), deviceB.start()]);
|
||||
await Promise.all([configureSync(deviceA), configureSync(deviceB)]);
|
||||
|
||||
const group = await deviceA.invoke("create_profile_group", {
|
||||
name: "Synced Group A",
|
||||
});
|
||||
const proxy = await deviceA.invoke("create_stored_proxy", {
|
||||
name: "Synced Proxy A",
|
||||
proxySettings: {
|
||||
proxy_type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 8089,
|
||||
username: null,
|
||||
password: null,
|
||||
},
|
||||
});
|
||||
const vpn = await deviceA.invoke("create_vpn_config_manual", {
|
||||
name: "Synced VPN A",
|
||||
vpnType: "WireGuard",
|
||||
configData: wireGuardFixture(),
|
||||
});
|
||||
const extension = await deviceA.invoke("add_extension", {
|
||||
name: "Synced Extension A",
|
||||
fileName: "synced-fixture.zip",
|
||||
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
||||
});
|
||||
const extensionGroup = await deviceA.invoke("create_extension_group", {
|
||||
name: "Synced Extension Group A",
|
||||
});
|
||||
await deviceA.invoke("add_extension_to_group", {
|
||||
groupId: extensionGroup.id,
|
||||
extensionId: extension.id,
|
||||
});
|
||||
|
||||
await Promise.all([
|
||||
deviceA.invoke("set_group_sync_enabled", {
|
||||
groupId: group.id,
|
||||
enabled: true,
|
||||
}),
|
||||
deviceA.invoke("set_proxy_sync_enabled", {
|
||||
proxyId: proxy.id,
|
||||
enabled: true,
|
||||
}),
|
||||
deviceA.invoke("set_vpn_sync_enabled", { vpnId: vpn.id, enabled: true }),
|
||||
deviceA.invoke("set_extension_sync_enabled", {
|
||||
extensionId: extension.id,
|
||||
enabled: true,
|
||||
}),
|
||||
deviceA.invoke("set_extension_group_sync_enabled", {
|
||||
extensionGroupId: extensionGroup.id,
|
||||
enabled: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
const profile = await createProfile(deviceA, "Synced Profile A");
|
||||
const profileData = path.join(
|
||||
deviceA.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
profile.id,
|
||||
"profile",
|
||||
"Default",
|
||||
);
|
||||
await mkdir(profileData, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(profileData, "Preferences"),
|
||||
JSON.stringify({ donutE2E: "regular-profile-payload" }),
|
||||
);
|
||||
await deviceA.invoke("update_profile_tags", {
|
||||
profileId: profile.id,
|
||||
tags: ["sync", "device-a"],
|
||||
});
|
||||
await deviceA.invoke("update_profile_note", {
|
||||
profileId: profile.id,
|
||||
note: "regular sync metadata",
|
||||
});
|
||||
await deviceA.invoke("set_profile_sync_mode", {
|
||||
profileId: profile.id,
|
||||
syncMode: "Regular",
|
||||
});
|
||||
await deviceA.invoke("request_profile_sync", { profileId: profile.id });
|
||||
assert.equal(
|
||||
await deviceA.invoke("cancel_profile_sync", {
|
||||
profileId: "not-running-sync",
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
await waitFor(
|
||||
deviceA,
|
||||
async () => {
|
||||
const keys = (await listRemote("")).map((object) => object.key);
|
||||
return [
|
||||
`groups/${group.id}.json`,
|
||||
`proxies/${proxy.id}.json`,
|
||||
`vpns/${vpn.id}.json`,
|
||||
`extensions/${extension.id}.json`,
|
||||
`extension_groups/${extensionGroup.id}.json`,
|
||||
`profiles/${profile.id}/manifest.json`,
|
||||
`profiles/${profile.id}/files/profile/Default/Preferences`,
|
||||
].every((key) => keys.includes(key));
|
||||
},
|
||||
"all regular entities uploaded",
|
||||
);
|
||||
|
||||
await deviceB.invoke("restart_sync_service");
|
||||
await waitFor(
|
||||
deviceB,
|
||||
async () => {
|
||||
const [profiles, groups, proxies, vpns, extensions, extensionGroups] =
|
||||
await Promise.all([
|
||||
deviceB.invoke("list_browser_profiles"),
|
||||
deviceB.invoke("get_profile_groups"),
|
||||
deviceB.invoke("get_stored_proxies"),
|
||||
deviceB.invoke("list_vpn_configs"),
|
||||
deviceB.invoke("list_extensions"),
|
||||
deviceB.invoke("list_extension_groups"),
|
||||
]);
|
||||
return (
|
||||
profiles.some((item) => item.id === profile.id) &&
|
||||
groups.some((item) => item.id === group.id) &&
|
||||
proxies.some((item) => item.id === proxy.id) &&
|
||||
vpns.some((item) => item.id === vpn.id) &&
|
||||
extensions.some((item) => item.id === extension.id) &&
|
||||
extensionGroups.some((item) => item.id === extensionGroup.id)
|
||||
);
|
||||
},
|
||||
"device B receives every entity",
|
||||
);
|
||||
const downloadedPreferences = path.join(
|
||||
deviceB.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
profile.id,
|
||||
"profile",
|
||||
"Default",
|
||||
"Preferences",
|
||||
);
|
||||
await waitFor(
|
||||
deviceB,
|
||||
async () =>
|
||||
(
|
||||
await readFile(downloadedPreferences, "utf8").catch(() => "")
|
||||
).includes("regular-profile-payload"),
|
||||
"device B receives profile browser files",
|
||||
);
|
||||
|
||||
// updated_at has one-second resolution. Make the device-B edits
|
||||
// unambiguously newer, then verify last-write-wins in both directions.
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||
await deviceB.invoke("update_stored_proxy", {
|
||||
proxyId: proxy.id,
|
||||
name: "Synced Proxy B Wins",
|
||||
proxySettings: null,
|
||||
});
|
||||
await deviceB.invoke("rename_profile", {
|
||||
profileId: profile.id,
|
||||
newName: "Synced Profile B Wins",
|
||||
});
|
||||
await deviceB.invoke("request_profile_sync", { profileId: profile.id });
|
||||
await deviceA.invoke("restart_sync_service");
|
||||
await waitFor(
|
||||
deviceA,
|
||||
async () => {
|
||||
const proxies = await deviceA.invoke("get_stored_proxies");
|
||||
const profiles = await deviceA.invoke("list_browser_profiles");
|
||||
return (
|
||||
proxies.find((item) => item.id === proxy.id)?.name ===
|
||||
"Synced Proxy B Wins" &&
|
||||
profiles.find((item) => item.id === profile.id)?.name ===
|
||||
"Synced Profile B Wins"
|
||||
);
|
||||
},
|
||||
"newer device-B edits win on device A",
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
await deviceA.invoke("is_proxy_in_use_by_synced_profile", {
|
||||
proxyId: proxy.id,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await deviceA.invoke("is_group_in_use_by_synced_profile", {
|
||||
groupId: group.id,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
await deviceA.invoke("is_vpn_in_use_by_synced_profile", {
|
||||
vpnId: vpn.id,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
const counts = await deviceA.invoke("get_unsynced_entity_counts");
|
||||
assert.equal(typeof counts.proxies, "number");
|
||||
await deviceA.invoke("enable_sync_for_all_entities");
|
||||
|
||||
await Promise.all([
|
||||
deviceB.invoke("delete_extension_group", {
|
||||
groupId: extensionGroup.id,
|
||||
}),
|
||||
deviceB.invoke("delete_extension", { extensionId: extension.id }),
|
||||
deviceB.invoke("delete_vpn_config", { vpnId: vpn.id }),
|
||||
deviceB.invoke("delete_profile_group", { groupId: group.id }),
|
||||
deviceB.invoke("delete_stored_proxy", { proxyId: proxy.id }),
|
||||
deviceB.invoke("delete_profile", { profileId: profile.id }),
|
||||
]);
|
||||
await waitFor(
|
||||
deviceB,
|
||||
async () => {
|
||||
const keys = (await listRemote("")).map((object) => object.key);
|
||||
return [
|
||||
`tombstones/groups/${group.id}.json`,
|
||||
`tombstones/proxies/${proxy.id}.json`,
|
||||
`tombstones/vpns/${vpn.id}.json`,
|
||||
`tombstones/extensions/${extension.id}.json`,
|
||||
`tombstones/extension_groups/${extensionGroup.id}.json`,
|
||||
`tombstones/profiles/${profile.id}.json`,
|
||||
].every((key) => keys.includes(key));
|
||||
},
|
||||
"deletions create every remote tombstone",
|
||||
);
|
||||
await waitFor(
|
||||
deviceA,
|
||||
async () => {
|
||||
const [profiles, groups, proxies, vpns, extensions, extensionGroups] =
|
||||
await Promise.all([
|
||||
deviceA.invoke("list_browser_profiles"),
|
||||
deviceA.invoke("get_profile_groups"),
|
||||
deviceA.invoke("get_stored_proxies"),
|
||||
deviceA.invoke("list_vpn_configs"),
|
||||
deviceA.invoke("list_extensions"),
|
||||
deviceA.invoke("list_extension_groups"),
|
||||
]);
|
||||
return (
|
||||
!profiles.some((item) => item.id === profile.id) &&
|
||||
!groups.some((item) => item.id === group.id) &&
|
||||
!proxies.some((item) => item.id === proxy.id) &&
|
||||
!vpns.some((item) => item.id === vpn.id) &&
|
||||
!extensions.some((item) => item.id === extension.id) &&
|
||||
!extensionGroups.some((item) => item.id === extensionGroup.id)
|
||||
);
|
||||
},
|
||||
"remote tombstones delete every entity from device A",
|
||||
);
|
||||
} catch (error) {
|
||||
await Promise.all([deviceA.capture("failure"), deviceB.capture("failure")]);
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.all([deviceA.close(), deviceB.close()]);
|
||||
}
|
||||
});
|
||||
|
||||
test("global config sealing and encrypted profile sync reject a wrong password, round-trip with the right one, and roll over", async () => {
|
||||
const source = appFromEnvironment("sync-encrypted-source");
|
||||
const receiver = appFromEnvironment("sync-encrypted-receiver");
|
||||
const rolloverReceiver = appFromEnvironment(
|
||||
"sync-encrypted-rollover-receiver",
|
||||
);
|
||||
try {
|
||||
await Promise.all([source.start(), receiver.start()]);
|
||||
await Promise.all([configureSync(source), configureSync(receiver)]);
|
||||
await source.invoke("set_e2e_password", {
|
||||
password: "shared encryption password",
|
||||
});
|
||||
await receiver.invoke("set_e2e_password", {
|
||||
password: "intentionally wrong password",
|
||||
});
|
||||
assert.equal(await source.invoke("check_has_e2e_password"), true);
|
||||
assert.equal(
|
||||
await source.invoke("verify_e2e_password", {
|
||||
password: "shared encryption password",
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await source.invoke("verify_e2e_password", { password: "wrong" }),
|
||||
false,
|
||||
);
|
||||
|
||||
const sealedProxy = await source.invoke("create_stored_proxy", {
|
||||
name: "SECRET-CONFIG-MARKER",
|
||||
proxySettings: {
|
||||
proxy_type: "http",
|
||||
host: "secret-proxy.invalid",
|
||||
port: 8443,
|
||||
username: "secret-user",
|
||||
password: "secret-password",
|
||||
},
|
||||
});
|
||||
await source.invoke("set_proxy_sync_enabled", {
|
||||
proxyId: sealedProxy.id,
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
const encryptedProfile = await createProfile(source, "Encrypted Profile");
|
||||
const encryptedData = path.join(
|
||||
source.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
encryptedProfile.id,
|
||||
"profile",
|
||||
);
|
||||
await mkdir(encryptedData, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(encryptedData, "Local State"),
|
||||
"SECRET-PROFILE-MARKER that must never appear remotely",
|
||||
);
|
||||
await source.invoke("set_profile_sync_mode", {
|
||||
profileId: encryptedProfile.id,
|
||||
syncMode: "Encrypted",
|
||||
});
|
||||
await source.invoke("request_profile_sync", {
|
||||
profileId: encryptedProfile.id,
|
||||
});
|
||||
|
||||
const proxyKey = `proxies/${sealedProxy.id}.json`;
|
||||
const profileMetadataKey = `profiles/${encryptedProfile.id}/metadata.json`;
|
||||
const profileFileKey = `profiles/${encryptedProfile.id}/files/profile/Local State`;
|
||||
await waitFor(
|
||||
source,
|
||||
async () => {
|
||||
const keys = (await listRemote("")).map((object) => object.key);
|
||||
return (
|
||||
keys.includes(proxyKey) &&
|
||||
keys.includes(profileMetadataKey) &&
|
||||
keys.includes(profileFileKey)
|
||||
);
|
||||
},
|
||||
"sealed config and encrypted profile uploaded",
|
||||
);
|
||||
const sealedBefore = await downloadRemote(proxyKey);
|
||||
const metadataBefore = await downloadRemote(profileMetadataKey);
|
||||
const encryptedFile = await downloadRemote(profileFileKey);
|
||||
assert.equal(
|
||||
sealedBefore.includes(Buffer.from("SECRET-CONFIG-MARKER")),
|
||||
false,
|
||||
);
|
||||
assert.equal(sealedBefore.includes(Buffer.from("secret-password")), false);
|
||||
assert.equal(
|
||||
encryptedFile.includes(Buffer.from("SECRET-PROFILE-MARKER")),
|
||||
false,
|
||||
);
|
||||
const envelope = JSON.parse(sealedBefore.toString("utf8"));
|
||||
assert.equal(envelope.v, 1);
|
||||
assert.ok(envelope.salt && envelope.ct);
|
||||
|
||||
await receiver.invoke("restart_sync_service");
|
||||
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||
assert.equal(
|
||||
(await receiver.invoke("get_stored_proxies")).some(
|
||||
(item) => item.id === sealedProxy.id,
|
||||
),
|
||||
false,
|
||||
"wrong password must not materialize sealed config",
|
||||
);
|
||||
assert.equal(
|
||||
(await receiver.invoke("list_browser_profiles")).some(
|
||||
(item) => item.id === encryptedProfile.id,
|
||||
),
|
||||
false,
|
||||
"wrong password must not materialize encrypted profiles",
|
||||
);
|
||||
|
||||
await receiver.invoke("set_e2e_password", {
|
||||
password: "shared encryption password",
|
||||
});
|
||||
await receiver.invoke("restart_sync_service");
|
||||
await waitFor(
|
||||
receiver,
|
||||
async () =>
|
||||
(await receiver.invoke("get_stored_proxies")).some(
|
||||
(item) =>
|
||||
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
||||
) &&
|
||||
(await receiver.invoke("list_browser_profiles")).some(
|
||||
(item) => item.id === encryptedProfile.id,
|
||||
),
|
||||
"correct password decrypts config and profile metadata",
|
||||
);
|
||||
const receiverFile = path.join(
|
||||
receiver.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
encryptedProfile.id,
|
||||
"profile",
|
||||
"Local State",
|
||||
);
|
||||
await waitFor(
|
||||
receiver,
|
||||
async () =>
|
||||
(await readFile(receiverFile, "utf8").catch(() => "")).includes(
|
||||
"SECRET-PROFILE-MARKER",
|
||||
),
|
||||
"correct password decrypts profile browser file",
|
||||
);
|
||||
|
||||
await source.invoke("set_e2e_password", {
|
||||
password: "rolled encryption password",
|
||||
});
|
||||
await source.invoke("rollover_encryption_for_all_entities");
|
||||
await waitFor(
|
||||
source,
|
||||
async () => {
|
||||
const [proxy, metadata] = await Promise.all([
|
||||
downloadRemote(proxyKey),
|
||||
downloadRemote(profileMetadataKey),
|
||||
]);
|
||||
return !proxy.equals(sealedBefore) && !metadata.equals(metadataBefore);
|
||||
},
|
||||
"password rollover rewrites sealed config and profile metadata",
|
||||
);
|
||||
const sealedAfter = await downloadRemote(proxyKey);
|
||||
assert.equal(
|
||||
sealedAfter.includes(Buffer.from("SECRET-CONFIG-MARKER")),
|
||||
false,
|
||||
);
|
||||
await receiver.invoke("set_e2e_password", {
|
||||
password: "rolled encryption password",
|
||||
});
|
||||
await receiver.invoke("restart_sync_service");
|
||||
await waitFor(
|
||||
receiver,
|
||||
async () =>
|
||||
(await receiver.invoke("get_stored_proxies")).some(
|
||||
(item) =>
|
||||
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
||||
),
|
||||
"receiver accepts rolled password",
|
||||
);
|
||||
|
||||
await rolloverReceiver.start();
|
||||
await rolloverReceiver.invoke("set_e2e_password", {
|
||||
password: "rolled encryption password",
|
||||
});
|
||||
await configureSync(rolloverReceiver);
|
||||
await waitFor(
|
||||
rolloverReceiver,
|
||||
async () =>
|
||||
(await rolloverReceiver.invoke("get_stored_proxies")).some(
|
||||
(item) =>
|
||||
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
||||
) &&
|
||||
(await rolloverReceiver.invoke("list_browser_profiles")).some(
|
||||
(item) => item.id === encryptedProfile.id,
|
||||
),
|
||||
"fresh receiver decrypts rolled config and profile metadata",
|
||||
);
|
||||
const rolloverFile = path.join(
|
||||
rolloverReceiver.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
encryptedProfile.id,
|
||||
"profile",
|
||||
"Local State",
|
||||
);
|
||||
await waitFor(
|
||||
rolloverReceiver,
|
||||
async () =>
|
||||
(await readFile(rolloverFile, "utf8").catch(() => "")).includes(
|
||||
"SECRET-PROFILE-MARKER",
|
||||
),
|
||||
"fresh receiver decrypts rolled profile browser file",
|
||||
);
|
||||
|
||||
await source.invoke("set_profile_sync_mode", {
|
||||
profileId: encryptedProfile.id,
|
||||
syncMode: "Disabled",
|
||||
});
|
||||
await source.invoke("delete_e2e_password");
|
||||
assert.equal(await source.invoke("check_has_e2e_password"), false);
|
||||
const missingPassword = await source.invokeError("verify_e2e_password", {
|
||||
password: "rolled encryption password",
|
||||
});
|
||||
assert.match(missingPassword, /NO_E2E_PASSWORD_SET/);
|
||||
} catch (error) {
|
||||
await Promise.all([
|
||||
source.capture("failure"),
|
||||
receiver.capture("failure"),
|
||||
rolloverReceiver.capture("failure"),
|
||||
]);
|
||||
throw error;
|
||||
} finally {
|
||||
await Promise.all([
|
||||
source.close(),
|
||||
receiver.close(),
|
||||
rolloverReceiver.close(),
|
||||
]);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,449 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
|
||||
const THEME_VARIABLES = [
|
||||
"--background",
|
||||
"--foreground",
|
||||
"--card",
|
||||
"--card-foreground",
|
||||
"--popover",
|
||||
"--popover-foreground",
|
||||
"--primary",
|
||||
"--primary-foreground",
|
||||
"--secondary",
|
||||
"--secondary-foreground",
|
||||
"--muted",
|
||||
"--muted-foreground",
|
||||
"--accent",
|
||||
"--accent-foreground",
|
||||
"--destructive",
|
||||
"--destructive-foreground",
|
||||
"--success",
|
||||
"--success-foreground",
|
||||
"--warning",
|
||||
"--warning-foreground",
|
||||
"--border",
|
||||
"--chart-1",
|
||||
"--chart-2",
|
||||
"--chart-3",
|
||||
"--chart-4",
|
||||
"--chart-5",
|
||||
];
|
||||
|
||||
const DRACULA_THEME = {
|
||||
"--background": "#282a36",
|
||||
"--foreground": "#f8f8f2",
|
||||
"--card": "#44475a",
|
||||
"--card-foreground": "#f8f8f2",
|
||||
"--popover": "#44475a",
|
||||
"--popover-foreground": "#f8f8f2",
|
||||
"--primary": "#bd93f9",
|
||||
"--primary-foreground": "#282a36",
|
||||
"--secondary": "#8be9fd",
|
||||
"--secondary-foreground": "#282a36",
|
||||
"--muted": "#6272a4",
|
||||
"--muted-foreground": "#f8f8f2",
|
||||
"--accent": "#ff79c6",
|
||||
"--accent-foreground": "#282a36",
|
||||
"--destructive": "#ff5555",
|
||||
"--destructive-foreground": "#f8f8f2",
|
||||
"--success": "#50fa7b",
|
||||
"--success-foreground": "#282a36",
|
||||
"--warning": "#ffb86c",
|
||||
"--warning-foreground": "#282a36",
|
||||
"--border": "#6272a4",
|
||||
"--chart-1": "#bd93f9",
|
||||
"--chart-2": "#50fa7b",
|
||||
"--chart-3": "#ff79c6",
|
||||
"--chart-4": "#8be9fd",
|
||||
"--chart-5": "#ffb86c",
|
||||
};
|
||||
|
||||
async function dismissSurface(app) {
|
||||
await app.pressShortcut({ key: "Escape" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
async function themeSnapshot(app) {
|
||||
return app.execute(
|
||||
`
|
||||
const root = document.documentElement;
|
||||
const rootStyle = getComputedStyle(root);
|
||||
const bodyStyle = getComputedStyle(document.body);
|
||||
const variables = arguments[0];
|
||||
return {
|
||||
mode: root.classList.contains("light")
|
||||
? "light"
|
||||
: root.classList.contains("dark")
|
||||
? "dark"
|
||||
: "unset",
|
||||
inline: Object.fromEntries(
|
||||
variables.map((key) => [key, root.style.getPropertyValue(key).trim()])
|
||||
),
|
||||
resolved: Object.fromEntries(
|
||||
variables.map((key) => [key, rootStyle.getPropertyValue(key).trim()])
|
||||
),
|
||||
bodyBackground: bodyStyle.backgroundColor,
|
||||
bodyForeground: bodyStyle.color,
|
||||
};
|
||||
`,
|
||||
[THEME_VARIABLES],
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForTheme(app, predicate, description) {
|
||||
return app.waitFor(
|
||||
async () => {
|
||||
const snapshot = await themeSnapshot(app);
|
||||
return predicate(snapshot) ? snapshot : false;
|
||||
},
|
||||
{ description },
|
||||
);
|
||||
}
|
||||
|
||||
function themeVariablesEqual(actual, expected) {
|
||||
return THEME_VARIABLES.every(
|
||||
(key) => actual[key]?.toLowerCase() === expected[key]?.toLowerCase(),
|
||||
);
|
||||
}
|
||||
|
||||
async function chooseSelectOption(app, triggerSelector, option) {
|
||||
await app.clickSelector(triggerSelector);
|
||||
await app.clickText(option, { roles: ["option"] });
|
||||
}
|
||||
|
||||
async function saveSettings(app) {
|
||||
await app.clickText("Save Settings", { roles: ["button"] });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(`return document.querySelector("#theme-select") === null;`),
|
||||
{ description: "Settings to close after saving" },
|
||||
);
|
||||
}
|
||||
|
||||
async function assertThemeAcrossNavigation(app, expected) {
|
||||
for (const surface of ["Network", "Extensions", "Profiles"]) {
|
||||
await app.clickSelector(`[aria-label="${surface}"]`);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(expected),
|
||||
{ description: `theme to remain unchanged on ${surface}` },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function dragBackgroundColorPicker(app) {
|
||||
await app.clickSelector('[aria-label="Background"]');
|
||||
const drag = await app.waitFor(
|
||||
() =>
|
||||
app.execute(`
|
||||
const popover = document.querySelector('[data-slot="popover-content"]');
|
||||
const selection = [...(popover?.querySelectorAll("div") ?? [])].find(
|
||||
(node) => node.style.background.includes("linear-gradient")
|
||||
);
|
||||
if (!selection) return null;
|
||||
const rect = selection.getBoundingClientRect();
|
||||
const points = [];
|
||||
for (const yf of [0.2, 0.4, 0.6, 0.8]) {
|
||||
for (const xf of [0.2, 0.4, 0.6, 0.8]) {
|
||||
const point = {
|
||||
x: Math.round(rect.left + rect.width * xf),
|
||||
y: Math.round(rect.top + rect.height * yf),
|
||||
};
|
||||
const hit = document.elementFromPoint(point.x, point.y);
|
||||
if (hit === selection || selection.contains(hit)) points.push(point);
|
||||
}
|
||||
}
|
||||
return points.length >= 2
|
||||
? { start: points[0], end: points[points.length - 1] }
|
||||
: null;
|
||||
`),
|
||||
{ description: "two pointer-interactive background color picker points" },
|
||||
);
|
||||
await app.execute(`
|
||||
window.__donutE2eThemePointerEvents = [];
|
||||
for (const type of ["pointermove", "pointerdown", "pointerup"]) {
|
||||
window.addEventListener(type, (event) => {
|
||||
window.__donutE2eThemePointerEvents.push({
|
||||
type,
|
||||
x: event.clientX,
|
||||
y: event.clientY,
|
||||
buttons: event.buttons,
|
||||
target: event.target?.className ?? event.target?.tagName ?? "",
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
`);
|
||||
await app.session.command("POST", "/actions", {
|
||||
actions: [
|
||||
{
|
||||
type: "pointer",
|
||||
id: "theme-color-pointer",
|
||||
actions: [
|
||||
{
|
||||
type: "pointerMove",
|
||||
x: drag.start.x,
|
||||
y: drag.start.y,
|
||||
origin: "viewport",
|
||||
},
|
||||
{ type: "pointerDown", button: 0 },
|
||||
{ type: "pause", duration: 150 },
|
||||
{
|
||||
type: "pointerMove",
|
||||
x: drag.end.x,
|
||||
y: drag.end.y,
|
||||
duration: 100,
|
||||
origin: "viewport",
|
||||
},
|
||||
{ type: "pointerUp", button: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
const pointerEvents = await app.execute(
|
||||
`return window.__donutE2eThemePointerEvents ?? [];`,
|
||||
);
|
||||
assert.deepEqual(
|
||||
pointerEvents.map((event) => event.type),
|
||||
["pointermove", "pointerdown", "pointermove", "pointerup"],
|
||||
);
|
||||
assert.match(pointerEvents[1].target, /cursor-pointer/);
|
||||
assert.match(pointerEvents[2].target, /cursor-pointer/);
|
||||
assert.equal(pointerEvents[2].buttons, 1);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelector("#theme-preset-select")?.textContent?.includes("Your Own") === true;`,
|
||||
),
|
||||
{
|
||||
description: `customized theme to be marked as Your Own after ${JSON.stringify(pointerEvents)}`,
|
||||
},
|
||||
);
|
||||
await app.clickSelector('[aria-label="Background"]');
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return document.querySelector('[data-slot="popover-content"]') === null;`,
|
||||
),
|
||||
{ description: "color picker to close" },
|
||||
);
|
||||
}
|
||||
|
||||
test("all primary navigation buttons and sub-page tabs render and remain interactive", async () => {
|
||||
await withApp("ui-navigation", async (app) => {
|
||||
const surfaces = [
|
||||
["Settings", /General|Appearance|Sync/i],
|
||||
["Network", /Proxies|VPNs|DNS/i],
|
||||
["Extensions", /Extensions|Groups/i],
|
||||
["Integrations", /API|MCP/i],
|
||||
["Account", /Account|Sign in/i],
|
||||
];
|
||||
for (const [label, expected] of surfaces) {
|
||||
await app.clickSelector(`[aria-label="${label}"]`);
|
||||
await app.waitFor(async () => expected.test(await app.bodyText()), {
|
||||
description: `${label} surface`,
|
||||
});
|
||||
assert.match(await app.bodyText(), expected);
|
||||
await dismissSurface(app);
|
||||
}
|
||||
|
||||
await app.clickSelector('[aria-label="Groups"]');
|
||||
await app.waitForText("Create");
|
||||
await dismissSurface(app);
|
||||
|
||||
await app.clickSelector('[aria-label="More"]');
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(`return Boolean(document.querySelector("[role='menu']"));`),
|
||||
{ description: "More menu" },
|
||||
);
|
||||
await dismissSurface(app);
|
||||
|
||||
await app.clickSelector('[aria-label="Profiles"]');
|
||||
await app.clickText("New");
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return Boolean(document.querySelector("[role='dialog']"));`,
|
||||
),
|
||||
{ description: "new profile dialog" },
|
||||
);
|
||||
assert.match(await app.bodyText(), /profile/i);
|
||||
await dismissSurface(app);
|
||||
});
|
||||
});
|
||||
|
||||
test("settings tabs, command palette filtering, and responsive layout survive resize", async () => {
|
||||
await withApp("ui-settings-responsive", async (app) => {
|
||||
await app.clickSelector('[aria-label="Settings"]');
|
||||
await app.waitForText("Appearance");
|
||||
for (const tab of ["Appearance", "Sync", "Encryption"]) {
|
||||
const exists = await app.execute(
|
||||
`return [...document.querySelectorAll("[role='tab']")].some(
|
||||
(node) => (node.textContent || "").trim() === arguments[0]
|
||||
);`,
|
||||
[tab],
|
||||
);
|
||||
if (exists) {
|
||||
await app.clickText(tab, { roles: ["tab"] });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return [...document.querySelectorAll("[role='tab']")].some(
|
||||
(node) => (node.textContent || "").trim() === arguments[0] &&
|
||||
node.getAttribute("data-state") === "active"
|
||||
);`,
|
||||
[tab],
|
||||
),
|
||||
{ description: `${tab} settings tab` },
|
||||
);
|
||||
}
|
||||
}
|
||||
await dismissSurface(app);
|
||||
|
||||
const modifier =
|
||||
process.platform === "darwin" ? { meta: true } : { ctrl: true };
|
||||
await app.pressShortcut({ key: "k", ...modifier });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(`return Boolean(document.querySelector("[cmdk-input]"));`),
|
||||
{ description: "command palette" },
|
||||
);
|
||||
const input = await app.session.findCss("[cmdk-input]");
|
||||
await app.session.sendKeys(input, "proxy vpn");
|
||||
assert.match(await app.bodyText(), /Network|Proxy|VPN/i);
|
||||
await dismissSurface(app);
|
||||
|
||||
// The native driver owns the top-level window. Resize through the WebDriver
|
||||
// protocol and assert the app still has usable controls at the minimum size.
|
||||
await app.session.command("POST", "/window/rect", {
|
||||
width: 640,
|
||||
height: 400,
|
||||
});
|
||||
const viewport = await app.execute(
|
||||
"return { width: innerWidth, height: innerHeight };",
|
||||
);
|
||||
assert.ok(viewport.width >= 600);
|
||||
assert.ok(viewport.height >= 350);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelector('[aria-label="Settings"]').getBoundingClientRect().width > 0;`,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("predefined theme remains rendered across navigation and restart", async () => {
|
||||
await withApp("ui-theme-predefined", async (app) => {
|
||||
await app.clickSelector('[aria-label="Settings"]');
|
||||
await app.waitForText("Appearance");
|
||||
await chooseSelectOption(app, "#theme-select", "Light");
|
||||
await saveSettings(app);
|
||||
|
||||
const persisted = await app.invoke("get_app_settings");
|
||||
assert.equal(persisted.theme, "light");
|
||||
const selected = await waitForTheme(
|
||||
app,
|
||||
(snapshot) =>
|
||||
snapshot.mode === "light" &&
|
||||
Object.values(snapshot.inline).every((value) => value === ""),
|
||||
"predefined light theme to render without custom variables",
|
||||
);
|
||||
assert.notEqual(selected.bodyBackground, "");
|
||||
assert.notEqual(selected.bodyForeground, "");
|
||||
await assertThemeAcrossNavigation(app, selected);
|
||||
|
||||
await app.restart();
|
||||
assert.equal((await app.invoke("get_app_settings")).theme, "light");
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(selected),
|
||||
{ description: "predefined light theme after restart" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
test("preset and manually customized themes survive navigation and restart", async () => {
|
||||
await withApp("ui-theme-custom", async (app) => {
|
||||
await app.clickSelector('[aria-label="Settings"]');
|
||||
await app.waitForText("Appearance");
|
||||
await chooseSelectOption(app, "#theme-select", "Custom");
|
||||
await chooseSelectOption(app, "#theme-preset-select", "Dracula");
|
||||
await saveSettings(app);
|
||||
|
||||
const presetSettings = await app.invoke("get_app_settings");
|
||||
assert.equal(presetSettings.theme, "custom");
|
||||
assert.deepEqual(presetSettings.custom_theme, DRACULA_THEME);
|
||||
const preset = await waitForTheme(
|
||||
app,
|
||||
(snapshot) =>
|
||||
snapshot.mode === "dark" &&
|
||||
themeVariablesEqual(snapshot.inline, DRACULA_THEME) &&
|
||||
themeVariablesEqual(snapshot.resolved, DRACULA_THEME),
|
||||
"Dracula preset variables to render",
|
||||
);
|
||||
await assertThemeAcrossNavigation(app, preset);
|
||||
|
||||
await app.restart();
|
||||
assert.deepEqual(
|
||||
(await app.invoke("get_app_settings")).custom_theme,
|
||||
DRACULA_THEME,
|
||||
);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(preset),
|
||||
{ description: "Dracula preset after restart" },
|
||||
);
|
||||
|
||||
await app.clickSelector('[aria-label="Settings"]');
|
||||
await app.waitForText("Appearance");
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelector("#theme-select")?.textContent?.trim();`,
|
||||
),
|
||||
"Custom",
|
||||
);
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
`return document.querySelector("#theme-preset-select")?.textContent?.trim();`,
|
||||
),
|
||||
"Dracula",
|
||||
);
|
||||
await dragBackgroundColorPicker(app);
|
||||
await saveSettings(app);
|
||||
|
||||
const customizedSettings = await app.invoke("get_app_settings");
|
||||
assert.equal(customizedSettings.theme, "custom");
|
||||
assert.notEqual(
|
||||
customizedSettings.custom_theme["--background"].toLowerCase(),
|
||||
DRACULA_THEME["--background"],
|
||||
);
|
||||
assert.deepEqual(
|
||||
Object.keys(customizedSettings.custom_theme).sort(),
|
||||
[...THEME_VARIABLES].sort(),
|
||||
);
|
||||
const customized = await waitForTheme(
|
||||
app,
|
||||
(snapshot) =>
|
||||
snapshot.mode === "dark" &&
|
||||
themeVariablesEqual(snapshot.inline, customizedSettings.custom_theme) &&
|
||||
themeVariablesEqual(snapshot.resolved, customizedSettings.custom_theme),
|
||||
"manually customized variables to render",
|
||||
);
|
||||
assert.notEqual(customized.bodyBackground, preset.bodyBackground);
|
||||
await assertThemeAcrossNavigation(app, customized);
|
||||
|
||||
await app.restart();
|
||||
assert.deepEqual(
|
||||
(await app.invoke("get_app_settings")).custom_theme,
|
||||
customizedSettings.custom_theme,
|
||||
);
|
||||
await app.waitFor(
|
||||
async () =>
|
||||
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(customized),
|
||||
{ description: "manually customized theme after restart" },
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.27.0";
|
||||
releaseVersion = "0.28.2";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_amd64.AppImage";
|
||||
hash = "sha256-b9jY+SPw+5UvvTKgXmvxLJjIbrLW6kHTVeZywJA6DFE=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage";
|
||||
hash = "sha256-+CqHiPMg4oczNiPg+MC6jvp0CUcK4kb5yeyk+QDbAWY=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.27.0/Donut_0.27.0_aarch64.AppImage";
|
||||
hash = "sha256-UyK3p88kx3JkJmQ9Jv1hQGmfLbG1YZDuF2pZ1h529sQ=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage";
|
||||
hash = "sha256-HodokW2ySIpdpW7Hyqpwsm8whQ0hHldlSg11Sl1UW3k=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+38
-31
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.27.0",
|
||||
"version": "0.28.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
@@ -12,41 +12,49 @@
|
||||
"test:rust": "cd src-tauri && cargo test",
|
||||
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
|
||||
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
|
||||
"e2e": "node e2e/run.mjs --suite=full",
|
||||
"e2e:smoke": "node e2e/run.mjs --suite=smoke",
|
||||
"e2e:ui": "node e2e/run.mjs --suite=ui",
|
||||
"e2e:entities": "node e2e/run.mjs --suite=entities",
|
||||
"e2e:network": "node e2e/run.mjs --suite=network",
|
||||
"e2e:integrations": "node e2e/run.mjs --suite=integrations",
|
||||
"e2e:sync": "node e2e/run.mjs --suite=sync",
|
||||
"e2e:browser": "node e2e/run.mjs --suite=browser",
|
||||
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
|
||||
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
|
||||
"lint:js": "biome check src/ e2e/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
|
||||
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||
"lint:spell": "typos .",
|
||||
"tauri": "node scripts/run-with-env.mjs tauri",
|
||||
"shadcn:add": "pnpm dlx shadcn@latest add",
|
||||
"prepare": "husky && husky install",
|
||||
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||
"format:js": "biome check src/ --write --unsafe && cd donut-sync && biome check src/ --write --unsafe",
|
||||
"format:js": "biome check src/ e2e/ --write --unsafe && cd donut-sync && biome check src/ --write --unsafe",
|
||||
"format": "pnpm format:js && pnpm format:rust",
|
||||
"build:sync": "cd donut-sync && pnpm build",
|
||||
"cargo": "cd src-tauri && cargo",
|
||||
"unused-exports:js": "ts-unused-exports tsconfig.json",
|
||||
"check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands",
|
||||
"copy-proxy-binary": "node src-tauri/copy-proxy-binary.mjs",
|
||||
"prebuild": "pnpm copy-proxy-binary",
|
||||
"copy-proxy-binary:release": "node src-tauri/copy-proxy-binary.mjs --release",
|
||||
"pretauri:dev": "pnpm copy-proxy-binary",
|
||||
"precargo": "pnpm copy-proxy-binary"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.5",
|
||||
"@radix-ui/react-dialog": "^1.1.17",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.18",
|
||||
"@radix-ui/react-label": "^2.1.10",
|
||||
"@radix-ui/react-popover": "^1.1.17",
|
||||
"@radix-ui/react-portal": "^1.1.12",
|
||||
"@radix-ui/react-progress": "^1.1.10",
|
||||
"@radix-ui/react-radio-group": "^1.4.1",
|
||||
"@radix-ui/react-scroll-area": "^1.2.12",
|
||||
"@radix-ui/react-select": "^2.3.1",
|
||||
"@radix-ui/react-checkbox": "^1.3.7",
|
||||
"@radix-ui/react-dialog": "^1.1.19",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.20",
|
||||
"@radix-ui/react-label": "^2.1.11",
|
||||
"@radix-ui/react-popover": "^1.1.19",
|
||||
"@radix-ui/react-portal": "^1.1.13",
|
||||
"@radix-ui/react-progress": "^1.1.12",
|
||||
"@radix-ui/react-radio-group": "^1.4.3",
|
||||
"@radix-ui/react-scroll-area": "^1.2.14",
|
||||
"@radix-ui/react-select": "^2.3.3",
|
||||
"@radix-ui/react-slot": "^1.3.0",
|
||||
"@radix-ui/react-tabs": "^1.1.15",
|
||||
"@radix-ui/react-tooltip": "^1.2.10",
|
||||
"@radix-ui/react-tabs": "^1.1.17",
|
||||
"@radix-ui/react-tooltip": "^1.2.12",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.14.3",
|
||||
"@tanstack/react-virtual": "^3.14.5",
|
||||
"@tauri-apps/api": "~2.11.1",
|
||||
"@tauri-apps/plugin-clipboard-manager": "^2.3.2",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.9",
|
||||
@@ -61,40 +69,39 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"framer-motion": "^12.40.0",
|
||||
"i18next": "^26.3.1",
|
||||
"lucide-react": "^1.21.0",
|
||||
"motion": "^12.40.0",
|
||||
"next": "^16.2.9",
|
||||
"i18next": "^26.3.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"motion": "^12.42.2",
|
||||
"next": "^16.2.10",
|
||||
"next-themes": "^0.4.6",
|
||||
"onborda": "^1.2.5",
|
||||
"radix-ui": "^1.6.0",
|
||||
"radix-ui": "^1.6.2",
|
||||
"react": "^19.2.7",
|
||||
"react-dom": "^19.2.7",
|
||||
"react-i18next": "^17.0.8",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "3.8.1",
|
||||
"react-icons": "^5.7.0",
|
||||
"recharts": "3.9.2",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.6.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.0",
|
||||
"@tailwindcss/postcss": "^4.3.1",
|
||||
"@tauri-apps/cli": "~2.11.3",
|
||||
"@biomejs/biome": "2.5.2",
|
||||
"@tailwindcss/postcss": "^4.3.2",
|
||||
"@tauri-apps/cli": "~2.11.4",
|
||||
"@types/canvas-confetti": "^1.9.0",
|
||||
"@types/color": "^4.2.1",
|
||||
"@types/node": "^26.0.0",
|
||||
"@types/node": "^26.1.0",
|
||||
"@types/react": "^19.2.17",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^17.0.8",
|
||||
"tailwindcss": "^4.3.1",
|
||||
"tailwindcss": "^4.3.2",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.2.2",
|
||||
"packageManager": "pnpm@11.10.0",
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||
"biome check --fix"
|
||||
|
||||
Generated
+1077
-1100
File diff suppressed because it is too large
Load Diff
@@ -37,3 +37,57 @@ allowBuilds:
|
||||
'@nestjs/core': true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
|
||||
minimumReleaseAgeExclude:
|
||||
- '@radix-ui/primitive@1.1.5'
|
||||
- '@radix-ui/react-accordion@1.2.16'
|
||||
- '@radix-ui/react-alert-dialog@1.1.19'
|
||||
- '@radix-ui/react-avatar@1.2.2'
|
||||
- '@radix-ui/react-checkbox@1.3.7'
|
||||
- '@radix-ui/react-collapsible@1.1.16'
|
||||
- '@radix-ui/react-collection@1.1.12'
|
||||
- '@radix-ui/react-context-menu@2.3.3'
|
||||
- '@radix-ui/react-context@1.2.0'
|
||||
- '@radix-ui/react-dialog@1.1.19'
|
||||
- '@radix-ui/react-dismissable-layer@1.1.15'
|
||||
- '@radix-ui/react-dropdown-menu@2.1.20'
|
||||
- '@radix-ui/react-focus-scope@1.1.12'
|
||||
- '@radix-ui/react-form@0.1.12'
|
||||
- '@radix-ui/react-hover-card@1.1.19'
|
||||
- '@radix-ui/react-menu@2.1.20'
|
||||
- '@radix-ui/react-menubar@1.1.20'
|
||||
- '@radix-ui/react-navigation-menu@1.2.18'
|
||||
- '@radix-ui/react-one-time-password-field@0.1.12'
|
||||
- '@radix-ui/react-password-toggle-field@0.1.7'
|
||||
- '@radix-ui/react-popover@1.1.19'
|
||||
- '@radix-ui/react-popper@1.3.3'
|
||||
- '@radix-ui/react-presence@1.1.7'
|
||||
- '@radix-ui/react-progress@1.1.12'
|
||||
- '@radix-ui/react-radio-group@1.4.3'
|
||||
- '@radix-ui/react-roving-focus@1.1.15'
|
||||
- '@radix-ui/react-scroll-area@1.2.14'
|
||||
- '@radix-ui/react-select@2.3.3'
|
||||
- '@radix-ui/react-slider@1.4.3'
|
||||
- '@radix-ui/react-switch@1.3.3'
|
||||
- '@radix-ui/react-tabs@1.1.17'
|
||||
- '@radix-ui/react-toast@1.2.19'
|
||||
- '@radix-ui/react-toggle-group@1.1.15'
|
||||
- '@radix-ui/react-toggle@1.1.14'
|
||||
- '@radix-ui/react-toolbar@1.1.15'
|
||||
- '@radix-ui/react-tooltip@1.2.12'
|
||||
- radix-ui@1.6.2
|
||||
- '@aws-sdk/checksums@3.1000.14'
|
||||
- '@aws-sdk/client-s3@3.1081.0'
|
||||
- '@aws-sdk/core@3.974.29'
|
||||
- '@aws-sdk/credential-provider-env@3.972.55'
|
||||
- '@aws-sdk/credential-provider-http@3.972.57'
|
||||
- '@aws-sdk/credential-provider-ini@3.972.62'
|
||||
- '@aws-sdk/credential-provider-login@3.972.61'
|
||||
- '@aws-sdk/credential-provider-node@3.972.64'
|
||||
- '@aws-sdk/credential-provider-process@3.972.55'
|
||||
- '@aws-sdk/credential-provider-sso@3.972.61'
|
||||
- '@aws-sdk/credential-provider-web-identity@3.972.61'
|
||||
- '@aws-sdk/middleware-sdk-s3@3.972.60'
|
||||
- '@aws-sdk/nested-clients@3.997.29'
|
||||
- '@aws-sdk/s3-request-presigner@3.1081.0'
|
||||
- '@aws-sdk/token-providers@3.1081.0'
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Buffer } from "node:buffer";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
|
||||
const URL_PATTERN = /\b[a-z][a-z\d+.-]{1,20}:\/\/[^\s<>"'`]+/giu;
|
||||
const PRIVATE_KEY_PATTERN =
|
||||
/-----BEGIN [^-\r\n]*PRIVATE KEY-----[\s\S]*?-----END [^-\r\n]*PRIVATE KEY-----/giu;
|
||||
const BEARER_PATTERN = /\bBearer\s+[A-Za-z\d._~+/=-]+/giu;
|
||||
const SECRET_ASSIGNMENT_PATTERN =
|
||||
/\b(?:api[_-]?key|authorization|password|passwd|private[_-]?key|proxy[_-]?(?:password|username)|refresh[_-]?token|secret|token|username)\b\s*[:=]\s*[^\s,;]+/giu;
|
||||
const JWT_PATTERN = /\beyJ[A-Za-z\d_-]+\.[A-Za-z\d_-]+\.[A-Za-z\d_-]+\b/gu;
|
||||
const TOKEN_PATTERN =
|
||||
/\b(?:gh[oprsu]_[A-Za-z\d]{20,}|github_pat_[A-Za-z\d_]{20,}|sk-[A-Za-z\d_-]{20,}|xox[baprs]-[A-Za-z\d-]{20,})\b/gu;
|
||||
const EMAIL_PATTERN = /\b[A-Z\d._%+-]+@[A-Z\d.-]+\.[A-Z]{2,}\b/giu;
|
||||
const UNIX_HOME_PATTERN = /\/(?:Users|home)\/[^/\s]+/gu;
|
||||
const WINDOWS_HOME_PATTERN = /\b[A-Z]:\\Users\\[^\\\s]+/giu;
|
||||
const IPV4_PATTERN =
|
||||
/\b(?:25[0-5]|2[0-4]\d|1?\d?\d)(?:\.(?:25[0-5]|2[0-4]\d|1?\d?\d)){3}\b/gu;
|
||||
const DOMAIN_PATTERN = /\b(?:[a-z\d-]+\.)+[a-z]{2,}\b/giu;
|
||||
const UUID_PATTERN =
|
||||
/\b[\da-f]{8}-[\da-f]{4}-[1-8][\da-f]{3}-[89ab][\da-f]{3}-[\da-f]{12}\b/giu;
|
||||
|
||||
function safeUrlLabel(value) {
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return `${parsed.protocol}//<redacted>`;
|
||||
} catch {
|
||||
return "<redacted-url>";
|
||||
}
|
||||
}
|
||||
|
||||
export function sensitiveVariants(values) {
|
||||
const variants = new Set();
|
||||
for (const rawValue of values ?? []) {
|
||||
const value = String(rawValue ?? "").trim();
|
||||
if (value.length < 4) continue;
|
||||
variants.add(value);
|
||||
variants.add(encodeURIComponent(value));
|
||||
variants.add(Buffer.from(value).toString("base64"));
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
for (const component of [
|
||||
parsed.username,
|
||||
parsed.password,
|
||||
parsed.hostname,
|
||||
parsed.host,
|
||||
]) {
|
||||
if (component.length >= 4) {
|
||||
variants.add(component);
|
||||
variants.add(decodeURIComponent(component));
|
||||
variants.add(encodeURIComponent(decodeURIComponent(component)));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-URL secrets are already covered by their literal and encoded forms.
|
||||
}
|
||||
}
|
||||
return [...variants].sort((left, right) => right.length - left.length);
|
||||
}
|
||||
|
||||
export function redactSensitiveText(text, { sensitiveValues = [] } = {}) {
|
||||
let redacted = String(text ?? "");
|
||||
for (const value of sensitiveVariants(sensitiveValues)) {
|
||||
redacted = redacted.split(value).join("<redacted-secret>");
|
||||
}
|
||||
return redacted
|
||||
.replace(PRIVATE_KEY_PATTERN, "<redacted-private-key>")
|
||||
.replace(URL_PATTERN, safeUrlLabel)
|
||||
.replace(BEARER_PATTERN, "Bearer <redacted-secret>")
|
||||
.replace(SECRET_ASSIGNMENT_PATTERN, "<redacted-secret>")
|
||||
.replace(JWT_PATTERN, "<redacted-token>")
|
||||
.replace(TOKEN_PATTERN, "<redacted-token>")
|
||||
.replace(EMAIL_PATTERN, "<redacted-email>")
|
||||
.replace(UNIX_HOME_PATTERN, "/<redacted-home>")
|
||||
.replace(WINDOWS_HOME_PATTERN, "<redacted-home>")
|
||||
.replace(IPV4_PATTERN, "<redacted-ip>")
|
||||
.replace(DOMAIN_PATTERN, "<redacted-domain>")
|
||||
.replace(UUID_PATTERN, "<redacted-identifier>");
|
||||
}
|
||||
|
||||
export function redactIssueBody(text) {
|
||||
const sections = String(text ?? "").split(/^###\s+/mu);
|
||||
const preamble = redactSensitiveText(sections.shift() ?? "").trim();
|
||||
const safeSections = sections.map((section) => {
|
||||
const newline = section.indexOf("\n");
|
||||
if (newline < 0) return redactSensitiveText(section);
|
||||
const heading = section.slice(0, newline).trim();
|
||||
const value = section.slice(newline + 1).trim();
|
||||
const safeValue = /^(?:error logs or screenshots|logs|screenshots)$/iu.test(
|
||||
heading,
|
||||
)
|
||||
? "[omitted from automated processing]"
|
||||
: redactSensitiveText(value);
|
||||
return `${heading}\n${safeValue}`;
|
||||
});
|
||||
return [preamble, ...safeSections.map((section) => `### ${section}`)]
|
||||
.filter(Boolean)
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
async function runCli() {
|
||||
let input = "";
|
||||
process.stdin.setEncoding("utf8");
|
||||
for await (const chunk of process.stdin) input += chunk;
|
||||
process.stdout.write(
|
||||
process.argv.includes("--issue-body")
|
||||
? redactIssueBody(input)
|
||||
: redactSensitiveText(input),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
process.argv[1] &&
|
||||
import.meta.url === pathToFileURL(process.argv[1]).href
|
||||
) {
|
||||
await runCli();
|
||||
}
|
||||
@@ -28,7 +28,9 @@ const CACHE_DIR = path.join(ROOT_DIR, ".cache", "sync-test");
|
||||
const MINIO_PORT = 9876;
|
||||
const MINIO_CONSOLE_PORT = 9877;
|
||||
const SYNC_PORT = 3456;
|
||||
const SYNC_TOKEN = "test-sync-token";
|
||||
// Must be >= 24 chars and not a known default — the server's validateEnv()
|
||||
// rejects short/placeholder tokens and exits at startup otherwise.
|
||||
const SYNC_TOKEN = "test-sync-token-0123456789abcdef";
|
||||
|
||||
const processes = [];
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
# cargo-audit configuration
|
||||
#
|
||||
# The ignored advisories below all concern `quick-xml` 0.39.4, which is pulled in
|
||||
# ONLY by `wayland-scanner` (Linux clipboard support via arboard →
|
||||
# tauri-plugin-clipboard-manager). `wayland-scanner` pins `quick-xml ^0.39`, so
|
||||
# no patched release (>= 0.41.0) is reachable through that dependency chain.
|
||||
#
|
||||
# `wayland-scanner` uses quick-xml at build time to parse the Wayland protocol
|
||||
# XML definitions that ship inside the crate — trusted, bundled input, never
|
||||
# attacker-controlled — so the denial-of-service vectors these advisories
|
||||
# describe do not apply. Our own direct dependency is already on the patched
|
||||
# quick-xml 0.41. Remove these entries once wayland-scanner bumps its requirement.
|
||||
[advisories]
|
||||
ignore = [
|
||||
"RUSTSEC-2026-0194", # quick-xml: quadratic runtime on duplicate start-tag attribute names
|
||||
"RUSTSEC-2026-0195", # quick-xml: unbounded namespace-declaration allocation in NsReader
|
||||
]
|
||||
Generated
+227
-427
File diff suppressed because it is too large
Load Diff
+16
-11
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.27.0"
|
||||
version = "0.28.2"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -31,7 +31,7 @@ resvg = "0.47"
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
tauri = { version = "2", features = ["devtools", "test", "tray-icon", "image-png"] }
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
@@ -74,11 +74,11 @@ chrono-tz = "0.10"
|
||||
axum = { version = "0.8.9", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.7", features = ["cors"] }
|
||||
rand = "0.10.1"
|
||||
rand = "0.10.2"
|
||||
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
|
||||
utoipa-axum = "0.2"
|
||||
argon2 = "0.5"
|
||||
aes-gcm = "0.10"
|
||||
aes-gcm = "0.11"
|
||||
aes = "0.9"
|
||||
cbc = "0.2"
|
||||
ring = "0.17"
|
||||
@@ -91,8 +91,6 @@ http-body-util = "0.1"
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
async-socks5 = "0.6"
|
||||
|
||||
# Camoufox/Playwright integration
|
||||
playwright = { git = "https://github.com/zhom/playwright-rust", branch = "master" }
|
||||
|
||||
# Wayfern CDP integration
|
||||
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
|
||||
@@ -102,12 +100,12 @@ toml = "1.1"
|
||||
thiserror = "2.0"
|
||||
regex-lite = "0.1"
|
||||
tempfile = "3"
|
||||
maxminddb = "0.28"
|
||||
quick-xml = { version = "0.40", features = ["serialize"] }
|
||||
maxminddb = "0.29"
|
||||
quick-xml = { version = "0.41", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
boringtun = "0.7"
|
||||
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
|
||||
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp", "socket-dns"] }
|
||||
|
||||
# Tray icon decoding (main-process system tray)
|
||||
image = "0.25"
|
||||
@@ -120,7 +118,7 @@ nix = { version = "0.31", features = ["signal", "process"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
objc2 = "0.6.3"
|
||||
objc2 = "0.6.4"
|
||||
objc2-app-kit = { version = "0.3.2", features = ["NSWindow", "NSApplication", "NSRunningApplication"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
@@ -139,7 +137,7 @@ windows = { version = "0.62", features = [
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.24.0"
|
||||
tempfile = "3.27.0"
|
||||
wiremock = "0.6"
|
||||
hyper = { version = "1.10", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
@@ -188,3 +186,10 @@ default = ["custom-protocol"]
|
||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||
# DO NOT remove this
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
e2e = []
|
||||
|
||||
# wayland-scanner 0.31.10 still pins vulnerable quick-xml 0.39. Upstream fixed
|
||||
# RUSTSEC-2026-0194 and RUSTSEC-2026-0195, but has not published the fix yet.
|
||||
# Remove this patch after the next wayland-scanner release is in the lockfile.
|
||||
[patch.crates-io]
|
||||
wayland-scanner = { git = "https://github.com/Smithay/wayland-rs", rev = "d07c4f91f28b42e5a485823ffd9d8d5a210b1053" }
|
||||
|
||||
+2
-20
@@ -8,26 +8,8 @@
|
||||
<string>Donut needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Donut has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Donut</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Donut</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.donutbrowser</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.donutbrowser</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>donutbrowser</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2025 Donut</string>
|
||||
<string>Copyright © 2026 Donut</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -57,4 +39,4 @@
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -5,25 +5,24 @@
|
||||
"windows": ["main"],
|
||||
"webviews": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-emit-to",
|
||||
"core:event:allow-unlisten",
|
||||
"core:image:default",
|
||||
"core:menu:default",
|
||||
"core:path:default",
|
||||
"core:tray:default",
|
||||
"core:webview:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"opener:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://*"
|
||||
},
|
||||
{
|
||||
"url": "http://*"
|
||||
},
|
||||
{
|
||||
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
|
||||
},
|
||||
@@ -32,28 +31,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"fs:default",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-kill",
|
||||
"shell:allow-open",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-stdin-write",
|
||||
"deep-link:default",
|
||||
"deep-link:allow-register",
|
||||
"deep-link:allow-unregister",
|
||||
"deep-link:allow-is-registered",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
"deep-link:allow-get-current",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-write-text-file",
|
||||
"macos-permissions:default",
|
||||
"macos-permissions:allow-request-microphone-permission",
|
||||
"macos-permissions:allow-request-camera-permission",
|
||||
"macos-permissions:allow-check-microphone-permission",
|
||||
"macos-permissions:allow-check-camera-permission",
|
||||
"log:default",
|
||||
"clipboard-manager:default",
|
||||
"clipboard-manager:allow-write-text"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MANIFEST_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const PROFILE = process.env.PROFILE || "debug";
|
||||
const PROFILE =
|
||||
process.argv.includes("--release") || process.env.PROFILE === "release"
|
||||
? "release"
|
||||
: "debug";
|
||||
|
||||
function getTarget() {
|
||||
if (process.env.TARGET) return process.env.TARGET;
|
||||
@@ -48,32 +51,22 @@ function copyBinary(baseName) {
|
||||
if (isWindows) destName += ".exe";
|
||||
const dest = join(destDir, destName);
|
||||
|
||||
if (existsSync(source)) {
|
||||
copyFileSync(source, dest);
|
||||
console.log(`Copied ${binName} to ${dest}`);
|
||||
} else {
|
||||
console.log(`Warning: Binary not found at ${source}`);
|
||||
console.log(`Building ${baseName} binary...`);
|
||||
|
||||
const buildArgs = ["build", "--bin", baseName];
|
||||
if (PROFILE === "release") buildArgs.push("--release");
|
||||
if (TARGET !== "unknown" && TARGET !== HOST_TARGET) {
|
||||
buildArgs.push("--target", TARGET);
|
||||
}
|
||||
|
||||
execFileSync("cargo", buildArgs, {
|
||||
cwd: MANIFEST_DIR,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (existsSync(source)) {
|
||||
copyFileSync(source, dest);
|
||||
console.log(`Built and copied ${binName} to ${dest}`);
|
||||
} else {
|
||||
console.error(`Error: Failed to build ${baseName} binary`);
|
||||
process.exit(1);
|
||||
}
|
||||
const buildArgs = ["build", "--bin", baseName];
|
||||
if (PROFILE === "release") buildArgs.push("--release");
|
||||
if (TARGET !== "unknown" && TARGET !== HOST_TARGET) {
|
||||
buildArgs.push("--target", TARGET);
|
||||
}
|
||||
execFileSync("cargo", buildArgs, {
|
||||
cwd: MANIFEST_DIR,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (!existsSync(source)) {
|
||||
console.error(`Error: Failed to build ${baseName} binary`);
|
||||
process.exit(1);
|
||||
}
|
||||
copyFileSync(source, dest);
|
||||
console.log(`Built and copied ${binName} to ${dest}`);
|
||||
}
|
||||
|
||||
copyBinary("donut-proxy");
|
||||
|
||||
@@ -2,37 +2,9 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-output</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
!macro NSIS_HOOK_PREINSTALL
|
||||
IfFileExists "$INSTDIR\donut-proxy.exe" 0 donut_proxy_preinstall_done
|
||||
|
||||
DetailPrint "Stopping Donut proxy workers before replacing application files"
|
||||
nsExec::ExecToStack '"$SYSDIR\taskkill.exe" /F /T /IM "donut-proxy.exe"'
|
||||
Pop $0
|
||||
Pop $1
|
||||
Sleep 1000
|
||||
|
||||
; Removing the old sidecar first prevents NSIS from retaining a same-version
|
||||
; or previously locked executable while updating the main application.
|
||||
Delete "$INSTDIR\donut-proxy.exe"
|
||||
|
||||
donut_proxy_preinstall_done:
|
||||
!macroend
|
||||
+52
-1501
File diff suppressed because it is too large
Load Diff
+516
-218
File diff suppressed because it is too large
Load Diff
@@ -87,6 +87,10 @@ pub struct AppReleaseAsset {
|
||||
pub name: String,
|
||||
pub browser_download_url: String,
|
||||
pub size: u64,
|
||||
/// GitHub-computed digest ("sha256:<hex>"); absent on assets uploaded
|
||||
/// before GitHub started calculating digests.
|
||||
#[serde(default)]
|
||||
pub digest: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -111,6 +115,15 @@ pub struct AppUpdateInfo {
|
||||
pub release_page_url: Option<String>,
|
||||
/// True when a system package manager repo is configured (apt/dnf/zypper)
|
||||
pub repo_update: bool,
|
||||
/// URL of the release's SHA256SUMS.txt asset. The downloaded update is
|
||||
/// verified against it before installation; without it the update is
|
||||
/// refused.
|
||||
#[serde(default)]
|
||||
pub checksums_url: Option<String>,
|
||||
/// GitHub's server-side digest of the chosen asset ("sha256:<hex>"),
|
||||
/// cross-checked in addition to SHA256SUMS.txt when present.
|
||||
#[serde(default)]
|
||||
pub asset_digest: Option<String>,
|
||||
}
|
||||
|
||||
pub struct AppAutoUpdater {
|
||||
@@ -214,6 +227,35 @@ impl AppAutoUpdater {
|
||||
// Find the appropriate asset for current platform
|
||||
let download_url = self.get_download_url_for_platform(&latest_release.assets);
|
||||
|
||||
// Locate the release's checksums file and the chosen asset's
|
||||
// GitHub-computed digest for post-download verification.
|
||||
let checksums_url = Self::find_checksums_url(&latest_release.assets);
|
||||
let asset_digest = download_url.as_deref().and_then(|url| {
|
||||
latest_release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|a| a.browser_download_url == url)
|
||||
.and_then(|a| a.digest.clone())
|
||||
});
|
||||
|
||||
// Both release workflows upload SHA256SUMS.txt only after every platform
|
||||
// build finishes, so a release without it is still being assembled (or
|
||||
// its pipeline broke). Downloading now is guaranteed to fail closed, so
|
||||
// treat the release as not ready and retry on a later check instead of
|
||||
// surfacing an error for a healthy in-progress release. Applies only to
|
||||
// the auto-download path — manual/repo notifications don't download.
|
||||
let auto_download_possible = download_url.is_some();
|
||||
#[cfg(target_os = "linux")]
|
||||
let auto_download_possible = auto_download_possible && !self.is_repo_configured();
|
||||
if auto_download_possible && checksums_url.is_none() {
|
||||
log::info!(
|
||||
"Release {} has no {} yet; treating as not ready for auto-update",
|
||||
latest_release.tag_name,
|
||||
Self::CHECKSUMS_ASSET_NAME
|
||||
);
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// On Linux, when a package repo is configured, notify users to update via
|
||||
// their package manager instead of auto-downloading from GitHub.
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -230,6 +272,8 @@ impl AppAutoUpdater {
|
||||
manual_update_required,
|
||||
release_page_url: Some(release_page_url),
|
||||
repo_update,
|
||||
checksums_url,
|
||||
asset_digest,
|
||||
};
|
||||
|
||||
log::info!(
|
||||
@@ -255,6 +299,8 @@ impl AppAutoUpdater {
|
||||
manual_update_required: false,
|
||||
release_page_url: Some(release_page_url),
|
||||
repo_update: false,
|
||||
checksums_url,
|
||||
asset_digest,
|
||||
};
|
||||
|
||||
log::info!(
|
||||
@@ -712,6 +758,156 @@ impl AppAutoUpdater {
|
||||
None
|
||||
}
|
||||
|
||||
/// Name of the checksums asset both release workflows publish.
|
||||
const CHECKSUMS_ASSET_NAME: &'static str = "SHA256SUMS.txt";
|
||||
|
||||
fn find_checksums_url(assets: &[AppReleaseAsset]) -> Option<String> {
|
||||
assets
|
||||
.iter()
|
||||
.find(|a| a.name == Self::CHECKSUMS_ASSET_NAME)
|
||||
.map(|a| a.browser_download_url.clone())
|
||||
}
|
||||
|
||||
/// Extract the hex digest for `filename` from standard `sha256sum` output
|
||||
/// (`<hex> <name>`, optionally with the `*` binary-mode marker).
|
||||
fn find_checksum_for_file(checksums_text: &str, filename: &str) -> Option<String> {
|
||||
checksums_text.lines().find_map(|line| {
|
||||
let (hash, rest) = line.split_once(char::is_whitespace)?;
|
||||
let name = rest.trim_start().trim_start_matches('*');
|
||||
if name == filename && hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) {
|
||||
Some(hash.to_ascii_lowercase())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn sha256_file(path: &Path) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Read;
|
||||
let mut file = fs::File::open(path)?;
|
||||
let mut hasher = Sha256::new();
|
||||
let mut buf = vec![0u8; 1024 * 1024];
|
||||
loop {
|
||||
let n = file.read(&mut buf)?;
|
||||
if n == 0 {
|
||||
break;
|
||||
}
|
||||
hasher.update(&buf[..n]);
|
||||
}
|
||||
let digest = hasher.finalize();
|
||||
let mut hex = String::with_capacity(digest.len() * 2);
|
||||
for byte in digest {
|
||||
use std::fmt::Write;
|
||||
let _ = write!(hex, "{byte:02x}");
|
||||
}
|
||||
Ok(hex)
|
||||
}
|
||||
|
||||
/// Fetch the release's SHA256SUMS.txt and return the expected digest for
|
||||
/// `filename`. Called BEFORE the (large) asset download so an unverifiable
|
||||
/// release is rejected without wasting the transfer. Every failure mode
|
||||
/// maps to the UPDATE_CHECKSUMS_UNAVAILABLE code; details go to the log.
|
||||
async fn fetch_expected_checksum(
|
||||
&self,
|
||||
update_info: &AppUpdateInfo,
|
||||
filename: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let unavailable = || -> Box<dyn std::error::Error + Send + Sync> {
|
||||
serde_json::json!({
|
||||
"code": "UPDATE_CHECKSUMS_UNAVAILABLE",
|
||||
"params": { "version": update_info.new_version }
|
||||
})
|
||||
.to_string()
|
||||
.into()
|
||||
};
|
||||
|
||||
let Some(checksums_url) = update_info.checksums_url.as_deref() else {
|
||||
log::warn!(
|
||||
"No {} asset on release {}",
|
||||
Self::CHECKSUMS_ASSET_NAME,
|
||||
update_info.new_version
|
||||
);
|
||||
return Err(unavailable());
|
||||
};
|
||||
|
||||
let response = match self
|
||||
.client
|
||||
.get(checksums_url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) if response.status().is_success() => response,
|
||||
Ok(response) => {
|
||||
log::warn!("Checksums file request failed: HTTP {}", response.status());
|
||||
return Err(unavailable());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Checksums file request failed: {e}");
|
||||
return Err(unavailable());
|
||||
}
|
||||
};
|
||||
|
||||
let checksums_text = match response.text().await {
|
||||
Ok(text) => text,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read checksums file: {e}");
|
||||
return Err(unavailable());
|
||||
}
|
||||
};
|
||||
|
||||
let Some(expected) = Self::find_checksum_for_file(&checksums_text, filename) else {
|
||||
log::warn!(
|
||||
"No checksum entry for {filename} in {}",
|
||||
Self::CHECKSUMS_ASSET_NAME
|
||||
);
|
||||
return Err(unavailable());
|
||||
};
|
||||
Ok(expected)
|
||||
}
|
||||
|
||||
/// Verify the downloaded update against the expected SHA256SUMS.txt digest
|
||||
/// (and GitHub's server-side asset digest when available) before anything
|
||||
/// is extracted or installed. A corrupt download is deleted so the next
|
||||
/// attempt starts fresh.
|
||||
fn verify_update_checksum(
|
||||
file_path: &Path,
|
||||
filename: &str,
|
||||
expected: &str,
|
||||
asset_digest: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let actual = Self::sha256_file(file_path)?;
|
||||
|
||||
let mut mismatch = !actual.eq_ignore_ascii_case(expected);
|
||||
|
||||
// Cross-check GitHub's server-side digest: SHA256SUMS.txt is computed by
|
||||
// re-downloading assets in CI, so this catches corruption in that step.
|
||||
if !mismatch {
|
||||
if let Some(hex) = asset_digest.and_then(|d| d.strip_prefix("sha256:")) {
|
||||
mismatch = !actual.eq_ignore_ascii_case(hex);
|
||||
}
|
||||
}
|
||||
|
||||
if mismatch {
|
||||
log::error!(
|
||||
"Checksum mismatch for {filename}: expected {expected}, got {actual} (asset digest: {asset_digest:?})"
|
||||
);
|
||||
let _ = fs::remove_file(file_path);
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "UPDATE_CHECKSUM_MISMATCH",
|
||||
"params": { "file": filename }
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
log::info!("Checksum verified for {filename}: {actual}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download the update file without progress tracking (silent download)
|
||||
async fn download_update_silent(
|
||||
&self,
|
||||
@@ -767,12 +963,24 @@ impl AppAutoUpdater {
|
||||
.unwrap_or("update.dmg")
|
||||
.to_string();
|
||||
|
||||
log::info!("Downloading update from: {}", update_info.download_url);
|
||||
// Resolve the expected checksum first so an unverifiable release is
|
||||
// rejected before the multi-hundred-MB download, not after.
|
||||
let expected_sha256 = self.fetch_expected_checksum(update_info, &filename).await?;
|
||||
|
||||
log::info!("Downloading update");
|
||||
|
||||
let download_path = self
|
||||
.download_update_silent(&update_info.download_url, &temp_dir, &filename)
|
||||
.await?;
|
||||
|
||||
log::info!("Verifying update checksum...");
|
||||
Self::verify_update_checksum(
|
||||
&download_path,
|
||||
&filename,
|
||||
&expected_sha256,
|
||||
update_info.asset_digest.as_deref(),
|
||||
)?;
|
||||
|
||||
log::info!("Extracting update...");
|
||||
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
|
||||
|
||||
@@ -825,7 +1033,10 @@ impl AppAutoUpdater {
|
||||
|
||||
// Handle compound extensions like .tar.gz
|
||||
if file_name.ends_with(".tar.gz") {
|
||||
return self.extractor.extract_tar_gz(archive_path, dest_dir).await;
|
||||
return self
|
||||
.extractor
|
||||
.extract_tar_gz(archive_path, dest_dir, None)
|
||||
.await;
|
||||
}
|
||||
|
||||
let extension = archive_path
|
||||
@@ -837,7 +1048,10 @@ impl AppAutoUpdater {
|
||||
"dmg" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
self.extractor.extract_dmg(archive_path, dest_dir).await
|
||||
self
|
||||
.extractor
|
||||
.extract_dmg(archive_path, dest_dir, None)
|
||||
.await
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
@@ -901,7 +1115,12 @@ impl AppAutoUpdater {
|
||||
Err("AppImage installation is only supported on Linux".into())
|
||||
}
|
||||
}
|
||||
"zip" => self.extractor.extract_zip(archive_path, dest_dir).await,
|
||||
"zip" => {
|
||||
self
|
||||
.extractor
|
||||
.extract_zip(archive_path, dest_dir, None)
|
||||
.await
|
||||
}
|
||||
_ => Err(format!("Unsupported archive format: {extension}").into()),
|
||||
}
|
||||
}
|
||||
@@ -1024,7 +1243,7 @@ impl AppAutoUpdater {
|
||||
if !log_content.is_empty() {
|
||||
log::info!(
|
||||
"Log file content (last 500 chars): {}",
|
||||
&log_content
|
||||
log_content
|
||||
.chars()
|
||||
.rev()
|
||||
.take(500)
|
||||
@@ -1110,7 +1329,7 @@ impl AppAutoUpdater {
|
||||
// Extract ZIP file
|
||||
let extracted_path = self
|
||||
.extractor
|
||||
.extract_zip(installer_path, &temp_extract_dir)
|
||||
.extract_zip(installer_path, &temp_extract_dir, None)
|
||||
.await?;
|
||||
|
||||
// Find the executable in the extracted files
|
||||
@@ -1385,7 +1604,7 @@ impl AppAutoUpdater {
|
||||
// Extract tarball
|
||||
let extracted_path = self
|
||||
.extractor
|
||||
.extract_tar_gz(tarball_path, &temp_extract_dir)
|
||||
.extract_tar_gz(tarball_path, &temp_extract_dir, None)
|
||||
.await?;
|
||||
|
||||
// Find the executable in the extracted files
|
||||
@@ -1479,6 +1698,95 @@ impl AppAutoUpdater {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
async fn prepare_windows_installer() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let profiles = match crate::profile::ProfileManager::instance().list_profiles() {
|
||||
Ok(profiles) => profiles,
|
||||
Err(e) => {
|
||||
log::error!("Failed to inspect running profiles before app update: {e}");
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "UPDATE_PREPARATION_FAILED"
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let has_running_profiles = profiles.into_iter().any(|profile| {
|
||||
profile
|
||||
.process_id
|
||||
.is_some_and(|pid| pid != 0 && crate::proxy_storage::is_process_running(pid))
|
||||
});
|
||||
if has_running_profiles {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "UPDATE_PROFILES_RUNNING"
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let proxy_configs = crate::proxy_storage::list_proxy_configs();
|
||||
let vpn_configs = crate::vpn_worker_storage::list_vpn_worker_configs();
|
||||
let mut worker_pids: Vec<u32> = proxy_configs
|
||||
.iter()
|
||||
.filter_map(|config| config.pid)
|
||||
.chain(vpn_configs.iter().filter_map(|config| config.pid))
|
||||
.collect();
|
||||
worker_pids.sort_unstable();
|
||||
worker_pids.dedup();
|
||||
|
||||
let proxy_ids: Vec<String> = proxy_configs.into_iter().map(|config| config.id).collect();
|
||||
let vpn_ids: Vec<String> = vpn_configs.into_iter().map(|config| config.id).collect();
|
||||
|
||||
let stop_proxies = futures_util::future::join_all(
|
||||
proxy_ids
|
||||
.iter()
|
||||
.map(|id| crate::proxy_runner::stop_proxy_process(id)),
|
||||
);
|
||||
let stop_vpns = futures_util::future::join_all(
|
||||
vpn_ids
|
||||
.iter()
|
||||
.map(|id| crate::vpn_worker_runner::stop_vpn_worker(id)),
|
||||
);
|
||||
let (proxy_results, vpn_results) = tokio::join!(stop_proxies, stop_vpns);
|
||||
|
||||
for result in proxy_results.into_iter().chain(vpn_results) {
|
||||
if let Err(e) = result {
|
||||
log::warn!("Failed to stop a network worker before app update: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
for _ in 0..20 {
|
||||
if worker_pids
|
||||
.iter()
|
||||
.all(|pid| !crate::proxy_storage::is_process_running(*pid))
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
let remaining: Vec<u32> = worker_pids
|
||||
.into_iter()
|
||||
.filter(|pid| crate::proxy_storage::is_process_running(*pid))
|
||||
.collect();
|
||||
log::error!(
|
||||
"App update aborted because donut-proxy worker PIDs are still running: {:?}",
|
||||
remaining
|
||||
);
|
||||
Err(
|
||||
serde_json::json!({
|
||||
"code": "UPDATE_PREPARATION_FAILED"
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Restart the application
|
||||
async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -1545,6 +1853,11 @@ rm "{}"
|
||||
let pending = PENDING_INSTALLER_PATH.lock().unwrap().take();
|
||||
|
||||
if let Some(installer_path) = pending {
|
||||
if let Err(e) = Self::prepare_windows_installer().await {
|
||||
*PENDING_INSTALLER_PATH.lock().unwrap() = Some(installer_path);
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
// Use ShellExecuteW to run the installer directly — no batch script,
|
||||
// no cmd.exe console window. The NSIS/MSI installer handles killing the
|
||||
// old process and restarting the app natively (via /UPDATE and
|
||||
@@ -1724,6 +2037,14 @@ rm "{}"
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
|
||||
#[cfg(feature = "e2e")]
|
||||
if crate::e2e_automation_enabled()
|
||||
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
|
||||
{
|
||||
log::info!("E2E: skipping automatic app update check");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if crate::app_dirs::is_portable() {
|
||||
log::info!("App auto-updates disabled in portable mode");
|
||||
return Ok(None);
|
||||
@@ -1754,7 +2075,16 @@ pub async fn download_and_prepare_app_update(
|
||||
updater
|
||||
.download_and_prepare_update(&app_handle, &update_info)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download and prepare app update: {e}"))
|
||||
.map_err(|e| {
|
||||
let msg = e.to_string();
|
||||
// Structured error codes (`{"code": ...}`) must reach the frontend
|
||||
// unwrapped so translateBackendError can resolve them.
|
||||
if msg.starts_with('{') {
|
||||
msg
|
||||
} else {
|
||||
format!("Failed to download and prepare app update: {msg}")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -1763,11 +2093,19 @@ pub async fn restart_application() -> Result<(), String> {
|
||||
updater
|
||||
.restart_application()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to restart application: {e}"))
|
||||
.map_err(|e| crate::wrap_backend_error(e, "Failed to restart application"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
|
||||
#[cfg(feature = "e2e")]
|
||||
if crate::e2e_automation_enabled()
|
||||
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
|
||||
{
|
||||
log::info!("E2E: skipping manual app update check");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
log::info!("Manual app update check triggered");
|
||||
let updater = AppAutoUpdater::instance();
|
||||
updater
|
||||
@@ -1888,6 +2226,106 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_checksum_for_file() {
|
||||
let sums = "\
|
||||
0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11 Donut_0.29.0_aarch64.dmg
|
||||
ABCDEF01745092B7D1C93C1E7E1C30D923BE3D1E916B661BD53D1C0C9C7F0A22 *Donut_0.29.0_x64.dmg
|
||||
not-a-hash Donut_0.29.0_amd64.deb
|
||||
";
|
||||
|
||||
// Plain entry.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_aarch64.dmg").as_deref(),
|
||||
Some("0e5a4601745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a11")
|
||||
);
|
||||
// Binary-mode marker is stripped; hash is normalized to lowercase.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_x64.dmg").as_deref(),
|
||||
Some("abcdef01745092b7d1c93c1e7e1c30d923be3d1e916b661bd53d1c0c9c7f0a22")
|
||||
);
|
||||
// Entries with malformed hashes are rejected rather than trusted.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_amd64.deb"),
|
||||
None
|
||||
);
|
||||
// Missing file.
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksum_for_file(sums, "Donut_0.29.0_arm64.deb"),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sha256_file_matches_known_digest() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let path = temp_dir.path().join("data.bin");
|
||||
std::fs::write(&path, b"hello world").unwrap();
|
||||
assert_eq!(
|
||||
AppAutoUpdater::sha256_file(&path).unwrap(),
|
||||
// sha256 of "hello world"
|
||||
"b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_find_checksums_url() {
|
||||
let assets = vec![
|
||||
AppReleaseAsset {
|
||||
name: "Donut_0.29.0_x64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/x64.dmg".to_string(),
|
||||
size: 1,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "SHA256SUMS.txt".to_string(),
|
||||
browser_download_url: "https://example.com/SHA256SUMS.txt".to_string(),
|
||||
size: 1,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
assert_eq!(
|
||||
AppAutoUpdater::find_checksums_url(&assets).as_deref(),
|
||||
Some("https://example.com/SHA256SUMS.txt")
|
||||
);
|
||||
assert_eq!(AppAutoUpdater::find_checksums_url(&assets[..1]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_release_asset_digest_is_optional_in_api_json() {
|
||||
// Assets uploaded before GitHub started computing digests omit the field.
|
||||
let without: AppReleaseAsset = serde_json::from_str(
|
||||
r#"{"name": "a.dmg", "browser_download_url": "https://example.com/a.dmg", "size": 5}"#,
|
||||
)
|
||||
.expect("asset without digest should deserialize");
|
||||
assert_eq!(without.digest, None);
|
||||
|
||||
let with: AppReleaseAsset = serde_json::from_str(
|
||||
r#"{"name": "a.dmg", "browser_download_url": "https://example.com/a.dmg", "size": 5, "digest": "sha256:ab12"}"#,
|
||||
)
|
||||
.expect("asset with digest should deserialize");
|
||||
assert_eq!(with.digest.as_deref(), Some("sha256:ab12"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_windows_installer_hook_protects_sidecar_replacement() {
|
||||
let _ = AppAutoUpdater::prepare_windows_installer;
|
||||
|
||||
let config: serde_json::Value =
|
||||
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||
assert_eq!(
|
||||
config["bundle"]["windows"]["nsis"]["installerHooks"].as_str(),
|
||||
Some("installer-hooks.nsh")
|
||||
);
|
||||
|
||||
let hooks = include_str!("../installer-hooks.nsh");
|
||||
assert!(hooks.contains("NSIS_HOOK_PREINSTALL"));
|
||||
assert!(hooks.contains("IfFileExists \"$INSTDIR\\donut-proxy.exe\""));
|
||||
assert!(hooks.contains("taskkill.exe"));
|
||||
assert!(hooks.contains("donut-proxy.exe"));
|
||||
assert!(hooks.contains("Delete \"$INSTDIR\\donut-proxy.exe\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_platform_specific_download_urls() {
|
||||
let updater = AppAutoUpdater::instance();
|
||||
@@ -1899,33 +2337,39 @@ mod tests {
|
||||
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser_0.1.0_x64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/x64.dmg".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Windows assets (NSIS naming: _ARCH-setup.exe)
|
||||
AppReleaseAsset {
|
||||
name: "Donut_0.1.0_x64-setup.exe".to_string(),
|
||||
browser_download_url: "https://example.com/x64-setup.exe".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Linux assets
|
||||
AppReleaseAsset {
|
||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "donutbrowser-0.1.0-1.x86_64.rpm".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.rpm".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2028,11 +2472,13 @@ mod tests {
|
||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -2073,23 +2519,27 @@ mod tests {
|
||||
name: "Donut.Browser_0.1.0_aarch64.dmg".to_string(),
|
||||
browser_download_url: "https://example.com/aarch64.dmg".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Windows assets
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser_0.1.0_x64.msi".to_string(),
|
||||
browser_download_url: "https://example.com/x64.msi".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
// Linux assets
|
||||
AppReleaseAsset {
|
||||
name: "donutbrowser_0.1.0_amd64.deb".to_string(),
|
||||
browser_download_url: "https://example.com/amd64.deb".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser-0.1.0-x86_64.AppImage".to_string(),
|
||||
browser_download_url: "https://example.com/x86_64.AppImage".to_string(),
|
||||
size: 12345,
|
||||
digest: None,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -190,6 +190,49 @@ pub fn set_test_cache_dir(dir: PathBuf) -> TestDirGuard {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restrict a just-written file to owner-only read/write (`0600`) on Unix so
|
||||
/// other local users/processes can't read secret material (tokens, E2E
|
||||
/// password, encrypted vault files). Best-effort: the write already succeeded,
|
||||
/// so a permission failure is logged, not propagated. On Windows the per-user
|
||||
/// profile ACL already restricts access, so this is a no-op there.
|
||||
pub fn restrict_to_owner(path: &std::path::Path) {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Err(e) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) {
|
||||
log::warn!("Failed to restrict permissions on {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
let _ = path;
|
||||
}
|
||||
}
|
||||
|
||||
/// Write sensitive data without creating a wider-permission file first.
|
||||
pub fn create_owner_only(path: &std::path::Path) -> std::io::Result<std::fs::File> {
|
||||
if path.exists() {
|
||||
restrict_to_owner(path);
|
||||
}
|
||||
let mut options = std::fs::OpenOptions::new();
|
||||
options.create(true).truncate(true).write(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let file = options.open(path)?;
|
||||
restrict_to_owner(path);
|
||||
Ok(file)
|
||||
}
|
||||
|
||||
pub fn write_owner_only(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
|
||||
use std::io::Write;
|
||||
let mut file = create_owner_only(path)?;
|
||||
file.write_all(content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -203,6 +246,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn owner_only_writer_uses_private_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let path = temp.path().join("secret.json");
|
||||
write_owner_only(&path, b"secret").unwrap();
|
||||
assert_eq!(
|
||||
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
||||
0o600
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_dir_returns_path() {
|
||||
let dir = data_dir();
|
||||
|
||||
+49
-187
@@ -13,7 +13,6 @@ pub struct UpdateNotification {
|
||||
pub current_version: String,
|
||||
pub new_version: String,
|
||||
pub affected_profiles: Vec<String>,
|
||||
pub is_stable_update: bool,
|
||||
pub timestamp: u64,
|
||||
}
|
||||
|
||||
@@ -231,18 +230,10 @@ impl AutoUpdater {
|
||||
available_versions: &[BrowserVersionInfo],
|
||||
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let current_version = &profile.version;
|
||||
let is_current_nightly =
|
||||
crate::api_client::is_browser_version_nightly(&profile.browser, current_version, None);
|
||||
|
||||
// Find the best available update
|
||||
let best_update = available_versions
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
// Only consider versions newer than current
|
||||
self.is_version_newer(&v.version, current_version)
|
||||
&& crate::api_client::is_browser_version_nightly(&profile.browser, &v.version, None)
|
||||
== is_current_nightly
|
||||
})
|
||||
.filter(|v| self.is_version_newer(&v.version, current_version))
|
||||
.max_by(|a, b| self.compare_versions(&a.version, &b.version));
|
||||
|
||||
if let Some(update_version) = best_update {
|
||||
@@ -255,7 +246,6 @@ impl AutoUpdater {
|
||||
current_version: current_version.clone(),
|
||||
new_version: update_version.version.clone(),
|
||||
affected_profiles: vec![profile.name.clone()],
|
||||
is_stable_update: !update_version.is_prerelease,
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
@@ -291,12 +281,7 @@ impl AutoUpdater {
|
||||
|
||||
let mut result: Vec<UpdateNotification> = grouped.into_values().collect();
|
||||
|
||||
// Sort by priority: stable updates first, then by timestamp
|
||||
result.sort_by(|a, b| match (a.is_stable_update, b.is_stable_update) {
|
||||
(true, false) => std::cmp::Ordering::Less,
|
||||
(false, true) => std::cmp::Ordering::Greater,
|
||||
_ => b.timestamp.cmp(&a.timestamp),
|
||||
});
|
||||
result.sort_by_key(|b| std::cmp::Reverse(b.timestamp));
|
||||
|
||||
result
|
||||
}
|
||||
@@ -338,7 +323,6 @@ impl AutoUpdater {
|
||||
current_version: profile.version.clone(),
|
||||
new_version: new_version.to_string(),
|
||||
affected_profiles: vec![profile.name.clone()],
|
||||
is_stable_update: true,
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
@@ -510,15 +494,6 @@ impl AutoUpdater {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Only update stable->stable and nightly->nightly
|
||||
let is_profile_nightly =
|
||||
crate::api_client::is_browser_version_nightly(&profile.browser, &profile.version, None);
|
||||
let is_latest_nightly =
|
||||
crate::api_client::is_browser_version_nightly(&profile.browser, &latest, None);
|
||||
if is_profile_nightly != is_latest_nightly {
|
||||
return None;
|
||||
}
|
||||
|
||||
match self
|
||||
.profile_manager
|
||||
.update_profile_version(app_handle, &profile.id.to_string(), &latest)
|
||||
@@ -595,15 +570,6 @@ impl AutoUpdater {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only update stable->stable and nightly->nightly
|
||||
let is_profile_nightly =
|
||||
crate::api_client::is_browser_version_nightly(&browser, &profile.version, None);
|
||||
let is_latest_nightly =
|
||||
crate::api_client::is_browser_version_nightly(&browser, &latest_version, None);
|
||||
if is_profile_nightly != is_latest_nightly {
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.profile_manager.update_profile_version(
|
||||
app_handle,
|
||||
&profile.id.to_string(),
|
||||
@@ -686,11 +652,11 @@ mod tests {
|
||||
launch_hook: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
wayfern_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
window_color: None,
|
||||
sync_mode: crate::profile::types::SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
@@ -702,15 +668,15 @@ mod tests {
|
||||
created_by_email: None,
|
||||
dns_blocklist: None,
|
||||
password_protected: false,
|
||||
clear_on_close: false,
|
||||
created_at: None,
|
||||
updated_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn create_test_version_info(version: &str, is_prerelease: bool) -> BrowserVersionInfo {
|
||||
fn create_test_version_info(version: &str) -> BrowserVersionInfo {
|
||||
BrowserVersionInfo {
|
||||
version: version.to_string(),
|
||||
is_prerelease,
|
||||
date: "2024-01-01".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -752,111 +718,26 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_camoufox_beta_version_comparison() {
|
||||
fn test_check_profile_update_picks_newer_wayfern_version() {
|
||||
let updater = AutoUpdater::instance();
|
||||
|
||||
// Test the exact user-reported scenario: 135.0.1beta24 vs 135.0beta22
|
||||
assert!(
|
||||
updater.is_version_newer("135.0.1beta24", "135.0beta22"),
|
||||
"135.0.1beta24 should be newer than 135.0beta22"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
updater.compare_versions("135.0.1beta24", "135.0beta22"),
|
||||
std::cmp::Ordering::Greater,
|
||||
"135.0.1beta24 should compare as greater than 135.0beta22"
|
||||
);
|
||||
|
||||
// Test other camoufox beta version combinations
|
||||
assert!(
|
||||
updater.is_version_newer("135.0.5beta24", "135.0.5beta22"),
|
||||
"135.0.5beta24 should be newer than 135.0.5beta22"
|
||||
);
|
||||
|
||||
assert!(
|
||||
updater.is_version_newer("135.0.1beta1", "135.0beta1"),
|
||||
"135.0.1beta1 should be newer than 135.0beta1 due to patch version"
|
||||
);
|
||||
|
||||
// Test that older versions are not considered newer
|
||||
assert!(
|
||||
!updater.is_version_newer("135.0beta22", "135.0.1beta24"),
|
||||
"135.0beta22 should NOT be newer than 135.0.1beta24"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_beta_version_ordering_comprehensive() {
|
||||
let updater = AutoUpdater::instance();
|
||||
|
||||
// Test various beta version patterns that could appear in camoufox
|
||||
let test_cases = vec![
|
||||
("135.0.1beta24", "135.0beta22", true), // User reported case
|
||||
("135.0.5beta24", "135.0.5beta22", true), // Same patch, different beta
|
||||
("135.1beta1", "135.0beta99", true), // Higher minor beats beta number
|
||||
("136.0beta1", "135.9.9beta99", true), // Higher major beats everything
|
||||
("135.0.1beta1", "135.0beta1", true), // Patch version matters
|
||||
("135.0beta22", "135.0.1beta24", false), // Reverse of user case
|
||||
];
|
||||
|
||||
for (newer, older, should_be_newer) in test_cases {
|
||||
let result = updater.is_version_newer(newer, older);
|
||||
assert_eq!(
|
||||
result,
|
||||
should_be_newer,
|
||||
"Expected {} {} {} but got {}",
|
||||
newer,
|
||||
if should_be_newer { ">" } else { "<=" },
|
||||
older,
|
||||
if result { "true" } else { "false" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_profile_update_stable_to_stable() {
|
||||
let updater = AutoUpdater::instance();
|
||||
let profile = create_test_profile("test", "firefox", "1.0.0");
|
||||
let profile = create_test_profile("test", "wayfern", "138.0.7204.49");
|
||||
let versions = vec![
|
||||
create_test_version_info("1.0.1", false), // stable, newer
|
||||
create_test_version_info("1.1.0-alpha", true), // alpha, should be ignored
|
||||
create_test_version_info("0.9.0", false), // stable, older
|
||||
create_test_version_info("138.0.7204.50"),
|
||||
create_test_version_info("138.0.7204.48"),
|
||||
];
|
||||
|
||||
let result = updater.check_profile_update(&profile, &versions).unwrap();
|
||||
assert!(result.is_some());
|
||||
|
||||
let update = result.unwrap();
|
||||
assert_eq!(update.new_version, "1.0.1");
|
||||
assert!(update.is_stable_update);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_profile_update_alpha_to_alpha() {
|
||||
let updater = AutoUpdater::instance();
|
||||
let profile = create_test_profile("test", "firefox", "1.0.0-alpha");
|
||||
let versions = vec![
|
||||
create_test_version_info("1.0.1", false), // stable, should be included
|
||||
create_test_version_info("1.1.0-alpha", true), // alpha, newer
|
||||
create_test_version_info("0.9.0-alpha", true), // alpha, older
|
||||
];
|
||||
|
||||
let result = updater.check_profile_update(&profile, &versions).unwrap();
|
||||
assert!(result.is_some());
|
||||
|
||||
let update = result.unwrap();
|
||||
// Should pick the newest version (alpha user can upgrade to stable or newer alpha)
|
||||
assert_eq!(update.new_version, "1.1.0-alpha");
|
||||
assert!(!update.is_stable_update);
|
||||
assert_eq!(result.unwrap().new_version, "138.0.7204.50");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_profile_update_no_update_available() {
|
||||
let updater = AutoUpdater::instance();
|
||||
let profile = create_test_profile("test", "firefox", "1.0.0");
|
||||
let profile = create_test_profile("test", "wayfern", "138.0.7204.50");
|
||||
let versions = vec![
|
||||
create_test_version_info("0.9.0", false), // older
|
||||
create_test_version_info("1.0.0", false), // same version
|
||||
create_test_version_info("138.0.7204.49"),
|
||||
create_test_version_info("138.0.7204.50"),
|
||||
];
|
||||
|
||||
let result = updater.check_profile_update(&profile, &versions).unwrap();
|
||||
@@ -868,50 +749,27 @@ mod tests {
|
||||
let updater = AutoUpdater::instance();
|
||||
let notifications = vec![
|
||||
UpdateNotification {
|
||||
id: "firefox_1.0.0_to_1.1.0_profile1".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "1.1.0".to_string(),
|
||||
id: "wayfern_138.0.7204.49_to_138.0.7204.50_profile1".to_string(),
|
||||
browser: "wayfern".to_string(),
|
||||
current_version: "138.0.7204.49".to_string(),
|
||||
new_version: "138.0.7204.50".to_string(),
|
||||
affected_profiles: vec!["profile1".to_string()],
|
||||
is_stable_update: true,
|
||||
timestamp: 1000,
|
||||
},
|
||||
UpdateNotification {
|
||||
id: "firefox_1.0.0_to_1.1.0_profile2".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "1.1.0".to_string(),
|
||||
id: "wayfern_138.0.7204.49_to_138.0.7204.50_profile2".to_string(),
|
||||
browser: "wayfern".to_string(),
|
||||
current_version: "138.0.7204.49".to_string(),
|
||||
new_version: "138.0.7204.50".to_string(),
|
||||
affected_profiles: vec!["profile2".to_string()],
|
||||
is_stable_update: true,
|
||||
timestamp: 1001,
|
||||
},
|
||||
UpdateNotification {
|
||||
id: "chrome_1.0.0_to_1.1.0-alpha".to_string(),
|
||||
browser: "chrome".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "1.1.0-alpha".to_string(),
|
||||
affected_profiles: vec!["profile3".to_string()],
|
||||
is_stable_update: false,
|
||||
timestamp: 1002,
|
||||
},
|
||||
];
|
||||
|
||||
let grouped = updater.group_update_notifications(notifications);
|
||||
|
||||
assert_eq!(grouped.len(), 2);
|
||||
|
||||
// Find the Firefox notification
|
||||
let firefox_notification = grouped.iter().find(|n| n.browser == "firefox").unwrap();
|
||||
assert_eq!(firefox_notification.affected_profiles.len(), 2);
|
||||
assert!(firefox_notification
|
||||
.affected_profiles
|
||||
.contains(&"profile1".to_string()));
|
||||
assert!(firefox_notification
|
||||
.affected_profiles
|
||||
.contains(&"profile2".to_string()));
|
||||
|
||||
// Stable updates should come first
|
||||
assert!(grouped[0].is_stable_update);
|
||||
assert_eq!(grouped.len(), 1);
|
||||
assert_eq!(grouped[0].affected_profiles.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -945,17 +803,16 @@ mod tests {
|
||||
let test_settings_manager = TestSettingsManager::new(temp_dir.path().to_path_buf());
|
||||
|
||||
let mut state = AutoUpdateState::default();
|
||||
state.disabled_browsers.insert("firefox".to_string());
|
||||
state.disabled_browsers.insert("testbrowser".to_string());
|
||||
state
|
||||
.auto_update_downloads
|
||||
.insert("firefox-1.1.0".to_string());
|
||||
.insert("testbrowser-1.1.0".to_string());
|
||||
state.pending_updates.push(UpdateNotification {
|
||||
id: "test".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "1.1.0".to_string(),
|
||||
affected_profiles: vec!["profile1".to_string()],
|
||||
is_stable_update: true,
|
||||
timestamp: 1000,
|
||||
});
|
||||
|
||||
@@ -974,9 +831,11 @@ mod tests {
|
||||
serde_json::from_str(&content).expect("Failed to deserialize state");
|
||||
|
||||
assert_eq!(loaded_state.disabled_browsers.len(), 1);
|
||||
assert!(loaded_state.disabled_browsers.contains("firefox"));
|
||||
assert!(loaded_state.disabled_browsers.contains("testbrowser"));
|
||||
assert_eq!(loaded_state.auto_update_downloads.len(), 1);
|
||||
assert!(loaded_state.auto_update_downloads.contains("firefox-1.1.0"));
|
||||
assert!(loaded_state
|
||||
.auto_update_downloads
|
||||
.contains("testbrowser-1.1.0"));
|
||||
assert_eq!(loaded_state.pending_updates.len(), 1);
|
||||
assert_eq!(loaded_state.pending_updates[0].id, "test");
|
||||
}
|
||||
@@ -1015,16 +874,16 @@ mod tests {
|
||||
// Initially not disabled (empty state file means default state)
|
||||
let state = AutoUpdateState::default();
|
||||
assert!(
|
||||
!state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should not be disabled initially"
|
||||
!state.disabled_browsers.contains("testbrowser"),
|
||||
"testbrowser should not be disabled initially"
|
||||
);
|
||||
|
||||
// Start update (should disable)
|
||||
let mut state = AutoUpdateState::default();
|
||||
state.disabled_browsers.insert("firefox".to_string());
|
||||
state.disabled_browsers.insert("testbrowser".to_string());
|
||||
state
|
||||
.auto_update_downloads
|
||||
.insert("firefox-1.1.0".to_string());
|
||||
.insert("testbrowser-1.1.0".to_string());
|
||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize state");
|
||||
std::fs::write(&state_file, json).expect("Failed to write state file");
|
||||
|
||||
@@ -1033,18 +892,20 @@ mod tests {
|
||||
let loaded_state: AutoUpdateState =
|
||||
serde_json::from_str(&content).expect("Failed to deserialize state");
|
||||
assert!(
|
||||
loaded_state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should be disabled"
|
||||
loaded_state.disabled_browsers.contains("testbrowser"),
|
||||
"testbrowser should be disabled"
|
||||
);
|
||||
assert!(
|
||||
loaded_state.auto_update_downloads.contains("firefox-1.1.0"),
|
||||
"Firefox download should be tracked"
|
||||
loaded_state
|
||||
.auto_update_downloads
|
||||
.contains("testbrowser-1.1.0"),
|
||||
"testbrowser download should be tracked"
|
||||
);
|
||||
|
||||
// Complete update (should enable)
|
||||
let mut state = loaded_state;
|
||||
state.disabled_browsers.remove("firefox");
|
||||
state.auto_update_downloads.remove("firefox-1.1.0");
|
||||
state.disabled_browsers.remove("testbrowser");
|
||||
state.auto_update_downloads.remove("testbrowser-1.1.0");
|
||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize final state");
|
||||
std::fs::write(&state_file, json).expect("Failed to write final state file");
|
||||
|
||||
@@ -1053,12 +914,14 @@ mod tests {
|
||||
let final_state: AutoUpdateState =
|
||||
serde_json::from_str(&content).expect("Failed to deserialize final state");
|
||||
assert!(
|
||||
!final_state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should be enabled again"
|
||||
!final_state.disabled_browsers.contains("testbrowser"),
|
||||
"testbrowser should be enabled again"
|
||||
);
|
||||
assert!(
|
||||
!final_state.auto_update_downloads.contains("firefox-1.1.0"),
|
||||
"Firefox download should not be tracked anymore"
|
||||
!final_state
|
||||
.auto_update_downloads
|
||||
.contains("testbrowser-1.1.0"),
|
||||
"testbrowser download should not be tracked anymore"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1089,11 +952,10 @@ mod tests {
|
||||
let mut state = AutoUpdateState::default();
|
||||
state.pending_updates.push(UpdateNotification {
|
||||
id: "test_notification".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
browser: "testbrowser".to_string(),
|
||||
current_version: "1.0.0".to_string(),
|
||||
new_version: "1.1.0".to_string(),
|
||||
affected_profiles: vec!["profile1".to_string()],
|
||||
is_stable_update: true,
|
||||
timestamp: 1000,
|
||||
});
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ use clap::{Arg, Command};
|
||||
use donutbrowser_lib::proxy_runner::{
|
||||
start_proxy_process_with_profile, stop_all_proxy_processes, stop_proxy_process,
|
||||
};
|
||||
use donutbrowser_lib::proxy_server::run_proxy_server;
|
||||
use donutbrowser_lib::proxy_storage::get_proxy_config;
|
||||
use donutbrowser_lib::proxy_server::{redacted_upstream, run_proxy_server};
|
||||
use donutbrowser_lib::proxy_storage::{build_proxy_url, get_proxy_config};
|
||||
use std::process;
|
||||
|
||||
fn set_high_priority() {
|
||||
@@ -55,31 +55,6 @@ fn set_high_priority() {
|
||||
}
|
||||
}
|
||||
|
||||
fn build_proxy_url(
|
||||
proxy_type: &str,
|
||||
host: &str,
|
||||
port: u16,
|
||||
username: Option<&str>,
|
||||
password: Option<&str>,
|
||||
) -> String {
|
||||
let mut url = format!("{}://", proxy_type.to_lowercase());
|
||||
|
||||
if let (Some(user), Some(pass)) = (username, password) {
|
||||
let encoded_user = urlencoding::encode(user);
|
||||
let encoded_pass = urlencoding::encode(pass);
|
||||
url.push_str(&format!("{}:{}@", encoded_user, encoded_pass));
|
||||
} else if let Some(user) = username {
|
||||
let encoded_user = urlencoding::encode(user);
|
||||
url.push_str(&format!("{}@", encoded_user));
|
||||
}
|
||||
|
||||
url.push_str(host);
|
||||
url.push(':');
|
||||
url.push_str(&port.to_string());
|
||||
|
||||
url
|
||||
}
|
||||
|
||||
#[tokio::main(flavor = "multi_thread")]
|
||||
async fn main() {
|
||||
// Initialize logger to write to stderr (which will be redirected to file).
|
||||
@@ -110,6 +85,7 @@ async fn main() {
|
||||
}));
|
||||
|
||||
let matches = Command::new("donut-proxy")
|
||||
.version(env!("BUILD_VERSION"))
|
||||
.subcommand(
|
||||
Command::new("proxy")
|
||||
.about("Manage proxy servers")
|
||||
@@ -128,8 +104,6 @@ async fn main() {
|
||||
.long("type")
|
||||
.help("Proxy type (http, https, socks4, socks5, ss)"),
|
||||
)
|
||||
.arg(Arg::new("username").long("username").help("Proxy username"))
|
||||
.arg(Arg::new("password").long("password").help("Proxy password"))
|
||||
.arg(
|
||||
Arg::new("port")
|
||||
.short('p')
|
||||
@@ -163,6 +137,12 @@ async fn main() {
|
||||
.long("blocklist-file")
|
||||
.help("Path to DNS blocklist file (one domain per line)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("dns-allowlist-mode")
|
||||
.long("dns-allowlist-mode")
|
||||
.num_args(0)
|
||||
.help("Treat --blocklist-file as an allowlist (block all domains not listed)"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("local-protocol")
|
||||
.long("local-protocol")
|
||||
@@ -236,16 +216,22 @@ async fn main() {
|
||||
start_matches.get_one::<u16>("proxy-port"),
|
||||
start_matches.get_one::<String>("type"),
|
||||
) {
|
||||
let username = start_matches.get_one::<String>("username");
|
||||
let password = start_matches.get_one::<String>("password");
|
||||
let username = std::env::var("DONUT_PROXY_USERNAME").ok();
|
||||
let password = std::env::var("DONUT_PROXY_PASSWORD").ok();
|
||||
upstream_url = Some(build_proxy_url(
|
||||
proxy_type,
|
||||
host,
|
||||
*port,
|
||||
username.map(|s| s.as_str()),
|
||||
password.map(|s| s.as_str()),
|
||||
username.as_deref(),
|
||||
password.as_deref(),
|
||||
));
|
||||
} else if let Some(upstream) = start_matches.get_one::<String>("upstream") {
|
||||
if url::Url::parse(upstream)
|
||||
.is_ok_and(|parsed| !parsed.username().is_empty() || parsed.password().is_some())
|
||||
{
|
||||
eprintln!("Credentialed upstream URLs are not accepted as process arguments");
|
||||
process::exit(2);
|
||||
}
|
||||
upstream_url = Some(upstream.clone());
|
||||
}
|
||||
|
||||
@@ -256,6 +242,7 @@ async fn main() {
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
|
||||
let dns_allowlist_mode = start_matches.get_flag("dns-allowlist-mode");
|
||||
let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
|
||||
|
||||
match start_proxy_process_with_profile(
|
||||
@@ -264,6 +251,7 @@ async fn main() {
|
||||
profile_id,
|
||||
bypass_rules,
|
||||
blocklist_file,
|
||||
dns_allowlist_mode,
|
||||
local_protocol,
|
||||
)
|
||||
.await
|
||||
@@ -277,7 +265,7 @@ async fn main() {
|
||||
"id": config.id,
|
||||
"localPort": config.local_port,
|
||||
"localUrl": config.local_url,
|
||||
"upstreamUrl": config.upstream_url,
|
||||
"upstreamUrl": redacted_upstream(&config.upstream_url),
|
||||
})
|
||||
);
|
||||
process::exit(0);
|
||||
@@ -371,7 +359,7 @@ async fn main() {
|
||||
"Found config: id={}, port={:?}, upstream={}",
|
||||
config.id,
|
||||
config.local_port,
|
||||
config.upstream_url
|
||||
redacted_upstream(&config.upstream_url)
|
||||
);
|
||||
break config;
|
||||
}
|
||||
|
||||
+172
-748
File diff suppressed because it is too large
Load Diff
+129
-1301
File diff suppressed because it is too large
Load Diff
@@ -1,12 +1,10 @@
|
||||
use crate::api_client::{sort_versions, ApiClient, BrowserRelease};
|
||||
use crate::browser::GithubRelease;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BrowserVersionInfo {
|
||||
pub version: String,
|
||||
pub is_prerelease: bool,
|
||||
pub date: String,
|
||||
}
|
||||
|
||||
@@ -20,7 +18,6 @@ pub struct BrowserVersionsResult {
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BrowserReleaseTypes {
|
||||
pub stable: Option<String>,
|
||||
pub nightly: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -53,33 +50,8 @@ impl BrowserVersionManager {
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
|
||||
match browser {
|
||||
"firefox" | "firefox-developer" => Ok(true),
|
||||
"zen" => {
|
||||
// Zen supports all platforms and architectures
|
||||
Ok(true)
|
||||
}
|
||||
"brave" => {
|
||||
// Brave supports all platforms and architectures
|
||||
Ok(true)
|
||||
}
|
||||
"chromium" => {
|
||||
// Chromium doesn't support ARM64 on Linux
|
||||
if arch == "arm64" && os == "linux" {
|
||||
Ok(false)
|
||||
} else {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
"camoufox" => {
|
||||
// Camoufox supports all platforms and architectures according to the JS code
|
||||
Ok(true)
|
||||
}
|
||||
"wayfern" => {
|
||||
// Wayfern support depends on version.json downloads availability
|
||||
// Currently supports macos-arm64 and linux-x64
|
||||
let platform_key = format!("{os}-{arch}");
|
||||
// Check dynamically, but allow the browser to appear even if platform not available yet
|
||||
// The actual download will fail gracefully if not supported
|
||||
Ok(matches!(
|
||||
platform_key.as_str(),
|
||||
"macos-arm64"
|
||||
@@ -96,15 +68,7 @@ impl BrowserVersionManager {
|
||||
|
||||
/// Get list of browsers supported on the current platform
|
||||
pub fn get_supported_browsers(&self) -> Vec<String> {
|
||||
let all_browsers = vec![
|
||||
"firefox",
|
||||
"firefox-developer",
|
||||
"zen",
|
||||
"brave",
|
||||
"chromium",
|
||||
"camoufox",
|
||||
"wayfern",
|
||||
];
|
||||
let all_browsers = vec!["wayfern"];
|
||||
|
||||
all_browsers
|
||||
.into_iter()
|
||||
@@ -115,13 +79,6 @@ impl BrowserVersionManager {
|
||||
|
||||
/// Get cached browser versions immediately (returns None if no cache exists)
|
||||
pub fn get_cached_browser_versions(&self, browser: &str) -> Option<Vec<String>> {
|
||||
if browser == "brave" {
|
||||
return self
|
||||
.api_client
|
||||
.get_cached_github_releases("brave")
|
||||
.map(|releases| releases.into_iter().map(|r| r.tag_name).collect());
|
||||
}
|
||||
|
||||
self
|
||||
.api_client
|
||||
.load_cached_versions(browser)
|
||||
@@ -133,20 +90,6 @@ impl BrowserVersionManager {
|
||||
&self,
|
||||
browser: &str,
|
||||
) -> Option<Vec<BrowserVersionInfo>> {
|
||||
if browser == "brave" {
|
||||
if let Some(releases) = self.api_client.get_cached_github_releases("brave") {
|
||||
let detailed_info: Vec<BrowserVersionInfo> = releases
|
||||
.into_iter()
|
||||
.map(|r| BrowserVersionInfo {
|
||||
version: r.tag_name,
|
||||
is_prerelease: r.is_nightly,
|
||||
date: r.published_at,
|
||||
})
|
||||
.collect();
|
||||
return Some(detailed_info);
|
||||
}
|
||||
}
|
||||
|
||||
let cached_releases = self.api_client.load_cached_versions(browser)?;
|
||||
|
||||
// Convert cached versions to detailed info (without dates since cache doesn't store them)
|
||||
@@ -154,7 +97,6 @@ impl BrowserVersionManager {
|
||||
.into_iter()
|
||||
.map(|r| BrowserVersionInfo {
|
||||
version: r.version,
|
||||
is_prerelease: r.is_prerelease,
|
||||
date: r.date,
|
||||
})
|
||||
.collect();
|
||||
@@ -167,45 +109,39 @@ impl BrowserVersionManager {
|
||||
self.api_client.is_cache_expired(browser)
|
||||
}
|
||||
|
||||
/// Get latest stable and nightly versions for a browser (cached first)
|
||||
/// Get the latest Wayfern version (fresh cache first)
|
||||
pub async fn get_browser_release_types(
|
||||
&self,
|
||||
browser: &str,
|
||||
) -> Result<BrowserReleaseTypes, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Try to get from cache first
|
||||
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) {
|
||||
let latest_stable = cached_versions
|
||||
.iter()
|
||||
.find(|v| !v.is_prerelease)
|
||||
.map(|v| v.version.clone());
|
||||
|
||||
let latest_nightly = cached_versions
|
||||
.iter()
|
||||
.find(|v| v.is_prerelease)
|
||||
.map(|v| v.version.clone());
|
||||
|
||||
return Ok(BrowserReleaseTypes {
|
||||
stable: latest_stable,
|
||||
nightly: latest_nightly,
|
||||
});
|
||||
if browser != "wayfern" {
|
||||
return Err(format!("Unsupported browser: {browser}").into());
|
||||
}
|
||||
|
||||
let detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?;
|
||||
// Only trust an unexpired cache. A stale entry can point at a version that
|
||||
// is no longer published — the downloader rejects such requests, so serving
|
||||
// it here would make every download started from this list fail.
|
||||
if !self.api_client.is_cache_expired(browser) {
|
||||
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) {
|
||||
return Ok(BrowserReleaseTypes {
|
||||
stable: cached_versions.first().map(|v| v.version.clone()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let latest_stable = detailed_versions
|
||||
.iter()
|
||||
.find(|v| !v.is_prerelease)
|
||||
.map(|v| v.version.clone());
|
||||
|
||||
let latest_nightly = detailed_versions
|
||||
.iter()
|
||||
.find(|v| v.is_prerelease)
|
||||
.map(|v| v.version.clone());
|
||||
|
||||
Ok(BrowserReleaseTypes {
|
||||
stable: latest_stable,
|
||||
nightly: latest_nightly,
|
||||
})
|
||||
// Expired or missing cache: fetch fresh, falling back to whatever cache
|
||||
// exists when the network is unavailable.
|
||||
match self.fetch_browser_versions_detailed(browser, false).await {
|
||||
Ok(detailed_versions) => Ok(BrowserReleaseTypes {
|
||||
stable: detailed_versions.first().map(|v| v.version.clone()),
|
||||
}),
|
||||
Err(e) => match self.get_cached_browser_versions_detailed(browser) {
|
||||
Some(cached_versions) => Ok(BrowserReleaseTypes {
|
||||
stable: cached_versions.first().map(|v| v.version.clone()),
|
||||
}),
|
||||
None => Err(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch browser versions with optional caching
|
||||
@@ -235,12 +171,6 @@ impl BrowserVersionManager {
|
||||
|
||||
// Fetch fresh versions from API
|
||||
let fresh_versions = match browser {
|
||||
"firefox" => self.fetch_firefox_versions(true).await?, // Always fetch fresh for merging
|
||||
"firefox-developer" => self.fetch_firefox_developer_versions(true).await?,
|
||||
"zen" => self.fetch_zen_versions(true).await?,
|
||||
"brave" => self.fetch_brave_versions(true).await?,
|
||||
"chromium" => self.fetch_chromium_versions(true).await?,
|
||||
"camoufox" => self.fetch_camoufox_versions(true).await?,
|
||||
"wayfern" => self.fetch_wayfern_versions(true).await?,
|
||||
_ => return Err(format!("Unsupported browser: {browser}").into()),
|
||||
};
|
||||
@@ -262,13 +192,12 @@ impl BrowserVersionManager {
|
||||
crate::api_client::sort_versions(&mut merged_versions);
|
||||
|
||||
// Save the merged cache (unless explicitly bypassing cache)
|
||||
if !no_caching && browser != "brave" {
|
||||
if !no_caching {
|
||||
let merged_releases: Vec<BrowserRelease> = merged_versions
|
||||
.iter()
|
||||
.map(|v| BrowserRelease {
|
||||
version: v.clone(),
|
||||
date: "".to_string(),
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly(browser, v, None),
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = self
|
||||
@@ -305,157 +234,14 @@ impl BrowserVersionManager {
|
||||
// Since we don't have detailed date/prerelease info for cached versions,
|
||||
// we'll fetch fresh detailed info and map it to our merged versions
|
||||
let detailed_info: Vec<BrowserVersionInfo> = match browser {
|
||||
"firefox" => {
|
||||
let releases = self.fetch_firefox_releases_detailed(true).await?;
|
||||
merged_versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
// Try to find matching release info, otherwise create basic info
|
||||
if let Some(release) = releases.iter().find(|r| r.version == version) {
|
||||
BrowserVersionInfo {
|
||||
version: release.version.clone(),
|
||||
is_prerelease: release.is_prerelease,
|
||||
date: release.date.clone(),
|
||||
}
|
||||
} else {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly(
|
||||
"firefox", &version, None,
|
||||
),
|
||||
date: "".to_string(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"firefox-developer" => {
|
||||
let releases = self.fetch_firefox_developer_releases_detailed(true).await?;
|
||||
merged_versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
if let Some(release) = releases.iter().find(|r| r.version == version) {
|
||||
BrowserVersionInfo {
|
||||
version: release.version.clone(),
|
||||
is_prerelease: release.is_prerelease,
|
||||
date: release.date.clone(),
|
||||
}
|
||||
} else {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly(
|
||||
"firefox-developer",
|
||||
&version,
|
||||
None,
|
||||
),
|
||||
date: "".to_string(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"zen" => {
|
||||
let releases = self.fetch_zen_releases_detailed(true).await?;
|
||||
merged_versions
|
||||
.into_iter()
|
||||
// Filter out twilight releases at the detailed level too
|
||||
.filter(|version| version.to_lowercase() != "twilight")
|
||||
.map(|version| {
|
||||
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
|
||||
BrowserVersionInfo {
|
||||
version: release.tag_name.clone(),
|
||||
is_prerelease: release.is_nightly,
|
||||
date: release.published_at.clone(),
|
||||
}
|
||||
} else {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly("zen", &version, None),
|
||||
date: "".to_string(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"brave" => {
|
||||
let releases = self.fetch_brave_releases_detailed(true).await?;
|
||||
merged_versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
|
||||
BrowserVersionInfo {
|
||||
version: release.tag_name.clone(),
|
||||
is_prerelease: release.is_nightly,
|
||||
date: release.published_at.clone(),
|
||||
}
|
||||
} else {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly(
|
||||
"brave", &version, None,
|
||||
),
|
||||
date: "".to_string(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"chromium" => {
|
||||
let releases = self.fetch_chromium_releases_detailed(true).await?;
|
||||
merged_versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
if let Some(release) = releases.iter().find(|r| r.version == version) {
|
||||
BrowserVersionInfo {
|
||||
version: release.version.clone(),
|
||||
is_prerelease: release.is_prerelease,
|
||||
date: release.date.clone(),
|
||||
}
|
||||
} else {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: false, // Chromium usually stable releases
|
||||
date: "".to_string(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"camoufox" => {
|
||||
let releases = self.fetch_camoufox_releases_detailed(true).await?;
|
||||
merged_versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
|
||||
BrowserVersionInfo {
|
||||
version: release.tag_name.clone(),
|
||||
is_prerelease: release.is_nightly,
|
||||
date: release.published_at.clone(),
|
||||
}
|
||||
} else {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: false, // Camoufox usually stable releases
|
||||
date: "".to_string(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
"wayfern" => {
|
||||
// Wayfern only has one version from version.json
|
||||
merged_versions
|
||||
.into_iter()
|
||||
.map(|version| BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: false, // Wayfern releases are always stable
|
||||
date: "".to_string(),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => {
|
||||
return Err(format!("Unsupported browser: {browser}").into());
|
||||
}
|
||||
"wayfern" => merged_versions
|
||||
.into_iter()
|
||||
.map(|version| BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
date: "".to_string(),
|
||||
})
|
||||
.collect(),
|
||||
_ => return Err(format!("Unsupported browser: {browser}").into()),
|
||||
};
|
||||
|
||||
Ok(detailed_info)
|
||||
@@ -493,7 +279,6 @@ impl BrowserVersionManager {
|
||||
.map(|v| BrowserRelease {
|
||||
version: v.clone(),
|
||||
date: "".to_string(),
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly(browser, v, None),
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = self.api_client.save_cached_versions(browser, &releases) {
|
||||
@@ -512,170 +297,6 @@ impl BrowserVersionManager {
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
|
||||
match browser {
|
||||
"firefox" => {
|
||||
let (platform_path, filename, is_archive) = match (&os[..], &arch[..]) {
|
||||
("windows", "x64") => ("win64", format!("Firefox Setup {version}.exe"), false),
|
||||
("windows", "arm64") => (
|
||||
"win64-aarch64",
|
||||
format!("Firefox Setup {version}.exe"),
|
||||
false,
|
||||
),
|
||||
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.xz"), true),
|
||||
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.xz"), true),
|
||||
("macos", _) => ("mac", format!("Firefox {version}.dmg"), true),
|
||||
_ => {
|
||||
return Err(
|
||||
format!("Unsupported platform/architecture for Firefox: {os}/{arch}").into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://download-installer.cdn.mozilla.net/pub/firefox/releases/{version}/{platform_path}/en-US/{filename}"
|
||||
),
|
||||
filename,
|
||||
is_archive,
|
||||
})
|
||||
}
|
||||
"firefox-developer" => {
|
||||
let (platform_path, filename, is_archive) = match (&os[..], &arch[..]) {
|
||||
("windows", "x64") => ("win64", format!("Firefox Setup {version}.exe"), false),
|
||||
("windows", "arm64") => (
|
||||
"win64-aarch64",
|
||||
format!("Firefox Setup {version}.exe"),
|
||||
false,
|
||||
),
|
||||
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.xz"), true),
|
||||
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.xz"), true),
|
||||
("macos", _) => ("mac", format!("Firefox {version}.dmg"), true),
|
||||
_ => {
|
||||
return Err(
|
||||
format!("Unsupported platform/architecture for Firefox Developer: {os}/{arch}")
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://download-installer.cdn.mozilla.net/pub/devedition/releases/{version}/{platform_path}/en-US/{filename}"
|
||||
),
|
||||
filename,
|
||||
is_archive,
|
||||
})
|
||||
}
|
||||
"zen" => {
|
||||
let (asset_name, filename, is_archive) = match (&os[..], &arch[..]) {
|
||||
("windows", "x64") => ("zen.installer.exe", format!("zen-{version}.exe"), false),
|
||||
("windows", "arm64") => (
|
||||
"zen.installer-arm64.exe",
|
||||
format!("zen-{version}-arm64.exe"),
|
||||
false,
|
||||
),
|
||||
("linux", "x64") => (
|
||||
"zen.linux-x86_64.tar.xz",
|
||||
format!("zen-{version}-x86_64.tar.xz"),
|
||||
true,
|
||||
),
|
||||
("linux", "arm64") => (
|
||||
"zen.linux-aarch64.tar.xz",
|
||||
format!("zen-{version}-aarch64.tar.xz"),
|
||||
true,
|
||||
),
|
||||
("macos", _) => (
|
||||
"zen.macos-universal.dmg",
|
||||
format!("zen-{version}.dmg"),
|
||||
true,
|
||||
),
|
||||
_ => {
|
||||
return Err(format!("Unsupported platform/architecture for Zen: {os}/{arch}").into())
|
||||
}
|
||||
};
|
||||
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://github.com/zen-browser/desktop/releases/download/{version}/{asset_name}"
|
||||
),
|
||||
filename,
|
||||
is_archive,
|
||||
})
|
||||
}
|
||||
"brave" => {
|
||||
let (filename, is_archive) = match (&os[..], &arch[..]) {
|
||||
("windows", _) => (format!("brave-{version}.exe"), false),
|
||||
("linux", "x64") => (format!("brave-browser-{version}-linux-amd64.zip"), true),
|
||||
("linux", "arm64") => (format!("brave-browser-{version}-linux-arm64.zip"), true),
|
||||
("macos", _) => ("Brave-Browser-universal.dmg".to_string(), true),
|
||||
_ => {
|
||||
return Err(format!("Unsupported platform/architecture for Brave: {os}/{arch}").into())
|
||||
}
|
||||
};
|
||||
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://github.com/brave/brave-browser/releases/download/{version}/{filename}"
|
||||
),
|
||||
filename,
|
||||
is_archive,
|
||||
})
|
||||
}
|
||||
"chromium" => {
|
||||
let platform_str = match (&os[..], &arch[..]) {
|
||||
("windows", "x64") => "Win_x64",
|
||||
("windows", "arm64") => "Win_Arm64",
|
||||
("linux", "x64") => "Linux_x64",
|
||||
("linux", "arm64") => return Err("Chromium doesn't support ARM64 on Linux".into()),
|
||||
("macos", "x64") => "Mac",
|
||||
("macos", "arm64") => "Mac_Arm",
|
||||
_ => {
|
||||
return Err(
|
||||
format!("Unsupported platform/architecture for Chromium: {os}/{arch}").into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
let (archive_name, filename) = match os.as_str() {
|
||||
"windows" => ("chrome-win.zip", format!("chromium-{version}-win.zip")),
|
||||
"linux" => ("chrome-linux.zip", format!("chromium-{version}-linux.zip")),
|
||||
"macos" => ("chrome-mac.zip", format!("chromium-{version}-mac.zip")),
|
||||
_ => return Err(format!("Unsupported platform for Chromium: {os}").into()),
|
||||
};
|
||||
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://commondatastorage.googleapis.com/chromium-browser-snapshots/{platform_str}/{version}/{archive_name}"
|
||||
),
|
||||
filename,
|
||||
is_archive: true,
|
||||
})
|
||||
}
|
||||
"camoufox" => {
|
||||
// Camoufox downloads from GitHub releases with pattern: camoufox-{version}-{release}-{os}.{arch}.zip
|
||||
let (os_name, arch_name) = match (&os[..], &arch[..]) {
|
||||
("windows", "x64") => ("win", "x86_64"),
|
||||
("windows", "arm64") => ("win", "arm64"),
|
||||
("linux", "x64") => ("lin", "x86_64"),
|
||||
("linux", "arm64") => ("lin", "arm64"),
|
||||
("macos", "x64") => ("mac", "x86_64"),
|
||||
("macos", "arm64") => ("mac", "arm64"),
|
||||
_ => {
|
||||
return Err(
|
||||
format!("Unsupported platform/architecture for Camoufox: {os}/{arch}").into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Note: We provide a placeholder URL here since Camoufox requires dynamic resolution
|
||||
// The actual URL will be resolved in download.rs resolve_download_url
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://github.com/daijro/camoufox/releases/download/{version}/camoufox-{{version}}-{{release}}-{os_name}.{arch_name}.zip"
|
||||
),
|
||||
filename: format!("camoufox-{version}-{os_name}.{arch_name}.zip"),
|
||||
is_archive: true,
|
||||
})
|
||||
}
|
||||
"wayfern" => {
|
||||
// Wayfern downloads from https://download.wayfern.com/
|
||||
// File naming: wayfern-{chromium_version}-{platform}-{arch}.{ext}
|
||||
@@ -728,153 +349,6 @@ impl BrowserVersionManager {
|
||||
(os.to_string(), arch.to_string())
|
||||
}
|
||||
|
||||
// Private helper methods for each browser type
|
||||
|
||||
async fn fetch_firefox_versions(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let releases = self.fetch_firefox_releases_detailed(no_caching).await?;
|
||||
Ok(releases.into_iter().map(|r| r.version).collect())
|
||||
}
|
||||
|
||||
async fn fetch_firefox_releases_detailed(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self
|
||||
.api_client
|
||||
.fetch_firefox_releases_with_caching(no_caching)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_firefox_developer_versions(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let releases = self
|
||||
.fetch_firefox_developer_releases_detailed(no_caching)
|
||||
.await?;
|
||||
Ok(releases.into_iter().map(|r| r.version).collect())
|
||||
}
|
||||
|
||||
async fn fetch_firefox_developer_releases_detailed(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self
|
||||
.api_client
|
||||
.fetch_firefox_developer_releases_with_caching(no_caching)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_zen_versions(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let releases = self.fetch_zen_releases_detailed(no_caching).await?;
|
||||
Ok(
|
||||
releases
|
||||
.into_iter()
|
||||
.filter(|r| r.tag_name.to_lowercase() != "twilight")
|
||||
.map(|r| r.tag_name)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn fetch_zen_releases_detailed(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self
|
||||
.api_client
|
||||
.fetch_zen_releases_with_caching(no_caching)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_brave_versions(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let releases = self.fetch_brave_releases_detailed(no_caching).await?;
|
||||
// Persist a lightweight versions cache with accurate prerelease info for Brave
|
||||
let converted: Vec<BrowserRelease> = releases
|
||||
.iter()
|
||||
.map(|r| BrowserRelease {
|
||||
version: r.tag_name.clone(),
|
||||
date: r.published_at.clone(),
|
||||
is_prerelease: r.is_nightly,
|
||||
})
|
||||
.collect();
|
||||
// Always save so that other callers without release_name can classify correctly
|
||||
if let Err(e) = self.api_client.save_cached_versions("brave", &converted) {
|
||||
log::error!("Failed to persist Brave versions cache: {e}");
|
||||
}
|
||||
|
||||
Ok(releases.into_iter().map(|r| r.tag_name).collect())
|
||||
}
|
||||
|
||||
async fn fetch_brave_releases_detailed(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_brave_releases_with_caching(no_caching)
|
||||
.await?;
|
||||
|
||||
// Save a parallel versions cache for Brave with accurate prerelease flags
|
||||
let converted: Vec<BrowserRelease> = releases
|
||||
.iter()
|
||||
.map(|r| BrowserRelease {
|
||||
version: r.tag_name.clone(),
|
||||
date: r.published_at.clone(),
|
||||
is_prerelease: r.is_nightly,
|
||||
})
|
||||
.collect();
|
||||
if let Err(e) = self.api_client.save_cached_versions("brave", &converted) {
|
||||
log::error!("Failed to persist Brave versions cache: {e}");
|
||||
}
|
||||
|
||||
Ok(releases)
|
||||
}
|
||||
|
||||
async fn fetch_chromium_versions(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let releases = self.fetch_chromium_releases_detailed(no_caching).await?;
|
||||
Ok(releases.into_iter().map(|r| r.version).collect())
|
||||
}
|
||||
|
||||
async fn fetch_chromium_releases_detailed(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self
|
||||
.api_client
|
||||
.fetch_chromium_releases_with_caching(no_caching)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_camoufox_versions(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let releases = self.fetch_camoufox_releases_detailed(no_caching).await?;
|
||||
Ok(releases.into_iter().map(|r| r.tag_name).collect())
|
||||
}
|
||||
|
||||
async fn fetch_camoufox_releases_detailed(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self
|
||||
.api_client
|
||||
.fetch_camoufox_releases_with_caching(no_caching)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_wayfern_versions(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
@@ -912,37 +386,14 @@ pub async fn get_browser_release_types(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use wiremock::MockServer;
|
||||
|
||||
async fn setup_mock_server() -> MockServer {
|
||||
MockServer::start().await
|
||||
}
|
||||
|
||||
fn create_test_api_client(server: &MockServer) -> ApiClient {
|
||||
let base_url = server.uri();
|
||||
ApiClient::new_with_base_urls(
|
||||
base_url.clone(), // firefox_api_base
|
||||
base_url.clone(), // firefox_dev_api_base
|
||||
base_url.clone(), // github_api_base
|
||||
base_url.clone(), // chromium_api_base
|
||||
)
|
||||
}
|
||||
|
||||
fn create_test_service(_api_client: ApiClient) -> &'static BrowserVersionManager {
|
||||
BrowserVersionManager::instance()
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_browser_version_manager_creation() {
|
||||
let _ = BrowserVersionManager::instance();
|
||||
// Test passes if we can create the service without panicking
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_unsupported_browser() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let service = create_test_service(api_client);
|
||||
let service = BrowserVersionManager::instance();
|
||||
|
||||
let result = service.fetch_browser_versions("unsupported", false).await;
|
||||
assert!(
|
||||
@@ -962,141 +413,48 @@ mod tests {
|
||||
fn test_get_download_info() {
|
||||
let service = BrowserVersionManager::instance();
|
||||
|
||||
// Test Firefox - platform-specific expectations
|
||||
let firefox_info = service.get_download_info("firefox", "139.0").unwrap();
|
||||
let wayfern_info = service.get_download_info("wayfern", "1.0.0").unwrap();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(all(target_os = "macos", target_arch = "aarch64"))]
|
||||
{
|
||||
assert_eq!(firefox_info.filename, "Firefox 139.0.dmg");
|
||||
assert!(firefox_info.is_archive);
|
||||
assert_eq!(wayfern_info.filename, "wayfern-1.0.0-macos-arm64.dmg");
|
||||
assert!(wayfern_info.is_archive);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(all(target_os = "macos", target_arch = "x86_64"))]
|
||||
{
|
||||
assert_eq!(firefox_info.filename, "firefox-139.0.tar.xz");
|
||||
assert!(firefox_info.is_archive);
|
||||
assert_eq!(wayfern_info.filename, "wayfern-1.0.0-macos-x64.dmg");
|
||||
assert!(wayfern_info.is_archive);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
|
||||
{
|
||||
assert_eq!(firefox_info.filename, "Firefox Setup 139.0.exe");
|
||||
assert!(!firefox_info.is_archive);
|
||||
assert_eq!(wayfern_info.filename, "wayfern-1.0.0-linux-x64.tar.xz");
|
||||
assert!(wayfern_info.is_archive);
|
||||
}
|
||||
|
||||
assert!(firefox_info
|
||||
.url
|
||||
.contains("download-installer.cdn.mozilla.net"));
|
||||
assert!(firefox_info.url.contains("/pub/firefox/releases/139.0/"));
|
||||
|
||||
// Test Firefox Developer
|
||||
let firefox_dev_info = service
|
||||
.get_download_info("firefox-developer", "139.0b1")
|
||||
.unwrap();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[cfg(all(target_os = "linux", target_arch = "aarch64"))]
|
||||
{
|
||||
assert_eq!(firefox_dev_info.filename, "Firefox 139.0b1.dmg");
|
||||
assert!(firefox_dev_info.is_archive);
|
||||
assert_eq!(wayfern_info.filename, "wayfern-1.0.0-linux-arm64.tar.xz");
|
||||
assert!(wayfern_info.is_archive);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[cfg(all(target_os = "windows", target_arch = "x86_64"))]
|
||||
{
|
||||
assert_eq!(firefox_dev_info.filename, "firefox-139.0b1.tar.xz");
|
||||
assert!(firefox_dev_info.is_archive);
|
||||
assert_eq!(wayfern_info.filename, "wayfern-1.0.0-windows-x64.zip");
|
||||
assert!(wayfern_info.is_archive);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[cfg(all(target_os = "windows", target_arch = "aarch64"))]
|
||||
{
|
||||
assert_eq!(firefox_dev_info.filename, "Firefox Setup 139.0b1.exe");
|
||||
assert!(!firefox_dev_info.is_archive);
|
||||
assert_eq!(wayfern_info.filename, "wayfern-1.0.0-windows-arm64.zip");
|
||||
assert!(wayfern_info.is_archive);
|
||||
}
|
||||
|
||||
assert!(firefox_dev_info
|
||||
.url
|
||||
.contains("download-installer.cdn.mozilla.net"));
|
||||
assert!(firefox_dev_info
|
||||
.url
|
||||
.contains("/pub/devedition/releases/139.0b1/"));
|
||||
assert!(wayfern_info.url.contains("download.wayfern.com"));
|
||||
|
||||
// Test Zen Browser
|
||||
let zen_info = service.get_download_info("zen", "1.11b").unwrap();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
assert_eq!(zen_info.filename, "zen-1.11b.dmg");
|
||||
assert!(zen_info.url.contains("zen.macos-universal.dmg"));
|
||||
assert!(zen_info.is_archive);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
assert_eq!(zen_info.filename, "zen-1.11b-x86_64.tar.xz");
|
||||
assert!(zen_info.url.contains("zen.linux-x86_64.tar.xz"));
|
||||
assert!(zen_info.is_archive);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
assert_eq!(zen_info.filename, "zen-1.11b.exe");
|
||||
assert!(zen_info.url.contains("zen.installer.exe"));
|
||||
assert!(!zen_info.is_archive);
|
||||
}
|
||||
|
||||
// Test Chromium
|
||||
let chromium_info = service.get_download_info("chromium", "1465660").unwrap();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
assert_eq!(chromium_info.filename, "chromium-1465660-mac.zip");
|
||||
assert!(chromium_info.url.contains("chrome-mac.zip"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
assert_eq!(chromium_info.filename, "chromium-1465660-linux.zip");
|
||||
assert!(chromium_info.url.contains("chrome-linux.zip"));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
assert_eq!(chromium_info.filename, "chromium-1465660-win.zip");
|
||||
assert!(chromium_info.url.contains("chrome-win.zip"));
|
||||
}
|
||||
|
||||
assert!(chromium_info.is_archive);
|
||||
|
||||
// Test Brave - Note: Brave uses dynamic URL resolution, so get_download_info provides a template URL
|
||||
let brave_info = service.get_download_info("brave", "v1.81.9").unwrap();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
assert_eq!(brave_info.filename, "Brave-Browser-universal.dmg");
|
||||
assert_eq!(brave_info.url, "https://github.com/brave/brave-browser/releases/download/v1.81.9/Brave-Browser-universal.dmg");
|
||||
assert!(brave_info.is_archive);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
assert_eq!(brave_info.filename, "brave-browser-v1.81.9-linux-amd64.zip");
|
||||
assert_eq!(brave_info.url, "https://github.com/brave/brave-browser/releases/download/v1.81.9/brave-browser-v1.81.9-linux-amd64.zip");
|
||||
assert!(brave_info.is_archive);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
assert_eq!(brave_info.filename, "brave-v1.81.9.exe");
|
||||
assert_eq!(
|
||||
brave_info.url,
|
||||
"https://github.com/brave/brave-browser/releases/download/v1.81.9/brave-v1.81.9.exe"
|
||||
);
|
||||
assert!(!brave_info.is_archive);
|
||||
}
|
||||
|
||||
// Test unsupported browser
|
||||
let unsupported_result = service.get_download_info("unsupported", "1.0.0");
|
||||
let unsupported_result = service.get_download_info("testbrowser", "1.0.0");
|
||||
assert!(unsupported_result.is_err());
|
||||
|
||||
log::info!("Download info test passed for all browsers");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,677 +0,0 @@
|
||||
//! Camoufox configuration builder.
|
||||
//!
|
||||
//! Converts fingerprints to Camoufox configuration format and builds launch options.
|
||||
|
||||
use rand::RngExt;
|
||||
use serde_yaml;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::camoufox::data;
|
||||
use crate::camoufox::env_vars;
|
||||
use crate::camoufox::fingerprint::types::*;
|
||||
use crate::camoufox::fonts;
|
||||
use crate::camoufox::geolocation;
|
||||
use crate::camoufox::presets;
|
||||
use crate::camoufox::webgl;
|
||||
|
||||
/// Browserforge mapping from YAML.
|
||||
type BrowserforgeMapping = HashMap<String, serde_yaml::Value>;
|
||||
|
||||
/// Load the browserforge mapping from embedded YAML.
|
||||
fn load_browserforge_mapping() -> BrowserforgeMapping {
|
||||
serde_yaml::from_str(data::BROWSERFORGE_YML).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Convert a fingerprint to Camoufox configuration.
|
||||
pub fn from_browserforge(
|
||||
fingerprint: &Fingerprint,
|
||||
ff_version: Option<u32>,
|
||||
) -> HashMap<String, serde_json::Value> {
|
||||
let mapping = load_browserforge_mapping();
|
||||
let mut config = HashMap::new();
|
||||
|
||||
// Convert fingerprint to a JSON value for easier traversal
|
||||
let fp_json = serde_json::to_value(fingerprint).unwrap_or_default();
|
||||
|
||||
// Apply mappings recursively
|
||||
cast_to_properties(&mut config, &mapping, &fp_json, ff_version);
|
||||
|
||||
// Handle window.screenX and window.screenY
|
||||
handle_screen_xy(&mut config, &fingerprint.screen);
|
||||
|
||||
config
|
||||
}
|
||||
|
||||
/// Recursively cast fingerprint properties to Camoufox config format.
|
||||
fn cast_to_properties(
|
||||
config: &mut HashMap<String, serde_json::Value>,
|
||||
mapping: &BrowserforgeMapping,
|
||||
fingerprint: &serde_json::Value,
|
||||
ff_version: Option<u32>,
|
||||
) {
|
||||
if let serde_json::Value::Object(fp_obj) = fingerprint {
|
||||
for (key, mapping_value) in mapping {
|
||||
let fp_value = fp_obj.get(key);
|
||||
|
||||
match mapping_value {
|
||||
serde_yaml::Value::String(target_key) => {
|
||||
if let Some(value) = fp_value {
|
||||
let mut final_value = value.clone();
|
||||
|
||||
// Handle negative screen values
|
||||
if target_key.starts_with("screen.") {
|
||||
if let Some(num) = final_value.as_i64() {
|
||||
if num < 0 {
|
||||
final_value = serde_json::json!(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Replace Firefox version in user agent strings
|
||||
if let (Some(version), Some(s)) = (ff_version, final_value.as_str()) {
|
||||
let replaced = replace_ff_version(s, version);
|
||||
final_value = serde_json::json!(replaced);
|
||||
}
|
||||
|
||||
config.insert(target_key.clone(), final_value);
|
||||
}
|
||||
}
|
||||
serde_yaml::Value::Mapping(nested_mapping) => {
|
||||
if let Some(nested_fp) = fp_value {
|
||||
let nested: BrowserforgeMapping = nested_mapping
|
||||
.iter()
|
||||
.filter_map(|(k, v)| k.as_str().map(|ks| (ks.to_string(), v.clone())))
|
||||
.collect();
|
||||
cast_to_properties(config, &nested, nested_fp, ff_version);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace Firefox version in user agent and related strings.
|
||||
fn replace_ff_version(s: &str, version: u32) -> String {
|
||||
// Match patterns like "135.0" (Firefox version) and replace with new version
|
||||
let re = regex_lite::Regex::new(r"(?<!\d)(1[0-9]{2})(\.0)(?!\d)").unwrap_or_else(|_| {
|
||||
// Fallback - just do simple replacement
|
||||
regex_lite::Regex::new(r"Firefox/\d+").unwrap()
|
||||
});
|
||||
|
||||
re.replace_all(s, format!("{}.0", version).as_str())
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// Handle window.screenX and window.screenY generation.
|
||||
fn handle_screen_xy(config: &mut HashMap<String, serde_json::Value>, screen: &ScreenFingerprint) {
|
||||
if config.contains_key("window.screenY") {
|
||||
return;
|
||||
}
|
||||
|
||||
let screen_x = screen.screen_x;
|
||||
if screen_x == 0 {
|
||||
config.insert("window.screenX".to_string(), serde_json::json!(0));
|
||||
config.insert("window.screenY".to_string(), serde_json::json!(0));
|
||||
return;
|
||||
}
|
||||
|
||||
if (-50..=50).contains(&screen_x) {
|
||||
config.insert("window.screenY".to_string(), serde_json::json!(screen_x));
|
||||
return;
|
||||
}
|
||||
|
||||
let screen_y = screen.avail_height as i32 - screen.outer_height as i32;
|
||||
let mut rng = rand::rng();
|
||||
|
||||
let y = if screen_y == 0 {
|
||||
0
|
||||
} else if screen_y > 0 {
|
||||
rng.random_range(0..=screen_y)
|
||||
} else {
|
||||
rng.random_range(screen_y..=0)
|
||||
};
|
||||
|
||||
config.insert("window.screenY".to_string(), serde_json::json!(y));
|
||||
}
|
||||
|
||||
/// GeoIP option - can be an IP address string or auto-detect.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum GeoIPOption {
|
||||
/// Auto-detect IP (fetch public IP, optionally through proxy)
|
||||
Auto,
|
||||
/// Use a specific IP address
|
||||
IP(String),
|
||||
}
|
||||
|
||||
/// Configuration builder for Camoufox launch.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CamoufoxConfigBuilder {
|
||||
fingerprint: Option<Fingerprint>,
|
||||
operating_system: Option<String>,
|
||||
screen_constraints: Option<ScreenConstraints>,
|
||||
block_images: bool,
|
||||
block_webrtc: bool,
|
||||
block_webgl: bool,
|
||||
custom_fonts: Option<Vec<String>>,
|
||||
custom_fonts_only: bool,
|
||||
firefox_prefs: HashMap<String, serde_json::Value>,
|
||||
proxy: Option<ProxyConfig>,
|
||||
headless: bool,
|
||||
ff_version: Option<u32>,
|
||||
extra_config: HashMap<String, serde_json::Value>,
|
||||
geoip: Option<GeoIPOption>,
|
||||
}
|
||||
|
||||
/// Proxy configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ProxyConfig {
|
||||
pub server: String,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
pub bypass: Option<String>,
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
/// Parse a proxy URL string into ProxyConfig.
|
||||
/// Supports formats like:
|
||||
/// - "http://host:port"
|
||||
/// - "http://user:pass@host:port"
|
||||
/// - "socks5://user:pass@host:port"
|
||||
pub fn from_url(url: &str) -> Result<Self, ConfigError> {
|
||||
let parsed = url::Url::parse(url).map_err(|e| ConfigError::InvalidProxy(e.to_string()))?;
|
||||
|
||||
let host = parsed
|
||||
.host_str()
|
||||
.ok_or_else(|| ConfigError::InvalidProxy("Missing host".to_string()))?;
|
||||
|
||||
let port = parsed.port().unwrap_or(8080);
|
||||
let scheme = parsed.scheme();
|
||||
|
||||
let server = format!("{scheme}://{host}:{port}");
|
||||
|
||||
let username = if !parsed.username().is_empty() {
|
||||
Some(parsed.username().to_string())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let password = parsed.password().map(String::from);
|
||||
|
||||
Ok(Self {
|
||||
server,
|
||||
username,
|
||||
password,
|
||||
bypass: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CamoufoxConfigBuilder {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl CamoufoxConfigBuilder {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
fingerprint: None,
|
||||
operating_system: None,
|
||||
screen_constraints: None,
|
||||
block_images: false,
|
||||
block_webrtc: false,
|
||||
block_webgl: false,
|
||||
custom_fonts: None,
|
||||
custom_fonts_only: false,
|
||||
firefox_prefs: HashMap::new(),
|
||||
proxy: None,
|
||||
headless: false,
|
||||
ff_version: None,
|
||||
extra_config: HashMap::new(),
|
||||
geoip: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fingerprint(mut self, fp: Fingerprint) -> Self {
|
||||
self.fingerprint = Some(fp);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn operating_system(mut self, os: &str) -> Self {
|
||||
self.operating_system = Some(os.to_string());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn screen_constraints(mut self, constraints: ScreenConstraints) -> Self {
|
||||
self.screen_constraints = Some(constraints);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn block_images(mut self, block: bool) -> Self {
|
||||
self.block_images = block;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn block_webrtc(mut self, block: bool) -> Self {
|
||||
self.block_webrtc = block;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn block_webgl(mut self, block: bool) -> Self {
|
||||
self.block_webgl = block;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn custom_fonts(mut self, fonts: Vec<String>) -> Self {
|
||||
self.custom_fonts = Some(fonts);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn custom_fonts_only(mut self, only: bool) -> Self {
|
||||
self.custom_fonts_only = only;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn firefox_pref<V: Into<serde_json::Value>>(mut self, key: &str, value: V) -> Self {
|
||||
self.firefox_prefs.insert(key.to_string(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
pub fn proxy(mut self, proxy: ProxyConfig) -> Self {
|
||||
self.proxy = Some(proxy);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn headless(mut self, headless: bool) -> Self {
|
||||
self.headless = headless;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn ff_version(mut self, version: u32) -> Self {
|
||||
self.ff_version = Some(version);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn extra_config<V: Into<serde_json::Value>>(mut self, key: &str, value: V) -> Self {
|
||||
self.extra_config.insert(key.to_string(), value.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Set GeoIP option for geolocation-based fingerprinting.
|
||||
/// Use `GeoIPOption::Auto` to auto-detect public IP (optionally through proxy).
|
||||
/// Use `GeoIPOption::IP(ip_string)` to use a specific IP address.
|
||||
pub fn geoip(mut self, option: GeoIPOption) -> Self {
|
||||
self.geoip = Some(option);
|
||||
self
|
||||
}
|
||||
|
||||
/// Build the complete Camoufox launch configuration.
|
||||
///
|
||||
/// Prefers a real-fingerprint preset (matched against the Camoufox build's
|
||||
/// Firefox version via `presets::preset_line_for`) when no explicit
|
||||
/// fingerprint was passed. Falls back to the Bayesian network-based
|
||||
/// synthesizer when presets are unavailable, so callers without a known
|
||||
/// Firefox version (or with no preset for the requested OS) still get a
|
||||
/// valid config — matching pre-v150 behaviour byte-for-byte.
|
||||
pub fn build(self) -> Result<CamoufoxLaunchConfig, ConfigError> {
|
||||
let mut rng = rand::rng();
|
||||
let ff_version = self.ff_version;
|
||||
|
||||
// 1) The caller supplied a fingerprint outright — honour it and skip
|
||||
// presets entirely. This is the path tests and advanced consumers
|
||||
// use to inject deterministic fixtures.
|
||||
// 2) Otherwise, try a bundled preset for the requested OS / FF line.
|
||||
// 3) Fall back to the Bayesian generator. This is also the path that
|
||||
// runs for users whose Camoufox binary has no readable `version.json`
|
||||
// (`ff_version == None`), or whose OS has no presets bundled.
|
||||
let (mut config, target_os) = if let Some(fp) = self.fingerprint {
|
||||
let target_os = env_vars::determine_ua_os(&fp.navigator.user_agent);
|
||||
// `from_browserforge` already runs `handle_screen_xy` internally.
|
||||
let config = from_browserforge(&fp, ff_version);
|
||||
(config, target_os)
|
||||
} else if let Some(preset) =
|
||||
presets::get_random_preset(self.operating_system.as_deref(), ff_version)
|
||||
{
|
||||
let mut config = presets::from_preset(&preset, ff_version);
|
||||
let target_os = config
|
||||
.get("navigator.userAgent")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(env_vars::determine_ua_os)
|
||||
.or_else(|| {
|
||||
// Last-resort heuristic from the platform string — keeps target_os
|
||||
// sensible even if a preset somehow omits the user agent.
|
||||
config
|
||||
.get("navigator.platform")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|p| match p {
|
||||
"Win32" => "windows",
|
||||
"MacIntel" => "macos",
|
||||
_ => "linux",
|
||||
})
|
||||
})
|
||||
.unwrap_or("macos");
|
||||
// Presets don't carry multi-monitor offsets, so default screenX/Y to
|
||||
// (0, 0) — matches what real single-display users send.
|
||||
config
|
||||
.entry("window.screenX".to_string())
|
||||
.or_insert(serde_json::json!(0));
|
||||
config
|
||||
.entry("window.screenY".to_string())
|
||||
.or_insert(serde_json::json!(0));
|
||||
(config, target_os)
|
||||
} else {
|
||||
let generator = crate::camoufox::fingerprint::FingerprintGenerator::new()?;
|
||||
let options = FingerprintOptions {
|
||||
operating_system: self.operating_system.clone(),
|
||||
browsers: Some(vec!["firefox".to_string()]),
|
||||
devices: Some(vec!["desktop".to_string()]),
|
||||
screen: self.screen_constraints,
|
||||
..Default::default()
|
||||
};
|
||||
let fingerprint = generator.get_fingerprint(&options)?.fingerprint;
|
||||
let target_os = env_vars::determine_ua_os(&fingerprint.navigator.user_agent);
|
||||
let config = from_browserforge(&fingerprint, ff_version);
|
||||
(config, target_os)
|
||||
};
|
||||
|
||||
// Note: we used to spoof `window.history.length` to a random value in
|
||||
// [1, 5] here. Newer Camoufox builds clamp the docShell session history
|
||||
// to this value, which disables the toolbar back/forward buttons when
|
||||
// the spoof rolls a small number. The fingerprint value drifts on every
|
||||
// user navigation anyway, so a constant spoof is detectable and not
|
||||
// worth the broken navigation UX.
|
||||
|
||||
// Add fonts
|
||||
if !self.custom_fonts_only {
|
||||
let system_fonts = fonts::get_fonts_for_os(target_os);
|
||||
let fonts = if let Some(custom) = &self.custom_fonts {
|
||||
let mut all_fonts = system_fonts;
|
||||
for font in custom {
|
||||
if !all_fonts.contains(font) {
|
||||
all_fonts.push(font.clone());
|
||||
}
|
||||
}
|
||||
all_fonts
|
||||
} else {
|
||||
system_fonts
|
||||
};
|
||||
config.insert("fonts".to_string(), serde_json::json!(fonts));
|
||||
} else if let Some(custom) = &self.custom_fonts {
|
||||
config.insert("fonts".to_string(), serde_json::json!(custom));
|
||||
}
|
||||
|
||||
// Add font spacing seed
|
||||
config.insert(
|
||||
"fonts:spacing_seed".to_string(),
|
||||
serde_json::json!(rng.random_range(0..1_073_741_824u32)),
|
||||
);
|
||||
|
||||
// Build Firefox preferences
|
||||
let mut firefox_prefs = self.firefox_prefs;
|
||||
|
||||
if self.block_images {
|
||||
firefox_prefs.insert(
|
||||
"permissions.default.image".to_string(),
|
||||
serde_json::json!(2),
|
||||
);
|
||||
}
|
||||
|
||||
if self.block_webrtc {
|
||||
firefox_prefs.insert(
|
||||
"media.peerconnection.enabled".to_string(),
|
||||
serde_json::json!(false),
|
||||
);
|
||||
}
|
||||
|
||||
if self.block_webgl {
|
||||
firefox_prefs.insert("webgl.disabled".to_string(), serde_json::json!(true));
|
||||
} else {
|
||||
// Sample and add WebGL configuration
|
||||
match webgl::sample_webgl(target_os, None, None) {
|
||||
Ok(webgl_data) => {
|
||||
for (key, value) in webgl_data.config {
|
||||
config.insert(key, value);
|
||||
}
|
||||
firefox_prefs.insert("webgl.force-enabled".to_string(), serde_json::json!(true));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to sample WebGL config: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Canvas anti-fingerprinting
|
||||
config.insert(
|
||||
"canvas:aaOffset".to_string(),
|
||||
serde_json::json!(rng.random_range(-50..=50)),
|
||||
);
|
||||
config.insert("canvas:aaCapOffset".to_string(), serde_json::json!(true));
|
||||
|
||||
// Add extra config (user-provided)
|
||||
for (key, value) in self.extra_config {
|
||||
config.insert(key, value);
|
||||
}
|
||||
|
||||
// Hardcoded Camoufox settings (cannot be overridden)
|
||||
// Disable theming to prevent fingerprinting via browser theme
|
||||
config.insert("disableTheming".to_string(), serde_json::json!(true));
|
||||
// Hide cursor in headless mode
|
||||
config.insert("showcursor".to_string(), serde_json::json!(false));
|
||||
|
||||
Ok(CamoufoxLaunchConfig {
|
||||
fingerprint_config: config,
|
||||
firefox_prefs,
|
||||
proxy: self.proxy,
|
||||
headless: self.headless,
|
||||
target_os: target_os.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the complete Camoufox launch configuration with async geolocation support.
|
||||
/// This method should be used when geoip option is set to Auto.
|
||||
pub async fn build_async(self) -> Result<CamoufoxLaunchConfig, ConfigError> {
|
||||
// Get full proxy URL (with credentials) for IP detection
|
||||
let proxy_url = self.proxy.as_ref().map(|p| {
|
||||
if let (Some(user), Some(pass)) = (&p.username, &p.password) {
|
||||
// Reconstruct URL with credentials: scheme://user:pass@host:port
|
||||
if let Ok(mut parsed) = url::Url::parse(&p.server) {
|
||||
let _ = parsed.set_username(user);
|
||||
let _ = parsed.set_password(Some(pass));
|
||||
parsed.to_string()
|
||||
} else {
|
||||
p.server.clone()
|
||||
}
|
||||
} else if let Some(user) = &p.username {
|
||||
if let Ok(mut parsed) = url::Url::parse(&p.server) {
|
||||
let _ = parsed.set_username(user);
|
||||
parsed.to_string()
|
||||
} else {
|
||||
p.server.clone()
|
||||
}
|
||||
} else {
|
||||
p.server.clone()
|
||||
}
|
||||
});
|
||||
let geoip_option = self.geoip.clone();
|
||||
let block_webrtc = self.block_webrtc;
|
||||
|
||||
// Build base config first
|
||||
let mut launch_config = self.build()?;
|
||||
|
||||
// Handle geolocation if geoip option is set
|
||||
if let Some(geoip) = geoip_option {
|
||||
let ip = match geoip {
|
||||
GeoIPOption::Auto => {
|
||||
// Fetch public IP, optionally through proxy
|
||||
geolocation::fetch_public_ip(proxy_url.as_deref())
|
||||
.await
|
||||
.map_err(geolocation::GeolocationError::from)?
|
||||
}
|
||||
GeoIPOption::IP(ip_str) => {
|
||||
if !geolocation::validate_ip(&ip_str) {
|
||||
return Err(ConfigError::Geolocation(
|
||||
geolocation::GeolocationError::InvalidIP(ip_str),
|
||||
));
|
||||
}
|
||||
ip_str
|
||||
}
|
||||
};
|
||||
|
||||
// Get geolocation from IP
|
||||
match geolocation::get_geolocation(&ip) {
|
||||
Ok(geo) => {
|
||||
// Add geolocation config
|
||||
for (key, value) in geo.as_config() {
|
||||
launch_config.fingerprint_config.insert(key, value);
|
||||
}
|
||||
|
||||
// Add WebRTC IP spoofing if not blocked
|
||||
if !block_webrtc {
|
||||
if geolocation::is_ipv4(&ip) {
|
||||
launch_config
|
||||
.fingerprint_config
|
||||
.insert("webrtc:ipv4".to_string(), serde_json::json!(ip));
|
||||
} else if geolocation::is_ipv6(&ip) {
|
||||
launch_config
|
||||
.fingerprint_config
|
||||
.insert("webrtc:ipv6".to_string(), serde_json::json!(ip));
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Applied geolocation from IP {}: {} ({})",
|
||||
ip,
|
||||
geo.locale.as_string(),
|
||||
geo.timezone
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to get geolocation for IP {}: {}", ip, e);
|
||||
// Continue without geolocation rather than failing
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(launch_config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Complete Camoufox launch configuration.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CamoufoxLaunchConfig {
|
||||
pub fingerprint_config: HashMap<String, serde_json::Value>,
|
||||
pub firefox_prefs: HashMap<String, serde_json::Value>,
|
||||
pub proxy: Option<ProxyConfig>,
|
||||
pub headless: bool,
|
||||
pub target_os: String,
|
||||
}
|
||||
|
||||
impl CamoufoxLaunchConfig {
|
||||
/// Get environment variables for launching Camoufox.
|
||||
pub fn get_env_vars(&self) -> Result<HashMap<String, String>, serde_json::Error> {
|
||||
env_vars::config_to_env_vars(&self.fingerprint_config)
|
||||
}
|
||||
|
||||
/// Get the config as JSON string.
|
||||
pub fn config_json(&self) -> Result<String, serde_json::Error> {
|
||||
serde_json::to_string(&self.fingerprint_config)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type for configuration operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ConfigError {
|
||||
#[error("Fingerprint generation error: {0}")]
|
||||
Fingerprint(#[from] crate::camoufox::fingerprint::FingerprintError),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("WebGL error: {0}")]
|
||||
WebGL(#[from] webgl::WebGLError),
|
||||
|
||||
#[error("Invalid proxy configuration: {0}")]
|
||||
InvalidProxy(String),
|
||||
|
||||
#[error("Geolocation error: {0}")]
|
||||
Geolocation(#[from] crate::camoufox::geolocation::GeolocationError),
|
||||
}
|
||||
|
||||
/// Get Firefox version from executable path.
|
||||
pub fn get_firefox_version(executable_path: &Path) -> Option<u32> {
|
||||
// Try to read version.json from the same directory
|
||||
let version_path = executable_path.parent()?.join("version.json");
|
||||
|
||||
if let Ok(content) = std::fs::read_to_string(&version_path) {
|
||||
if let Ok(json) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(version_str) = json.get("version").and_then(|v| v.as_str()) {
|
||||
// Parse major version from "135.0" or similar
|
||||
let major: u32 = version_str.split('.').next()?.parse().ok()?;
|
||||
return Some(major);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_config_builder() {
|
||||
let config = CamoufoxConfigBuilder::new()
|
||||
.operating_system("windows")
|
||||
.block_images(true)
|
||||
.build();
|
||||
|
||||
assert!(config.is_ok());
|
||||
let config = config.unwrap();
|
||||
assert!(config
|
||||
.firefox_prefs
|
||||
.contains_key("permissions.default.image"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_replace_ff_version() {
|
||||
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0";
|
||||
let replaced = replace_ff_version(ua, 140);
|
||||
assert!(replaced.contains("140.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_from_browserforge() {
|
||||
let fingerprint = Fingerprint {
|
||||
screen: ScreenFingerprint {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
avail_width: 1920,
|
||||
avail_height: 1040,
|
||||
color_depth: 24,
|
||||
pixel_depth: 24,
|
||||
inner_width: 1903,
|
||||
inner_height: 969,
|
||||
outer_width: 1920,
|
||||
outer_height: 1040,
|
||||
..Default::default()
|
||||
},
|
||||
navigator: NavigatorFingerprint {
|
||||
user_agent: "Mozilla/5.0 Firefox/135.0".to_string(),
|
||||
platform: "Win32".to_string(),
|
||||
language: "en-US".to_string(),
|
||||
languages: vec!["en-US".to_string()],
|
||||
hardware_concurrency: 8,
|
||||
..Default::default()
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let config = from_browserforge(&fingerprint, Some(140));
|
||||
|
||||
assert!(config.contains_key("navigator.userAgent"));
|
||||
assert!(config.contains_key("screen.width"));
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
[
|
||||
"chrome/143.0.0.0|2",
|
||||
"safari/18.3.1|2",
|
||||
"chrome/101.0.4951.54|2",
|
||||
"chrome/139.0.0.0|2",
|
||||
"safari/16.6|2",
|
||||
"safari/26.2|2",
|
||||
"safari/18.6|2",
|
||||
"safari/26.1|2",
|
||||
"chrome/142.0.0.0|2",
|
||||
"chrome/141.0.0.0|2",
|
||||
"safari/18.7.3|2",
|
||||
"edge/143.0.0.0|2",
|
||||
"safari/18.4|2",
|
||||
"safari/17.3.1|2",
|
||||
"chrome/135.0.0.0|2",
|
||||
"safari/18.5|2",
|
||||
"safari/18.7.2|2",
|
||||
"chrome/143.0.0.0|1",
|
||||
"chrome/128.0.0.0|2",
|
||||
"chrome/131.0.0.0|2",
|
||||
"safari/26.3|2",
|
||||
"safari/26.0.1|2",
|
||||
"chrome/114.0.0.0|2",
|
||||
"safari/18.1|2",
|
||||
"firefox/147.0|2",
|
||||
"safari/17.5|2",
|
||||
"chrome/140.0.0.0|2",
|
||||
"safari/16.6.1|2",
|
||||
"firefox/146.0|2",
|
||||
"chrome/124.0.0.0|1",
|
||||
"chrome/34.0.1847.114|2",
|
||||
"chrome/130.0.0.0|2",
|
||||
"safari/15.6.7|2",
|
||||
"chrome/144.0.0.0|2",
|
||||
"safari/18.3|2",
|
||||
"safari/16.4|2",
|
||||
"chrome/141.0.7390.122|1",
|
||||
"firefox/140.0|2",
|
||||
"chrome/138.0.0.0|2",
|
||||
"firefox/135.0|2",
|
||||
"safari/17.6|2",
|
||||
"chrome/132.0.0.0|2",
|
||||
"chrome/109.0.0.0|2",
|
||||
"chrome/92.0.4515.131|2",
|
||||
"chrome/136.0.0.0|2",
|
||||
"edge/142.0.0.0|2",
|
||||
"chrome/125.0.0.0|2",
|
||||
"safari/17.8|2",
|
||||
"edge/143.0.0.0|1",
|
||||
"chrome/123.0.0.0|2",
|
||||
"chrome/137.0.0.0|2",
|
||||
"chrome/129.0.0.0|2",
|
||||
"chrome/126.0.0.0|2",
|
||||
"safari/26.0|2",
|
||||
"chrome/133.0.0.0|2",
|
||||
"chrome/119.0.0.0|2",
|
||||
"chrome/145.0.0.0|2",
|
||||
"firefox/145.0|2",
|
||||
"safari/17.3|2",
|
||||
"safari/18.2|2",
|
||||
"safari/16.5.2|2",
|
||||
"safari/17.4|2",
|
||||
"chrome/120.0.0.0|2",
|
||||
"chrome/116.0.0.0|2",
|
||||
"firefox/141.0|2",
|
||||
"safari/17.4.1|2",
|
||||
"chrome/134.0.0.0|2",
|
||||
"safari/15.4|2",
|
||||
"safari/18.1.1|2",
|
||||
"edge/144.0.0.0|2",
|
||||
"firefox/144.0|2",
|
||||
"safari/16.3|2",
|
||||
"safari/13.0.3|2",
|
||||
"chrome/131.0.6778.33|2",
|
||||
"edge/145.0.0.0|2",
|
||||
"edge/139.0.0.0|2",
|
||||
"safari/17.1|2",
|
||||
"chrome/133.0.0.0|1",
|
||||
"chrome/121.0.0.0|2",
|
||||
"chrome/124.0.0.0|2",
|
||||
"chrome/127.0.0.0|2",
|
||||
"chrome/122.0.6261.95|2",
|
||||
"chrome/91.0.4450.0|2",
|
||||
"edge/134.0.0.0|2",
|
||||
"chrome/134.0.6998.179|2",
|
||||
"chrome/122.0.0.0|2",
|
||||
"firefox/128.0|2",
|
||||
"chrome/142.0.0.0|1",
|
||||
"safari/18.7|2",
|
||||
"safari/17.8.1|2",
|
||||
"firefox/115.0|2",
|
||||
"safari/17.2|2",
|
||||
"chrome/117.0.0.0|2",
|
||||
"safari/18.0.1|2",
|
||||
"chrome/139.0.7258.5|2",
|
||||
"edge/140.0.0.0|2",
|
||||
"safari/16.5|2",
|
||||
"safari/18.6.2|2",
|
||||
"firefox/136.0|2",
|
||||
"safari/17.2.1|2",
|
||||
"safari/18.0|2",
|
||||
"safari/15.6.1|2",
|
||||
"safari/26.2|1",
|
||||
"safari/17.1.2|2",
|
||||
"safari/17.7|2",
|
||||
"safari/16.2|2",
|
||||
"edge/122.0.0.0|2",
|
||||
"chrome/139.0.0.0|1",
|
||||
"safari/17.0|2",
|
||||
"firefox/139.0|2",
|
||||
"chrome/101.0.9316.173|2",
|
||||
"chrome/101.0.4951.64|2",
|
||||
"chrome/141.0.0.0|1",
|
||||
"safari/15.5|2",
|
||||
"safari/18.6|1",
|
||||
"chrome/112.0.0.0|2",
|
||||
"edge/135.0.0.0|2",
|
||||
"chrome/140.0.0.0|1"
|
||||
]
|
||||
@@ -1,68 +0,0 @@
|
||||
# Mappings of Browserforge fingerprints to Camoufox config properties.
|
||||
|
||||
navigator:
|
||||
# Note: Browserforge tends to have outdated UAs.
|
||||
# The version will be replaced in Camoufox.
|
||||
userAgent: navigator.userAgent
|
||||
# userAgentData not in Firefox
|
||||
doNotTrack: navigator.doNotTrack
|
||||
appCodeName: navigator.appCodeName
|
||||
appName: navigator.appName
|
||||
appVersion: navigator.appVersion
|
||||
oscpu: navigator.oscpu
|
||||
# webdriver is always True
|
||||
# Locale is now implemented separately:
|
||||
# language: navigator.language
|
||||
# languages: navigator.languages
|
||||
platform: navigator.platform
|
||||
# deviceMemory not in Firefox
|
||||
hardwareConcurrency: navigator.hardwareConcurrency
|
||||
product: navigator.product
|
||||
# Never override productSub #105
|
||||
# productSub: navigator.productSub
|
||||
# vendor is not necessary
|
||||
# vendorSub is not necessary
|
||||
maxTouchPoints: navigator.maxTouchPoints
|
||||
extraProperties:
|
||||
# Note: Changing pdfViewerEnabled is not recommended. This will be kept to True.
|
||||
globalPrivacyControl: navigator.globalPrivacyControl
|
||||
|
||||
screen:
|
||||
# hasHDR is not implemented in Camoufox
|
||||
availLeft: screen.availLeft
|
||||
availTop: screen.availTop
|
||||
availWidth: screen.availWidth
|
||||
availHeight: screen.availHeight
|
||||
height: screen.height
|
||||
width: screen.width
|
||||
colorDepth: screen.colorDepth
|
||||
pixelDepth: screen.pixelDepth
|
||||
# devicePixelRatio is not recommended. Any value other than 1.0 is suspicious.
|
||||
pageXOffset: screen.pageXOffset
|
||||
pageYOffset: screen.pageYOffset
|
||||
outerHeight: window.outerHeight
|
||||
outerWidth: window.outerWidth
|
||||
innerHeight: window.innerHeight
|
||||
innerWidth: window.innerWidth
|
||||
screenX: window.screenX
|
||||
screenY: window.screenY
|
||||
# Tends to generate out of bounds (network inconsistencies):
|
||||
# clientWidth: document.body.clientWidth
|
||||
# clientHeight: document.body.clientHeight
|
||||
|
||||
# videoCard:
|
||||
# renderer: webgl:renderer
|
||||
# vendor: webgl:vendor
|
||||
|
||||
headers:
|
||||
# headers.User-Agent is redundant with navigator.userAgent
|
||||
# headers.Accept-Language is redundant with locale:*
|
||||
Accept-Encoding: headers.Accept-Encoding
|
||||
|
||||
battery:
|
||||
charging: battery:charging
|
||||
chargingTime: battery:chargingTime
|
||||
dischargingTime: battery:dischargingTime
|
||||
|
||||
# Unsupported: videoCodecs, audioCodecs, pluginsData, multimediaDevices
|
||||
# Fonts are listed through the launcher.
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,822 +0,0 @@
|
||||
{
|
||||
"win": [
|
||||
"Arial",
|
||||
"Arial Black",
|
||||
"Bahnschrift",
|
||||
"Calibri",
|
||||
"Calibri Light",
|
||||
"Cambria",
|
||||
"Cambria Math",
|
||||
"Candara",
|
||||
"Candara Light",
|
||||
"Comic Sans MS",
|
||||
"Consolas",
|
||||
"Constantia",
|
||||
"Corbel",
|
||||
"Corbel Light",
|
||||
"Courier New",
|
||||
"Ebrima",
|
||||
"Franklin Gothic Medium",
|
||||
"Gabriola",
|
||||
"Gadugi",
|
||||
"Georgia",
|
||||
"HoloLens MDL2 Assets",
|
||||
"Impact",
|
||||
"Ink Free",
|
||||
"Javanese Text",
|
||||
"Leelawadee UI",
|
||||
"Leelawadee UI Semilight",
|
||||
"Lucida Console",
|
||||
"Lucida Sans Unicode",
|
||||
"MS Gothic",
|
||||
"MS PGothic",
|
||||
"MS UI Gothic",
|
||||
"MV Boli",
|
||||
"Malgun Gothic",
|
||||
"Malgun Gothic Semilight",
|
||||
"Marlett",
|
||||
"Microsoft Himalaya",
|
||||
"Microsoft JhengHei",
|
||||
"Microsoft JhengHei Light",
|
||||
"Microsoft JhengHei UI",
|
||||
"Microsoft JhengHei UI Light",
|
||||
"Microsoft New Tai Lue",
|
||||
"Microsoft PhagsPa",
|
||||
"Microsoft Sans Serif",
|
||||
"Microsoft Tai Le",
|
||||
"Microsoft YaHei",
|
||||
"Microsoft YaHei Light",
|
||||
"Microsoft YaHei UI",
|
||||
"Microsoft YaHei UI Light",
|
||||
"Microsoft Yi Baiti",
|
||||
"MingLiU-ExtB",
|
||||
"MingLiU_HKSCS-ExtB",
|
||||
"Mongolian Baiti",
|
||||
"Myanmar Text",
|
||||
"NSimSun",
|
||||
"Nirmala UI",
|
||||
"Nirmala UI Semilight",
|
||||
"PMingLiU-ExtB",
|
||||
"Palatino Linotype",
|
||||
"Segoe Fluent Icons",
|
||||
"Segoe MDL2 Assets",
|
||||
"Segoe Print",
|
||||
"Segoe Script",
|
||||
"Segoe UI",
|
||||
"Segoe UI Black",
|
||||
"Segoe UI Emoji",
|
||||
"Segoe UI Historic",
|
||||
"Segoe UI Light",
|
||||
"Segoe UI Semibold",
|
||||
"Segoe UI Semilight",
|
||||
"Segoe UI Symbol",
|
||||
"Segoe UI Variable",
|
||||
"SimSun",
|
||||
"SimSun-ExtB",
|
||||
"Sitka",
|
||||
"Sitka Text",
|
||||
"Sylfaen",
|
||||
"Symbol",
|
||||
"Tahoma",
|
||||
"Times New Roman",
|
||||
"Trebuchet MS",
|
||||
"Twemoji Mozilla",
|
||||
"Verdana",
|
||||
"Webdings",
|
||||
"Wingdings",
|
||||
"Yu Gothic",
|
||||
"Yu Gothic Light",
|
||||
"Yu Gothic Medium",
|
||||
"Yu Gothic UI",
|
||||
"Yu Gothic UI Light",
|
||||
"Yu Gothic UI Semibold",
|
||||
"Yu Gothic UI Semilight",
|
||||
"\u5b8b\u4f53",
|
||||
"\u5fae\u8edf\u6b63\u9ed1\u9ad4",
|
||||
"\u5fae\u8edf\u6b63\u9ed1\u9ad4 Light",
|
||||
"\u5fae\u8f6f\u96c5\u9ed1",
|
||||
"\u5fae\u8f6f\u96c5\u9ed1 Light",
|
||||
"\u65b0\u5b8b\u4f53",
|
||||
"\u65b0\u7d30\u660e\u9ad4-ExtB",
|
||||
"\u6e38\u30b4\u30b7\u30c3\u30af",
|
||||
"\u6e38\u30b4\u30b7\u30c3\u30af Light",
|
||||
"\u6e38\u30b4\u30b7\u30c3\u30af Medium",
|
||||
"\u7d30\u660e\u9ad4-ExtB",
|
||||
"\u7d30\u660e\u9ad4_HKSCS-ExtB",
|
||||
"\ub9d1\uc740 \uace0\ub515",
|
||||
"\ub9d1\uc740 \uace0\ub515 Semilight",
|
||||
"\uff2d\uff33 \u30b4\u30b7\u30c3\u30af",
|
||||
"\uff2d\uff33 \uff30\u30b4\u30b7\u30c3\u30af"
|
||||
],
|
||||
"mac": [
|
||||
".Al Bayan PUA",
|
||||
".Al Nile PUA",
|
||||
".Al Tarikh PUA",
|
||||
".Apple Color Emoji UI",
|
||||
".Apple SD Gothic NeoI",
|
||||
".Aqua Kana",
|
||||
".Aqua Kana Bold",
|
||||
".Aqua \u304b\u306a",
|
||||
".Aqua \u304b\u306a \u30dc\u30fc\u30eb\u30c9",
|
||||
".Arial Hebrew Desk Interface",
|
||||
".Baghdad PUA",
|
||||
".Beirut PUA",
|
||||
".Damascus PUA",
|
||||
".DecoType Naskh PUA",
|
||||
".Diwan Kufi PUA",
|
||||
".Farah PUA",
|
||||
".Geeza Pro Interface",
|
||||
".Geeza Pro PUA",
|
||||
".Helvetica LT MM",
|
||||
".Hiragino Kaku Gothic Interface",
|
||||
".Hiragino Sans GB Interface",
|
||||
".Keyboard",
|
||||
".KufiStandardGK PUA",
|
||||
".LastResort",
|
||||
".Lucida Grande UI",
|
||||
".Muna PUA",
|
||||
".Nadeem PUA",
|
||||
".New York",
|
||||
".Noto Nastaliq Urdu UI",
|
||||
".PingFang HK",
|
||||
".PingFang SC",
|
||||
".PingFang TC",
|
||||
".SF Arabic",
|
||||
".SF Arabic Rounded",
|
||||
".SF Compact",
|
||||
".SF Compact Rounded",
|
||||
".SF NS",
|
||||
".SF NS Mono",
|
||||
".SF NS Rounded",
|
||||
".Sana PUA",
|
||||
".Savoye LET CC.",
|
||||
".ThonburiUI",
|
||||
".ThonburiUIWatch",
|
||||
".\u82f9\u65b9-\u6e2f",
|
||||
".\u82f9\u65b9-\u7b80",
|
||||
".\u82f9\u65b9-\u7e41",
|
||||
".\u860b\u65b9-\u6e2f",
|
||||
".\u860b\u65b9-\u7c21",
|
||||
".\u860b\u65b9-\u7e41",
|
||||
"Academy Engraved LET",
|
||||
"Al Bayan",
|
||||
"Al Nile",
|
||||
"Al Tarikh",
|
||||
"American Typewriter",
|
||||
"Andale Mono",
|
||||
"Apple Braille",
|
||||
"Apple Chancery",
|
||||
"Apple Color Emoji",
|
||||
"Apple SD Gothic Neo",
|
||||
"Apple SD \uc0b0\ub3cc\uace0\ub515 Neo",
|
||||
"Apple Symbols",
|
||||
"AppleGothic",
|
||||
"AppleMyungjo",
|
||||
"Arial",
|
||||
"Arial Black",
|
||||
"Arial Hebrew",
|
||||
"Arial Hebrew Scholar",
|
||||
"Arial Narrow",
|
||||
"Arial Rounded MT Bold",
|
||||
"Arial Unicode MS",
|
||||
"Athelas",
|
||||
"Avenir",
|
||||
"Avenir Black",
|
||||
"Avenir Black Oblique",
|
||||
"Avenir Book",
|
||||
"Avenir Heavy",
|
||||
"Avenir Light",
|
||||
"Avenir Medium",
|
||||
"Avenir Next",
|
||||
"Avenir Next Condensed",
|
||||
"Avenir Next Condensed Demi Bold",
|
||||
"Avenir Next Condensed Heavy",
|
||||
"Avenir Next Condensed Medium",
|
||||
"Avenir Next Condensed Ultra Light",
|
||||
"Avenir Next Demi Bold",
|
||||
"Avenir Next Heavy",
|
||||
"Avenir Next Medium",
|
||||
"Avenir Next Ultra Light",
|
||||
"Ayuthaya",
|
||||
"Baghdad",
|
||||
"Bangla MN",
|
||||
"Bangla Sangam MN",
|
||||
"Baskerville",
|
||||
"Beirut",
|
||||
"Big Caslon",
|
||||
"Bodoni 72",
|
||||
"Bodoni 72 Oldstyle",
|
||||
"Bodoni 72 Smallcaps",
|
||||
"Bodoni Ornaments",
|
||||
"Bradley Hand",
|
||||
"Brush Script MT",
|
||||
"Chalkboard",
|
||||
"Chalkboard SE",
|
||||
"Chalkduster",
|
||||
"Charter",
|
||||
"Charter Black",
|
||||
"Cochin",
|
||||
"Comic Sans MS",
|
||||
"Copperplate",
|
||||
"Corsiva Hebrew",
|
||||
"Courier",
|
||||
"Courier New",
|
||||
"Czcionka systemowa",
|
||||
"DIN Alternate",
|
||||
"DIN Condensed",
|
||||
"Damascus",
|
||||
"DecoType Naskh",
|
||||
"Devanagari MT",
|
||||
"Devanagari Sangam MN",
|
||||
"Didot",
|
||||
"Diwan Kufi",
|
||||
"Diwan Thuluth",
|
||||
"Euphemia UCAS",
|
||||
"Farah",
|
||||
"Farisi",
|
||||
"Font Sistem",
|
||||
"Font de sistem",
|
||||
"Font di sistema",
|
||||
"Font sustava",
|
||||
"Fonte do Sistema",
|
||||
"Futura",
|
||||
"GB18030 Bitmap",
|
||||
"Galvji",
|
||||
"Geeza Pro",
|
||||
"Geneva",
|
||||
"Georgia",
|
||||
"Gill Sans",
|
||||
"Grantha Sangam MN",
|
||||
"Gujarati MT",
|
||||
"Gujarati Sangam MN",
|
||||
"Gurmukhi MN",
|
||||
"Gurmukhi MT",
|
||||
"Gurmukhi Sangam MN",
|
||||
"Heiti SC",
|
||||
"Heiti TC",
|
||||
"Heiti-\uac04\uccb4",
|
||||
"Heiti-\ubc88\uccb4",
|
||||
"Helvetica",
|
||||
"Helvetica Neue",
|
||||
"Herculanum",
|
||||
"Hiragino Kaku Gothic Pro",
|
||||
"Hiragino Kaku Gothic Pro W3",
|
||||
"Hiragino Kaku Gothic Pro W6",
|
||||
"Hiragino Kaku Gothic ProN",
|
||||
"Hiragino Kaku Gothic ProN W3",
|
||||
"Hiragino Kaku Gothic ProN W6",
|
||||
"Hiragino Kaku Gothic Std",
|
||||
"Hiragino Kaku Gothic Std W8",
|
||||
"Hiragino Kaku Gothic StdN",
|
||||
"Hiragino Kaku Gothic StdN W8",
|
||||
"Hiragino Maru Gothic Pro",
|
||||
"Hiragino Maru Gothic Pro W4",
|
||||
"Hiragino Maru Gothic ProN",
|
||||
"Hiragino Maru Gothic ProN W4",
|
||||
"Hiragino Mincho Pro",
|
||||
"Hiragino Mincho Pro W3",
|
||||
"Hiragino Mincho Pro W6",
|
||||
"Hiragino Mincho ProN",
|
||||
"Hiragino Mincho ProN W3",
|
||||
"Hiragino Mincho ProN W6",
|
||||
"Hiragino Sans",
|
||||
"Hiragino Sans GB",
|
||||
"Hiragino Sans GB W3",
|
||||
"Hiragino Sans GB W6",
|
||||
"Hiragino Sans W0",
|
||||
"Hiragino Sans W1",
|
||||
"Hiragino Sans W2",
|
||||
"Hiragino Sans W3",
|
||||
"Hiragino Sans W4",
|
||||
"Hiragino Sans W5",
|
||||
"Hiragino Sans W6",
|
||||
"Hiragino Sans W7",
|
||||
"Hiragino Sans W8",
|
||||
"Hiragino Sans W9",
|
||||
"Hoefler Text",
|
||||
"Hoefler Text Ornaments",
|
||||
"ITF Devanagari",
|
||||
"ITF Devanagari Marathi",
|
||||
"Impact",
|
||||
"InaiMathi",
|
||||
"Iowan Old Style",
|
||||
"Iowan Old Style Black",
|
||||
"J\u00e4rjestelm\u00e4fontti",
|
||||
"Kailasa",
|
||||
"Kannada MN",
|
||||
"Kannada Sangam MN",
|
||||
"Kefa",
|
||||
"Khmer MN",
|
||||
"Khmer Sangam MN",
|
||||
"Kohinoor Bangla",
|
||||
"Kohinoor Devanagari",
|
||||
"Kohinoor Gujarati",
|
||||
"Kohinoor Telugu",
|
||||
"Kokonor",
|
||||
"Krungthep",
|
||||
"KufiStandardGK",
|
||||
"Lao MN",
|
||||
"Lao Sangam MN",
|
||||
"Lucida Grande",
|
||||
"Luminari",
|
||||
"Malayalam MN",
|
||||
"Malayalam Sangam MN",
|
||||
"Marion",
|
||||
"Marker Felt",
|
||||
"Menlo",
|
||||
"Microsoft Sans Serif",
|
||||
"Mishafi",
|
||||
"Mishafi Gold",
|
||||
"Monaco",
|
||||
"Mshtakan",
|
||||
"Mukta Mahee",
|
||||
"MuktaMahee Bold",
|
||||
"MuktaMahee ExtraBold",
|
||||
"MuktaMahee ExtraLight",
|
||||
"MuktaMahee Light",
|
||||
"MuktaMahee Medium",
|
||||
"MuktaMahee Regular",
|
||||
"MuktaMahee SemiBold",
|
||||
"Muna",
|
||||
"Myanmar MN",
|
||||
"Myanmar Sangam MN",
|
||||
"Nadeem",
|
||||
"New Peninim MT",
|
||||
"Noteworthy",
|
||||
"Noto Nastaliq Urdu",
|
||||
"Noto Sans Adlam",
|
||||
"Noto Sans Armenian",
|
||||
"Noto Sans Armenian Blk",
|
||||
"Noto Sans Armenian ExtBd",
|
||||
"Noto Sans Armenian ExtLt",
|
||||
"Noto Sans Armenian Light",
|
||||
"Noto Sans Armenian Med",
|
||||
"Noto Sans Armenian SemBd",
|
||||
"Noto Sans Armenian Thin",
|
||||
"Noto Sans Avestan",
|
||||
"Noto Sans Bamum",
|
||||
"Noto Sans Bassa Vah",
|
||||
"Noto Sans Batak",
|
||||
"Noto Sans Bhaiksuki",
|
||||
"Noto Sans Brahmi",
|
||||
"Noto Sans Buginese",
|
||||
"Noto Sans Buhid",
|
||||
"Noto Sans CanAborig",
|
||||
"Noto Sans Canadian Aboriginal",
|
||||
"Noto Sans Carian",
|
||||
"Noto Sans CaucAlban",
|
||||
"Noto Sans Caucasian Albanian",
|
||||
"Noto Sans Chakma",
|
||||
"Noto Sans Cham",
|
||||
"Noto Sans Coptic",
|
||||
"Noto Sans Cuneiform",
|
||||
"Noto Sans Cypriot",
|
||||
"Noto Sans Duployan",
|
||||
"Noto Sans EgyptHiero",
|
||||
"Noto Sans Egyptian Hieroglyphs",
|
||||
"Noto Sans Elbasan",
|
||||
"Noto Sans Glagolitic",
|
||||
"Noto Sans Gothic",
|
||||
"Noto Sans Gunjala Gondi",
|
||||
"Noto Sans Hanifi Rohingya",
|
||||
"Noto Sans HanifiRohg",
|
||||
"Noto Sans Hanunoo",
|
||||
"Noto Sans Hatran",
|
||||
"Noto Sans ImpAramaic",
|
||||
"Noto Sans Imperial Aramaic",
|
||||
"Noto Sans InsPahlavi",
|
||||
"Noto Sans InsParthi",
|
||||
"Noto Sans Inscriptional Pahlavi",
|
||||
"Noto Sans Inscriptional Parthian",
|
||||
"Noto Sans Javanese",
|
||||
"Noto Sans Kaithi",
|
||||
"Noto Sans Kannada",
|
||||
"Noto Sans Kannada Black",
|
||||
"Noto Sans Kannada ExtraBold",
|
||||
"Noto Sans Kannada ExtraLight",
|
||||
"Noto Sans Kannada Light",
|
||||
"Noto Sans Kannada Medium",
|
||||
"Noto Sans Kannada SemiBold",
|
||||
"Noto Sans Kannada Thin",
|
||||
"Noto Sans Kayah Li",
|
||||
"Noto Sans Kharoshthi",
|
||||
"Noto Sans Khojki",
|
||||
"Noto Sans Khudawadi",
|
||||
"Noto Sans Lepcha",
|
||||
"Noto Sans Limbu",
|
||||
"Noto Sans Linear A",
|
||||
"Noto Sans Linear B",
|
||||
"Noto Sans Lisu",
|
||||
"Noto Sans Lycian",
|
||||
"Noto Sans Lydian",
|
||||
"Noto Sans Mahajani",
|
||||
"Noto Sans Mandaic",
|
||||
"Noto Sans Manichaean",
|
||||
"Noto Sans Marchen",
|
||||
"Noto Sans Masaram Gondi",
|
||||
"Noto Sans Meetei Mayek",
|
||||
"Noto Sans Mende Kikakui",
|
||||
"Noto Sans Meroitic",
|
||||
"Noto Sans Miao",
|
||||
"Noto Sans Modi",
|
||||
"Noto Sans Mongolian",
|
||||
"Noto Sans Mro",
|
||||
"Noto Sans Multani",
|
||||
"Noto Sans Myanmar",
|
||||
"Noto Sans Myanmar Blk",
|
||||
"Noto Sans Myanmar ExtBd",
|
||||
"Noto Sans Myanmar ExtLt",
|
||||
"Noto Sans Myanmar Light",
|
||||
"Noto Sans Myanmar Med",
|
||||
"Noto Sans Myanmar SemBd",
|
||||
"Noto Sans Myanmar Thin",
|
||||
"Noto Sans NKo",
|
||||
"Noto Sans Nabataean",
|
||||
"Noto Sans New Tai Lue",
|
||||
"Noto Sans Newa",
|
||||
"Noto Sans Ol Chiki",
|
||||
"Noto Sans Old Hungarian",
|
||||
"Noto Sans Old Italic",
|
||||
"Noto Sans Old North Arabian",
|
||||
"Noto Sans Old Permic",
|
||||
"Noto Sans Old Persian",
|
||||
"Noto Sans Old South Arabian",
|
||||
"Noto Sans Old Turkic",
|
||||
"Noto Sans OldHung",
|
||||
"Noto Sans OldNorArab",
|
||||
"Noto Sans OldSouArab",
|
||||
"Noto Sans Oriya",
|
||||
"Noto Sans Osage",
|
||||
"Noto Sans Osmanya",
|
||||
"Noto Sans Pahawh Hmong",
|
||||
"Noto Sans Palmyrene",
|
||||
"Noto Sans Pau Cin Hau",
|
||||
"Noto Sans PhagsPa",
|
||||
"Noto Sans Phoenician",
|
||||
"Noto Sans PsaPahlavi",
|
||||
"Noto Sans Psalter Pahlavi",
|
||||
"Noto Sans Rejang",
|
||||
"Noto Sans Samaritan",
|
||||
"Noto Sans Saurashtra",
|
||||
"Noto Sans Sharada",
|
||||
"Noto Sans Siddham",
|
||||
"Noto Sans Sora Sompeng",
|
||||
"Noto Sans SoraSomp",
|
||||
"Noto Sans Sundanese",
|
||||
"Noto Sans Syloti Nagri",
|
||||
"Noto Sans Syriac",
|
||||
"Noto Sans Tagalog",
|
||||
"Noto Sans Tagbanwa",
|
||||
"Noto Sans Tai Le",
|
||||
"Noto Sans Tai Tham",
|
||||
"Noto Sans Tai Viet",
|
||||
"Noto Sans Takri",
|
||||
"Noto Sans Thaana",
|
||||
"Noto Sans Tifinagh",
|
||||
"Noto Sans Tirhuta",
|
||||
"Noto Sans Ugaritic",
|
||||
"Noto Sans Vai",
|
||||
"Noto Sans Wancho",
|
||||
"Noto Sans Warang Citi",
|
||||
"Noto Sans Yi",
|
||||
"Noto Sans Zawgyi",
|
||||
"Noto Sans Zawgyi Blk",
|
||||
"Noto Sans Zawgyi ExtBd",
|
||||
"Noto Sans Zawgyi ExtLt",
|
||||
"Noto Sans Zawgyi Light",
|
||||
"Noto Sans Zawgyi Med",
|
||||
"Noto Sans Zawgyi SemBd",
|
||||
"Noto Sans Zawgyi Thin",
|
||||
"Noto Serif Ahom",
|
||||
"Noto Serif Balinese",
|
||||
"Noto Serif Hmong Nyiakeng",
|
||||
"Noto Serif Myanmar",
|
||||
"Noto Serif Myanmar Blk",
|
||||
"Noto Serif Myanmar ExtBd",
|
||||
"Noto Serif Myanmar ExtLt",
|
||||
"Noto Serif Myanmar Light",
|
||||
"Noto Serif Myanmar Med",
|
||||
"Noto Serif Myanmar SemBd",
|
||||
"Noto Serif Myanmar Thin",
|
||||
"Noto Serif Yezidi",
|
||||
"Optima",
|
||||
"Oriya MN",
|
||||
"Oriya Sangam MN",
|
||||
"PT Mono",
|
||||
"PT Sans",
|
||||
"PT Sans Caption",
|
||||
"PT Sans Narrow",
|
||||
"PT Serif",
|
||||
"PT Serif Caption",
|
||||
"Palatino",
|
||||
"Papyrus",
|
||||
"Party LET",
|
||||
"Phosphate",
|
||||
"Ph\u00f4ng ch\u1eef H\u1ec7 th\u1ed1ng",
|
||||
"PingFang HK",
|
||||
"PingFang SC",
|
||||
"PingFang TC",
|
||||
"Plantagenet Cherokee",
|
||||
"Police syst\u00e8me",
|
||||
"Raanana",
|
||||
"Rendszerbet\u0171t\u00edpus",
|
||||
"Rockwell",
|
||||
"STIX Two Math",
|
||||
"STIX Two Text",
|
||||
"STIXGeneral",
|
||||
"STIXIntegralsD",
|
||||
"STIXIntegralsSm",
|
||||
"STIXIntegralsUp",
|
||||
"STIXIntegralsUpD",
|
||||
"STIXIntegralsUpSm",
|
||||
"STIXNonUnicode",
|
||||
"STIXSizeFiveSym",
|
||||
"STIXSizeFourSym",
|
||||
"STIXSizeOneSym",
|
||||
"STIXSizeThreeSym",
|
||||
"STIXSizeTwoSym",
|
||||
"STIXVariants",
|
||||
"STSong",
|
||||
"Sana",
|
||||
"Sathu",
|
||||
"Savoye LET",
|
||||
"Seravek",
|
||||
"Seravek ExtraLight",
|
||||
"Seravek Light",
|
||||
"Seravek Medium",
|
||||
"Shree Devanagari 714",
|
||||
"SignPainter",
|
||||
"SignPainter-HouseScript",
|
||||
"Silom",
|
||||
"Sinhala MN",
|
||||
"Sinhala Sangam MN",
|
||||
"Sistem Fontu",
|
||||
"Skia",
|
||||
"Snell Roundhand",
|
||||
"Songti SC",
|
||||
"Songti TC",
|
||||
"Sukhumvit Set",
|
||||
"Superclarendon",
|
||||
"Symbol",
|
||||
"Systeemlettertype",
|
||||
"System Font",
|
||||
"Systemschrift",
|
||||
"Systemskrift",
|
||||
"Systemtypsnitt",
|
||||
"Syst\u00e9mov\u00e9 p\u00edsmo",
|
||||
"Tahoma",
|
||||
"Tamil MN",
|
||||
"Tamil Sangam MN",
|
||||
"Telugu MN",
|
||||
"Telugu Sangam MN",
|
||||
"Thonburi",
|
||||
"Times",
|
||||
"Times New Roman",
|
||||
"Tipo de letra del sistema",
|
||||
"Tipo de letra do sistema",
|
||||
"Tipus de lletra del sistema",
|
||||
"Trattatello",
|
||||
"Trebuchet MS",
|
||||
"Verdana",
|
||||
"Waseem",
|
||||
"Webdings",
|
||||
"Wingdings",
|
||||
"Wingdings 2",
|
||||
"Wingdings 3",
|
||||
"Zapf Dingbats",
|
||||
"Zapfino",
|
||||
"\u0393\u03c1\u03b1\u03bc\u03bc\u03b1\u03c4\u03bf\u03c3\u03b5\u03b9\u03c1\u03ac \u03c3\u03c5\u03c3\u03c4\u03ae\u03bc\u03b1\u03c4\u03bf\u03c2",
|
||||
"\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u0438\u0439 \u0448\u0440\u0438\u0444\u0442",
|
||||
"\u0421\u0438\u0441\u0442\u0435\u043c\u043d\u044b\u0439 \u0448\u0440\u0438\u0444\u0442",
|
||||
"\u05d2\u05d5\u05e4\u05df \u05de\u05e2\u05e8\u05db\u05ea",
|
||||
"\u0627\u0644\u0628\u064a\u0627\u0646",
|
||||
"\u0627\u0644\u062a\u0627\u0631\u064a\u062e",
|
||||
"\u0627\u0644\u0646\u064a\u0644",
|
||||
"\u0628\u063a\u062f\u0627\u062f",
|
||||
"\u0628\u064a\u0631\u0648\u062a",
|
||||
"\u062c\u064a\u0632\u0629",
|
||||
"\u062e\u0637 \u0627\u0644\u0646\u0638\u0627\u0645",
|
||||
"\u062f\u0645\u0634\u0642",
|
||||
"\u062f\u064a\u0648\u0627\u0646 \u062b\u0644\u062b",
|
||||
"\u062f\u064a\u0648\u0627\u0646 \u0643\u0648\u0641\u064a",
|
||||
"\u0635\u0646\u0639\u0627\u0621",
|
||||
"\u0641\u0627\u0631\u0633\u064a",
|
||||
"\u0641\u0631\u062d",
|
||||
"\u0643\u0648\u0641\u064a",
|
||||
"\u0645\u0646\u0649",
|
||||
"\u0645\u0650\u0635\u062d\u0641\u064a",
|
||||
"\u0645\u0650\u0635\u062d\u0641\u064a \u0630\u0647\u0628\u064a",
|
||||
"\u0646\u062f\u064a\u0645",
|
||||
"\u0646\u0633\u062e",
|
||||
"\u0648\u0633\u064a\u0645",
|
||||
"\u0906\u0908\u0970\u091f\u0940\u0970\u090f\u092b\u093c\u0970 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940",
|
||||
"\u0906\u0908\u0970\u091f\u0940\u0970\u090f\u092b\u093c\u0970 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u092e\u0930\u093e\u0920\u0940",
|
||||
"\u0915\u094b\u0939\u093f\u0928\u0942\u0930 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940",
|
||||
"\u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u090f\u092e\u0970\u091f\u0940\u0970",
|
||||
"\u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u0938\u0902\u0917\u092e \u090f\u092e\u0970\u090f\u0928\u0970",
|
||||
"\u0936\u094d\u0930\u0940 \u0926\u0947\u0935\u0928\u093e\u0917\u0930\u0940 \u096d\u0967\u096a",
|
||||
"\u0e41\u0e1a\u0e1a\u0e2d\u0e31\u0e01\u0e29\u0e23\u0e23\u0e30\u0e1a\u0e1a",
|
||||
"\u2e41\u7175\u6120\u82a9\u82c8",
|
||||
"\u30b7\u30b9\u30c6\u30e0\u30d5\u30a9\u30f3\u30c8",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 Pro",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 Pro W4",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 ProN",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u4e38\u30b4 ProN W4",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d Pro",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d Pro W3",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d Pro W6",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d ProN",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d ProN W3",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u660e\u671d ProN W6",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Pro",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Pro W3",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Pro W6",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 ProN",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 ProN W3",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 ProN W6",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Std",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 Std W8",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 StdN",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 StdN W8",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 \u7c21\u4f53\u4e2d\u6587",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 \u7c21\u4f53\u4e2d\u6587 W3",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4 \u7c21\u4f53\u4e2d\u6587 W6",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W0",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W1",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W2",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W3",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W4",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W5",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W6",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W7",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W8",
|
||||
"\u30d2\u30e9\u30ae\u30ce\u89d2\u30b4\u30b7\u30c3\u30af W9",
|
||||
"\u51ac\u9752\u9ed1\u4f53\u7b80\u4f53\u4e2d\u6587",
|
||||
"\u51ac\u9752\u9ed1\u4f53\u7b80\u4f53\u4e2d\u6587 W3",
|
||||
"\u51ac\u9752\u9ed1\u4f53\u7b80\u4f53\u4e2d\u6587 W6",
|
||||
"\u51ac\u9752\u9ed1\u9ad4\u7c21\u9ad4\u4e2d\u6587",
|
||||
"\u51ac\u9752\u9ed1\u9ad4\u7c21\u9ad4\u4e2d\u6587 W3",
|
||||
"\u51ac\u9752\u9ed1\u9ad4\u7c21\u9ad4\u4e2d\u6587 W6",
|
||||
"\u5b8b\u4f53-\u7b80",
|
||||
"\u5b8b\u4f53-\u7e41",
|
||||
"\u5b8b\u9ad4-\u7c21",
|
||||
"\u5b8b\u9ad4-\u7e41",
|
||||
"\u7cfb\u7d71\u5b57\u9ad4",
|
||||
"\u7cfb\u7edf\u5b57\u4f53",
|
||||
"\u82f9\u65b9-\u6e2f",
|
||||
"\u82f9\u65b9-\u7b80",
|
||||
"\u82f9\u65b9-\u7e41",
|
||||
"\u8371\u8389\u834d\u836d\u8a70\u8353\u2050\u726f",
|
||||
"\u8371\u8389\u834d\u836d\u8a70\u8353\u2053\u7464",
|
||||
"\u8371\u8389\u834d\u836d\u8a70\u8353\u8356\u8362\u834e",
|
||||
"\u8371\u8389\u834d\u836d\u8adb\u8353\u2050\u726f",
|
||||
"\u8371\u8389\u834d\u836d\u96be\u92a9\u2050\u726f",
|
||||
"\u860b\u65b9-\u6e2f",
|
||||
"\u860b\u65b9-\u7c21",
|
||||
"\u860b\u65b9-\u7e41",
|
||||
"\u9ed1\u4f53-\u7b80",
|
||||
"\u9ed1\u4f53-\u7e41",
|
||||
"\u9ed1\u9ad4-\u7c21",
|
||||
"\u9ed1\u9ad4-\u7e41",
|
||||
"\u9ed2\u4f53-\u7c21",
|
||||
"\u9ed2\u4f53-\u7e41",
|
||||
"\uc2dc\uc2a4\ud15c \uc11c\uccb4"
|
||||
],
|
||||
"lin": [
|
||||
"Arimo",
|
||||
"Cousine",
|
||||
"Noto Naskh Arabic",
|
||||
"Noto Sans Adlam",
|
||||
"Noto Sans Armenian",
|
||||
"Noto Sans Balinese",
|
||||
"Noto Sans Bamum",
|
||||
"Noto Sans Bassa Vah",
|
||||
"Noto Sans Batak",
|
||||
"Noto Sans Bengali",
|
||||
"Noto Sans Buginese",
|
||||
"Noto Sans Buhid",
|
||||
"Noto Sans Canadian Aboriginal",
|
||||
"Noto Sans Chakma",
|
||||
"Noto Sans Cham",
|
||||
"Noto Sans Cherokee",
|
||||
"Noto Sans Coptic",
|
||||
"Noto Sans Deseret",
|
||||
"Noto Sans Devanagari",
|
||||
"Noto Sans Elbasan",
|
||||
"Noto Sans Ethiopic",
|
||||
"Noto Sans Georgian",
|
||||
"Noto Sans Grantha",
|
||||
"Noto Sans Gujarati",
|
||||
"Noto Sans Gunjala Gondi",
|
||||
"Noto Sans Gurmukhi",
|
||||
"Noto Sans Hanifi Rohingya",
|
||||
"Noto Sans Hanunoo",
|
||||
"Noto Sans Hebrew",
|
||||
"Noto Sans JP",
|
||||
"Noto Sans Javanese",
|
||||
"Noto Sans KR",
|
||||
"Noto Sans Kannada",
|
||||
"Noto Sans Kayah Li",
|
||||
"Noto Sans Khmer",
|
||||
"Noto Sans Khojki",
|
||||
"Noto Sans Khudawadi",
|
||||
"Noto Sans Lao",
|
||||
"Noto Sans Lepcha",
|
||||
"Noto Sans Limbu",
|
||||
"Noto Sans Lisu",
|
||||
"Noto Sans Mahajani",
|
||||
"Noto Sans Malayalam",
|
||||
"Noto Sans Mandaic",
|
||||
"Noto Sans Masaram Gondi",
|
||||
"Noto Sans Medefaidrin",
|
||||
"Noto Sans Meetei Mayek",
|
||||
"Noto Sans Mende Kikakui",
|
||||
"Noto Sans Miao",
|
||||
"Noto Sans Modi",
|
||||
"Noto Sans Mongolian",
|
||||
"Noto Sans Mro",
|
||||
"Noto Sans Multani",
|
||||
"Noto Sans Myanmar",
|
||||
"Noto Sans NKo",
|
||||
"Noto Sans New Tai Lue",
|
||||
"Noto Sans Newa",
|
||||
"Noto Sans Ol Chiki",
|
||||
"Noto Sans Oriya",
|
||||
"Noto Sans Osage",
|
||||
"Noto Sans Osmanya",
|
||||
"Noto Sans Pahawh Hmong",
|
||||
"Noto Sans Pau Cin Hau",
|
||||
"Noto Sans Rejang",
|
||||
"Noto Sans Runic",
|
||||
"Noto Sans SC",
|
||||
"Noto Sans Samaritan",
|
||||
"Noto Sans Saurashtra",
|
||||
"Noto Sans Sharada",
|
||||
"Noto Sans Shavian",
|
||||
"Noto Sans Sinhala",
|
||||
"Noto Sans Sora Sompeng",
|
||||
"Noto Sans Soyombo",
|
||||
"Noto Sans Sundanese",
|
||||
"Noto Sans Syloti Nagri",
|
||||
"Noto Sans Symbols",
|
||||
"Noto Sans Symbols 2",
|
||||
"Noto Sans Syriac",
|
||||
"Noto Sans TC",
|
||||
"Noto Sans Tagalog",
|
||||
"Noto Sans Tagbanwa",
|
||||
"Noto Sans Tai Le",
|
||||
"Noto Sans Tai Tham",
|
||||
"Noto Sans Tai Viet",
|
||||
"Noto Sans Takri",
|
||||
"Noto Sans Tamil",
|
||||
"Noto Sans Telugu",
|
||||
"Noto Sans Thaana",
|
||||
"Noto Sans Thai",
|
||||
"Noto Sans Tifinagh",
|
||||
"Noto Sans Tifinagh APT",
|
||||
"Noto Sans Tifinagh Adrar",
|
||||
"Noto Sans Tifinagh Agraw Imazighen",
|
||||
"Noto Sans Tifinagh Ahaggar",
|
||||
"Noto Sans Tifinagh Air",
|
||||
"Noto Sans Tifinagh Azawagh",
|
||||
"Noto Sans Tifinagh Ghat",
|
||||
"Noto Sans Tifinagh Hawad",
|
||||
"Noto Sans Tifinagh Rhissa Ixa",
|
||||
"Noto Sans Tifinagh SIL",
|
||||
"Noto Sans Tifinagh Tawellemmet",
|
||||
"Noto Sans Tirhuta",
|
||||
"Noto Sans Vai",
|
||||
"Noto Sans Wancho",
|
||||
"Noto Sans Warang Citi",
|
||||
"Noto Sans Yi",
|
||||
"Noto Sans Zanabazar Square",
|
||||
"Noto Serif Armenian",
|
||||
"Noto Serif Balinese",
|
||||
"Noto Serif Bengali",
|
||||
"Noto Serif Devanagari",
|
||||
"Noto Serif Dogra",
|
||||
"Noto Serif Ethiopic",
|
||||
"Noto Serif Georgian",
|
||||
"Noto Serif Grantha",
|
||||
"Noto Serif Gujarati",
|
||||
"Noto Serif Gurmukhi",
|
||||
"Noto Serif Hebrew",
|
||||
"Noto Serif Kannada",
|
||||
"Noto Serif Khmer",
|
||||
"Noto Serif Khojki",
|
||||
"Noto Serif Lao",
|
||||
"Noto Serif Malayalam",
|
||||
"Noto Serif Myanmar",
|
||||
"Noto Serif NP Hmong",
|
||||
"Noto Serif Sinhala",
|
||||
"Noto Serif Tamil",
|
||||
"Noto Serif Telugu",
|
||||
"Noto Serif Thai",
|
||||
"Noto Serif Tibetan",
|
||||
"Noto Serif Yezidi",
|
||||
"STIX Two Math",
|
||||
"Tinos",
|
||||
"Twemoji Mozilla"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -1,164 +0,0 @@
|
||||
{
|
||||
"safari": [
|
||||
"Referer",
|
||||
"Origin",
|
||||
"Content-Type",
|
||||
"Accept",
|
||||
"Upgrade-Insecure-Requests",
|
||||
"User-Agent",
|
||||
"Content-Length",
|
||||
"Accept-Encoding",
|
||||
"Accept-Language",
|
||||
"Connection",
|
||||
"Host",
|
||||
"Cookie",
|
||||
"Sec-Fetch-Dest",
|
||||
"Sec-Fetch-Mode",
|
||||
"Sec-Fetch-Site",
|
||||
":method",
|
||||
":scheme",
|
||||
":authority",
|
||||
":path",
|
||||
"referer",
|
||||
"origin",
|
||||
"content-type",
|
||||
"accept",
|
||||
"user-agent",
|
||||
"content-length",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"cookie",
|
||||
"sec-fetch-dest",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-site"
|
||||
],
|
||||
"chrome": [
|
||||
"Host",
|
||||
"Connection",
|
||||
"Content-Length",
|
||||
"Cache-Control",
|
||||
"sec-ch-ua",
|
||||
"sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform",
|
||||
"Origin",
|
||||
"Content-Type",
|
||||
"Upgrade-Insecure-Requests",
|
||||
"User-Agent",
|
||||
"Accept",
|
||||
"Sec-Fetch-Site",
|
||||
"Sec-Fetch-Mode",
|
||||
"Sec-Fetch-User",
|
||||
"Sec-Fetch-Dest",
|
||||
"Referer",
|
||||
"Accept-Encoding",
|
||||
"Accept-Language",
|
||||
"Cookie",
|
||||
":method",
|
||||
":authority",
|
||||
":scheme",
|
||||
":path",
|
||||
"content-length",
|
||||
"cache-control",
|
||||
"sec-ch-ua",
|
||||
"sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform",
|
||||
"origin",
|
||||
"content-type",
|
||||
"upgrade-insecure-requests",
|
||||
"user-agent",
|
||||
"accept",
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-user",
|
||||
"sec-fetch-dest",
|
||||
"referer",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"cookie",
|
||||
"priority"
|
||||
],
|
||||
"firefox": [
|
||||
"Host",
|
||||
"User-Agent",
|
||||
"Accept",
|
||||
"Accept-Language",
|
||||
"Accept-Encoding",
|
||||
"Content-Type",
|
||||
"Content-Length",
|
||||
"Origin",
|
||||
"Connection",
|
||||
"Referer",
|
||||
"Cookie",
|
||||
"Upgrade-Insecure-Requests",
|
||||
"Sec-Fetch-Dest",
|
||||
"Sec-Fetch-Mode",
|
||||
"Sec-Fetch-Site",
|
||||
"Sec-Fetch-User",
|
||||
"Priority",
|
||||
":method",
|
||||
":path",
|
||||
":authority",
|
||||
":scheme",
|
||||
"user-agent",
|
||||
"accept",
|
||||
"accept-language",
|
||||
"accept-encoding",
|
||||
"content-type",
|
||||
"content-length",
|
||||
"origin",
|
||||
"referer",
|
||||
"cookie",
|
||||
"upgrade-insecure-requests",
|
||||
"sec-fetch-dest",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-user",
|
||||
"priority",
|
||||
"te"
|
||||
],
|
||||
"edge": [
|
||||
"Host",
|
||||
"Connection",
|
||||
"Content-Length",
|
||||
"Cache-Control",
|
||||
"sec-ch-ua",
|
||||
"sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform",
|
||||
"Origin",
|
||||
"Content-Type",
|
||||
"Upgrade-Insecure-Requests",
|
||||
"User-Agent",
|
||||
"Accept",
|
||||
"Sec-Fetch-Site",
|
||||
"Sec-Fetch-Mode",
|
||||
"Sec-Fetch-User",
|
||||
"Sec-Fetch-Dest",
|
||||
"Referer",
|
||||
"Accept-Encoding",
|
||||
"Accept-Language",
|
||||
"Cookie",
|
||||
":method",
|
||||
":authority",
|
||||
":scheme",
|
||||
":path",
|
||||
"content-length",
|
||||
"cache-control",
|
||||
"sec-ch-ua",
|
||||
"sec-ch-ua-mobile",
|
||||
"sec-ch-ua-platform",
|
||||
"origin",
|
||||
"content-type",
|
||||
"upgrade-insecure-requests",
|
||||
"user-agent",
|
||||
"accept",
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-user",
|
||||
"sec-fetch-dest",
|
||||
"referer",
|
||||
"accept-encoding",
|
||||
"accept-language",
|
||||
"cookie",
|
||||
"priority"
|
||||
]
|
||||
}
|
||||
Binary file not shown.
@@ -1,27 +0,0 @@
|
||||
pub const FINGERPRINT_NETWORK_ZIP: &[u8] = include_bytes!("fingerprint-network-definition.zip");
|
||||
pub const INPUT_NETWORK_ZIP: &[u8] = include_bytes!("input-network-definition.zip");
|
||||
pub const HEADER_NETWORK_ZIP: &[u8] = include_bytes!("header-network-definition.zip");
|
||||
pub const BROWSER_HELPER_JSON: &str = include_str!("browser-helper-file.json");
|
||||
pub const HEADERS_ORDER_JSON: &str = include_str!("headers-order.json");
|
||||
pub const FONTS_JSON: &str = include_str!("fonts.json");
|
||||
pub const BROWSERFORGE_YML: &str = include_str!("browserforge.yml");
|
||||
pub const WEBGL_DATA_DB: &[u8] = include_bytes!("webgl_data.db");
|
||||
pub const TERRITORY_INFO_XML: &str = include_str!("territoryInfo.xml");
|
||||
|
||||
/// Real fingerprint presets bundled with the original Camoufox v135 line
|
||||
/// (Firefox <= 148). Frozen upstream — kept around so users who haven't
|
||||
/// upgraded their Camoufox binary keep getting matched fingerprints.
|
||||
/// Mirrors `pythonlib/camoufox/fingerprint-presets.json` upstream.
|
||||
pub const FINGERPRINT_PRESETS_V135_JSON: &str = include_str!("fingerprint-presets-v135.json");
|
||||
|
||||
/// Real fingerprint presets for every Camoufox release after the v135 line
|
||||
/// (currently Firefox 149+ via the v150 build). This file is expected to
|
||||
/// be refreshed regularly as upstream Camoufox tracks newer Firefox
|
||||
/// releases — we keep the upstream filename here so each refresh is a
|
||||
/// straight `cp` from `pythonlib/camoufox/fingerprint-presets-v150.json`.
|
||||
pub const FINGERPRINT_PRESETS_NEWER_JSON: &str = include_str!("fingerprint-presets-v150.json");
|
||||
|
||||
/// Firefox major version at which the newer preset bundle takes over from
|
||||
/// the frozen v135 bundle. Matches `PRESETS_V150_MIN_FF` in
|
||||
/// `pythonlib/camoufox/fingerprints.py`.
|
||||
pub const PRESETS_NEWER_MIN_FF: u32 = 149;
|
||||
Binary file not shown.
@@ -1,142 +0,0 @@
|
||||
//! Environment variable handling for Camoufox configuration.
|
||||
//!
|
||||
//! Camoufox reads its configuration from environment variables named CAMOU_CONFIG_1, CAMOU_CONFIG_2, etc.
|
||||
//! The configuration JSON is chunked to fit within environment variable size limits.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Maximum chunk size for environment variables on Windows.
|
||||
const CHUNK_SIZE_WINDOWS: usize = 2047;
|
||||
|
||||
/// Maximum chunk size for environment variables on Unix systems.
|
||||
const CHUNK_SIZE_UNIX: usize = 32767;
|
||||
|
||||
/// Get the chunk size for the current platform.
|
||||
fn get_chunk_size() -> usize {
|
||||
if cfg!(windows) {
|
||||
CHUNK_SIZE_WINDOWS
|
||||
} else {
|
||||
CHUNK_SIZE_UNIX
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a Camoufox config map to environment variables.
|
||||
///
|
||||
/// The config is serialized to JSON and split into chunks that fit within
|
||||
/// environment variable size limits. Each chunk is stored in a variable
|
||||
/// named CAMOU_CONFIG_1, CAMOU_CONFIG_2, etc.
|
||||
pub fn config_to_env_vars(
|
||||
config: &HashMap<String, serde_json::Value>,
|
||||
) -> Result<HashMap<String, String>, serde_json::Error> {
|
||||
let config_json = serde_json::to_string(config)?;
|
||||
Ok(chunk_config_string(&config_json))
|
||||
}
|
||||
|
||||
/// Split a config string into chunks and create environment variable map.
|
||||
pub fn chunk_config_string(config_str: &str) -> HashMap<String, String> {
|
||||
let chunk_size = get_chunk_size();
|
||||
let mut env_vars = HashMap::new();
|
||||
|
||||
for (i, chunk) in config_str.as_bytes().chunks(chunk_size).enumerate() {
|
||||
let chunk_str = String::from_utf8_lossy(chunk).to_string();
|
||||
let env_name = format!("CAMOU_CONFIG_{}", i + 1);
|
||||
env_vars.insert(env_name, chunk_str);
|
||||
}
|
||||
|
||||
env_vars
|
||||
}
|
||||
|
||||
/// Determine the target OS from a user agent string.
|
||||
pub fn determine_ua_os(user_agent: &str) -> &'static str {
|
||||
let ua_lower = user_agent.to_lowercase();
|
||||
|
||||
if ua_lower.contains("mac os") || ua_lower.contains("macos") || ua_lower.contains("macintosh") {
|
||||
"mac"
|
||||
} else if ua_lower.contains("windows") {
|
||||
"win"
|
||||
} else {
|
||||
"lin"
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the fontconfig path environment variable for Linux.
|
||||
pub fn get_fontconfig_env(target_os: &str, camoufox_path: &std::path::Path) -> Option<String> {
|
||||
if cfg!(target_os = "linux") {
|
||||
let fontconfig_dir = camoufox_path.join("fontconfig").join(target_os);
|
||||
if fontconfig_dir.exists() {
|
||||
return Some(fontconfig_dir.to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_chunk_small_config() {
|
||||
let config = r#"{"navigator.userAgent": "Mozilla/5.0"}"#;
|
||||
let env_vars = chunk_config_string(config);
|
||||
|
||||
assert_eq!(env_vars.len(), 1);
|
||||
assert!(env_vars.contains_key("CAMOU_CONFIG_1"));
|
||||
assert_eq!(env_vars.get("CAMOU_CONFIG_1").unwrap(), config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chunk_large_config() {
|
||||
// Create a config string larger than the chunk size
|
||||
let chunk_size = get_chunk_size();
|
||||
let large_value = "x".repeat(chunk_size * 2 + 100);
|
||||
let config = format!(r#"{{"key": "{}"}}"#, large_value);
|
||||
|
||||
let env_vars = chunk_config_string(&config);
|
||||
|
||||
// Should have at least 2 chunks
|
||||
assert!(env_vars.len() >= 2);
|
||||
assert!(env_vars.contains_key("CAMOU_CONFIG_1"));
|
||||
assert!(env_vars.contains_key("CAMOU_CONFIG_2"));
|
||||
|
||||
// Reconstruct and verify
|
||||
let mut reconstructed = String::new();
|
||||
let mut i = 1;
|
||||
while let Some(chunk) = env_vars.get(&format!("CAMOU_CONFIG_{}", i)) {
|
||||
reconstructed.push_str(chunk);
|
||||
i += 1;
|
||||
}
|
||||
assert_eq!(reconstructed, config);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_ua_os_windows() {
|
||||
let ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0";
|
||||
assert_eq!(determine_ua_os(ua), "win");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_ua_os_macos() {
|
||||
let ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/135.0";
|
||||
assert_eq!(determine_ua_os(ua), "mac");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_determine_ua_os_linux() {
|
||||
let ua = "Mozilla/5.0 (X11; Linux x86_64; rv:135.0) Gecko/20100101 Firefox/135.0";
|
||||
assert_eq!(determine_ua_os(ua), "lin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_to_env_vars() {
|
||||
let mut config = HashMap::new();
|
||||
config.insert(
|
||||
"navigator.userAgent".to_string(),
|
||||
serde_json::json!("Mozilla/5.0 Firefox/135.0"),
|
||||
);
|
||||
config.insert("screen.width".to_string(), serde_json::json!(1920));
|
||||
|
||||
let env_vars = config_to_env_vars(&config).unwrap();
|
||||
assert!(!env_vars.is_empty());
|
||||
assert!(env_vars.contains_key("CAMOU_CONFIG_1"));
|
||||
}
|
||||
}
|
||||
@@ -1,198 +0,0 @@
|
||||
//! Bayesian network for fingerprint generation.
|
||||
//!
|
||||
//! Loads pre-trained probability distributions from ZIP files and samples fingerprints.
|
||||
|
||||
use super::bayesian_node::{BayesianNode, NodeDefinition};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Cursor, Read};
|
||||
use zip::ZipArchive;
|
||||
|
||||
/// Network definition structure from the ZIP file.
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct NetworkDefinition {
|
||||
pub nodes: Vec<NodeDefinition>,
|
||||
}
|
||||
|
||||
/// A Bayesian network for generating consistent fingerprints.
|
||||
pub struct BayesianNetwork {
|
||||
nodes_in_sampling_order: Vec<BayesianNode>,
|
||||
nodes_by_name: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl BayesianNetwork {
|
||||
/// Load a Bayesian network from embedded ZIP file bytes.
|
||||
pub fn from_zip_bytes(zip_bytes: &[u8]) -> Result<Self, BayesianNetworkError> {
|
||||
let cursor = Cursor::new(zip_bytes);
|
||||
let mut archive = ZipArchive::new(cursor)?;
|
||||
|
||||
// Find and read the JSON file from the ZIP
|
||||
let mut json_content = String::new();
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i)?;
|
||||
if file.name().ends_with(".json") {
|
||||
file.read_to_string(&mut json_content)?;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if json_content.is_empty() {
|
||||
return Err(BayesianNetworkError::NoJsonInZip);
|
||||
}
|
||||
|
||||
let definition: NetworkDefinition = serde_json::from_str(&json_content)?;
|
||||
|
||||
let mut nodes_in_sampling_order = Vec::with_capacity(definition.nodes.len());
|
||||
let mut nodes_by_name = HashMap::with_capacity(definition.nodes.len());
|
||||
|
||||
for (i, node_def) in definition.nodes.into_iter().enumerate() {
|
||||
nodes_by_name.insert(node_def.name.clone(), i);
|
||||
nodes_in_sampling_order.push(BayesianNode::new(node_def));
|
||||
}
|
||||
|
||||
Ok(Self {
|
||||
nodes_in_sampling_order,
|
||||
nodes_by_name,
|
||||
})
|
||||
}
|
||||
|
||||
/// Get a node by name.
|
||||
pub fn get_node(&self, name: &str) -> Option<&BayesianNode> {
|
||||
self
|
||||
.nodes_by_name
|
||||
.get(name)
|
||||
.map(|&i| &self.nodes_in_sampling_order[i])
|
||||
}
|
||||
|
||||
/// Get possible values for a node.
|
||||
pub fn get_possible_values(&self, name: &str) -> Option<Vec<String>> {
|
||||
self
|
||||
.get_node(name)
|
||||
.map(|node| node.possible_values().to_vec())
|
||||
}
|
||||
|
||||
/// Generate a random sample from the network.
|
||||
///
|
||||
/// `input_values` contains already known node values that should not be overwritten.
|
||||
pub fn generate_sample(&self, input_values: &HashMap<String, String>) -> HashMap<String, String> {
|
||||
let mut sample = input_values.clone();
|
||||
|
||||
for node in &self.nodes_in_sampling_order {
|
||||
if !sample.contains_key(node.name()) {
|
||||
let value = node.sample(&sample);
|
||||
sample.insert(node.name().to_string(), value);
|
||||
}
|
||||
}
|
||||
|
||||
sample
|
||||
}
|
||||
|
||||
/// Generate a random sample consistent with the given value restrictions.
|
||||
///
|
||||
/// Uses backtracking to find a valid configuration.
|
||||
/// Returns `None` if no consistent sample can be generated.
|
||||
pub fn generate_consistent_sample_when_possible(
|
||||
&self,
|
||||
value_possibilities: &HashMap<String, Vec<String>>,
|
||||
) -> Option<HashMap<String, String>> {
|
||||
self.recursively_generate_consistent_sample(HashMap::new(), value_possibilities, 0)
|
||||
}
|
||||
|
||||
fn recursively_generate_consistent_sample(
|
||||
&self,
|
||||
sample_so_far: HashMap<String, String>,
|
||||
value_possibilities: &HashMap<String, Vec<String>>,
|
||||
depth: usize,
|
||||
) -> Option<HashMap<String, String>> {
|
||||
if depth >= self.nodes_in_sampling_order.len() {
|
||||
return Some(sample_so_far);
|
||||
}
|
||||
|
||||
let node = &self.nodes_in_sampling_order[depth];
|
||||
let mut banned_values: Vec<String> = Vec::new();
|
||||
let mut sample_so_far = sample_so_far;
|
||||
|
||||
loop {
|
||||
let sample_value = node.sample_according_to_restrictions(
|
||||
&sample_so_far,
|
||||
value_possibilities.get(node.name()).map(|v| v.as_slice()),
|
||||
&banned_values,
|
||||
);
|
||||
|
||||
let Some(value) = sample_value else {
|
||||
break;
|
||||
};
|
||||
|
||||
sample_so_far.insert(node.name().to_string(), value.clone());
|
||||
|
||||
if let Some(complete_sample) = self.recursively_generate_consistent_sample(
|
||||
sample_so_far.clone(),
|
||||
value_possibilities,
|
||||
depth + 1,
|
||||
) {
|
||||
return Some(complete_sample);
|
||||
}
|
||||
|
||||
banned_values.push(value);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors that can occur when working with Bayesian networks.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum BayesianNetworkError {
|
||||
#[error("ZIP file error: {0}")]
|
||||
Zip(#[from] zip::result::ZipError),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("JSON parsing error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("No JSON file found in ZIP archive")]
|
||||
NoJsonInZip,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_load_input_network() {
|
||||
let zip_bytes = include_bytes!("../data/input-network-definition.zip");
|
||||
let network = BayesianNetwork::from_zip_bytes(zip_bytes);
|
||||
assert!(
|
||||
network.is_ok(),
|
||||
"Failed to load input network: {:?}",
|
||||
network.err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_sample_from_input_network() {
|
||||
let zip_bytes = include_bytes!("../data/input-network-definition.zip");
|
||||
let network = BayesianNetwork::from_zip_bytes(zip_bytes).unwrap();
|
||||
|
||||
let sample = network.generate_sample(&HashMap::new());
|
||||
assert!(!sample.is_empty(), "Sample should not be empty");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_consistent_sample() {
|
||||
let zip_bytes = include_bytes!("../data/input-network-definition.zip");
|
||||
let network = BayesianNetwork::from_zip_bytes(zip_bytes).unwrap();
|
||||
|
||||
let mut constraints = HashMap::new();
|
||||
constraints.insert("*OPERATING_SYSTEM".to_string(), vec!["windows".to_string()]);
|
||||
|
||||
let sample = network.generate_consistent_sample_when_possible(&constraints);
|
||||
assert!(sample.is_some(), "Should generate a consistent sample");
|
||||
|
||||
if let Some(s) = sample {
|
||||
assert_eq!(s.get("*OPERATING_SYSTEM"), Some(&"windows".to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,231 +0,0 @@
|
||||
//! Bayesian network node implementation for fingerprint generation.
|
||||
//!
|
||||
//! Implements weighted random sampling from conditional probability distributions.
|
||||
|
||||
use rand::RngExt;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// Node definition from the network JSON file.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NodeDefinition {
|
||||
pub name: String,
|
||||
pub parent_names: Vec<String>,
|
||||
pub possible_values: Vec<String>,
|
||||
pub conditional_probabilities: ConditionalProbabilities,
|
||||
}
|
||||
|
||||
/// Conditional probability structure - can be nested or terminal.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ConditionalProbabilities {
|
||||
#[serde(default)]
|
||||
pub deeper: Option<HashMap<String, ConditionalProbabilities>>,
|
||||
#[serde(default)]
|
||||
pub skip: Option<Box<ConditionalProbabilities>>,
|
||||
#[serde(flatten)]
|
||||
pub probabilities: HashMap<String, f64>,
|
||||
}
|
||||
|
||||
impl ConditionalProbabilities {
|
||||
/// Check if this is a terminal node (has actual probabilities, not deeper nesting)
|
||||
pub fn is_terminal(&self) -> bool {
|
||||
self.deeper.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
/// A single node in the Bayesian network.
|
||||
pub struct BayesianNode {
|
||||
definition: NodeDefinition,
|
||||
}
|
||||
|
||||
impl BayesianNode {
|
||||
pub fn new(definition: NodeDefinition) -> Self {
|
||||
Self { definition }
|
||||
}
|
||||
|
||||
pub fn name(&self) -> &str {
|
||||
&self.definition.name
|
||||
}
|
||||
|
||||
pub fn parent_names(&self) -> &[String] {
|
||||
&self.definition.parent_names
|
||||
}
|
||||
|
||||
pub fn possible_values(&self) -> &[String] {
|
||||
&self.definition.possible_values
|
||||
}
|
||||
|
||||
/// Get the probability distribution given parent node values.
|
||||
fn get_probabilities_given_known_values(
|
||||
&self,
|
||||
parent_values: &HashMap<String, String>,
|
||||
) -> HashMap<String, f64> {
|
||||
let mut probabilities = &self.definition.conditional_probabilities;
|
||||
|
||||
for parent_name in &self.definition.parent_names {
|
||||
if let Some(deeper) = &probabilities.deeper {
|
||||
if let Some(parent_value) = parent_values.get(parent_name) {
|
||||
if let Some(next_level) = deeper.get(parent_value) {
|
||||
probabilities = next_level;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Use skip if parent value not found in deeper
|
||||
if let Some(skip) = &probabilities.skip {
|
||||
probabilities = skip;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
probabilities.probabilities.clone()
|
||||
}
|
||||
|
||||
/// Randomly sample from the given values using the given probabilities.
|
||||
fn sample_random_value_from_possibilities(
|
||||
possible_values: &[String],
|
||||
total_probability: f64,
|
||||
probabilities: &HashMap<String, f64>,
|
||||
) -> String {
|
||||
if possible_values.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut rng = rand::rng();
|
||||
let anchor = rng.random::<f64>() * total_probability;
|
||||
let mut cumulative = 0.0;
|
||||
|
||||
for value in possible_values {
|
||||
if let Some(&prob) = probabilities.get(value) {
|
||||
cumulative += prob;
|
||||
if cumulative > anchor {
|
||||
return value.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
possible_values.first().cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Sample a value from the conditional distribution given parent values.
|
||||
pub fn sample(&self, parent_values: &HashMap<String, String>) -> String {
|
||||
let probabilities = self.get_probabilities_given_known_values(parent_values);
|
||||
let possible_values: Vec<String> = probabilities.keys().cloned().collect();
|
||||
|
||||
Self::sample_random_value_from_possibilities(&possible_values, 1.0, &probabilities)
|
||||
}
|
||||
|
||||
/// Sample according to restrictions on possible values.
|
||||
///
|
||||
/// Returns `None` if no valid value can be sampled.
|
||||
pub fn sample_according_to_restrictions(
|
||||
&self,
|
||||
parent_values: &HashMap<String, String>,
|
||||
value_possibilities: Option<&[String]>,
|
||||
banned_values: &[String],
|
||||
) -> Option<String> {
|
||||
let probabilities = self.get_probabilities_given_known_values(parent_values);
|
||||
let values_in_distribution: Vec<String> = probabilities.keys().cloned().collect();
|
||||
|
||||
let possible_values = value_possibilities.unwrap_or(&values_in_distribution);
|
||||
|
||||
let mut valid_values = Vec::new();
|
||||
let mut total_probability = 0.0;
|
||||
|
||||
for value in possible_values {
|
||||
if !banned_values.contains(value) && values_in_distribution.contains(value) {
|
||||
if let Some(&prob) = probabilities.get(value) {
|
||||
valid_values.push(value.clone());
|
||||
total_probability += prob;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if valid_values.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(Self::sample_random_value_from_possibilities(
|
||||
&valid_values,
|
||||
total_probability,
|
||||
&probabilities,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn create_test_node() -> BayesianNode {
|
||||
let mut probs = HashMap::new();
|
||||
probs.insert("1920".to_string(), 0.5);
|
||||
probs.insert("1366".to_string(), 0.3);
|
||||
probs.insert("1536".to_string(), 0.2);
|
||||
|
||||
let definition = NodeDefinition {
|
||||
name: "screen.width".to_string(),
|
||||
parent_names: vec![],
|
||||
possible_values: vec!["1920".to_string(), "1366".to_string(), "1536".to_string()],
|
||||
conditional_probabilities: ConditionalProbabilities {
|
||||
deeper: None,
|
||||
skip: None,
|
||||
probabilities: probs,
|
||||
},
|
||||
};
|
||||
|
||||
BayesianNode::new(definition)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_returns_valid_value() {
|
||||
let node = create_test_node();
|
||||
let parent_values = HashMap::new();
|
||||
|
||||
for _ in 0..100 {
|
||||
let value = node.sample(&parent_values);
|
||||
assert!(
|
||||
node.possible_values().contains(&value),
|
||||
"Sampled value '{}' not in possible values",
|
||||
value
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_with_restrictions() {
|
||||
let node = create_test_node();
|
||||
let parent_values = HashMap::new();
|
||||
|
||||
let allowed = vec!["1920".to_string()];
|
||||
let banned = vec![];
|
||||
|
||||
let value = node.sample_according_to_restrictions(&parent_values, Some(&allowed), &banned);
|
||||
|
||||
assert_eq!(value, Some("1920".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_with_banned_values() {
|
||||
let node = create_test_node();
|
||||
let parent_values = HashMap::new();
|
||||
|
||||
let banned = vec!["1920".to_string(), "1366".to_string()];
|
||||
|
||||
for _ in 0..100 {
|
||||
let value = node.sample_according_to_restrictions(&parent_values, None, &banned);
|
||||
assert_eq!(value, Some("1536".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sample_returns_none_when_all_banned() {
|
||||
let node = create_test_node();
|
||||
let parent_values = HashMap::new();
|
||||
|
||||
let banned = vec!["1920".to_string(), "1366".to_string(), "1536".to_string()];
|
||||
|
||||
let value = node.sample_according_to_restrictions(&parent_values, None, &banned);
|
||||
assert!(value.is_none());
|
||||
}
|
||||
}
|
||||
@@ -1,569 +0,0 @@
|
||||
//! Fingerprint generation module.
|
||||
//!
|
||||
//! Generates realistic browser fingerprints using Bayesian networks trained on real browser data.
|
||||
|
||||
pub mod bayesian_network;
|
||||
pub mod bayesian_node;
|
||||
pub mod types;
|
||||
|
||||
use bayesian_network::{BayesianNetwork, BayesianNetworkError};
|
||||
use std::collections::HashMap;
|
||||
use types::*;
|
||||
|
||||
use crate::camoufox::data;
|
||||
|
||||
/// Fingerprint generator using Bayesian networks.
|
||||
pub struct FingerprintGenerator {
|
||||
fingerprint_network: BayesianNetwork,
|
||||
input_network: BayesianNetwork,
|
||||
header_network: BayesianNetwork,
|
||||
browser_helper: Vec<BrowserHttpInfo>,
|
||||
headers_order: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
/// Parsed browser/HTTP version info.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct BrowserHttpInfo {
|
||||
pub name: String,
|
||||
pub version: Vec<u32>,
|
||||
pub http_version: String,
|
||||
pub complete_string: String,
|
||||
}
|
||||
|
||||
impl BrowserHttpInfo {
|
||||
fn parse(s: &str) -> Option<Self> {
|
||||
if s == MISSING_VALUE_DATASET_TOKEN {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = s.split('|').collect();
|
||||
if parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let browser_string = parts[0];
|
||||
let http_version = parts[1].to_string();
|
||||
|
||||
let browser_parts: Vec<&str> = browser_string.split('/').collect();
|
||||
if browser_parts.len() != 2 {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = browser_parts[0].to_string();
|
||||
let version: Vec<u32> = browser_parts[1]
|
||||
.split('.')
|
||||
.filter_map(|v| v.parse().ok())
|
||||
.collect();
|
||||
|
||||
Some(Self {
|
||||
name,
|
||||
version,
|
||||
http_version,
|
||||
complete_string: s.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn major_version(&self) -> u32 {
|
||||
self.version.first().copied().unwrap_or(0)
|
||||
}
|
||||
}
|
||||
|
||||
/// Error type for fingerprint generation.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum FingerprintError {
|
||||
#[error("Bayesian network error: {0}")]
|
||||
Network(#[from] BayesianNetworkError),
|
||||
|
||||
#[error("JSON parsing error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
#[error("Failed to generate consistent fingerprint after {0} attempts")]
|
||||
GenerationFailed(u32),
|
||||
|
||||
#[error("No valid fingerprint generated")]
|
||||
NoValidFingerprint,
|
||||
}
|
||||
|
||||
impl FingerprintGenerator {
|
||||
/// Create a new fingerprint generator.
|
||||
pub fn new() -> Result<Self, FingerprintError> {
|
||||
let fingerprint_network = BayesianNetwork::from_zip_bytes(data::FINGERPRINT_NETWORK_ZIP)?;
|
||||
let input_network = BayesianNetwork::from_zip_bytes(data::INPUT_NETWORK_ZIP)?;
|
||||
let header_network = BayesianNetwork::from_zip_bytes(data::HEADER_NETWORK_ZIP)?;
|
||||
|
||||
let browser_strings: Vec<String> = serde_json::from_str(data::BROWSER_HELPER_JSON)?;
|
||||
let browser_helper: Vec<BrowserHttpInfo> = browser_strings
|
||||
.iter()
|
||||
.filter_map(|s| BrowserHttpInfo::parse(s))
|
||||
.collect();
|
||||
|
||||
let headers_order: HashMap<String, Vec<String>> =
|
||||
serde_json::from_str(data::HEADERS_ORDER_JSON)?;
|
||||
|
||||
Ok(Self {
|
||||
fingerprint_network,
|
||||
input_network,
|
||||
header_network,
|
||||
browser_helper,
|
||||
headers_order,
|
||||
})
|
||||
}
|
||||
|
||||
/// Generate a fingerprint with matching headers.
|
||||
pub fn get_fingerprint(
|
||||
&self,
|
||||
options: &FingerprintOptions,
|
||||
) -> Result<FingerprintWithHeaders, FingerprintError> {
|
||||
const MAX_RETRIES: u32 = 10;
|
||||
|
||||
// Build constraints from options
|
||||
let mut value_possibilities = self.build_constraints(options);
|
||||
|
||||
// Handle screen constraints
|
||||
let screen_values = if let Some(screen_constraints) = &options.screen {
|
||||
self.filter_screen_values(screen_constraints)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if let Some(sv) = screen_values {
|
||||
value_possibilities.insert("screen".to_string(), sv);
|
||||
}
|
||||
|
||||
for attempt in 0..MAX_RETRIES {
|
||||
// Generate input sample consistent with constraints
|
||||
let input_sample = self
|
||||
.input_network
|
||||
.generate_consistent_sample_when_possible(&value_possibilities);
|
||||
|
||||
let Some(input_sample) = input_sample else {
|
||||
continue;
|
||||
};
|
||||
|
||||
// Generate header sample from input
|
||||
let header_sample = self.header_network.generate_sample(&input_sample);
|
||||
|
||||
// Extract user agent
|
||||
let user_agent = header_sample
|
||||
.get("user-agent")
|
||||
.or_else(|| header_sample.get("User-Agent"))
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build fingerprint constraints with the generated user agent
|
||||
let mut fp_constraints = value_possibilities.clone();
|
||||
fp_constraints.insert("userAgent".to_string(), vec![user_agent.clone()]);
|
||||
|
||||
// Generate fingerprint sample
|
||||
let fingerprint_sample = self
|
||||
.fingerprint_network
|
||||
.generate_consistent_sample_when_possible(&fp_constraints);
|
||||
|
||||
let Some(fp_sample) = fingerprint_sample else {
|
||||
log::debug!(
|
||||
"Failed to generate fingerprint on attempt {}, retrying",
|
||||
attempt + 1
|
||||
);
|
||||
continue;
|
||||
};
|
||||
|
||||
// Transform the sample to a Fingerprint struct
|
||||
match self.transform_sample(&fp_sample, &header_sample, options) {
|
||||
Ok(result) => return Ok(result),
|
||||
Err(e) => {
|
||||
log::debug!(
|
||||
"Failed to transform fingerprint on attempt {}: {}",
|
||||
attempt + 1,
|
||||
e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(FingerprintError::GenerationFailed(MAX_RETRIES))
|
||||
}
|
||||
|
||||
/// Build constraint map from options.
|
||||
fn build_constraints(&self, options: &FingerprintOptions) -> HashMap<String, Vec<String>> {
|
||||
let mut constraints = HashMap::new();
|
||||
|
||||
// Operating system constraint
|
||||
if let Some(os) = &options.operating_system {
|
||||
constraints.insert(OPERATING_SYSTEM_NODE_NAME.to_string(), vec![os.clone()]);
|
||||
}
|
||||
|
||||
// Device constraint (default to desktop)
|
||||
let devices = options
|
||||
.devices
|
||||
.clone()
|
||||
.unwrap_or_else(|| vec!["desktop".to_string()]);
|
||||
constraints.insert(DEVICE_NODE_NAME.to_string(), devices);
|
||||
|
||||
// Browser constraint
|
||||
let browsers = options
|
||||
.browsers
|
||||
.clone()
|
||||
.unwrap_or_else(|| SUPPORTED_BROWSERS.iter().map(|s| s.to_string()).collect());
|
||||
|
||||
let http_version = options
|
||||
.http_version
|
||||
.clone()
|
||||
.unwrap_or_else(|| "2".to_string());
|
||||
|
||||
// Filter browser helper entries by browser names and HTTP version
|
||||
let browser_http_values: Vec<String> = self
|
||||
.browser_helper
|
||||
.iter()
|
||||
.filter(|bh| browsers.contains(&bh.name) && bh.http_version == http_version)
|
||||
.map(|bh| bh.complete_string.clone())
|
||||
.collect();
|
||||
|
||||
if !browser_http_values.is_empty() {
|
||||
constraints.insert(BROWSER_HTTP_NODE_NAME.to_string(), browser_http_values);
|
||||
}
|
||||
|
||||
constraints
|
||||
}
|
||||
|
||||
/// Filter screen values based on constraints.
|
||||
fn filter_screen_values(&self, constraints: &ScreenConstraints) -> Option<Vec<String>> {
|
||||
let possible_values = self.fingerprint_network.get_possible_values("screen")?;
|
||||
|
||||
let filtered: Vec<String> = possible_values
|
||||
.into_iter()
|
||||
.filter(|screen_str| {
|
||||
// Screen values are stored as "*STRINGIFIED*{...json...}"
|
||||
if let Some(json_str) = screen_str.strip_prefix(STRINGIFIED_PREFIX) {
|
||||
if let Ok(screen) = serde_json::from_str::<serde_json::Value>(json_str) {
|
||||
let width = screen["width"].as_u64().unwrap_or(0) as u32;
|
||||
let height = screen["height"].as_u64().unwrap_or(0) as u32;
|
||||
return constraints.matches(width, height);
|
||||
}
|
||||
}
|
||||
true
|
||||
})
|
||||
.collect();
|
||||
|
||||
if filtered.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(filtered)
|
||||
}
|
||||
}
|
||||
|
||||
/// Transform raw sample data into a Fingerprint struct.
|
||||
fn transform_sample(
|
||||
&self,
|
||||
fp_sample: &HashMap<String, String>,
|
||||
header_sample: &HashMap<String, String>,
|
||||
options: &FingerprintOptions,
|
||||
) -> Result<FingerprintWithHeaders, FingerprintError> {
|
||||
// Parse values, handling STRINGIFIED prefix and MISSING_VALUE token
|
||||
let mut parsed: HashMap<String, serde_json::Value> = HashMap::new();
|
||||
|
||||
for (key, value) in fp_sample {
|
||||
if value == MISSING_VALUE_DATASET_TOKEN {
|
||||
continue;
|
||||
}
|
||||
|
||||
let parsed_value = if let Some(json_str) = value.strip_prefix(STRINGIFIED_PREFIX) {
|
||||
serde_json::from_str(json_str)?
|
||||
} else {
|
||||
serde_json::Value::String(value.clone())
|
||||
};
|
||||
|
||||
parsed.insert(key.clone(), parsed_value);
|
||||
}
|
||||
|
||||
// Check if screen was generated
|
||||
let screen_value = parsed.get("screen");
|
||||
if screen_value.is_none() {
|
||||
return Err(FingerprintError::NoValidFingerprint);
|
||||
}
|
||||
|
||||
// Extract screen fingerprint
|
||||
let screen = if let Some(screen_val) = screen_value {
|
||||
serde_json::from_value(screen_val.clone()).unwrap_or_default()
|
||||
} else {
|
||||
ScreenFingerprint::default()
|
||||
};
|
||||
|
||||
// Build languages from Accept-Language header
|
||||
let accept_language = header_sample
|
||||
.get("accept-language")
|
||||
.or_else(|| header_sample.get("Accept-Language"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "en-US".to_string());
|
||||
|
||||
let languages: Vec<String> = accept_language
|
||||
.split(',')
|
||||
.map(|s| s.split(';').next().unwrap_or(s).trim().to_string())
|
||||
.collect();
|
||||
|
||||
let language = languages
|
||||
.first()
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "en-US".to_string());
|
||||
|
||||
// Build navigator fingerprint
|
||||
let navigator = NavigatorFingerprint {
|
||||
user_agent: get_string(&parsed, "userAgent"),
|
||||
user_agent_data: parsed
|
||||
.get("userAgentData")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok()),
|
||||
do_not_track: parsed
|
||||
.get("doNotTrack")
|
||||
.and_then(|v| v.as_str().map(String::from)),
|
||||
app_code_name: get_string_or(&parsed, "appCodeName", "Mozilla"),
|
||||
app_name: get_string_or(&parsed, "appName", "Netscape"),
|
||||
app_version: get_string(&parsed, "appVersion"),
|
||||
oscpu: parsed
|
||||
.get("oscpu")
|
||||
.and_then(|v| v.as_str().map(String::from)),
|
||||
webdriver: parsed
|
||||
.get("webdriver")
|
||||
.and_then(|v| v.as_str().map(String::from)),
|
||||
language,
|
||||
languages,
|
||||
platform: get_string(&parsed, "platform"),
|
||||
device_memory: parsed
|
||||
.get("deviceMemory")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok()),
|
||||
hardware_concurrency: parsed
|
||||
.get("hardwareConcurrency")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(4),
|
||||
product: get_string_or(&parsed, "product", "Gecko"),
|
||||
product_sub: get_string(&parsed, "productSub"),
|
||||
vendor: get_string(&parsed, "vendor"),
|
||||
vendor_sub: get_string(&parsed, "vendorSub"),
|
||||
max_touch_points: parsed
|
||||
.get("maxTouchPoints")
|
||||
.and_then(|v| v.as_str())
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0),
|
||||
extra_properties: parsed
|
||||
.get("extraProperties")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok()),
|
||||
};
|
||||
|
||||
// Build video card (will be filled later by WebGL sampler)
|
||||
let video_card = parsed
|
||||
.get("videoCard")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
// Build other components
|
||||
let audio_codecs = parsed
|
||||
.get("audioCodecs")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let video_codecs = parsed
|
||||
.get("videoCodecs")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let plugins_data = parsed
|
||||
.get("pluginsData")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let battery = parsed
|
||||
.get("battery")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok());
|
||||
|
||||
let multimedia_devices = parsed
|
||||
.get("multimediaDevices")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let fonts = parsed
|
||||
.get("fonts")
|
||||
.and_then(|v| serde_json::from_value(v.clone()).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
let fingerprint = Fingerprint {
|
||||
screen,
|
||||
navigator,
|
||||
video_codecs,
|
||||
audio_codecs,
|
||||
plugins_data,
|
||||
battery,
|
||||
video_card,
|
||||
multimedia_devices,
|
||||
fonts,
|
||||
mock_web_rtc: options.mock_web_rtc,
|
||||
slim: options.slim,
|
||||
};
|
||||
|
||||
// Build headers (filter out internal nodes and missing values)
|
||||
let headers: Headers = header_sample
|
||||
.iter()
|
||||
.filter(|(k, v)| !k.starts_with('*') && *v != MISSING_VALUE_DATASET_TOKEN)
|
||||
.map(|(k, v)| (k.clone(), v.clone()))
|
||||
.collect();
|
||||
|
||||
// Order headers
|
||||
let ordered_headers = self.order_headers(&headers, &fingerprint.navigator.user_agent);
|
||||
|
||||
Ok(FingerprintWithHeaders {
|
||||
fingerprint,
|
||||
headers: ordered_headers,
|
||||
})
|
||||
}
|
||||
|
||||
/// Order headers according to browser-specific ordering.
|
||||
fn order_headers(&self, headers: &Headers, user_agent: &str) -> Headers {
|
||||
let browser = detect_browser_from_ua(user_agent);
|
||||
let order = self.headers_order.get(browser).cloned().unwrap_or_default();
|
||||
|
||||
let mut ordered = HashMap::new();
|
||||
|
||||
// Add headers in order
|
||||
for header_name in &order {
|
||||
if let Some(value) = headers.get(header_name) {
|
||||
ordered.insert(header_name.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining headers not in order
|
||||
for (key, value) in headers {
|
||||
if !order.contains(key) {
|
||||
ordered.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
|
||||
ordered
|
||||
}
|
||||
}
|
||||
|
||||
fn get_string(map: &HashMap<String, serde_json::Value>, key: &str) -> String {
|
||||
map
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn get_string_or(map: &HashMap<String, serde_json::Value>, key: &str, default: &str) -> String {
|
||||
map
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from)
|
||||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
fn detect_browser_from_ua(user_agent: &str) -> &str {
|
||||
let ua_lower = user_agent.to_lowercase();
|
||||
if ua_lower.contains("firefox") {
|
||||
"firefox"
|
||||
} else if ua_lower.contains("edg/") || ua_lower.contains("edge") {
|
||||
"edge"
|
||||
} else if ua_lower.contains("chrome") {
|
||||
"chrome"
|
||||
} else if ua_lower.contains("safari") {
|
||||
"safari"
|
||||
} else {
|
||||
"chrome"
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_create_generator() {
|
||||
let generator = FingerprintGenerator::new();
|
||||
assert!(
|
||||
generator.is_ok(),
|
||||
"Failed to create generator: {:?}",
|
||||
generator.err()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_fingerprint() {
|
||||
let generator = FingerprintGenerator::new().unwrap();
|
||||
let options = FingerprintOptions::default();
|
||||
|
||||
let result = generator.get_fingerprint(&options);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to generate fingerprint: {:?}",
|
||||
result.err()
|
||||
);
|
||||
|
||||
if let Ok(fp) = result {
|
||||
assert!(!fp.fingerprint.navigator.user_agent.is_empty());
|
||||
assert!(fp.fingerprint.screen.width > 0);
|
||||
assert!(fp.fingerprint.screen.height > 0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_firefox_fingerprint() {
|
||||
let generator = FingerprintGenerator::new().unwrap();
|
||||
let options = FingerprintOptions {
|
||||
browsers: Some(vec!["firefox".to_string()]),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = generator.get_fingerprint(&options);
|
||||
assert!(result.is_ok(), "Failed to generate Firefox fingerprint");
|
||||
|
||||
if let Ok(fp) = result {
|
||||
assert!(
|
||||
fp.fingerprint
|
||||
.navigator
|
||||
.user_agent
|
||||
.to_lowercase()
|
||||
.contains("firefox"),
|
||||
"User agent should contain Firefox: {}",
|
||||
fp.fingerprint.navigator.user_agent
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_with_screen_constraints() {
|
||||
let generator = FingerprintGenerator::new().unwrap();
|
||||
let options = FingerprintOptions {
|
||||
screen: Some(ScreenConstraints {
|
||||
min_width: Some(1900),
|
||||
max_width: Some(1920),
|
||||
min_height: Some(1000),
|
||||
max_height: Some(1100),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = generator.get_fingerprint(&options);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Failed to generate fingerprint with screen constraints"
|
||||
);
|
||||
|
||||
if let Ok(fp) = result {
|
||||
assert!(
|
||||
fp.fingerprint.screen.width >= 1900 && fp.fingerprint.screen.width <= 1920,
|
||||
"Screen width {} should be between 1900 and 1920",
|
||||
fp.fingerprint.screen.width
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_browser_http_info_parse() {
|
||||
let info = BrowserHttpInfo::parse("chrome/143.0.0.0|2");
|
||||
assert!(info.is_some());
|
||||
let info = info.unwrap();
|
||||
assert_eq!(info.name, "chrome");
|
||||
assert_eq!(info.major_version(), 143);
|
||||
assert_eq!(info.http_version, "2");
|
||||
}
|
||||
}
|
||||
@@ -1,302 +0,0 @@
|
||||
//! Fingerprint type definitions.
|
||||
//!
|
||||
//! These types represent browser fingerprints that can be injected into Camoufox.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
/// A complete browser fingerprint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct Fingerprint {
|
||||
pub screen: ScreenFingerprint,
|
||||
pub navigator: NavigatorFingerprint,
|
||||
#[serde(default)]
|
||||
pub video_codecs: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub audio_codecs: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub plugins_data: HashMap<String, String>,
|
||||
#[serde(default)]
|
||||
pub battery: Option<BatteryFingerprint>,
|
||||
pub video_card: VideoCard,
|
||||
#[serde(default)]
|
||||
pub multimedia_devices: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub fonts: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub mock_web_rtc: bool,
|
||||
#[serde(default)]
|
||||
pub slim: bool,
|
||||
}
|
||||
|
||||
/// Screen-related fingerprint properties.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ScreenFingerprint {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub avail_width: u32,
|
||||
pub avail_height: u32,
|
||||
#[serde(default)]
|
||||
pub avail_top: u32,
|
||||
#[serde(default)]
|
||||
pub avail_left: u32,
|
||||
pub color_depth: u32,
|
||||
pub pixel_depth: u32,
|
||||
#[serde(default = "default_device_pixel_ratio")]
|
||||
pub device_pixel_ratio: f64,
|
||||
#[serde(default)]
|
||||
pub page_x_offset: f64,
|
||||
#[serde(default)]
|
||||
pub page_y_offset: f64,
|
||||
pub inner_width: u32,
|
||||
pub inner_height: u32,
|
||||
pub outer_width: u32,
|
||||
pub outer_height: u32,
|
||||
#[serde(default)]
|
||||
pub screen_x: i32,
|
||||
#[serde(default)]
|
||||
pub screen_y: i32,
|
||||
#[serde(default)]
|
||||
pub client_width: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub client_height: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub has_hdr: bool,
|
||||
}
|
||||
|
||||
fn default_device_pixel_ratio() -> f64 {
|
||||
1.0
|
||||
}
|
||||
|
||||
/// Brand information for User-Agent Client Hints.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Brand {
|
||||
pub brand: String,
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// User-Agent Client Hints data.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct UserAgentData {
|
||||
#[serde(default)]
|
||||
pub brands: Vec<Brand>,
|
||||
#[serde(default)]
|
||||
pub mobile: bool,
|
||||
#[serde(default)]
|
||||
pub platform: String,
|
||||
#[serde(default)]
|
||||
pub architecture: String,
|
||||
#[serde(default)]
|
||||
pub bitness: String,
|
||||
#[serde(default)]
|
||||
pub full_version_list: Vec<Brand>,
|
||||
#[serde(default)]
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub platform_version: String,
|
||||
#[serde(default)]
|
||||
pub ua_full_version: String,
|
||||
}
|
||||
|
||||
/// Extra navigator properties.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ExtraProperties {
|
||||
#[serde(default)]
|
||||
pub vendor_flavors: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub is_bluetooth_supported: bool,
|
||||
#[serde(default)]
|
||||
pub global_privacy_control: Option<bool>,
|
||||
#[serde(default = "default_pdf_viewer_enabled")]
|
||||
pub pdf_viewer_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub installed_apps: Vec<serde_json::Value>,
|
||||
}
|
||||
|
||||
fn default_pdf_viewer_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
/// Navigator-related fingerprint properties.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct NavigatorFingerprint {
|
||||
pub user_agent: String,
|
||||
#[serde(default)]
|
||||
pub user_agent_data: Option<UserAgentData>,
|
||||
#[serde(default)]
|
||||
pub do_not_track: Option<String>,
|
||||
#[serde(default = "default_app_code_name")]
|
||||
pub app_code_name: String,
|
||||
#[serde(default = "default_app_name")]
|
||||
pub app_name: String,
|
||||
#[serde(default)]
|
||||
pub app_version: String,
|
||||
#[serde(default)]
|
||||
pub oscpu: Option<String>,
|
||||
#[serde(default)]
|
||||
pub webdriver: Option<String>,
|
||||
pub language: String,
|
||||
pub languages: Vec<String>,
|
||||
pub platform: String,
|
||||
#[serde(default)]
|
||||
pub device_memory: Option<u32>,
|
||||
pub hardware_concurrency: u32,
|
||||
#[serde(default = "default_product")]
|
||||
pub product: String,
|
||||
#[serde(default)]
|
||||
pub product_sub: String,
|
||||
#[serde(default)]
|
||||
pub vendor: String,
|
||||
#[serde(default)]
|
||||
pub vendor_sub: String,
|
||||
#[serde(default)]
|
||||
pub max_touch_points: u32,
|
||||
#[serde(default)]
|
||||
pub extra_properties: Option<ExtraProperties>,
|
||||
}
|
||||
|
||||
fn default_app_code_name() -> String {
|
||||
"Mozilla".to_string()
|
||||
}
|
||||
|
||||
fn default_app_name() -> String {
|
||||
"Netscape".to_string()
|
||||
}
|
||||
|
||||
fn default_product() -> String {
|
||||
"Gecko".to_string()
|
||||
}
|
||||
|
||||
/// WebGL video card information.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct VideoCard {
|
||||
pub vendor: String,
|
||||
pub renderer: String,
|
||||
}
|
||||
|
||||
/// Battery status fingerprint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct BatteryFingerprint {
|
||||
pub charging: bool,
|
||||
pub charging_time: f64,
|
||||
pub discharging_time: f64,
|
||||
pub level: f64,
|
||||
}
|
||||
|
||||
/// HTTP headers for a fingerprint.
|
||||
pub type Headers = HashMap<String, String>;
|
||||
|
||||
/// A fingerprint combined with matching HTTP headers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct FingerprintWithHeaders {
|
||||
pub fingerprint: Fingerprint,
|
||||
pub headers: Headers,
|
||||
}
|
||||
|
||||
/// Options for generating fingerprints.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct FingerprintOptions {
|
||||
/// Target operating system: "windows", "macos", "linux"
|
||||
pub operating_system: Option<String>,
|
||||
/// Target browser: "firefox", "chrome", "safari", "edge"
|
||||
pub browsers: Option<Vec<String>>,
|
||||
/// Target device type: "desktop", "mobile"
|
||||
pub devices: Option<Vec<String>>,
|
||||
/// Locales for Accept-Language header
|
||||
pub locales: Option<Vec<String>>,
|
||||
/// HTTP version: "1" or "2"
|
||||
pub http_version: Option<String>,
|
||||
/// Screen dimension constraints
|
||||
pub screen: Option<ScreenConstraints>,
|
||||
/// Whether to mock WebRTC
|
||||
pub mock_web_rtc: bool,
|
||||
/// Slim mode (fewer evasions)
|
||||
pub slim: bool,
|
||||
}
|
||||
|
||||
/// Constraints for screen dimensions.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct ScreenConstraints {
|
||||
pub min_width: Option<u32>,
|
||||
pub max_width: Option<u32>,
|
||||
pub min_height: Option<u32>,
|
||||
pub max_height: Option<u32>,
|
||||
}
|
||||
|
||||
impl ScreenConstraints {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn with_min_width(mut self, width: u32) -> Self {
|
||||
self.min_width = Some(width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_width(mut self, width: u32) -> Self {
|
||||
self.max_width = Some(width);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_min_height(mut self, height: u32) -> Self {
|
||||
self.min_height = Some(height);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_max_height(mut self, height: u32) -> Self {
|
||||
self.max_height = Some(height);
|
||||
self
|
||||
}
|
||||
|
||||
/// Check if a screen size matches these constraints.
|
||||
pub fn matches(&self, width: u32, height: u32) -> bool {
|
||||
if let Some(min_w) = self.min_width {
|
||||
if width < min_w {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(max_w) = self.max_width {
|
||||
if width > max_w {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(min_h) = self.min_height {
|
||||
if height < min_h {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(max_h) = self.max_height {
|
||||
if height > max_h {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Constants used in fingerprint generation.
|
||||
pub const MISSING_VALUE_DATASET_TOKEN: &str = "*MISSING_VALUE*";
|
||||
pub const STRINGIFIED_PREFIX: &str = "*STRINGIFIED*";
|
||||
|
||||
/// Special node names in the Bayesian networks.
|
||||
pub const BROWSER_HTTP_NODE_NAME: &str = "*BROWSER_HTTP";
|
||||
pub const OPERATING_SYSTEM_NODE_NAME: &str = "*OPERATING_SYSTEM";
|
||||
pub const DEVICE_NODE_NAME: &str = "*DEVICE";
|
||||
|
||||
/// Supported browsers.
|
||||
pub const SUPPORTED_BROWSERS: &[&str] = &["chrome", "firefox", "safari", "edge"];
|
||||
|
||||
/// Supported operating systems.
|
||||
pub const SUPPORTED_OPERATING_SYSTEMS: &[&str] = &["windows", "macos", "linux", "android", "ios"];
|
||||
|
||||
/// Supported devices.
|
||||
pub const SUPPORTED_DEVICES: &[&str] = &["desktop", "mobile"];
|
||||
|
||||
/// Supported HTTP versions.
|
||||
pub const SUPPORTED_HTTP_VERSIONS: &[&str] = &["1", "2"];
|
||||
@@ -1,83 +0,0 @@
|
||||
//! OS-specific font lists for Camoufox.
|
||||
//!
|
||||
//! Provides default system fonts for Windows, macOS, and Linux.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::camoufox::data;
|
||||
|
||||
/// Get fonts for the target OS.
|
||||
pub fn get_fonts_for_os(target_os: &str) -> Vec<String> {
|
||||
let fonts_map: HashMap<String, Vec<String>> =
|
||||
serde_json::from_str(data::FONTS_JSON).unwrap_or_default();
|
||||
|
||||
let os_key = match target_os {
|
||||
"win" | "windows" => "win",
|
||||
"mac" | "macos" => "mac",
|
||||
"lin" | "linux" => "lin",
|
||||
_ => "win", // Default to Windows fonts
|
||||
};
|
||||
|
||||
fonts_map.get(os_key).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Get fonts for the target OS with additional custom fonts.
|
||||
pub fn get_fonts_with_custom(target_os: &str, custom_fonts: Option<&[String]>) -> Vec<String> {
|
||||
let mut fonts = get_fonts_for_os(target_os);
|
||||
|
||||
if let Some(custom) = custom_fonts {
|
||||
// Add custom fonts, avoiding duplicates
|
||||
for font in custom {
|
||||
if !fonts.contains(font) {
|
||||
fonts.push(font.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fonts
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_get_fonts_for_windows() {
|
||||
let fonts = get_fonts_for_os("win");
|
||||
assert!(!fonts.is_empty());
|
||||
assert!(fonts.contains(&"Arial".to_string()));
|
||||
assert!(fonts.contains(&"Calibri".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_fonts_for_macos() {
|
||||
let fonts = get_fonts_for_os("mac");
|
||||
assert!(!fonts.is_empty());
|
||||
assert!(fonts.contains(&"Helvetica".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_fonts_for_linux() {
|
||||
let fonts = get_fonts_for_os("lin");
|
||||
assert!(!fonts.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_fonts_with_custom() {
|
||||
let custom = vec!["MyCustomFont".to_string()];
|
||||
let fonts = get_fonts_with_custom("win", Some(&custom));
|
||||
|
||||
assert!(fonts.contains(&"MyCustomFont".to_string()));
|
||||
assert!(fonts.contains(&"Arial".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fonts_no_duplicates() {
|
||||
let custom = vec!["Arial".to_string()]; // Arial already exists in Windows fonts
|
||||
let fonts = get_fonts_with_custom("win", Some(&custom));
|
||||
|
||||
// Count occurrences of Arial
|
||||
let arial_count = fonts.iter().filter(|f| *f == "Arial").count();
|
||||
assert_eq!(arial_count, 1);
|
||||
}
|
||||
}
|
||||
@@ -1,338 +0,0 @@
|
||||
//! Camoufox browser launcher using playwright-rust.
|
||||
//!
|
||||
//! Provides functionality to launch Camoufox browser instances with fingerprint injection.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use playwright::api::{Browser, BrowserContext, Playwright, ProxySettings};
|
||||
use playwright::Error as PlaywrightError;
|
||||
|
||||
use crate::camoufox::config::{CamoufoxConfigBuilder, CamoufoxLaunchConfig, ProxyConfig};
|
||||
use crate::camoufox::fingerprint::types::{Fingerprint, ScreenConstraints};
|
||||
|
||||
/// Camoufox launcher for creating browser instances.
|
||||
pub struct CamoufoxLauncher {
|
||||
playwright: Arc<Playwright>,
|
||||
executable_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Error type for launcher operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum LauncherError {
|
||||
#[error("Playwright error: {0}")]
|
||||
Playwright(PlaywrightError),
|
||||
|
||||
#[error("Playwright Arc error: {0}")]
|
||||
PlaywrightArc(#[from] Arc<PlaywrightError>),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(#[from] crate::camoufox::config::ConfigError),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("Camoufox executable not found at: {0}")]
|
||||
ExecutableNotFound(PathBuf),
|
||||
|
||||
#[error("Failed to generate environment variables: {0}")]
|
||||
EnvVars(#[from] serde_json::Error),
|
||||
}
|
||||
|
||||
/// Options for launching Camoufox.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LaunchOptions {
|
||||
/// Operating system to spoof: "windows", "macos", "linux"
|
||||
pub os: Option<String>,
|
||||
/// Block all images
|
||||
pub block_images: bool,
|
||||
/// Block WebRTC entirely
|
||||
pub block_webrtc: bool,
|
||||
/// Block WebGL (not recommended unless necessary)
|
||||
pub block_webgl: bool,
|
||||
/// Screen dimension constraints
|
||||
pub screen: Option<ScreenConstraints>,
|
||||
/// Fixed window size [width, height]
|
||||
pub window: Option<(u32, u32)>,
|
||||
/// Custom fingerprint (if not provided, one will be generated)
|
||||
pub fingerprint: Option<Fingerprint>,
|
||||
/// Run in headless mode
|
||||
pub headless: bool,
|
||||
/// Custom fonts to load
|
||||
pub fonts: Option<Vec<String>>,
|
||||
/// Only use custom fonts (disable OS fonts)
|
||||
pub custom_fonts_only: bool,
|
||||
/// Firefox user preferences
|
||||
pub firefox_user_prefs: Option<HashMap<String, serde_json::Value>>,
|
||||
/// Proxy configuration
|
||||
pub proxy: Option<ProxyConfig>,
|
||||
/// Additional browser arguments
|
||||
pub args: Option<Vec<String>>,
|
||||
/// Additional environment variables
|
||||
pub env: Option<HashMap<String, String>>,
|
||||
/// Profile/user data directory
|
||||
pub user_data_dir: Option<PathBuf>,
|
||||
/// Enable debug output
|
||||
pub debug: bool,
|
||||
}
|
||||
|
||||
impl CamoufoxLauncher {
|
||||
/// Create a new Camoufox launcher.
|
||||
pub async fn new(executable_path: impl AsRef<Path>) -> Result<Self, LauncherError> {
|
||||
let executable_path = executable_path.as_ref().to_path_buf();
|
||||
|
||||
if !executable_path.exists() {
|
||||
return Err(LauncherError::ExecutableNotFound(executable_path));
|
||||
}
|
||||
|
||||
let playwright = Playwright::initialize()
|
||||
.await
|
||||
.map_err(LauncherError::Playwright)?;
|
||||
|
||||
Ok(Self {
|
||||
playwright: Arc::new(playwright),
|
||||
executable_path,
|
||||
})
|
||||
}
|
||||
|
||||
/// Launch a new Camoufox browser instance.
|
||||
pub async fn launch(&self, options: LaunchOptions) -> Result<Browser, LauncherError> {
|
||||
let config = self.build_config(&options)?;
|
||||
|
||||
if options.debug {
|
||||
log::debug!("Camoufox config: {:?}", config.fingerprint_config);
|
||||
}
|
||||
|
||||
// Get environment variables
|
||||
let env_vars = config.get_env_vars()?;
|
||||
|
||||
// Build launch arguments
|
||||
let mut args = options.args.clone().unwrap_or_default();
|
||||
|
||||
// Add headless flag if needed
|
||||
if options.headless {
|
||||
args.push("--headless".to_string());
|
||||
}
|
||||
|
||||
// Merge environment variables
|
||||
let mut env = options.env.clone().unwrap_or_default();
|
||||
for (key, value) in env_vars {
|
||||
env.insert(key, value);
|
||||
}
|
||||
|
||||
// Handle fontconfig on Linux
|
||||
if cfg!(target_os = "linux") {
|
||||
if let Some(fontconfig_path) =
|
||||
crate::camoufox::env_vars::get_fontconfig_env(&config.target_os, &self.executable_path)
|
||||
{
|
||||
env.insert("FONTCONFIG_PATH".to_string(), fontconfig_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Build Firefox user prefs
|
||||
let mut firefox_prefs = config.firefox_prefs.clone();
|
||||
if let Some(user_prefs) = options.firefox_user_prefs {
|
||||
for (key, value) in user_prefs {
|
||||
firefox_prefs.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the Firefox browser type
|
||||
let firefox = self.playwright.firefox();
|
||||
|
||||
// Build launch options
|
||||
let mut launch_options = firefox.launcher();
|
||||
launch_options = launch_options.executable(&self.executable_path);
|
||||
launch_options = launch_options.headless(options.headless);
|
||||
|
||||
// Add args
|
||||
if !args.is_empty() {
|
||||
launch_options = launch_options.args(&args);
|
||||
}
|
||||
|
||||
// Add environment as serde_json::Map
|
||||
if !env.is_empty() {
|
||||
let env_map: serde_json::Map<String, serde_json::Value> = env
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, serde_json::Value::String(v)))
|
||||
.collect();
|
||||
launch_options = launch_options.env(env_map);
|
||||
}
|
||||
|
||||
// Add proxy if configured
|
||||
if let Some(proxy) = &config.proxy {
|
||||
let proxy_settings = ProxySettings {
|
||||
server: proxy.server.clone(),
|
||||
username: proxy.username.clone(),
|
||||
password: proxy.password.clone(),
|
||||
bypass: proxy.bypass.clone(),
|
||||
};
|
||||
launch_options = launch_options.proxy(proxy_settings);
|
||||
}
|
||||
|
||||
// Add Firefox preferences
|
||||
if !firefox_prefs.is_empty() {
|
||||
let prefs_map: serde_json::Map<String, serde_json::Value> =
|
||||
firefox_prefs.into_iter().collect();
|
||||
launch_options = launch_options.firefox_user_prefs(prefs_map);
|
||||
}
|
||||
|
||||
// Launch the browser
|
||||
let browser = launch_options.launch().await?;
|
||||
|
||||
Ok(browser)
|
||||
}
|
||||
|
||||
/// Launch a persistent browser context.
|
||||
pub async fn launch_persistent_context(
|
||||
&self,
|
||||
user_data_dir: impl AsRef<Path>,
|
||||
options: LaunchOptions,
|
||||
) -> Result<BrowserContext, LauncherError> {
|
||||
let config = self.build_config(&options)?;
|
||||
|
||||
if options.debug {
|
||||
log::debug!("Camoufox config: {:?}", config.fingerprint_config);
|
||||
}
|
||||
|
||||
// Get environment variables
|
||||
let env_vars = config.get_env_vars()?;
|
||||
|
||||
// Build launch arguments
|
||||
let mut args = options.args.clone().unwrap_or_default();
|
||||
|
||||
if options.headless {
|
||||
args.push("--headless".to_string());
|
||||
}
|
||||
|
||||
// Merge environment variables
|
||||
let mut env = options.env.clone().unwrap_or_default();
|
||||
for (key, value) in env_vars {
|
||||
env.insert(key, value);
|
||||
}
|
||||
|
||||
// Handle fontconfig on Linux
|
||||
if cfg!(target_os = "linux") {
|
||||
if let Some(fontconfig_path) =
|
||||
crate::camoufox::env_vars::get_fontconfig_env(&config.target_os, &self.executable_path)
|
||||
{
|
||||
env.insert("FONTCONFIG_PATH".to_string(), fontconfig_path);
|
||||
}
|
||||
}
|
||||
|
||||
// Build Firefox user prefs
|
||||
let mut firefox_prefs = config.firefox_prefs.clone();
|
||||
if let Some(user_prefs) = options.firefox_user_prefs {
|
||||
for (key, value) in user_prefs {
|
||||
firefox_prefs.insert(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
// Get the Firefox browser type
|
||||
let firefox = self.playwright.firefox();
|
||||
|
||||
// Build persistent context options
|
||||
let mut context_options = firefox.persistent_context_launcher(user_data_dir.as_ref());
|
||||
context_options = context_options.executable(&self.executable_path);
|
||||
context_options = context_options.headless(options.headless);
|
||||
|
||||
// Add args
|
||||
if !args.is_empty() {
|
||||
context_options = context_options.args(&args);
|
||||
}
|
||||
|
||||
// Add environment as serde_json::Map
|
||||
if !env.is_empty() {
|
||||
let env_map: serde_json::Map<String, serde_json::Value> = env
|
||||
.into_iter()
|
||||
.map(|(k, v)| (k, serde_json::Value::String(v)))
|
||||
.collect();
|
||||
context_options = context_options.env(env_map);
|
||||
}
|
||||
|
||||
// Add proxy if configured
|
||||
if let Some(proxy) = &config.proxy {
|
||||
let proxy_settings = ProxySettings {
|
||||
server: proxy.server.clone(),
|
||||
username: proxy.username.clone(),
|
||||
password: proxy.password.clone(),
|
||||
bypass: proxy.bypass.clone(),
|
||||
};
|
||||
context_options = context_options.proxy(proxy_settings);
|
||||
}
|
||||
|
||||
// Note: PersistentContextLauncher doesn't support firefox_user_prefs
|
||||
// Firefox preferences should be set via about:config or prefs.js in the profile
|
||||
|
||||
// Launch the persistent context
|
||||
let context = context_options.launch().await?;
|
||||
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
/// Build Camoufox configuration from launch options.
|
||||
fn build_config(&self, options: &LaunchOptions) -> Result<CamoufoxLaunchConfig, LauncherError> {
|
||||
let mut builder = CamoufoxConfigBuilder::new();
|
||||
|
||||
if let Some(os) = &options.os {
|
||||
builder = builder.operating_system(os);
|
||||
}
|
||||
|
||||
if let Some(screen) = &options.screen {
|
||||
builder = builder.screen_constraints(screen.clone());
|
||||
}
|
||||
|
||||
if let Some(fingerprint) = &options.fingerprint {
|
||||
builder = builder.fingerprint(fingerprint.clone());
|
||||
}
|
||||
|
||||
builder = builder.block_images(options.block_images);
|
||||
builder = builder.block_webrtc(options.block_webrtc);
|
||||
builder = builder.block_webgl(options.block_webgl);
|
||||
builder = builder.headless(options.headless);
|
||||
|
||||
if let Some(fonts) = &options.fonts {
|
||||
builder = builder.custom_fonts(fonts.clone());
|
||||
}
|
||||
|
||||
builder = builder.custom_fonts_only(options.custom_fonts_only);
|
||||
|
||||
if let Some(proxy) = &options.proxy {
|
||||
builder = builder.proxy(proxy.clone());
|
||||
}
|
||||
|
||||
// Get Firefox version from executable
|
||||
if let Some(version) = crate::camoufox::config::get_firefox_version(&self.executable_path) {
|
||||
builder = builder.ff_version(version);
|
||||
}
|
||||
|
||||
Ok(builder.build()?)
|
||||
}
|
||||
|
||||
/// Get the executable path.
|
||||
pub fn executable_path(&self) -> &Path {
|
||||
&self.executable_path
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience function to launch Camoufox with default settings.
|
||||
pub async fn launch_camoufox(
|
||||
executable_path: impl AsRef<Path>,
|
||||
options: LaunchOptions,
|
||||
) -> Result<Browser, LauncherError> {
|
||||
let launcher = CamoufoxLauncher::new(executable_path).await?;
|
||||
launcher.launch(options).await
|
||||
}
|
||||
|
||||
/// Convenience function to launch a persistent Camoufox context.
|
||||
pub async fn launch_persistent_camoufox(
|
||||
executable_path: impl AsRef<Path>,
|
||||
user_data_dir: impl AsRef<Path>,
|
||||
options: LaunchOptions,
|
||||
) -> Result<BrowserContext, LauncherError> {
|
||||
let launcher = CamoufoxLauncher::new(executable_path).await?;
|
||||
launcher
|
||||
.launch_persistent_context(user_data_dir, options)
|
||||
.await
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
//! Camoufox browser integration module.
|
||||
//!
|
||||
//! Provides native Rust support for launching Camoufox browsers with realistic
|
||||
//! fingerprint injection using playwright-rust.
|
||||
//!
|
||||
//! # Overview
|
||||
//!
|
||||
//! This module replaces the previous Node.js-based nodecar implementation with
|
||||
//! a pure Rust solution. Key components:
|
||||
//!
|
||||
//! - **Fingerprint Generation**: Bayesian network-based fingerprint generation
|
||||
//! - **WebGL Sampling**: Realistic WebGL configurations from a SQLite database
|
||||
//! - **Configuration Builder**: Converts fingerprints to Camoufox config format
|
||||
//! - **Launcher**: playwright-rust integration for browser launching
|
||||
//!
|
||||
//! # Example
|
||||
//!
|
||||
//! ```rust,ignore
|
||||
//! use donutbrowser_lib::camoufox::{CamoufoxLauncher, LaunchOptions};
|
||||
//!
|
||||
//! async fn launch_browser() -> Result<(), Box<dyn std::error::Error>> {
|
||||
//! let launcher = CamoufoxLauncher::new("/path/to/camoufox").await?;
|
||||
//!
|
||||
//! let options = LaunchOptions {
|
||||
//! os: Some("windows".to_string()),
|
||||
//! headless: false,
|
||||
//! ..Default::default()
|
||||
//! };
|
||||
//!
|
||||
//! let browser = launcher.launch(options).await?;
|
||||
//!
|
||||
//! // Use the browser...
|
||||
//!
|
||||
//! browser.close().await?;
|
||||
//! Ok(())
|
||||
//! }
|
||||
//! ```
|
||||
|
||||
pub mod config;
|
||||
pub mod data;
|
||||
pub mod env_vars;
|
||||
pub mod fingerprint;
|
||||
pub mod fonts;
|
||||
pub mod geolocation;
|
||||
pub mod launcher;
|
||||
pub mod presets;
|
||||
pub mod webgl;
|
||||
|
||||
// Re-export main types for convenience
|
||||
pub use config::{
|
||||
CamoufoxConfigBuilder, CamoufoxLaunchConfig, ConfigError, GeoIPOption, ProxyConfig,
|
||||
};
|
||||
pub use fingerprint::types::{
|
||||
Fingerprint, FingerprintOptions, FingerprintWithHeaders, NavigatorFingerprint, ScreenConstraints,
|
||||
ScreenFingerprint, VideoCard,
|
||||
};
|
||||
pub use fingerprint::{FingerprintError, FingerprintGenerator};
|
||||
pub use geolocation::{
|
||||
fetch_public_ip, get_geolocation, is_geoip_available, is_ipv4, is_ipv6, validate_ip, Geolocation,
|
||||
GeolocationError, Locale, LocaleSelector,
|
||||
};
|
||||
pub use launcher::{
|
||||
launch_camoufox, launch_persistent_camoufox, CamoufoxLauncher, LaunchOptions, LauncherError,
|
||||
};
|
||||
pub use webgl::{sample_webgl, WebGLData, WebGLError};
|
||||
|
||||
/// Unified error type for all Camoufox operations.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum CamoufoxError {
|
||||
#[error("Launcher error: {0}")]
|
||||
Launcher(#[from] LauncherError),
|
||||
|
||||
#[error("Configuration error: {0}")]
|
||||
Config(#[from] ConfigError),
|
||||
|
||||
#[error("Fingerprint error: {0}")]
|
||||
Fingerprint(#[from] FingerprintError),
|
||||
|
||||
#[error("WebGL error: {0}")]
|
||||
WebGL(#[from] WebGLError),
|
||||
|
||||
#[error("Geolocation error: {0}")]
|
||||
Geolocation(#[from] GeolocationError),
|
||||
|
||||
#[error("IO error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_fingerprint_generation() {
|
||||
let generator = FingerprintGenerator::new().unwrap();
|
||||
let options = FingerprintOptions {
|
||||
browsers: Some(vec!["firefox".to_string()]),
|
||||
operating_system: Some("windows".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let result = generator.get_fingerprint(&options);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let fp = result.unwrap();
|
||||
assert!(!fp.fingerprint.navigator.user_agent.is_empty());
|
||||
assert!(fp.fingerprint.screen.width > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_builder() {
|
||||
let config = CamoufoxConfigBuilder::new()
|
||||
.operating_system("windows")
|
||||
.block_images(false)
|
||||
.build();
|
||||
|
||||
assert!(config.is_ok());
|
||||
|
||||
let config = config.unwrap();
|
||||
assert!(!config.fingerprint_config.is_empty());
|
||||
assert!(config
|
||||
.fingerprint_config
|
||||
.contains_key("navigator.userAgent"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_webgl_sampling() {
|
||||
let result = webgl::sample_webgl("win", None, None);
|
||||
assert!(result.is_ok());
|
||||
|
||||
let webgl_data = result.unwrap();
|
||||
assert!(!webgl_data.vendor.is_empty());
|
||||
assert!(!webgl_data.renderer.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fonts() {
|
||||
let fonts = fonts::get_fonts_for_os("win");
|
||||
assert!(!fonts.is_empty());
|
||||
assert!(fonts.contains(&"Arial".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_env_vars() {
|
||||
let mut config = std::collections::HashMap::new();
|
||||
config.insert(
|
||||
"navigator.userAgent".to_string(),
|
||||
serde_json::json!("Mozilla/5.0"),
|
||||
);
|
||||
|
||||
let env_vars = env_vars::config_to_env_vars(&config).unwrap();
|
||||
assert!(!env_vars.is_empty());
|
||||
assert!(env_vars.contains_key("CAMOU_CONFIG_1"));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user