Compare commits

..

8 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
99 changed files with 16710 additions and 1091 deletions
+6 -1
View File
@@ -2,6 +2,11 @@ name: Bug Report
description: Something isn't working
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
Do not include passwords, access tokens, proxy credentials, personal information, or other secrets. Automated triage sends the issue title and body to OpenRouter after removing the logs/screenshots field and redacting common sensitive-data patterns.
- type: textarea
id: description
attributes:
@@ -54,7 +59,7 @@ body:
id: logs
attributes:
label: Error logs or screenshots
description: Run from terminal to get logs. Paste errors, screenshots, or screen recordings.
description: Use Settings → Advanced → Copy logs for a redacted log bundle. Review it before posting. Never include credentials or personal information.
placeholder: Paste logs here or drag screenshots
validations:
required: false
@@ -2,6 +2,11 @@ name: Feature Request
description: Suggest a new feature
labels: ["enhancement"]
body:
- type: markdown
attributes:
value: |
Do not include passwords, access tokens, personal information, or other secrets. Automated triage sends the issue title and body to OpenRouter after redacting common sensitive-data patterns.
- type: textarea
id: description
attributes:
+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:
workflow_call:
inputs:
checkout_ref:
description: Optional commit to check out instead of the triggering ref
required: false
type: string
default: ""
push:
branches: ["main"]
pull_request:
@@ -32,6 +38,8 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
ref: ${{ inputs.checkout_ref }}
- name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
@@ -39,7 +47,7 @@ jobs:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with:
node-version-file: .node-version
cache: "pnpm"
+8 -4
View File
@@ -30,7 +30,8 @@ jobs:
name: Lint JavaScript/TypeScript
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/lint-js.yml
secrets: inherit
with:
checkout_ref: ${{ github.event.pull_request.head.sha }}
permissions:
contents: read
@@ -38,7 +39,8 @@ jobs:
name: Lint Rust
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
with:
checkout_ref: ${{ github.event.pull_request.head.sha }}
permissions:
contents: read
@@ -46,7 +48,8 @@ jobs:
name: CodeQL
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/codeql.yml
secrets: inherit
with:
checkout_ref: ${{ github.event.pull_request.head.sha }}
permissions:
security-events: write
contents: read
@@ -57,7 +60,8 @@ jobs:
name: Spell Check
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
with:
checkout_ref: ${{ github.event.pull_request.head.sha }}
permissions:
contents: read
+16 -5
View File
@@ -11,6 +11,11 @@ on:
description: "Docker tag (e.g., v1.0.0)"
required: true
type: string
secrets:
DOCKERHUB_USERNAME:
required: true
DOCKERHUB_TOKEN:
required: true
workflow_dispatch:
inputs:
tag:
@@ -43,23 +48,29 @@ jobs:
- name: Determine tags
id: tags
env:
INPUT_TAG: ${{ inputs.tag }}
EVENT_NAME: ${{ github.event_name }}
COMMIT_SHA: ${{ github.sha }}
run: |
TAGS=""
INPUT_TAG="${{ inputs.tag }}"
if [ -n "$INPUT_TAG" ]; then
# Called from release workflow or manual dispatch
if [[ ! "$INPUT_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then
echo "Invalid Docker tag" >&2
exit 1
fi
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${INPUT_TAG}"
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
elif [ "${{ github.event_name }}" = "push" ]; then
elif [ "$EVENT_NAME" = "push" ]; then
# Push to main (nightly): tag with nightly and commit SHA
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
SHORT_SHA=${COMMIT_SHA:0:7}
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly"
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${SHORT_SHA}"
fi
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
echo "Tags: ${TAGS}"
printf 'tags=%s\n' "$TAGS" >> "$GITHUB_OUTPUT"
- name: Build and push Docker image
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a #v7.3.0
+3 -5
View File
@@ -29,8 +29,8 @@ jobs:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
printf '%s' "$ISSUE_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" | node scripts/redact-sensitive-text.mjs --issue-body > /tmp/issue-body.txt
- name: Build prompt
run: |
@@ -92,11 +92,9 @@ jobs:
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
echo "::warning::Model returned non-JSON; treating as compliant"
cat /tmp/raw.txt
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
fi
echo "Result:"
cat /tmp/result.json
echo "Compliance response validated"
- name: Build comment
id: build
+48 -40
View File
@@ -14,7 +14,6 @@ permissions:
contents: read
issues: write
pull-requests: write
id-token: write
env:
# Single source of truth for the model used by both triage and composer.
@@ -49,8 +48,9 @@ jobs:
env:
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
node <<'EOF'
const fs = require('node:fs');
node --input-type=module <<'EOF'
import fs from 'node:fs';
import { redactIssueBody, redactSensitiveText } from './scripts/redact-sensitive-text.mjs';
const body = process.env.ISSUE_BODY || '';
// GitHub issue templates render fields as `### Heading\nValue` blocks.
// Split on `###` at line start to recover them.
@@ -61,29 +61,27 @@ jobs:
if (nl < 0) continue;
const heading = section.slice(0, nl).trim();
const value = section.slice(nl + 1).trim();
fields[heading] = value === '_No response_' ? '' : value;
const normalized = value === '_No response_' ? '' : value;
fields[heading] = heading === 'Error logs or screenshots'
? '[omitted from automated processing]'
: redactSensitiveText(normalized);
}
fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2));
// Convenience extractions for the prompt — empty string if missing.
const get = (k) => fields[k] || '';
fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
fs.writeFileSync('/tmp/issue-wayfern-version.txt', get('Wayfern version'));
fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce'));
fs.writeFileSync('/tmp/issue-logs.txt', get('Error logs or screenshots'));
fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?'));
fs.writeFileSync('/tmp/issue-body.txt', redactIssueBody(body));
EOF
echo "Parsed fields:"
cat /tmp/issue-fields.json
- name: Build repo context
env:
ISSUE_TITLE: ${{ github.event.issue.title }}
ISSUE_BODY: ${{ github.event.issue.body }}
run: |
cp CLAUDE.md /tmp/repo-context.txt
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
printf '%s' "$ISSUE_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/issue-title.txt
# List all source files for the AI to choose from
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
@@ -199,7 +197,7 @@ jobs:
Return ONLY valid JSON. No preamble, no code fences. Schema:
{
"language": "en" or ISO 639-1 code,
"classification": one of ["bug-in-scope", "bug-template-violation", "feature-request", "fork-request", "regression", "ai-generated-junk", "question", "other"],
"classification": one of ["bug-in-scope", "bug-template-violation", "feature-request", "fork-request", "regression", "automated-content", "question", "other"],
"operating_system": "macos" | "windows" | "linux" | "unknown",
"is_paid_feature": true | false,
"user_followed_template": true | false,
@@ -211,11 +209,11 @@ jobs:
Classification guidance:
- "bug-template-violation": missing or filled-in nonsense for required template fields.
- "ai-generated-junk": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has the polished AI-spam shape (long, structured, fabricated certainty).
- "automated-content": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has a highly structured automated-submission pattern with fabricated certainty.
- "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
- "regression": user names a prior version that worked.
File selection: pick files that an experienced reviewer would actually look at to act on this issue. If the issue is 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
} > /tmp/triage-system.txt
wc -c /tmp/triage-system.txt
@@ -238,7 +236,7 @@ jobs:
messages: [
{ role: "system", content: $system_prompt },
{ role: "user",
content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
content: ("Issue title: " + $title + "\n\nSanitized body:\n" + $body + "\n\nSanitized template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
]
}')
@@ -249,14 +247,12 @@ jobs:
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt
# Strip ```json fences if the model couldn't help itself.
# Normalize optional markdown fences before parsing.
sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json
# Validate; if the model returned junk, fall back to a minimal stub so the
# composer still gets called and produces SOMETHING.
# Fall back to a safe classification when the response is not JSON.
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
echo "::warning::Triage returned non-JSON; using fallback classification"
cat /tmp/triage-raw.txt
jq -n '{
language: "en",
classification: "bug-in-scope",
@@ -270,17 +266,16 @@ jobs:
}' > /tmp/triage.json
fi
echo "Triage result:"
cat /tmp/triage.json
echo "Triage response validated"
- name: Read files chosen by triage
run: |
: > /tmp/file-context.txt
# files_to_read may be empty (e.g. 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
filepath=$(echo "$filepath" | xargs)
[ -z "$filepath" ] && continue
# Reject paths that escape the repo or look fishy
# Reject paths that escape the repository.
case "$filepath" in
/*|*..*|*$'\n'*) continue ;;
esac
@@ -331,7 +326,7 @@ jobs:
The triage classification (`triage.classification`) determines the response shape:
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
- `bug-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.
- `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.
@@ -408,8 +403,8 @@ jobs:
+ "Title: " + $title
+ "\nAuthor: " + $author
+ "\n\n## Triage result\n" + $triage
+ "\n\n## Parsed template fields\n" + $fields
+ "\n\n## Raw issue body\n" + $body
+ "\n\n## Sanitized template fields\n" + $fields
+ "\n\n## Sanitized issue body\n" + $body
+ "\n\n## Source files (selected by triage)\n" + $files) }
]
}')
@@ -423,8 +418,6 @@ jobs:
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::error::Composer returned empty response"
echo "Raw response:"
echo "$RESPONSE"
exit 1
fi
@@ -484,8 +477,9 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
run: |
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
gh api --paginate "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
> /tmp/pr-files.txt
@@ -499,14 +493,27 @@ jobs:
cp CLAUDE.md /tmp/repo-context.txt
: > /tmp/related-file-contents.txt
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
cat "$filepath" >> /tmp/related-file-contents.txt
echo "" >> /tmp/related-file-contents.txt
gh api --paginate "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \
--jq '.[] | select(.status != "removed") | [.filename, .sha] | @tsv' |
while IFS=$'\t' read -r filepath blob_sha; do
case "$filepath" in
/*|*..*|*$'\n'*) continue ;;
esac
blob_file=$(mktemp)
if gh api "/repos/$HEAD_REPOSITORY/git/blobs/$blob_sha" --jq .content \
| tr -d '\n' | base64 --decode > "$blob_file" 2>/dev/null \
&& file --mime "$blob_file" | grep -q "text/"; then
echo "=== $filepath (head revision) ===" >> /tmp/related-file-contents.txt
cat "$blob_file" >> /tmp/related-file-contents.txt
echo "" >> /tmp/related-file-contents.txt
fi
rm -f "$blob_file"
done
head -c 100000 /tmp/related-file-contents.txt > /tmp/pr-file-context.txt
node scripts/redact-sensitive-text.mjs < /tmp/pr-diff.txt > /tmp/pr-diff.safe.txt
mv /tmp/pr-diff.safe.txt /tmp/pr-diff.txt
node scripts/redact-sensitive-text.mjs < /tmp/pr-file-context.txt > /tmp/pr-file-context.safe.txt
mv /tmp/pr-file-context.safe.txt /tmp/pr-file-context.txt
- name: Analyze PR with AI
env:
@@ -525,8 +532,8 @@ jobs:
GREETING='This is a first-time contributor. Start your comment with: "Thanks for your first PR!"'
fi
printf '%s' "$PR_TITLE" > /tmp/pr-title.txt
printf '%s' "${PR_BODY:-}" > /tmp/pr-body.txt
printf '%s' "$PR_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/pr-title.txt
printf '%s' "${PR_BODY:-}" | node scripts/redact-sensitive-text.mjs > /tmp/pr-body.txt
printf '%s' "$PR_AUTHOR" > /tmp/pr-author.txt
printf '%s' "$PR_BASE" > /tmp/pr-base.txt
printf '%s' "$PR_HEAD" > /tmp/pr-head.txt
@@ -550,7 +557,7 @@ jobs:
messages: [
{
role: "system",
content: ("You are a code review bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nContributing guidelines:\n" + $contributing + "\n\nYou have access to the full changed files and the diff. Use them to give a substantive review.\n\nReview this PR and produce a single comment. Format:\n\n1. One sentence summarizing what this PR does and whether the approach is sound.\n2. **Code review** - Specific observations about the actual code changes. Mention file names and what you see in the diff. Look for:\n - Bugs or logic errors in the changed code\n - Security issues (SQL injection, path traversal, XSS, command injection)\n - Missing error handling or edge cases\n - Breaking changes to existing APIs or behavior\n - If UI text was added/changed, check if all 7 translation files (en, es, fr, ja, pt, ru, zh) in src/i18n/locales/ were updated\n - If Tauri commands were added/removed, the unused-commands test in lib.rs needs updating\n3. **Suggestions** - Concrete improvements if any. Skip if the PR looks good.\n\nRules:\n- Be substantive. Review the actual diff, not just the description.\n- Do NOT nitpick formatting or style — the project has automated linting (biome + clippy + rustfmt).\n- Do NOT just summarize the PR description back to the user — they wrote it, they know what it says.\n- If the PR is good, say so briefly.\n- Never exceed 20 lines.")
content: ("You are a code review bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nContributing guidelines:\n" + $contributing + "\n\nYou have access to sanitized head-revision contents for changed files and a sanitized diff. Use them to give a substantive review.\n\nReview this PR and produce a single comment. Format:\n\n1. One sentence summarizing what this PR does and whether the approach is sound.\n2. **Code review** - Specific observations about the actual code changes. Mention file names and what you see in the diff. Look for:\n - Bugs or logic errors in the changed code\n - Security issues (SQL injection, path traversal, XSS, command injection)\n - Missing error handling or edge cases\n - Breaking changes to existing APIs or behavior\n - If UI text was added or changed, verify the key exists in every JSON file under src/i18n/locales/\n - If Tauri commands were added or removed, verify e2e/coverage-map.mjs is updated exactly once per command\n3. **Suggestions** - Concrete improvements if any. Skip if the PR looks good.\n\nRules:\n- Be substantive. Review the actual diff, not just the description.\n- Do NOT nitpick formatting or style — the project has automated linting (biome + clippy + rustfmt).\n- Do NOT just summarize the PR description back to the user — they wrote it, they know what it says.\n- If the PR is good, say so briefly.\n- Never exceed 20 lines.")
},
{
role: "user",
@@ -577,8 +584,6 @@ jobs:
if [ ! -s /tmp/ai-comment.txt ]; then
echo "::error::AI response was empty"
echo "Raw response:"
echo "$RESPONSE"
exit 1
fi
@@ -593,6 +598,9 @@ jobs:
if: |
github.repository == 'zhom/donutbrowser' &&
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
(github.event.comment.author_association == 'OWNER' ||
github.event.comment.author_association == 'MEMBER' ||
github.event.comment.author_association == 'COLLABORATOR') &&
(contains(github.event.comment.body, ' /oc') ||
startsWith(github.event.comment.body, '/oc') ||
contains(github.event.comment.body, ' /opencode') ||
@@ -603,7 +611,7 @@ jobs:
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
- name: Run opencode
uses: anomalyco/opencode/github@b1fc8113948b518835c2a39ece49553cffe9b30c #v1.17.18
uses: anomalyco/opencode/github@127bdb30784d508cc556c71a0f32b508a3061517 #v1.18.3
env:
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
TOKEN: ${{ secrets.GITHUB_TOKEN }}
+9 -1
View File
@@ -4,6 +4,12 @@ name: Lint Node.js
on:
workflow_call:
inputs:
checkout_ref:
description: Optional commit to check out instead of the triggering ref
required: false
type: string
default: ""
push:
branches:
- main
@@ -35,6 +41,8 @@ jobs:
- name: Checkout repository code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
ref: ${{ inputs.checkout_ref }}
- name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
@@ -42,7 +50,7 @@ jobs:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with:
node-version-file: .node-version
cache: "pnpm"
+9 -1
View File
@@ -4,6 +4,12 @@ name: Lint Rust
on:
workflow_call:
inputs:
checkout_ref:
description: Optional commit to check out instead of the triggering ref
required: false
type: string
default: ""
push:
branches:
- main
@@ -42,6 +48,8 @@ jobs:
- name: Checkout repository code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
ref: ${{ inputs.checkout_ref }}
- name: Set up pnpm package manager
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
@@ -49,7 +57,7 @@ jobs:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with:
node-version-file: .node-version
cache: "pnpm"
+11 -5
View File
@@ -15,14 +15,12 @@ jobs:
lint-js:
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions:
contents: read
lint-rust:
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions:
contents: read
@@ -41,19 +39,27 @@ jobs:
sync-e2e:
name: Sync E2E Tests
uses: ./.github/workflows/sync-e2e.yml
secrets: inherit
permissions:
contents: read
app-e2e:
name: Cross-platform App E2E
uses: ./.github/workflows/app-e2e.yml
secrets:
TAURI_WEBDRIVER_TOKEN: ${{ secrets.TAURI_WEBDRIVER_TOKEN }}
WAYFERN_TEST_TOKEN: ${{ secrets.WAYFERN_TEST_TOKEN }}
permissions:
contents: read
pr-status:
name: PR Status Check
runs-on: ubuntu-latest
needs: [lint-js, lint-rust, security-scan, sync-e2e]
needs: [lint-js, lint-rust, security-scan, sync-e2e, app-e2e]
if: always()
steps:
- name: Check all jobs succeeded
run: |
if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" ]]; then
if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" || "${{ needs.app-e2e.result }}" != "success" ]]; then
echo "One or more checks failed"
exit 1
fi
+15 -15
View File
@@ -31,17 +31,24 @@ jobs:
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
INPUT_TAG: ${{ inputs.tag }}
EVENT_NAME: ${{ github.event_name }}
WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
REPOSITORY: ${{ github.repository }}
run: |
if [[ -n "${INPUT_TAG:-}" ]]; then
echo "tag=${INPUT_TAG}" >> "$GITHUB_OUTPUT"
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
TAG="$INPUT_TAG"
elif [[ "$EVENT_NAME" == "workflow_run" ]]; then
# The Release workflow is triggered by a tag push (v*),
# so head_branch is the tag name
echo "tag=${{ github.event.workflow_run.head_branch }}" >> "$GITHUB_OUTPUT"
TAG="$WORKFLOW_HEAD_BRANCH"
else
TAG=$(gh release view --repo "${{ github.repository }}" --json tagName -q .tagName)
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
TAG=$(gh release view --repo "$REPOSITORY" --json tagName -q .tagName)
fi
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "Invalid release tag" >&2
exit 1
fi
printf 'tag=%s\n' "$TAG" >> "$GITHUB_OUTPUT"
- name: Install tools
run: |
@@ -59,19 +66,12 @@ jobs:
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
run: |
# GitHub injects secrets verbatim. If a value was pasted with
# surrounding quotes or a trailing newline — the local .env wraps all
# four R2_* values in double quotes — it reaches the script malformed:
# e.g. an endpoint of https://"host" yields
# `Could not connect to the endpoint URL`, and a quoted key yields
# `Unauthorized`. The local run is unaffected because publish-repo.sh
# sources .env through bash, which strips the quotes; CI has no .env,
# so strip here. No-op when the secrets are already clean. The script
# itself is intentionally left untouched.
# Normalize accidental quotes and whitespace in configured secrets.
strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; }
export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
bash scripts/publish-repo.sh "$RELEASE_TAG"
+10 -7
View File
@@ -37,7 +37,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser'
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions:
contents: read
@@ -45,7 +44,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser'
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions:
contents: read
@@ -53,7 +51,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser'
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
permissions:
security-events: write
contents: read
@@ -64,7 +61,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser'
name: Spell Check
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
permissions:
contents: read
@@ -113,7 +109,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with:
node-version-file: .node-version
cache: "pnpm"
@@ -160,6 +156,8 @@ jobs:
- name: Build sidecar binaries
shell: bash
working-directory: ./src-tauri
env:
GITHUB_REF_NAME: ${{ github.ref_name }}
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
@@ -216,6 +214,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TARGET: ${{ matrix.target }}
# tauri-action invokes `pnpm tauri build`, which runs
# `beforeBuildCommand` from tauri.conf.json. That rebuilds the
# frontend in its own subprocess, so the env var MUST be forwarded
@@ -557,7 +556,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger Cloudflare Pages deployment
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
env:
DEPLOYMENT_HOOK: ${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}
run: curl -fsSL -X POST "$DEPLOYMENT_HOOK"
docker:
if: github.repository == 'zhom/donutbrowser'
@@ -565,7 +566,9 @@ jobs:
uses: ./.github/workflows/docker-sync.yml
with:
tag: ${{ github.ref_name }}
secrets: inherit
secrets:
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
update-flake:
if: github.repository == 'zhom/donutbrowser'
+21 -19
View File
@@ -44,7 +44,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser'
name: Lint JavaScript/TypeScript
uses: ./.github/workflows/lint-js.yml
secrets: inherit
permissions:
contents: read
@@ -52,7 +51,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser'
name: Lint Rust
uses: ./.github/workflows/lint-rs.yml
secrets: inherit
permissions:
contents: read
@@ -60,7 +58,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser'
name: CodeQL
uses: ./.github/workflows/codeql.yml
secrets: inherit
permissions:
security-events: write
contents: read
@@ -71,7 +68,6 @@ jobs:
if: github.repository == 'zhom/donutbrowser'
name: Spell Check
uses: ./.github/workflows/spellcheck.yml
secrets: inherit
permissions:
contents: read
@@ -120,7 +116,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
with:
node-version-file: .node-version
cache: "pnpm"
@@ -164,9 +160,25 @@ jobs:
echo "Checking from src-tauri perspective:"
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"
- name: Generate nightly timestamp
id: timestamp
shell: bash
run: |
# Committer date, not wall clock: every job in this run (including
# update-nightly-release, which runs much later) must derive the
# exact same tag, or a run straddling midnight UTC splits the
# release from its checksums.
TIMESTAMP=$(git show -s --format=%cs HEAD)
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
- name: Build sidecar binaries
shell: bash
working-directory: ./src-tauri
env:
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
run: |
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
@@ -214,19 +226,6 @@ jobs:
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
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f #v1.0.0
env:
@@ -238,6 +237,7 @@ jobs:
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
TARGET: ${{ matrix.target }}
# tauri-action's inner `pnpm tauri build` re-runs beforeBuildCommand
# which rebuilds dist/ in a subprocess. The env var must be here too.
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
@@ -416,7 +416,9 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Trigger Cloudflare Pages deployment
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
env:
DEPLOYMENT_HOOK: ${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}
run: curl -fsSL -X POST "$DEPLOYMENT_HOOK"
notify-discord:
if: github.repository == 'zhom/donutbrowser'
+8
View File
@@ -5,6 +5,12 @@ permissions:
on:
workflow_call:
inputs:
checkout_ref:
description: Optional commit to check out instead of the triggering ref
required: false
type: string
default: ""
push:
branches: ["main"]
pull_request:
@@ -22,5 +28,7 @@ jobs:
steps:
- name: Checkout Actions Repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
with:
ref: ${{ inputs.checkout_ref }}
- name: Spell Check Repo
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 #v1.48.0
+4 -4
View File
@@ -32,7 +32,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.0
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
@@ -40,7 +40,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
cache: "pnpm"
@@ -73,7 +73,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@v7.0.0
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Start MinIO
run: |
@@ -99,7 +99,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@v6
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: "22"
cache: "pnpm"
+1
View File
@@ -11,6 +11,7 @@
# testing
/coverage
/e2e/app/target/
# next.js
/.next/
+36 -2
View File
@@ -1,6 +1,6 @@
# ⛔ ABSOLUTE GIT RULE — READ FIRST (2026-06-11)
**NEVER run any git command that modifies git history OR the working tree, in ANY repo** (wayfern, wayfern-macos, wayfern-test, donutbrowser, build/src), **unless the user EXPLICITLY authorizes that exact command.** Forbidden without per-command authorization: `commit`, `revert`, `cherry-pick`, `restore`, `checkout` (files/branches), `reset`, `rebase`, `merge`, `stash`, `clean`, `apply`, `add`, `rm`, `push`, any force op. Only read-only git (`status`, `log`, `show`, `diff`, `ls-files`, `rev-parse`) is allowed without asking. **Authorization is per-command: 1 explicit authorization = exactly 1 command.** If a git mutation seems needed, STOP and ask for that one command.
**NEVER run any git command that modifies git history OR the working tree, in ANY repo**, **unless the user EXPLICITLY authorizes that exact command.** Forbidden without per-command authorization: `commit`, `revert`, `cherry-pick`, `restore`, `checkout` (files/branches), `reset`, `rebase`, `merge`, `stash`, `clean`, `apply`, `add`, `rm`, `push`, any force op. Only read-only git (`status`, `log`, `show`, `diff`, `ls-files`, `rev-parse`) is allowed without asking. **Authorization is per-command: 1 explicit authorization = exactly 1 command.** If a git mutation seems needed, STOP and ask for that one command.
---
@@ -51,7 +51,10 @@ donutbrowser/
│ └── Cargo.toml # Rust dependencies
├── donut-sync/ # NestJS sync server (self-hostable)
│ └── src/ # Controllers, services, auth, S3 sync
├── docs/ # Documentation (self-hosting guide)
├── e2e/ # Isolated native UI/sync/Wayfern E2E system
│ ├── app/ # Test-only Tauri harness that injects the private driver
│ ├── lib/ # WebDriver, CDP, fixtures, app-session helpers
│ └── tests/ # Smoke, UI, entity, integration, sync, browser suites
├── flake.nix # Nix development environment
└── .github/workflows/ # CI/CD pipelines
```
@@ -64,6 +67,37 @@ donutbrowser/
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
### Native app E2E tests are mandatory for affected behavior
The native suites use a sibling private test-driver repository and launch an `e2e`-feature build.
Every session gets its own temporary Donut data/cache/log root, home directory,
WebView store, ports, and sync bucket. Never point a suite at production or development data.
After a behavior change, run the smallest affected subset below in addition to the standard
format/lint/unit-test command. A code change is not considered verified until its affected native
suite passes:
| Changed area | Required command |
| --- | --- |
| Startup, settings, persistence, window state, shortcuts, navigation | `pnpm e2e:smoke` |
| React components, dialogs, themes/appearance, responsive layout, accessibility, onboarding | `pnpm e2e:ui` |
| Profile/import/group/proxy/VPN/extension CRUD, DNS, cookies, passwords, traffic | `pnpm e2e:entities` |
| Profile/group/proxy/VPN/extension UI, proxy routing, VPN routing, or their browser-launch integration | `pnpm e2e:network` |
| REST API/OpenAPI, MCP, cloud/update contracts, team locks, real-time synchronizer | `pnpm e2e:integrations` |
| Sync client/server, manifests, timestamps, deletion, encryption, password rollover | `pnpm e2e:sync` |
| Wayfern download/terms/fingerprint, browser runner, CDP, automation endpoints, process cleanup | `pnpm e2e:browser` |
| E2E harness, WebDriver plugin/driver, app isolation hooks, or changes spanning multiple rows | Run every affected row; use `pnpm e2e` for cross-cutting changes |
`e2e:browser` requires `WAYFERN_TEST_TOKEN` in the environment or local `.env`. `e2e:network`
and the full suite additionally require Docker plus `RESIDENTIAL_PROXY_URL_ONE_HTTP` and
`RESIDENTIAL_PROXY_URL_ONE_SOCKS`. Other individual suites must run without credentials. Use
`--no-build` only when the frontend, Rust app, sidecar, and WebDriver binaries are already current.
Keep failed artifacts and inspect the per-session app/driver logs and screenshot before changing
assertions.
When adding a Tauri command, assign it exactly once in `e2e/coverage-map.mjs` and add executable
evidence to the owning suite. `e2e:smoke` fails if command registration and the coverage map drift.
## Logs (when debugging a running app)
Three log surfaces, in order of usefulness:
+7
View File
@@ -186,6 +186,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
<sub><b>Thiago Mafra</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/mchnkkc">
<img src="https://avatars.githubusercontent.com/u/251900355?v=4" width="100;" alt="mchnkkc"/>
<br />
<sub><b>mchnkkc</b></sub>
</a>
</td>
<td align="center">
<a href="https://github.com/liasica">
<img src="https://avatars.githubusercontent.com/u/671431?v=4" width="100;" alt="liasica"/>
+10 -1
View File
@@ -329,7 +329,16 @@ export class SyncService implements OnModuleInit {
Metadata: metadata,
});
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
const metadataHeaders = new Set(
Object.keys(metadata ?? {}).map((name) => `x-amz-meta-${name}`),
);
const url = await getSignedUrl(this.s3Client, command, {
expiresIn,
// The AWS presigner otherwise hoists user metadata into the query string.
// The client echoes the response metadata as headers, so those headers
// must remain in the request and be covered by SignedHeaders.
unhoistableHeaders: metadataHeaders,
});
// Report profile usage after upload presign if key is under profiles/
if (ctx.mode === "cloud" && dto.key.startsWith("profiles/")) {
+61
View File
@@ -17,6 +17,7 @@ import {
interface PresignResponse {
url: string;
expiresAt: string;
metadata?: Record<string, string>;
}
interface ListResponse {
@@ -34,6 +35,7 @@ interface StatResponse {
exists: boolean;
size?: number;
lastModified?: string;
metadata?: Record<string, string>;
}
describe("SyncController (e2e)", () => {
@@ -112,6 +114,65 @@ describe("SyncController (e2e)", () => {
expect(body.url).toContain("test/upload-key.txt");
expect(body.expiresAt).toBeDefined();
});
it("should sign and persist echoed object metadata", async () => {
const testKey = `vpns/metadata-${Date.now()}.json`;
const updatedAt = Math.floor(Date.now() / 1000).toString();
try {
const response = await request(app.getHttpServer())
.post("/v1/objects/presign-upload")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({
key: testKey,
contentType: "application/json",
metadata: {
"updated-at": updatedAt,
ignored: "not-allowed",
},
})
.expect(200);
const body = response.body as PresignResponse;
expect(body.metadata).toEqual({ "updated-at": updatedAt });
const uploadUrl = new URL(body.url);
const signedHeaders =
uploadUrl.searchParams.get("X-Amz-SignedHeaders")?.split(";") ?? [];
expect(signedHeaders).toContain("x-amz-meta-updated-at");
expect(uploadUrl.searchParams.has("x-amz-meta-updated-at")).toBe(false);
const uploadResult = await fetch(body.url, {
method: "PUT",
body: "{}",
headers: {
"Content-Type": "application/json",
"x-amz-meta-updated-at": updatedAt,
},
});
if (!uploadResult.ok) {
throw new Error(
`Metadata upload failed with status ${uploadResult.status}: ${await uploadResult.text()}`,
);
}
const statResponse = await request(app.getHttpServer())
.post("/v1/objects/stat")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey })
.expect(200);
const statBody = statResponse.body as StatResponse;
expect(statBody.exists).toBe(true);
expect(statBody.metadata?.["updated-at"]).toBe(updatedAt);
} finally {
await request(app.getHttpServer())
.post("/v1/objects/delete")
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
.send({ key: testKey })
.expect(200);
}
});
});
describe("POST /v1/objects/presign-download", () => {
+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" },
);
});
});
+11 -3
View File
@@ -12,22 +12,30 @@
"test:rust": "cd src-tauri && cargo test",
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
"e2e": "node e2e/run.mjs --suite=full",
"e2e:smoke": "node e2e/run.mjs --suite=smoke",
"e2e:ui": "node e2e/run.mjs --suite=ui",
"e2e:entities": "node e2e/run.mjs --suite=entities",
"e2e:network": "node e2e/run.mjs --suite=network",
"e2e:integrations": "node e2e/run.mjs --suite=integrations",
"e2e:sync": "node e2e/run.mjs --suite=sync",
"e2e:browser": "node e2e/run.mjs --suite=browser",
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint:js": "biome check src/ e2e/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"lint:spell": "typos .",
"tauri": "node scripts/run-with-env.mjs tauri",
"shadcn:add": "pnpm dlx shadcn@latest add",
"prepare": "husky && husky install",
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
"format:js": "biome check src/ --write --unsafe && cd donut-sync && biome check src/ --write --unsafe",
"format:js": "biome check src/ e2e/ --write --unsafe && cd donut-sync && biome check src/ --write --unsafe",
"format": "pnpm format:js && pnpm format:rust",
"build:sync": "cd donut-sync && pnpm build",
"cargo": "cd src-tauri && cargo",
"unused-exports:js": "ts-unused-exports tsconfig.json",
"check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands",
"copy-proxy-binary": "node src-tauri/copy-proxy-binary.mjs",
"prebuild": "pnpm copy-proxy-binary",
"copy-proxy-binary:release": "node src-tauri/copy-proxy-binary.mjs --release",
"pretauri:dev": "pnpm copy-proxy-binary",
"precargo": "pnpm copy-proxy-binary"
},
+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();
}
+8 -18
View File
@@ -1836,7 +1836,7 @@ dependencies = [
"objc2",
"objc2-app-kit",
"once_cell",
"quick-xml 0.41.0",
"quick-xml",
"rand 0.10.2",
"regex-lite",
"reqwest",
@@ -4686,7 +4686,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
dependencies = [
"base64 0.22.1",
"indexmap 2.14.0",
"quick-xml 0.41.0",
"quick-xml",
"serde",
"time",
]
@@ -4941,15 +4941,6 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "quick-xml"
version = "0.41.0"
@@ -7032,9 +7023,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tokio"
version = "1.52.3"
version = "1.53.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
dependencies = [
"bytes",
"libc",
@@ -7694,9 +7685,9 @@ dependencies = [
[[package]]
name = "uuid"
version = "1.23.4"
version = "1.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -7914,11 +7905,10 @@ dependencies = [
[[package]]
name = "wayland-scanner"
version = "0.31.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
source = "git+https://github.com/Smithay/wayland-rs?rev=d07c4f91f28b42e5a485823ffd9d8d5a210b1053#d07c4f91f28b42e5a485823ffd9d8d5a210b1053"
dependencies = [
"proc-macro2",
"quick-xml 0.39.4",
"quick-xml",
"quote",
]
+8 -1
View File
@@ -31,7 +31,7 @@ resvg = "0.47"
[dependencies]
serde_json = "1"
serde = { version = "1", features = ["derive"] }
tauri = { version = "2", features = ["devtools", "test", "tray-icon", "image-png"] }
tauri = { version = "2", features = ["tray-icon", "image-png"] }
tauri-plugin-opener = "2"
tauri-plugin-fs = "2"
tauri-plugin-shell = "2"
@@ -186,3 +186,10 @@ default = ["custom-protocol"]
# this feature is used used for production builds where `devPath` points to the filesystem
# DO NOT remove this
custom-protocol = ["tauri/custom-protocol"]
e2e = []
# wayland-scanner 0.31.10 still pins vulnerable quick-xml 0.39. Upstream fixed
# RUSTSEC-2026-0194 and RUSTSEC-2026-0195, but has not published the fix yet.
# Remove this patch after the next wayland-scanner release is in the lockfile.
[patch.crates-io]
wayland-scanner = { git = "https://github.com/Smithay/wayland-rs", rev = "d07c4f91f28b42e5a485823ffd9d8d5a210b1053" }
+2 -20
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>
<key>NSLocalNetworkUsageDescription</key>
<string>Donut has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
<key>CFBundleDisplayName</key>
<string>Donut</string>
<key>CFBundleName</key>
<string>Donut</string>
<key>CFBundleIdentifier</key>
<string>com.donutbrowser</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleURLName</key>
<string>com.donutbrowser</string>
<key>CFBundleExecutable</key>
<string>donutbrowser</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleIconFile</key>
<string>icon.icns</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.productivity</string>
<key>NSHumanReadableCopyright</key>
<string>Copyright © 2025 Donut</string>
<string>Copyright © 2026 Donut</string>
<key>CFBundleDocumentTypes</key>
<array>
<dict>
@@ -57,4 +39,4 @@
</dict>
</array>
</dict>
</plist>
</plist>
+9 -22
View File
@@ -5,25 +5,24 @@
"windows": ["main"],
"webviews": ["main"],
"permissions": [
"core:default",
"core:event:allow-listen",
"core:event:allow-emit",
"core:event:allow-emit-to",
"core:event:allow-unlisten",
"core:image:default",
"core:menu:default",
"core:path:default",
"core:tray:default",
"core:webview:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
"core:window:allow-is-maximized",
"core:window:allow-minimize",
"core:window:allow-toggle-maximize",
"opener:default",
{
"identifier": "opener:allow-open-url",
"allow": [
{
"url": "https://*"
},
{
"url": "http://*"
},
{
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
},
@@ -32,28 +31,16 @@
}
]
},
"fs:default",
"shell:allow-execute",
"shell:allow-kill",
"shell:allow-open",
"shell:allow-spawn",
"shell:allow-stdin-write",
"deep-link:default",
"deep-link:allow-register",
"deep-link:allow-unregister",
"deep-link:allow-is-registered",
"fs:allow-read-text-file",
"fs:allow-write-text-file",
"deep-link:allow-get-current",
"dialog:default",
"dialog:allow-open",
"dialog:allow-save",
"fs:allow-write-text-file",
"macos-permissions:default",
"macos-permissions:allow-request-microphone-permission",
"macos-permissions:allow-request-camera-permission",
"macos-permissions:allow-check-microphone-permission",
"macos-permissions:allow-check-camera-permission",
"log:default",
"clipboard-manager:default",
"clipboard-manager:allow-write-text"
]
}
+19 -26
View File
@@ -4,7 +4,10 @@ import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const MANIFEST_DIR = dirname(fileURLToPath(import.meta.url));
const PROFILE = process.env.PROFILE || "debug";
const PROFILE =
process.argv.includes("--release") || process.env.PROFILE === "release"
? "release"
: "debug";
function getTarget() {
if (process.env.TARGET) return process.env.TARGET;
@@ -48,32 +51,22 @@ function copyBinary(baseName) {
if (isWindows) destName += ".exe";
const dest = join(destDir, destName);
if (existsSync(source)) {
copyFileSync(source, dest);
console.log(`Copied ${binName} to ${dest}`);
} else {
console.log(`Warning: Binary not found at ${source}`);
console.log(`Building ${baseName} binary...`);
const buildArgs = ["build", "--bin", baseName];
if (PROFILE === "release") buildArgs.push("--release");
if (TARGET !== "unknown" && TARGET !== HOST_TARGET) {
buildArgs.push("--target", TARGET);
}
execFileSync("cargo", buildArgs, {
cwd: MANIFEST_DIR,
stdio: "inherit",
});
if (existsSync(source)) {
copyFileSync(source, dest);
console.log(`Built and copied ${binName} to ${dest}`);
} else {
console.error(`Error: Failed to build ${baseName} binary`);
process.exit(1);
}
const buildArgs = ["build", "--bin", baseName];
if (PROFILE === "release") buildArgs.push("--release");
if (TARGET !== "unknown" && TARGET !== HOST_TARGET) {
buildArgs.push("--target", TARGET);
}
execFileSync("cargo", buildArgs, {
cwd: MANIFEST_DIR,
stdio: "inherit",
});
if (!existsSync(source)) {
console.error(`Error: Failed to build ${baseName} binary`);
process.exit(1);
}
copyFileSync(source, dest);
console.log(`Built and copied ${binName} to ${dest}`);
}
copyBinary("donut-proxy");
+1 -29
View File
@@ -2,37 +2,9 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<true/>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.files.downloads.read-write</key>
<true/>
<key>com.apple.security.device.camera</key>
<true/>
<key>com.apple.security.device.audio-output</key>
<true/>
<key>com.apple.security.device.microphone</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
<key>com.apple.security.device.usb</key>
<true/>
<key>com.apple.security.inherit</key>
<true/>
</dict>
</plist>
</plist>
+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
+131 -2
View File
@@ -967,7 +967,7 @@ impl AppAutoUpdater {
// rejected before the multi-hundred-MB download, not after.
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
.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
async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
#[cfg(target_os = "macos")]
@@ -1764,6 +1853,11 @@ rm "{}"
let pending = PENDING_INSTALLER_PATH.lock().unwrap().take();
if let Some(installer_path) = pending {
if let Err(e) = Self::prepare_windows_installer().await {
*PENDING_INSTALLER_PATH.lock().unwrap() = Some(installer_path);
return Err(e);
}
// Use ShellExecuteW to run the installer directly — no batch script,
// no cmd.exe console window. The NSIS/MSI installer handles killing the
// old process and restarting the app natively (via /UPDATE and
@@ -1943,6 +2037,14 @@ rm "{}"
#[tauri::command]
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping automatic app update check");
return Ok(None);
}
if crate::app_dirs::is_portable() {
log::info!("App auto-updates disabled in portable mode");
return Ok(None);
@@ -1991,11 +2093,19 @@ pub async fn restart_application() -> Result<(), String> {
updater
.restart_application()
.await
.map_err(|e| format!("Failed to restart application: {e}"))
.map_err(|e| crate::wrap_backend_error(e, "Failed to restart application"))
}
#[tauri::command]
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
#[cfg(feature = "e2e")]
if crate::e2e_automation_enabled()
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
{
log::info!("E2E: skipping manual app update check");
return Ok(None);
}
log::info!("Manual app update check triggered");
let updater = AppAutoUpdater::instance();
updater
@@ -2197,6 +2307,25 @@ not-a-hash Donut_0.29.0_amd64.deb
assert_eq!(with.digest.as_deref(), Some("sha256:ab12"));
}
#[test]
fn test_windows_installer_hook_protects_sidecar_replacement() {
let _ = AppAutoUpdater::prepare_windows_installer;
let config: serde_json::Value =
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
assert_eq!(
config["bundle"]["windows"]["nsis"]["installerHooks"].as_str(),
Some("installer-hooks.nsh")
);
let hooks = include_str!("../installer-hooks.nsh");
assert!(hooks.contains("NSIS_HOOK_PREINSTALL"));
assert!(hooks.contains("IfFileExists \"$INSTDIR\\donut-proxy.exe\""));
assert!(hooks.contains("taskkill.exe"));
assert!(hooks.contains("donut-proxy.exe"));
assert!(hooks.contains("Delete \"$INSTDIR\\donut-proxy.exe\""));
}
#[test]
fn test_platform_specific_download_urls() {
let updater = AppAutoUpdater::instance();
+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)]
mod tests {
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]
fn test_data_dir_returns_path() {
let dir = data_dir();
+15 -35
View File
@@ -2,8 +2,8 @@ use clap::{Arg, Command};
use donutbrowser_lib::proxy_runner::{
start_proxy_process_with_profile, stop_all_proxy_processes, stop_proxy_process,
};
use donutbrowser_lib::proxy_server::run_proxy_server;
use donutbrowser_lib::proxy_storage::get_proxy_config;
use donutbrowser_lib::proxy_server::{redacted_upstream, run_proxy_server};
use donutbrowser_lib::proxy_storage::{build_proxy_url, get_proxy_config};
use std::process;
fn set_high_priority() {
@@ -55,31 +55,6 @@ fn set_high_priority() {
}
}
fn build_proxy_url(
proxy_type: &str,
host: &str,
port: u16,
username: Option<&str>,
password: Option<&str>,
) -> String {
let mut url = format!("{}://", proxy_type.to_lowercase());
if let (Some(user), Some(pass)) = (username, password) {
let encoded_user = urlencoding::encode(user);
let encoded_pass = urlencoding::encode(pass);
url.push_str(&format!("{}:{}@", encoded_user, encoded_pass));
} else if let Some(user) = username {
let encoded_user = urlencoding::encode(user);
url.push_str(&format!("{}@", encoded_user));
}
url.push_str(host);
url.push(':');
url.push_str(&port.to_string());
url
}
#[tokio::main(flavor = "multi_thread")]
async fn main() {
// Initialize logger to write to stderr (which will be redirected to file).
@@ -110,6 +85,7 @@ async fn main() {
}));
let matches = Command::new("donut-proxy")
.version(env!("BUILD_VERSION"))
.subcommand(
Command::new("proxy")
.about("Manage proxy servers")
@@ -128,8 +104,6 @@ async fn main() {
.long("type")
.help("Proxy type (http, https, socks4, socks5, ss)"),
)
.arg(Arg::new("username").long("username").help("Proxy username"))
.arg(Arg::new("password").long("password").help("Proxy password"))
.arg(
Arg::new("port")
.short('p')
@@ -242,16 +216,22 @@ async fn main() {
start_matches.get_one::<u16>("proxy-port"),
start_matches.get_one::<String>("type"),
) {
let username = start_matches.get_one::<String>("username");
let password = start_matches.get_one::<String>("password");
let username = std::env::var("DONUT_PROXY_USERNAME").ok();
let password = std::env::var("DONUT_PROXY_PASSWORD").ok();
upstream_url = Some(build_proxy_url(
proxy_type,
host,
*port,
username.map(|s| s.as_str()),
password.map(|s| s.as_str()),
username.as_deref(),
password.as_deref(),
));
} else if let Some(upstream) = start_matches.get_one::<String>("upstream") {
if url::Url::parse(upstream)
.is_ok_and(|parsed| !parsed.username().is_empty() || parsed.password().is_some())
{
eprintln!("Credentialed upstream URLs are not accepted as process arguments");
process::exit(2);
}
upstream_url = Some(upstream.clone());
}
@@ -285,7 +265,7 @@ async fn main() {
"id": config.id,
"localPort": config.local_port,
"localUrl": config.local_url,
"upstreamUrl": config.upstream_url,
"upstreamUrl": redacted_upstream(&config.upstream_url),
})
);
process::exit(0);
@@ -379,7 +359,7 @@ async fn main() {
"Found config: id={}, port={:?}, upstream={}",
config.id,
config.local_port,
config.upstream_url
redacted_upstream(&config.upstream_url)
);
break config;
}
+30 -22
View File
@@ -115,10 +115,9 @@ impl BrowserRunner {
}
let url = parsed.to_string();
let profile_name = profile.name.clone();
let profile_id = profile.id.to_string();
let url_label = crate::log_redaction::url_label(&url);
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 {
let client = match reqwest::Client::builder()
@@ -127,20 +126,23 @@ impl BrowserRunner {
{
Ok(c) => c,
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;
}
};
match client.get(&url).send().await {
Ok(resp) => {
log::info!(
"Launch hook {url} for profile {profile_name} returned status {}",
resp.status()
);
log::info!("Launch hook {url_label} returned status {}", resp.status());
}
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())
);
}
}
});
@@ -274,7 +276,7 @@ impl BrowserRunner {
)
.await
.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);
error_msg
})?;
@@ -675,18 +677,18 @@ impl BrowserRunner {
.unwrap_or_else(|| updated_profile.clone());
log::info!(
"Browser status check - Profile: {} (ID: {}), Running: {}, URL: {:?}, PID: {:?}",
final_profile.name,
final_profile.id,
is_running,
url,
final_profile.process_id
"Browser status check: running={is_running}, URL requested={}, PID present={}",
url.is_some(),
final_profile.process_id.is_some()
);
if is_running && url.is_some() {
// Browser is running and we have a URL to open
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
.open_url_in_existing_browser(
@@ -702,7 +704,10 @@ impl BrowserRunner {
Ok(final_profile)
}
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
log::info!(
@@ -1163,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
self
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
.await
.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}")
})?;
log::info!("Successfully opened URL '{url}' with profile '{profile_id}'");
log::info!("Successfully opened URL with selected profile");
Ok(())
}
}
@@ -1297,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);
}
}
format!("Failed to launch browser or open URL: {e}")
crate::wrap_backend_error(e, "Failed to launch browser or open URL")
})?;
log::info!(
+32 -19
View File
@@ -609,9 +609,8 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
log::warn!("Token refresh failed ({status}): {body}");
return Err(format!("Token refresh failed ({status}): {body}"));
log::warn!("Token refresh failed ({status})");
return Err(format!("Token refresh failed ({status})"));
}
let result: RefreshTokenResponse = response
@@ -779,6 +778,13 @@ impl CloudAuthManager {
/// Launch/drive profiles programmatically (local API + MCP automation).
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
.entitlements()
.await
@@ -788,6 +794,13 @@ impl CloudAuthManager {
/// Edit fingerprints / use a non-native OS fingerprint.
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
.entitlements()
.await
@@ -909,14 +922,12 @@ impl CloudAuthManager {
let status = response.status();
if status == reqwest::StatusCode::FORBIDDEN {
let body = response.text().await.unwrap_or_default();
log::warn!("Proxy config returned 403: {body}");
log::warn!("Proxy config returned 403");
return Err("__403__".to_string());
}
if !response.status().is_success() {
let body = response.text().await.unwrap_or_default();
return Err(format!("Proxy config fetch failed ({status}): {body}"));
return Err(format!("Proxy config fetch failed ({status})"));
}
response
@@ -1178,8 +1189,7 @@ impl CloudAuthManager {
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Wayfern token request failed ({status}): {body}"));
return Err(format!("Wayfern token request failed ({status})"));
}
let result: WayfernTokenResponse = response
@@ -1195,17 +1205,10 @@ impl CloudAuthManager {
let token = match result {
Ok(token) => token,
Err(e) => {
// The backend returns 403 (ForbiddenException) for paid-feature blocks:
// token-reuse throttle, "active subscription required", and the
// 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.
// A 403 rejects the entitlement without invalidating the login session.
// Clear the browser token and refresh account state before notifying UI.
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;
if let Err(fetch_err) = self.fetch_profile().await {
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
@@ -1224,6 +1227,16 @@ impl CloudAuthManager {
/// Get the current wayfern token, if any.
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;
wt.clone()
}
+28 -41
View File
@@ -1057,6 +1057,23 @@ impl CookieManager {
mod tests {
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]
fn test_parse_netscape_cookies_valid() {
let content = "# Netscape HTTP Cookie File\n\
@@ -1482,50 +1499,29 @@ mod tests {
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]
#[cfg(target_os = "macos")]
fn test_decrypt_v10_cookie_with_real_vector() {
fn test_decrypt_v10_cookie_with_synthetic_vector() {
let profile_dir =
std::env::temp_dir().join(format!("donut_decrypt_vector_{}", uuid::Uuid::new_v4()));
std::fs::create_dir_all(&profile_dir).unwrap();
std::fs::write(
profile_dir.join("os_crypt_key"),
b"OSfgzI5GUqy/pK4ANrYugw==",
SYNTHETIC_OS_CRYPT_PASSWORD,
)
.unwrap();
let key = chrome_decrypt::get_encryption_key(&profile_dir)
.expect("should derive key from os_crypt_key file");
let encrypted_hex = "76313077ad5b27e78f685a6ccc7b92a8a242e279e54b8d2ba8e55b433ca7e2421bec52369e29a57b593c02c839f50962245da3ed8617dce142fff67778950a271d2c07";
let encrypted: Vec<u8> = (0..encrypted_hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&encrypted_hex[i..i + 2], 16).unwrap())
.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 decrypted =
chrome_decrypt::decrypt(&synthetic_encrypted_cookie(), SYNTHETIC_COOKIE_HOST, &key)
.expect("decryption must succeed with correct key and host");
assert_eq!(decrypted, SYNTHETIC_COOKIE_VALUE);
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]
#[cfg(target_os = "macos")]
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::write(
profile_dir.join("os_crypt_key"),
b"OSfgzI5GUqy/pK4ANrYugw==",
SYNTHETIC_OS_CRYPT_PASSWORD,
)
.unwrap();
let key = chrome_decrypt::get_encryption_key(&profile_dir).unwrap();
let encrypted_hex = "76313077ad5b27e78f685a6ccc7b92a8a242e279e54b8d2ba8e55b433ca7e2421bec52369e29a57b593c02c839f50962245da3ed8617dce142fff67778950a271d2c07";
let encrypted: Vec<u8> = (0..encrypted_hex.len())
.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);
let result =
chrome_decrypt::decrypt(&synthetic_encrypted_cookie(), ".wrong.example.test", &key);
assert!(
result.as_deref() != Some("GH1.1.2077424036.1774792325"),
"decrypt must not return the real cookie value when host_key is wrong"
result.as_deref() != Some(SYNTHETIC_COOKIE_VALUE),
"decrypt must not return the cookie value when host_key is wrong"
);
let _ = std::fs::remove_dir_all(&profile_dir);
+16 -2
View File
@@ -295,9 +295,23 @@ impl BlocklistManager {
}
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
let url = level
let production_url = level
.url()
.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 =
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
@@ -311,7 +325,7 @@ impl BlocklistManager {
);
let response = HTTP_CLIENT
.get(url)
.get(&url)
.send()
.await
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
@@ -1261,6 +1261,14 @@ mod tests {
pub async fn ensure_active_browsers_downloaded(
app_handle: tauri::AppHandle,
) -> 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 version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
let mut downloaded = Vec::new();
@@ -1410,6 +1418,14 @@ pub async fn check_missing_binaries() -> Result<Vec<(String, String, String)>, S
pub async fn ensure_all_binaries_exist(
app_handle: tauri::AppHandle,
) -> 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();
registry
.ensure_all_binaries_exist(&app_handle)
+1 -1
View File
@@ -231,7 +231,7 @@ impl Downloader {
let download_url = self
.resolve_download_url(browser_type.clone(), version, download_info)
.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
// drop mid-stream. Rather than surfacing the first stall/chunk error as a
+15 -6
View File
@@ -141,13 +141,22 @@ impl GeoIPDownloader {
},
);
// Fetch latest release from GitHub
let releases = self.fetch_geoip_releases().await?;
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
#[cfg(feature = "e2e")]
let fixture_url = std::env::var("DONUT_E2E_GEOIP_DOWNLOAD_URL")
.ok()
.filter(|url| !url.is_empty());
#[cfg(not(feature = "e2e"))]
let fixture_url: Option<String> = None;
let download_url = self
.find_city_mmdb_asset(latest_release)
.ok_or("No compatible GeoIP database asset found")?;
let download_url = if let Some(url) = fixture_url {
url
} 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
let cache_dir = Self::get_cache_dir();
+222 -191
View File
@@ -3,6 +3,7 @@ use std::env;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
#[cfg(not(feature = "e2e"))]
use tauri_plugin_deep_link::DeepLinkExt;
use tauri_plugin_log::{Target, TargetKind};
@@ -14,6 +15,25 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
// to the confirmation dialog.
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_server;
mod app_auto_updater;
@@ -35,6 +55,7 @@ mod geolocation;
mod group_manager;
mod human_typing;
mod ip_utils;
mod log_redaction;
mod platform_browser;
mod profile;
mod profile_importer;
@@ -227,7 +248,7 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
// Called internally for deep-link / startup URL handling — not invoked from the
// frontend, so it is intentionally not a `#[tauri::command]`.
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
if let Some(window) = app.get_webview_window("main") {
@@ -1263,6 +1284,7 @@ fn hide_to_tray(app_handle: tauri::AppHandle) -> Result<(), String> {
Ok(())
}
#[cfg(not(feature = "e2e"))]
fn show_main_window(app_handle: &tauri::AppHandle) {
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
@@ -1302,6 +1324,7 @@ fn update_tray_menu(
/// Build the system tray. Best-effort: on Linux the tray depends on
/// libayatana-appindicator at runtime, so any failure here must not abort app
/// 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>> {
use std::sync::atomic::Ordering;
use tauri::menu::{MenuBuilder, MenuItemBuilder};
@@ -1368,11 +1391,18 @@ fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::E
#[cfg_attr(mobile, tauri::mobile_entry_point)]
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 startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned();
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();
pending.push(url.clone());
}
@@ -1391,54 +1421,61 @@ pub fn run() {
}),
};
tauri::Builder::default()
.plugin(
tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(file_log_target)
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
// truncated useful context in customer support reports; 50 MB
// turned out to be excessive disk pressure.
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
.level(log::LevelFilter::Info)
.format(|out, message, record| {
use chrono::Local;
let now = Local::now();
let timestamp = format!(
"{}.{:03}",
now.format("%Y-%m-%d %H:%M:%S"),
now.timestamp_subsec_millis()
);
out.finish(format_args!(
"[{}][{}][{}] {}",
timestamp,
record.target(),
record.level(),
message
))
})
.build(),
)
.plugin(tauri_plugin_single_instance::init(
|app_handle, args, _cwd| {
log::info!("Single instance triggered with args: {args:?}");
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
}
},
))
let builder = configure_builder(tauri::Builder::default());
let builder = builder.plugin(
tauri_plugin_log::Builder::new()
.clear_targets() // Clear default targets to avoid duplicates
.target(Target::new(TargetKind::Stdout))
.target(Target::new(TargetKind::Webview))
.target(file_log_target)
// Keep enough context for customer support without letting a long-running
// installation accumulate logs without bound.
.max_file_size(5 * 1024 * 1024)
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(10))
.level(log::LevelFilter::Info)
.format(|out, message, record| {
use chrono::Local;
let now = Local::now();
let timestamp = format!(
"{}.{:03}",
now.format("%Y-%m-%d %H:%M:%S"),
now.timestamp_subsec_millis()
);
out.finish(format_args!(
"[{}][{}][{}] {}",
timestamp,
record.target(),
record.level(),
message
))
})
.build(),
);
#[cfg(not(feature = "e2e"))]
let builder = builder.plugin(tauri_plugin_single_instance::init(
|app_handle, args, _cwd| {
log::info!("Single instance triggered with args: {args:?}");
if let Some(window) = app_handle.get_webview_window("main") {
let _ = window.show();
let _ = window.set_focus();
let _ = window.unminimize();
}
},
));
let builder = builder
.plugin(tauri_plugin_deep_link::init())
.plugin(tauri_plugin_fs::init())
.plugin(tauri_plugin_opener::init())
.plugin(tauri_plugin_shell::init())
.plugin(tauri_plugin_dialog::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
// because the app hides to tray: restoring visibility would otherwise
// relaunch with an invisible window after quitting from the tray while
@@ -1453,8 +1490,9 @@ pub fn run() {
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
)
.build(),
)
.setup(|app| {
);
builder.setup(|app| {
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
ephemeral_dirs::recover_ephemeral_dirs();
@@ -1476,6 +1514,19 @@ pub fn run() {
.focused(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")]
let win_builder = win_builder.decorations(false);
@@ -1486,8 +1537,11 @@ pub fn run() {
// dialog's "Minimize" action hides the window. Best-effort: a tray
// failure (e.g. missing libayatana-appindicator on Linux) must never
// prevent the app from launching, so we log and continue without it.
if let Err(e) = setup_system_tray(app.handle()) {
log::warn!("System tray unavailable, continuing without it: {e}");
#[cfg(not(feature = "e2e"))]
{
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
@@ -1530,7 +1584,7 @@ pub fn run() {
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
if let Err(e) = app.deep_link().register_all() {
@@ -1538,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
if let Err(e) = app.deep_link().register_all() {
@@ -1548,63 +1602,62 @@ pub fn run() {
}
}
app.deep_link().on_open_url({
let handle = handle.clone();
move |event| {
let urls = event.urls();
log::info!("Deep link event received with {} URLs", urls.len());
#[cfg(not(feature = "e2e"))]
{
app.deep_link().on_open_url({
let handle = handle.clone();
move |event| {
let urls = event.urls();
log::info!("Deep link event received with {} URLs", urls.len());
for url in urls {
let url_string = url.to_string();
log::info!("Deep link received: {url_string}");
for url in urls {
let url_string = url.to_string();
log::info!("Processing deep link URL");
let handle_clone = handle.clone();
// Clone the handle for each async task
let handle_clone = handle.clone();
// 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}");
}
});
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 {
let handle_clone = handle.clone();
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 {
log::error!("Failed to handle startup URL: {e}");
}
});
}
// Initialize and start background version updater
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let version_updater = get_version_updater();
if !e2e_automation_enabled() {
// Initialize and start background version updater
let app_handle = app.handle().clone();
tauri::async_runtime::spawn(async move {
let version_updater = get_version_updater();
// Set the app handle
{
let mut updater_guard = version_updater.lock().await;
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}");
{
let mut updater_guard = version_updater.lock().await;
updater_guard.set_app_handle(app_handle);
}
}
});
// Start the background update task separately
tauri::async_runtime::spawn(async move {
version_updater::VersionUpdater::run_background_task().await;
});
{
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}");
}
}
});
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
// decision so customer logs reveal whether MCP is actually running —
@@ -1765,12 +1818,12 @@ pub fn run() {
}
}
let app_handle_auto_updater = app.handle().clone();
// Start the auto-update check task separately
tauri::async_runtime::spawn(async move {
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
});
if !e2e_automation_enabled() {
let app_handle_auto_updater = app.handle().clone();
tauri::async_runtime::spawn(async move {
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
});
}
// Handle any pending URLs that were received before the window was ready
let handle_pending = handle.clone();
@@ -1786,114 +1839,92 @@ pub fn run() {
};
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 {
log::error!("Failed to handle pending URL: {e}");
}
}
});
// Start periodic cleanup task for unused binaries
// Only runs when sync is not in progress to avoid deleting browsers
// that might be needed for profiles being synced from the cloud
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); // Every 12 hours
loop {
interval.tick().await;
// Check if sync is in progress before running cleanup
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}");
if !e2e_automation_enabled() {
// Start periodic cleanup task for unused binaries.
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
loop {
interval.tick().await;
if let Some(scheduler) = sync::get_global_scheduler() {
if scheduler.is_sync_in_progress().await {
log::debug!("Skipping cleanup: sync is in progress");
continue;
}
}
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 app_handle_geoip = app.handle().clone();
tauri::async_runtime::spawn(async move {
// Wait a bit for the app to fully initialize
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}");
let registry =
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
if let Err(e) = registry.cleanup_unused_binaries() {
log::error!("Periodic cleanup failed: {e}");
} 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
let app_handle_proxy_cleanup = app.handle().clone();
+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>");
}
}
+1 -1
View File
@@ -39,7 +39,7 @@ fn is_kept(name: &str) -> bool {
/// 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 taken for junk and removed wholesale, destroying the
/// 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")
+23 -65
View File
@@ -279,7 +279,7 @@ impl ProxyManager {
) -> Result<(), Box<dyn std::error::Error>> {
let cache_file = self.get_proxy_check_cache_file(proxy_id)?;
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(())
}
@@ -404,7 +404,7 @@ impl ProxyManager {
let proxy_file = self.get_proxy_file_path(&proxy.id);
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(())
}
@@ -1614,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
let mut proxy_cmd = app_handle
.shell()
@@ -1632,12 +1636,13 @@ impl ProxyManager {
.arg("--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 {
proxy_cmd = proxy_cmd.arg("--username").arg(username);
proxy_cmd = proxy_cmd.env("DONUT_PROXY_USERNAME", username);
}
if let Some(password) = &proxy_settings.password {
proxy_cmd = proxy_cmd.arg("--password").arg(password);
proxy_cmd = proxy_cmd.env("DONUT_PROXY_PASSWORD", password);
}
}
@@ -2522,7 +2527,7 @@ mod tests {
Ok(())
}
// Test that validates the command line arguments are constructed correctly
// Validate that non-secret proxy settings remain command arguments.
#[test]
fn test_proxy_command_construction() {
let proxy_settings = ProxySettings {
@@ -2543,10 +2548,6 @@ mod tests {
"8080",
"--type",
"http",
"--username",
"user",
"--password",
"pass",
];
// This test verifies the argument structure without actually running the command
@@ -2670,61 +2671,6 @@ mod tests {
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
// ──────────────────────────────────────────────────────────────────────
@@ -3060,6 +3006,18 @@ mod tests {
// Save
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
let loaded = get_proxy_config(&id).expect("Config should be loadable");
assert_eq!(loaded.id, config.id);
+171 -4
View File
@@ -4,11 +4,52 @@ use crate::proxy_storage::{
};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::atomic::{AtomicBool, Ordering};
lazy_static::lazy_static! {
static ref PROXY_PROCESSES: std::sync::Mutex<std::collections::HashMap<String, u32>> =
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> {
let target = std::env::var("TARGET").ok()?;
@@ -156,6 +197,77 @@ 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(
upstream_url: Option<String>,
port: Option<u16>,
@@ -173,6 +285,8 @@ pub async fn start_proxy_process_with_profile(
dns_allowlist_mode: bool,
local_protocol: Option<String>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
ensure_sidecar_version().await?;
let id = generate_proxy_id();
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
@@ -201,6 +315,10 @@ pub async fn start_proxy_process_with_profile(
// Spawn proxy worker process in the background using std::process::Command
// This ensures proper process detachment on Unix systems
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)]
{
@@ -212,13 +330,14 @@ pub async fn start_proxy_process_with_profile(
cmd.arg("start");
cmd.arg("--id");
cmd.arg(&id);
cmd.env_remove("DONUT_PROXY_USERNAME");
cmd.env_remove("DONUT_PROXY_PASSWORD");
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
// 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) = std::fs::File::create(&log_path) {
if let Ok(file) = log_file {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
@@ -289,13 +408,14 @@ pub async fn start_proxy_process_with_profile(
cmd.arg("start");
cmd.arg("--id");
cmd.arg(&id);
cmd.env_remove("DONUT_PROXY_USERNAME");
cmd.env_remove("DONUT_PROXY_PASSWORD");
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
// 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) = std::fs::File::create(&log_path) {
if let Ok(file) = log_file {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
@@ -442,3 +562,50 @@ pub async fn stop_all_proxy_processes() -> Result<(), Box<dyn std::error::Error>
}
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());
}
}
+12 -3
View File
@@ -1351,9 +1351,8 @@ pub async fn handle_proxy_connection(
/// Render an upstream proxy URL for logging with any embedded credentials
/// stripped. `config.upstream_url` carries `scheme://user:pass@host:port`, and
/// these logs land in a world-readable file under the system temp dir, so the
/// userinfo must never be emitted.
fn redacted_upstream(upstream: &str) -> String {
/// diagnostic logs and command responses must never expose the userinfo.
pub fn redacted_upstream(upstream: &str) -> String {
if upstream.is_empty() {
return "none".to_string();
}
@@ -2238,6 +2237,16 @@ mod tests {
assert_eq!(upstream_userpass(&u), ("u@name".into(), String::new()));
}
#[test]
fn upstream_log_value_never_contains_credentials() {
assert_eq!(
redacted_upstream("http://user:p%40ss@example.com:8080"),
"http://example.com:8080"
);
assert_eq!(redacted_upstream("not a URL"), "<redacted>");
assert_eq!(redacted_upstream(""), "none");
}
#[test]
fn test_blocklist_exact_match() {
let mut matcher = BlocklistMatcher::new();
+45 -4
View File
@@ -87,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 {
crate::app_dirs::proxy_workers_dir()
}
@@ -97,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 content = serde_json::to_string_pretty(config)?;
fs::write(&file_path, content)?;
crate::app_dirs::write_owner_only(&file_path, content.as_bytes())?;
Ok(())
}
@@ -159,10 +182,13 @@ pub fn update_proxy_config(config: &ProxyConfig) -> bool {
return false;
}
match serde_json::to_string_pretty(config) {
Ok(content) => fs::write(&file_path, content).is_ok(),
Err(_) => false,
let Ok(content) = serde_json::to_string_pretty(config) else {
return false;
};
if crate::app_dirs::write_owner_only(&file_path, content.as_bytes()).is_err() {
return false;
}
true
}
pub fn generate_proxy_id() -> String {
@@ -195,6 +221,21 @@ pub fn is_process_running(pid: u32) -> bool {
mod tests {
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]
fn test_is_process_running_detects_current_process() {
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;
let mut out = String::with_capacity(64 * 1024);
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 {
break;
}
@@ -884,7 +890,7 @@ pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, Stri
.map(|s| format!("===== {s}"))
.collect::<String>();
Ok(final_out)
Ok(crate::log_redaction::text(&final_out))
}
/// Reveal the log directory in the OS file manager.
+120 -150
View File
@@ -492,6 +492,9 @@ impl SyncEngine {
return Ok(());
}
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
let profile = &reconciled_profile;
// Derive encryption key if encrypted sync
let encryption_key = if profile.is_encrypted_sync() {
let password = encryption::load_e2e_password()
@@ -697,11 +700,6 @@ impl SyncEngine {
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
// 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
@@ -747,37 +745,18 @@ impl SyncEngine {
let _ = self.sync_vpn(vpn_id, Some(app_handle)).await;
}
// Download remote metadata and merge changes (name, tags, notes, etc.)
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 = profile.clone();
// Merge fields that can be changed on other devices
updated_profile.name = remote_meta.name;
updated_profile.tags = remote_meta.tags;
updated_profile.note = remote_meta.note;
updated_profile.proxy_id = remote_meta.proxy_id;
updated_profile.vpn_id = remote_meta.vpn_id;
updated_profile.group_id = remote_meta.group_id;
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 mut updated_profile = profile.clone();
updated_profile.last_sync = Some(
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs(),
);
profile_manager
.save_profile(&updated_profile)
.map_err(|e| {
SyncError::IoError(format!("Failed to save reconciled profile metadata: {e}"))
})?;
let _ = events::emit("profiles-changed", ());
let _ = events::emit(
@@ -881,6 +860,45 @@ impl SyncEngine {
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).
/// No browser files are synced.
async fn sync_cross_os_metadata(
@@ -889,34 +907,8 @@ impl SyncEngine {
profile: &BrowserProfile,
) -> SyncResult<()> {
let profile_id = profile.id.to_string();
let key_prefix = Self::get_team_key_prefix(profile).await;
let profile_manager = ProfileManager::instance();
// 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);
}
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
let profile = &reconciled_profile;
// Sync associated entities
if let Some(proxy_id) = &profile.proxy_id {
@@ -954,18 +946,9 @@ impl SyncEngine {
let json = serde_json::to_string_pretty(&sanitized)
.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 presign = self
.client
.presign_upload(&remote_key, Some(content_type))
.await?;
self
.client
.upload_bytes(&presign.url, &payload, Some(content_type))
.upload_config_json(&remote_key, &json, sanitized.updated_at.unwrap_or(0))
.await?;
Ok(())
@@ -4023,28 +4006,55 @@ pub async fn rollover_encryption_for_all_entities(
) -> Result<(), String> {
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 profiles = profile_manager
.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
.iter()
.filter(|p| p.sync_mode != SyncMode::Disabled)
.collect();
let total_profiles = synced_profiles.len();
let mut running_profile_ids: std::collections::HashSet<uuid::Uuid> =
std::collections::HashSet::new();
if synced_profiles
.iter()
.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() {
if profile.process_id.is_some() {
running_profile_ids.insert(profile.id);
}
let id_str = profile.id.to_string();
if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await {
log::warn!("Rollover: profile {} re-sync failed: {e}", id_str);
}
// The remote manifest may be encrypted with the previous password. Delete
// 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(
"e2e-rollover-progress",
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 synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
let total_proxies = synced_proxies.len();
let mut deferred = Vec::new();
for (i, proxy) in synced_proxies.iter().enumerate() {
if deferred_proxy_ids.contains(&proxy.id) {
deferred.push(proxy.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_proxy_sync(proxy.id.clone()).await;
}
engine
.upload_proxy(proxy)
.await
.map_err(|e| internal_error(format!("Failed to roll over proxy {}: {e}", proxy.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
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 gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
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 total_groups = synced_groups.len();
let mut deferred_groups = Vec::new();
for (i, group) in synced_groups.iter().enumerate() {
if deferred_group_ids.contains(&group.id) {
deferred_groups.push(group.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_group_sync(group.id.clone()).await;
}
engine
.upload_group(group)
.await
.map_err(|e| internal_error(format!("Failed to roll over group {}: {e}", group.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
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();
storage
.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 total_vpns = synced_vpns.len();
let mut deferred_vpns = Vec::new();
for (i, config) in synced_vpns.iter().enumerate() {
if deferred_vpn_ids.contains(&config.id) {
deferred_vpns.push(config.id.clone());
} else if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_vpn_sync(config.id.clone()).await;
}
engine
.upload_vpn(config)
.await
.map_err(|e| internal_error(format!("Failed to roll over VPN {}: {e}", config.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
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 em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
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 total_exts = synced_exts.len();
for (i, ext) in synced_exts.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_sync(ext.id.clone()).await;
}
engine
.upload_extension(ext)
.await
.map_err(|e| internal_error(format!("Failed to roll over extension {}: {e}", ext.id)))?;
let _ = events::emit(
"e2e-rollover-progress",
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 em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
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 total_eg = synced_ext_groups.len();
for (i, group) in synced_ext_groups.iter().enumerate() {
if let Some(scheduler) = super::get_global_scheduler() {
scheduler.queue_extension_group_sync(group.id.clone()).await;
}
engine.upload_extension_group(group).await.map_err(|e| {
internal_error(format!(
"Failed to roll over extension group {}: {e}",
group.id
))
})?;
let _ = events::emit(
"e2e-rollover-progress",
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", ());
Ok(())
}
+8 -137
View File
@@ -8,7 +8,6 @@ use std::path::Path;
use std::time::SystemTime;
use super::types::{SyncError, SyncResult};
use crate::profile::types::BrowserProfile;
/// Default exclude patterns for volatile browser profile files.
/// Patterns use `**/` prefix to match at any directory depth, since the sync
@@ -61,6 +60,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
"**/DawnWebGPUCache/**",
"**/BrowserMetrics*",
"**/.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/**",
// Orphaned local-only marker from earlier rollover-based fingerprint
// 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()))
}
/// 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
/// Returns None if the file doesn't exist (was deleted)
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
@@ -372,19 +342,7 @@ pub fn generate_manifest(
*max_mtime = (*max_mtime).max(mtime);
// Check cache for existing hash
let hash = if relative_path == "metadata.json" {
// 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) {
let hash = if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
cached_hash.to_string()
} else {
match hash_file(&path)? {
@@ -651,21 +609,15 @@ mod tests {
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
// metadata.json at root
let profile = BrowserProfile::default();
fs::write(
profile_dir.join("metadata.json"),
serde_json::to_string(&profile).unwrap(),
)
.unwrap();
fs::write(profile_dir.join("metadata.json"), "{}").unwrap();
let mut cache = HashCache::default();
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();
assert!(
paths.contains(&"metadata.json"),
"metadata.json should be synced"
!paths.contains(&"metadata.json"),
"metadata.json is reconciled separately from browser files"
);
assert!(
paths.contains(&"profile/Default/Cookies"),
@@ -865,85 +817,4 @@ mod tests {
assert!(diff.files_to_delete_remote.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 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");
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)
.await
@@ -504,7 +504,7 @@ impl SynchronizerManager {
Ok(url) => {
match tokio_tungstenite::connect_async(&url).await {
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>();
follower_senders.insert(fid.clone(), tx);
@@ -674,7 +674,7 @@ impl SynchronizerManager {
if is_top {
if let Some(url) = frame.get("url").and_then(|v| v.as_str()) {
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 {
event_type: "navigate".to_string(),
url: Some(url.to_string()),
+1 -1
View File
@@ -1158,7 +1158,7 @@ mod tests {
#[test]
fn test_parse_key_valid() {
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
let key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
assert!(parse_key(key).is_ok());
}
+3 -5
View File
@@ -349,12 +349,11 @@ mod tests {
fn create_test_config() -> WireGuardConfig {
WireGuardConfig {
// These are test keys, not real ones
private_key: "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=".to_string(),
private_key: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string(),
address: "10.0.0.2/24".to_string(),
dns: Some("1.1.1.1".to_string()),
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(),
allowed_ips: vec!["0.0.0.0/0".to_string()],
persistent_keepalive: Some(25),
@@ -375,8 +374,7 @@ mod tests {
#[test]
fn test_parse_key_valid() {
// Valid base64-encoded 32-byte key
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
let key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
let result = WireGuardTunnel::parse_key(key);
assert!(result.is_ok());
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>> {
crate::proxy_runner::ensure_sidecar_version().await?;
for config in list_vpn_worker_configs() {
if let Some(pid) = config.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 {
// Map the local proxy scheme to the matching PAC directive. SOCKS5 lets
// Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy;
@@ -942,6 +937,10 @@ impl WayfernManager {
.stdin(Stdio::null())
.stdout(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
.spawn()
@@ -1040,16 +1039,13 @@ impl WayfernManager {
for target in &page_targets {
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
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
.await
{
Ok(result) => {
log::info!(
"Successfully applied fingerprint to page target: {:?}",
result
);
log::info!("Successfully applied fingerprint to page target");
// Wayfern.setFingerprint echoes back the fingerprint it actually
// used, which may be UPGRADED from what we sent (e.g. when the
// stored fingerprint targets an older browser version). Capture
@@ -1080,7 +1076,7 @@ impl WayfernManager {
// Geolocation is handled internally by the browser binary.
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(ws_url) = &target.websocket_debugger_url {
if let Err(e) = self
@@ -1212,7 +1208,7 @@ impl WayfernManager {
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(())
}
+18 -6
View File
@@ -6,13 +6,24 @@
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
"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"
},
"app": {
"windows": [],
"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": {
@@ -35,9 +46,7 @@
"signingIdentity": null,
"providerShortName": null,
"entitlements": "entitlements.plist",
"files": {
"Info.plist": "Info.plist"
}
"infoPlist": "Info.plist"
},
"linux": {
"deb": {
@@ -57,7 +66,10 @@
"windows": {
"certificateThumbprint": null,
"digestAlgorithm": "sha256",
"timestampUrl": ""
"timestampUrl": "",
"nsis": {
"installerHooks": "installer-hooks.nsh"
}
}
},
"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(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...");
let build_status = std::process::Command::new("cargo")
.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)
#[tokio::test]
#[serial]
@@ -1360,9 +1389,8 @@ async fn test_local_proxy_with_shadowsocks_upstream(
}
// Start donut-proxy with Shadowsocks upstream
let output = TestUtils::execute_command(
&binary_path,
&[
let output = tokio::process::Command::new(&binary_path)
.args([
"proxy",
"start",
"--host",
@@ -1371,13 +1399,11 @@ async fn test_local_proxy_with_shadowsocks_upstream(
&ss_port.to_string(),
"--type",
"ss",
"--username",
ss_method,
"--password",
ss_password,
],
)
.await?;
])
.env("DONUT_PROXY_USERNAME", ss_method)
.env("DONUT_PROXY_PASSWORD", ss_password)
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
+2 -5
View File
@@ -1,13 +1,10 @@
# Sample WireGuard configuration for testing
# This is NOT a real configuration - for unit test purposes only
[Interface]
PrivateKey = YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=
PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=
Address = 10.0.0.2/24
DNS = 1.1.1.1
[Peer]
PublicKey = aGnF7JlG+U5t0BqB1PVf1yOuELHrWLGGcUJb0eCK9Aw=
PublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=
AllowedIPs = 0.0.0.0/0, ::/0
Endpoint = vpn.example.com:51820
PersistentKeepalive = 25
+5 -9
View File
@@ -292,11 +292,11 @@ fn create_test_storage(temp_dir: &tempfile::TempDir) -> VpnStorage {
#[serial]
async fn test_wireguard_tunnel_init() {
let config = WireGuardConfig {
private_key: "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=".to_string(),
private_key: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string(),
address: "10.0.0.2/24".to_string(),
dns: Some("1.1.1.1".to_string()),
mtu: None,
peer_public_key: "aGnF7JlG+U5t0BqB1PVf1yOuELHrWLGGcUJb0eCK9Aw=".to_string(),
peer_public_key: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=".to_string(),
peer_endpoint: "127.0.0.1:51820".to_string(),
allowed_ips: vec!["0.0.0.0/0".to_string()],
persistent_keepalive: Some(25),
@@ -780,13 +780,9 @@ async fn test_wireguard_traffic_flows_through_donut_proxy(
}
let binary_path = ensure_donut_proxy_binary().await?;
let wg_config = match test_harness::start_wireguard_server().await {
Ok(config) => config,
Err(error) => {
eprintln!("skipping WireGuard e2e test: {error}");
return Ok(());
}
};
let wg_config = test_harness::start_wireguard_server()
.await
.map_err(|error| format!("failed to start Docker WireGuard fixture: {error}"))?;
let vpn_config = new_test_vpn_config(
"WireGuard E2E",
+1 -1
View File
@@ -950,7 +950,7 @@ export default function Home() {
}
} catch (err: unknown) {
console.error("Failed to launch browser:", err);
const errorMessage = err instanceof Error ? err.message : String(err);
const errorMessage = translateBackendError(t, err);
showErrorToast(
t("errors.launchBrowserFailed", { error: errorMessage }),
);
-51
View File
@@ -1,54 +1,3 @@
/**
* Unified Toast System
*
* This module provides a comprehensive toast system that solves styling issues
* and provides a single, flexible toast component for all use cases.
*
* Features:
* - Proper background styling (no transparency issues)
* - Loading states with spinners
* - Progress bars for downloads/updates
* - Success/error states
* - Customizable icons and content
* - Auto-update notifications
*
* Usage Examples:
*
* Simple loading toast:
* ```
* import { showToast } from "./custom-toast";
* showToast({
* type: "loading",
* title: "Loading...",
* description: "Please wait..."
* });
* ```
*
* Auto-update toast:
* ```
* showAutoUpdateToast("Wayfern", "149.0.7827.116");
* ```
*
* Download progress toast:
* ```
* showToast({
* type: "download",
* title: "Downloading Wayfern 149.0.7827.116",
* progress: { percentage: 45, speed: "2.5", eta: "30s" }
* });
* ```
*
* Version update progress:
* ```
* showToast({
* type: "version-update",
* title: "Updating browser versions",
* progress: { current: 3, total: 5, found: 12 }
* });
* ```
*/
/** biome-ignore-all lint/suspicious/noExplicitAny: TODO */
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useTranslation } from "react-i18next";
import {
+13 -15
View File
@@ -1,5 +1,3 @@
/** biome-ignore-all lint/a11y/noStaticElementInteractions: temporary suppress until in active use */
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: temporary suppress until in active use */
"use client";
import { Command as CommandPrimitive, useCommandState } from "cmdk";
@@ -14,9 +12,9 @@ export interface Option {
value: string;
label?: string;
disable?: boolean;
/** fixed option that can't be removed. */
/** An option that cannot be removed. */
fixed?: boolean;
/** Group the options by providing key. */
/** Group options by this property. */
[key: string]: string | boolean | undefined;
}
type GroupOption = Record<string, Option[]>;
@@ -24,21 +22,20 @@ type GroupOption = Record<string, Option[]>;
interface MultipleSelectorProps {
value?: Option[];
defaultOptions?: Option[];
/** manually controlled options */
/** Manually controlled options. */
options?: Option[];
placeholder?: string;
/** Loading component. */
loadingIndicator?: React.ReactNode;
/** Empty component. */
emptyIndicator?: React.ReactNode;
/** Debounce time for async search. Only work with `onSearch`. */
/** Debounce time for async search. Used only with `onSearch`. */
delay?: number;
/**
* Only work with `onSearch` prop. Trigger search when `onFocus`.
* For example, when user click on the input, it will trigger the search to get initial options.
* Trigger `onSearch` when the input receives focus.
**/
triggerSearchOnFocus?: boolean;
/** async search */
/** Asynchronous search. */
onSearch?: (value: string) => Promise<Option[]>;
onChange?: (options: Option[]) => void;
/** Limit the maximum number of selected options. */
@@ -48,13 +45,12 @@ interface MultipleSelectorProps {
/** Hide the placeholder when there are options selected. */
hidePlaceholderWhenSelected?: boolean;
disabled?: boolean;
/** Group the options base on provided key. */
/** Group options by the provided key. */
groupBy?: string;
className?: string;
badgeClassName?: string;
/**
* First item selected is a default behavior by cmdk. That is why the default is true.
* This is a workaround solution by add a dummy item.
* Prevent cmdk from selecting the first item by inserting a dummy item.
*
* @reference: https://github.com/pacocoursey/cmdk/issues/171
*/
@@ -375,7 +371,7 @@ const MultipleSelector = React.forwardRef<
return Object.values(selectables).some((group) => group.length > 0);
}, [selectables]);
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
// Skip fuzzy matching for creatable inputs to keep long pasted values responsive.
const commandFilter = React.useCallback(() => {
if (commandProps?.filter) {
return commandProps.filter;
@@ -401,13 +397,15 @@ const MultipleSelector = React.forwardRef<
"relative h-auto overflow-visible bg-transparent",
commandProps?.className,
)}
// Consumers may override filtering even when search is asynchronous.
shouldFilter={
commandProps?.shouldFilter !== undefined
? commandProps.shouldFilter
: !onSearch
} // When onSearch is provided, we don't want to filter the options. You can still override it.
}
filter={commandFilter()}
>
{/* biome-ignore lint/a11y/noStaticElementInteractions: pointer focus is forwarded to the nested input; keyboard users focus nested controls directly */}
<div
className={cn(
"min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
@@ -417,7 +415,7 @@ const MultipleSelector = React.forwardRef<
},
className,
)}
onClick={() => {
onMouseDown={() => {
if (disabled) return;
inputRef.current?.focus();
}}
+18 -1
View File
@@ -243,6 +243,17 @@ export function RailNav({
handleClick,
} = useLogoEasterEgg({ currentPage, onNavigate });
useEffect(() => {
if (!moreOpen) return;
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setMoreOpen(false);
}
};
document.addEventListener("keydown", closeOnEscape);
return () => document.removeEventListener("keydown", closeOnEscape);
}, [moreOpen]);
return (
<nav className="relative flex w-10 shrink-0 flex-col items-center gap-1 border-r border-border bg-background py-2">
{!isHidden ? (
@@ -379,11 +390,16 @@ export function RailNav({
setMoreOpen(false);
}}
/>
<div className="surface-material-card absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1">
<div
role="menu"
aria-label={t("rail.more.label")}
className="surface-material-card absolute bottom-14 left-11 z-40 w-56 animate-in rounded-lg border border-border p-1 shadow-2xl duration-100 fade-in-0 slide-in-from-bottom-1"
>
{MORE_ITEMS.map(({ page, Icon, labelKey, hintKey }) => (
<button
key={page}
type="button"
role="menuitem"
onClick={() => {
setMoreOpen(false);
onNavigate(page);
@@ -405,6 +421,7 @@ export function RailNav({
))}
<button
type="button"
role="menuitem"
onClick={() => {
setMoreOpen(false);
onOpenAbout();
+24 -27
View File
@@ -1,6 +1,6 @@
"use client";
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
import { type HTMLMotionProps, motion } from "motion/react";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { useTranslation } from "react-i18next";
@@ -72,20 +72,24 @@ type DialogPortalProps = Omit<
"forceMount"
>;
function DialogPortal(props: DialogPortalProps) {
function DialogPortal({
children,
container: portalContainer,
...props
}: DialogPortalProps) {
const { isOpen, container } = useDialog();
if (!isOpen) return null;
return (
<AnimatePresence>
{isOpen && (
<DialogPrimitive.Portal
data-slot="dialog-portal"
forceMount
container={container ?? props.container}
{...props}
/>
)}
</AnimatePresence>
<DialogPrimitive.Portal
data-slot="dialog-portal"
forceMount
container={container ?? portalContainer}
{...props}
>
{children}
</DialogPrimitive.Portal>
);
}
@@ -106,7 +110,6 @@ function DialogOverlay({
key="dialog-overlay"
initial={{ opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(4px)" }}
transition={transition}
className={cn("fixed inset-0 z-9999 bg-background/50", className)}
{...props}
@@ -217,8 +220,9 @@ function DialogContent({
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogOverlay key="dialog-overlay" />
<DialogPrimitive.Content
key="dialog-content"
asChild
forceMount
onOpenAutoFocus={onOpenAutoFocus}
@@ -243,22 +247,15 @@ function DialogContent({
<motion.div
key="dialog-content"
data-slot="dialog-content"
// Open/close motion modeled on transitions.dev's modal: a subtle
// scale from 0.96 → 1 with opacity, eased with cubic-bezier(0.22, 1,
// 0.36, 1). Open is 250ms; close is a quicker 150ms. The centering
// translate stays in `style` so `scale` animates around the center
// without fighting the transform-based positioning.
// Open motion modeled on transitions.dev's modal: a subtle scale
// from 0.96 → 1 with opacity, eased with cubic-bezier(0.22, 1, 0.36,
// 1). The portal unmounts immediately on close so a closed Radix
// surface cannot linger over the app. The centering translate stays
// in `style` so `scale` animates around the center without fighting
// the transform-based positioning.
style={{ transformOrigin: "center" }}
initial={{ opacity: 0, scale: 0.96 }}
animate={{ opacity: 1, scale: 1 }}
exit={{
opacity: 0,
scale: 0.96,
transition: transition ?? {
duration: 0.15,
ease: [0.22, 1, 0.36, 1],
},
}}
transition={
transition ?? { duration: 0.25, ease: [0.22, 1, 0.36, 1] }
}
+1 -1
View File
@@ -32,7 +32,7 @@ function PopoverContent({
sideOffset={sideOffset}
collisionPadding={collisionPadding}
className={cn(
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
"z-50000 max-h-(--radix-popover-content-available-height) origin-(--radix-popover-content-transform-origin) overflow-y-auto rounded-md surface-material-popover border p-4 text-popover-foreground shadow-md outline-hidden data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:pointer-events-none data-[state=closed]:animate-none data-[state=closed]:opacity-0 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className,
)}
{...props}
+1 -1
View File
@@ -106,7 +106,7 @@ export function useAppUpdateNotifications() {
showToast({
type: "error",
title: t("appUpdate.toast.restartFailed"),
description: String(error),
description: translateBackendError(t, error),
duration: 6000,
});
}
+5 -2
View File
@@ -192,7 +192,7 @@
"copyLogs": "Copy logs",
"openLogDir": "Open log folder",
"copyLogsSuccess": "Logs copied to clipboard",
"copyLogsDescription": "Bundles the most recent log files (up to 5 MB) into your clipboard for sharing in bug reports."
"copyLogsDescription": "Copies a redacted bundle of recent logs (up to 5 MB). Review it before sharing because redaction cannot identify every kind of personal data."
},
"disableAutoUpdates": "Disable App Auto Updates",
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected.",
@@ -1870,7 +1870,10 @@
"unsupportedDnsRulesFormat": "Unsupported rules format: {{format}}",
"dnsRulesSaveFailed": "Failed to save the DNS rules.",
"dnsRulesExportFailed": "Failed to export the DNS rules.",
"fingerprintMatchFailed": "Couldn't match the fingerprint to the proxy."
"fingerprintMatchFailed": "Couldn't match the fingerprint to the proxy.",
"proxySidecarVersionMismatch": "Some Donut Browser files are from different versions. Reinstall the latest update; your profiles will stay safe.",
"updateProfilesRunning": "Stop all running profiles before installing the update.",
"updatePreparationFailed": "Donut Browser could not safely stop a background network process. Restart your computer, then try the update again."
},
"rail": {
"profiles": "Profiles",
+5 -2
View File
@@ -192,7 +192,7 @@
"copyLogs": "Copiar registros",
"openLogDir": "Abrir carpeta de registros",
"copyLogsSuccess": "Registros copiados al portapapeles",
"copyLogsDescription": "Une los archivos de registros recientes (hasta 5 MB) en tu portapapeles para compartirlos en informes de error."
"copyLogsDescription": "Copia un paquete censurado de los registros recientes (hasta 5 MB). Revísalo antes de compartirlo, ya que la censura no puede identificar todos los tipos de datos personales."
},
"disableAutoUpdates": "Desactivar Actualizaciones Automáticas de la App",
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas.",
@@ -1870,7 +1870,10 @@
"unsupportedDnsRulesFormat": "Formato de reglas no compatible: {{format}}",
"dnsRulesSaveFailed": "No se pudieron guardar las reglas DNS.",
"dnsRulesExportFailed": "No se pudieron exportar las reglas DNS.",
"fingerprintMatchFailed": "No se pudo ajustar la huella al proxy."
"fingerprintMatchFailed": "No se pudo ajustar la huella al proxy.",
"proxySidecarVersionMismatch": "Algunos archivos de Donut Browser pertenecen a versiones diferentes. Reinstala la última actualización; tus perfiles permanecerán seguros.",
"updateProfilesRunning": "Detén todos los perfiles en ejecución antes de instalar la actualización.",
"updatePreparationFailed": "Donut Browser no pudo detener de forma segura un proceso de red en segundo plano. Reinicia el equipo y vuelve a intentar la actualización."
},
"rail": {
"profiles": "Perfiles",
+5 -2
View File
@@ -192,7 +192,7 @@
"copyLogs": "Copier les journaux",
"openLogDir": "Ouvrir le dossier des journaux",
"copyLogsSuccess": "Journaux copiés dans le presse-papiers",
"copyLogsDescription": "Regroupe les derniers fichiers de journal (jusqu’à 5 Mo) dans votre presse-papiers pour les rapports de bug."
"copyLogsDescription": "Copie un lot expurgé des journaux récents (jusqu’à 5 Mo). Vérifiez-le avant de le partager, car lexpurgation ne peut pas identifier tous les types de données personnelles."
},
"disableAutoUpdates": "Désactiver les mises à jour automatiques de l'app",
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées.",
@@ -1870,7 +1870,10 @@
"unsupportedDnsRulesFormat": "Format de règles non pris en charge : {{format}}",
"dnsRulesSaveFailed": "Échec de l'enregistrement des règles DNS.",
"dnsRulesExportFailed": "Échec de l'exportation des règles DNS.",
"fingerprintMatchFailed": "Impossible d'aligner l'empreinte sur le proxy."
"fingerprintMatchFailed": "Impossible d'aligner l'empreinte sur le proxy.",
"proxySidecarVersionMismatch": "Certains fichiers de Donut Browser proviennent de versions différentes. Réinstallez la dernière mise à jour ; vos profils resteront intacts.",
"updateProfilesRunning": "Arrêtez tous les profils en cours dexécution avant dinstaller la mise à jour.",
"updatePreparationFailed": "Donut Browser na pas pu arrêter en toute sécurité un processus réseau en arrière-plan. Redémarrez lordinateur, puis réessayez la mise à jour."
},
"rail": {
"profiles": "Profils",
+5 -2
View File
@@ -192,7 +192,7 @@
"copyLogs": "ログをコピー",
"openLogDir": "ログフォルダを開く",
"copyLogsSuccess": "ログをクリップボードにコピーしました",
"copyLogsDescription": "最のログファイル(最大 5 MB)をクリップボードにまとめ、不具合報告で共有できるようにします。"
"copyLogsDescription": "最のログを編集したバンドル(最大 5 MB)をコピーします。編集ではすべての種類の個人データを識別できないため、共有前に内容を確認してください。"
},
"disableAutoUpdates": "アプリの自動更新を無効にする",
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。",
@@ -1870,7 +1870,10 @@
"unsupportedDnsRulesFormat": "サポートされていないルール形式: {{format}}",
"dnsRulesSaveFailed": "DNS ルールを保存できませんでした。",
"dnsRulesExportFailed": "DNS ルールをエクスポートできませんでした。",
"fingerprintMatchFailed": "フィンガープリントをプロキシに合わせられませんでした。"
"fingerprintMatchFailed": "フィンガープリントをプロキシに合わせられませんでした。",
"proxySidecarVersionMismatch": "Donut Browser のファイルに異なるバージョンが混在しています。最新のアップデートを再インストールしてください。プロファイルはそのまま保持されます。",
"updateProfilesRunning": "アップデートをインストールする前に、実行中のプロファイルをすべて停止してください。",
"updatePreparationFailed": "バックグラウンドのネットワークプロセスを安全に停止できませんでした。コンピューターを再起動してから、もう一度アップデートしてください。"
},
"rail": {
"profiles": "プロファイル",
+5 -2
View File
@@ -192,7 +192,7 @@
"copyLogs": "로그 복사",
"openLogDir": "로그 폴더 열기",
"copyLogsSuccess": "로그가 클립보드에 복사되었습니다",
"copyLogsDescription": "최 로그 파일(최대 5MB)을 클립보드에 묶어 버그 보고서에서 공유할 수 있도록 합니다."
"copyLogsDescription": "최 로그를 민감 정보가 제거된 묶음으로 복사합니다(최대 5MB). 모든 유형의 개인 데이터를 식별할 수는 없으므로 공유하기 전에 검토하세요."
},
"disableAutoUpdates": "앱 자동 업데이트 사용 안 함",
"disableAutoUpdatesDescription": "Donut Browser 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.",
@@ -1870,7 +1870,10 @@
"unsupportedDnsRulesFormat": "지원되지 않는 규칙 형식: {{format}}",
"dnsRulesSaveFailed": "DNS 규칙을 저장하지 못했습니다.",
"dnsRulesExportFailed": "DNS 규칙을 내보내지 못했습니다.",
"fingerprintMatchFailed": "지문을 프록시에 맞추지 못했습니다."
"fingerprintMatchFailed": "지문을 프록시에 맞추지 못했습니다.",
"proxySidecarVersionMismatch": "Donut Browser 파일에 서로 다른 버전이 섞여 있습니다. 최신 업데이트를 다시 설치해 주세요. 프로필은 안전하게 유지됩니다.",
"updateProfilesRunning": "업데이트를 설치하기 전에 실행 중인 모든 프로필을 중지하세요.",
"updatePreparationFailed": "Donut Browser가 백그라운드 네트워크 프로세스를 안전하게 중지하지 못했습니다. 컴퓨터를 다시 시작한 후 업데이트를 다시 시도하세요."
},
"rail": {
"profiles": "프로필",
+5 -2
View File
@@ -192,7 +192,7 @@
"copyLogs": "Copiar logs",
"openLogDir": "Abrir pasta de logs",
"copyLogsSuccess": "Logs copiados para a área de transferência",
"copyLogsDescription": "Junta os arquivos de log mais recentes (até 5 MB) na sua área de transferência para compartilhar em relatórios de bug."
"copyLogsDescription": "Copia um pacote editado dos logs recentes (até 5 MB). Revise-o antes de compartilhar, pois a edição não consegue identificar todos os tipos de dados pessoais."
},
"disableAutoUpdates": "Desativar Atualizações Automáticas do App",
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas.",
@@ -1870,7 +1870,10 @@
"unsupportedDnsRulesFormat": "Formato de regras não suportado: {{format}}",
"dnsRulesSaveFailed": "Falha ao salvar as regras DNS.",
"dnsRulesExportFailed": "Falha ao exportar as regras DNS.",
"fingerprintMatchFailed": "Não foi possível ajustar a impressão digital ao proxy."
"fingerprintMatchFailed": "Não foi possível ajustar a impressão digital ao proxy.",
"proxySidecarVersionMismatch": "Alguns arquivos do Donut Browser são de versões diferentes. Reinstale a atualização mais recente; seus perfis permanecerão seguros.",
"updateProfilesRunning": "Pare todos os perfis em execução antes de instalar a atualização.",
"updatePreparationFailed": "O Donut Browser não conseguiu encerrar com segurança um processo de rede em segundo plano. Reinicie o computador e tente atualizar novamente."
},
"rail": {
"profiles": "Perfis",
+5 -2
View File
@@ -192,7 +192,7 @@
"copyLogs": "Скопировать логи",
"openLogDir": "Открыть папку логов",
"copyLogsSuccess": "Логи скопированы в буфер обмена",
"copyLogsDescription": "Собирает последние файлы логов (до 5 МБ) в буфер обмена для прикрепления к багам."
"copyLogsDescription": "Копирует отредактированный набор последних логов (до 5 МБ). Проверьте его перед отправкой: редактирование не может выявить все виды персональных данных."
},
"disableAutoUpdates": "Отключить автообновление приложения",
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.",
@@ -1870,7 +1870,10 @@
"unsupportedDnsRulesFormat": "Неподдерживаемый формат правил: {{format}}",
"dnsRulesSaveFailed": "Не удалось сохранить правила DNS.",
"dnsRulesExportFailed": "Не удалось экспортировать правила DNS.",
"fingerprintMatchFailed": "Не удалось подогнать отпечаток под прокси."
"fingerprintMatchFailed": "Не удалось подогнать отпечаток под прокси.",
"proxySidecarVersionMismatch": "Некоторые файлы Donut Browser относятся к разным версиям. Переустановите последнее обновление — ваши профили останутся в безопасности.",
"updateProfilesRunning": "Остановите все запущенные профили перед установкой обновления.",
"updatePreparationFailed": "Donut Browser не удалось безопасно остановить фоновый сетевой процесс. Перезагрузите компьютер и повторите обновление."
},
"rail": {
"profiles": "Профили",
+5 -2
View File
@@ -192,7 +192,7 @@
"copyLogs": "Günlükleri kopyala",
"openLogDir": "Günlük klasörünü aç",
"copyLogsSuccess": "Günlükler panoya kopyalandı",
"copyLogsDescription": "En son günlük dosyalarını (en fazla 5 MB) hata raporlarında paylaşmak üzere panonuza paketler."
"copyLogsDescription": "Son günlüklerin hassas verileri ayıklanmış bir paketini kopyalar (en fazla 5 MB). Ayıklama her tür kişisel veriyi belirleyemeyeceğinden paylaşmadan önce inceleyin."
},
"disableAutoUpdates": "Uygulama Otomatik Güncellemelerini Devre Dışı Bırak",
"disableAutoUpdatesDescription": "Uygulamanın Donut Browser güncellemelerini otomatik olarak denetlemesini ve yüklemesini engelleyin. Tarayıcı güncellemeleri bundan etkilenmez.",
@@ -1870,7 +1870,10 @@
"unsupportedDnsRulesFormat": "Desteklenmeyen kural biçimi: {{format}}",
"dnsRulesSaveFailed": "DNS kuralları kaydedilemedi.",
"dnsRulesExportFailed": "DNS kuralları dışa aktarılamadı.",
"fingerprintMatchFailed": "Parmak izi proxy'ye eşlenemedi."
"fingerprintMatchFailed": "Parmak izi proxy'ye eşlenemedi.",
"proxySidecarVersionMismatch": "Bazı Donut Browser dosyaları farklı sürümlere ait. En son güncellemeyi yeniden yükleyin; profilleriniz güvende kalır.",
"updateProfilesRunning": "Güncellemeyi yüklemeden önce çalışan tüm profilleri durdurun.",
"updatePreparationFailed": "Donut Browser arka plandaki bir ağ işlemini güvenli şekilde durduramadı. Bilgisayarınızı yeniden başlatıp güncellemeyi tekrar deneyin."
},
"rail": {
"profiles": "Profiller",
+5 -2
View File
@@ -192,7 +192,7 @@
"copyLogs": "Sao chép nhật ký",
"openLogDir": "Mở thư mục nhật ký",
"copyLogsSuccess": "Đã sao chép nhật ký vào clipboard",
"copyLogsDescription": "Đóng gói các file nhật ký gần nhất (tối đa 5 MB) vào clipboard để chia sẻ trong báo cáo lỗi."
"copyLogsDescription": "Sao chép gói nhật ký gần đây đã được che thông tin nhạy cảm (tối đa 5 MB). Hãy xem lại trước khi chia sẻ vì việc che dữ liệu không thể nhận diện mọi loại dữ liệu cá nhân."
},
"disableAutoUpdates": "Tắt tự động cập nhật ứng dụng",
"disableAutoUpdatesDescription": "Ngăn ứng dụng tự động kiểm tra và cài đặt bản cập nhật Donut Browser. Cập nhật trình duyệt không bị ảnh hưởng.",
@@ -1870,7 +1870,10 @@
"unsupportedDnsRulesFormat": "Định dạng quy tắc không được hỗ trợ: {{format}}",
"dnsRulesSaveFailed": "Không thể lưu quy tắc DNS.",
"dnsRulesExportFailed": "Không thể xuất quy tắc DNS.",
"fingerprintMatchFailed": "Không thể khớp vân tay với proxy."
"fingerprintMatchFailed": "Không thể khớp vân tay với proxy.",
"proxySidecarVersionMismatch": "Một số tệp Donut Browser thuộc các phiên bản khác nhau. Hãy cài đặt lại bản cập nhật mới nhất; hồ sơ của bạn vẫn được giữ an toàn.",
"updateProfilesRunning": "Hãy dừng tất cả hồ sơ đang chạy trước khi cài đặt bản cập nhật.",
"updatePreparationFailed": "Donut Browser không thể dừng an toàn một tiến trình mạng chạy nền. Hãy khởi động lại máy tính rồi thử cập nhật lại."
},
"rail": {
"profiles": "Profile",
+5 -2
View File
@@ -192,7 +192,7 @@
"copyLogs": "复制日志",
"openLogDir": "打开日志文件夹",
"copyLogsSuccess": "日志已复制到剪贴板",
"copyLogsDescription": "将最近的日志文件(最多 5 MB)合并到剪贴板,便于在反馈问题时分享。"
"copyLogsDescription": "复制经过脱敏的近期日志包(最多 5 MB)。脱敏无法识别所有类型的个人数据,请在分享前检查内容。"
},
"disableAutoUpdates": "禁用应用自动更新",
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。",
@@ -1870,7 +1870,10 @@
"unsupportedDnsRulesFormat": "不支持的规则格式:{{format}}",
"dnsRulesSaveFailed": "保存 DNS 规则失败。",
"dnsRulesExportFailed": "导出 DNS 规则失败。",
"fingerprintMatchFailed": "无法将指纹匹配到代理。"
"fingerprintMatchFailed": "无法将指纹匹配到代理。",
"proxySidecarVersionMismatch": "部分 Donut Browser 文件来自不同版本。请重新安装最新更新;你的配置文件将保持安全。",
"updateProfilesRunning": "安装更新前,请停止所有正在运行的配置文件。",
"updatePreparationFailed": "Donut Browser 无法安全停止后台网络进程。请重启电脑,然后再次尝试更新。"
},
"rail": {
"profiles": "配置文件",
+9
View File
@@ -36,8 +36,11 @@ export type BackendErrorCode =
| "PROXY_PAYMENT_REQUIRED"
| "VPN_NOT_WORKING"
| "CAMOUFOX_IMPORT_DEPRECATED"
| "PROXY_SIDECAR_VERSION_MISMATCH"
| "UPDATE_CHECKSUMS_UNAVAILABLE"
| "UPDATE_CHECKSUM_MISMATCH"
| "UPDATE_PROFILES_RUNNING"
| "UPDATE_PREPARATION_FAILED"
| "PROFILE_NAME_EXISTS"
| "IMPORT_SOURCE_NOT_FOUND"
| "IMPORT_NO_ITEMS"
@@ -162,6 +165,8 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.vpnNotWorking");
case "CAMOUFOX_IMPORT_DEPRECATED":
return t("backendErrors.camoufoxImportDeprecated");
case "PROXY_SIDECAR_VERSION_MISMATCH":
return t("backendErrors.proxySidecarVersionMismatch");
case "UPDATE_CHECKSUMS_UNAVAILABLE":
return t("backendErrors.updateChecksumsUnavailable", {
version: parsed.params?.version ?? "",
@@ -170,6 +175,10 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.updateChecksumMismatch", {
file: parsed.params?.file ?? "",
});
case "UPDATE_PROFILES_RUNNING":
return t("backendErrors.updateProfilesRunning");
case "UPDATE_PREPARATION_FAILED":
return t("backendErrors.updatePreparationFailed");
case "PROFILE_NAME_EXISTS":
return t("backendErrors.profileNameExists", {
name: parsed.params?.name ?? "",
+2 -6
View File
@@ -167,12 +167,8 @@
body {
@apply bg-background text-foreground;
}
/* Interactive elements show a pointer cursor app-wide, so cursor behavior is
consistent everywhere without per-element classes and there's no lingering
"fix the cursor" surface for drive-by PRs. Disabled controls rely on
pointer-events:none / their own disabled cursor, and an explicit `cursor-*`
utility still wins over this base rule (utilities out-rank the base layer),
e.g. the invisible click-to-close backdrop and the auto-scroll buttons. */
/* Keep pointer behavior consistent; explicit cursor utilities still override
this base rule. */
button:not(:disabled),
[role="button"]:not([aria-disabled="true"]),
[role="menuitem"],