Compare commits

..

25 Commits

Author SHA1 Message Date
zhom 9624ec846d refactor: harden tests 2026-07-21 23:13:17 +04:00
zhom f7daf68b52 chore: add cross-platform webdriver tests 2026-07-20 03:26:31 +04:00
zhom 8fe38453d4 fix: properly handle x-amz-meta-updated-at 2026-07-20 00:31:43 +04:00
zhom a71dad735e refactor: block windows app update if the browser is running 2026-07-20 00:16:49 +04:00
andy a4ed5c855a Merge pull request #512 from zhom/dependabot/github_actions/github-actions-0376029783
ci(deps): bump the github-actions group with 2 updates
2026-07-18 11:06:24 +02:00
dependabot[bot] 32fcd2328c ci(deps): bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [actions/setup-node](https://github.com/actions/setup-node) and [anomalyco/opencode/github](https://github.com/anomalyco/opencode).


Updates `actions/setup-node` from 6 to 7
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

Updates `anomalyco/opencode/github` from 1.17.18 to 1.18.3
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/b1fc8113948b518835c2a39ece49553cffe9b30c...127bdb30784d508cc556c71a0f32b508a3061517)

---
updated-dependencies:
- dependency-name: actions/setup-node
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: anomalyco/opencode/github
  dependency-version: 1.18.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-18 09:05:02 +00:00
andy f84dc3f959 Merge pull request #511 from zhom/contributors-readme-action-26yrabQoS1
docs(contributor): contributors readme action update
2026-07-18 09:47:03 +02:00
github-actions[bot] bf0d0d59a7 docs(contributor): contrib-readme-action has updated readme 2026-07-18 07:42:42 +00:00
zhom f1664b2950 refactor: ui refresh 2026-07-18 11:42:07 +04:00
zhom 4f7910dd23 feat: mass import via gui, api, and mcp 2026-07-17 02:05:34 +04:00
zhom e1c9ce6525 refactors: animations cleanup 2026-07-16 23:56:59 +04:00
andy cef522649a Merge pull request #508 from zhom/contributors-readme-action-Vk126eR_TC
docs(contributor): contributors readme action update
2026-07-16 19:49:01 +02:00
github-actions[bot] f95816d70d docs(contributor): contrib-readme-action has updated readme 2026-07-16 17:45:44 +00:00
andy b15231d752 Merge pull request #505 from mchnkkc/feat/turkish-locale
feat: add Turkish (tr) language support
2026-07-16 19:45:28 +02:00
andy af792745fc Merge pull request #506 from xenos1337/fix/ui-interaction-issues
fix: improve UI interactions and page consistency
2026-07-16 19:29:11 +02:00
xenos 22f976442b restore settings redirect 2026-07-16 19:24:12 +02:00
xenos 809a95c729 fix group create translation key 2026-07-16 15:10:20 +02:00
xenos cea4ece698 fix: improve UI interactions and page consistency 2026-07-16 14:37:13 +02:00
cihan ac03b70f94 feat: add Turkish (tr) language support
Adds a complete Turkish translation (all 1648 keys, validated against
en.json for key parity and {{placeholder}} consistency) and registers
Turkish in SUPPORTED_LANGUAGES.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-16 14:54:55 +03:00
github-actions[bot] 95f84248ab chore: update flake.nix for v0.28.2 [skip ci] (#501)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-12 20:44:29 +00:00
github-actions[bot] dd5357d6c3 docs: update CHANGELOG.md and README.md for v0.28.2 [skip ci] (#500)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-07-12 20:44:06 +00:00
zhom 7b7849a54a chore: version bump 2026-07-12 23:29:52 +04:00
andy cb0ec75d37 Merge pull request #497 from zhom/dependabot/github_actions/github-actions-ce149d7018
ci(deps): bump the github-actions group with 2 updates
2026-07-11 19:38:20 +02:00
zhom ae8afbb158 refactor: api cleanup 2026-07-11 21:37:41 +04:00
dependabot[bot] 97f1f52a6d ci(deps): bump the github-actions group with 2 updates
---
updated-dependencies:
- dependency-name: anomalyco/opencode/github
  dependency-version: 1.17.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/stale
  dependency-version: 10.4.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-11 09:05:11 +00:00
160 changed files with 29538 additions and 5336 deletions
+6 -1
View File
@@ -2,6 +2,11 @@ name: Bug Report
description: Something isn't working description: Something isn't working
labels: ["bug"] labels: ["bug"]
body: 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 - type: textarea
id: description id: description
attributes: attributes:
@@ -54,7 +59,7 @@ body:
id: logs id: logs
attributes: attributes:
label: Error logs or screenshots 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 placeholder: Paste logs here or drag screenshots
validations: validations:
required: false required: false
@@ -2,6 +2,11 @@ name: Feature Request
description: Suggest a new feature description: Suggest a new feature
labels: ["enhancement"] labels: ["enhancement"]
body: 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 - type: textarea
id: description id: description
attributes: attributes:
+269
View File
@@ -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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
path: donutbrowser
persist-credentials: false
- name: Checkout test driver
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
path: donutbrowser
persist-credentials: false
- name: Checkout test driver
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
path: donutbrowser
persist-credentials: false
- name: Checkout test driver
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
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
+9 -1
View File
@@ -2,6 +2,12 @@ name: "CodeQL"
on: on:
workflow_call: workflow_call:
inputs:
checkout_ref:
description: Optional commit to check out instead of the triggering ref
required: false
type: string
default: ""
push: push:
branches: ["main"] branches: ["main"]
pull_request: pull_request:
@@ -32,6 +38,8 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
ref: ${{ inputs.checkout_ref }}
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
@@ -39,7 +47,7 @@ jobs:
run_install: false run_install: false
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with: with:
node-version-file: .node-version node-version-file: .node-version
cache: "pnpm" cache: "pnpm"
+8 -4
View File
@@ -30,7 +30,8 @@ jobs:
name: Lint JavaScript/TypeScript name: Lint JavaScript/TypeScript
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]' if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/lint-js.yml uses: ./.github/workflows/lint-js.yml
secrets: inherit with:
checkout_ref: ${{ github.event.pull_request.head.sha }}
permissions: permissions:
contents: read contents: read
@@ -38,7 +39,8 @@ jobs:
name: Lint Rust name: Lint Rust
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]' if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/lint-rs.yml uses: ./.github/workflows/lint-rs.yml
secrets: inherit with:
checkout_ref: ${{ github.event.pull_request.head.sha }}
permissions: permissions:
contents: read contents: read
@@ -46,7 +48,8 @@ jobs:
name: CodeQL name: CodeQL
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]' if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/codeql.yml uses: ./.github/workflows/codeql.yml
secrets: inherit with:
checkout_ref: ${{ github.event.pull_request.head.sha }}
permissions: permissions:
security-events: write security-events: write
contents: read contents: read
@@ -57,7 +60,8 @@ jobs:
name: Spell Check name: Spell Check
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]' if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/spellcheck.yml uses: ./.github/workflows/spellcheck.yml
secrets: inherit with:
checkout_ref: ${{ github.event.pull_request.head.sha }}
permissions: permissions:
contents: read contents: read
+16 -5
View File
@@ -11,6 +11,11 @@ on:
description: "Docker tag (e.g., v1.0.0)" description: "Docker tag (e.g., v1.0.0)"
required: true required: true
type: string type: string
secrets:
DOCKERHUB_USERNAME:
required: true
DOCKERHUB_TOKEN:
required: true
workflow_dispatch: workflow_dispatch:
inputs: inputs:
tag: tag:
@@ -43,23 +48,29 @@ jobs:
- name: Determine tags - name: Determine tags
id: tags id: tags
env:
INPUT_TAG: ${{ inputs.tag }}
EVENT_NAME: ${{ github.event_name }}
COMMIT_SHA: ${{ github.sha }}
run: | run: |
TAGS="" TAGS=""
INPUT_TAG="${{ inputs.tag }}"
if [ -n "$INPUT_TAG" ]; then if [ -n "$INPUT_TAG" ]; then
# Called from release workflow or manual dispatch # 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="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${INPUT_TAG}"
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest" 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 # 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="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly"
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${SHORT_SHA}" TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${SHORT_SHA}"
fi fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" printf 'tags=%s\n' "$TAGS" >> "$GITHUB_OUTPUT"
echo "Tags: ${TAGS}"
- name: Build and push Docker image - name: Build and push Docker image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a #v7.3.0 uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a #v7.3.0
+3 -5
View File
@@ -29,8 +29,8 @@ jobs:
ISSUE_TITLE: ${{ github.event.issue.title }} ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }} ISSUE_BODY: ${{ github.event.issue.body }}
run: | run: |
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt printf '%s' "$ISSUE_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt printf '%s' "${ISSUE_BODY:-}" | node scripts/redact-sensitive-text.mjs --issue-body > /tmp/issue-body.txt
- name: Build prompt - name: Build prompt
run: | run: |
@@ -92,11 +92,9 @@ jobs:
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; treating as compliant" echo "::warning::Model returned non-JSON; treating as compliant"
cat /tmp/raw.txt
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
fi fi
echo "Result:" echo "Compliance response validated"
cat /tmp/result.json
- name: Build comment - name: Build comment
id: build id: build
+48 -40
View File
@@ -14,7 +14,6 @@ permissions:
contents: read contents: read
issues: write issues: write
pull-requests: write pull-requests: write
id-token: write
env: env:
# Single source of truth for the model used by both triage and composer. # Single source of truth for the model used by both triage and composer.
@@ -49,8 +48,9 @@ jobs:
env: env:
ISSUE_BODY: ${{ github.event.issue.body }} ISSUE_BODY: ${{ github.event.issue.body }}
run: | run: |
node <<'EOF' node --input-type=module <<'EOF'
const fs = require('node:fs'); import fs from 'node:fs';
import { redactIssueBody, redactSensitiveText } from './scripts/redact-sensitive-text.mjs';
const body = process.env.ISSUE_BODY || ''; const body = process.env.ISSUE_BODY || '';
// GitHub issue templates render fields as `### Heading\nValue` blocks. // GitHub issue templates render fields as `### Heading\nValue` blocks.
// Split on `###` at line start to recover them. // Split on `###` at line start to recover them.
@@ -61,29 +61,27 @@ jobs:
if (nl < 0) continue; if (nl < 0) continue;
const heading = section.slice(0, nl).trim(); const heading = section.slice(0, nl).trim();
const value = section.slice(nl + 1).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)); 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] || ''; const get = (k) => fields[k] || '';
fs.writeFileSync('/tmp/issue-os.txt', get('Operating System')); fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version')); fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
fs.writeFileSync('/tmp/issue-wayfern-version.txt', get('Wayfern version')); fs.writeFileSync('/tmp/issue-wayfern-version.txt', get('Wayfern version'));
fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce')); 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-what.txt', get('What happened?') || get('What do you want?'));
fs.writeFileSync('/tmp/issue-body.txt', redactIssueBody(body));
EOF EOF
echo "Parsed fields:"
cat /tmp/issue-fields.json
- name: Build repo context - name: Build repo context
env: env:
ISSUE_TITLE: ${{ github.event.issue.title }} ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: | run: |
cp CLAUDE.md /tmp/repo-context.txt cp CLAUDE.md /tmp/repo-context.txt
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt printf '%s' "$ISSUE_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
# List all source files for the AI to choose from # List all source files for the AI to choose from
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \ find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
@@ -199,7 +197,7 @@ jobs:
Return ONLY valid JSON. No preamble, no code fences. Schema: Return ONLY valid JSON. No preamble, no code fences. Schema:
{ {
"language": "en" or ISO 639-1 code, "language": "en" or ISO 639-1 code,
"classification": one of ["bug-in-scope", "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", "operating_system": "macos" | "windows" | "linux" | "unknown",
"is_paid_feature": true | false, "is_paid_feature": true | false,
"user_followed_template": true | false, "user_followed_template": true | false,
@@ -211,11 +209,11 @@ jobs:
Classification guidance: Classification guidance:
- "bug-template-violation": missing or filled-in nonsense for required template fields. - "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. - "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
- "regression": user names a prior version that worked. - "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 a 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 TRIAGE_TAIL
} > /tmp/triage-system.txt } > /tmp/triage-system.txt
wc -c /tmp/triage-system.txt wc -c /tmp/triage-system.txt
@@ -238,7 +236,7 @@ jobs:
messages: [ messages: [
{ role: "system", content: $system_prompt }, { role: "system", content: $system_prompt },
{ role: "user", { 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) }
] ]
}') }')
@@ -249,14 +247,12 @@ jobs:
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt 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 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 # Fall back to a safe classification when the response is not JSON.
# composer still gets called and produces SOMETHING.
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
echo "::warning::Triage returned non-JSON; using fallback classification" echo "::warning::Triage returned non-JSON; using fallback classification"
cat /tmp/triage-raw.txt
jq -n '{ jq -n '{
language: "en", language: "en",
classification: "bug-in-scope", classification: "bug-in-scope",
@@ -270,17 +266,16 @@ jobs:
}' > /tmp/triage.json }' > /tmp/triage.json
fi fi
echo "Triage result:" echo "Triage response validated"
cat /tmp/triage.json
- name: Read files chosen by triage - name: Read files chosen by triage
run: | run: |
: > /tmp/file-context.txt : > /tmp/file-context.txt
# files_to_read may be empty (e.g. fork-request or junk) — 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 jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do
filepath=$(echo "$filepath" | xargs) filepath=$(echo "$filepath" | xargs)
[ -z "$filepath" ] && continue [ -z "$filepath" ] && continue
# Reject paths that escape the repo or look fishy # Reject paths that escape the repository.
case "$filepath" in case "$filepath" in
/*|*..*|*$'\n'*) continue ;; /*|*..*|*$'\n'*) continue ;;
esac esac
@@ -331,7 +326,7 @@ jobs:
The triage classification (`triage.classification`) determines the response shape: 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-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
- `bug-template-violation` or `ai-generated-junk`: 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. - `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. - `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. - `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. - `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.
@@ -408,8 +403,8 @@ jobs:
+ "Title: " + $title + "Title: " + $title
+ "\nAuthor: " + $author + "\nAuthor: " + $author
+ "\n\n## Triage result\n" + $triage + "\n\n## Triage result\n" + $triage
+ "\n\n## Parsed template fields\n" + $fields + "\n\n## Sanitized template fields\n" + $fields
+ "\n\n## Raw issue body\n" + $body + "\n\n## Sanitized issue body\n" + $body
+ "\n\n## Source files (selected by triage)\n" + $files) } + "\n\n## Source files (selected by triage)\n" + $files) }
] ]
}') }')
@@ -423,8 +418,6 @@ jobs:
if [ ! -s /tmp/ai-comment.txt ]; then if [ ! -s /tmp/ai-comment.txt ]; then
echo "::error::Composer returned empty response" echo "::error::Composer returned empty response"
echo "Raw response:"
echo "$RESPONSE"
exit 1 exit 1
fi fi
@@ -484,8 +477,9 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }} PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
run: | 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)"' \ --jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
> /tmp/pr-files.txt > /tmp/pr-files.txt
@@ -499,14 +493,27 @@ jobs:
cp CLAUDE.md /tmp/repo-context.txt cp CLAUDE.md /tmp/repo-context.txt
: > /tmp/related-file-contents.txt : > /tmp/related-file-contents.txt
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do gh api --paginate "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then --jq '.[] | select(.status != "removed") | [.filename, .sha] | @tsv' |
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt while IFS=$'\t' read -r filepath blob_sha; do
cat "$filepath" >> /tmp/related-file-contents.txt case "$filepath" in
echo "" >> /tmp/related-file-contents.txt /*|*..*|*$'\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 fi
rm -f "$blob_file"
done done
head -c 100000 /tmp/related-file-contents.txt > /tmp/pr-file-context.txt 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 - name: Analyze PR with AI
env: env:
@@ -525,8 +532,8 @@ jobs:
GREETING='This is a first-time contributor. Start your comment with: "Thanks for your first PR!"' GREETING='This is a first-time contributor. Start your comment with: "Thanks for your first PR!"'
fi fi
printf '%s' "$PR_TITLE" > /tmp/pr-title.txt printf '%s' "$PR_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/pr-title.txt
printf '%s' "${PR_BODY:-}" > /tmp/pr-body.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_AUTHOR" > /tmp/pr-author.txt
printf '%s' "$PR_BASE" > /tmp/pr-base.txt printf '%s' "$PR_BASE" > /tmp/pr-base.txt
printf '%s' "$PR_HEAD" > /tmp/pr-head.txt printf '%s' "$PR_HEAD" > /tmp/pr-head.txt
@@ -550,7 +557,7 @@ jobs:
messages: [ messages: [
{ {
role: "system", 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", role: "user",
@@ -577,8 +584,6 @@ jobs:
if [ ! -s /tmp/ai-comment.txt ]; then if [ ! -s /tmp/ai-comment.txt ]; then
echo "::error::AI response was empty" echo "::error::AI response was empty"
echo "Raw response:"
echo "$RESPONSE"
exit 1 exit 1
fi fi
@@ -593,6 +598,9 @@ jobs:
if: | if: |
github.repository == 'zhom/donutbrowser' && github.repository == 'zhom/donutbrowser' &&
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') && (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') || (contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') || startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') || contains(github.event.comment.body, ' /opencode') ||
@@ -603,7 +611,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Run opencode - name: Run opencode
uses: anomalyco/opencode/github@10c894bdeef3618f5666fb506ef7f9491bb964d8 #v1.17.13 uses: anomalyco/opencode/github@127bdb30784d508cc556c71a0f32b508a3061517 #v1.18.3
env: env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }} ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }} TOKEN: ${{ secrets.GITHUB_TOKEN }}
+9 -1
View File
@@ -4,6 +4,12 @@ name: Lint Node.js
on: on:
workflow_call: workflow_call:
inputs:
checkout_ref:
description: Optional commit to check out instead of the triggering ref
required: false
type: string
default: ""
push: push:
branches: branches:
- main - main
@@ -35,6 +41,8 @@ jobs:
- name: Checkout repository code - name: Checkout repository code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
ref: ${{ inputs.checkout_ref }}
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
@@ -42,7 +50,7 @@ jobs:
run_install: false run_install: false
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with: with:
node-version-file: .node-version node-version-file: .node-version
cache: "pnpm" cache: "pnpm"
+9 -1
View File
@@ -4,6 +4,12 @@ name: Lint Rust
on: on:
workflow_call: workflow_call:
inputs:
checkout_ref:
description: Optional commit to check out instead of the triggering ref
required: false
type: string
default: ""
push: push:
branches: branches:
- main - main
@@ -42,6 +48,8 @@ jobs:
- name: Checkout repository code - name: Checkout repository code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
ref: ${{ inputs.checkout_ref }}
- name: Set up pnpm package manager - name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
@@ -49,7 +57,7 @@ jobs:
run_install: false run_install: false
- name: Set up Node.js - name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with: with:
node-version-file: .node-version node-version-file: .node-version
cache: "pnpm" cache: "pnpm"
+11 -5
View File
@@ -15,14 +15,12 @@ jobs:
lint-js: lint-js:
name: Lint JavaScript/TypeScript name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions: permissions:
contents: read contents: read
lint-rust: lint-rust:
name: Lint Rust name: Lint Rust
uses: ./.github/workflows/lint-rs.yml uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions: permissions:
contents: read contents: read
@@ -41,19 +39,27 @@ jobs:
sync-e2e: sync-e2e:
name: Sync E2E Tests name: Sync E2E Tests
uses: ./.github/workflows/sync-e2e.yml 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: permissions:
contents: read contents: read
pr-status: pr-status:
name: PR Status Check name: PR Status Check
runs-on: ubuntu-latest 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() if: always()
steps: steps:
- name: Check all jobs succeeded - name: Check all jobs succeeded
run: | 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" echo "One or more checks failed"
exit 1 exit 1
fi fi
+15 -15
View File
@@ -31,17 +31,24 @@ jobs:
env: env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_TAG: ${{ inputs.tag }} INPUT_TAG: ${{ inputs.tag }}
EVENT_NAME: ${{ github.event_name }}
WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
REPOSITORY: ${{ github.repository }}
run: | run: |
if [[ -n "${INPUT_TAG:-}" ]]; then if [[ -n "${INPUT_TAG:-}" ]]; then
echo "tag=${INPUT_TAG}" >> "$GITHUB_OUTPUT" TAG="$INPUT_TAG"
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then elif [[ "$EVENT_NAME" == "workflow_run" ]]; then
# The Release workflow is triggered by a tag push (v*), # The Release workflow is triggered by a tag push (v*),
# so head_branch is the tag name # so head_branch is the tag name
echo "tag=${{ github.event.workflow_run.head_branch }}" >> "$GITHUB_OUTPUT" TAG="$WORKFLOW_HEAD_BRANCH"
else else
TAG=$(gh release view --repo "${{ github.repository }}" --json tagName -q .tagName) TAG=$(gh release view --repo "$REPOSITORY" --json tagName -q .tagName)
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
fi 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 - name: Install tools
run: | run: |
@@ -59,19 +66,12 @@ jobs:
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }} R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }} R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: | run: |
# GitHub injects secrets verbatim. If a value was pasted with # Normalize accidental quotes and whitespace in configured secrets.
# 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.
strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; } 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_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")" export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")" export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")" export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}" bash scripts/publish-repo.sh "$RELEASE_TAG"
+10 -7
View File
@@ -37,7 +37,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: Lint JavaScript/TypeScript name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions: permissions:
contents: read contents: read
@@ -45,7 +44,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: Lint Rust name: Lint Rust
uses: ./.github/workflows/lint-rs.yml uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions: permissions:
contents: read contents: read
@@ -53,7 +51,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: CodeQL name: CodeQL
uses: ./.github/workflows/codeql.yml uses: ./.github/workflows/codeql.yml
secrets: inherit
permissions: permissions:
security-events: write security-events: write
contents: read contents: read
@@ -64,7 +61,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: Spell Check name: Spell Check
uses: ./.github/workflows/spellcheck.yml uses: ./.github/workflows/spellcheck.yml
secrets: inherit
permissions: permissions:
contents: read contents: read
@@ -113,7 +109,7 @@ jobs:
run_install: false run_install: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with: with:
node-version-file: .node-version node-version-file: .node-version
cache: "pnpm" cache: "pnpm"
@@ -160,6 +156,8 @@ jobs:
- name: Build sidecar binaries - name: Build sidecar binaries
shell: bash shell: bash
working-directory: ./src-tauri working-directory: ./src-tauri
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
run: | run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release cargo build --bin donut-proxy --target ${{ matrix.target }} --release
@@ -216,6 +214,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TARGET: ${{ matrix.target }}
# tauri-action invokes `pnpm tauri build`, which runs # tauri-action invokes `pnpm tauri build`, which runs
# `beforeBuildCommand` from tauri.conf.json. That rebuilds the # `beforeBuildCommand` from tauri.conf.json. That rebuilds the
# frontend in its own subprocess, so the env var MUST be forwarded # frontend in its own subprocess, so the env var MUST be forwarded
@@ -557,7 +556,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Trigger Cloudflare Pages deployment - 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: docker:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
@@ -565,7 +566,9 @@ jobs:
uses: ./.github/workflows/docker-sync.yml uses: ./.github/workflows/docker-sync.yml
with: with:
tag: ${{ github.ref_name }} tag: ${{ github.ref_name }}
secrets: inherit secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
update-flake: update-flake:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
+21 -19
View File
@@ -44,7 +44,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: Lint JavaScript/TypeScript name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions: permissions:
contents: read contents: read
@@ -52,7 +51,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: Lint Rust name: Lint Rust
uses: ./.github/workflows/lint-rs.yml uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions: permissions:
contents: read contents: read
@@ -60,7 +58,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: CodeQL name: CodeQL
uses: ./.github/workflows/codeql.yml uses: ./.github/workflows/codeql.yml
secrets: inherit
permissions: permissions:
security-events: write security-events: write
contents: read contents: read
@@ -71,7 +68,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
name: Spell Check name: Spell Check
uses: ./.github/workflows/spellcheck.yml uses: ./.github/workflows/spellcheck.yml
secrets: inherit
permissions: permissions:
contents: read contents: read
@@ -120,7 +116,7 @@ jobs:
run_install: false run_install: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with: with:
node-version-file: .node-version node-version-file: .node-version
cache: "pnpm" cache: "pnpm"
@@ -164,9 +160,25 @@ jobs:
echo "Checking from src-tauri perspective:" echo "Checking from src-tauri perspective:"
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri" 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 - name: Build sidecar binaries
shell: bash shell: bash
working-directory: ./src-tauri working-directory: ./src-tauri
env:
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
run: | run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release cargo build --bin donut-proxy --target ${{ matrix.target }} --release
@@ -214,19 +226,6 @@ jobs:
rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH
- 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 Tauri app - name: Build Tauri app
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f #v1.0.0 uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f #v1.0.0
env: env:
@@ -238,6 +237,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }} APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TARGET: ${{ matrix.target }}
# tauri-action's inner `pnpm tauri build` re-runs beforeBuildCommand # tauri-action's inner `pnpm tauri build` re-runs beforeBuildCommand
# which rebuilds dist/ in a subprocess. The env var must be here too. # which rebuilds dist/ in a subprocess. The env var must be here too.
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }} NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
@@ -416,7 +416,9 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Trigger Cloudflare Pages deployment - 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: notify-discord:
if: github.repository == 'zhom/donutbrowser' if: github.repository == 'zhom/donutbrowser'
+8
View File
@@ -5,6 +5,12 @@ permissions:
on: on:
workflow_call: workflow_call:
inputs:
checkout_ref:
description: Optional commit to check out instead of the triggering ref
required: false
type: string
default: ""
push: push:
branches: ["main"] branches: ["main"]
pull_request: pull_request:
@@ -22,5 +28,7 @@ jobs:
steps: steps:
- name: Checkout Actions Repository - name: Checkout Actions Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
ref: ${{ inputs.checkout_ref }}
- name: Spell Check Repo - name: Spell Check Repo
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 #v1.48.0 uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 #v1.48.0
+1 -1
View File
@@ -13,7 +13,7 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 - uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open." stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
+4 -4
View File
@@ -32,7 +32,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9 uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
@@ -40,7 +40,7 @@ jobs:
run_install: false run_install: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: with:
node-version: "22" node-version: "22"
cache: "pnpm" cache: "pnpm"
@@ -73,7 +73,7 @@ jobs:
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v7.0.0 uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Start MinIO - name: Start MinIO
run: | run: |
@@ -99,7 +99,7 @@ jobs:
run_install: false run_install: false
- name: Setup Node.js - name: Setup Node.js
uses: actions/setup-node@v6 uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with: with:
node-version: "22" node-version: "22"
cache: "pnpm" cache: "pnpm"
+1
View File
@@ -11,6 +11,7 @@
# testing # testing
/coverage /coverage
/e2e/app/target/
# next.js # next.js
/.next/ /.next/
+70 -8
View File
@@ -1,6 +1,6 @@
# ⛔ ABSOLUTE GIT RULE — READ FIRST (2026-06-11) # ⛔ 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) │ ├── app/ # App router (page.tsx, layout.tsx)
│ ├── components/ # 50+ React components (dialogs, tables, UI) │ ├── components/ # 50+ React components (dialogs, tables, UI)
│ ├── hooks/ # Event-driven React hooks │ ├── 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) │ ├── lib/ # Utilities (themes, toast, browser-utils)
│ └── types.ts # Shared TypeScript interfaces │ └── types.ts # Shared TypeScript interfaces
├── src-tauri/ # Rust backend (Tauri) ├── src-tauri/ # Rust backend (Tauri)
@@ -38,6 +38,10 @@ donutbrowser/
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi) │ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
│ │ ├── settings_manager.rs # App settings persistence │ │ ├── settings_manager.rs # App settings persistence
│ │ ├── cookie_manager.rs # Cookie import/export │ │ ├── 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 │ │ ├── extension_manager.rs # Browser extension management
│ │ ├── group_manager.rs # Profile group management │ │ ├── group_manager.rs # Profile group management
│ │ ├── synchronizer.rs # Real-time profile synchronizer │ │ ├── synchronizer.rs # Real-time profile synchronizer
@@ -47,7 +51,10 @@ donutbrowser/
│ └── Cargo.toml # Rust dependencies │ └── Cargo.toml # Rust dependencies
├── donut-sync/ # NestJS sync server (self-hostable) ├── donut-sync/ # NestJS sync server (self-hostable)
│ └── src/ # Controllers, services, auth, S3 sync │ └── 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 ├── flake.nix # Nix development environment
└── .github/workflows/ # CI/CD pipelines └── .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: - 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. `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) ## Logs (when debugging a running app)
Three log surfaces, in order of usefulness: 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()`. - 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. - 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. - 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. - 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. - **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. - 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) ## 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`. 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", …)`. 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. 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 ## 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: 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`: 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. - `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. - `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 19 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table. - `matchesGroupDigit(event)` returns 19 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. 1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`. 2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`. 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. 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.
+23
View File
@@ -1,6 +1,29 @@
# Changelog # 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) ## v0.28.1 (2026-07-09)
### Refactoring ### Refactoring
+21 -7
View File
@@ -46,7 +46,7 @@
| | Apple Silicon | Intel | | | Apple Silicon | Intel |
|---|---|---| |---|---|---|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_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: Or install via Homebrew:
@@ -56,15 +56,15 @@ brew install --cask donut
### Windows ### Windows
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_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 ### Linux
| Format | x86_64 | ARM64 | | Format | x86_64 | ARM64 |
|---|---|---| |---|---|---|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_arm64.deb) | | **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.1/Donut-0.28.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut-0.28.1-1.aarch64.rpm) | | **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.1/Donut_0.28.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_aarch64.AppImage) | | **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 --> <!-- install-links-end -->
Or install via package manager: Or install via package manager:
@@ -135,6 +135,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>Hassiy</b></sub> <sub><b>Hassiy</b></sub>
</a> </a>
</td> </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"> <td align="center">
<a href="https://github.com/webees"> <a href="https://github.com/webees">
<img src="https://avatars.githubusercontent.com/u/5155291?v=4" width="100;" alt="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> <sub><b>Huy Le</b></sub>
</a> </a>
</td> </td>
</tr>
<tr>
<td align="center"> <td align="center">
<a href="https://github.com/drunkod"> <a href="https://github.com/drunkod">
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="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> <sub><b>drunkod</b></sub>
</a> </a>
</td> </td>
</tr>
<tr>
<td align="center"> <td align="center">
<a href="https://github.com/JorySeverijnse"> <a href="https://github.com/JorySeverijnse">
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="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> <sub><b>Thiago Mafra</b></sub>
</a> </a>
</td> </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"> <td align="center">
<a href="https://github.com/liasica"> <a href="https://github.com/liasica">
<img src="https://avatars.githubusercontent.com/u/671431?v=4" width="100;" alt="liasica"/> <img src="https://avatars.githubusercontent.com/u/671431?v=4" width="100;" alt="liasica"/>
+10 -1
View File
@@ -329,7 +329,16 @@ export class SyncService implements OnModuleInit {
Metadata: metadata, 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/ // Report profile usage after upload presign if key is under profiles/
if (ctx.mode === "cloud" && dto.key.startsWith("profiles/")) { if (ctx.mode === "cloud" && dto.key.startsWith("profiles/")) {
+61
View File
@@ -17,6 +17,7 @@ import {
interface PresignResponse { interface PresignResponse {
url: string; url: string;
expiresAt: string; expiresAt: string;
metadata?: Record<string, string>;
} }
interface ListResponse { interface ListResponse {
@@ -34,6 +35,7 @@ interface StatResponse {
exists: boolean; exists: boolean;
size?: number; size?: number;
lastModified?: string; lastModified?: string;
metadata?: Record<string, string>;
} }
describe("SyncController (e2e)", () => { describe("SyncController (e2e)", () => {
@@ -112,6 +114,65 @@ describe("SyncController (e2e)", () => {
expect(body.url).toContain("test/upload-key.txt"); expect(body.url).toContain("test/upload-key.txt");
expect(body.expiresAt).toBeDefined(); 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", () => { describe("POST /v1/objects/presign-download", () => {
+93
View File
@@ -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`.
+9073
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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" }
+7
View File
@@ -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())
});
}
+286
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
+99
View File
@@ -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;
}
+168
View File
@@ -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";
}
+178
View File
@@ -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
View File
@@ -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();
+426
View File
@@ -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",
);
}
});
+145
View File
@@ -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");
});
+116
View File
@@ -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);
});
+501
View File
@@ -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],
});
});
});
+403
View File
@@ -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);
});
});
+605
View File
@@ -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();
}
});
+171
View File
@@ -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();
}
});
+577
View File
@@ -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(),
]);
}
});
+449
View File
@@ -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" },
);
});
});
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" ( pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs pkgConfigLibs ++ map lib.getDev pkgConfigLibs
); );
releaseVersion = "0.28.1"; releaseVersion = "0.28.2";
releaseAppImage = releaseAppImage =
if system == "x86_64-linux" then if system == "x86_64-linux" then
pkgs.fetchurl { pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_amd64.AppImage"; url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage";
hash = "sha256-ItjyK206mpwMXzTNnSHHM8L2R8EjvEP3mrKnz6ze4oI="; hash = "sha256-+CqHiPMg4oczNiPg+MC6jvp0CUcK4kb5yeyk+QDbAWY=";
} }
else if system == "aarch64-linux" then else if system == "aarch64-linux" then
pkgs.fetchurl { pkgs.fetchurl {
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_aarch64.AppImage"; url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage";
hash = "sha256-ZdQKOJRwVB9Wb/ncC8j9ezoaOqPuj0zjuDB3N3r2JYY="; hash = "sha256-HodokW2ySIpdpW7Hyqpwsm8whQ0hHldlSg11Sl1UW3k=";
} }
else else
null; null;
+12 -5
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser", "name": "donutbrowser",
"private": true, "private": true,
"license": "AGPL-3.0", "license": "AGPL-3.0",
"version": "0.28.1", "version": "0.28.2",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "next dev --turbopack -p 12341", "dev": "next dev --turbopack -p 12341",
@@ -12,22 +12,30 @@
"test:rust": "cd src-tauri && cargo test", "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: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", "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": "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:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"lint:spell": "typos .", "lint:spell": "typos .",
"tauri": "node scripts/run-with-env.mjs tauri", "tauri": "node scripts/run-with-env.mjs tauri",
"shadcn:add": "pnpm dlx shadcn@latest add", "shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky && husky install", "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: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", "format": "pnpm format:js && pnpm format:rust",
"build:sync": "cd donut-sync && pnpm build", "build:sync": "cd donut-sync && pnpm build",
"cargo": "cd src-tauri && cargo", "cargo": "cd src-tauri && cargo",
"unused-exports:js": "ts-unused-exports tsconfig.json", "unused-exports:js": "ts-unused-exports tsconfig.json",
"check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands", "check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands",
"copy-proxy-binary": "node src-tauri/copy-proxy-binary.mjs", "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", "pretauri:dev": "pnpm copy-proxy-binary",
"precargo": "pnpm copy-proxy-binary" "precargo": "pnpm copy-proxy-binary"
}, },
@@ -61,7 +69,6 @@
"cmdk": "^1.1.1", "cmdk": "^1.1.1",
"color": "^5.0.3", "color": "^5.0.3",
"flag-icons": "^7.5.0", "flag-icons": "^7.5.0",
"framer-motion": "^12.42.2",
"i18next": "^26.3.4", "i18next": "^26.3.4",
"lucide-react": "^1.23.0", "lucide-react": "^1.23.0",
"motion": "^12.42.2", "motion": "^12.42.2",
-3
View File
@@ -109,9 +109,6 @@ importers:
flag-icons: flag-icons:
specifier: ^7.5.0 specifier: ^7.5.0
version: 7.5.0 version: 7.5.0
framer-motion:
specifier: ^12.42.2
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
i18next: i18next:
specifier: ^26.3.4 specifier: ^26.3.4
version: 26.3.4(typescript@6.0.3) version: 26.3.4(typescript@6.0.3)
+117
View File
@@ -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();
}
+9 -19
View File
@@ -1797,7 +1797,7 @@ dependencies = [
[[package]] [[package]]
name = "donutbrowser" name = "donutbrowser"
version = "0.28.1" version = "0.28.2"
dependencies = [ dependencies = [
"aes 0.9.1", "aes 0.9.1",
"aes-gcm 0.11.0", "aes-gcm 0.11.0",
@@ -1836,7 +1836,7 @@ dependencies = [
"objc2", "objc2",
"objc2-app-kit", "objc2-app-kit",
"once_cell", "once_cell",
"quick-xml 0.41.0", "quick-xml",
"rand 0.10.2", "rand 0.10.2",
"regex-lite", "regex-lite",
"reqwest", "reqwest",
@@ -4686,7 +4686,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [ dependencies = [
"base64 0.22.1", "base64 0.22.1",
"indexmap 2.14.0", "indexmap 2.14.0",
"quick-xml 0.41.0", "quick-xml",
"serde", "serde",
"time", "time",
] ]
@@ -4941,15 +4941,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
[[package]]
name = "quick-xml"
version = "0.39.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
dependencies = [
"memchr",
]
[[package]] [[package]]
name = "quick-xml" name = "quick-xml"
version = "0.41.0" version = "0.41.0"
@@ -7032,9 +7023,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.52.3" version = "1.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
dependencies = [ dependencies = [
"bytes", "bytes",
"libc", "libc",
@@ -7694,9 +7685,9 @@ dependencies = [
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.4" version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [ dependencies = [
"getrandom 0.4.3", "getrandom 0.4.3",
"js-sys", "js-sys",
@@ -7914,11 +7905,10 @@ dependencies = [
[[package]] [[package]]
name = "wayland-scanner" name = "wayland-scanner"
version = "0.31.10" version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "git+https://github.com/Smithay/wayland-rs?rev=d07c4f91f28b42e5a485823ffd9d8d5a210b1053#d07c4f91f28b42e5a485823ffd9d8d5a210b1053"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quick-xml 0.39.4", "quick-xml",
"quote", "quote",
] ]
+9 -2
View File
@@ -1,6 +1,6 @@
[package] [package]
name = "donutbrowser" name = "donutbrowser"
version = "0.28.1" version = "0.28.2"
description = "Simple Yet Powerful Anti-Detect Browser" description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"] authors = ["zhom@github"]
edition = "2021" edition = "2021"
@@ -31,7 +31,7 @@ resvg = "0.47"
[dependencies] [dependencies]
serde_json = "1" serde_json = "1"
serde = { version = "1", features = ["derive"] } 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-opener = "2"
tauri-plugin-fs = "2" tauri-plugin-fs = "2"
tauri-plugin-shell = "2" tauri-plugin-shell = "2"
@@ -186,3 +186,10 @@ default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem # this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this # DO NOT remove this
custom-protocol = ["tauri/custom-protocol"] 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" }
+1 -19
View File
@@ -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> <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> <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> <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> <key>NSHumanReadableCopyright</key>
<string>Copyright © 2025 Donut</string> <string>Copyright © 2026 Donut</string>
<key>CFBundleDocumentTypes</key> <key>CFBundleDocumentTypes</key>
<array> <array>
<dict> <dict>
+9 -22
View File
@@ -5,25 +5,24 @@
"windows": ["main"], "windows": ["main"],
"webviews": ["main"], "webviews": ["main"],
"permissions": [ "permissions": [
"core:default",
"core:event:allow-listen", "core:event:allow-listen",
"core:event:allow-emit", "core:event:allow-emit",
"core:event:allow-emit-to", "core:event:allow-emit-to",
"core:event:allow-unlisten", "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-start-dragging",
"core:window:allow-close", "core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-minimize", "core:window:allow-minimize",
"core:window:allow-toggle-maximize", "core:window:allow-toggle-maximize",
"opener:default",
{ {
"identifier": "opener:allow-open-url", "identifier": "opener:allow-open-url",
"allow": [ "allow": [
{
"url": "https://*"
},
{
"url": "http://*"
},
{ {
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone" "url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
}, },
@@ -32,28 +31,16 @@
} }
] ]
}, },
"fs:default", "fs:allow-read-text-file",
"shell:allow-execute", "fs:allow-write-text-file",
"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",
"deep-link:allow-get-current", "deep-link:allow-get-current",
"dialog:default",
"dialog:allow-open", "dialog:allow-open",
"dialog:allow-save", "dialog:allow-save",
"fs:allow-write-text-file",
"macos-permissions:default",
"macos-permissions:allow-request-microphone-permission", "macos-permissions:allow-request-microphone-permission",
"macos-permissions:allow-request-camera-permission", "macos-permissions:allow-request-camera-permission",
"macos-permissions:allow-check-microphone-permission", "macos-permissions:allow-check-microphone-permission",
"macos-permissions:allow-check-camera-permission", "macos-permissions:allow-check-camera-permission",
"log:default", "log:default",
"clipboard-manager:default",
"clipboard-manager:allow-write-text" "clipboard-manager:allow-write-text"
] ]
} }
+19 -26
View File
@@ -4,7 +4,10 @@ import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
const MANIFEST_DIR = dirname(fileURLToPath(import.meta.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() { function getTarget() {
if (process.env.TARGET) return process.env.TARGET; if (process.env.TARGET) return process.env.TARGET;
@@ -48,32 +51,22 @@ function copyBinary(baseName) {
if (isWindows) destName += ".exe"; if (isWindows) destName += ".exe";
const dest = join(destDir, destName); const dest = join(destDir, destName);
if (existsSync(source)) { const buildArgs = ["build", "--bin", baseName];
copyFileSync(source, dest); if (PROFILE === "release") buildArgs.push("--release");
console.log(`Copied ${binName} to ${dest}`); if (TARGET !== "unknown" && TARGET !== HOST_TARGET) {
} else { buildArgs.push("--target", TARGET);
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);
}
} }
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"); copyBinary("donut-proxy");
-28
View File
@@ -2,37 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"> <plist version="1.0">
<dict> <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> <key>com.apple.security.device.camera</key>
<true/> <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> <key>com.apple.security.device.audio-input</key>
<true/> <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> </dict>
</plist> </plist>
+15
View File
@@ -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
+1 -1
View File
@@ -278,7 +278,7 @@ impl ApiClient {
{ {
Ok(response) => { Ok(response) => {
if !response.status().is_success() { if !response.status().is_success() {
last_err = Some(format!("HTTP {}", response.status())); last_err = Some(format!("HTTP {}", response.status().as_u16()));
} else { } else {
match response.json::<WayfernVersionInfo>().await { match response.json::<WayfernVersionInfo>().await {
Ok(info) => { Ok(info) => {
File diff suppressed because it is too large Load Diff
+131 -2
View File
@@ -967,7 +967,7 @@ impl AppAutoUpdater {
// rejected before the multi-hundred-MB download, not after. // rejected before the multi-hundred-MB download, not after.
let expected_sha256 = self.fetch_expected_checksum(update_info, &filename).await?; let expected_sha256 = self.fetch_expected_checksum(update_info, &filename).await?;
log::info!("Downloading update from: {}", update_info.download_url); log::info!("Downloading update");
let download_path = self let download_path = self
.download_update_silent(&update_info.download_url, &temp_dir, &filename) .download_update_silent(&update_info.download_url, &temp_dir, &filename)
@@ -1698,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 /// Restart the application
async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> { async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
@@ -1764,6 +1853,11 @@ rm "{}"
let pending = PENDING_INSTALLER_PATH.lock().unwrap().take(); let pending = PENDING_INSTALLER_PATH.lock().unwrap().take();
if let Some(installer_path) = pending { 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, // Use ShellExecuteW to run the installer directly — no batch script,
// no cmd.exe console window. The NSIS/MSI installer handles killing the // no cmd.exe console window. The NSIS/MSI installer handles killing the
// old process and restarting the app natively (via /UPDATE and // old process and restarting the app natively (via /UPDATE and
@@ -1943,6 +2037,14 @@ rm "{}"
#[tauri::command] #[tauri::command]
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> { 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() { if crate::app_dirs::is_portable() {
log::info!("App auto-updates disabled in portable mode"); log::info!("App auto-updates disabled in portable mode");
return Ok(None); return Ok(None);
@@ -1991,11 +2093,19 @@ pub async fn restart_application() -> Result<(), String> {
updater updater
.restart_application() .restart_application()
.await .await
.map_err(|e| format!("Failed to restart application: {e}")) .map_err(|e| crate::wrap_backend_error(e, "Failed to restart application"))
} }
#[tauri::command] #[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> { 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"); log::info!("Manual app update check triggered");
let updater = AppAutoUpdater::instance(); let updater = AppAutoUpdater::instance();
updater updater
@@ -2197,6 +2307,25 @@ not-a-hash Donut_0.29.0_amd64.deb
assert_eq!(with.digest.as_deref(), Some("sha256:ab12")); 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] #[test]
fn test_platform_specific_download_urls() { fn test_platform_specific_download_urls() {
let updater = AppAutoUpdater::instance(); let updater = AppAutoUpdater::instance();
+37
View File
@@ -209,6 +209,30 @@ pub fn restrict_to_owner(path: &std::path::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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -222,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] #[test]
fn test_data_dir_returns_path() { fn test_data_dir_returns_path() {
let dir = data_dir(); let dir = data_dir();
+27 -20
View File
@@ -668,6 +668,7 @@ mod tests {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
clear_on_close: false,
created_at: None, created_at: None,
updated_at: None, updated_at: None,
} }
@@ -802,13 +803,13 @@ mod tests {
let test_settings_manager = TestSettingsManager::new(temp_dir.path().to_path_buf()); let test_settings_manager = TestSettingsManager::new(temp_dir.path().to_path_buf());
let mut state = AutoUpdateState::default(); let mut state = AutoUpdateState::default();
state.disabled_browsers.insert("firefox".to_string()); state.disabled_browsers.insert("testbrowser".to_string());
state state
.auto_update_downloads .auto_update_downloads
.insert("firefox-1.1.0".to_string()); .insert("testbrowser-1.1.0".to_string());
state.pending_updates.push(UpdateNotification { state.pending_updates.push(UpdateNotification {
id: "test".to_string(), id: "test".to_string(),
browser: "firefox".to_string(), browser: "testbrowser".to_string(),
current_version: "1.0.0".to_string(), current_version: "1.0.0".to_string(),
new_version: "1.1.0".to_string(), new_version: "1.1.0".to_string(),
affected_profiles: vec!["profile1".to_string()], affected_profiles: vec!["profile1".to_string()],
@@ -830,9 +831,11 @@ mod tests {
serde_json::from_str(&content).expect("Failed to deserialize state"); serde_json::from_str(&content).expect("Failed to deserialize state");
assert_eq!(loaded_state.disabled_browsers.len(), 1); 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_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.len(), 1);
assert_eq!(loaded_state.pending_updates[0].id, "test"); assert_eq!(loaded_state.pending_updates[0].id, "test");
} }
@@ -871,16 +874,16 @@ mod tests {
// Initially not disabled (empty state file means default state) // Initially not disabled (empty state file means default state)
let state = AutoUpdateState::default(); let state = AutoUpdateState::default();
assert!( assert!(
!state.disabled_browsers.contains("firefox"), !state.disabled_browsers.contains("testbrowser"),
"Firefox should not be disabled initially" "testbrowser should not be disabled initially"
); );
// Start update (should disable) // Start update (should disable)
let mut state = AutoUpdateState::default(); let mut state = AutoUpdateState::default();
state.disabled_browsers.insert("firefox".to_string()); state.disabled_browsers.insert("testbrowser".to_string());
state state
.auto_update_downloads .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"); 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"); std::fs::write(&state_file, json).expect("Failed to write state file");
@@ -889,18 +892,20 @@ mod tests {
let loaded_state: AutoUpdateState = let loaded_state: AutoUpdateState =
serde_json::from_str(&content).expect("Failed to deserialize state"); serde_json::from_str(&content).expect("Failed to deserialize state");
assert!( assert!(
loaded_state.disabled_browsers.contains("firefox"), loaded_state.disabled_browsers.contains("testbrowser"),
"Firefox should be disabled" "testbrowser should be disabled"
); );
assert!( assert!(
loaded_state.auto_update_downloads.contains("firefox-1.1.0"), loaded_state
"Firefox download should be tracked" .auto_update_downloads
.contains("testbrowser-1.1.0"),
"testbrowser download should be tracked"
); );
// Complete update (should enable) // Complete update (should enable)
let mut state = loaded_state; let mut state = loaded_state;
state.disabled_browsers.remove("firefox"); state.disabled_browsers.remove("testbrowser");
state.auto_update_downloads.remove("firefox-1.1.0"); state.auto_update_downloads.remove("testbrowser-1.1.0");
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize final state"); 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"); std::fs::write(&state_file, json).expect("Failed to write final state file");
@@ -909,12 +914,14 @@ mod tests {
let final_state: AutoUpdateState = let final_state: AutoUpdateState =
serde_json::from_str(&content).expect("Failed to deserialize final state"); serde_json::from_str(&content).expect("Failed to deserialize final state");
assert!( assert!(
!final_state.disabled_browsers.contains("firefox"), !final_state.disabled_browsers.contains("testbrowser"),
"Firefox should be enabled again" "testbrowser should be enabled again"
); );
assert!( assert!(
!final_state.auto_update_downloads.contains("firefox-1.1.0"), !final_state
"Firefox download should not be tracked anymore" .auto_update_downloads
.contains("testbrowser-1.1.0"),
"testbrowser download should not be tracked anymore"
); );
} }
@@ -945,7 +952,7 @@ mod tests {
let mut state = AutoUpdateState::default(); let mut state = AutoUpdateState::default();
state.pending_updates.push(UpdateNotification { state.pending_updates.push(UpdateNotification {
id: "test_notification".to_string(), id: "test_notification".to_string(),
browser: "firefox".to_string(), browser: "testbrowser".to_string(),
current_version: "1.0.0".to_string(), current_version: "1.0.0".to_string(),
new_version: "1.1.0".to_string(), new_version: "1.1.0".to_string(),
affected_profiles: vec!["profile1".to_string()], affected_profiles: vec!["profile1".to_string()],
+23 -35
View File
@@ -2,8 +2,8 @@ use clap::{Arg, Command};
use donutbrowser_lib::proxy_runner::{ use donutbrowser_lib::proxy_runner::{
start_proxy_process_with_profile, stop_all_proxy_processes, stop_proxy_process, start_proxy_process_with_profile, stop_all_proxy_processes, stop_proxy_process,
}; };
use donutbrowser_lib::proxy_server::run_proxy_server; use donutbrowser_lib::proxy_server::{redacted_upstream, run_proxy_server};
use donutbrowser_lib::proxy_storage::get_proxy_config; use donutbrowser_lib::proxy_storage::{build_proxy_url, get_proxy_config};
use std::process; use std::process;
fn set_high_priority() { 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")] #[tokio::main(flavor = "multi_thread")]
async fn main() { async fn main() {
// Initialize logger to write to stderr (which will be redirected to file). // 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") let matches = Command::new("donut-proxy")
.version(env!("BUILD_VERSION"))
.subcommand( .subcommand(
Command::new("proxy") Command::new("proxy")
.about("Manage proxy servers") .about("Manage proxy servers")
@@ -128,8 +104,6 @@ async fn main() {
.long("type") .long("type")
.help("Proxy type (http, https, socks4, socks5, ss)"), .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(
Arg::new("port") Arg::new("port")
.short('p') .short('p')
@@ -163,6 +137,12 @@ async fn main() {
.long("blocklist-file") .long("blocklist-file")
.help("Path to DNS blocklist file (one domain per line)"), .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(
Arg::new("local-protocol") Arg::new("local-protocol")
.long("local-protocol") .long("local-protocol")
@@ -236,16 +216,22 @@ async fn main() {
start_matches.get_one::<u16>("proxy-port"), start_matches.get_one::<u16>("proxy-port"),
start_matches.get_one::<String>("type"), start_matches.get_one::<String>("type"),
) { ) {
let username = start_matches.get_one::<String>("username"); let username = std::env::var("DONUT_PROXY_USERNAME").ok();
let password = start_matches.get_one::<String>("password"); let password = std::env::var("DONUT_PROXY_PASSWORD").ok();
upstream_url = Some(build_proxy_url( upstream_url = Some(build_proxy_url(
proxy_type, proxy_type,
host, host,
*port, *port,
username.map(|s| s.as_str()), username.as_deref(),
password.map(|s| s.as_str()), password.as_deref(),
)); ));
} else if let Some(upstream) = start_matches.get_one::<String>("upstream") { } 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()); upstream_url = Some(upstream.clone());
} }
@@ -256,6 +242,7 @@ async fn main() {
.and_then(|s| serde_json::from_str(s).ok()) .and_then(|s| serde_json::from_str(s).ok())
.unwrap_or_default(); .unwrap_or_default();
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned(); 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(); let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
match start_proxy_process_with_profile( match start_proxy_process_with_profile(
@@ -264,6 +251,7 @@ async fn main() {
profile_id, profile_id,
bypass_rules, bypass_rules,
blocklist_file, blocklist_file,
dns_allowlist_mode,
local_protocol, local_protocol,
) )
.await .await
@@ -277,7 +265,7 @@ async fn main() {
"id": config.id, "id": config.id,
"localPort": config.local_port, "localPort": config.local_port,
"localUrl": config.local_url, "localUrl": config.local_url,
"upstreamUrl": config.upstream_url, "upstreamUrl": redacted_upstream(&config.upstream_url),
}) })
); );
process::exit(0); process::exit(0);
@@ -371,7 +359,7 @@ async fn main() {
"Found config: id={}, port={:?}, upstream={}", "Found config: id={}, port={:?}, upstream={}",
config.id, config.id,
config.local_port, config.local_port,
config.upstream_url redacted_upstream(&config.upstream_url)
); );
break config; break config;
} }
+1
View File
@@ -642,6 +642,7 @@ mod tests {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
clear_on_close: false,
created_at: None, created_at: None,
updated_at: None, updated_at: None,
}; };
+117 -102
View File
@@ -34,24 +34,29 @@ impl BrowserRunner {
crate::app_dirs::binaries_dir() crate::app_dirs::binaries_dir()
} }
/// Resolve the DNS blocklist level to a cached file path. /// Resolve the DNS blocklist level to a cached file path plus whether that
/// If a level is set but the cache is missing, fetches on demand (blocks until done). /// file should be treated as an allowlist. If a level is set but the cache
/// is missing, fetches/compiles on demand (blocks until done).
async fn resolve_blocklist_file( async fn resolve_blocklist_file(
profile: &crate::profile::BrowserProfile, profile: &crate::profile::BrowserProfile,
) -> Result<Option<String>, String> { ) -> Result<(Option<String>, bool), String> {
let Some(ref level_str) = profile.dns_blocklist else { let Some(ref level_str) = profile.dns_blocklist else {
return Ok(None); return Ok((None, false));
}; };
let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else { let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else {
return Ok(None); return Ok((None, false));
}; };
if level == crate::dns_blocklist::BlocklistLevel::None { if level == crate::dns_blocklist::BlocklistLevel::None {
return Ok(None); return Ok((None, false));
} }
// Only the user's custom list can be an allowlist; the Hagezi tiers are
// always block lists.
let allowlist_mode = level == crate::dns_blocklist::BlocklistLevel::Custom
&& crate::dns_blocklist::CustomDnsConfig::load().allowlist_mode;
let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level) let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level)
.await .await
.map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?; .map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?;
Ok(Some(path.to_string_lossy().to_string())) Ok((Some(path.to_string_lossy().to_string()), allowlist_mode))
} }
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy, /// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
@@ -110,10 +115,9 @@ impl BrowserRunner {
} }
let url = parsed.to_string(); let url = parsed.to_string();
let profile_name = profile.name.clone(); let url_label = crate::log_redaction::url_label(&url);
let profile_id = profile.id.to_string();
log::info!("Firing launch hook GET {url} for profile {profile_name} (ID: {profile_id})"); log::info!("Firing launch hook GET {url_label}");
tokio::spawn(async move { tokio::spawn(async move {
let client = match reqwest::Client::builder() let client = match reqwest::Client::builder()
@@ -122,20 +126,23 @@ impl BrowserRunner {
{ {
Ok(c) => c, Ok(c) => c,
Err(e) => { Err(e) => {
log::warn!("Launch hook client build failed for {url}: {e}"); log::warn!(
"Launch hook client build failed: {}",
crate::log_redaction::text(&e.to_string())
);
return; return;
} }
}; };
match client.get(&url).send().await { match client.get(&url).send().await {
Ok(resp) => { Ok(resp) => {
log::info!( log::info!("Launch hook {url_label} returned status {}", resp.status());
"Launch hook {url} for profile {profile_name} returned status {}",
resp.status()
);
} }
Err(e) => { Err(e) => {
log::warn!("Launch hook {url} for profile {profile_name} failed: {e}"); log::warn!(
"Launch hook {url_label} failed: {}",
crate::log_redaction::text(&e.to_string())
);
} }
} }
}); });
@@ -247,15 +254,20 @@ impl BrowserRunner {
// Start the proxy and get local proxy settings // Start the proxy and get local proxy settings
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy // If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
let profile_id_str = profile.id.to_string(); let profile_id_str = profile.id.to_string();
let blocklist_file = Self::resolve_blocklist_file(profile).await?; let (blocklist_file, dns_allowlist_mode) = Self::resolve_blocklist_file(profile).await?;
// Unique per-launch key: a shared constant here would let concurrent
// launches overwrite each other's active_proxies entry, ending with one
// browser's worker tracked under another browser's PID.
let launch_placeholder_pid = crate::proxy_manager::next_launch_placeholder_pid();
let local_proxy = PROXY_MANAGER let local_proxy = PROXY_MANAGER
.start_proxy( .start_proxy(
app_handle.clone(), app_handle.clone(),
upstream_proxy.as_ref(), upstream_proxy.as_ref(),
0, // Use 0 as temporary PID, will be updated later launch_placeholder_pid,
Some(&profile_id_str), Some(&profile_id_str),
profile.proxy_bypass_rules.clone(), profile.proxy_bypass_rules.clone(),
blocklist_file, blocklist_file,
dns_allowlist_mode,
// Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC // Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC
// UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without // UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without
// leaking the real IP, rather than being forced direct as they // leaking the real IP, rather than being forced direct as they
@@ -264,11 +276,45 @@ impl BrowserRunner {
) )
.await .await
.map_err(|e| { .map_err(|e| {
let error_msg = format!("Failed to start local proxy for Wayfern: {e}"); let error_msg = crate::wrap_backend_error(e, "Failed to start local proxy for Wayfern");
log::error!("{}", error_msg); log::error!("{}", error_msg);
error_msg error_msg
})?; })?;
// If any step below fails before the browser is up, the detached worker
// must be stopped here: its config never gets a browser_pid, so neither
// the GUI sweeps nor the worker's own watchdog would ever reap it — it
// would survive until machine reboot.
struct ProxyLaunchGuard {
app_handle: tauri::AppHandle,
placeholder_pid: u32,
profile_name: String,
armed: bool,
}
impl Drop for ProxyLaunchGuard {
fn drop(&mut self) {
if self.armed {
log::warn!(
"Launch failed after local proxy start for profile {}; stopping proxy worker",
self.profile_name
);
let app_handle = self.app_handle.clone();
let pid = self.placeholder_pid;
tauri::async_runtime::spawn(async move {
if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle, pid).await {
log::warn!("Failed to stop proxy worker after failed launch: {e}");
}
});
}
}
}
let mut proxy_launch_guard = ProxyLaunchGuard {
app_handle: app_handle.clone(),
placeholder_pid: launch_placeholder_pid,
profile_name: profile.name.clone(),
armed: true,
};
// Format proxy URL for wayfern - use SOCKS5 for the local proxy so // Format proxy URL for wayfern - use SOCKS5 for the local proxy so
// Chromium proxies UDP (QUIC/WebRTC), not just TCP. // Chromium proxies UDP (QUIC/WebRTC), not just TCP.
let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port); let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port);
@@ -317,11 +363,10 @@ impl BrowserRunner {
if wayfern_config.os.is_some() { if wayfern_config.os.is_some() {
updated_wayfern_config.os = wayfern_config.os.clone(); updated_wayfern_config.os = wayfern_config.os.clone();
} }
// The fresh fingerprint's location matches the current routing; record // Record which routing this fresh fingerprint's geolocation was built
// its signature so launches keep it in sync with the non-randomize // for (provenance only — nothing rewrites the fingerprint from it).
// path. Only when geolocation actually applied otherwise leave it // Only when geolocation actually applied; otherwise leave it unset so a
// unset so the refresh path can repair the location if the user later // later on-demand match can tell the location was never resolved.
// turns randomize off.
updated_wayfern_config.geo_proxy_signature = if geolocation_applied { updated_wayfern_config.geo_proxy_signature = if geolocation_applied {
Some(crate::wayfern_manager::WayfernManager::geo_signature( Some(crate::wayfern_manager::WayfernManager::geo_signature(
upstream_proxy.as_ref(), upstream_proxy.as_ref(),
@@ -338,63 +383,15 @@ impl BrowserRunner {
profile.name, profile.name,
updated_wayfern_config.fingerprint.as_ref().map(|f| f.len()).unwrap_or(0) updated_wayfern_config.fingerprint.as_ref().map(|f| f.len()).unwrap_or(0)
); );
} else {
// Safety net: the stored fingerprint's timezone and geolocation were
// computed for whatever proxy was set when the fingerprint was
// generated. If the profile's proxy or VPN has changed since (the
// common case being a user who forgot to set a proxy at creation and
// added one afterwards), that location data is stale and the user would
// see the wrong timezone on first launch. When the routing signature no
// longer matches, refresh just the location fields of the stored
// fingerprint through the current proxy. Wayfern only; the randomize
// path above already regenerates the whole fingerprint each launch.
let current_geo_sig = crate::wayfern_manager::WayfernManager::geo_signature(
upstream_proxy.as_ref(),
profile.vpn_id.as_deref(),
wayfern_config.geoip.as_ref(),
);
let geo_enabled = !matches!(
wayfern_config.geoip.as_ref(),
Some(serde_json::Value::Bool(false))
);
if geo_enabled
&& wayfern_config.geo_proxy_signature.as_deref() != Some(current_geo_sig.as_str())
{
if let Some(stored_fp) = wayfern_config.fingerprint.clone() {
log::info!(
"Routing changed for Wayfern profile {} since its fingerprint was generated (was {:?}, now {}); refreshing timezone and geolocation",
profile.name,
wayfern_config.geo_proxy_signature,
current_geo_sig
);
match crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
&stored_fp,
wayfern_config.proxy.as_deref(),
wayfern_config.geoip.as_ref(),
)
.await
{
Some(refreshed) => {
// Use the refreshed fingerprint for this launch...
wayfern_config.fingerprint = Some(refreshed.clone());
wayfern_config.geo_proxy_signature = Some(current_geo_sig.clone());
// ...and persist it so the corrected location sticks and we do
// not refresh again on the next launch with the same proxy.
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
cfg.fingerprint = Some(refreshed);
cfg.geo_proxy_signature = Some(current_geo_sig);
updated_profile.wayfern_config = Some(cfg);
}
None => {
log::warn!(
"Could not refresh geolocation for Wayfern profile {} (proxy unreachable?); launching with existing location and will retry next launch",
profile.name
);
}
}
}
}
} }
// A non-randomize profile keeps its configured fingerprint verbatim, even
// when its proxy/VPN routing has changed since the fingerprint was built.
// We deliberately do NOT silently rewrite its timezone/language to match
// the new exit: that hid every real fingerprint-vs-exit mismatch (a US
// fingerprint behind a German exit would be quietly relabelled German
// before the launch-time consistency check could see it). The check now
// surfaces the mismatch, and the user re-matches on demand via
// `match_profile_fingerprint_to_exit`.
// Create ephemeral dir for ephemeral or password-protected profiles // Create ephemeral dir for ephemeral or password-protected profiles
if profile.password_protected { if profile.password_protected {
@@ -457,6 +454,10 @@ impl BrowserRunner {
format!("Failed to launch Wayfern: {e}").into() format!("Failed to launch Wayfern: {e}").into()
})?; })?;
// Browser is up and using the worker — failures past this point must
// not stop it.
proxy_launch_guard.armed = false;
// Get the process ID from launch result // Get the process ID from launch result
let process_id = wayfern_result.processId.unwrap_or(0); let process_id = wayfern_result.processId.unwrap_or(0);
log::info!("Wayfern launched successfully with PID: {process_id}"); log::info!("Wayfern launched successfully with PID: {process_id}");
@@ -483,11 +484,18 @@ impl BrowserRunner {
updated_profile.process_id = Some(process_id); updated_profile.process_id = Some(process_id);
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs()); updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
// Update the proxy manager with the correct PID // Update the proxy manager with the correct PID. When the browser
if let Err(e) = PROXY_MANAGER.update_proxy_pid(0, process_id) { // reported no PID, keep the entry keyed by its unique placeholder (which
log::warn!("Warning: Failed to update proxy PID mapping: {e}"); // the cleanup sweep skips) rather than remapping to a shared 0 key that
} else { // concurrent launches could collide on.
log::info!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}"); if process_id != 0 {
if let Err(e) = PROXY_MANAGER.update_proxy_pid(launch_placeholder_pid, process_id) {
log::warn!("Warning: Failed to update proxy PID mapping: {e}");
} else {
log::info!(
"Updated proxy PID mapping from launch placeholder {launch_placeholder_pid} to actual PID: {process_id}"
);
}
} }
// Persist the real browser PID so the detached proxy worker self-reaps // Persist the real browser PID so the detached proxy worker self-reaps
@@ -669,18 +677,18 @@ impl BrowserRunner {
.unwrap_or_else(|| updated_profile.clone()); .unwrap_or_else(|| updated_profile.clone());
log::info!( log::info!(
"Browser status check - Profile: {} (ID: {}), Running: {}, URL: {:?}, PID: {:?}", "Browser status check: running={is_running}, URL requested={}, PID present={}",
final_profile.name, url.is_some(),
final_profile.id, final_profile.process_id.is_some()
is_running,
url,
final_profile.process_id
); );
if is_running && url.is_some() { if is_running && url.is_some() {
// Browser is running and we have a URL to open // Browser is running and we have a URL to open
if let Some(url_ref) = url.as_ref() { if let Some(url_ref) = url.as_ref() {
log::info!("Opening URL in existing browser: {url_ref}"); log::info!(
"Opening {} in existing browser",
crate::log_redaction::url_label(url_ref)
);
match self match self
.open_url_in_existing_browser( .open_url_in_existing_browser(
@@ -696,7 +704,10 @@ impl BrowserRunner {
Ok(final_profile) Ok(final_profile)
} }
Err(e) => { Err(e) => {
log::info!("Failed to open URL in existing browser: {e}"); log::info!(
"Failed to open URL in existing browser: {}",
crate::log_redaction::text(&e.to_string())
);
// Fall back to launching a new instance // Fall back to launching a new instance
log::info!( log::info!(
@@ -1096,6 +1107,10 @@ impl BrowserRunner {
crate::profile::password::complete_after_quit_and_wait(profile).await; crate::profile::password::complete_after_quit_and_wait(profile).await;
} else if profile.ephemeral { } else if profile.ephemeral {
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string()); crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
} else if profile.clear_on_close {
// Awaited for the same reason as re-encryption above: a queued sync
// must see the cleared dir, not the pre-clear snapshot.
crate::profile::clear_on_close::clear_profile_browsing_data(profile).await;
} }
log::info!( log::info!(
@@ -1153,18 +1168,21 @@ impl BrowserRunner {
)); ));
} }
log::info!("Opening URL '{url}' with profile '{profile_id}'"); log::info!("Opening URL with selected profile");
// Use launch_or_open_url which handles both launching new instances and opening in existing ones // Use launch_or_open_url which handles both launching new instances and opening in existing ones
self self
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None) .launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
.await .await
.map_err(|e| { .map_err(|e| {
log::info!("Failed to open URL with profile '{profile_id}': {e}"); log::info!(
"Failed to open URL with selected profile: {}",
crate::log_redaction::text(&e.to_string())
);
format!("Failed to open URL with profile: {e}") format!("Failed to open URL with profile: {e}")
})?; })?;
log::info!("Successfully opened URL '{url}' with profile '{profile_id}'"); log::info!("Successfully opened URL with selected profile");
Ok(()) Ok(())
} }
} }
@@ -1287,7 +1305,7 @@ pub async fn launch_browser_profile_impl(
return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH); return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH);
} }
} }
format!("Failed to launch browser or open URL: {e}") crate::wrap_backend_error(e, "Failed to launch browser or open URL")
})?; })?;
log::info!( log::info!(
@@ -1296,11 +1314,8 @@ pub async fn launch_browser_profile_impl(
updated_profile.id updated_profile.id
); );
// Now update the proxy with the correct PID if we have one // The proxy PID mapping was already reconciled inside launch_browser_internal
if let Some(actual_pid) = updated_profile.process_id { // (placeholder → real browser PID); nothing is ever keyed by a constant here.
// Update the proxy manager with the correct PID (we always started with temp pid 1)
let _ = PROXY_MANAGER.update_proxy_pid(1u32, actual_pid);
}
Ok(updated_profile) Ok(updated_profile)
} }
+24 -11
View File
@@ -109,7 +109,7 @@ impl BrowserVersionManager {
self.api_client.is_cache_expired(browser) self.api_client.is_cache_expired(browser)
} }
/// Get the latest Wayfern version (cached first) /// Get the latest Wayfern version (fresh cache first)
pub async fn get_browser_release_types( pub async fn get_browser_release_types(
&self, &self,
browser: &str, browser: &str,
@@ -118,17 +118,30 @@ impl BrowserVersionManager {
return Err(format!("Unsupported browser: {browser}").into()); return Err(format!("Unsupported browser: {browser}").into());
} }
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) { // Only trust an unexpired cache. A stale entry can point at a version that
return Ok(BrowserReleaseTypes { // is no longer published — the downloader rejects such requests, so serving
stable: cached_versions.first().map(|v| v.version.clone()), // 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 detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?; // Expired or missing cache: fetch fresh, falling back to whatever cache
// exists when the network is unavailable.
Ok(BrowserReleaseTypes { match self.fetch_browser_versions_detailed(browser, false).await {
stable: detailed_versions.first().map(|v| v.version.clone()), 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 /// Fetch browser versions with optional caching
@@ -440,7 +453,7 @@ mod tests {
assert!(wayfern_info.url.contains("download.wayfern.com")); assert!(wayfern_info.url.contains("download.wayfern.com"));
let unsupported_result = service.get_download_info("firefox", "1.0.0"); let unsupported_result = service.get_download_info("testbrowser", "1.0.0");
assert!(unsupported_result.is_err()); assert!(unsupported_result.is_err());
} }
} }
+32 -19
View File
@@ -609,9 +609,8 @@ impl CloudAuthManager {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); log::warn!("Token refresh failed ({status})");
log::warn!("Token refresh failed ({status}): {body}"); return Err(format!("Token refresh failed ({status})"));
return Err(format!("Token refresh failed ({status}): {body}"));
} }
let result: RefreshTokenResponse = response let result: RefreshTokenResponse = response
@@ -779,6 +778,13 @@ impl CloudAuthManager {
/// Launch/drive profiles programmatically (local API + MCP automation). /// Launch/drive profiles programmatically (local API + MCP automation).
pub async fn can_use_browser_automation(&self) -> bool { pub async fn can_use_browser_automation(&self) -> bool {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
{
return true;
}
self self
.entitlements() .entitlements()
.await .await
@@ -788,6 +794,13 @@ impl CloudAuthManager {
/// Edit fingerprints / use a non-native OS fingerprint. /// Edit fingerprints / use a non-native OS fingerprint.
pub async fn can_use_cross_os_fingerprints(&self) -> bool { pub async fn can_use_cross_os_fingerprints(&self) -> bool {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
{
return true;
}
self self
.entitlements() .entitlements()
.await .await
@@ -909,14 +922,12 @@ impl CloudAuthManager {
let status = response.status(); let status = response.status();
if status == reqwest::StatusCode::FORBIDDEN { if status == reqwest::StatusCode::FORBIDDEN {
let body = response.text().await.unwrap_or_default(); log::warn!("Proxy config returned 403");
log::warn!("Proxy config returned 403: {body}");
return Err("__403__".to_string()); return Err("__403__".to_string());
} }
if !response.status().is_success() { if !response.status().is_success() {
let body = response.text().await.unwrap_or_default(); return Err(format!("Proxy config fetch failed ({status})"));
return Err(format!("Proxy config fetch failed ({status}): {body}"));
} }
response response
@@ -1178,8 +1189,7 @@ impl CloudAuthManager {
if !response.status().is_success() { if !response.status().is_success() {
let status = response.status(); let status = response.status();
let body = response.text().await.unwrap_or_default(); return Err(format!("Wayfern token request failed ({status})"));
return Err(format!("Wayfern token request failed ({status}): {body}"));
} }
let result: WayfernTokenResponse = response let result: WayfernTokenResponse = response
@@ -1195,17 +1205,10 @@ impl CloudAuthManager {
let token = match result { let token = match result {
Ok(token) => token, Ok(token) => token,
Err(e) => { Err(e) => {
// The backend returns 403 (ForbiddenException) for paid-feature blocks: // A 403 rejects the entitlement without invalidating the login session.
// token-reuse throttle, "active subscription required", and the // Clear the browser token and refresh account state before notifying UI.
// primary-device restriction (see donutbrowser-infra wayfern.service.ts).
// This is distinct from a 401 (dead access token) — the session is still
// valid, the user is just temporarily/conditionally not entitled. So we
// do NOT invalidate the session. Instead: drop the stale wayfern token so
// no browser launches half-authenticated, re-fetch the profile so the
// cached plan reflects the backend's real state (it may have changed),
// and signal the UI so the user learns why automation stopped working.
if e.contains("(403") || e.contains("Forbidden") { if e.contains("(403") || e.contains("Forbidden") {
log::warn!("Wayfern token blocked by backend (403): {e}"); log::warn!("Wayfern token blocked by backend (403)");
self.clear_wayfern_token().await; self.clear_wayfern_token().await;
if let Err(fetch_err) = self.fetch_profile().await { if let Err(fetch_err) = self.fetch_profile().await {
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}"); log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
@@ -1224,6 +1227,16 @@ impl CloudAuthManager {
/// Get the current wayfern token, if any. /// Get the current wayfern token, if any.
pub async fn get_wayfern_token(&self) -> Option<String> { pub async fn get_wayfern_token(&self) -> Option<String> {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled() {
if let Some(token) = std::env::var_os("WAYFERN_TEST_TOKEN")
.filter(|token| !token.is_empty())
.and_then(|token| token.into_string().ok())
{
return Some(token);
}
}
let wt = self.wayfern_token.lock().await; let wt = self.wayfern_token.lock().await;
wt.clone() wt.clone()
} }
+29 -42
View File
@@ -1057,6 +1057,23 @@ impl CookieManager {
mod tests { mod tests {
use super::*; use super::*;
#[cfg(target_os = "macos")]
const SYNTHETIC_COOKIE_HOST: &str = ".example.test";
#[cfg(target_os = "macos")]
const SYNTHETIC_COOKIE_VALUE: &str = "synthetic-cookie-value";
#[cfg(target_os = "macos")]
const SYNTHETIC_OS_CRYPT_PASSWORD: &[u8] = b"donut-synthetic-cookie-key";
#[cfg(target_os = "macos")]
const SYNTHETIC_ENCRYPTED_COOKIE_HEX: &str = "763130d83b9fd3e6d1b1c793769f55251f5e9d1193be72c0c08ea32e2cf068a85d9d0b97d8b2e6deca93a2b3c290e98e1a851f83d5566f9aa9314befe56dc6bdbd423d";
#[cfg(target_os = "macos")]
fn synthetic_encrypted_cookie() -> Vec<u8> {
(0..SYNTHETIC_ENCRYPTED_COOKIE_HEX.len())
.step_by(2)
.map(|i| u8::from_str_radix(&SYNTHETIC_ENCRYPTED_COOKIE_HEX[i..i + 2], 16).unwrap())
.collect()
}
#[test] #[test]
fn test_parse_netscape_cookies_valid() { fn test_parse_netscape_cookies_valid() {
let content = "# Netscape HTTP Cookie File\n\ let content = "# Netscape HTTP Cookie File\n\
@@ -1427,7 +1444,7 @@ mod tests {
.unwrap(); .unwrap();
// Imported session cookies are promoted to persistent with a far-future // Imported session cookies are promoted to persistent with a far-future
// expiry so an imported login survives relaunch (mirrors the Firefox writer). // expiry so an imported login survives relaunch.
assert_eq!(has_expires, 1); assert_eq!(has_expires, 1);
assert_eq!(is_persistent, 1); assert_eq!(is_persistent, 1);
// Must be a real future expiry, not 0 (which Chromium reads as 1601-01-01). // Must be a real future expiry, not 0 (which Chromium reads as 1601-01-01).
@@ -1482,50 +1499,29 @@ mod tests {
let _ = std::fs::remove_file(&tmp); let _ = std::fs::remove_file(&tmp);
} }
/// Regression: decrypting a real v10-encrypted Chromium cookie with the
/// correct PBKDF2 iterations and the `SHA-256(host_key)` integrity-prefix
/// strip. Captured from a real Wayfern profile:
/// host_key = ".github.com"
/// name = "_octo"
/// password = "OSfgzI5GUqy/pK4ANrYugw==" (contents of os_crypt_key)
/// value = "GH1.1.2077424036.1774792325"
///
/// If PBKDF2 iterations or the host-hash prefix handling ever regress,
/// this test fails and we instantly know why all copied cookies end up
/// with empty values — which is exactly the bug that shipped and made
/// issue-265-style silent failures reappear.
#[test] #[test]
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn test_decrypt_v10_cookie_with_real_vector() { fn test_decrypt_v10_cookie_with_synthetic_vector() {
let profile_dir = let profile_dir =
std::env::temp_dir().join(format!("donut_decrypt_vector_{}", uuid::Uuid::new_v4())); std::env::temp_dir().join(format!("donut_decrypt_vector_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&profile_dir).unwrap(); std::fs::create_dir_all(&profile_dir).unwrap();
std::fs::write( std::fs::write(
profile_dir.join("os_crypt_key"), profile_dir.join("os_crypt_key"),
b"OSfgzI5GUqy/pK4ANrYugw==", SYNTHETIC_OS_CRYPT_PASSWORD,
) )
.unwrap(); .unwrap();
let key = chrome_decrypt::get_encryption_key(&profile_dir) let key = chrome_decrypt::get_encryption_key(&profile_dir)
.expect("should derive key from os_crypt_key file"); .expect("should derive key from os_crypt_key file");
let encrypted_hex = "76313077ad5b27e78f685a6ccc7b92a8a242e279e54b8d2ba8e55b433ca7e2421bec52369e29a57b593c02c839f50962245da3ed8617dce142fff67778950a271d2c07"; let decrypted =
let encrypted: Vec<u8> = (0..encrypted_hex.len()) chrome_decrypt::decrypt(&synthetic_encrypted_cookie(), SYNTHETIC_COOKIE_HOST, &key)
.step_by(2) .expect("decryption must succeed with correct key and host");
.map(|i| u8::from_str_radix(&encrypted_hex[i..i + 2], 16).unwrap()) assert_eq!(decrypted, SYNTHETIC_COOKIE_VALUE);
.collect();
let decrypted = chrome_decrypt::decrypt(&encrypted, ".github.com", &key)
.expect("decryption must succeed with correct key and host");
assert_eq!(decrypted, "GH1.1.2077424036.1774792325");
let _ = std::fs::remove_dir_all(&profile_dir); let _ = std::fs::remove_dir_all(&profile_dir);
} }
/// Sanity: decrypting with the wrong host_key (hash mismatch) must not
/// return a half-garbage value — it should fall back to the full
/// decrypted bytes, which for a modern cookie includes the 32-byte hash
/// prefix and therefore won't be valid UTF-8 → `None`.
#[test] #[test]
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
fn test_decrypt_with_wrong_host_returns_none_or_raw() { fn test_decrypt_with_wrong_host_returns_none_or_raw() {
@@ -1534,25 +1530,16 @@ mod tests {
std::fs::create_dir_all(&profile_dir).unwrap(); std::fs::create_dir_all(&profile_dir).unwrap();
std::fs::write( std::fs::write(
profile_dir.join("os_crypt_key"), profile_dir.join("os_crypt_key"),
b"OSfgzI5GUqy/pK4ANrYugw==", SYNTHETIC_OS_CRYPT_PASSWORD,
) )
.unwrap(); .unwrap();
let key = chrome_decrypt::get_encryption_key(&profile_dir).unwrap(); let key = chrome_decrypt::get_encryption_key(&profile_dir).unwrap();
let encrypted_hex = "76313077ad5b27e78f685a6ccc7b92a8a242e279e54b8d2ba8e55b433ca7e2421bec52369e29a57b593c02c839f50962245da3ed8617dce142fff67778950a271d2c07"; let result =
let encrypted: Vec<u8> = (0..encrypted_hex.len()) chrome_decrypt::decrypt(&synthetic_encrypted_cookie(), ".wrong.example.test", &key);
.step_by(2)
.map(|i| u8::from_str_radix(&encrypted_hex[i..i + 2], 16).unwrap())
.collect();
// Wrong host: the prefix won't match, so we fall through to
// `String::from_utf8(full_decrypted)` which fails on the binary hash
// bytes and returns `None`. Either way, we must NOT return the real
// value "GH1.1.2077424036.1774792325".
let result = chrome_decrypt::decrypt(&encrypted, ".facebook.com", &key);
assert!( assert!(
result.as_deref() != Some("GH1.1.2077424036.1774792325"), result.as_deref() != Some(SYNTHETIC_COOKIE_VALUE),
"decrypt must not return the real cookie value when host_key is wrong" "decrypt must not return the cookie value when host_key is wrong"
); );
let _ = std::fs::remove_dir_all(&profile_dir); let _ = std::fs::remove_dir_all(&profile_dir);
+475 -3
View File
@@ -16,6 +16,9 @@ pub enum BlocklistLevel {
Pro, Pro,
ProPlus, ProPlus,
Ultimate, Ultimate,
/// User-defined list: compiled from custom source URLs + custom block
/// domains, with custom allow domains removed (allowlist overrides).
Custom,
} }
impl BlocklistLevel { impl BlocklistLevel {
@@ -26,6 +29,7 @@ impl BlocklistLevel {
"pro" => Some(Self::Pro), "pro" => Some(Self::Pro),
"pro_plus" => Some(Self::ProPlus), "pro_plus" => Some(Self::ProPlus),
"ultimate" => Some(Self::Ultimate), "ultimate" => Some(Self::Ultimate),
"custom" => Some(Self::Custom),
"none" => Some(Self::None), "none" => Some(Self::None),
_ => None, _ => None,
} }
@@ -39,6 +43,7 @@ impl BlocklistLevel {
Self::Pro => "pro", Self::Pro => "pro",
Self::ProPlus => "pro_plus", Self::ProPlus => "pro_plus",
Self::Ultimate => "ultimate", Self::Ultimate => "ultimate",
Self::Custom => "custom",
} }
} }
@@ -50,12 +55,13 @@ impl BlocklistLevel {
Self::Pro => "Pro", Self::Pro => "Pro",
Self::ProPlus => "Pro++", Self::ProPlus => "Pro++",
Self::Ultimate => "Ultimate", Self::Ultimate => "Ultimate",
Self::Custom => "Custom",
} }
} }
pub fn url(&self) -> Option<&'static str> { pub fn url(&self) -> Option<&'static str> {
match self { match self {
Self::None => None, Self::None | Self::Custom => None,
Self::Light => { Self::Light => {
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt") Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
} }
@@ -80,6 +86,7 @@ impl BlocklistLevel {
Self::Pro => Some("pro.txt"), Self::Pro => Some("pro.txt"),
Self::ProPlus => Some("pro.plus.txt"), Self::ProPlus => Some("pro.plus.txt"),
Self::Ultimate => Some("ultimate.txt"), Self::Ultimate => Some("ultimate.txt"),
Self::Custom => Some("custom.txt"),
} }
} }
@@ -94,6 +101,150 @@ impl BlocklistLevel {
} }
} }
/// User-defined DNS filtering: extra blocklist source URLs plus manual block /
/// allow domain rules. Allow rules override blocks (the standard exceptions
/// model). Stored as one JSON blob; the compiled result lands in `custom.txt`.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CustomDnsConfig {
#[serde(default)]
pub sources: Vec<String>,
#[serde(default)]
pub block_domains: Vec<String>,
#[serde(default)]
pub allow_domains: Vec<String>,
/// When true the custom list is a strict allowlist: the browser may only
/// reach `allow_domains` (and their subdomains); everything else is blocked.
/// Sources/block_domains are ignored in this mode.
#[serde(default)]
pub allowlist_mode: bool,
#[serde(default)]
pub updated_at: Option<u64>,
}
fn normalize_domain(raw: &str) -> Option<String> {
let mut line = raw.trim();
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
return None;
}
// Hosts-file format ("0.0.0.0 ads.example.com", "127.0.0.1 tracker.net") is
// common in public blocklists — take the domain after the sink IP rather
// than rejecting the whole line on the embedded space.
if let Some((first, rest)) = line.split_once(char::is_whitespace) {
if matches!(first, "0.0.0.0" | "127.0.0.1" | "::" | "::1") {
line = rest.trim();
}
}
// Strip a trailing comment ("0.0.0.0 ads.example.com # AdGuard"). Public
// hosts lists annotate entries this way; without this the whitespace guard
// below rejects every annotated line, silently dropping most of the source.
for marker in ['#', '!'] {
if let Some((before, _)) = line.split_once(marker) {
line = before.trim();
}
}
let d = line
.trim_start_matches("*.")
.trim_start_matches("||")
.trim_end_matches('^')
.trim_end_matches('.')
.to_lowercase();
if d.is_empty() {
return None;
}
// Reject anything still containing whitespace or a scheme — not a bare domain.
if d.contains(char::is_whitespace) || d.contains("://") {
return None;
}
Some(d)
}
impl CustomDnsConfig {
fn path() -> PathBuf {
app_dirs::data_subdir().join("custom_dns.json")
}
pub fn load() -> Self {
match std::fs::read_to_string(Self::path()) {
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
Err(_) => Self::default(),
}
}
pub fn save(&self) -> Result<(), String> {
let path = Self::path();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
}
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
std::fs::write(&path, json).map_err(|e| e.to_string())
}
/// Serialize to a plain-text rule list (uBlock-ish): `! source:` comments for
/// source URLs, `@@domain` for allow rules, bare domains for blocks.
pub fn to_txt(&self) -> String {
let mut out = String::new();
for s in &self.sources {
out.push_str("! source: ");
out.push_str(s);
out.push('\n');
}
for d in &self.allow_domains {
out.push_str("@@");
out.push_str(d);
out.push('\n');
}
for d in &self.block_domains {
out.push_str(d);
out.push('\n');
}
out
}
/// Parse a plain-text rule list back into a config (sources from
/// `! source:` lines, `@@`-prefixed as allow, bare domains as block).
pub fn from_txt(content: &str) -> Self {
let mut cfg = CustomDnsConfig::default();
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
if let Some(src) = line.strip_prefix("! source:") {
let src = src.trim();
if !src.is_empty() {
cfg.sources.push(src.to_string());
}
continue;
}
if line.starts_with('#') || line.starts_with('!') {
continue;
}
if let Some(allow) = line.strip_prefix("@@") {
if let Some(d) = normalize_domain(allow) {
cfg.allow_domains.push(d);
}
} else if let Some(d) = normalize_domain(line) {
cfg.block_domains.push(d);
}
}
cfg.dedup();
cfg
}
/// Drop duplicates, preserving first-seen order so an exported rule list
/// still reads the way the user wrote it.
fn dedup(&mut self) {
for v in [
&mut self.sources,
&mut self.block_domains,
&mut self.allow_domains,
] {
let mut seen = std::collections::HashSet::new();
v.retain(|item| seen.insert(item.clone()));
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BlocklistCacheStatus { pub struct BlocklistCacheStatus {
pub level: String, pub level: String,
@@ -144,9 +295,23 @@ impl BlocklistManager {
} }
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> { pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
let url = level let production_url = level
.url() .url()
.ok_or_else(|| format!("No URL for level {:?}", level))?; .ok_or_else(|| format!("No URL for level {:?}", level))?;
#[cfg(feature = "e2e")]
let url = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
.ok()
.filter(|base| !base.is_empty())
.map(|base| {
format!(
"{}/{}",
base.trim_end_matches('/'),
level.filename().unwrap_or("blocklist.txt")
)
})
.unwrap_or_else(|| production_url.to_string());
#[cfg(not(feature = "e2e"))]
let url = production_url.to_string();
let path = let path =
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?; Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
@@ -160,7 +325,7 @@ impl BlocklistManager {
); );
let response = HTTP_CLIENT let response = HTTP_CLIENT
.get(url) .get(&url)
.send() .send()
.await .await
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?; .map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
@@ -193,6 +358,19 @@ impl BlocklistManager {
} }
pub async fn ensure_cached(level: BlocklistLevel) -> Result<PathBuf, String> { pub async fn ensure_cached(level: BlocklistLevel) -> Result<PathBuf, String> {
if level == BlocklistLevel::Custom {
// Recompile only when the compiled file is missing or stale. Edits call
// compile_custom_blocklist directly and refresh_all_stale keeps sources
// current, so recompiling unconditionally would re-download every source
// on every profile launch — and this is awaited on the blocking launch
// path, so the browser cannot start until it finishes.
if let Some(path) = Self::cached_file_path(level) {
if Self::is_cache_fresh(level) {
return Ok(path);
}
}
return Self::compile_custom_blocklist().await;
}
if let Some(path) = Self::cached_file_path(level) { if let Some(path) = Self::cached_file_path(level) {
if path.exists() { if path.exists() {
return Ok(path); return Ok(path);
@@ -201,6 +379,120 @@ impl BlocklistManager {
Self::fetch_blocklist(level).await Self::fetch_blocklist(level).await
} }
/// Compile the user's custom DNS file. In blocklist mode: fetch every source,
/// union with the manual block domains, then remove the allow domains
/// (allowlist overrides). In allowlist mode: the file is just the allow
/// domains — the worker then blocks everything NOT listed. Always rewrites
/// `custom.txt`.
pub async fn compile_custom_blocklist() -> Result<PathBuf, String> {
use std::collections::HashSet;
let config = CustomDnsConfig::load();
let mut domains: HashSet<String> = HashSet::new();
let path =
Self::cached_file_path(BlocklistLevel::Custom).ok_or("No filename for custom level")?;
if config.allowlist_mode {
// Strict allowlist: the compiled file is the set of permitted domains.
for d in &config.allow_domains {
if let Some(n) = normalize_domain(d) {
domains.insert(n);
}
}
} else {
// Fetch every source concurrently. Sequential awaits make this the sum of
// all source latencies, and it runs on the blocking profile-launch path.
let fetches = config.sources.iter().map(|source| async move {
match HTTP_CLIENT.get(source).send().await {
Ok(resp) if resp.status().is_success() => resp
.text()
.await
.map_err(|e| format!("custom source {source} body read failed: {e}")),
Ok(resp) => Err(format!(
"custom source {source} returned HTTP {}",
resp.status()
)),
Err(e) => Err(format!("custom source {source} failed: {e}")),
}
});
let mut source_failures = 0usize;
for result in futures_util::future::join_all(fetches).await {
match result {
Ok(body) => {
for line in body.lines() {
if let Some(d) = normalize_domain(line) {
domains.insert(d);
}
}
}
Err(e) => {
log::warn!("[dns-blocklist] {e}");
source_failures += 1;
}
}
}
// A failed source must never shrink the compiled list. Overwriting with a
// short (or empty) result would silently stop blocking whatever that
// source contributed — and `is_blocked` fails open on an empty set, so a
// single offline launch would disable custom filtering entirely and
// destroy the good cached list. Re-seed from the cached file so a network
// failure degrades to "stale" instead of "off"; manual rule edits below
// still apply, and the next successful compile rewrites cleanly.
if source_failures > 0 {
match std::fs::read_to_string(&path) {
Ok(cached) => {
let before = domains.len();
for line in cached.lines() {
if let Some(d) = normalize_domain(line) {
domains.insert(d);
}
}
log::warn!(
"[dns-blocklist] {source_failures} custom source(s) failed; retained {} domain(s) from the cached list",
domains.len().saturating_sub(before)
);
}
Err(_) => log::warn!(
"[dns-blocklist] {source_failures} custom source(s) failed and no cached list exists; custom filtering is incomplete"
),
}
}
for d in &config.block_domains {
if let Some(n) = normalize_domain(d) {
domains.insert(n);
}
}
// Allow rules override blocks (exceptions).
for d in &config.allow_domains {
if let Some(n) = normalize_domain(d) {
domains.remove(&n);
}
}
}
let cache_dir = Self::cache_dir();
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
let mut sorted: Vec<&str> = domains.iter().map(String::as_str).collect();
sorted.sort_unstable();
let body = sorted.join("\n");
let tmp_path = path.with_extension("tmp");
std::fs::write(&tmp_path, &body)
.map_err(|e| format!("Failed to write custom blocklist: {e}"))?;
std::fs::rename(&tmp_path, &path)
.map_err(|e| format!("Failed to rename custom blocklist: {e}"))?;
log::info!(
"[dns-blocklist] Compiled custom blocklist ({} domains)",
domains.len()
);
Ok(path)
}
pub async fn refresh_all_stale(&self) { pub async fn refresh_all_stale(&self) {
for &level in BlocklistLevel::all_downloadable() { for &level in BlocklistLevel::all_downloadable() {
if !Self::is_cache_fresh(level) { if !Self::is_cache_fresh(level) {
@@ -219,6 +511,13 @@ impl BlocklistManager {
} }
} }
} }
// Recompile the custom list too so its sources track upstream changes.
let config = CustomDnsConfig::load();
if !config.sources.is_empty() || !config.block_domains.is_empty() {
if let Err(e) = Self::compile_custom_blocklist().await {
log::error!("[dns-blocklist] Failed to recompile custom list: {e}");
}
}
} }
pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option<PathBuf> { pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option<PathBuf> {
@@ -289,6 +588,102 @@ pub async fn refresh_dns_blocklists() -> Result<(), String> {
Ok(()) Ok(())
} }
#[tauri::command]
pub async fn get_custom_dns_config() -> Result<CustomDnsConfig, String> {
Ok(CustomDnsConfig::load())
}
/// Normalize, persist, recompile and announce a custom DNS config. Shared by
/// the set and import commands so both apply the same normalization and the
/// same side effects — a step added here reaches both.
async fn persist_custom_config(mut config: CustomDnsConfig) -> Result<CustomDnsConfig, String> {
config.sources = std::mem::take(&mut config.sources)
.into_iter()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect();
config.block_domains = config
.block_domains
.iter()
.filter_map(|d| normalize_domain(d))
.collect();
config.allow_domains = config
.allow_domains
.iter()
.filter_map(|d| normalize_domain(d))
.collect();
config.updated_at = Some(crate::proxy_manager::now_secs());
config.dedup();
config
.save()
.map_err(|_| serde_json::json!({ "code": "DNS_RULES_SAVE_FAILED" }).to_string())?;
// Recompile so a profile relaunch picks up the new rules immediately.
let _ = BlocklistManager::compile_custom_blocklist().await;
let _ = crate::events::emit_empty("custom-dns-changed");
Ok(config)
}
/// Persist the custom DNS config and recompile the `custom.txt` list. Domains
/// are normalized; blank/comment entries are dropped.
#[tauri::command]
pub async fn set_custom_dns_config(
sources: Vec<String>,
block_domains: Vec<String>,
allow_domains: Vec<String>,
allowlist_mode: bool,
) -> Result<CustomDnsConfig, String> {
persist_custom_config(CustomDnsConfig {
sources,
block_domains,
allow_domains,
allowlist_mode,
updated_at: None,
})
.await
}
/// Import custom DNS rules. `format` is "json" (a full CustomDnsConfig) or
/// "txt" (uBlock-ish: `! source:`, `@@allow`, bare block domains).
#[tauri::command]
pub async fn import_custom_dns_rules(
content: String,
format: String,
) -> Result<CustomDnsConfig, String> {
let config = match format.as_str() {
"json" => serde_json::from_str::<CustomDnsConfig>(&content)
.map_err(|_| serde_json::json!({ "code": "INVALID_DNS_RULES_JSON" }).to_string())?,
"txt" => CustomDnsConfig::from_txt(&content),
other => {
return Err(
serde_json::json!({
"code": "UNSUPPORTED_DNS_RULES_FORMAT",
"params": { "format": other },
})
.to_string(),
)
}
};
persist_custom_config(config).await
}
/// Export the custom DNS rules as "json" or "txt".
#[tauri::command]
pub async fn export_custom_dns_rules(format: String) -> Result<String, String> {
let config = CustomDnsConfig::load();
match format.as_str() {
"json" => serde_json::to_string_pretty(&config)
.map_err(|_| serde_json::json!({ "code": "DNS_RULES_EXPORT_FAILED" }).to_string()),
"txt" => Ok(config.to_txt()),
other => Err(
serde_json::json!({
"code": "UNSUPPORTED_DNS_RULES_FORMAT",
"params": { "format": other },
})
.to_string(),
),
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -306,6 +701,83 @@ mod tests {
); );
} }
#[test]
fn test_custom_config_txt_roundtrip() {
let txt = "! source: https://example.com/list.txt\n@@allowed.com\nblocked.com\n*.tracker.net\n";
let cfg = CustomDnsConfig::from_txt(txt);
assert_eq!(cfg.sources, vec!["https://example.com/list.txt"]);
assert_eq!(cfg.allow_domains, vec!["allowed.com"]);
assert_eq!(cfg.block_domains, vec!["blocked.com", "tracker.net"]);
// Re-exporting produces parseable text.
let reparsed = CustomDnsConfig::from_txt(&cfg.to_txt());
assert_eq!(reparsed.block_domains, cfg.block_domains);
assert_eq!(reparsed.allow_domains, cfg.allow_domains);
assert_eq!(reparsed.sources, cfg.sources);
}
#[test]
fn test_normalize_domain() {
assert_eq!(
normalize_domain("*.Example.COM"),
Some("example.com".into())
);
assert_eq!(normalize_domain("||ads.net^"), Some("ads.net".into()));
assert_eq!(normalize_domain(" "), None);
assert_eq!(normalize_domain("# comment"), None);
assert_eq!(normalize_domain("http://x.com"), None);
// Hosts-file format lines yield the domain, not None.
assert_eq!(
normalize_domain("0.0.0.0 ads.example.com"),
Some("ads.example.com".into())
);
assert_eq!(
normalize_domain("127.0.0.1\ttracker.net"),
Some("tracker.net".into())
);
// A bare two-token line that isn't hosts-format is still rejected.
assert_eq!(normalize_domain("foo bar"), None);
}
#[test]
fn test_normalize_domain_strips_trailing_comments() {
// Public hosts lists (StevenBlack, AdAway) annotate entries inline. Without
// comment stripping the whitespace guard drops every annotated line, so a
// source compiles down to a fraction of its real domain count.
assert_eq!(
normalize_domain("0.0.0.0 ads.example.com # AdGuard"),
Some("ads.example.com".into())
);
assert_eq!(
normalize_domain("127.0.0.1 tracker.net #comment"),
Some("tracker.net".into())
);
assert_eq!(
normalize_domain("plain.example.com # trailing note"),
Some("plain.example.com".into())
);
assert_eq!(
normalize_domain("ads.example.com ! adblock note"),
Some("ads.example.com".into())
);
// A sink IP followed only by a comment has no domain left.
assert_eq!(normalize_domain("0.0.0.0 # just a comment"), None);
// Full-line comments stay rejected.
assert_eq!(normalize_domain("! adblock header"), None);
}
#[test]
fn test_custom_level_roundtrip() {
assert_eq!(
BlocklistLevel::parse_level("custom"),
Some(BlocklistLevel::Custom)
);
assert_eq!(BlocklistLevel::Custom.as_str(), "custom");
assert_eq!(BlocklistLevel::Custom.filename(), Some("custom.txt"));
assert!(BlocklistLevel::Custom.url().is_none());
// Custom must NOT be in the auto-refresh set (no upstream URL).
assert!(!BlocklistLevel::all_downloadable().contains(&BlocklistLevel::Custom));
}
#[test] #[test]
fn test_level_urls_all_present() { fn test_level_urls_all_present() {
for &level in BlocklistLevel::all_downloadable() { for &level in BlocklistLevel::all_downloadable() {
+75 -42
View File
@@ -1057,15 +1057,15 @@ mod tests {
fn test_add_and_get_browser() { fn test_add_and_get_browser() {
let registry = DownloadedBrowsersRegistry::new(); let registry = DownloadedBrowsersRegistry::new();
let info = DownloadedBrowserInfo { let info = DownloadedBrowserInfo {
browser: "firefox".to_string(), browser: "testbrowser".to_string(),
version: "139.0".to_string(), version: "139.0".to_string(),
file_path: PathBuf::from("/test/path"), file_path: PathBuf::from("/test/path"),
}; };
registry.add_browser(info.clone()); registry.add_browser(info.clone());
assert!(registry.is_browser_registered("firefox", "139.0")); assert!(registry.is_browser_registered("testbrowser", "139.0"));
assert!(!registry.is_browser_registered("firefox", "140.0")); assert!(!registry.is_browser_registered("testbrowser", "140.0"));
assert!(!registry.is_browser_registered("chrome", "139.0")); assert!(!registry.is_browser_registered("chrome", "139.0"));
} }
@@ -1074,19 +1074,19 @@ mod tests {
let registry = DownloadedBrowsersRegistry::new(); let registry = DownloadedBrowsersRegistry::new();
let info1 = DownloadedBrowserInfo { let info1 = DownloadedBrowserInfo {
browser: "firefox".to_string(), browser: "testbrowser".to_string(),
version: "139.0".to_string(), version: "139.0".to_string(),
file_path: PathBuf::from("/test/path1"), file_path: PathBuf::from("/test/path1"),
}; };
let info2 = DownloadedBrowserInfo { let info2 = DownloadedBrowserInfo {
browser: "firefox".to_string(), browser: "testbrowser".to_string(),
version: "140.0".to_string(), version: "140.0".to_string(),
file_path: PathBuf::from("/test/path2"), file_path: PathBuf::from("/test/path2"),
}; };
let info3 = DownloadedBrowserInfo { let info3 = DownloadedBrowserInfo {
browser: "firefox".to_string(), browser: "testbrowser".to_string(),
version: "141.0".to_string(), version: "141.0".to_string(),
file_path: PathBuf::from("/test/path3"), file_path: PathBuf::from("/test/path3"),
}; };
@@ -1095,7 +1095,7 @@ mod tests {
registry.add_browser(info2); registry.add_browser(info2);
registry.add_browser(info3); registry.add_browser(info3);
let versions = registry.get_downloaded_versions("firefox"); let versions = registry.get_downloaded_versions("testbrowser");
assert_eq!(versions.len(), 3); assert_eq!(versions.len(), 3);
assert!(versions.contains(&"139.0".to_string())); assert!(versions.contains(&"139.0".to_string()));
assert!(versions.contains(&"140.0".to_string())); assert!(versions.contains(&"140.0".to_string()));
@@ -1107,22 +1107,22 @@ mod tests {
let registry = DownloadedBrowsersRegistry::new(); let registry = DownloadedBrowsersRegistry::new();
// Mark download started // Mark download started
registry.mark_download_started("firefox", "139.0", PathBuf::from("/test/path")); registry.mark_download_started("testbrowser", "139.0", PathBuf::from("/test/path"));
// Should NOT be registered until verification completes // Should NOT be registered until verification completes
assert!( assert!(
!registry.is_browser_registered("firefox", "139.0"), !registry.is_browser_registered("testbrowser", "139.0"),
"Browser should NOT be registered after marking as started (only after verification)" "Browser should NOT be registered after marking as started (only after verification)"
); );
// Mark as completed (after verification) // Mark as completed (after verification)
registry registry
.mark_download_completed("firefox", "139.0", PathBuf::from("/test/path")) .mark_download_completed("testbrowser", "139.0", PathBuf::from("/test/path"))
.expect("Failed to mark download as completed"); .expect("Failed to mark download as completed");
// Should now be registered // Should now be registered
assert!( assert!(
registry.is_browser_registered("firefox", "139.0"), registry.is_browser_registered("testbrowser", "139.0"),
"Browser should be registered after verification completes" "Browser should be registered after verification completes"
); );
} }
@@ -1131,24 +1131,24 @@ mod tests {
fn test_remove_browser() { fn test_remove_browser() {
let registry = DownloadedBrowsersRegistry::new(); let registry = DownloadedBrowsersRegistry::new();
let info = DownloadedBrowserInfo { let info = DownloadedBrowserInfo {
browser: "firefox".to_string(), browser: "testbrowser".to_string(),
version: "139.0".to_string(), version: "139.0".to_string(),
file_path: PathBuf::from("/test/path"), file_path: PathBuf::from("/test/path"),
}; };
registry.add_browser(info); registry.add_browser(info);
assert!( assert!(
registry.is_browser_registered("firefox", "139.0"), registry.is_browser_registered("testbrowser", "139.0"),
"Browser should be registered after adding" "Browser should be registered after adding"
); );
let removed = registry.remove_browser("firefox", "139.0"); let removed = registry.remove_browser("testbrowser", "139.0");
assert!( assert!(
removed.is_some(), removed.is_some(),
"Remove operation should return the removed browser info" "Remove operation should return the removed browser info"
); );
assert!( assert!(
!registry.is_browser_registered("firefox", "139.0"), !registry.is_browser_registered("testbrowser", "139.0"),
"Browser should not be registered after removal" "Browser should not be registered after removal"
); );
} }
@@ -1182,11 +1182,11 @@ mod tests {
fn test_last_version_kept_during_cleanup() { fn test_last_version_kept_during_cleanup() {
let registry = DownloadedBrowsersRegistry::new(); let registry = DownloadedBrowsersRegistry::new();
// Add a single version for "firefox" // Add a single version for "testbrowser"
registry.add_browser(DownloadedBrowserInfo { registry.add_browser(DownloadedBrowserInfo {
browser: "firefox".to_string(), browser: "testbrowser".to_string(),
version: "139.0".to_string(), version: "139.0".to_string(),
file_path: PathBuf::from("/test/firefox/139.0"), file_path: PathBuf::from("/test/testbrowser/139.0"),
}); });
// Add two versions for "chromium" // Add two versions for "chromium"
@@ -1206,11 +1206,11 @@ mod tests {
.cleanup_unused_binaries_internal(&[], &[]) .cleanup_unused_binaries_internal(&[], &[])
.expect("cleanup should succeed"); .expect("cleanup should succeed");
// firefox 139.0 should be kept (last version), chromium should lose one but keep one // testbrowser 139.0 should be kept (last version), chromium should lose one but keep one
// The exact one kept depends on iteration order, but at least one must remain // The exact one kept depends on iteration order, but at least one must remain
assert!( assert!(
!result.contains(&"firefox 139.0".to_string()), !result.contains(&"testbrowser 139.0".to_string()),
"Last version of firefox should not be cleaned up" "Last version of testbrowser should not be cleaned up"
); );
// At most one chromium version should have been cleaned up // At most one chromium version should have been cleaned up
let chromium_cleaned: Vec<_> = result let chromium_cleaned: Vec<_> = result
@@ -1223,10 +1223,10 @@ mod tests {
chromium_cleaned chromium_cleaned
); );
// Verify firefox is still registered // Verify testbrowser is still registered
assert!( assert!(
registry.is_browser_registered("firefox", "139.0"), registry.is_browser_registered("testbrowser", "139.0"),
"Last firefox version should still be registered" "Last testbrowser version should still be registered"
); );
} }
@@ -1234,7 +1234,7 @@ mod tests {
fn test_is_browser_registered_vs_downloaded() { fn test_is_browser_registered_vs_downloaded() {
let registry = DownloadedBrowsersRegistry::new(); let registry = DownloadedBrowsersRegistry::new();
let info = DownloadedBrowserInfo { let info = DownloadedBrowserInfo {
browser: "firefox".to_string(), browser: "testbrowser".to_string(),
version: "139.0".to_string(), version: "139.0".to_string(),
file_path: PathBuf::from("/test/path"), file_path: PathBuf::from("/test/path"),
}; };
@@ -1244,14 +1244,14 @@ mod tests {
// Should be registered (in-memory check) // Should be registered (in-memory check)
assert!( assert!(
registry.is_browser_registered("firefox", "139.0"), registry.is_browser_registered("testbrowser", "139.0"),
"Browser should be registered after adding to registry" "Browser should be registered after adding to registry"
); );
// is_browser_downloaded should return false in test environment because files don't exist // is_browser_downloaded should return false in test environment because files don't exist
// This tests the difference between registered (in registry) vs downloaded (files exist) // This tests the difference between registered (in registry) vs downloaded (files exist)
assert!( assert!(
!registry.is_browser_downloaded("firefox", "139.0"), !registry.is_browser_downloaded("testbrowser", "139.0"),
"Browser should not be considered downloaded when files don't exist on disk" "Browser should not be considered downloaded when files don't exist on disk"
); );
} }
@@ -1261,6 +1261,14 @@ mod tests {
pub async fn ensure_active_browsers_downloaded( pub async fn ensure_active_browsers_downloaded(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
) -> Result<Vec<String>, String> { ) -> Result<Vec<String>, String> {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping proactive browser download");
return Ok(Vec::new());
}
let registry = DownloadedBrowsersRegistry::instance(); let registry = DownloadedBrowsersRegistry::instance();
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance(); let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
let mut downloaded = Vec::new(); let mut downloaded = Vec::new();
@@ -1277,21 +1285,38 @@ pub async fn ensure_active_browsers_downloaded(
} }
log::info!("ensure_active: No {browser} versions found, will download"); log::info!("ensure_active: No {browser} versions found, will download");
// Get the latest release type for this browser // Resolve the version to download. For wayfern, only the currently
let release_types = match version_manager.get_browser_release_types(browser).await { // published version is downloadable, so ask the API fresh — the release-type
Ok(rt) => rt, // cache can be stale right after a new version is published, and the
Err(e) => { // downloader rejects requests for versions that are no longer available.
log::warn!("Failed to get release types for {browser}: {e}"); let version = if *browser == "wayfern" {
continue; match crate::api_client::ApiClient::instance()
.fetch_wayfern_version_with_caching(true)
.await
{
Ok(info) => info.version,
Err(e) => {
log::warn!("Failed to resolve current {browser} version: {e}");
continue;
}
} }
}; } else {
// Get the latest release type for this browser
let release_types = match version_manager.get_browser_release_types(browser).await {
Ok(rt) => rt,
Err(e) => {
log::warn!("Failed to get release types for {browser}: {e}");
continue;
}
};
// Use stable version (the only release type for these browsers) // Use stable version (the only release type for these browsers)
let version = match release_types.stable { match release_types.stable {
Some(v) => v, Some(v) => v,
None => { None => {
log::debug!("No stable version available for {browser} on this platform, skipping"); log::debug!("No stable version available for {browser} on this platform, skipping");
continue; continue;
}
} }
}; };
@@ -1330,8 +1355,8 @@ pub async fn ensure_active_browsers_downloaded(
Err(_) => { Err(_) => {
// The download future itself hung past the overall timeout and was dropped, // The download future itself hung past the overall timeout and was dropped,
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping // so its own cleanup never ran. Clear any leftover in-progress bookkeeping
// (the future may have re-resolved to a different version, so clear by // (by browser prefix, as a catch-all for whatever key was in flight) and
// browser prefix) and emit a terminal error event so the UI stops spinning. // emit a terminal error event so the UI stops spinning.
log::warn!( log::warn!(
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})", "Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
ATTEMPT_TIMEOUT.as_secs() ATTEMPT_TIMEOUT.as_secs()
@@ -1393,6 +1418,14 @@ pub async fn check_missing_binaries() -> Result<Vec<(String, String, String)>, S
pub async fn ensure_all_binaries_exist( pub async fn ensure_all_binaries_exist(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
) -> Result<Vec<String>, String> { ) -> Result<Vec<String>, String> {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping proactive binary and GeoIP downloads");
return Ok(Vec::new());
}
let registry = DownloadedBrowsersRegistry::instance(); let registry = DownloadedBrowsersRegistry::instance();
registry registry
.ensure_all_binaries_exist(&app_handle) .ensure_all_binaries_exist(&app_handle)
+72 -74
View File
@@ -23,6 +23,22 @@ lazy_static::lazy_static! {
std::sync::Arc::new(Mutex::new(std::collections::HashMap::new())); std::sync::Arc::new(Mutex::new(std::collections::HashMap::new()));
} }
/// Clears a browser-version pair from the in-flight download maps on every
/// exit path of `download_browser_full`. A leaked key would permanently report
/// "already being downloaded" for that version until app restart.
struct InFlightDownload(String);
impl Drop for InFlightDownload {
fn drop(&mut self) {
if let Ok(mut downloading) = DOWNLOADING_BROWSERS.lock() {
downloading.remove(&self.0);
}
if let Ok(mut tokens) = DOWNLOAD_CANCELLATION_TOKENS.lock() {
tokens.remove(&self.0);
}
}
}
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct DownloadProgress { pub struct DownloadProgress {
pub browser: String, pub browser: String,
@@ -97,7 +113,13 @@ impl Downloader {
.await?; .await?;
if !response.status().is_success() { if !response.status().is_success() {
return Err(format!("Download failed with status: {}", response.status()).into()); return Err(
format!(
"Download failed with HTTP status {}",
response.status().as_u16()
)
.into(),
);
} }
let mut file = std::fs::OpenOptions::new() let mut file = std::fs::OpenOptions::new()
@@ -131,10 +153,16 @@ impl Downloader {
.fetch_wayfern_version_with_caching(true) .fetch_wayfern_version_with_caching(true)
.await?; .await?;
// Never substitute: downloading the current build into the requested
// version's directory would register a mislabeled install.
if version_info.version != version { if version_info.version != version {
log::info!( return Err(
"Wayfern: requested version {version}, using available version {}", serde_json::json!({
version_info.version "code": "WAYFERN_VERSION_NOT_AVAILABLE",
"params": { "requested": version, "current": version_info.version }
})
.to_string()
.into(),
); );
} }
@@ -203,7 +231,7 @@ impl Downloader {
let download_url = self let download_url = self
.resolve_download_url(browser_type.clone(), version, download_info) .resolve_download_url(browser_type.clone(), version, download_info)
.await?; .await?;
log::info!("Download URL resolved: {}", download_url); log::info!("Download URL resolved");
// In-session resume: a large (~1GB) download over a flaky connection can // In-session resume: a large (~1GB) download over a flaky connection can
// drop mid-stream. Rather than surfacing the first stall/chunk error as a // drop mid-stream. Rather than surfacing the first stall/chunk error as a
@@ -301,7 +329,13 @@ impl Downloader {
// Check if the response is successful (200 OK or 206 Partial Content) // Check if the response is successful (200 OK or 206 Partial Content)
if !(response.status().is_success() || response.status().as_u16() == 206) { if !(response.status().is_success() || response.status().as_u16() == 206) {
return Err(format!("Download failed with status: {}", response.status()).into()); return Err(
format!(
"Download failed with HTTP status {}",
response.status().as_u16()
)
.into(),
);
} }
// Determine total size // Determine total size
@@ -504,25 +538,36 @@ impl Downloader {
return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into()); return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into());
} }
// For Wayfern, resolve the actual available version from the API // Validate the browser type before touching the in-flight maps so a bad
let version = if browser_str == "wayfern" { // request can't leave state behind.
match self let browser_type =
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
let browser = create_browser(browser_type.clone());
// For Wayfern, only the currently published version can be fetched.
// Requesting any other not-yet-downloaded version is an error — silently
// substituting the latest would install a version the caller never asked
// for while the response still echoes the requested one. The fetch must
// succeed too: proceeding unverified would let resolve_download_url fetch
// the current build into the requested version's directory (a mislabeled
// install), and that URL resolution needs the same endpoint anyway.
if browser_str == "wayfern" && !self.registry.is_browser_downloaded(&browser_str, &version) {
let info = self
.api_client .api_client
.fetch_wayfern_version_with_caching(true) .fetch_wayfern_version_with_caching(true)
.await .await
{ .map_err(|e| format!("Failed to determine the current Wayfern version: {e}"))?;
Ok(info) if info.version != version => { if info.version != version {
log::info!( return Err(
"Wayfern: requested {version}, using available {}", serde_json::json!({
info.version "code": "WAYFERN_VERSION_NOT_AVAILABLE",
); "params": { "requested": version, "current": info.version }
info.version })
} .to_string()
_ => version, .into(),
);
} }
} else { }
version
};
// Check if this browser-version pair is already being downloaded // Check if this browser-version pair is already being downloaded
let download_key = format!("{browser_str}-{version}"); let download_key = format!("{browser_str}-{version}");
@@ -539,10 +584,8 @@ impl Downloader {
tokens.insert(download_key.clone(), token.clone()); tokens.insert(download_key.clone(), token.clone());
token token
}; };
// Cleared on drop, whatever exit path this function takes.
let browser_type = let _in_flight = InFlightDownload(download_key.clone());
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
let browser = create_browser(browser_type.clone());
let binaries_dir = crate::app_dirs::binaries_dir(); let binaries_dir = crate::app_dirs::binaries_dir();
@@ -551,12 +594,6 @@ impl Downloader {
let actually_exists = browser.is_version_downloaded(&version, &binaries_dir); let actually_exists = browser.is_version_downloaded(&version, &binaries_dir);
if actually_exists { if actually_exists {
// Remove from downloading set since it's already downloaded
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
drop(downloading);
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
return Ok(version); return Ok(version);
} else { } else {
// Registry says it's downloaded but files don't exist - clean up registry // Registry says it's downloaded but files don't exist - clean up registry
@@ -575,12 +612,6 @@ impl Downloader {
.is_browser_supported(&browser_str) .is_browser_supported(&browser_str)
.unwrap_or(false) .unwrap_or(false)
{ {
// Remove from downloading set on error
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
drop(downloading);
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
return Err( return Err(
format!( format!(
"Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}", "Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}",
@@ -630,11 +661,6 @@ impl Downloader {
// Clean registry entry and stop here so the UI can show a single, clear error. // Clean registry entry and stop here so the UI can show a single, clear error.
let _ = self.registry.remove_browser(&browser_str, &version); let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save(); let _ = self.registry.save();
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
drop(downloading);
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to // Emit a terminal stage so the UI stops spinning. A user cancellation maps to
// "cancelled"; any other failure (network error, stall timeout, bad status) // "cancelled"; any other failure (network error, stall timeout, bad status)
@@ -687,14 +713,6 @@ impl Downloader {
let _ = self.registry.remove_browser(&browser_str, &version); let _ = self.registry.remove_browser(&browser_str, &version);
let _ = self.registry.save(); let _ = self.registry.save();
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
}
// Emit error stage so the UI shows a toast // Emit error stage so the UI shows a toast
let progress = DownloadProgress { let progress = DownloadProgress {
@@ -777,15 +795,6 @@ impl Downloader {
}; };
let _ = events::emit("download-progress", &progress); let _ = events::emit("download-progress", &progress);
// Remove browser-version pair from downloading set on verification failure
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
}
return Err(error_details.into()); return Err(error_details.into());
} }
@@ -825,16 +834,6 @@ impl Downloader {
}; };
let _ = events::emit("download-progress", &progress); let _ = events::emit("download-progress", &progress);
// Remove browser-version pair from downloading set and cancel token
{
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
downloading.remove(&download_key);
}
{
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
tokens.remove(&download_key);
}
// Auto-update non-running profiles to the latest installed version and cleanup unused binaries // Auto-update non-running profiles to the latest installed version and cleanup unused binaries
{ {
let app_handle_for_update = app_handle.clone(); let app_handle_for_update = app_handle.clone();
@@ -883,10 +882,9 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
/// Clear all in-progress download bookkeeping for a browser. /// Clear all in-progress download bookkeeping for a browser.
/// ///
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped /// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
/// by an outer timeout) before its own error path could run. Because /// by an outer timeout) before its own error path could run. Matches by the
/// `download_browser_full` may re-resolve to a different version than requested, this /// `"{browser}-"` key prefix rather than an exact version so no stuck key is left
/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck /// behind even when the caller doesn't know which version was actually in flight.
/// key is left behind regardless of which version was actually in flight.
pub fn clear_download_state_for_browser(browser: &str) { pub fn clear_download_state_for_browser(browser: &str) {
let prefix = format!("{browser}-"); let prefix = format!("{browser}-");
{ {
@@ -909,7 +907,7 @@ pub async fn download_browser(
downloader downloader
.download_browser_full(&app_handle, browser_str, version) .download_browser_full(&app_handle, browser_str, version)
.await .await
.map_err(|e| format!("Failed to download browser: {e}")) .map_err(|e| crate::wrap_backend_error(e, "Failed to download browser"))
} }
#[tauri::command] #[tauri::command]
+1
View File
@@ -280,6 +280,7 @@ mod tests {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
clear_on_close: false,
created_at: None, created_at: None,
updated_at: None, updated_at: None,
} }
+32 -7
View File
@@ -280,12 +280,21 @@ impl ExtensionManager {
let (manifest_name, version, description, author, homepage_url) = let (manifest_name, version, description, author, homepage_url) =
extract_manifest_metadata(&file_data, &file_type); extract_manifest_metadata(&file_data, &file_type);
let final_name = if manifest_name.is_some() { // An empty/whitespace-only manifest name counts as absent so the
manifest_name.clone().unwrap_or(name) // user-provided name still applies.
} else { let final_name = match manifest_name.clone() {
name Some(n) if !n.trim().is_empty() => n,
_ => name,
}; };
if final_name.trim().is_empty() {
return Err(
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
.to_string()
.into(),
);
}
let ext = Extension { let ext = Extension {
id: uuid::Uuid::new_v4().to_string(), id: uuid::Uuid::new_v4().to_string(),
name: final_name, name: final_name,
@@ -510,6 +519,14 @@ impl ExtensionManager {
} }
pub fn create_group(&self, name: String) -> Result<ExtensionGroup, Box<dyn std::error::Error>> { pub fn create_group(&self, name: String) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
if name.trim().is_empty() {
return Err(
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
.to_string()
.into(),
);
}
let mut data = self.load_groups_data()?; let mut data = self.load_groups_data()?;
if data.groups.iter().any(|g| g.name == name) { if data.groups.iter().any(|g| g.name == name) {
@@ -566,6 +583,14 @@ impl ExtensionManager {
name: Option<String>, name: Option<String>,
extension_ids: Option<Vec<String>>, extension_ids: Option<Vec<String>>,
) -> Result<ExtensionGroup, Box<dyn std::error::Error>> { ) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
if name.as_deref().is_some_and(|n| n.trim().is_empty()) {
return Err(
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
.to_string()
.into(),
);
}
let mut data = self.load_groups_data()?; let mut data = self.load_groups_data()?;
if let Some(ref new_name) = name { if let Some(ref new_name) = name {
@@ -1080,7 +1105,7 @@ pub async fn add_extension(
let mgr = EXTENSION_MANAGER.lock().unwrap(); let mgr = EXTENSION_MANAGER.lock().unwrap();
mgr mgr
.add_extension(name, file_name, file_data) .add_extension(name, file_name, file_data)
.map_err(|e| format!("Failed to add extension: {e}")) .map_err(|e| crate::wrap_backend_error(e, "Failed to add extension"))
} }
#[tauri::command] #[tauri::command]
@@ -1120,7 +1145,7 @@ pub async fn create_extension_group(name: String) -> Result<ExtensionGroup, Stri
let mgr = EXTENSION_MANAGER.lock().unwrap(); let mgr = EXTENSION_MANAGER.lock().unwrap();
mgr mgr
.create_group(name) .create_group(name)
.map_err(|e| format!("Failed to create extension group: {e}")) .map_err(|e| crate::wrap_backend_error(e, "Failed to create extension group"))
} }
#[tauri::command] #[tauri::command]
@@ -1132,7 +1157,7 @@ pub async fn update_extension_group(
let mgr = EXTENSION_MANAGER.lock().unwrap(); let mgr = EXTENSION_MANAGER.lock().unwrap();
mgr mgr
.update_group(&group_id, name, extension_ids) .update_group(&group_id, name, extension_ids)
.map_err(|e| format!("Failed to update extension group: {e}")) .map_err(|e| crate::wrap_backend_error(e, "Failed to update extension group"))
} }
#[tauri::command] #[tauri::command]
+381
View File
@@ -0,0 +1,381 @@
//! Launch-time consistency check: resolve the proxy's exit IP, geolocate it
//! with the bundled MaxMind database (the same source the fingerprint generator
//! uses), then compare its timezone and country against the profile
//! fingerprint's timezone and language. A mismatch (e.g. a US fingerprint
//! behind a German exit IP) is a strong anti-bot tell even though the real
//! device never leaks — so we warn the user after launch and offer to match the
//! fingerprint to the exit. Launches never rewrite the fingerprint silently, so
//! a real mismatch always surfaces here.
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;
use crate::profile::types::BrowserProfile;
use crate::proxy_manager::PROXY_MANAGER;
/// Exit-node lookups are cached per proxy for this long. A stored proxy's exit
/// geolocation is stable enough that re-resolving the exit IP through the proxy
/// on every launch is wasteful.
const EXIT_CACHE_TTL_SECS: u64 = 30 * 60;
#[derive(Clone)]
struct CachedExit {
fetched_at: u64,
/// The proxy URL this exit was measured through. Editing a stored proxy keeps
/// its id, so without this an entry outlives the endpoint it describes: the
/// check would compare a re-generated fingerprint against the *old* exit and
/// either warn about a correct profile or — worse — call a genuinely
/// mismatched one consistent, which is exactly the tell it exists to catch.
proxy_url: String,
timezone: Option<String>,
country_code: Option<String>,
ip: Option<String>,
}
lazy_static::lazy_static! {
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct ConsistencyResult {
/// True when everything we could check lines up (or there was nothing to
/// check — no proxy assigned).
pub consistent: bool,
/// True when we actually reached an exit node and compared something.
pub checked: bool,
pub exit_ip: Option<String>,
pub exit_country_code: Option<String>,
pub exit_timezone: Option<String>,
pub fingerprint_timezone: Option<String>,
pub fingerprint_language: Option<String>,
/// One of "timezone", "language" — the dimensions that disagree.
pub mismatches: Vec<String>,
}
impl ConsistencyResult {
fn skip() -> Self {
Self {
consistent: true,
checked: false,
exit_ip: None,
exit_country_code: None,
exit_timezone: None,
fingerprint_timezone: None,
fingerprint_language: None,
mismatches: Vec::new(),
}
}
}
/// URL for handing this proxy to reqwest, or None for upstreams reqwest can't
/// drive (Shadowsocks).
fn proxy_url(settings: &crate::browser::ProxySettings) -> Option<String> {
match settings.proxy_type.to_lowercase().as_str() {
"http" | "https" | "socks4" | "socks5" => Some(
crate::proxy_manager::ProxyManager::build_proxy_url(settings),
),
_ => None,
}
}
/// Whether the fingerprint's language is plausible for the exit country.
///
/// Validated against the same CLDR data the fingerprint generator samples from
/// (`geolocation::LocaleSelector`), not a hand-written country->language table.
/// The generator picks a language at random weighted by CLDR speaker share, so
/// any table naming one "expected" language per country flags fingerprints
/// Donut itself produced — roughly 10% of US profiles legitimately get `es-US`
/// and ~23% of Canadian ones get `fr-CA`. `None` means the country has no CLDR
/// data and the check is skipped.
fn language_matches_country(cc: &str, language: &str) -> Option<bool> {
crate::geolocation::locale_selector()?.region_speaks(cc, language)
}
/// Extract (timezone, language) from a profile's stored fingerprint JSON.
fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<String>) {
let Some(config) = &profile.wayfern_config else {
return (None, None);
};
let Some(fp_str) = &config.fingerprint else {
return (None, None);
};
let Ok(fp) = serde_json::from_str::<serde_json::Value>(fp_str) else {
return (None, None);
};
let timezone = fp
.get("timezone")
.and_then(|v| v.as_str())
.map(str::to_string);
let language = fp
.get("language")
.and_then(|v| v.as_str())
.map(str::to_string);
(timezone, language)
}
/// Run the check for a profile. No-ops (consistent, unchecked) when the
/// profile has no proxy or the exit node can't be reached.
pub async fn check_profile_consistency(
profile: &BrowserProfile,
) -> Result<ConsistencyResult, String> {
let Some(proxy_id) = &profile.proxy_id else {
return Ok(ConsistencyResult::skip());
};
let Some(settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) else {
return Ok(ConsistencyResult::skip());
};
let Some(url) = proxy_url(&settings) else {
return Ok(ConsistencyResult::skip());
};
let now = crate::proxy_manager::now_secs();
// Serve a fresh cached exit lookup for this proxy if we have one, but only if
// it was measured through the proxy's current endpoint and credentials.
let cached = {
let cache = EXIT_CACHE.lock().unwrap();
cache
.get(proxy_id)
.filter(|c| c.proxy_url == url && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS)
.cloned()
};
let (exit_tz, exit_cc, exit_ip) = if let Some(c) = cached {
(c.timezone, c.country_code, c.ip)
} else {
// Resolve the exit IP through the proxy, then geolocate it with the SAME
// bundled MaxMind database the fingerprint generator (and the on-demand
// match) use. Using one geo source everywhere means the check can never
// disagree with what generation produced — a second source (e.g. ip-api)
// routinely reports a different IANA zone for the same IP in multi-zone
// countries, which would flag correctly-generated fingerprints and would
// leave the "match to proxy" fix unable to satisfy the check.
let exit_ip = crate::ip_utils::fetch_public_ip(Some(&url))
.await
.map_err(|e| format!("exit-node lookup failed: {e}"))?;
match crate::geolocation::get_geolocation(&exit_ip) {
Ok(geo) => {
let tz = Some(geo.timezone);
let cc = geo.locale.region.clone();
let ip = Some(exit_ip);
EXIT_CACHE.lock().unwrap().insert(
proxy_id.clone(),
CachedExit {
fetched_at: now,
proxy_url: url.clone(),
timezone: tz.clone(),
country_code: cc.clone(),
ip: ip.clone(),
},
);
(tz, cc, ip)
}
// Reached the exit but couldn't place it (database missing, or a private
// exit IP). Skip rather than warn on an unknown location — the same
// database gates fingerprint geo, so there's nothing to disagree with.
Err(e) => {
log::debug!("Consistency check: could not geolocate exit IP: {e}");
return Ok(ConsistencyResult::skip());
}
}
};
let (fp_tz, fp_lang) = fingerprint_locale(profile);
let mut mismatches = Vec::new();
if let (Some(exit), Some(fp)) = (&exit_tz, &fp_tz) {
if !exit.eq_ignore_ascii_case(fp) {
mismatches.push("timezone".to_string());
}
}
if let (Some(cc), Some(lang)) = (&exit_cc, &fp_lang) {
if language_matches_country(cc, lang) == Some(false) {
mismatches.push("language".to_string());
}
}
Ok(ConsistencyResult {
consistent: mismatches.is_empty(),
checked: true,
exit_ip,
exit_country_code: exit_cc,
exit_timezone: exit_tz,
fingerprint_timezone: fp_tz,
fingerprint_language: fp_lang,
mismatches,
})
}
#[tauri::command]
pub async fn check_profile_fingerprint_consistency(
profile_id: String,
) -> Result<ConsistencyResult, String> {
let profiles = crate::profile::ProfileManager::instance()
.list_profiles()
.map_err(|e| e.to_string())?;
let profile = profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
check_profile_consistency(&profile).await
}
/// Rewrite a profile's stored fingerprint so its geolocation (timezone,
/// language, coordinates) matches `exit_ip`, and persist it. This is the
/// on-demand resolution for the consistency warning: launches no longer rewrite
/// the fingerprint silently, so the user opts into matching it here.
///
/// `exit_ip` is the exit the consistency check already resolved, applied via
/// MaxMind directly — no second proxy round-trip — and it forces geolocation
/// even when the profile has geo spoofing disabled, since the user explicitly
/// asked to match. Takes effect on the next launch of the profile.
#[tauri::command]
pub async fn match_profile_fingerprint_to_exit(
profile_id: String,
exit_ip: String,
) -> Result<(), String> {
let manager = crate::profile::ProfileManager::instance();
let mut profile = manager
.list_profiles()
.map_err(|e| e.to_string())?
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
let mut config = profile
.wayfern_config
.clone()
.filter(|c| c.fingerprint.is_some())
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
let fingerprint = config.fingerprint.clone().unwrap();
let geoip_override = serde_json::Value::String(exit_ip);
let refreshed = crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
&fingerprint,
None,
Some(&geoip_override),
)
.await
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
config.fingerprint = Some(refreshed);
profile.wayfern_config = Some(config);
manager.save_profile(&profile).map_err(|e| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
.to_string()
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn language_check_accepts_the_main_language_of_the_country() {
assert_eq!(language_matches_country("US", "en-US"), Some(true));
assert_eq!(language_matches_country("de", "de-DE"), Some(true));
assert_eq!(language_matches_country("BR", "pt-BR"), Some(true));
}
#[test]
fn language_check_accepts_what_the_fingerprint_generator_emits() {
// These are not the "expected" language for the country, but the generator
// samples the CLDR distribution and produces them routinely — CLDR puts es
// at 9.6% in the US and fr at 30% in Canada. Flagging them warns the user
// about a fingerprint Donut itself created.
assert_eq!(language_matches_country("US", "es-US"), Some(true));
assert_eq!(language_matches_country("CA", "fr-CA"), Some(true));
assert_eq!(language_matches_country("CA", "en-CA"), Some(true));
}
#[test]
fn language_check_flags_us_fingerprint_behind_armenian_exit() {
// The reported scenario: a US (en) fingerprint routed through an Armenian
// exit. Armenia's CLDR lists hy/ku/az, never en, so this must flag.
assert_eq!(language_matches_country("AM", "en-US"), Some(false));
// And a fingerprint actually matched to Armenia must not flag.
assert_eq!(language_matches_country("AM", "hy-AM"), Some(true));
}
#[test]
fn language_check_still_flags_implausible_combinations() {
// Nothing in CLDR associates these with the country, so they remain the
// anti-bot tell the check exists to surface. (Japan lists ja/ryu/ko only.)
assert_eq!(language_matches_country("JP", "pt-BR"), Some(false));
assert_eq!(language_matches_country("JP", "de-DE"), Some(false));
assert_eq!(language_matches_country("BR", "ru-RU"), Some(false));
}
#[test]
fn language_check_tolerates_minority_languages_the_generator_can_emit() {
// CLDR lists ja for Brazil (0.21%, the Japanese-Brazilian community) and de
// for the US (0.47%), and the generator samples both. They are weak signals
// but flagging them would contradict our own fingerprints, so the check
// accepts anything CLDR lists at all. That is the deliberate ceiling on
// this dimension — timezone, compared exactly, carries the real signal.
assert_eq!(language_matches_country("BR", "ja-JP"), Some(true));
assert_eq!(language_matches_country("US", "de-DE"), Some(true));
}
#[test]
fn language_check_skips_countries_without_cldr_data() {
// The old hand-written table returned None for ~180 countries and silently
// skipped the check; only genuinely unknown territories should do that now.
assert_eq!(language_matches_country("ZZ", "en-US"), None);
// Countries the old table never covered are now checked.
assert!(language_matches_country("ID", "id-ID").is_some());
assert!(language_matches_country("CH", "de-CH").is_some());
}
#[test]
fn proxy_url_percent_encodes_credentials_and_skips_shadowsocks() {
let http = crate::browser::ProxySettings {
proxy_type: "http".into(),
host: "h".into(),
port: 8080,
username: Some("u".into()),
password: Some("p".into()),
};
assert_eq!(proxy_url(&http).as_deref(), Some("http://u:p@h:8080"));
// A password with URL-reserved characters must not break the authority —
// unencoded, the `/` truncates the host and reqwest targets `u` instead.
let reserved = crate::browser::ProxySettings {
proxy_type: "http".into(),
host: "gw.provider.io".into(),
port: 8080,
username: Some("user".into()),
password: Some("ab/cd@ef".into()),
};
assert_eq!(
proxy_url(&reserved).as_deref(),
Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080")
);
// Username-only proxies keep their auth.
let user_only = crate::browser::ProxySettings {
proxy_type: "socks5".into(),
host: "h".into(),
port: 1080,
username: Some("justuser".into()),
password: None,
};
assert_eq!(
proxy_url(&user_only).as_deref(),
Some("socks5://justuser@h:1080")
);
let ss = crate::browser::ProxySettings {
proxy_type: "ss".into(),
host: "h".into(),
port: 8080,
username: None,
password: None,
};
assert_eq!(proxy_url(&ss), None);
}
}
+15 -6
View File
@@ -141,13 +141,22 @@ impl GeoIPDownloader {
}, },
); );
// Fetch latest release from GitHub #[cfg(feature = "e2e")]
let releases = self.fetch_geoip_releases().await?; let fixture_url = std::env::var("DONUT_E2E_GEOIP_DOWNLOAD_URL")
let latest_release = releases.first().ok_or("No GeoIP database releases found")?; .ok()
.filter(|url| !url.is_empty());
#[cfg(not(feature = "e2e"))]
let fixture_url: Option<String> = None;
let download_url = self let download_url = if let Some(url) = fixture_url {
.find_city_mmdb_asset(latest_release) url
.ok_or("No compatible GeoIP database asset found")?; } else {
let releases = self.fetch_geoip_releases().await?;
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
self
.find_city_mmdb_asset(latest_release)
.ok_or("No compatible GeoIP database asset found")?
};
// Create cache directory // Create cache directory
let cache_dir = Self::get_cache_dir(); let cache_dir = Self::get_cache_dir();
+44
View File
@@ -9,6 +9,7 @@ use rand::RngExt;
use std::collections::HashMap; use std::collections::HashMap;
use std::net::IpAddr; use std::net::IpAddr;
use std::str::FromStr; use std::str::FromStr;
use std::sync::OnceLock;
const TERRITORY_INFO_XML: &str = include_str!("territory_info.xml"); const TERRITORY_INFO_XML: &str = include_str!("territory_info.xml");
@@ -41,6 +42,27 @@ pub enum GeolocationError {
Ip(#[from] IpError), Ip(#[from] IpError),
} }
/// The language part of a locale or language tag: `"en"` from `"en-US"`,
/// `"zh"` from `"zh_Hant"`.
pub fn primary_subtag(tag: &str) -> &str {
tag.split(['-', '_']).next().unwrap_or(tag)
}
/// Shared selector over the bundled CLDR territory data. Parsing the XML costs
/// real time, and the data is immutable, so build it once.
pub fn locale_selector() -> Option<&'static LocaleSelector> {
static SELECTOR: OnceLock<Option<LocaleSelector>> = OnceLock::new();
SELECTOR
.get_or_init(|| match LocaleSelector::new() {
Ok(s) => Some(s),
Err(e) => {
log::warn!("Failed to build the CLDR locale selector: {e}");
None
}
})
.as_ref()
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Locale { pub struct Locale {
pub language: String, pub language: String,
@@ -152,6 +174,28 @@ impl LocaleSelector {
Ok(Self { territories }) Ok(Self { territories })
} }
/// Whether CLDR associates `language` with `region` at all.
///
/// Returns `None` for a territory with no language data, so callers can skip
/// rather than guess. The test is deliberately "is this language listed for
/// this country", not "is this the country's main language": `from_region`
/// samples the whole listed distribution weighted by speaker share, so every
/// listed language is one this selector itself can emit. Anything stricter
/// would flag fingerprints Donut generated on purpose — CLDR puts `es` at
/// 9.6% in the US and `fr` at 30% in Canada, and those get picked.
pub fn region_speaks(&self, region: &str, language: &str) -> Option<bool> {
let languages = self.territories.get(&region.to_uppercase())?;
if languages.is_empty() {
return None;
}
let primary = primary_subtag(language);
Some(
languages
.iter()
.any(|l| primary_subtag(&l.language).eq_ignore_ascii_case(primary)),
)
}
#[allow(clippy::wrong_self_convention)] #[allow(clippy::wrong_self_convention)]
pub fn from_region(&self, region: &str) -> Result<Locale, GeolocationError> { pub fn from_region(&self, region: &str) -> Result<Locale, GeolocationError> {
let region_upper = region.to_uppercase(); let region_upper = region.to_uppercase();
+16
View File
@@ -81,6 +81,14 @@ impl GroupManager {
_app_handle: &tauri::AppHandle, _app_handle: &tauri::AppHandle,
name: String, name: String,
) -> Result<ProfileGroup, Box<dyn std::error::Error>> { ) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
if name.trim().is_empty() {
return Err(
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
.to_string()
.into(),
);
}
let mut groups_data = self.load_groups_data()?; let mut groups_data = self.load_groups_data()?;
// Check if group with this name already exists // Check if group with this name already exists
@@ -127,6 +135,14 @@ impl GroupManager {
id: String, id: String,
name: String, name: String,
) -> Result<ProfileGroup, Box<dyn std::error::Error>> { ) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
if name.trim().is_empty() {
return Err(
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
.to_string()
.into(),
);
}
let mut groups_data = self.load_groups_data()?; let mut groups_data = self.load_groups_data()?;
// Check if another group with this name already exists // Check if another group with this name already exists
+275 -198
View File
@@ -3,6 +3,7 @@ use std::env;
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex; use std::sync::Mutex;
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder}; use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
#[cfg(not(feature = "e2e"))]
use tauri_plugin_deep_link::DeepLinkExt; use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::{Target, TargetKind}; use tauri_plugin_log::{Target, TargetKind};
@@ -14,6 +15,25 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
// to the confirmation dialog. // to the confirmation dialog.
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false); static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
fn e2e_automation_enabled() -> bool {
#[cfg(feature = "e2e")]
{
std::env::var("TAURI_AUTOMATION")
.is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
}
#[cfg(not(feature = "e2e"))]
{
false
}
}
#[cfg(feature = "e2e")]
fn e2e_automation_profile_dir() -> Option<std::path::PathBuf> {
e2e_automation_enabled()
.then(|| std::env::var_os("TAURI_AUTOMATION_PROFILE_DIR").map(std::path::PathBuf::from))
.flatten()
}
mod api_client; mod api_client;
mod api_server; mod api_server;
mod app_auto_updater; mod app_auto_updater;
@@ -29,11 +49,13 @@ mod downloader;
mod ephemeral_dirs; mod ephemeral_dirs;
mod extension_manager; mod extension_manager;
mod extraction; mod extraction;
mod fingerprint_consistency;
mod geoip_downloader; mod geoip_downloader;
mod geolocation; mod geolocation;
mod group_manager; mod group_manager;
mod human_typing; mod human_typing;
mod ip_utils; mod ip_utils;
mod log_redaction;
mod platform_browser; mod platform_browser;
mod profile; mod profile;
mod profile_importer; mod profile_importer;
@@ -68,9 +90,10 @@ use browser_runner::{
use profile::manager::{ use profile::manager::{
check_browser_status, clone_profile, create_browser_profile_new, delete_profile, check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
list_browser_profiles, rename_profile, update_profile_dns_blocklist, update_profile_launch_hook, list_browser_profiles, rename_profile, update_profile_clear_on_close,
update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_dns_blocklist, update_profile_launch_hook, update_profile_note,
update_profile_tags, update_profile_vpn, update_profile_window_color, update_wayfern_config, update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
update_profile_window_color, update_wayfern_config,
}; };
use profile::password::{ use profile::password::{
@@ -125,7 +148,10 @@ use app_auto_updater::{
restart_application, restart_application,
}; };
use profile_importer::{detect_existing_profiles, import_browser_profile}; use profile_importer::{
cleanup_profile_import_scratch, detect_existing_profiles, import_browser_profiles,
scan_folder_for_profiles, scan_profile_archive,
};
use extension_manager::{ use extension_manager::{
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group, add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
@@ -222,7 +248,7 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
// Called internally for deep-link / startup URL handling — not invoked from the // Called internally for deep-link / startup URL handling — not invoked from the
// frontend, so it is intentionally not a `#[tauri::command]`. // frontend, so it is intentionally not a `#[tauri::command]`.
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> { async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
log::info!("handle_url_open called with URL: {url}"); log::info!("Handling URL open request");
// Check if the main window exists and is ready // Check if the main window exists and is ready
if let Some(window) = app.get_webview_window("main") { if let Some(window) = app.get_webview_window("main") {
@@ -245,6 +271,18 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
Ok(()) Ok(())
} }
/// Prefix a command error with context, but pass structured `{"code": ...}`
/// backend errors through untouched — the frontend can only translate a code
/// when the JSON is the entire message (see src/lib/backend-errors.ts).
pub(crate) fn wrap_backend_error(e: impl std::fmt::Display, context: &str) -> String {
let msg = e.to_string();
if msg.starts_with('{') {
msg
} else {
format!("{context}: {msg}")
}
}
#[tauri::command] #[tauri::command]
async fn create_stored_proxy( async fn create_stored_proxy(
app_handle: tauri::AppHandle, app_handle: tauri::AppHandle,
@@ -254,7 +292,7 @@ async fn create_stored_proxy(
if let Some(settings) = proxy_settings { if let Some(settings) = proxy_settings {
crate::proxy_manager::PROXY_MANAGER crate::proxy_manager::PROXY_MANAGER
.create_stored_proxy(&app_handle, name, settings) .create_stored_proxy(&app_handle, name, settings)
.map_err(|e| format!("Failed to create stored proxy: {e}")) .map_err(|e| wrap_backend_error(e, "Failed to create stored proxy"))
} else { } else {
Err("proxy_settings is required".to_string()) Err("proxy_settings is required".to_string())
} }
@@ -274,7 +312,7 @@ async fn update_stored_proxy(
) -> Result<crate::proxy_manager::StoredProxy, String> { ) -> Result<crate::proxy_manager::StoredProxy, String> {
crate::proxy_manager::PROXY_MANAGER crate::proxy_manager::PROXY_MANAGER
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings) .update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
.map_err(|e| format!("Failed to update stored proxy: {e}")) .map_err(|e| wrap_backend_error(e, "Failed to update stored proxy"))
} }
#[tauri::command] #[tauri::command]
@@ -769,6 +807,13 @@ async fn clear_all_traffic_stats() -> Result<(), String> {
.map_err(|e| format!("Failed to clear traffic stats: {e}")) .map_err(|e| format!("Failed to clear traffic stats: {e}"))
} }
#[tauri::command]
async fn clear_profile_traffic_stats(profile_id: String) -> Result<(), String> {
crate::traffic_stats::delete_traffic_stats(&profile_id);
let _ = events::emit_empty("traffic-stats-changed");
Ok(())
}
#[tauri::command] #[tauri::command]
async fn get_traffic_stats_for_period( async fn get_traffic_stats_for_period(
profile_id: String, profile_id: String,
@@ -1202,6 +1247,7 @@ async fn generate_sample_fingerprint(
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
clear_on_close: false,
created_at: None, created_at: None,
updated_at: None, updated_at: None,
}; };
@@ -1238,6 +1284,7 @@ fn hide_to_tray(app_handle: tauri::AppHandle) -> Result<(), String> {
Ok(()) Ok(())
} }
#[cfg(not(feature = "e2e"))]
fn show_main_window(app_handle: &tauri::AppHandle) { fn show_main_window(app_handle: &tauri::AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") { if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show(); let _ = window.show();
@@ -1277,6 +1324,7 @@ fn update_tray_menu(
/// Build the system tray. Best-effort: on Linux the tray depends on /// Build the system tray. Best-effort: on Linux the tray depends on
/// libayatana-appindicator at runtime, so any failure here must not abort app /// libayatana-appindicator at runtime, so any failure here must not abort app
/// startup — the caller logs and continues without a tray. /// startup — the caller logs and continues without a tray.
#[cfg(not(feature = "e2e"))]
fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> { fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
use std::sync::atomic::Ordering; use std::sync::atomic::Ordering;
use tauri::menu::{MenuBuilder, MenuItemBuilder}; use tauri::menu::{MenuBuilder, MenuItemBuilder};
@@ -1343,11 +1391,18 @@ fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::E
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
run_with_builder(|builder| builder);
}
#[doc(hidden)]
pub fn run_with_builder(
configure_builder: impl FnOnce(tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry>,
) {
let args: Vec<String> = env::args().collect(); let args: Vec<String> = env::args().collect();
let startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned(); let startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned();
if let Some(url) = startup_url.clone() { if let Some(url) = startup_url.clone() {
log::info!("Found startup URL in command line: {url}"); log::info!("Found startup URL in command line");
let mut pending = PENDING_URLS.lock().unwrap(); let mut pending = PENDING_URLS.lock().unwrap();
pending.push(url.clone()); pending.push(url.clone());
} }
@@ -1366,54 +1421,61 @@ pub fn run() {
}), }),
}; };
tauri::Builder::default() let builder = configure_builder(tauri::Builder::default());
.plugin(
tauri_plugin_log::Builder::new() let builder = builder.plugin(
.clear_targets() // Clear default targets to avoid duplicates tauri_plugin_log::Builder::new()
.target(Target::new(TargetKind::Stdout)) .clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Webview)) .target(Target::new(TargetKind::Stdout))
.target(file_log_target) .target(Target::new(TargetKind::Webview))
// 5 MB per rotated file × KeepAll — the previous 100 KB limit .target(file_log_target)
// truncated useful context in customer support reports; 50 MB // Keep enough context for customer support without letting a long-running
// turned out to be excessive disk pressure. // installation accumulate logs without bound.
.max_file_size(5 * 1024 * 1024) .max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll) .rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(10))
.level(log::LevelFilter::Info) .level(log::LevelFilter::Info)
.format(|out, message, record| { .format(|out, message, record| {
use chrono::Local; use chrono::Local;
let now = Local::now(); let now = Local::now();
let timestamp = format!( let timestamp = format!(
"{}.{:03}", "{}.{:03}",
now.format("%Y-%m-%d %H:%M:%S"), now.format("%Y-%m-%d %H:%M:%S"),
now.timestamp_subsec_millis() now.timestamp_subsec_millis()
); );
out.finish(format_args!( out.finish(format_args!(
"[{}][{}][{}] {}", "[{}][{}][{}] {}",
timestamp, timestamp,
record.target(), record.target(),
record.level(), record.level(),
message message
)) ))
}) })
.build(), .build(),
) );
.plugin(tauri_plugin_single_instance::init(
|app_handle, args, _cwd| { #[cfg(not(feature = "e2e"))]
log::info!("Single instance triggered with args: {args:?}"); let builder = builder.plugin(tauri_plugin_single_instance::init(
if let Some(window) = app_handle.get_webview_window("main") { |app_handle, args, _cwd| {
let _ = window.show(); log::info!("Single instance triggered with args: {args:?}");
let _ = window.set_focus(); if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.unminimize(); let _ = window.show();
} let _ = window.set_focus();
}, let _ = window.unminimize();
)) }
},
));
let builder = builder
.plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_fs::init()) .plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init()) .plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_macos_permissions::init()) .plugin(tauri_plugin_macos_permissions::init())
.plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_clipboard_manager::init());
#[cfg(not(feature = "e2e"))]
let builder = builder
// Persist window size/position across restarts. VISIBLE is excluded // Persist window size/position across restarts. VISIBLE is excluded
// because the app hides to tray: restoring visibility would otherwise // because the app hides to tray: restoring visibility would otherwise
// relaunch with an invisible window after quitting from the tray while // relaunch with an invisible window after quitting from the tray while
@@ -1428,8 +1490,9 @@ pub fn run() {
& !tauri_plugin_window_state::StateFlags::FULLSCREEN, & !tauri_plugin_window_state::StateFlags::FULLSCREEN,
) )
.build(), .build(),
) );
.setup(|app| {
builder.setup(|app| {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk) // Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs(); ephemeral_dirs::recover_ephemeral_dirs();
@@ -1451,6 +1514,19 @@ pub fn run() {
.focused(true) .focused(true)
.visible(true); .visible(true);
#[cfg(feature = "e2e")]
let win_builder = match e2e_automation_profile_dir() {
Some(profile_dir) => win_builder
.data_directory(profile_dir.join("webview"))
// WKWebView ignores data_directory on macOS. Incognito gives every
// launched app process a non-persistent data store there, and also
// prevents WebView2/WebKitGTK caches from escaping the session on
// the other platforms. Durable app state is still exercised via
// DONUTBROWSER_DATA_ROOT; only browser-engine storage is ephemeral.
.incognito(true),
None => win_builder,
};
#[cfg(target_os = "windows")] #[cfg(target_os = "windows")]
let win_builder = win_builder.decorations(false); let win_builder = win_builder.decorations(false);
@@ -1461,8 +1537,11 @@ pub fn run() {
// dialog's "Minimize" action hides the window. Best-effort: a tray // dialog's "Minimize" action hides the window. Best-effort: a tray
// failure (e.g. missing libayatana-appindicator on Linux) must never // failure (e.g. missing libayatana-appindicator on Linux) must never
// prevent the app from launching, so we log and continue without it. // prevent the app from launching, so we log and continue without it.
if let Err(e) = setup_system_tray(app.handle()) { #[cfg(not(feature = "e2e"))]
log::warn!("System tray unavailable, continuing without it: {e}"); {
if let Err(e) = setup_system_tray(app.handle()) {
log::warn!("System tray unavailable, continuing without it: {e}");
}
} }
// Intercept the window close so the frontend can ask the user whether // Intercept the window close so the frontend can ask the user whether
@@ -1505,7 +1584,7 @@ pub fn run() {
log::warn!("Failed to set global event emitter: {e}"); log::warn!("Failed to set global event emitter: {e}");
} }
#[cfg(windows)] #[cfg(all(windows, not(feature = "e2e")))]
{ {
// For Windows, register all deep links at runtime // For Windows, register all deep links at runtime
if let Err(e) = app.deep_link().register_all() { if let Err(e) = app.deep_link().register_all() {
@@ -1513,7 +1592,7 @@ pub fn run() {
} }
} }
#[cfg(target_os = "macos")] #[cfg(all(target_os = "macos", not(feature = "e2e")))]
{ {
// On macOS, try to register deep links for development builds // On macOS, try to register deep links for development builds
if let Err(e) = app.deep_link().register_all() { if let Err(e) = app.deep_link().register_all() {
@@ -1523,63 +1602,62 @@ pub fn run() {
} }
} }
app.deep_link().on_open_url({ #[cfg(not(feature = "e2e"))]
let handle = handle.clone(); {
move |event| { app.deep_link().on_open_url({
let urls = event.urls(); let handle = handle.clone();
log::info!("Deep link event received with {} URLs", urls.len()); move |event| {
let urls = event.urls();
log::info!("Deep link event received with {} URLs", urls.len());
for url in urls { for url in urls {
let url_string = url.to_string(); let url_string = url.to_string();
log::info!("Deep link received: {url_string}"); log::info!("Processing deep link URL");
let handle_clone = handle.clone();
// Clone the handle for each async task tauri::async_runtime::spawn(async move {
let handle_clone = handle.clone(); if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
log::error!("Failed to handle deep link URL: {e}");
// Handle the URL asynchronously }
tauri::async_runtime::spawn(async move { });
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await { }
log::error!("Failed to handle deep link URL: {e}");
}
});
} }
} });
}); }
if let Some(startup_url) = startup_url { if let Some(startup_url) = startup_url {
let handle_clone = handle.clone(); let handle_clone = handle.clone();
tauri::async_runtime::spawn(async move { tauri::async_runtime::spawn(async move {
log::info!("Processing startup URL from command line: {startup_url}"); log::info!("Processing startup URL from command line");
if let Err(e) = handle_url_open(handle_clone, startup_url.clone()).await { if let Err(e) = handle_url_open(handle_clone, startup_url.clone()).await {
log::error!("Failed to handle startup URL: {e}"); log::error!("Failed to handle startup URL: {e}");
} }
}); });
} }
// Initialize and start background version updater if !e2e_automation_enabled() {
let app_handle = app.handle().clone(); // Initialize and start background version updater
tauri::async_runtime::spawn(async move { let app_handle = app.handle().clone();
let version_updater = get_version_updater(); tauri::async_runtime::spawn(async move {
let version_updater = get_version_updater();
// Set the app handle {
{ let mut updater_guard = version_updater.lock().await;
let mut updater_guard = version_updater.lock().await; updater_guard.set_app_handle(app_handle);
updater_guard.set_app_handle(app_handle);
}
// Run startup check without holding the lock
{
let updater_guard = version_updater.lock().await;
if let Err(e) = updater_guard.start_background_updates().await {
log::error!("Failed to start background updates: {e}");
} }
}
});
// Start the background update task separately {
tauri::async_runtime::spawn(async move { let updater_guard = version_updater.lock().await;
version_updater::VersionUpdater::run_background_task().await; if let Err(e) = updater_guard.start_background_updates().await {
}); log::error!("Failed to start background updates: {e}");
}
}
});
tauri::async_runtime::spawn(async move {
version_updater::VersionUpdater::run_background_task().await;
});
}
// Auto-start MCP server if it was previously enabled. Always log the // Auto-start MCP server if it was previously enabled. Always log the
// decision so customer logs reveal whether MCP is actually running — // decision so customer logs reveal whether MCP is actually running —
@@ -1740,12 +1818,12 @@ pub fn run() {
} }
} }
let app_handle_auto_updater = app.handle().clone(); if !e2e_automation_enabled() {
let app_handle_auto_updater = app.handle().clone();
// Start the auto-update check task separately tauri::async_runtime::spawn(async move {
tauri::async_runtime::spawn(async move { auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await; });
}); }
// Handle any pending URLs that were received before the window was ready // Handle any pending URLs that were received before the window was ready
let handle_pending = handle.clone(); let handle_pending = handle.clone();
@@ -1761,114 +1839,92 @@ pub fn run() {
}; };
for url in pending_urls { for url in pending_urls {
log::info!("Processing pending URL: {url}"); log::info!("Processing pending URL");
if let Err(e) = handle_url_open(handle_pending.clone(), url).await { if let Err(e) = handle_url_open(handle_pending.clone(), url).await {
log::error!("Failed to handle pending URL: {e}"); log::error!("Failed to handle pending URL: {e}");
} }
} }
}); });
// Start periodic cleanup task for unused binaries if !e2e_automation_enabled() {
// Only runs when sync is not in progress to avoid deleting browsers // Start periodic cleanup task for unused binaries.
// that might be needed for profiles being synced from the cloud tauri::async_runtime::spawn(async move {
tauri::async_runtime::spawn(async move { let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); // Every 12 hours loop {
interval.tick().await;
loop { if let Some(scheduler) = sync::get_global_scheduler() {
interval.tick().await; if scheduler.is_sync_in_progress().await {
log::debug!("Skipping cleanup: sync is in progress");
// Check if sync is in progress before running cleanup continue;
if let Some(scheduler) = sync::get_global_scheduler() {
if scheduler.is_sync_in_progress().await {
log::debug!("Skipping cleanup: sync is in progress");
continue;
}
}
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::error!("Periodic cleanup failed: {e}");
} else {
log::debug!("Periodic cleanup completed successfully");
}
}
});
// DNS blocklist refresh task (every 12 hours)
tauri::async_runtime::spawn(async move {
let manager = dns_blocklist::BlocklistManager::instance();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
interval.tick().await; // Skip the immediate first tick
loop {
interval.tick().await;
manager.refresh_all_stale().await;
}
});
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
loop {
interval.tick().await;
log::info!("Checking for app updates...");
// Route through check_for_app_updates (not the raw check_for_updates)
// so the background loop respects portable mode and the
// disable_auto_updates setting. Previously it bypassed both, so a
// portable install would auto-download and run the NSIS installer,
// clobbering the portable folder instead of updating in place.
match app_auto_updater::check_for_app_updates().await {
Ok(Some(update_info)) => {
log::info!(
"App update available: {} -> {}",
update_info.current_version,
update_info.new_version
);
if let Err(e) = events::emit("app-update-available", &update_info) {
log::error!("Failed to emit app update event: {e}");
} }
} }
Ok(None) => {
log::debug!("No app updates available");
}
Err(e) => {
log::error!("Failed to check for app updates: {e}");
}
}
}
});
// Check and download GeoIP database at startup if needed let registry =
let app_handle_geoip = app.handle().clone(); crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
tauri::async_runtime::spawn(async move { if let Err(e) = registry.cleanup_unused_binaries() {
// Wait a bit for the app to fully initialize log::error!("Periodic cleanup failed: {e}");
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
match geoip_downloader.check_missing_geoip_database() {
Ok(true) => {
log::info!(
"GeoIP database is missing for Wayfern profiles, downloading at startup..."
);
let geoip_downloader = GeoIPDownloader::instance();
if let Err(e) = geoip_downloader
.download_geoip_database(&app_handle_geoip)
.await
{
log::error!("Failed to download GeoIP database at startup: {e}");
} else { } else {
log::info!("GeoIP database downloaded successfully at startup"); log::debug!("Periodic cleanup completed successfully");
} }
} }
Ok(false) => { });
// No Wayfern profiles or GeoIP database already available
tauri::async_runtime::spawn(async move {
let manager = dns_blocklist::BlocklistManager::instance();
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
interval.tick().await;
loop {
interval.tick().await;
manager.refresh_all_stale().await;
} }
Err(e) => { });
log::error!("Failed to check GeoIP database status at startup: {e}");
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
loop {
interval.tick().await;
log::info!("Checking for app updates...");
match app_auto_updater::check_for_app_updates().await {
Ok(Some(update_info)) => {
log::info!(
"App update available: {} -> {}",
update_info.current_version,
update_info.new_version
);
if let Err(e) = events::emit("app-update-available", &update_info) {
log::error!("Failed to emit app update event: {e}");
}
}
Ok(None) => log::debug!("No app updates available"),
Err(e) => log::error!("Failed to check for app updates: {e}"),
}
} }
} });
});
let app_handle_geoip = app.handle().clone();
tauri::async_runtime::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
match geoip_downloader.check_missing_geoip_database() {
Ok(true) => {
log::info!(
"GeoIP database is missing for Wayfern profiles, downloading at startup..."
);
let geoip_downloader = GeoIPDownloader::instance();
if let Err(e) = geoip_downloader
.download_geoip_database(&app_handle_geoip)
.await
{
log::error!("Failed to download GeoIP database at startup: {e}");
} else {
log::info!("GeoIP database downloaded successfully at startup");
}
}
Ok(false) => {}
Err(e) => log::error!("Failed to check GeoIP database status at startup: {e}"),
}
});
}
// Start proxy cleanup task for dead browser processes // Start proxy cleanup task for dead browser processes
let app_handle_proxy_cleanup = app.handle().clone(); let app_handle_proxy_cleanup = app.handle().clone();
@@ -2029,6 +2085,16 @@ pub fn run() {
.await; .await;
} }
// Clear-on-close for natural exits (user closed the window).
// The explicit kill path in browser_runner.rs handles
// app-driven stops. Must also run before
// `mark_profile_stopped` so a queued sync sees the cleared
// dir rather than re-uploading the wiped browsing data.
if !is_running {
crate::profile::clear_on_close::clear_profile_browsing_data(&profile)
.await;
}
// Notify sync scheduler of running state changes // Notify sync scheduler of running state changes
if let Some(scheduler) = sync::get_global_scheduler() { if let Some(scheduler) = sync::get_global_scheduler() {
if is_running { if is_running {
@@ -2223,6 +2289,7 @@ pub fn run() {
update_profile_vpn, update_profile_vpn,
update_profile_tags, update_profile_tags,
update_profile_note, update_profile_note,
update_profile_clear_on_close,
update_profile_launch_hook, update_profile_launch_hook,
update_profile_window_color, update_profile_window_color,
update_profile_proxy_bypass_rules, update_profile_proxy_bypass_rules,
@@ -2256,7 +2323,10 @@ pub fn run() {
download_and_prepare_app_update, download_and_prepare_app_update,
restart_application, restart_application,
detect_existing_profiles, detect_existing_profiles,
import_browser_profile, import_browser_profiles,
scan_folder_for_profiles,
scan_profile_archive,
cleanup_profile_import_scratch,
check_missing_binaries, check_missing_binaries,
check_missing_geoip_database, check_missing_geoip_database,
ensure_all_binaries_exist, ensure_all_binaries_exist,
@@ -2301,7 +2371,10 @@ pub fn run() {
get_all_traffic_snapshots, get_all_traffic_snapshots,
get_profile_traffic_snapshot, get_profile_traffic_snapshot,
clear_all_traffic_stats, clear_all_traffic_stats,
clear_profile_traffic_stats,
get_traffic_stats_for_period, get_traffic_stats_for_period,
fingerprint_consistency::check_profile_fingerprint_consistency,
fingerprint_consistency::match_profile_fingerprint_to_exit,
get_sync_settings, get_sync_settings,
save_sync_settings, save_sync_settings,
set_profile_sync_mode, set_profile_sync_mode,
@@ -2377,6 +2450,10 @@ pub fn run() {
// DNS blocklist commands // DNS blocklist commands
dns_blocklist::get_dns_blocklist_cache_status, dns_blocklist::get_dns_blocklist_cache_status,
dns_blocklist::refresh_dns_blocklists, 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,
// Profile password commands // Profile password commands
set_profile_password, set_profile_password,
change_profile_password, change_profile_password,
+96
View File
@@ -0,0 +1,96 @@
use regex_lite::Regex;
use std::sync::LazyLock;
static URL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r#"(?i)\b[a-z][a-z0-9+.-]{1,20}://[^\s<>"']+"#).expect("valid URL regex")
});
static PRIVATE_KEY_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?is)-----BEGIN [^-\r\n]*PRIVATE KEY-----.*?-----END [^-\r\n]*PRIVATE KEY-----")
.expect("valid private-key regex")
});
static BEARER_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+").expect("valid bearer regex"));
static SECRET_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(
r"(?i)\b(api[_-]?key|authorization|password|passwd|private[_-]?key|proxy[_-]?(password|username)|refresh[_-]?token|secret|token|username)\b\s*[:=]\s*[^\s,;]+",
)
.expect("valid secret regex")
});
static EMAIL_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b").expect("valid email regex")
});
static UNIX_HOME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"/(Users|home)/[^/\s]+").expect("valid Unix home regex"));
static WINDOWS_HOME_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\b[A-Z]:\\Users\\[^\\\s]+").expect("valid Windows home regex"));
static IPV4_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\b([0-9]{1,3}\.){3}[0-9]{1,3}\b").expect("valid IPv4 regex"));
static DOMAIN_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"(?i)\b([a-z0-9-]+\.)+[a-z]{2,}\b").expect("valid domain regex"));
static UUID_RE: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(r"(?i)\b[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\b")
.expect("valid UUID regex")
});
pub fn url_label(value: &str) -> String {
url::Url::parse(value)
.map(|parsed| format!("{}://<redacted>", parsed.scheme()))
.unwrap_or_else(|_| "<redacted-url>".to_string())
}
pub fn text(value: &str) -> String {
let redacted = PRIVATE_KEY_RE.replace_all(value, "<redacted-private-key>");
let redacted = URL_RE.replace_all(&redacted, "<redacted-url>");
let redacted = BEARER_RE.replace_all(&redacted, "Bearer <redacted-secret>");
let redacted = SECRET_RE.replace_all(&redacted, "<redacted-secret>");
let redacted = EMAIL_RE.replace_all(&redacted, "<redacted-email>");
let redacted = UNIX_HOME_RE.replace_all(&redacted, "/<redacted-home>");
let redacted = WINDOWS_HOME_RE.replace_all(&redacted, "<redacted-home>");
let redacted = IPV4_RE.replace_all(&redacted, "<redacted-ip>");
let redacted = DOMAIN_RE.replace_all(&redacted, "<redacted-domain>");
UUID_RE
.replace_all(&redacted, "<redacted-identifier>")
.into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redacts_sensitive_log_content() {
let input = format!(
concat!(
"URL https://user:pass@example.com/callback?code=private\n",
"Authorization: Bearer secret-token\n",
"password=hunter2\n",
"user@example.com /Users/alice/Library C:\\Users\\alice\\AppData\n",
"exit 203.0.113.42\n",
"-----BEGIN {0} KEY-----\nprivate-material\n-----END {0} KEY-----\n",
),
"PRIVATE"
);
let output = text(&input);
for sensitive in [
"user:pass",
"example.com",
"private-material",
"secret-token",
"hunter2",
"user@example.com",
"alice",
"203.0.113.42",
] {
assert!(!output.contains(sensitive), "log output leaked {sensitive}");
}
}
#[test]
fn url_labels_retain_only_the_scheme() {
assert_eq!(
url_label("https://user:pass@example.com/path?token=value"),
"https://<redacted>"
);
assert_eq!(url_label("not a URL"), "<redacted-url>");
}
}
+165
View File
@@ -638,6 +638,64 @@ impl McpServer {
"required": ["name", "browser"] "required": ["name", "browser"]
}), }),
}, },
McpTool {
name: "detect_browser_profiles".to_string(),
description: "Detect importable Chromium-family browser profiles (Chrome, Chromium, Brave) on this machine, or scan a custom folder for profile directories".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"folder": {
"type": "string",
"description": "Optional folder to scan instead of the default browser locations. Accepts a single profile dir, a Chromium user-data dir, or a folder holding one profile dir per child."
}
}
}),
},
McpTool {
name: "import_browser_profiles".to_string(),
description: "Bulk-import browser profiles from on-disk profile folders (e.g. paths returned by detect_browser_profiles). Each imported profile becomes a Wayfern profile; items are isolated so one failure doesn't stop the rest".to_string(),
input_schema: serde_json::json!({
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"source_path": {
"type": "string",
"description": "Path to the source profile directory"
},
"new_profile_name": {
"type": "string",
"description": "Name for the imported profile"
},
"proxy_id": {
"type": "string",
"description": "Optional proxy UUID to assign to this profile"
},
"vpn_id": {
"type": "string",
"description": "Optional VPN UUID to assign to this profile"
}
},
"required": ["source_path", "new_profile_name"]
},
"description": "Profiles to import"
},
"group_id": {
"type": "string",
"description": "Optional group UUID assigned to every imported profile"
},
"duplicate_strategy": {
"type": "string",
"enum": ["skip", "rename"],
"description": "How to handle an already-taken profile name (default: rename with a numeric suffix)"
}
},
"required": ["items"]
}),
},
McpTool { McpTool {
name: "update_profile".to_string(), name: "update_profile".to_string(),
description: "Update an existing browser profile's settings".to_string(), description: "Update an existing browser profile's settings".to_string(),
@@ -677,6 +735,10 @@ impl McpServer {
"type": "array", "type": "array",
"items": { "type": "string" }, "items": { "type": "string" },
"description": "Proxy bypass rules (replaces existing rules)" "description": "Proxy bypass rules (replaces existing rules)"
},
"clear_on_close": {
"type": "boolean",
"description": "Wipe browsing data (keeping extensions and bookmarks) when the browser exits. Not available for ephemeral or password-protected profiles."
} }
}, },
"required": ["profile_id"] "required": ["profile_id"]
@@ -1731,6 +1793,9 @@ impl McpServer {
self.handle_batch_stop_profiles(arguments).await self.handle_batch_stop_profiles(arguments).await
} }
"create_profile" => self.handle_create_profile(arguments).await, "create_profile" => self.handle_create_profile(arguments).await,
// Profile import (free, like create_profile — importing is not automation)
"detect_browser_profiles" => self.handle_detect_browser_profiles(arguments).await,
"import_browser_profiles" => self.handle_import_browser_profiles(arguments).await,
"update_profile" => self.handle_update_profile(arguments).await, "update_profile" => self.handle_update_profile(arguments).await,
"delete_profile" => self.handle_delete_profile(arguments).await, "delete_profile" => self.handle_delete_profile(arguments).await,
"list_tags" => self.handle_list_tags().await, "list_tags" => self.handle_list_tags().await,
@@ -2490,6 +2555,14 @@ impl McpServer {
})?; })?;
} }
if let Some(clear_on_close) = arguments.get("clear_on_close").and_then(|v| v.as_bool()) {
pm.update_profile_clear_on_close(app_handle, profile_id, clear_on_close)
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to update clear-on-close: {e}"),
})?;
}
Ok(serde_json::json!({ Ok(serde_json::json!({
"content": [{ "content": [{
"type": "text", "type": "text",
@@ -3201,6 +3274,95 @@ impl McpServer {
})) }))
} }
// Profile import handlers
async fn handle_detect_browser_profiles(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let importer = crate::profile_importer::ProfileImporter::instance();
let profiles = match arguments.get("folder").and_then(|v| v.as_str()) {
Some(folder) => importer.scan_folder(std::path::Path::new(folder)),
None => importer.detect_existing_profiles(),
}
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to detect profiles: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": serde_json::to_string_pretty(&profiles).unwrap_or_else(|_| "[]".to_string())
}]
}))
}
async fn handle_import_browser_profiles(
&self,
arguments: &serde_json::Value,
) -> Result<serde_json::Value, McpError> {
let items: Vec<crate::profile_importer::ImportProfileItem> = arguments
.get("items")
.cloned()
.ok_or_else(|| McpError {
code: -32602,
message: "Missing items".to_string(),
})
.and_then(|v| {
serde_json::from_value(v).map_err(|e| McpError {
code: -32602,
message: format!("Invalid items: {e}"),
})
})?;
let group_id = arguments
.get("group_id")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
let duplicate_strategy = arguments
.get("duplicate_strategy")
.cloned()
.map(serde_json::from_value::<crate::profile_importer::DuplicateStrategy>)
.transpose()
.map_err(|e| McpError {
code: -32602,
message: format!("Invalid duplicate_strategy: {e}"),
})?
.unwrap_or_default();
// Clone the handle instead of holding the inner lock across a potentially
// multi-GB copy.
let app_handle = {
let inner = self.inner.lock().await;
inner.app_handle.clone().ok_or_else(|| McpError {
code: -32000,
message: "MCP server not properly initialized".to_string(),
})?
};
let result = crate::profile_importer::ProfileImporter::instance()
.import_profiles(&app_handle, items, group_id, duplicate_strategy, None)
.await
.map_err(|e| McpError {
code: -32000,
message: format!("Failed to import profiles: {e}"),
})?;
Ok(serde_json::json!({
"content": [{
"type": "text",
"text": format!(
"Import complete: {} imported, {} skipped, {} failed\n{}",
result.imported_count,
result.skipped_count,
result.failed_count,
serde_json::to_string_pretty(&result.results).unwrap_or_default()
)
}]
}))
}
// Cookie management handlers // Cookie management handlers
async fn handle_import_profile_cookies( async fn handle_import_profile_cookies(
&self, &self,
@@ -5304,6 +5466,9 @@ mod tests {
assert!(tool_names.contains(&"run_profile")); assert!(tool_names.contains(&"run_profile"));
assert!(tool_names.contains(&"kill_profile")); assert!(tool_names.contains(&"kill_profile"));
assert!(tool_names.contains(&"get_profile_status")); assert!(tool_names.contains(&"get_profile_status"));
// Profile import tools
assert!(tool_names.contains(&"detect_browser_profiles"));
assert!(tool_names.contains(&"import_browser_profiles"));
// Group tools // Group tools
assert!(tool_names.contains(&"list_groups")); assert!(tool_names.contains(&"list_groups"));
assert!(tool_names.contains(&"get_group")); assert!(tool_names.contains(&"get_group"));
+5 -166
View File
@@ -15,8 +15,7 @@ use std::process::Command;
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool { fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect(); let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
for (i, arg) in args.iter().enumerate() { for (i, arg) in args.iter().enumerate() {
// Exact argument equality (Firefox: `-profile <path>`; some launchers // Exact argument equality (some launchers pass the path as its own arg).
// pass the path as its own arg).
if *arg == profile_path { if *arg == profile_path {
return true; return true;
} }
@@ -85,59 +84,6 @@ pub mod macos {
} }
} }
pub async fn open_url_in_existing_browser_firefox_like(
profile: &BrowserProfile,
url: &str,
browser_type: BrowserType,
browser_dir: &Path,
profiles_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let pid = profile.process_id.unwrap();
let profile_data_path = profile.get_profile_data_path(profiles_dir);
// First try: Use Firefox remote command
log::info!("Trying Firefox remote command for PID: {pid}");
let browser = create_browser(browser_type);
if let Ok(executable_path) = browser.get_executable_path(browser_dir) {
let remote_args = vec![
"-profile".to_string(),
profile_data_path.to_string_lossy().to_string(),
"-new-tab".to_string(),
url.to_string(),
];
let remote_output = Command::new(executable_path).args(&remote_args).output();
match remote_output {
Ok(output) if output.status.success() => {
log::info!("Firefox remote command succeeded");
return Ok(());
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::info!(
"Firefox remote command failed with stderr: {stderr}, trying AppleScript fallback"
);
}
Err(e) => {
log::info!("Firefox remote command error: {e}, trying AppleScript fallback");
}
}
}
// The Firefox `-new-tab` remote command failed. We intentionally do NOT
// fall back to an AppleScript `System Events` keystroke path: that would
// send Apple Events to another application and trigger the macOS TCC
// "<Donut> wants control of <Browser>" / "prevented from modifying other
// apps" prompts. Donut must never touch other apps on the user's Mac.
Err(
format!(
"Firefox remote command failed for PID {pid}; cannot open URL in existing window without touching other apps"
)
.into(),
)
}
pub async fn kill_browser_process_impl( pub async fn kill_browser_process_impl(
pid: u32, pid: u32,
profile_data_path: Option<&str>, profile_data_path: Option<&str>,
@@ -399,77 +345,6 @@ pub mod windows {
Ok(child) Ok(child)
} }
pub async fn open_url_in_existing_browser_firefox_like(
profile: &BrowserProfile,
url: &str,
browser_type: BrowserType,
browser_dir: &Path,
profiles_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let browser = create_browser(browser_type);
let executable_path = browser
.get_executable_path(browser_dir)
.map_err(|e| format!("Failed to get executable path: {}", e))?;
let profile_data_path = profile.get_profile_data_path(profiles_dir);
// For Windows, try using the -requestPending approach for Firefox
let mut cmd = Command::new(executable_path);
cmd.args([
"-profile",
&profile_data_path.to_string_lossy(),
"-requestPending",
"-new-tab",
url,
]);
// Set working directory
if let Some(parent_dir) = browser_dir
.parent()
.or_else(|| browser_dir.ancestors().nth(1))
{
cmd.current_dir(parent_dir);
}
let output = cmd.output()?;
if !output.status.success() {
// Fallback: try without -requestPending
let executable_path = browser
.get_executable_path(browser_dir)
.map_err(|e| format!("Failed to get executable path: {}", e))?;
let mut fallback_cmd = Command::new(executable_path);
let profile_data_path = profile.get_profile_data_path(profiles_dir);
fallback_cmd.args([
"-profile",
&profile_data_path.to_string_lossy(),
"-new-tab",
url,
]);
if let Some(parent_dir) = browser_dir
.parent()
.or_else(|| browser_dir.ancestors().nth(1))
{
fallback_cmd.current_dir(parent_dir);
}
let fallback_output = fallback_cmd.output()?;
if !fallback_output.status.success() {
return Err(
format!(
"Failed to open URL in existing browser: {}",
String::from_utf8_lossy(&fallback_output.stderr)
)
.into(),
);
}
}
Ok(())
}
pub async fn open_url_in_existing_browser_chromium( pub async fn open_url_in_existing_browser_chromium(
profile: &BrowserProfile, profile: &BrowserProfile,
url: &str, url: &str,
@@ -588,7 +463,7 @@ pub mod linux {
let mut cmd = Command::new(executable_path); let mut cmd = Command::new(executable_path);
cmd.args(args); cmd.args(args);
// For Firefox-based browsers, ensure library path includes the installation directory // Ensure the library path includes the installation directory
if let Some(install_dir) = executable_path.parent() { if let Some(install_dir) = executable_path.parent() {
let mut ld_library_path = Vec::new(); let mut ld_library_path = Vec::new();
@@ -606,16 +481,15 @@ pub mod linux {
} }
} }
// For Firefox specifically, add common system library paths that might be needed // Add common system library paths that might be needed
let firefox_lib_paths = [ let system_lib_paths = [
"/usr/lib/firefox",
"/usr/lib/x86_64-linux-gnu", "/usr/lib/x86_64-linux-gnu",
"/usr/lib/aarch64-linux-gnu", "/usr/lib/aarch64-linux-gnu",
"/lib/x86_64-linux-gnu", "/lib/x86_64-linux-gnu",
"/lib/aarch64-linux-gnu", "/lib/aarch64-linux-gnu",
]; ];
for lib_path in &firefox_lib_paths { for lib_path in &system_lib_paths {
let path = std::path::Path::new(lib_path); let path = std::path::Path::new(lib_path);
if path.exists() { if path.exists() {
ld_library_path.push(lib_path.to_string()); ld_library_path.push(lib_path.to_string());
@@ -686,41 +560,6 @@ pub mod linux {
} }
} }
pub async fn open_url_in_existing_browser_firefox_like(
profile: &BrowserProfile,
url: &str,
browser_type: BrowserType,
browser_dir: &Path,
profiles_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let browser = create_browser(browser_type);
let executable_path = browser
.get_executable_path(browser_dir)
.map_err(|e| format!("Failed to get executable path: {}", e))?;
let profile_data_path = profile.get_profile_data_path(profiles_dir);
let output = Command::new(executable_path)
.args([
"-profile",
&profile_data_path.to_string_lossy(),
"-new-tab",
url,
])
.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to open URL in existing browser: {}",
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
Ok(())
}
pub async fn open_url_in_existing_browser_chromium( pub async fn open_url_in_existing_browser_chromium(
profile: &BrowserProfile, profile: &BrowserProfile,
url: &str, url: &str,
+292
View File
@@ -0,0 +1,292 @@
//! Clear-on-close: wipe a profile's browsing data when the browser exits,
//! keeping extensions and bookmarks (and the settings files Chromium needs to
//! keep those working). The middle ground between a fully persistent profile
//! and a RAM-backed ephemeral one.
use std::fs;
use std::path::Path;
use crate::profile::types::BrowserProfile;
/// Entries kept inside a Chromium profile directory (one with `Preferences`).
/// Everything else — cookies, History, caches, storage, sessions, autofill,
/// saved logins — is browsing data and gets removed.
const PROFILE_KEEP: &[&str] = &[
"Bookmarks",
"Bookmarks.bak",
"Extensions",
"Extension State",
"Extension Rules",
"Extension Scripts",
"Extension Cookies",
"Local Extension Settings",
"Managed Extension Settings",
// Preferences hold the extension registry + user settings; deleting them
// disables every installed extension, so they stay.
"Preferences",
"Secure Preferences",
];
/// Entries kept at the user-data-dir top level (outside profile subdirs).
const TOP_KEEP: &[&str] = &["Local State", "First Run"];
fn is_kept(name: &str) -> bool {
PROFILE_KEEP.contains(&name) || TOP_KEEP.contains(&name)
}
/// Whether a top-level entry name is one of Chromium's profile directories.
///
/// Identity must not rest on `Preferences` existing. Chromium writes it lazily,
/// so a crash — or a user deleting a corrupt copy, a standard troubleshooting
/// step since it regenerates — leaves a populated `Default/` without it. Such a
/// directory would then be treated as stale and removed wholesale, destroying the
/// Extensions and Bookmarks this feature exists to preserve.
fn is_profile_dir_name(name: &str) -> bool {
matches!(name, "Default" | "Guest Profile" | "System Profile")
|| name
.strip_prefix("Profile ")
.is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
}
fn remove_entry(path: &Path) {
let result = if path.is_dir() {
fs::remove_dir_all(path)
} else {
fs::remove_file(path)
};
if let Err(e) = result {
log::warn!("clear-on-close: failed to remove {}: {e}", path.display());
}
}
/// Wipe browsing data inside a Chromium profile dir, keeping `PROFILE_KEEP`
/// (and `TOP_KEEP`, harmless at this level).
fn clear_chromium_profile_dir(profile_dir: &Path) -> usize {
let mut cleared = 0usize;
let Ok(entries) = fs::read_dir(profile_dir) else {
return 0;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if is_kept(name) {
continue;
}
remove_entry(&entry.path());
cleared += 1;
}
cleared
}
/// Clear browsing data in a Wayfern user-data directory. Handles both
/// layouts: profile content at the root (imported profiles copy a Chromium
/// profile dir directly) and the standard `Default` / `Profile N` subdirs
/// Chromium creates itself. Top-level cache dirs (ShaderCache, GrShaderCache,
/// component_crx_cache, …) are removed; `Local State` stays because it holds
/// the os_crypt key extensions may rely on.
pub fn clear_user_data_dir(user_data_dir: &Path) -> usize {
if !user_data_dir.exists() {
return 0;
}
// Root itself is a profile dir (legacy/imported layout).
if user_data_dir.join("Preferences").exists() {
return clear_chromium_profile_dir(user_data_dir);
}
let mut cleared = 0usize;
let Ok(entries) = fs::read_dir(user_data_dir) else {
return 0;
};
for entry in entries.flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if is_kept(name) {
continue;
}
let path = entry.path();
if path.is_dir() && (path.join("Preferences").exists() || is_profile_dir_name(name)) {
// A profile subdir (Default / Profile N) — clear inside, keep the dir.
cleared += clear_chromium_profile_dir(&path);
} else {
remove_entry(&path);
cleared += 1;
}
}
cleared
}
/// Clear a profile's browsing data if its `clear_on_close` flag is set.
/// No-ops for ephemeral (already wiped) and password-protected (dir is
/// re-encrypted; plaintext never persists) profiles. Runs the filesystem work
/// on a blocking thread.
pub async fn clear_profile_browsing_data(profile: &BrowserProfile) {
if !profile.clear_on_close || profile.ephemeral || profile.password_protected {
return;
}
let profiles_dir = crate::profile::ProfileManager::instance().get_profiles_dir();
let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
let name = profile.name.clone();
let cleared = tokio::task::spawn_blocking(move || clear_user_data_dir(&user_data_dir))
.await
.unwrap_or(0);
log::info!("clear-on-close: cleared {cleared} browsing-data entries for profile '{name}'");
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn touch(dir: &Path, name: &str) {
fs::write(dir.join(name), "x").unwrap();
}
fn mkdir(dir: &Path, name: &str) {
fs::create_dir_all(dir.join(name)).unwrap();
}
#[test]
fn clears_root_profile_layout_keeping_extensions_and_bookmarks() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
touch(dir, "Preferences");
touch(dir, "Secure Preferences");
touch(dir, "Bookmarks");
touch(dir, "History");
touch(dir, "Web Data");
touch(dir, "Login Data");
mkdir(dir, "Extensions");
mkdir(dir, "Local Extension Settings");
mkdir(dir, "Cache");
mkdir(dir, "Network");
touch(&dir.join("Network"), "Cookies");
mkdir(dir, "Local Storage");
let cleared = clear_user_data_dir(dir);
assert!(
cleared >= 5,
"should clear History/WebData/Login/Cache/Network/LocalStorage, got {cleared}"
);
assert!(dir.join("Preferences").exists());
assert!(dir.join("Bookmarks").exists());
assert!(dir.join("Extensions").exists());
assert!(dir.join("Local Extension Settings").exists());
assert!(!dir.join("History").exists());
assert!(!dir.join("Web Data").exists());
assert!(!dir.join("Login Data").exists());
assert!(!dir.join("Cache").exists());
assert!(!dir.join("Network").exists(), "Network/Cookies must go");
assert!(!dir.join("Local Storage").exists());
}
#[test]
fn clears_standard_user_data_layout() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
touch(dir, "Local State");
mkdir(dir, "ShaderCache");
mkdir(dir, "Default");
let default = dir.join("Default");
touch(&default, "Preferences");
touch(&default, "Bookmarks");
touch(&default, "History");
mkdir(&default, "Extensions");
mkdir(&default, "IndexedDB");
clear_user_data_dir(dir);
assert!(dir.join("Local State").exists());
assert!(!dir.join("ShaderCache").exists());
assert!(default.join("Preferences").exists());
assert!(default.join("Bookmarks").exists());
assert!(default.join("Extensions").exists());
assert!(!default.join("History").exists());
assert!(!default.join("IndexedDB").exists());
}
#[test]
fn profile_subdir_without_preferences_keeps_extensions_and_bookmarks() {
// Chromium writes Preferences lazily, so a crash (or a user removing a
// corrupt copy) can leave a populated Default/ without it. Identifying
// profile dirs by Preferences alone would delete the whole directory.
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
touch(dir, "Local State");
mkdir(dir, "Default");
let default = dir.join("Default");
touch(&default, "Bookmarks");
touch(&default, "History");
mkdir(&default, "Extensions");
mkdir(&default, "Local Extension Settings");
mkdir(&default, "IndexedDB");
clear_user_data_dir(dir);
assert!(default.exists(), "the profile dir must survive");
assert!(default.join("Bookmarks").exists());
assert!(default.join("Extensions").exists());
assert!(default.join("Local Extension Settings").exists());
// Browsing data inside it is still cleared.
assert!(!default.join("History").exists());
assert!(!default.join("IndexedDB").exists());
}
#[test]
fn numbered_profile_dirs_are_recognised() {
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
mkdir(dir, "Profile 2");
let p2 = dir.join("Profile 2");
touch(&p2, "Bookmarks");
touch(&p2, "History");
clear_user_data_dir(dir);
assert!(p2.join("Bookmarks").exists());
assert!(!p2.join("History").exists());
}
#[test]
fn non_profile_dirs_are_still_removed_wholesale() {
// The permissive branch must not turn into "never delete a directory":
// top-level caches are browsing data and have to go.
let tmp = TempDir::new().unwrap();
let dir = tmp.path();
mkdir(dir, "ShaderCache");
mkdir(dir, "component_crx_cache");
mkdir(dir, "Profileless");
clear_user_data_dir(dir);
assert!(!dir.join("ShaderCache").exists());
assert!(!dir.join("component_crx_cache").exists());
assert!(
!dir.join("Profileless").exists(),
"a name that merely starts with 'Profile' is not a profile dir"
);
}
#[test]
fn profile_dir_name_matching() {
assert!(is_profile_dir_name("Default"));
assert!(is_profile_dir_name("Profile 1"));
assert!(is_profile_dir_name("Profile 42"));
assert!(is_profile_dir_name("Guest Profile"));
assert!(is_profile_dir_name("System Profile"));
assert!(!is_profile_dir_name("Profile"));
assert!(!is_profile_dir_name("Profile "));
assert!(!is_profile_dir_name("Profile abc"));
assert!(!is_profile_dir_name("ShaderCache"));
}
#[test]
fn missing_dir_is_a_noop() {
let tmp = TempDir::new().unwrap();
assert_eq!(clear_user_data_dir(&tmp.path().join("nope")), 0);
}
}
+72 -4
View File
@@ -82,6 +82,14 @@ impl ProfileManager {
dns_blocklist: Option<String>, dns_blocklist: Option<String>,
launch_hook: Option<String>, launch_hook: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> { ) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
if name.trim().is_empty() {
return Err(
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
.to_string()
.into(),
);
}
if proxy_id.is_some() && vpn_id.is_some() { if proxy_id.is_some() && vpn_id.is_some() {
return Err("Cannot set both proxy_id and vpn_id".into()); return Err("Cannot set both proxy_id and vpn_id".into());
} }
@@ -205,6 +213,7 @@ impl ProfileManager {
created_by_email: None, created_by_email: None,
dns_blocklist: None, dns_blocklist: None,
password_protected: false, password_protected: false,
clear_on_close: false,
created_at: None, created_at: None,
updated_at: None, updated_at: None,
}; };
@@ -291,6 +300,7 @@ impl ProfileManager {
created_by_email: None, created_by_email: None,
dns_blocklist, dns_blocklist,
password_protected: false, password_protected: false,
clear_on_close: false,
created_at: Some( created_at: Some(
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -399,6 +409,14 @@ impl ProfileManager {
profile_id: &str, profile_id: &str,
new_name: &str, new_name: &str,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> { ) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
if new_name.trim().is_empty() {
return Err(
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
.to_string()
.into(),
);
}
// Check if new name already exists (case insensitive) // Check if new name already exists (case insensitive)
let existing_profiles = self.list_profiles()?; let existing_profiles = self.list_profiles()?;
if existing_profiles if existing_profiles
@@ -730,6 +748,44 @@ impl ProfileManager {
Ok(profile) Ok(profile)
} }
pub fn update_profile_clear_on_close(
&self,
_app_handle: &tauri::AppHandle,
profile_id: &str,
clear_on_close: bool,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let mut profile = profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Ephemeral profiles are already wiped on close; password-protected ones
// re-encrypt and never persist plaintext — the flag is meaningless there.
if clear_on_close && (profile.ephemeral || profile.password_protected) {
return Err(
serde_json::json!({ "code": "CLEAR_ON_CLOSE_UNAVAILABLE" })
.to_string()
.into(),
);
}
profile.clear_on_close = clear_on_close;
profile.updated_at = Some(crate::proxy_manager::now_secs());
self.save_profile(&profile)?;
crate::sync::queue_profile_sync_if_eligible(&profile);
if let Err(e) = events::emit_empty("profiles-changed") {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub fn update_profile_window_color( pub fn update_profile_window_color(
&self, &self,
_app_handle: &tauri::AppHandle, _app_handle: &tauri::AppHandle,
@@ -994,6 +1050,7 @@ impl ProfileManager {
created_by_email: None, created_by_email: None,
dns_blocklist: source.dns_blocklist, dns_blocklist: source.dns_blocklist,
password_protected: false, password_protected: false,
clear_on_close: false,
created_at: Some( created_at: Some(
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -1285,7 +1342,7 @@ impl ProfileManager {
let profile_data_path_str = profile_data_path.to_string_lossy(); let profile_data_path_str = profile_data_path.to_string_lossy();
let profile_path_match = cmd.iter().any(|s| { let profile_path_match = cmd.iter().any(|s| {
let arg = s.to_str().unwrap_or(""); let arg = s.to_str().unwrap_or("");
// For Firefox-based browsers, check for exact profile path match // Match the Chromium --user-data-dir flag or an exact profile path argument
arg.contains(&format!("--user-data-dir={profile_data_path_str}")) arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str || arg == profile_data_path_str
}); });
@@ -1323,7 +1380,7 @@ impl ProfileManager {
let profile_data_path_str = profile_data_path.to_string_lossy(); let profile_data_path_str = profile_data_path.to_string_lossy();
let profile_path_match = cmd.iter().any(|s| { let profile_path_match = cmd.iter().any(|s| {
let arg = s.to_str().unwrap_or(""); let arg = s.to_str().unwrap_or("");
// For Firefox-based browsers, check for exact profile path match // Match the Chromium --user-data-dir flag or an exact profile path argument
arg.contains(&format!("--user-data-dir={profile_data_path_str}")) arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str || arg == profile_data_path_str
}); });
@@ -1649,7 +1706,7 @@ pub async fn create_browser_profile_with_group(
launch_hook, launch_hook,
) )
.await .await
.map_err(|e| format!("Failed to create profile: {e}")) .map_err(|e| crate::wrap_backend_error(e, "Failed to create profile"))
} }
#[tauri::command] #[tauri::command]
@@ -1721,6 +1778,17 @@ pub fn update_profile_window_color(
.map_err(|e| format!("Failed to update profile window color: {e}")) .map_err(|e| format!("Failed to update profile window color: {e}"))
} }
#[tauri::command]
pub fn update_profile_clear_on_close(
app_handle: tauri::AppHandle,
profile_id: String,
clear_on_close: bool,
) -> Result<BrowserProfile, String> {
ProfileManager::instance()
.update_profile_clear_on_close(&app_handle, &profile_id, clear_on_close)
.map_err(crate::profile_importer::error_to_code_string)
}
/// Validate a launch hook value. Returns `Ok(None)` for "clear the hook" /// Validate a launch hook value. Returns `Ok(None)` for "clear the hook"
/// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s) /// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s)
/// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload. /// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload.
@@ -1799,7 +1867,7 @@ pub fn rename_profile(
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
profile_manager profile_manager
.rename_profile(&app_handle, &profile_id, &new_name) .rename_profile(&app_handle, &profile_id, &new_name)
.map_err(|e| format!("Failed to rename profile: {e}")) .map_err(|e| crate::wrap_backend_error(e, "Failed to rename profile"))
} }
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
+1
View File
@@ -1,3 +1,4 @@
pub mod clear_on_close;
pub mod encryption; pub mod encryption;
pub mod manager; pub mod manager;
pub mod password; pub mod password;
+4
View File
@@ -72,6 +72,10 @@ pub struct BrowserProfile {
/// Decryption goes to a RAM-backed ephemeral dir, never to disk. /// Decryption goes to a RAM-backed ephemeral dir, never to disk.
#[serde(default)] #[serde(default)]
pub password_protected: bool, pub password_protected: bool,
/// Wipe browsing data (except extensions and bookmarks) when the browser
/// exits. Ignored for ephemeral and password-protected profiles.
#[serde(default)]
pub clear_on_close: bool,
/// Profile creation timestamp (epoch seconds, UTC). `None` for legacy /// Profile creation timestamp (epoch seconds, UTC). `None` for legacy
/// profiles that pre-date this field — those are treated as ancient by /// profiles that pre-date this field — those are treated as ancient by
/// any staleness check. /// any staleness check.
File diff suppressed because it is too large Load Diff
+110 -74
View File
@@ -137,6 +137,25 @@ pub fn now_secs() -> u64 {
.as_secs() .as_secs()
} }
/// Keys in `active_proxies` at or above this value are in-flight launch
/// placeholders, not real browser PIDs. Real OS PIDs never reach this range
/// (Linux pid_max caps at 2^22, macOS at ~100k, Windows PIDs are small DWORD
/// multiples of 4), so a placeholder can never shadow a live process ID.
pub const LAUNCH_PLACEHOLDER_PID_MIN: u32 = u32::MAX - 0x00FF_FFFF;
static NEXT_LAUNCH_PLACEHOLDER_PID: std::sync::atomic::AtomicU32 =
std::sync::atomic::AtomicU32::new(u32::MAX);
/// Allocate a unique `active_proxies` key for a launch in flight, so concurrent
/// launches can never overwrite each other's placeholder entry.
pub fn next_launch_placeholder_pid() -> u32 {
NEXT_LAUNCH_PLACEHOLDER_PID.fetch_sub(1, std::sync::atomic::Ordering::Relaxed)
}
pub fn is_launch_placeholder_pid(pid: u32) -> bool {
pid >= LAUNCH_PLACEHOLDER_PID_MIN
}
impl StoredProxy { impl StoredProxy {
pub fn new(name: String, proxy_settings: ProxySettings) -> Self { pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
let sync_enabled = crate::sync::is_sync_configured(); let sync_enabled = crate::sync::is_sync_configured();
@@ -260,7 +279,7 @@ impl ProxyManager {
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let cache_file = self.get_proxy_check_cache_file(proxy_id)?; let cache_file = self.get_proxy_check_cache_file(proxy_id)?;
let content = serde_json::to_string_pretty(result)?; let content = serde_json::to_string_pretty(result)?;
fs::write(&cache_file, content)?; crate::app_dirs::write_owner_only(&cache_file, content.as_bytes())?;
Ok(()) Ok(())
} }
@@ -385,7 +404,7 @@ impl ProxyManager {
let proxy_file = self.get_proxy_file_path(&proxy.id); let proxy_file = self.get_proxy_file_path(&proxy.id);
let content = serde_json::to_string_pretty(proxy)?; let content = serde_json::to_string_pretty(proxy)?;
fs::write(&proxy_file, content)?; crate::app_dirs::write_owner_only(&proxy_file, content.as_bytes())?;
Ok(()) Ok(())
} }
@@ -406,6 +425,10 @@ impl ProxyManager {
name: String, name: String,
proxy_settings: ProxySettings, proxy_settings: ProxySettings,
) -> Result<StoredProxy, String> { ) -> Result<StoredProxy, String> {
if name.trim().is_empty() {
return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string());
}
// Check if name already exists // Check if name already exists
{ {
let stored_proxies = self.stored_proxies.lock().unwrap(); let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -795,6 +818,10 @@ impl ProxyManager {
name: Option<String>, name: Option<String>,
proxy_settings: Option<ProxySettings>, proxy_settings: Option<ProxySettings>,
) -> Result<StoredProxy, String> { ) -> Result<StoredProxy, String> {
if name.as_deref().is_some_and(|n| n.trim().is_empty()) {
return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string());
}
// First, check for conflicts without holding a mutable reference // First, check for conflicts without holding a mutable reference
{ {
let stored_proxies = self.stored_proxies.lock().unwrap(); let stored_proxies = self.stored_proxies.lock().unwrap();
@@ -1032,8 +1059,10 @@ impl ProxyManager {
format!("Proxy check failed for {proxy_addr}. Could not connect through the proxy.") format!("Proxy check failed for {proxy_addr}. Could not connect through the proxy.")
} }
// Build proxy URL string from ProxySettings // Build proxy URL string from ProxySettings. Credentials are percent-encoded:
fn build_proxy_url(proxy_settings: &ProxySettings) -> String { // a password containing `/`, `#`, `?` or `@` otherwise breaks the URL
// authority and silently retargets the request at the wrong host.
pub fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
let mut url = format!("{}://", proxy_settings.proxy_type); let mut url = format!("{}://", proxy_settings.proxy_type);
if let (Some(username), Some(password)) = (&proxy_settings.username, &proxy_settings.password) { if let (Some(username), Some(password)) = (&proxy_settings.username, &proxy_settings.password) {
@@ -1073,9 +1102,17 @@ impl ProxyManager {
.map_err(|e| e.to_string()); .map_err(|e| e.to_string());
let ip_result = match proxy_start_result { let ip_result = match proxy_start_result {
Ok(proxy_config) => { Ok(mut proxy_config) => {
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0)); let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
let config_id = proxy_config.id.clone(); let config_id = proxy_config.id.clone();
// Tie the check worker's lifetime to this GUI process: the worker's
// PID watchdog self-exits when browser_pid dies, so if the app is
// killed mid-check the worker follows instead of idling until the
// next app launch.
proxy_config.browser_pid = Some(std::process::id());
if !crate::proxy_storage::update_proxy_config(&proxy_config) {
log::warn!("Failed to tag check worker {config_id} with app PID for self-expiry");
}
// Wrap in a timeout so the check worker doesn't stay alive indefinitely // Wrap in a timeout so the check worker doesn't stay alive indefinitely
// if the upstream is slow or unreachable. // if the upstream is slow or unreachable.
let result = tokio::time::timeout( let result = tokio::time::timeout(
@@ -1485,6 +1522,7 @@ impl ProxyManager {
profile_id: Option<&str>, profile_id: Option<&str>,
bypass_rules: Vec<String>, bypass_rules: Vec<String>,
blocklist_file: Option<String>, blocklist_file: Option<String>,
dns_allowlist_mode: bool,
// Protocol the local worker serves the browser: "socks5" (Wayfern). Reflected in // Protocol the local worker serves the browser: "socks5" (Wayfern). Reflected in
// the returned ProxySettings.proxy_type so the caller formats the right local proxy URL scheme. // the returned ProxySettings.proxy_type so the caller formats the right local proxy URL scheme.
local_protocol: &str, local_protocol: &str,
@@ -1576,6 +1614,10 @@ impl ProxyManager {
} }
} }
crate::proxy_runner::ensure_sidecar_version()
.await
.map_err(|e| e.to_string())?;
// Start a new proxy using the donut-proxy binary with the correct CLI interface // Start a new proxy using the donut-proxy binary with the correct CLI interface
let mut proxy_cmd = app_handle let mut proxy_cmd = app_handle
.shell() .shell()
@@ -1594,12 +1636,13 @@ impl ProxyManager {
.arg("--type") .arg("--type")
.arg(&proxy_settings.proxy_type); .arg(&proxy_settings.proxy_type);
// Add credentials if provided // Keep credentials out of process arguments. The short-lived sidecar
// removes these variables before it spawns the detached worker.
if let Some(username) = &proxy_settings.username { if let Some(username) = &proxy_settings.username {
proxy_cmd = proxy_cmd.arg("--username").arg(username); proxy_cmd = proxy_cmd.env("DONUT_PROXY_USERNAME", username);
} }
if let Some(password) = &proxy_settings.password { if let Some(password) = &proxy_settings.password {
proxy_cmd = proxy_cmd.arg("--password").arg(password); proxy_cmd = proxy_cmd.env("DONUT_PROXY_PASSWORD", password);
} }
} }
@@ -1618,6 +1661,9 @@ impl ProxyManager {
// Add blocklist file path if provided // Add blocklist file path if provided
if let Some(ref path) = blocklist_file { if let Some(ref path) = blocklist_file {
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path); proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
if dns_allowlist_mode {
proxy_cmd = proxy_cmd.arg("--dns-allowlist-mode");
}
} }
// Tell the worker which protocol to serve the browser (http or socks5) // Tell the worker which protocol to serve the browser (http or socks5)
@@ -1688,6 +1734,10 @@ impl ProxyManager {
} }
} }
if !ready { if !ready {
// The detached worker is already running with its config on disk, but
// it was never registered in active_proxies — no cleanup pass could
// ever find it, so it must be killed before this error propagates.
let _ = crate::proxy_runner::stop_proxy_process(&proxy_info.id).await;
return Err(format!( return Err(format!(
"Local proxy on 127.0.0.1:{} did not become ready in time", "Local proxy on 127.0.0.1:{} did not become ready in time",
proxy_info.local_port proxy_info.local_port
@@ -1860,9 +1910,9 @@ impl ProxyManager {
/// Persist the real browser PID onto the worker's on-disk config so the /// Persist the real browser PID onto the worker's on-disk config so the
/// detached worker can self-terminate when that browser dies, independent of /// detached worker can self-terminate when that browser dies, independent of
/// the GUI being alive. Resolved via the profile→proxy_id map rather than the /// the GUI being alive. Resolved via the profile→proxy_id map rather than the
/// PID-keyed `active_proxies` map: the latter uses a placeholder key 0 during /// PID-keyed `active_proxies` map: the latter is keyed by a per-launch
/// launch that collides across concurrent launches, which could tag a live /// placeholder until `update_proxy_pid` runs, so it is not a reliable way to
/// worker with the wrong (dead) PID and make it self-exit. Safe on the reuse /// find the worker for a profile mid-launch. Safe on the reuse
/// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid` /// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid`
/// of 0 (launch failed to report a PID) is ignored so the worker never /// of 0 (launch failed to report a PID) is ignored so the worker never
/// self-exits against a bogus PID. /// self-exits against a bogus PID.
@@ -2082,9 +2132,9 @@ impl ProxyManager {
let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new(); let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
for (browser_pid, proxy_id, profile_id) in snapshot { for (browser_pid, proxy_id, profile_id) in snapshot {
snapshot_pids.insert(browser_pid); snapshot_pids.insert(browser_pid);
// The sentinel PID=0 is used as a placeholder during launch, // Launch placeholders (and the legacy 0 sentinel) are not real
// before update_proxy_pid has recorded the real browser PID. // browser PIDs — update_proxy_pid hasn't recorded the real one yet.
if browser_pid == 0 { if browser_pid == 0 || is_launch_placeholder_pid(browser_pid) {
continue; continue;
} }
if system if system
@@ -2477,7 +2527,7 @@ mod tests {
Ok(()) Ok(())
} }
// Test that validates the command line arguments are constructed correctly // Validate that non-secret proxy settings remain command arguments.
#[test] #[test]
fn test_proxy_command_construction() { fn test_proxy_command_construction() {
let proxy_settings = ProxySettings { let proxy_settings = ProxySettings {
@@ -2498,10 +2548,6 @@ mod tests {
"8080", "8080",
"--type", "--type",
"http", "http",
"--username",
"user",
"--password",
"pass",
]; ];
// This test verifies the argument structure without actually running the command // This test verifies the argument structure without actually running the command
@@ -2625,61 +2671,6 @@ mod tests {
Ok(()) Ok(())
} }
// Test that validates URL encoding for special characters in credentials
#[tokio::test]
async fn test_proxy_credentials_encoding() -> Result<(), Box<dyn std::error::Error>> {
let proxy_path = ensure_donut_proxy_binary().await?;
// Test with credentials that include special characters
let mut cmd = Command::new(&proxy_path);
cmd
.arg("proxy")
.arg("start")
.arg("--host")
.arg("test.example.com")
.arg("--proxy-port")
.arg("8080")
.arg("--type")
.arg("http")
.arg("--username")
.arg("user@domain.com")
.arg("--password")
.arg("pass word!");
let output = tokio::time::timeout(Duration::from_secs(10), cmd.output()).await??;
if output.status.success() {
let stdout = String::from_utf8(output.stdout)?;
let config: serde_json::Value = serde_json::from_str(&stdout)?;
let upstream_url = config["upstreamUrl"].as_str().unwrap();
println!("Generated upstream URL: {upstream_url}");
// Verify that special characters are properly encoded
assert!(upstream_url.contains("user%40domain.com"));
assert!(upstream_url.contains("pass%20word"));
println!("URL encoding test passed - special characters handled correctly");
// Clean up
let proxy_id = config["id"].as_str().unwrap();
let mut stop_cmd = Command::new(&proxy_path);
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
let _ = stop_cmd.output().await;
} else {
let stdout = String::from_utf8(output.stdout)?;
let stderr = String::from_utf8(output.stderr)?;
println!("Command failed (expected for non-existent upstream):");
println!("Stdout: {stdout}");
println!("Stderr: {stderr}");
println!("URL encoding test completed - credentials should be properly encoded");
}
Ok(())
}
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
// Complex proxy process monitoring tests // Complex proxy process monitoring tests
// ────────────────────────────────────────────────────────────────────── // ──────────────────────────────────────────────────────────────────────
@@ -2750,6 +2741,35 @@ mod tests {
assert!(result.unwrap_err().contains("No proxy found for PID 777")); assert!(result.unwrap_err().contains("No proxy found for PID 777"));
} }
#[test]
fn test_launch_placeholder_pids_unique_and_reconcile_independently() {
let a = next_launch_placeholder_pid();
let b = next_launch_placeholder_pid();
assert_ne!(a, b);
assert!(is_launch_placeholder_pid(a));
assert!(is_launch_placeholder_pid(b));
// Real PIDs (and the legacy 0 sentinel) are never in the placeholder range.
assert!(!is_launch_placeholder_pid(0));
assert!(!is_launch_placeholder_pid(1));
assert!(!is_launch_placeholder_pid(4_194_304)); // Linux pid_max
assert!(!is_launch_placeholder_pid(std::process::id()));
// Two concurrent launches with distinct placeholder keys: finishing
// launch A must not remap or evict launch B's in-flight entry.
let pm = ProxyManager::new();
pm.insert_active_proxy(a, make_proxy_info("px_launch_a", 9201, Some("prof_a")));
pm.insert_active_proxy(b, make_proxy_info("px_launch_b", 9202, Some("prof_b")));
pm.update_proxy_pid(a, 3001).unwrap();
assert_eq!(pm.get_active_proxy(3001).unwrap().id, "px_launch_a");
assert_eq!(pm.get_active_proxy(b).unwrap().id, "px_launch_b");
pm.update_proxy_pid(b, 3002).unwrap();
assert_eq!(pm.get_active_proxy(3002).unwrap().id, "px_launch_b");
assert!(pm.get_active_proxy(a).is_none());
assert!(pm.get_active_proxy(b).is_none());
}
#[test] #[test]
fn test_profile_proxy_id_mapping_tracks_active_proxy() { fn test_profile_proxy_id_mapping_tracks_active_proxy() {
let pm = ProxyManager::new(); let pm = ProxyManager::new();
@@ -2922,6 +2942,7 @@ mod tests {
profile_id: None, profile_id: None,
bypass_rules: Vec::new(), bypass_rules: Vec::new(),
blocklist_file: None, blocklist_file: None,
dns_allowlist_mode: false,
local_protocol: None, local_protocol: None,
browser_pid: None, browser_pid: None,
}; };
@@ -2935,6 +2956,7 @@ mod tests {
profile_id: None, profile_id: None,
bypass_rules: Vec::new(), bypass_rules: Vec::new(),
blocklist_file: None, blocklist_file: None,
dns_allowlist_mode: false,
local_protocol: None, local_protocol: None,
browser_pid: None, browser_pid: None,
}; };
@@ -2976,6 +2998,7 @@ mod tests {
profile_id: Some("prof_abc".to_string()), profile_id: Some("prof_abc".to_string()),
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()], bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
blocklist_file: None, blocklist_file: None,
dns_allowlist_mode: false,
local_protocol: None, local_protocol: None,
browser_pid: None, browser_pid: None,
}; };
@@ -2983,6 +3006,18 @@ mod tests {
// Save // Save
save_proxy_config(&config).unwrap(); save_proxy_config(&config).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode =
std::fs::metadata(crate::proxy_storage::get_storage_dir().join(format!("{id}.json")))
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "proxy credentials must be owner-only");
}
// Load and compare // Load and compare
let loaded = get_proxy_config(&id).expect("Config should be loadable"); let loaded = get_proxy_config(&id).expect("Config should be loadable");
assert_eq!(loaded.id, config.id); assert_eq!(loaded.id, config.id);
@@ -3296,6 +3331,7 @@ mod tests {
profile_id: None, profile_id: None,
bypass_rules: Vec::new(), bypass_rules: Vec::new(),
blocklist_file: None, blocklist_file: None,
dns_allowlist_mode: false,
local_protocol: None, local_protocol: None,
browser_pid: None, browser_pid: None,
}; };
+186 -19
View File
@@ -4,11 +4,52 @@ use crate::proxy_storage::{
}; };
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Stdio; use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
lazy_static::lazy_static! { lazy_static::lazy_static! {
static ref PROXY_PROCESSES: std::sync::Mutex<std::collections::HashMap<String, u32>> = static ref PROXY_PROCESSES: std::sync::Mutex<std::collections::HashMap<String, u32>> =
std::sync::Mutex::new(std::collections::HashMap::new()); std::sync::Mutex::new(std::collections::HashMap::new());
} }
static SIDECAR_VERSION_VERIFIED: AtomicBool = AtomicBool::new(false);
const RETAINED_PROXY_LOGS: usize = 20;
fn prune_stale_proxy_logs(temp_dir: &Path, retain: usize) {
let active_ids = PROXY_PROCESSES
.lock()
.map(|processes| processes.keys().cloned().collect::<Vec<_>>())
.unwrap_or_default();
let Ok(entries) = std::fs::read_dir(temp_dir) else {
return;
};
let mut logs = entries
.flatten()
.filter_map(|entry| {
let file_name = entry.file_name();
let file_name = file_name.to_str()?;
let id = file_name
.strip_prefix("donut-proxy-")?
.strip_suffix(".log")?;
if active_ids.iter().any(|active_id| active_id == id) {
return None;
}
let modified = entry
.metadata()
.and_then(|metadata| metadata.modified())
.unwrap_or(std::time::UNIX_EPOCH);
Some((modified, entry.path()))
})
.collect::<Vec<_>>();
logs.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.0));
for (_, path) in logs.into_iter().skip(retain) {
if let Err(error) = std::fs::remove_file(&path) {
log::debug!(
"Failed to prune stale proxy log {}: {error}",
path.display()
);
}
}
}
fn target_binary_name(base_name: &str) -> Option<String> { fn target_binary_name(base_name: &str) -> Option<String> {
let target = std::env::var("TARGET").ok()?; let target = std::env::var("TARGET").ok()?;
@@ -156,21 +197,96 @@ pub(crate) fn find_sidecar_executable(
) )
} }
fn parse_sidecar_version(stdout: &[u8]) -> Option<String> {
let output = std::str::from_utf8(stdout).ok()?.trim();
output
.strip_prefix("donut-proxy ")
.map(str::trim)
.filter(|version| !version.is_empty() && !version.contains(char::is_whitespace))
.map(str::to_string)
}
fn sidecar_version_mismatch_error() -> Box<dyn std::error::Error> {
serde_json::json!({
"code": "PROXY_SIDECAR_VERSION_MISMATCH"
})
.to_string()
.into()
}
/// Verify that the installed sidecar was built for the same release as the
/// main app. Windows can otherwise retain an executing, locked sidecar while
/// NSIS replaces the app, leaving an incompatible mixed-version installation.
pub(crate) async fn ensure_sidecar_version() -> Result<(), Box<dyn std::error::Error>> {
if SIDECAR_VERSION_VERIFIED.load(Ordering::Acquire) {
return Ok(());
}
let executable = match find_sidecar_executable("donut-proxy") {
Ok(executable) => executable,
Err(e) => {
log::error!("Failed to locate donut-proxy for version verification: {e}");
return Err(sidecar_version_mismatch_error());
}
};
let mut command = std::process::Command::new(&executable);
command.arg("--version");
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
command.creation_flags(CREATE_NO_WINDOW);
}
let output = match command.output() {
Ok(output) => output,
Err(e) => {
log::error!(
"Failed to run {} for version verification: {e}",
executable.display()
);
return Err(sidecar_version_mismatch_error());
}
};
let actual_version = parse_sidecar_version(&output.stdout);
let expected_version = env!("BUILD_VERSION");
if output.status.success() && actual_version.as_deref() == Some(expected_version) {
SIDECAR_VERSION_VERIFIED.store(true, Ordering::Release);
return Ok(());
}
log::error!(
"donut-proxy version mismatch: expected {}, got {:?}; status={}, stdout={:?}, stderr={:?}",
expected_version,
actual_version,
output.status,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
Err(sidecar_version_mismatch_error())
}
pub async fn start_proxy_process( pub async fn start_proxy_process(
upstream_url: Option<String>, upstream_url: Option<String>,
port: Option<u16>, port: Option<u16>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> { ) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, None).await start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, false, None).await
} }
#[allow(clippy::too_many_arguments)]
pub async fn start_proxy_process_with_profile( pub async fn start_proxy_process_with_profile(
upstream_url: Option<String>, upstream_url: Option<String>,
port: Option<u16>, port: Option<u16>,
profile_id: Option<String>, profile_id: Option<String>,
bypass_rules: Vec<String>, bypass_rules: Vec<String>,
blocklist_file: Option<String>, blocklist_file: Option<String>,
dns_allowlist_mode: bool,
local_protocol: Option<String>, local_protocol: Option<String>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> { ) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
ensure_sidecar_version().await?;
let id = generate_proxy_id(); let id = generate_proxy_id();
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string()); let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
@@ -185,6 +301,7 @@ pub async fn start_proxy_process_with_profile(
.with_profile_id(profile_id.clone()) .with_profile_id(profile_id.clone())
.with_bypass_rules(bypass_rules) .with_bypass_rules(bypass_rules)
.with_blocklist_file(blocklist_file) .with_blocklist_file(blocklist_file)
.with_dns_allowlist_mode(dns_allowlist_mode)
.with_local_protocol(local_protocol); .with_local_protocol(local_protocol);
save_proxy_config(&config)?; save_proxy_config(&config)?;
@@ -198,6 +315,10 @@ pub async fn start_proxy_process_with_profile(
// Spawn proxy worker process in the background using std::process::Command // Spawn proxy worker process in the background using std::process::Command
// This ensures proper process detachment on Unix systems // This ensures proper process detachment on Unix systems
let exe = find_sidecar_executable("donut-proxy")?; let exe = find_sidecar_executable("donut-proxy")?;
let temp_dir = std::env::temp_dir();
let log_path = temp_dir.join(format!("donut-proxy-{id}.log"));
let log_file = crate::app_dirs::create_owner_only(&log_path);
prune_stale_proxy_logs(&temp_dir, RETAINED_PROXY_LOGS);
#[cfg(unix)] #[cfg(unix)]
{ {
@@ -209,13 +330,14 @@ pub async fn start_proxy_process_with_profile(
cmd.arg("start"); cmd.arg("start");
cmd.arg("--id"); cmd.arg("--id");
cmd.arg(&id); cmd.arg(&id);
cmd.env_remove("DONUT_PROXY_USERNAME");
cmd.env_remove("DONUT_PROXY_PASSWORD");
cmd.stdin(Stdio::null()); cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null()); cmd.stdout(Stdio::null());
// Always log to file for diagnostics (both debug and release builds) // Always log to file for diagnostics (both debug and release builds)
let log_path = std::env::temp_dir().join(format!("donut-proxy-{}.log", id)); if let Ok(file) = log_file {
if let Ok(file) = std::fs::File::create(&log_path) {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path); log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file)); cmd.stderr(Stdio::from(file));
} else { } else {
@@ -286,13 +408,14 @@ pub async fn start_proxy_process_with_profile(
cmd.arg("start"); cmd.arg("start");
cmd.arg("--id"); cmd.arg("--id");
cmd.arg(&id); cmd.arg(&id);
cmd.env_remove("DONUT_PROXY_USERNAME");
cmd.env_remove("DONUT_PROXY_PASSWORD");
cmd.stdin(Stdio::null()); cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null()); cmd.stdout(Stdio::null());
// Log to file for diagnostics (matching Unix behavior) // Log to file for diagnostics (matching Unix behavior)
let log_path = std::env::temp_dir().join(format!("donut-proxy-{}.log", id)); if let Ok(file) = log_file {
if let Ok(file) = std::fs::File::create(&log_path) {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path); log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file)); cmd.stderr(Stdio::from(file));
} else { } else {
@@ -370,24 +493,21 @@ pub async fn start_proxy_process_with_profile(
attempts += 1; attempts += 1;
if attempts >= max_attempts { if attempts >= max_attempts {
// Try to get the config one more time for better error message // Try to get the config one more time for better error message
if let Some(config) = get_proxy_config(&id) { let detail = if let Some(config) = get_proxy_config(&id) {
// Check if process is still running // Check if process is still running
let process_running = config.pid.map(is_process_running).unwrap_or(false); let process_running = config.pid.map(is_process_running).unwrap_or(false);
return Err(
format!(
"Proxy worker failed to start in time. Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}",
config.id, config.local_url, config.local_port, config.pid, process_running
)
.into(),
);
}
return Err(
format!( format!(
"Proxy worker failed to start in time. Config not found for id: {}", "Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}",
id config.id, config.local_url, config.local_port, config.pid, process_running
) )
.into(), } else {
); format!("Config not found for id: {}", id)
};
// The detached worker (if it did spawn) would otherwise outlive this
// failed start with nothing tracking it — callers only get an error
// string, so this is the last place that can still kill it.
let _ = stop_proxy_process(&id).await;
return Err(format!("Proxy worker failed to start in time. {detail}").into());
} }
} }
} }
@@ -442,3 +562,50 @@ pub async fn stop_all_proxy_processes() -> Result<(), Box<dyn std::error::Error>
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::{parse_sidecar_version, prune_stale_proxy_logs};
use std::fs;
use std::time::Duration;
#[test]
fn parses_exact_sidecar_version_output() {
assert_eq!(
parse_sidecar_version(b"donut-proxy v0.28.2\n").as_deref(),
Some("v0.28.2")
);
assert_eq!(
parse_sidecar_version(b"donut-proxy nightly-2026-07-19-a4ed5c8\r\n").as_deref(),
Some("nightly-2026-07-19-a4ed5c8")
);
}
#[test]
fn rejects_missing_or_ambiguous_sidecar_version_output() {
assert_eq!(parse_sidecar_version(b""), None);
assert_eq!(parse_sidecar_version(b"donut-proxy"), None);
assert_eq!(parse_sidecar_version(b"other-proxy v0.28.2"), None);
assert_eq!(
parse_sidecar_version(b"donut-proxy v0.28.2\nunexpected"),
None
);
}
#[test]
fn prunes_only_old_proxy_logs() {
let temp = tempfile::tempdir().unwrap();
for id in ["oldest", "middle", "newest"] {
fs::write(temp.path().join(format!("donut-proxy-{id}.log")), id).unwrap();
std::thread::sleep(Duration::from_millis(10));
}
fs::write(temp.path().join("unrelated.log"), "keep").unwrap();
prune_stale_proxy_logs(temp.path(), 2);
assert!(!temp.path().join("donut-proxy-oldest.log").exists());
assert!(temp.path().join("donut-proxy-middle.log").exists());
assert!(temp.path().join("donut-proxy-newest.log").exists());
assert!(temp.path().join("unrelated.log").exists());
}
}
File diff suppressed because it is too large Load Diff
+66 -8
View File
@@ -16,6 +16,10 @@ pub struct ProxyConfig {
pub bypass_rules: Vec<String>, pub bypass_rules: Vec<String>,
#[serde(default)] #[serde(default)]
pub blocklist_file: Option<String>, pub blocklist_file: Option<String>,
/// When true, `blocklist_file` is treated as an ALLOW list: the browser may
/// only reach domains in the file; everything else is blocked.
#[serde(default)]
pub dns_allowlist_mode: bool,
/// Protocol the local worker serves to the browser: "socks5" (Wayfern/Chromium so QUIC and /// Protocol the local worker serves to the browser: "socks5" (Wayfern/Chromium so QUIC and
/// WebRTC UDP can be proxied without leaking the real IP). Independent of /// WebRTC UDP can be proxied without leaking the real IP). Independent of
/// `upstream_url`, which is the real upstream proxy/VPN this worker dials. /// `upstream_url`, which is the real upstream proxy/VPN this worker dials.
@@ -42,6 +46,7 @@ impl ProxyConfig {
profile_id: None, profile_id: None,
bypass_rules: Vec::new(), bypass_rules: Vec::new(),
blocklist_file: None, blocklist_file: None,
dns_allowlist_mode: false,
local_protocol: None, local_protocol: None,
browser_pid: None, browser_pid: None,
} }
@@ -62,6 +67,11 @@ impl ProxyConfig {
self self
} }
pub fn with_dns_allowlist_mode(mut self, allowlist_mode: bool) -> Self {
self.dns_allowlist_mode = allowlist_mode;
self
}
pub fn with_local_protocol(mut self, local_protocol: Option<String>) -> Self { pub fn with_local_protocol(mut self, local_protocol: Option<String>) -> Self {
self.local_protocol = local_protocol; self.local_protocol = local_protocol;
self self
@@ -77,6 +87,29 @@ impl ProxyConfig {
} }
} }
pub 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) {
url.push_str(&format!(
"{}:{}@",
urlencoding::encode(user),
urlencoding::encode(pass)
));
} else if let Some(user) = username {
url.push_str(&format!("{}@", urlencoding::encode(user)));
}
url.push_str(host);
url.push(':');
url.push_str(&port.to_string());
url
}
pub fn get_storage_dir() -> PathBuf { pub fn get_storage_dir() -> PathBuf {
crate::app_dirs::proxy_workers_dir() crate::app_dirs::proxy_workers_dir()
} }
@@ -87,7 +120,7 @@ pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box<dyn std::error:
let file_path = storage_dir.join(format!("{}.json", config.id)); let file_path = storage_dir.join(format!("{}.json", config.id));
let content = serde_json::to_string_pretty(config)?; let content = serde_json::to_string_pretty(config)?;
fs::write(&file_path, content)?; crate::app_dirs::write_owner_only(&file_path, content.as_bytes())?;
Ok(()) Ok(())
} }
@@ -149,10 +182,13 @@ pub fn update_proxy_config(config: &ProxyConfig) -> bool {
return false; return false;
} }
match serde_json::to_string_pretty(config) { let Ok(content) = serde_json::to_string_pretty(config) else {
Ok(content) => fs::write(&file_path, content).is_ok(), return false;
Err(_) => false, };
if crate::app_dirs::write_owner_only(&file_path, content.as_bytes()).is_err() {
return false;
} }
true
} }
pub fn generate_proxy_id() -> String { pub fn generate_proxy_id() -> String {
@@ -167,17 +203,39 @@ pub fn generate_proxy_id() -> String {
} }
pub fn is_process_running(pid: u32) -> bool { pub fn is_process_running(pid: u32) -> bool {
use sysinfo::{ProcessRefreshKind, RefreshKind, System}; use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
let system = System::new_with_specifics( let pid = sysinfo::Pid::from_u32(pid);
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()), // Refresh only the queried PID with the minimal refresh kind: this is a
// pure existence check, and callers (worker supervisors every 15s, GUI
// cleanup loops) must not pay for a full system process-table scan.
let mut system = System::new();
system.refresh_processes_specifics(
ProcessesToUpdate::Some(&[pid]),
true,
ProcessRefreshKind::nothing(),
); );
system.process(sysinfo::Pid::from_u32(pid)).is_some() system.process(pid).is_some()
} }
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
#[test]
fn proxy_url_encodes_credentials() {
let url = build_proxy_url(
"HTTP",
"test.example.com",
8080,
Some("user@domain.com"),
Some("pass word!"),
);
assert_eq!(
url,
"http://user%40domain.com:pass%20word%21@test.example.com:8080"
);
}
#[test] #[test]
fn test_is_process_running_detects_current_process() { fn test_is_process_running_detects_current_process() {
let pid = std::process::id(); let pid = std::process::id();
+8 -2
View File
@@ -852,7 +852,13 @@ pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, Stri
const MAX_BYTES: usize = 5 * 1024 * 1024; const MAX_BYTES: usize = 5 * 1024 * 1024;
let mut out = String::with_capacity(64 * 1024); let mut out = String::with_capacity(64 * 1024);
for (path, _) in entries.iter().rev() { for (path, _) in entries.iter().rev() {
let header = format!("===== {} =====\n", path.display()); let header = format!(
"===== {} =====\n",
path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("log")
);
if out.len() + header.len() >= MAX_BYTES { if out.len() + header.len() >= MAX_BYTES {
break; break;
} }
@@ -884,7 +890,7 @@ pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, Stri
.map(|s| format!("===== {s}")) .map(|s| format!("===== {s}"))
.collect::<String>(); .collect::<String>();
Ok(final_out) Ok(crate::log_redaction::text(&final_out))
} }
/// Reveal the log directory in the OS file manager. /// Reveal the log directory in the OS file manager.
+282 -13
View File
@@ -13,13 +13,21 @@
//! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses //! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses
//! the association) the request is refused, so Chromium falls back to proxied //! the association) the request is refused, so Chromium falls back to proxied
//! TCP rather than sending UDP from the real IP. //! TCP rather than sending UDP from the real IP.
//!
//! Both commands enforce the same DNS blocklist and feed the same traffic
//! tracker as the HTTP front-end: CONNECT via `handle_connect`, and every UDP
//! datagram via [`UdpRelayContext`]. Without that, QUIC and WebRTC would be an
//! unfiltered, unmetered side channel around the TCP filter.
use crate::proxy_server::{ use crate::proxy_server::{
connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher, connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
}; };
use crate::traffic_stats::get_traffic_tracker; use crate::traffic_stats::{get_traffic_tracker, LiveTrafficTracker};
use async_socks5::{AddrKind, Auth, SocksDatagram}; use async_socks5::{AddrKind, Auth, SocksDatagram};
use std::collections::HashMap;
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
use std::sync::Arc;
use std::time::{Duration, Instant};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpStream, UdpSocket}; use tokio::net::{TcpStream, UdpSocket};
use url::Url; use url::Url;
@@ -37,6 +45,127 @@ const CMD_UDP_ASSOCIATE: u8 = 0x03;
// Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack. // Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack.
const UDP_BUF: usize = 65_536; const UDP_BUF: usize = 65_536;
// How often per-domain UDP byte totals are pushed into the traffic tracker.
// Datagram relay is a per-packet path, so totals accumulate locally and flush
// on this interval rather than taking the tracker's domain write lock per
// packet. Global byte counters are plain atomics and update inline, so live
// bandwidth stays real-time exactly as it does for TCP tunnels.
const UDP_STATS_FLUSH_INTERVAL: Duration = Duration::from_secs(2);
// Cap on remembered destination->host flows per association. A long-lived
// association fanning out to many peers must not grow this map without bound.
const UDP_FLOW_MAP_MAX: usize = 1024;
/// Per-association filtering and traffic accounting for the UDP relay loops.
///
/// UDP has no per-datagram error channel (RFC 1928 §7 has no reply code), so a
/// blocked destination is dropped silently — which is what the browser sees for
/// any unreachable UDP peer, and makes it fall back to proxied TCP.
struct UdpRelayContext {
blocklist_matcher: BlocklistMatcher,
tracker: Option<Arc<LiveTrafficTracker>>,
/// Pending per-domain (sent, received) deltas awaiting flush.
pending: HashMap<String, (u64, u64)>,
/// Destinations already counted as a request, so each is recorded once.
seen: std::collections::HashSet<String>,
/// Maps a peer key back to the destination host we sent to, so reply bytes
/// are attributed to the domain rather than to a bare IP.
flow: HashMap<String, String>,
last_flush: Instant,
}
impl UdpRelayContext {
fn new(blocklist_matcher: BlocklistMatcher) -> Self {
Self {
blocklist_matcher,
tracker: get_traffic_tracker(),
pending: HashMap::new(),
seen: std::collections::HashSet::new(),
flow: HashMap::new(),
last_flush: Instant::now(),
}
}
/// Apply the DNS blocklist to a datagram destination. Mirrors the TCP path
/// exactly: the host string is matched whether it is a domain or an IP
/// literal, so allowlist mode rejects unlisted IP destinations here too.
fn is_blocked(&self, host: &str) -> bool {
self.blocklist_matcher.is_blocked(host)
}
/// Account a datagram relayed browser -> destination.
fn record_sent(&mut self, host: &str, peer_key: String, bytes: u64) {
let Some(tracker) = &self.tracker else {
return;
};
tracker.add_bytes_sent(bytes);
if self.seen.insert(host.to_string()) {
// First datagram to this destination: count it once so UDP peers show up
// in the domain list the way CONNECT targets do.
tracker.record_request(host, 0, 0);
}
if self.flow.len() < UDP_FLOW_MAP_MAX {
self.flow.insert(peer_key, host.to_string());
}
self.pending.entry(host.to_string()).or_insert((0, 0)).0 += bytes;
self.maybe_flush();
}
/// Account a datagram relayed destination -> browser. `peer_key` is looked up
/// against the flows recorded by `record_sent`; an unmatched reply (an
/// upstream that reports a different address than we addressed) is attributed
/// to `fallback_host`.
fn record_received(&mut self, peer_key: &str, fallback_host: &str, bytes: u64) {
let Some(tracker) = &self.tracker else {
return;
};
tracker.add_bytes_received(bytes);
let host = self
.flow
.get(peer_key)
.cloned()
.unwrap_or_else(|| fallback_host.to_string());
self.pending.entry(host).or_insert((0, 0)).1 += bytes;
self.maybe_flush();
}
fn maybe_flush(&mut self) {
if self.last_flush.elapsed() >= UDP_STATS_FLUSH_INTERVAL {
self.flush();
}
}
/// Push accumulated per-domain totals into the tracker.
fn flush(&mut self) {
let Some(tracker) = &self.tracker else {
self.pending.clear();
return;
};
for (domain, (sent, recv)) in self.pending.drain() {
tracker.update_domain_bytes(&domain, sent, recv);
}
self.last_flush = Instant::now();
}
}
/// Host portion of a UDP destination, for blocklist matching. An IP literal is
/// rendered without its port so it matches the same way a CONNECT host does.
fn addrkind_host(addr: &AddrKind) -> String {
match addr {
AddrKind::Ip(s) => s.ip().to_string(),
AddrKind::Domain(domain, _) => domain.clone(),
}
}
/// Stable key identifying a UDP peer, used to tie replies back to the
/// destination host they belong to.
fn addrkind_key(addr: &AddrKind) -> String {
match addr {
AddrKind::Ip(s) => s.to_string(),
AddrKind::Domain(domain, port) => format!("{domain}:{port}"),
}
}
/// How a UDP ASSOCIATE request must be served for a given upstream so the real /// How a UDP ASSOCIATE request must be served for a given upstream so the real
/// IP never leaks. /// IP never leaks.
#[derive(Debug, PartialEq, Eq)] #[derive(Debug, PartialEq, Eq)]
@@ -108,7 +237,7 @@ pub async fn handle_socks5_connection(
.await; .await;
} }
CMD_UDP_ASSOCIATE => { CMD_UDP_ASSOCIATE => {
handle_udp_associate(stream, upstream_url).await; handle_udp_associate(stream, upstream_url, blocklist_matcher).await;
} }
other => { other => {
log::debug!("SOCKS5 unsupported command {other:#04x}"); log::debug!("SOCKS5 unsupported command {other:#04x}");
@@ -294,8 +423,13 @@ async fn handle_connect(
/// ///
/// `control` is the TCP control connection; the UDP association lives exactly /// `control` is the TCP control connection; the UDP association lives exactly
/// as long as it stays open (RFC 1928 §6), so the relay loop tears down when /// as long as it stays open (RFC 1928 §6), so the relay loop tears down when
/// the browser closes it. /// the browser closes it. Every relayed datagram is filtered and metered via
async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<String>) { /// [`UdpRelayContext`], matching the TCP CONNECT path.
async fn handle_udp_associate(
mut control: TcpStream,
upstream_url: Option<String>,
blocklist_matcher: BlocklistMatcher,
) {
let mode = udp_mode(upstream_url.as_deref()); let mode = udp_mode(upstream_url.as_deref());
if mode == UdpMode::Refuse { if mode == UdpMode::Refuse {
@@ -344,7 +478,7 @@ async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<Strin
return; return;
} }
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}"); log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
run_udp_relay_direct(control, relay, out).await; run_udp_relay_direct(control, relay, out, UdpRelayContext::new(blocklist_matcher)).await;
} }
UdpMode::Socks5Upstream => { UdpMode::Socks5Upstream => {
// Establish the upstream association FIRST; if the upstream refuses UDP, // Establish the upstream association FIRST; if the upstream refuses UDP,
@@ -367,7 +501,13 @@ async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<Strin
return; return;
} }
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}"); log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
run_udp_relay_socks5(control, relay, datagram).await; run_udp_relay_socks5(
control,
relay,
datagram,
UdpRelayContext::new(blocklist_matcher),
)
.await;
} }
UdpMode::Refuse => unreachable!("handled above"), UdpMode::Refuse => unreachable!("handled above"),
} }
@@ -389,10 +529,20 @@ async fn associate_upstream(
None None
}; };
let proxy_stream = TcpStream::connect((host, port)).await?; let proxy_stream = tokio::time::timeout(
crate::proxy_server::UPSTREAM_DIAL_TIMEOUT,
TcpStream::connect((host, port)),
)
.await
.map_err(|_| format!("upstream SOCKS5 connect to {host}:{port} timed out"))??;
let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?; let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?;
// association_addr None => 0.0.0.0:0 (we accept replies from any peer). // association_addr None => 0.0.0.0:0 (we accept replies from any peer).
let datagram = SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>).await?; let datagram = tokio::time::timeout(
crate::proxy_server::UPSTREAM_DIAL_TIMEOUT,
SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>),
)
.await
.map_err(|_| "upstream SOCKS5 UDP ASSOCIATE handshake timed out")??;
Ok(datagram) Ok(datagram)
} }
@@ -467,7 +617,12 @@ fn build_udp_response(peer: SocketAddr, data: &[u8]) -> Vec<u8> {
/// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when /// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when
/// there is no upstream proxy, so the host IP is the profile's own IP. /// there is no upstream proxy, so the host IP is the profile's own IP.
async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: UdpSocket) { async fn run_udp_relay_direct(
mut control: TcpStream,
relay: UdpSocket,
out: UdpSocket,
mut ctx: UdpRelayContext,
) {
let mut client_addr: Option<SocketAddr> = None; let mut client_addr: Option<SocketAddr> = None;
let mut from_client = vec![0u8; UDP_BUF]; let mut from_client = vec![0u8; UDP_BUF];
let mut from_target = vec![0u8; UDP_BUF]; let mut from_target = vec![0u8; UDP_BUF];
@@ -490,23 +645,37 @@ async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: Udp
if header.frag != 0 { if header.frag != 0 {
continue; // fragmentation unsupported continue; // fragmentation unsupported
} }
let host = addrkind_host(&header.dst);
if ctx.is_blocked(&host) {
log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}");
continue;
}
let payload = &from_client[header.data_offset..n]; let payload = &from_client[header.data_offset..n];
// Resolve after the blocklist check so a blocked host is never even
// looked up, and count bytes against the resolved peer so replies from
// it are attributed back to this host.
let dst = match resolve_addr(&header.dst).await { let dst = match resolve_addr(&header.dst).await {
Some(d) => d, Some(d) => d,
None => continue, None => continue,
}; };
let _ = out.send_to(payload, dst).await; if out.send_to(payload, dst).await.is_ok() {
ctx.record_sent(&host, dst.to_string(), payload.len() as u64);
}
} }
// Target -> browser. // Target -> browser.
r = out.recv_from(&mut from_target) => { r = out.recv_from(&mut from_target) => {
let Ok((n, peer)) = r else { continue }; let Ok((n, peer)) = r else { continue };
if let Some(client) = client_addr { if let Some(client) = client_addr {
let resp = build_udp_response(peer, &from_target[..n]); let resp = build_udp_response(peer, &from_target[..n]);
let _ = relay.send_to(&resp, client).await; if relay.send_to(&resp, client).await.is_ok() {
ctx.record_received(&peer.to_string(), &peer.ip().to_string(), n as u64);
}
} }
} }
} }
} }
ctx.flush();
} }
/// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE. /// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE.
@@ -514,6 +683,7 @@ async fn run_udp_relay_socks5(
mut control: TcpStream, mut control: TcpStream,
relay: UdpSocket, relay: UdpSocket,
datagram: SocksDatagram<TcpStream>, datagram: SocksDatagram<TcpStream>,
mut ctx: UdpRelayContext,
) { ) {
let mut client_addr: Option<SocketAddr> = None; let mut client_addr: Option<SocketAddr> = None;
let mut from_client = vec![0u8; UDP_BUF]; let mut from_client = vec![0u8; UDP_BUF];
@@ -536,19 +706,33 @@ async fn run_udp_relay_socks5(
if header.frag != 0 { if header.frag != 0 {
continue; continue;
} }
let host = addrkind_host(&header.dst);
if ctx.is_blocked(&host) {
log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}");
continue;
}
let peer_key = addrkind_key(&header.dst);
let payload = from_client[header.data_offset..n].to_vec(); let payload = from_client[header.data_offset..n].to_vec();
let _ = datagram.send_to(&payload, header.dst).await; if datagram.send_to(&payload, header.dst).await.is_ok() {
ctx.record_sent(&host, peer_key, payload.len() as u64);
}
} }
// Upstream -> browser. // Upstream -> browser.
r = datagram.recv_from(&mut from_upstream) => { r = datagram.recv_from(&mut from_upstream) => {
let Ok((n, peer)) = r else { continue }; let Ok((n, peer)) = r else { continue };
if let Some(client) = client_addr { if let Some(client) = client_addr {
let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]); let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]);
let _ = relay.send_to(&resp, client).await; if relay.send_to(&resp, client).await.is_ok() {
// The upstream usually reports the peer by IP even when we
// addressed a domain, so an unmatched flow falls back to that IP.
ctx.record_received(&addrkind_key(&peer), &addrkind_host(&peer), n as u64);
}
} }
} }
} }
} }
ctx.flush();
} }
/// Resolve a UDP destination to a concrete socket address for direct relay. /// Resolve a UDP destination to a concrete socket address for direct relay.
@@ -637,6 +821,91 @@ mod tests {
assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none()); assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none());
} }
#[test]
fn addrkind_host_strips_port_for_blocklist_matching() {
// The blocklist matches CONNECT hosts without a port, so UDP destinations
// must be rendered the same way or IP rules would never match.
assert_eq!(
addrkind_host(&AddrKind::Ip(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
443
))),
"1.2.3.4"
);
assert_eq!(
addrkind_host(&AddrKind::Domain("ads.example.com".into(), 443)),
"ads.example.com"
);
}
#[test]
fn addrkind_key_distinguishes_ports() {
assert_eq!(
addrkind_key(&AddrKind::Domain("example.com".into(), 443)),
"example.com:443"
);
assert_eq!(
addrkind_key(&AddrKind::Ip(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
53
))),
"1.2.3.4:53"
);
}
#[test]
fn udp_relay_context_blocks_like_the_tcp_path() {
let dir = std::env::temp_dir().join(format!("donut-udp-blocklist-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("block.txt");
std::fs::write(&path, "ads.example.com\n").unwrap();
let matcher = BlocklistMatcher::from_file(path.to_str().unwrap()).unwrap();
let ctx = UdpRelayContext::new(matcher);
assert!(ctx.is_blocked("ads.example.com"));
// Subdomains are blocked by the parent rule, as on the CONNECT path.
assert!(ctx.is_blocked("cdn.ads.example.com"));
assert!(!ctx.is_blocked("example.com"));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn udp_relay_context_allowlist_mode_blocks_unlisted_ip_destinations() {
// Parity check: allowlist mode blocks a bare IP destination over UDP the
// same way is_blocked does for a CONNECT to an IP literal — otherwise QUIC
// to a hardcoded IP would walk straight through a strict allowlist.
let dir = std::env::temp_dir().join(format!("donut-udp-allowlist-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("allow.txt");
std::fs::write(&path, "trusted.example.com\n").unwrap();
let matcher = BlocklistMatcher::from_file_with_mode(path.to_str().unwrap(), true).unwrap();
let ctx = UdpRelayContext::new(matcher);
assert!(!ctx.is_blocked("trusted.example.com"));
assert!(ctx.is_blocked("evil.example.com"));
assert!(ctx.is_blocked(&addrkind_host(&AddrKind::Ip(SocketAddr::new(
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
443
)))));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn udp_relay_context_attributes_replies_to_the_destination_host() {
// No tracker is initialised in tests, so this exercises the flow map that
// decides which domain reply bytes belong to.
let mut ctx = UdpRelayContext::new(BlocklistMatcher::new());
ctx.flow.insert("1.2.3.4:443".into(), "example.com".into());
assert_eq!(
ctx.flow.get("1.2.3.4:443").map(String::as_str),
Some("example.com")
);
// An unknown peer has no flow and falls back to the peer's own host.
assert!(!ctx.flow.contains_key("9.9.9.9:53"));
}
#[test] #[test]
fn build_udp_response_prefixes_header() { fn build_udp_response_prefixes_header() {
let resp = build_udp_response( let resp = build_udp_response(
+120 -150
View File
@@ -492,6 +492,9 @@ impl SyncEngine {
return Ok(()); return Ok(());
} }
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
let profile = &reconciled_profile;
// Derive encryption key if encrypted sync // Derive encryption key if encrypted sync
let encryption_key = if profile.is_encrypted_sync() { let encryption_key = if profile.is_encrypted_sync() {
let password = encryption::load_e2e_password() let password = encryption::load_e2e_password()
@@ -697,11 +700,6 @@ impl SyncEngine {
log::debug!("Deleted remote file: {}", path); log::debug!("Deleted remote file: {}", path);
} }
// Upload metadata.json (sanitized profile)
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
// If this sync changed the local profile directory (downloaded files and/or // If this sync changed the local profile directory (downloaded files and/or
// deleted local files), the manifest generated at the START of the sync is // deleted local files), the manifest generated at the START of the sync is
// now stale. Uploading it would advertise wrong hashes/mtimes for the files // now stale. Uploading it would advertise wrong hashes/mtimes for the files
@@ -747,37 +745,18 @@ impl SyncEngine {
let _ = self.sync_vpn(vpn_id, Some(app_handle)).await; let _ = self.sync_vpn(vpn_id, Some(app_handle)).await;
} }
// Download remote metadata and merge changes (name, tags, notes, etc.) let mut updated_profile = profile.clone();
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id); updated_profile.last_sync = Some(
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await { std::time::SystemTime::now()
let mut updated_profile = profile.clone(); .duration_since(std::time::UNIX_EPOCH)
// Merge fields that can be changed on other devices .unwrap()
updated_profile.name = remote_meta.name; .as_secs(),
updated_profile.tags = remote_meta.tags; );
updated_profile.note = remote_meta.note; profile_manager
updated_profile.proxy_id = remote_meta.proxy_id; .save_profile(&updated_profile)
updated_profile.vpn_id = remote_meta.vpn_id; .map_err(|e| {
updated_profile.group_id = remote_meta.group_id; SyncError::IoError(format!("Failed to save reconciled profile metadata: {e}"))
updated_profile.extension_group_id = remote_meta.extension_group_id; })?;
updated_profile.window_color = remote_meta.window_color;
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
} else {
// Fallback: just update last_sync
let mut updated_profile = profile.clone();
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated_profile);
}
let _ = events::emit("profiles-changed", ()); let _ = events::emit("profiles-changed", ());
let _ = events::emit( let _ = events::emit(
@@ -881,6 +860,45 @@ impl SyncEngine {
Ok(profile) Ok(profile)
} }
async fn reconcile_profile_metadata(
&self,
profile: &BrowserProfile,
) -> SyncResult<BrowserProfile> {
let profile_id = profile.id.to_string();
let key_prefix = Self::get_team_key_prefix(profile).await;
let remote_key = format!("{key_prefix}profiles/{profile_id}/metadata.json");
let stat = self.client.stat(&remote_key).await?;
if !stat.exists {
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
return Ok(profile.clone());
}
let local_updated = profile.updated_at.unwrap_or(0);
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
if local_updated > remote_updated {
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
return Ok(profile.clone());
}
if remote_updated <= local_updated {
return Ok(profile.clone());
}
let mut remote = self.download_profile_metadata(&remote_key).await?;
// Process state is device-local and deliberately stripped from uploads.
remote.process_id = profile.process_id;
remote.last_launch = profile.last_launch;
remote.last_sync = profile.last_sync;
ProfileManager::instance()
.save_profile(&remote)
.map_err(|e| SyncError::IoError(format!("Failed to save remote profile metadata: {e}")))?;
Ok(remote)
}
/// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups). /// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups).
/// No browser files are synced. /// No browser files are synced.
async fn sync_cross_os_metadata( async fn sync_cross_os_metadata(
@@ -889,34 +907,8 @@ impl SyncEngine {
profile: &BrowserProfile, profile: &BrowserProfile,
) -> SyncResult<()> { ) -> SyncResult<()> {
let profile_id = profile.id.to_string(); let profile_id = profile.id.to_string();
let key_prefix = Self::get_team_key_prefix(profile).await; let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
let profile_manager = ProfileManager::instance(); let profile = &reconciled_profile;
// Upload our metadata
self
.upload_profile_metadata(&profile_id, profile, &key_prefix)
.await?;
// Download remote metadata and merge if remote has changes
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
let mut updated = profile.clone();
updated.name = remote_meta.name;
updated.tags = remote_meta.tags;
updated.note = remote_meta.note;
updated.proxy_id = remote_meta.proxy_id;
updated.vpn_id = remote_meta.vpn_id;
updated.group_id = remote_meta.group_id;
updated.extension_group_id = remote_meta.extension_group_id;
updated.window_color = remote_meta.window_color;
updated.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
let _ = profile_manager.save_profile(&updated);
}
// Sync associated entities // Sync associated entities
if let Some(proxy_id) = &profile.proxy_id { if let Some(proxy_id) = &profile.proxy_id {
@@ -954,18 +946,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&sanitized) let json = serde_json::to_string_pretty(&sanitized)
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?; .map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
.map_err(|e| SyncError::InvalidData(format!("Failed to seal profile metadata: {e}")))?;
let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id); let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
let presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self self
.client .upload_config_json(&remote_key, &json, sanitized.updated_at.unwrap_or(0))
.upload_bytes(&presign.url, &payload, Some(content_type))
.await?; .await?;
Ok(()) Ok(())
@@ -4023,28 +4006,55 @@ pub async fn rollover_encryption_for_all_entities(
) -> Result<(), String> { ) -> Result<(), String> {
let _ = events::emit("e2e-rollover-started", ()); let _ = events::emit("e2e-rollover-started", ());
let internal_error = |detail: String| {
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": detail } }).to_string()
};
let engine = SyncEngine::create_from_settings(&app_handle)
.await
.map_err(&internal_error)?;
let profile_manager = ProfileManager::instance(); let profile_manager = ProfileManager::instance();
let profiles = profile_manager let profiles = profile_manager
.list_profiles() .list_profiles()
.map_err(|e| format!("Failed to list profiles: {e}"))?; .map_err(|e| internal_error(format!("Failed to list profiles: {e}")))?;
let synced_profiles: Vec<_> = profiles let synced_profiles: Vec<_> = profiles
.iter() .iter()
.filter(|p| p.sync_mode != SyncMode::Disabled) .filter(|p| p.sync_mode != SyncMode::Disabled)
.collect(); .collect();
let total_profiles = synced_profiles.len(); if synced_profiles
let mut running_profile_ids: std::collections::HashSet<uuid::Uuid> = .iter()
std::collections::HashSet::new(); .any(|profile| profile.process_id.is_some())
{
return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string());
}
let total_profiles = synced_profiles.len();
for (i, profile) in synced_profiles.iter().enumerate() { for (i, profile) in synced_profiles.iter().enumerate() {
if profile.process_id.is_some() {
running_profile_ids.insert(profile.id);
}
let id_str = profile.id.to_string(); let id_str = profile.id.to_string();
if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await { // The remote manifest may be encrypted with the previous password. Delete
log::warn!("Rollover: profile {} re-sync failed: {e}", id_str); // only that manifest so the normal sync path treats every local file as an
} // upload and rewrites it with the current password. Existing remote files
// remain available until their replacements have uploaded.
let key_prefix = SyncEngine::get_team_key_prefix(profile).await;
engine
.upload_profile_metadata(&id_str, profile, &key_prefix)
.await
.map_err(|e| {
internal_error(format!(
"Failed to roll over profile metadata {id_str}: {e}"
))
})?;
let manifest_key = format!("{key_prefix}profiles/{id_str}/manifest.json");
engine
.client
.delete(&manifest_key, None)
.await
.map_err(|e| internal_error(format!("Failed to reset profile manifest: {e}")))?;
engine
.sync_profile(&app_handle, profile)
.await
.map_err(|e| internal_error(format!("Failed to roll over profile {id_str}: {e}")))?;
let _ = events::emit( let _ = events::emit(
"e2e-rollover-progress", "e2e-rollover-progress",
serde_json::json!({ serde_json::json!({
@@ -4055,37 +4065,14 @@ pub async fn rollover_encryption_for_all_entities(
); );
} }
// Determine which entity ids are referenced by running profiles, so we can
// defer their re-upload (changing their files mid-session would cause the
// running browser to see a different proxy/extension config than what it
// launched with).
let mut deferred_proxy_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deferred_vpn_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut deferred_group_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
for p in &profiles {
if running_profile_ids.contains(&p.id) {
if let Some(id) = &p.proxy_id {
deferred_proxy_ids.insert(id.clone());
}
if let Some(id) = &p.vpn_id {
deferred_vpn_ids.insert(id.clone());
}
if let Some(id) = &p.group_id {
deferred_group_ids.insert(id.clone());
}
}
}
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies(); let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect(); let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
let total_proxies = synced_proxies.len(); let total_proxies = synced_proxies.len();
let mut deferred = Vec::new();
for (i, proxy) in synced_proxies.iter().enumerate() { for (i, proxy) in synced_proxies.iter().enumerate() {
if deferred_proxy_ids.contains(&proxy.id) { engine
deferred.push(proxy.id.clone()); .upload_proxy(proxy)
} else if let Some(scheduler) = super::get_global_scheduler() { .await
scheduler.queue_proxy_sync(proxy.id.clone()).await; .map_err(|e| internal_error(format!("Failed to roll over proxy {}: {e}", proxy.id)))?;
}
let _ = events::emit( let _ = events::emit(
"e2e-rollover-progress", "e2e-rollover-progress",
serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}), serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}),
@@ -4095,17 +4082,15 @@ pub async fn rollover_encryption_for_all_entities(
let groups = { let groups = {
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap(); let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
gm.get_all_groups() gm.get_all_groups()
.map_err(|e| format!("Failed to get groups: {e}"))? .map_err(|e| internal_error(format!("Failed to get groups: {e}")))?
}; };
let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect(); let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect();
let total_groups = synced_groups.len(); let total_groups = synced_groups.len();
let mut deferred_groups = Vec::new();
for (i, group) in synced_groups.iter().enumerate() { for (i, group) in synced_groups.iter().enumerate() {
if deferred_group_ids.contains(&group.id) { engine
deferred_groups.push(group.id.clone()); .upload_group(group)
} else if let Some(scheduler) = super::get_global_scheduler() { .await
scheduler.queue_group_sync(group.id.clone()).await; .map_err(|e| internal_error(format!("Failed to roll over group {}: {e}", group.id)))?;
}
let _ = events::emit( let _ = events::emit(
"e2e-rollover-progress", "e2e-rollover-progress",
serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}), serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}),
@@ -4116,17 +4101,15 @@ pub async fn rollover_encryption_for_all_entities(
let storage = crate::vpn::VPN_STORAGE.lock().unwrap(); let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
storage storage
.list_configs() .list_configs()
.map_err(|e| format!("Failed to list VPN configs: {e}"))? .map_err(|e| internal_error(format!("Failed to list VPN configs: {e}")))?
}; };
let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect(); let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect();
let total_vpns = synced_vpns.len(); let total_vpns = synced_vpns.len();
let mut deferred_vpns = Vec::new();
for (i, config) in synced_vpns.iter().enumerate() { for (i, config) in synced_vpns.iter().enumerate() {
if deferred_vpn_ids.contains(&config.id) { engine
deferred_vpns.push(config.id.clone()); .upload_vpn(config)
} else if let Some(scheduler) = super::get_global_scheduler() { .await
scheduler.queue_vpn_sync(config.id.clone()).await; .map_err(|e| internal_error(format!("Failed to roll over VPN {}: {e}", config.id)))?;
}
let _ = events::emit( let _ = events::emit(
"e2e-rollover-progress", "e2e-rollover-progress",
serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}), serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}),
@@ -4136,14 +4119,15 @@ pub async fn rollover_encryption_for_all_entities(
let extensions = { let extensions = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_extensions() em.list_extensions()
.map_err(|e| format!("Failed to list extensions: {e}"))? .map_err(|e| internal_error(format!("Failed to list extensions: {e}")))?
}; };
let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect(); let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect();
let total_exts = synced_exts.len(); let total_exts = synced_exts.len();
for (i, ext) in synced_exts.iter().enumerate() { for (i, ext) in synced_exts.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() { engine
scheduler.queue_extension_sync(ext.id.clone()).await; .upload_extension(ext)
} .await
.map_err(|e| internal_error(format!("Failed to roll over extension {}: {e}", ext.id)))?;
let _ = events::emit( let _ = events::emit(
"e2e-rollover-progress", "e2e-rollover-progress",
serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}), serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}),
@@ -4153,37 +4137,23 @@ pub async fn rollover_encryption_for_all_entities(
let ext_groups = { let ext_groups = {
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap(); let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
em.list_groups() em.list_groups()
.map_err(|e| format!("Failed to list extension groups: {e}"))? .map_err(|e| internal_error(format!("Failed to list extension groups: {e}")))?
}; };
let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect(); let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect();
let total_eg = synced_ext_groups.len(); let total_eg = synced_ext_groups.len();
for (i, group) in synced_ext_groups.iter().enumerate() { for (i, group) in synced_ext_groups.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() { engine.upload_extension_group(group).await.map_err(|e| {
scheduler.queue_extension_group_sync(group.id.clone()).await; internal_error(format!(
} "Failed to roll over extension group {}: {e}",
group.id
))
})?;
let _ = events::emit( let _ = events::emit(
"e2e-rollover-progress", "e2e-rollover-progress",
serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}), serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}),
); );
} }
if !deferred.is_empty() || !deferred_groups.is_empty() || !deferred_vpns.is_empty() {
tauri::async_runtime::spawn(async move {
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
if let Some(scheduler) = super::get_global_scheduler() {
for id in deferred {
scheduler.queue_proxy_sync(id).await;
}
for id in deferred_groups {
scheduler.queue_group_sync(id).await;
}
for id in deferred_vpns {
scheduler.queue_vpn_sync(id).await;
}
}
});
}
let _ = events::emit("e2e-rollover-completed", ()); let _ = events::emit("e2e-rollover-completed", ());
Ok(()) Ok(())
} }
+8 -137
View File
@@ -8,7 +8,6 @@ use std::path::Path;
use std::time::SystemTime; use std::time::SystemTime;
use super::types::{SyncError, SyncResult}; use super::types::{SyncError, SyncResult};
use crate::profile::types::BrowserProfile;
/// Default exclude patterns for volatile browser profile files. /// Default exclude patterns for volatile browser profile files.
/// Patterns use `**/` prefix to match at any directory depth, since the sync /// Patterns use `**/` prefix to match at any directory depth, since the sync
@@ -61,6 +60,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/DawnWebGPUCache/**", "**/DawnWebGPUCache/**",
"**/BrowserMetrics*", "**/BrowserMetrics*",
"**/.DS_Store", "**/.DS_Store",
// Profile metadata is a separately reconciled LWW config object. Including
// it in the browser-file manifest creates two competing sync mechanisms and
// lets a stale in-memory profile overwrite a metadata download.
"metadata.json",
".donut-sync/**", ".donut-sync/**",
// Orphaned local-only marker from earlier rollover-based fingerprint // Orphaned local-only marker from earlier rollover-based fingerprint
// regeneration. Keep excluding it so any markers left on disk from // regeneration. Keep excluding it so any markers left on disk from
@@ -224,39 +227,6 @@ fn hash_file(path: &Path) -> Result<Option<String>, SyncError> {
Ok(Some(hasher.finalize().to_hex().to_string())) Ok(Some(hasher.finalize().to_hex().to_string()))
} }
/// Compute blake3 hash of metadata.json after sanitizing volatile fields.
/// This prevents infinite sync loops where updating last_sync triggers a new sync.
fn hash_sanitized_metadata(path: &Path) -> Result<Option<String>, SyncError> {
let content = match fs::read_to_string(path) {
Ok(c) => c,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => {
return Err(SyncError::IoError(format!(
"Failed to read metadata at {}: {e}",
path.display()
)));
}
};
let mut profile: BrowserProfile = serde_json::from_str(&content).map_err(|e| {
SyncError::SerializationError(format!("Failed to parse metadata for hashing: {e}"))
})?;
// Sanitize volatile fields that should not trigger a re-sync
profile.last_sync = None;
profile.process_id = None;
profile.last_launch = None;
let sanitized_json = serde_json::to_string(&profile).map_err(|e| {
SyncError::SerializationError(format!("Failed to serialize sanitized metadata: {e}"))
})?;
let mut hasher = blake3::Hasher::new();
hasher.update(sanitized_json.as_bytes());
Ok(Some(hasher.finalize().to_hex().to_string()))
}
/// Get mtime as unix timestamp /// Get mtime as unix timestamp
/// Returns None if the file doesn't exist (was deleted) /// Returns None if the file doesn't exist (was deleted)
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> { fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
@@ -372,19 +342,7 @@ pub fn generate_manifest(
*max_mtime = (*max_mtime).max(mtime); *max_mtime = (*max_mtime).max(mtime);
// Check cache for existing hash // Check cache for existing hash
let hash = if relative_path == "metadata.json" { let hash = if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
// Special case: sanitize metadata.json before hashing to prevent sync loops
match hash_sanitized_metadata(&path)? {
Some(computed_hash) => computed_hash,
None => {
log::debug!(
"File disappeared during manifest generation, skipping: {}",
path.display()
);
continue;
}
}
} else if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
cached_hash.to_string() cached_hash.to_string()
} else { } else {
match hash_file(&path)? { match hash_file(&path)? {
@@ -651,21 +609,15 @@ mod tests {
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap(); fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap(); fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
// metadata.json at root fs::write(profile_dir.join("metadata.json"), "{}").unwrap();
let profile = BrowserProfile::default();
fs::write(
profile_dir.join("metadata.json"),
serde_json::to_string(&profile).unwrap(),
)
.unwrap();
let mut cache = HashCache::default(); let mut cache = HashCache::default();
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap(); let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect(); let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
assert!( assert!(
paths.contains(&"metadata.json"), !paths.contains(&"metadata.json"),
"metadata.json should be synced" "metadata.json is reconciled separately from browser files"
); );
assert!( assert!(
paths.contains(&"profile/Default/Cookies"), paths.contains(&"profile/Default/Cookies"),
@@ -865,85 +817,4 @@ mod tests {
assert!(diff.files_to_delete_remote.is_empty()); assert!(diff.files_to_delete_remote.is_empty());
assert!(diff.files_to_delete_local.is_empty()); assert!(diff.files_to_delete_local.is_empty());
} }
#[test]
fn test_generate_manifest_sanitizes_metadata() {
let temp_dir = TempDir::new().unwrap();
let profile_dir = temp_dir.path().join("profile");
fs::create_dir_all(&profile_dir).unwrap();
let profile_id = uuid::Uuid::new_v4();
let metadata_path = profile_dir.join("metadata.json");
let profile = BrowserProfile {
id: profile_id,
name: "test-profile".to_string(),
last_sync: Some(100),
process_id: Some(1234),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile).unwrap()).unwrap();
let mut cache = HashCache::default();
let manifest1 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash1 = manifest1
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Update volatile fields
let profile2 = BrowserProfile {
id: profile_id,
name: "test-profile".to_string(),
last_sync: Some(200),
process_id: Some(5678),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile2).unwrap()).unwrap();
let manifest2 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash2 = manifest2
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Hash should be identical because volatile fields are sanitized
assert_eq!(
hash1, hash2,
"Metadata hash should be stable across last_sync/process_id updates"
);
// Change a non-volatile field
let profile3 = BrowserProfile {
id: profile_id,
name: "changed-name".to_string(),
last_sync: Some(200),
..Default::default()
};
fs::write(&metadata_path, serde_json::to_string(&profile3).unwrap()).unwrap();
let manifest3 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
let hash3 = manifest3
.files
.iter()
.find(|f| f.path == "metadata.json")
.unwrap()
.hash
.clone();
// Hash should be different because name changed
assert_ne!(
hash1, hash3,
"Metadata hash should change when non-volatile fields change"
);
}
} }
+1 -1
View File
@@ -207,7 +207,7 @@ impl SyncSubscription {
)); ));
} }
log::info!("Connected to sync subscription at {url}"); log::info!("Connected to sync subscription");
let _ = events::emit("sync-subscription-status", "connected"); let _ = events::emit("sync-subscription-status", "connected");
let mut buffer = String::new(); let mut buffer = String::new();
+3 -3
View File
@@ -359,7 +359,7 @@ impl SynchronizerManager {
log::info!("Synchronizer: leader CDP port = {leader_port}, getting WS URL"); log::info!("Synchronizer: leader CDP port = {leader_port}, getting WS URL");
let leader_ws_url = Self::get_page_ws_url(leader_port).await?; let leader_ws_url = Self::get_page_ws_url(leader_port).await?;
log::info!("Synchronizer: connecting to leader page at {leader_ws_url}"); log::info!("Synchronizer: connecting to leader page");
let (mut ws_stream, _) = connect_async(&leader_ws_url) let (mut ws_stream, _) = connect_async(&leader_ws_url)
.await .await
@@ -504,7 +504,7 @@ impl SynchronizerManager {
Ok(url) => { Ok(url) => {
match tokio_tungstenite::connect_async(&url).await { match tokio_tungstenite::connect_async(&url).await {
Ok((ws, _)) => { Ok((ws, _)) => {
log::info!("Synchronizer: follower {} connected at {}", fp.name, url); log::info!("Synchronizer: follower connected");
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<CapturedEvent>(); let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<CapturedEvent>();
follower_senders.insert(fid.clone(), tx); follower_senders.insert(fid.clone(), tx);
@@ -674,7 +674,7 @@ impl SynchronizerManager {
if is_top { if is_top {
if let Some(url) = frame.get("url").and_then(|v| v.as_str()) { if let Some(url) = frame.get("url").and_then(|v| v.as_str()) {
if !url.starts_with("about:") && !url.starts_with("chrome://") { if !url.starts_with("about:") && !url.starts_with("chrome://") {
log::info!("Synchronizer: replaying address-bar navigation to {url}"); log::info!("Synchronizer: replaying address-bar navigation");
let nav_event = CapturedEvent { let nav_event = CapturedEvent {
event_type: "navigate".to_string(), event_type: "navigate".to_string(),
url: Some(url.to_string()), url: Some(url.to_string()),
+103 -33
View File
@@ -347,7 +347,12 @@ pub fn save_traffic_stats(stats: &TrafficStats) -> Result<(), Box<dyn std::error
let key = get_stats_storage_key(stats); let key = get_stats_storage_key(stats);
let file_path = storage_dir.join(format!("{key}.json")); let file_path = storage_dir.join(format!("{key}.json"));
let content = serde_json::to_string(stats)?; let content = serde_json::to_string(stats)?;
fs::write(&file_path, content)?;
// Write atomically via temp file + rename so readers never observe a
// partially-written file (same pattern as write_session_snapshot)
let temp_path = storage_dir.join(format!("{key}.json.tmp"));
fs::write(&temp_path, content)?;
fs::rename(&temp_path, &file_path)?;
Ok(()) Ok(())
} }
@@ -370,15 +375,18 @@ pub fn load_traffic_stats_by_profile(profile_id: &str) -> Option<TrafficStats> {
load_traffic_stats(profile_id) load_traffic_stats(profile_id)
} }
/// List all traffic stats files and migrate old proxy-id based files to profile-id based /// Load all traffic stats files keyed by storage key, migrating old
pub fn list_traffic_stats() -> Vec<TrafficStats> { /// proxy-id based files to profile-id based ones. Only writes to disk when a
/// migration or merge actually changed data; the common case is read-only.
fn collect_traffic_stats() -> HashMap<String, TrafficStats> {
let storage_dir = get_traffic_stats_dir(); let storage_dir = get_traffic_stats_dir();
if !storage_dir.exists() { if !storage_dir.exists() {
return Vec::new(); return HashMap::new();
} }
let mut stats_map: HashMap<String, TrafficStats> = HashMap::new(); let mut stats_map: HashMap<String, TrafficStats> = HashMap::new();
let mut dirty_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut files_to_delete: Vec<std::path::PathBuf> = Vec::new(); let mut files_to_delete: Vec<std::path::PathBuf> = Vec::new();
if let Ok(entries) = fs::read_dir(&storage_dir) { if let Ok(entries) = fs::read_dir(&storage_dir) {
@@ -399,12 +407,14 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
if let Some(existing) = stats_map.get_mut(&key) { if let Some(existing) = stats_map.get_mut(&key) {
// Merge stats from this file into existing // Merge stats from this file into existing
merge_traffic_stats(existing, &s); merge_traffic_stats(existing, &s);
dirty_keys.insert(key);
if is_old_proxy_file { if is_old_proxy_file {
files_to_delete.push(path.clone()); files_to_delete.push(path.clone());
} }
} else { } else {
stats_map.insert(key.clone(), s); stats_map.insert(key.clone(), s);
if is_old_proxy_file { if is_old_proxy_file {
dirty_keys.insert(key);
files_to_delete.push(path.clone()); files_to_delete.push(path.clone());
} }
} }
@@ -414,10 +424,12 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
} }
} }
// Save merged stats and delete old files // Persist only entries actually changed by a merge or migration
for stats in stats_map.values() { for key in &dirty_keys {
if let Err(e) = save_traffic_stats(stats) { if let Some(stats) = stats_map.get(key) {
log::warn!("Failed to save merged traffic stats: {}", e); if let Err(e) = save_traffic_stats(stats) {
log::warn!("Failed to save merged traffic stats: {}", e);
}
} }
} }
@@ -427,7 +439,12 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
} }
} }
stats_map.into_values().collect() stats_map
}
/// List all traffic stats files and migrate old proxy-id based files to profile-id based
pub fn list_traffic_stats() -> Vec<TrafficStats> {
collect_traffic_stats().into_values().collect()
} }
/// Merge traffic stats from source into destination /// Merge traffic stats from source into destination
@@ -487,27 +504,82 @@ fn merge_traffic_stats(dest: &mut TrafficStats, src: &TrafficStats) {
} }
} }
/// Delete traffic stats by id (profile_id or proxy_id) /// Delete traffic stats by id (profile_id or proxy_id).
///
/// Removes the session snapshot and any temp orphans alongside the main file.
/// The snapshot is what a running worker writes every second, and
/// `get_all_traffic_snapshots_realtime` rebuilds an entry straight from it when
/// no main file exists — so clearing only `{id}.json` resurrects the very
/// totals the user asked to erase, and leaves the snapshot's copy on disk.
pub fn delete_traffic_stats(id: &str) -> bool { pub fn delete_traffic_stats(id: &str) -> bool {
let storage_dir = get_traffic_stats_dir(); let storage_dir = get_traffic_stats_dir();
let file_path = storage_dir.join(format!("{id}.json")); let mut removed = false;
if file_path.exists() { for name in [
fs::remove_file(&file_path).is_ok() format!("{id}.json"),
} else { format!("{id}.json.tmp"),
false format!("{id}.session.json"),
format!("{id}.session.json.tmp"),
] {
let path = storage_dir.join(name);
if path.exists() && secure_remove_file(&path).is_ok() {
removed = true;
}
} }
removed
} }
/// Clear all traffic stats (used when clearing cache) /// Best-effort secure erase: overwrite the file's bytes with zeros and flush
/// before unlinking, so the traffic history isn't trivially recoverable from
/// the freed blocks. On copy-on-write / SSD storage the OS may still retain
/// old blocks — this is a best-effort mitigation, not a guarantee.
fn secure_remove_file(path: &std::path::Path) -> std::io::Result<()> {
use std::io::Write;
if let Ok(meta) = fs::metadata(path) {
let len = meta.len();
if len > 0 {
if let Ok(mut f) = fs::OpenOptions::new().write(true).open(path) {
let zeros = vec![0u8; 8192];
let mut remaining = len;
// The overwrite is best-effort and must never gate the unlink: a write
// failure part-way (ENOSPC on a copy-on-write volume, EIO) would
// otherwise leave the file both un-wiped and un-deleted, which is
// strictly worse than the plain remove this replaced — and the caller
// reports success either way, so the history would silently survive a
// clear.
while remaining > 0 {
let chunk = remaining.min(zeros.len() as u64) as usize;
if f.write_all(&zeros[..chunk]).is_err() {
break;
}
remaining -= chunk as u64;
}
let _ = f.flush();
let _ = f.sync_all();
}
}
}
fs::remove_file(path)
}
/// Clear all traffic stats (used when clearing cache), securely erasing each
/// file first.
pub fn clear_all_traffic_stats() -> Result<(), Box<dyn std::error::Error>> { pub fn clear_all_traffic_stats() -> Result<(), Box<dyn std::error::Error>> {
let storage_dir = get_traffic_stats_dir(); let storage_dir = get_traffic_stats_dir();
if storage_dir.exists() { if storage_dir.exists() {
for entry in fs::read_dir(&storage_dir)?.flatten() { for entry in fs::read_dir(&storage_dir)?.flatten() {
let path = entry.path(); let path = entry.path();
if path.extension().is_some_and(|ext| ext == "json") { // Wipe the live stats files and any temp orphan left by an interrupted
let _ = fs::remove_file(&path); // atomic save, which still holds a copy of the history. This directory
// holds nothing but traffic stats, so matching every `.json`/`.tmp` also
// catches orphans from older temp-naming schemes.
let is_stats = path
.extension()
.is_some_and(|ext| ext == "json" || ext == "tmp");
if is_stats {
let _ = secure_remove_file(&path);
} }
} }
} }
@@ -584,8 +656,10 @@ impl LiveTrafficTracker {
fs::create_dir_all(&storage_dir)?; fs::create_dir_all(&storage_dir)?;
let session_file = storage_dir.join(format!("{}.session.json", storage_key)); let session_file = storage_dir.join(format!("{}.session.json", storage_key));
// Write atomically using a temp file // Write atomically using a temp file. Named by suffix rather than
let temp_file = session_file.with_extension("tmp"); // `with_extension`, which would replace `.json` and yield
// `{key}.session.tmp` — a name the cleanup sweeps did not recognise.
let temp_file = storage_dir.join(format!("{}.session.json.tmp", storage_key));
let content = serde_json::to_string(&snapshot)?; let content = serde_json::to_string(&snapshot)?;
fs::write(&temp_file, content)?; fs::write(&temp_file, content)?;
fs::rename(&temp_file, &session_file)?; fs::rename(&temp_file, &session_file)?;
@@ -1035,14 +1109,14 @@ fn load_session_snapshot(profile_id: &str) -> Option<SessionSnapshot> {
pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> { pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> {
use std::collections::HashMap; use std::collections::HashMap;
// Start with disk-stored stats // Start with disk-stored stats, keeping last_flush_timestamp per key so the
let mut snapshots: HashMap<String, TrafficSnapshot> = list_traffic_stats() // session-snapshot merge below doesn't need to re-read the files
.into_iter() let mut snapshots: HashMap<String, TrafficSnapshot> = HashMap::new();
.map(|s| { let mut last_flush_by_key: HashMap<String, u64> = HashMap::new();
let key = s.profile_id.clone().unwrap_or_else(|| s.proxy_id.clone()); for (key, s) in collect_traffic_stats() {
(key, s.to_snapshot()) last_flush_by_key.insert(key.clone(), s.last_flush_timestamp);
}) snapshots.insert(key, s.to_snapshot());
.collect(); }
// Try to merge in real-time data from active tracker (if in same process) // Try to merge in real-time data from active tracker (if in same process)
if let Some(tracker) = get_traffic_tracker() { if let Some(tracker) = get_traffic_tracker() {
@@ -1068,11 +1142,7 @@ pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> {
// Only merge session data if it's newer than the last flush // Only merge session data if it's newer than the last flush
// Session snapshots written before the last flush contain bytes already // Session snapshots written before the last flush contain bytes already
// included in disk totals, so merging them would cause double-counting // included in disk totals, so merging them would cause double-counting
let disk_stats = load_traffic_stats(profile_id); let last_flush = last_flush_by_key.get(profile_id).copied().unwrap_or(0);
let last_flush = disk_stats
.as_ref()
.map(|s| s.last_flush_timestamp)
.unwrap_or(0);
if session.timestamp > last_flush { if session.timestamp > last_flush {
// Session data contains in-memory counters not yet flushed to disk // Session data contains in-memory counters not yet flushed to disk
+1 -1
View File
@@ -1158,7 +1158,7 @@ mod tests {
#[test] #[test]
fn test_parse_key_valid() { fn test_parse_key_valid() {
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA="; let key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
assert!(parse_key(key).is_ok()); assert!(parse_key(key).is_ok());
} }
+3 -5
View File
@@ -349,12 +349,11 @@ mod tests {
fn create_test_config() -> WireGuardConfig { fn create_test_config() -> WireGuardConfig {
WireGuardConfig { WireGuardConfig {
// These are test keys, not real ones private_key: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string(),
private_key: "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=".to_string(),
address: "10.0.0.2/24".to_string(), address: "10.0.0.2/24".to_string(),
dns: Some("1.1.1.1".to_string()), dns: Some("1.1.1.1".to_string()),
mtu: Some(1420), mtu: Some(1420),
peer_public_key: "aGnF7JlG+U5t0BqB1PVf1yOuELHrWLGGcUJb0eCK9Aw=".to_string(), peer_public_key: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=".to_string(),
peer_endpoint: "127.0.0.1:51820".to_string(), peer_endpoint: "127.0.0.1:51820".to_string(),
allowed_ips: vec!["0.0.0.0/0".to_string()], allowed_ips: vec!["0.0.0.0/0".to_string()],
persistent_keepalive: Some(25), persistent_keepalive: Some(25),
@@ -375,8 +374,7 @@ mod tests {
#[test] #[test]
fn test_parse_key_valid() { fn test_parse_key_valid() {
// Valid base64-encoded 32-byte key let key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
let result = WireGuardTunnel::parse_key(key); let result = WireGuardTunnel::parse_key(key);
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!(result.unwrap().len(), 32); assert_eq!(result.unwrap().len(), 32);
+2
View File
@@ -100,6 +100,8 @@ async fn wait_for_vpn_worker_ready(
} }
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> { pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
crate::proxy_runner::ensure_sidecar_version().await?;
for config in list_vpn_worker_configs() { for config in list_vpn_worker_configs() {
if let Some(pid) = config.pid { if let Some(pid) = config.pid {
if !is_process_running(pid) { if !is_process_running(pid) {
+8 -12
View File
@@ -910,11 +910,6 @@ impl WayfernManager {
); );
} }
} }
if let Some(ref token) = wayfern_token {
args.push(format!("--wayfern-token={token}"));
log::info!("Wayfern token passed as CLI flag (length: {})", token.len());
}
if let Some(proxy) = proxy_url { if let Some(proxy) = proxy_url {
// Map the local proxy scheme to the matching PAC directive. SOCKS5 lets // Map the local proxy scheme to the matching PAC directive. SOCKS5 lets
// Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy; // Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy;
@@ -942,6 +937,10 @@ impl WayfernManager {
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()); .stderr(Stdio::null());
if let Some(ref token) = wayfern_token {
command.env("WAYFERN_TOKEN", token);
log::info!("Wayfern authorization configured for browser process");
}
let child = command let child = command
.spawn() .spawn()
@@ -1040,16 +1039,13 @@ impl WayfernManager {
for target in &page_targets { for target in &page_targets {
if let Some(ws_url) = &target.websocket_debugger_url { if let Some(ws_url) = &target.websocket_debugger_url {
log::info!("Applying fingerprint to target via WebSocket: {}", ws_url); log::info!("Applying fingerprint to page target");
match self match self
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone()) .send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
.await .await
{ {
Ok(result) => { Ok(result) => {
log::info!( log::info!("Successfully applied fingerprint to page target");
"Successfully applied fingerprint to page target: {:?}",
result
);
// Wayfern.setFingerprint echoes back the fingerprint it actually // Wayfern.setFingerprint echoes back the fingerprint it actually
// used, which may be UPGRADED from what we sent (e.g. when the // used, which may be UPGRADED from what we sent (e.g. when the
// stored fingerprint targets an older browser version). Capture // stored fingerprint targets an older browser version). Capture
@@ -1080,7 +1076,7 @@ impl WayfernManager {
// Geolocation is handled internally by the browser binary. // Geolocation is handled internally by the browser binary.
if let Some(url) = url { if let Some(url) = url {
log::info!("Navigating to URL via CDP: {}", url); log::info!("Navigating to URL via CDP");
if let Some(target) = page_targets.first() { if let Some(target) = page_targets.first() {
if let Some(ws_url) = &target.websocket_debugger_url { if let Some(ws_url) = &target.websocket_debugger_url {
if let Err(e) = self if let Err(e) = self
@@ -1212,7 +1208,7 @@ impl WayfernManager {
return Err(format!("CDP /json/new returned HTTP {}", resp.status()).into()); return Err(format!("CDP /json/new returned HTTP {}", resp.status()).into());
} }
log::info!("Opened URL in new tab via CDP: {}", url); log::info!("Opened URL in new tab via CDP");
Ok(()) Ok(())
} }
+19 -7
View File
@@ -1,18 +1,29 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Donut", "productName": "Donut",
"version": "0.28.1", "version": "0.28.2",
"identifier": "com.donutbrowser", "identifier": "com.donutbrowser",
"build": { "build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev", "beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
"devUrl": "http://localhost:12341", "devUrl": "http://localhost:12341",
"beforeBuildCommand": "pnpm copy-proxy-binary && (test -d ../dist || pnpm build)", "beforeBuildCommand": "pnpm copy-proxy-binary:release && (test -d ../dist || pnpm build)",
"frontendDist": "../dist" "frontendDist": "../dist"
}, },
"app": { "app": {
"windows": [], "windows": [],
"security": { "security": {
"csp": null "csp": {
"default-src": "'self' customprotocol: asset:",
"connect-src": "ipc: http://ipc.localhost http: https: ws: wss:",
"font-src": "'self' data:",
"img-src": "'self' asset: http://asset.localhost blob: data:",
"style-src": "'self' 'unsafe-inline'",
"script-src": "'self'",
"object-src": "'none'",
"base-uri": "'none'",
"form-action": "'none'",
"frame-ancestors": "'none'"
}
} }
}, },
"bundle": { "bundle": {
@@ -35,9 +46,7 @@
"signingIdentity": null, "signingIdentity": null,
"providerShortName": null, "providerShortName": null,
"entitlements": "entitlements.plist", "entitlements": "entitlements.plist",
"files": { "infoPlist": "Info.plist"
"Info.plist": "Info.plist"
}
}, },
"linux": { "linux": {
"deb": { "deb": {
@@ -57,7 +66,10 @@
"windows": { "windows": {
"certificateThumbprint": null, "certificateThumbprint": null,
"digestAlgorithm": "sha256", "digestAlgorithm": "sha256",
"timestampUrl": "" "timestampUrl": "",
"nsis": {
"installerHooks": "installer-hooks.nsh"
}
} }
}, },
"plugins": { "plugins": {
+37 -11
View File
@@ -66,7 +66,17 @@ async fn setup_test() -> Result<std::path::PathBuf, Box<dyn std::error::Error +
.join("debug") .join("debug")
.join(proxy_binary_name); .join(proxy_binary_name);
if !proxy_binary.exists() { let binary_is_current = proxy_binary.exists()
&& std::process::Command::new(&proxy_binary)
.arg("--version")
.output()
.is_ok_and(|output| {
output.status.success()
&& String::from_utf8_lossy(&output.stdout).trim()
== format!("donut-proxy {}", env!("BUILD_VERSION"))
});
if !binary_is_current {
println!("Building donut-proxy binary for integration tests..."); println!("Building donut-proxy binary for integration tests...");
let build_status = std::process::Command::new("cargo") let build_status = std::process::Command::new("cargo")
.args(["build", "--bin", "donut-proxy"]) .args(["build", "--bin", "donut-proxy"])
@@ -127,6 +137,25 @@ impl Drop for ProxyTestTracker {
} }
} }
#[tokio::test]
#[serial]
async fn test_sidecar_reports_build_version() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
{
let binary_path = setup_test().await?;
let output = TestUtils::execute_command(&binary_path, &["--version"]).await?;
assert!(
output.status.success(),
"donut-proxy --version failed: {}",
String::from_utf8_lossy(&output.stderr)
);
assert_eq!(
String::from_utf8(output.stdout)?.trim(),
format!("donut-proxy {}", env!("BUILD_VERSION"))
);
Ok(())
}
/// Test starting a local proxy without upstream proxy (DIRECT) /// Test starting a local proxy without upstream proxy (DIRECT)
#[tokio::test] #[tokio::test]
#[serial] #[serial]
@@ -1360,9 +1389,8 @@ async fn test_local_proxy_with_shadowsocks_upstream(
} }
// Start donut-proxy with Shadowsocks upstream // Start donut-proxy with Shadowsocks upstream
let output = TestUtils::execute_command( let output = tokio::process::Command::new(&binary_path)
&binary_path, .args([
&[
"proxy", "proxy",
"start", "start",
"--host", "--host",
@@ -1371,13 +1399,11 @@ async fn test_local_proxy_with_shadowsocks_upstream(
&ss_port.to_string(), &ss_port.to_string(),
"--type", "--type",
"ss", "ss",
"--username", ])
ss_method, .env("DONUT_PROXY_USERNAME", ss_method)
"--password", .env("DONUT_PROXY_PASSWORD", ss_password)
ss_password, .output()
], .await?;
)
.await?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);

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