mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-25 05:40:50 +02:00
refactor: harden tests
This commit is contained in:
@@ -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:
|
||||
|
||||
+105
-15
@@ -8,6 +8,19 @@ on:
|
||||
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:
|
||||
@@ -20,6 +33,7 @@ on:
|
||||
- smoke
|
||||
- ui
|
||||
- entities
|
||||
- network
|
||||
- integrations
|
||||
- sync
|
||||
- browser
|
||||
@@ -51,13 +65,15 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
path: donutbrowser
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout private cross-platform WebDriver
|
||||
- name: Checkout test driver
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY || 'zhom/tauri-cross-platform-webdriver' }}
|
||||
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
|
||||
@@ -81,6 +97,7 @@ jobs:
|
||||
with:
|
||||
workspaces: |
|
||||
donutbrowser/src-tauri -> target
|
||||
donutbrowser/e2e/app -> target
|
||||
tauri-cross-platform-webdriver -> target
|
||||
|
||||
- name: Install Tauri dependencies
|
||||
@@ -95,8 +112,6 @@ jobs:
|
||||
|
||||
- name: Run isolated smoke suite
|
||||
working-directory: donutbrowser
|
||||
env:
|
||||
DONUT_E2E_KEEP_ARTIFACTS: "1"
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${RUNNER_OS}" == "Linux" ]]; then
|
||||
@@ -106,17 +121,17 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload E2E diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: app-e2e-smoke-${{ matrix.os }}
|
||||
path: ${{ runner.temp }}/donut-e2e-*
|
||||
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 }}
|
||||
if: ${{ inputs.smoke_only != true && inputs.suite != 'network' }}
|
||||
runs-on: macos-latest
|
||||
timeout-minutes: 40
|
||||
|
||||
@@ -125,13 +140,15 @@ jobs:
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
path: donutbrowser
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout private cross-platform WebDriver
|
||||
- name: Checkout test driver
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY || 'zhom/tauri-cross-platform-webdriver' }}
|
||||
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
|
||||
@@ -155,6 +172,7 @@ jobs:
|
||||
with:
|
||||
workspaces: |
|
||||
donutbrowser/src-tauri -> target
|
||||
donutbrowser/e2e/app -> target
|
||||
tauri-cross-platform-webdriver -> target
|
||||
|
||||
- name: Install app dependencies
|
||||
@@ -164,16 +182,88 @@ jobs:
|
||||
- name: Run full isolated app suite
|
||||
working-directory: donutbrowser
|
||||
env:
|
||||
DONUT_E2E_KEEP_ARTIFACTS: "1"
|
||||
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}
|
||||
run: node e2e/run.mjs "--suite=${DONUT_E2E_SUITE}"
|
||||
|
||||
- name: Upload E2E diagnostics
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: app-e2e-full-macos
|
||||
path: ${{ runner.temp }}/donut-e2e-*
|
||||
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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -30,7 +30,8 @@ jobs:
|
||||
name: Lint JavaScript/TypeScript
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -38,7 +39,8 @@ jobs:
|
||||
name: Lint Rust
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
@@ -46,7 +48,8 @@ jobs:
|
||||
name: CodeQL
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
@@ -57,7 +60,8 @@ jobs:
|
||||
name: Spell Check
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/spellcheck.yml
|
||||
secrets: inherit
|
||||
with:
|
||||
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -11,6 +11,11 @@ on:
|
||||
description: "Docker tag (e.g., v1.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
secrets:
|
||||
DOCKERHUB_USERNAME:
|
||||
required: true
|
||||
DOCKERHUB_TOKEN:
|
||||
required: true
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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') ||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,14 +39,15 @@ 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: inherit
|
||||
secrets:
|
||||
TAURI_WEBDRIVER_TOKEN: ${{ secrets.TAURI_WEBDRIVER_TOKEN }}
|
||||
WAYFERN_TEST_TOKEN: ${{ secrets.WAYFERN_TEST_TOKEN }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -218,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
|
||||
@@ -559,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'
|
||||
@@ -567,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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -241,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 }}
|
||||
@@ -419,7 +416,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Cloudflare Pages deployment
|
||||
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
|
||||
env:
|
||||
DEPLOYMENT_HOOK: ${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}
|
||||
run: curl -fsSL -X POST "$DEPLOYMENT_HOOK"
|
||||
|
||||
notify-discord:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
|
||||
@@ -5,6 +5,12 @@ permissions:
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
checkout_ref:
|
||||
description: Optional commit to check out instead of the triggering ref
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
@@ -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
|
||||
|
||||
@@ -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@v7
|
||||
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@v7
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
/e2e/app/target/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
@@ -52,6 +52,7 @@ donutbrowser/
|
||||
├── donut-sync/ # NestJS sync server (self-hostable)
|
||||
│ └── src/ # Controllers, services, auth, S3 sync
|
||||
├── 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
|
||||
@@ -68,8 +69,8 @@ donutbrowser/
|
||||
|
||||
### Native app E2E tests are mandatory for affected behavior
|
||||
|
||||
The native suites use the sibling `../tauri-cross-platform-webdriver/` repository and launch an
|
||||
`e2e`-feature build. Every session gets its own temporary Donut data/cache/log root, home directory,
|
||||
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
|
||||
@@ -79,17 +80,20 @@ suite passes:
|
||||
| Changed area | Required command |
|
||||
| --- | --- |
|
||||
| Startup, settings, persistence, window state, shortcuts, navigation | `pnpm e2e:smoke` |
|
||||
| React components, dialogs, responsive layout, accessibility, onboarding | `pnpm e2e:ui` |
|
||||
| Profiles, imports, groups, proxies, VPNs, extensions, DNS, cookies, passwords, traffic | `pnpm e2e:entities` |
|
||||
| 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` and the full suite require `WAYFERN_TEST_TOKEN` in the environment or the local
|
||||
`.env`; all other 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.
|
||||
`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.
|
||||
|
||||
+40
-15
@@ -1,7 +1,7 @@
|
||||
# Donut Browser native E2E tests
|
||||
|
||||
These tests exercise the actual Tauri application through the private
|
||||
`../tauri-cross-platform-webdriver/` driver. They do not replace Rust or React unit tests; they
|
||||
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.
|
||||
@@ -13,14 +13,22 @@ Place both repositories beside each other:
|
||||
```text
|
||||
Code/
|
||||
├── donutbrowser/
|
||||
└── tauri-cross-platform-webdriver/
|
||||
└── <test-driver-checkout>/
|
||||
```
|
||||
|
||||
Install Donut dependencies with `pnpm install`. The browser suite also needs
|
||||
`WAYFERN_TEST_TOKEN`. The runner reads it from the environment, Donut's `.env`, or
|
||||
`../wayfern-test/.env` without printing it. If `../wayfern-test/test_extracted_app` contains a
|
||||
Wayfern build, the runner links/copies it into the test data root; otherwise the browser suite
|
||||
downloads the current published build into that root.
|
||||
`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:
|
||||
|
||||
@@ -28,15 +36,31 @@ Run one suite:
|
||||
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
|
||||
`e2e`-feature app, and `tauri-wd`. Add `--no-build` to `node e2e/run.mjs --suite=<name>` only when
|
||||
all four outputs are current. `DONUT_E2E_KEEP_ARTIFACTS=1` retains successful runs; failed runs are
|
||||
always retained and their location is printed.
|
||||
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
|
||||
|
||||
@@ -60,9 +84,10 @@ the WebDriver plugin or this fallback.
|
||||
|
||||
`.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.
|
||||
including MinIO-backed sync and real Wayfern automation, plus a Linux/Docker job for residential
|
||||
proxy and local WireGuard browser traffic.
|
||||
|
||||
Because the WebDriver repository is private, CI needs a `TAURI_WEBDRIVER_TOKEN` secret with
|
||||
read-only access. `TAURI_WEBDRIVER_REPOSITORY` may override its default
|
||||
`zhom/tauri-cross-platform-webdriver` repository name. The full job also requires the
|
||||
`WAYFERN_TEST_TOKEN` secret.
|
||||
CI needs a `TAURI_WEBDRIVER_TOKEN` secret with read-only access and a
|
||||
`TAURI_WEBDRIVER_REPOSITORY` repository variable identifying the test-driver checkout. The full
|
||||
job also requires the `WAYFERN_TEST_TOKEN` secret. The network job requires that secret plus
|
||||
`RESIDENTIAL_PROXY_URL_ONE_HTTP` and `RESIDENTIAL_PROXY_URL_ONE_SOCKS`.
|
||||
|
||||
Generated
+9073
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
||||
[package]
|
||||
name = "donutbrowser-e2e"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
donutbrowser-lib = { package = "donutbrowser", path = "../../src-tauri", features = ["e2e"] }
|
||||
tauri-plugin-cross-platform-webdriver = { path = "../../../tauri-cross-platform-webdriver/crates/tauri-plugin-cross-platform-webdriver" }
|
||||
|
||||
[patch.crates-io]
|
||||
wayland-scanner = { git = "https://github.com/Smithay/wayland-rs", rev = "d07c4f91f28b42e5a485823ffd9d8d5a210b1053" }
|
||||
@@ -0,0 +1,7 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
donutbrowser_lib::run_with_builder(|builder| {
|
||||
builder.plugin(tauri_plugin_cross_platform_webdriver::init())
|
||||
});
|
||||
}
|
||||
+107
@@ -47,6 +47,8 @@ export class AppSession {
|
||||
extraEnv = {},
|
||||
args = [],
|
||||
seedVersionCache = true,
|
||||
onboardingCompleted = true,
|
||||
wayfernTermsAccepted = true,
|
||||
}) {
|
||||
this.name = name;
|
||||
this.root = root;
|
||||
@@ -57,6 +59,8 @@ export class AppSession {
|
||||
this.extraEnv = extraEnv;
|
||||
this.args = args;
|
||||
this.seedVersionCache = seedVersionCache;
|
||||
this.onboardingCompleted = onboardingCompleted;
|
||||
this.wayfernTermsAccepted = wayfernTermsAccepted;
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
@@ -70,6 +74,69 @@ export class AppSession {
|
||||
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,
|
||||
@@ -257,6 +324,44 @@ export class AppSession {
|
||||
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(
|
||||
() =>
|
||||
@@ -366,6 +471,8 @@ export function appFromEnvironment(name, options = {}) {
|
||||
extraEnv: options.extraEnv,
|
||||
args: options.args,
|
||||
seedVersionCache: options.seedVersionCache,
|
||||
onboardingCompleted: options.onboardingCompleted,
|
||||
wayfernTermsAccepted: options.wayfernTermsAccepted,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+44
-23
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { chmod, copyFile, mkdir, symlink, writeFile } from "node:fs/promises";
|
||||
import { chmod, copyFile, cp, mkdir, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
|
||||
@@ -11,17 +11,13 @@ export function defaultWayfernPath(projectRoot) {
|
||||
if (process.env.DONUT_E2E_WAYFERN_PATH) {
|
||||
return path.resolve(process.env.DONUT_E2E_WAYFERN_PATH);
|
||||
}
|
||||
const sibling = path.resolve(
|
||||
projectRoot,
|
||||
"../wayfern-test/test_extracted_app",
|
||||
);
|
||||
if (process.platform === "darwin") {
|
||||
return path.join(sibling, "Wayfern.app");
|
||||
}
|
||||
if (process.platform === "win32") {
|
||||
return path.join(sibling, "Wayfern.exe");
|
||||
}
|
||||
return path.join(sibling, "wayfern");
|
||||
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) {
|
||||
@@ -60,18 +56,16 @@ export function inspectWayfern(bundlePath) {
|
||||
return { bundlePath, executable, version: match[1], output };
|
||||
}
|
||||
|
||||
async function linkOrCopy(source, destination) {
|
||||
async function cloneAppBundle(source, destination) {
|
||||
await mkdir(path.dirname(destination), { recursive: true });
|
||||
try {
|
||||
await symlink(
|
||||
source,
|
||||
destination,
|
||||
process.platform === "win32" ? "junction" : undefined,
|
||||
);
|
||||
} catch (error) {
|
||||
if (error.code !== "EEXIST") {
|
||||
throw error;
|
||||
}
|
||||
execFileSync("/bin/cp", ["-cR", source, destination]);
|
||||
} catch (_error) {
|
||||
await cp(source, destination, {
|
||||
recursive: true,
|
||||
preserveTimestamps: true,
|
||||
errorOnExist: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,7 +79,10 @@ export async function seedWayfern(dataRoot, wayfern) {
|
||||
);
|
||||
await mkdir(installDir, { recursive: true });
|
||||
if (process.platform === "darwin") {
|
||||
await linkOrCopy(wayfern.bundlePath, path.join(installDir, "Wayfern.app"));
|
||||
await cloneAppBundle(
|
||||
wayfern.bundlePath,
|
||||
path.join(installDir, "Wayfern.app"),
|
||||
);
|
||||
} else {
|
||||
const name = process.platform === "win32" ? "wayfern.exe" : "wayfern";
|
||||
const destination = path.join(installDir, name);
|
||||
@@ -116,6 +113,30 @@ export async function seedWayfern(dataRoot, wayfern) {
|
||||
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]",
|
||||
|
||||
+223
-29
@@ -7,7 +7,15 @@ import {
|
||||
existsSync,
|
||||
statSync,
|
||||
} from "node:fs";
|
||||
import { chmod, mkdir, mkdtemp, readFile, rename, rm } from "node:fs/promises";
|
||||
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";
|
||||
@@ -15,6 +23,7 @@ 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, "..");
|
||||
@@ -26,10 +35,11 @@ const isWindows = process.platform === "win32";
|
||||
const executableSuffix = isWindows ? ".exe" : "";
|
||||
const appBinary = path.join(
|
||||
projectRoot,
|
||||
"src-tauri",
|
||||
"e2e",
|
||||
"app",
|
||||
"target",
|
||||
"debug",
|
||||
`donutbrowser${executableSuffix}`,
|
||||
`donutbrowser-e2e${executableSuffix}`,
|
||||
);
|
||||
const driverBinary = path.join(
|
||||
webdriverRoot,
|
||||
@@ -39,17 +49,20 @@ const driverBinary = path.join(
|
||||
);
|
||||
|
||||
const suiteFiles = {
|
||||
smoke: ["smoke.test.mjs", "coverage.test.mjs"],
|
||||
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",
|
||||
@@ -189,28 +202,42 @@ async function stopProcess(record) {
|
||||
record.stream.end();
|
||||
}
|
||||
|
||||
async function loadLocalToken() {
|
||||
if (process.env.WAYFERN_TEST_TOKEN) {
|
||||
return process.env.WAYFERN_TEST_TOKEN;
|
||||
function unquoteEnvValue(value) {
|
||||
const trimmed = value.trim();
|
||||
const quote = trimmed[0];
|
||||
if ((quote === '"' || quote === "'") && trimmed.at(-1) === quote) {
|
||||
return trimmed.slice(1, -1);
|
||||
}
|
||||
for (const file of [
|
||||
path.join(projectRoot, ".env"),
|
||||
path.resolve(projectRoot, "../wayfern-test/.env"),
|
||||
]) {
|
||||
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");
|
||||
const match = content.match(
|
||||
/^\s*(?:export\s+)?WAYFERN_TEST_TOKEN\s*=\s*(.+?)\s*$/m,
|
||||
);
|
||||
if (match) {
|
||||
const raw = match[1].trim();
|
||||
return raw.replace(/^(['"])(.*)\1$/, "$2");
|
||||
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 {
|
||||
// A local token is only mandatory for the browser suite.
|
||||
// Individual suites validate the secrets they require.
|
||||
}
|
||||
}
|
||||
return "";
|
||||
return values;
|
||||
}
|
||||
|
||||
function buildAll() {
|
||||
@@ -221,8 +248,8 @@ function buildAll() {
|
||||
run("pnpm", ["copy-proxy-binary"], projectRoot);
|
||||
run(
|
||||
"cargo",
|
||||
["build", "--features", "e2e", "--bin", "donutbrowser"],
|
||||
path.join(projectRoot, "src-tauri"),
|
||||
["build", "--locked", "--manifest-path", "e2e/app/Cargo.toml"],
|
||||
projectRoot,
|
||||
);
|
||||
run(
|
||||
"cargo",
|
||||
@@ -474,21 +501,159 @@ async function startSyncInfrastructure(runRoot, options, records) {
|
||||
};
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
};
|
||||
@@ -536,22 +701,42 @@ async function main() {
|
||||
await waitForUrl(`http://127.0.0.1:${driverPort}/status`, 15_000, driver);
|
||||
|
||||
const needsBrowser =
|
||||
options.suite === "browser" || options.suite === "full";
|
||||
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);
|
||||
}
|
||||
|
||||
const token = await loadLocalToken();
|
||||
if ((options.suite === "browser" || options.suite === "full") && !token) {
|
||||
throw new Error("WAYFERN_TEST_TOKEN is required by the browser suite");
|
||||
if (networkEnabled && process.env.DONUT_E2E_SKIP_VPN_TUNNEL !== "1") {
|
||||
wireGuard = await startWireGuardInfrastructure();
|
||||
}
|
||||
|
||||
const files = suiteFiles[options.suite].map((file) =>
|
||||
path.join(dirname, "tests", file),
|
||||
);
|
||||
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",
|
||||
@@ -570,9 +755,18 @@ async function main() {
|
||||
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",
|
||||
});
|
||||
|
||||
@@ -7,11 +7,7 @@ import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { appFromEnvironment } from "../lib/app.mjs";
|
||||
import { CdpClient } from "../lib/cdp.mjs";
|
||||
import {
|
||||
defaultWayfernPath,
|
||||
inspectWayfern,
|
||||
seedWayfern,
|
||||
} from "../lib/fixtures.mjs";
|
||||
import { defaultWayfernPath, prepareWayfern } from "../lib/fixtures.mjs";
|
||||
|
||||
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
|
||||
|
||||
@@ -36,30 +32,6 @@ async function request(url, { method = "GET", token, body } = {}) {
|
||||
return { response, value };
|
||||
}
|
||||
|
||||
async function prepareWayfern(app) {
|
||||
const localBundle = defaultWayfernPath(process.env.DONUT_E2E_PROJECT_ROOT);
|
||||
if (existsSync(localBundle)) {
|
||||
const wayfern = inspectWayfern(localBundle);
|
||||
await seedWayfern(app.dataRoot, wayfern);
|
||||
return { version: wayfern.version, source: "local fixture" };
|
||||
}
|
||||
|
||||
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" };
|
||||
}
|
||||
|
||||
function processExists(pid) {
|
||||
if (!pid) return false;
|
||||
try {
|
||||
@@ -166,11 +138,15 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
);
|
||||
const app = appFromEnvironment("browser-wayfern", {
|
||||
seedVersionCache: hasLocalWayfern,
|
||||
wayfernTermsAccepted: false,
|
||||
});
|
||||
let cdp;
|
||||
let browserPid;
|
||||
try {
|
||||
const prepared = await prepareWayfern(app);
|
||||
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);
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
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) {
|
||||
@@ -58,10 +67,15 @@ test("every Tauri command has exactly one E2E owner and evidence level", async (
|
||||
continue;
|
||||
}
|
||||
|
||||
const suiteSource = await readFile(
|
||||
const evidenceFiles = [
|
||||
path.join(root, "e2e", "tests", `${entry.suite}.test.mjs`),
|
||||
"utf8",
|
||||
);
|
||||
...(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),
|
||||
@@ -94,3 +108,38 @@ test("WebDriver client preserves application values that contain an error field"
|
||||
await new Promise((resolve) => server.close(resolve));
|
||||
}
|
||||
});
|
||||
|
||||
test("Wayfern fixtures are copied into the isolated data root, never linked", async (t) => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "donut-wayfern-copy-"));
|
||||
t.after(() => rm(root, { recursive: true, force: true }));
|
||||
const source =
|
||||
process.platform === "darwin"
|
||||
? path.join(root, "source", "Wayfern.app", "Contents", "MacOS", "Wayfern")
|
||||
: path.join(
|
||||
root,
|
||||
"source",
|
||||
process.platform === "win32" ? "Wayfern.exe" : "wayfern",
|
||||
);
|
||||
await mkdir(path.dirname(source), { recursive: true });
|
||||
await writeFile(source, "source-fixture");
|
||||
const bundlePath =
|
||||
process.platform === "darwin"
|
||||
? path.join(root, "source", "Wayfern.app")
|
||||
: source;
|
||||
const installDir = await seedWayfern(path.join(root, "isolated"), {
|
||||
bundlePath,
|
||||
executable: source,
|
||||
version: "1.2.3.4",
|
||||
});
|
||||
const destination =
|
||||
process.platform === "darwin"
|
||||
? path.join(installDir, "Wayfern.app", "Contents", "MacOS", "Wayfern")
|
||||
: path.join(
|
||||
installDir,
|
||||
process.platform === "win32" ? "wayfern.exe" : "wayfern",
|
||||
);
|
||||
|
||||
assert.equal((await lstat(destination)).isSymbolicLink(), false);
|
||||
await writeFile(destination, "isolated-mutation");
|
||||
assert.equal(await readFile(source, "utf8"), "source-fixture");
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import assert from "node:assert/strict";
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { after, test } from "node:test";
|
||||
import { redactIssueBody } from "../../scripts/redact-sensitive-text.mjs";
|
||||
import { createSafeDiagnostics } from "../lib/diagnostics.mjs";
|
||||
|
||||
const roots = [];
|
||||
after(async () => {
|
||||
await Promise.all(
|
||||
roots.map((root) => rm(root, { recursive: true, force: true })),
|
||||
);
|
||||
});
|
||||
|
||||
test("shared E2E diagnostics contain only redacted text logs", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "donut-diagnostics-test-"));
|
||||
roots.push(root);
|
||||
const secretUrl = "http://real-user:real-password@proxy.example:8080";
|
||||
const token = ["github", "pat", "example", "token", "0123456789"].join("_");
|
||||
const logText = [
|
||||
`proxy=${secretUrl}`,
|
||||
`Authorization: Bearer ${token}`,
|
||||
"visited https://example.com/callback?code=private-code",
|
||||
"exit IP 203.0.113.42",
|
||||
"home /Users/private-person/Library/Application Support",
|
||||
"email private.person@example.com",
|
||||
"PrivateKey = wireguard-private-key",
|
||||
].join("\n");
|
||||
|
||||
await Promise.all([
|
||||
mkdir(path.join(root, "logs"), { recursive: true }),
|
||||
mkdir(path.join(root, "sessions", "network", "donut", "logs"), {
|
||||
recursive: true,
|
||||
}),
|
||||
mkdir(path.join(root, "sessions", "network", "donut", "data", "proxies"), {
|
||||
recursive: true,
|
||||
}),
|
||||
mkdir(path.join(root, "sessions", "network", "artifacts"), {
|
||||
recursive: true,
|
||||
}),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(path.join(root, "logs", "driver.log"), logText),
|
||||
writeFile(
|
||||
path.join(root, "sessions", "network", "donut", "logs", "app.log"),
|
||||
logText,
|
||||
),
|
||||
writeFile(
|
||||
path.join(
|
||||
root,
|
||||
"sessions",
|
||||
"network",
|
||||
"donut",
|
||||
"data",
|
||||
"proxies",
|
||||
"real.json",
|
||||
),
|
||||
JSON.stringify({ upstream_url: secretUrl, token }),
|
||||
),
|
||||
writeFile(
|
||||
path.join(root, "sessions", "network", "artifacts", "page.html"),
|
||||
`<html>${secretUrl}</html>`,
|
||||
),
|
||||
]);
|
||||
|
||||
const diagnostics = await createSafeDiagnostics(root, {
|
||||
suite: "network",
|
||||
failed: true,
|
||||
sensitiveValues: [secretUrl, token],
|
||||
});
|
||||
const files = await readdir(diagnostics);
|
||||
assert.deepEqual(files.sort(), ["001.log", "002.log", "summary.json"]);
|
||||
const combined = (
|
||||
await Promise.all(
|
||||
files.map((file) => readFile(path.join(diagnostics, file), "utf8")),
|
||||
)
|
||||
).join("\n");
|
||||
for (const value of [
|
||||
secretUrl,
|
||||
"real-user",
|
||||
"real-password",
|
||||
"proxy.example",
|
||||
token,
|
||||
"private-code",
|
||||
"203.0.113.42",
|
||||
"private-person",
|
||||
"private.person@example.com",
|
||||
"wireguard-private-key",
|
||||
]) {
|
||||
assert.ok(!combined.includes(value), `diagnostics leaked ${value}`);
|
||||
}
|
||||
assert.ok(
|
||||
!files.some(
|
||||
(file) => /\.(?:html|json)$/u.test(file) && file !== "summary.json",
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
test("automated issue processing omits the complete log field", () => {
|
||||
const safe = redactIssueBody(
|
||||
`### What happened?\nA failure at user@example.com\n\n### Error logs or screenshots\nARBITRARY_PRIVATE_LOG_CONTENT\npassword=hunter2\n\n### Operating System\nLinux`,
|
||||
);
|
||||
assert.ok(!safe.includes("ARBITRARY_PRIVATE_LOG_CONTENT"));
|
||||
assert.ok(!safe.includes("hunter2"));
|
||||
assert.ok(!safe.includes("user@example.com"));
|
||||
assert.match(safe, /omitted from automated processing/u);
|
||||
assert.match(safe, /Operating System\nLinux/u);
|
||||
});
|
||||
@@ -25,7 +25,6 @@ async function createProfile(app, name = "Entity Profile") {
|
||||
|
||||
test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", async () => {
|
||||
await withApp("entities-core", async (app) => {
|
||||
await app.invoke("complete_onboarding");
|
||||
const group = await app.invoke("create_profile_group", {
|
||||
name: "Research",
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
+60
-53
@@ -5,59 +5,66 @@ 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");
|
||||
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 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");
|
||||
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.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);
|
||||
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);
|
||||
});
|
||||
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 () => {
|
||||
@@ -89,15 +96,15 @@ test("two isolated sessions run concurrently and do not share frontend or backen
|
||||
|
||||
test("keyboard command palette and major navigation surfaces are operable through native WebDriver", async () => {
|
||||
await withApp("smoke-ui", async (app) => {
|
||||
await app.invoke("complete_onboarding");
|
||||
const modifier =
|
||||
process.platform === "darwin" ? { meta: true } : { ctrl: true };
|
||||
await app.pressShortcut({ key: "k", ...modifier });
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
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" },
|
||||
);
|
||||
|
||||
|
||||
+336
-4
@@ -2,15 +2,236 @@ 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) => {
|
||||
await app.invoke("complete_onboarding");
|
||||
await app.restart();
|
||||
const surfaces = [
|
||||
["Settings", /General|Appearance|Sync/i],
|
||||
["Network", /Proxies|VPNs|DNS/i],
|
||||
@@ -55,8 +276,6 @@ test("all primary navigation buttons and sub-page tabs render and remain interac
|
||||
|
||||
test("settings tabs, command palette filtering, and responsive layout survive resize", async () => {
|
||||
await withApp("ui-settings-responsive", async (app) => {
|
||||
await app.invoke("complete_onboarding");
|
||||
await app.restart();
|
||||
await app.clickSelector('[aria-label="Settings"]');
|
||||
await app.waitForText("Appearance");
|
||||
for (const tab of ["Appearance", "Sync", "Encryption"]) {
|
||||
@@ -115,3 +334,116 @@ test("settings tabs, command palette filtering, and responsive layout survive re
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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" },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+2
-1
@@ -16,6 +16,7 @@
|
||||
"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",
|
||||
@@ -34,7 +35,7 @@
|
||||
"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"
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
Generated
+70
-231
@@ -442,7 +442,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b"
|
||||
dependencies = [
|
||||
"atk-sys",
|
||||
"glib 0.18.5",
|
||||
"glib",
|
||||
"libc",
|
||||
]
|
||||
|
||||
@@ -452,10 +452,10 @@ version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086"
|
||||
dependencies = [
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -912,7 +912,7 @@ checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"cairo-sys-rs",
|
||||
"glib 0.18.5",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"thiserror 1.0.69",
|
||||
@@ -924,9 +924,9 @@ version = "0.18.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51"
|
||||
dependencies = [
|
||||
"glib-sys 0.18.1",
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1027,17 +1027,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon 0.12.16",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.20.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon 0.13.5",
|
||||
"target-lexicon",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1846,7 +1836,7 @@ dependencies = [
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"once_cell",
|
||||
"quick-xml 0.41.0",
|
||||
"quick-xml",
|
||||
"rand 0.10.2",
|
||||
"regex-lite",
|
||||
"reqwest",
|
||||
@@ -1867,7 +1857,6 @@ dependencies = [
|
||||
"tauri",
|
||||
"tauri-build",
|
||||
"tauri-plugin-clipboard-manager",
|
||||
"tauri-plugin-cross-platform-webdriver",
|
||||
"tauri-plugin-deep-link",
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-fs",
|
||||
@@ -2481,7 +2470,7 @@ dependencies = [
|
||||
"gdk-pixbuf",
|
||||
"gdk-sys",
|
||||
"gio",
|
||||
"glib 0.18.5",
|
||||
"glib",
|
||||
"libc",
|
||||
"pango",
|
||||
]
|
||||
@@ -2494,7 +2483,7 @@ checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec"
|
||||
dependencies = [
|
||||
"gdk-pixbuf-sys",
|
||||
"gio",
|
||||
"glib 0.18.5",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
]
|
||||
@@ -2505,11 +2494,11 @@ version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7"
|
||||
dependencies = [
|
||||
"gio-sys 0.18.1",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2520,13 +2509,13 @@ checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7"
|
||||
dependencies = [
|
||||
"cairo-sys-rs",
|
||||
"gdk-pixbuf-sys",
|
||||
"gio-sys 0.18.1",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"pkg-config",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2536,11 +2525,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69"
|
||||
dependencies = [
|
||||
"gdk-sys",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2552,7 +2541,7 @@ dependencies = [
|
||||
"gdk",
|
||||
"gdkx11-sys",
|
||||
"gio",
|
||||
"glib 0.18.5",
|
||||
"glib",
|
||||
"libc",
|
||||
"x11",
|
||||
]
|
||||
@@ -2564,9 +2553,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d"
|
||||
dependencies = [
|
||||
"gdk-sys",
|
||||
"glib-sys 0.18.1",
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
"x11",
|
||||
]
|
||||
|
||||
@@ -2664,8 +2653,8 @@ dependencies = [
|
||||
"futures-core",
|
||||
"futures-io",
|
||||
"futures-util",
|
||||
"gio-sys 0.18.1",
|
||||
"glib 0.18.5",
|
||||
"gio-sys",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pin-project-lite",
|
||||
@@ -2679,26 +2668,13 @@ version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2"
|
||||
dependencies = [
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gio-sys"
|
||||
version = "0.21.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0071fe88dba8e40086c8ff9bbb62622999f49628344b1d1bf490a48a29d80f22"
|
||||
dependencies = [
|
||||
"glib-sys 0.21.5",
|
||||
"gobject-sys 0.21.5",
|
||||
"libc",
|
||||
"system-deps 7.0.8",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.18.5"
|
||||
@@ -2711,10 +2687,10 @@ dependencies = [
|
||||
"futures-executor",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"gio-sys 0.18.1",
|
||||
"glib-macros 0.18.5",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"gio-sys",
|
||||
"glib-macros",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"memchr",
|
||||
"once_cell",
|
||||
@@ -2722,27 +2698,6 @@ dependencies = [
|
||||
"thiserror 1.0.69",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib"
|
||||
version = "0.21.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16de123c2e6c90ce3b573b7330de19be649080ec612033d397d72da265f1bd8b"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"futures-channel",
|
||||
"futures-core",
|
||||
"futures-executor",
|
||||
"futures-task",
|
||||
"futures-util",
|
||||
"gio-sys 0.21.5",
|
||||
"glib-macros 0.21.5",
|
||||
"glib-sys 0.21.5",
|
||||
"gobject-sys 0.21.5",
|
||||
"libc",
|
||||
"memchr",
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-macros"
|
||||
version = "0.18.5"
|
||||
@@ -2757,19 +2712,6 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-macros"
|
||||
version = "0.21.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf59b675301228a696fe01c3073974643365080a76cc3ed5bc2cbc466ad87f17"
|
||||
dependencies = [
|
||||
"heck 0.5.0",
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-sys"
|
||||
version = "0.18.1"
|
||||
@@ -2777,17 +2719,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "glib-sys"
|
||||
version = "0.21.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2d95e1a3a19ae464a7286e14af9a90683c64d70c02532d88d87ce95056af3e6c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"system-deps 7.0.8",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2815,20 +2747,9 @@ version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44"
|
||||
dependencies = [
|
||||
"glib-sys 0.18.1",
|
||||
"glib-sys",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gobject-sys"
|
||||
version = "0.21.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2dca35da0d19a18f4575f3cb99fe1c9e029a2941af5662f326f738a21edaf294"
|
||||
dependencies = [
|
||||
"glib-sys 0.21.5",
|
||||
"libc",
|
||||
"system-deps 7.0.8",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2844,7 +2765,7 @@ dependencies = [
|
||||
"gdk",
|
||||
"gdk-pixbuf",
|
||||
"gio",
|
||||
"glib 0.18.5",
|
||||
"glib",
|
||||
"gtk-sys",
|
||||
"gtk3-macros",
|
||||
"libc",
|
||||
@@ -2862,12 +2783,12 @@ dependencies = [
|
||||
"cairo-sys-rs",
|
||||
"gdk-pixbuf-sys",
|
||||
"gdk-sys",
|
||||
"gio-sys 0.18.1",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"pango-sys",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3506,7 +3427,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"glib 0.18.5",
|
||||
"glib",
|
||||
"javascriptcore-rs-sys",
|
||||
]
|
||||
|
||||
@@ -3516,10 +3437,10 @@ version = "1.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124"
|
||||
dependencies = [
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3675,7 +3596,7 @@ version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a"
|
||||
dependencies = [
|
||||
"glib 0.18.5",
|
||||
"glib",
|
||||
"gtk",
|
||||
"gtk-sys",
|
||||
"libappindicator-sys",
|
||||
@@ -4357,16 +4278,6 @@ dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-javascript-core"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a1e6550c4caed348956ce3370c9ffeca70bb1dbed4fa96112e7c6170e074586"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-open-directory"
|
||||
version = "0.3.2"
|
||||
@@ -4390,17 +4301,6 @@ dependencies = [
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-security"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "709fe137109bd1e8b5a99390f77a7d8b2961dafc1a1c5db8f2e60329ad6d895a"
|
||||
dependencies = [
|
||||
"bitflags 2.13.0",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-ui-kit"
|
||||
version = "0.3.2"
|
||||
@@ -4444,8 +4344,6 @@ dependencies = [
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"objc2-javascript-core",
|
||||
"objc2-security",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4563,7 +4461,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4"
|
||||
dependencies = [
|
||||
"gio",
|
||||
"glib 0.18.5",
|
||||
"glib",
|
||||
"libc",
|
||||
"once_cell",
|
||||
"pango-sys",
|
||||
@@ -4575,10 +4473,10 @@ version = "0.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5"
|
||||
dependencies = [
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4788,7 +4686,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.14.0",
|
||||
"quick-xml 0.41.0",
|
||||
"quick-xml",
|
||||
"serde",
|
||||
"time",
|
||||
]
|
||||
@@ -5043,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"
|
||||
@@ -5392,8 +5281,8 @@ checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672"
|
||||
dependencies = [
|
||||
"block2",
|
||||
"dispatch2",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"js-sys",
|
||||
"log",
|
||||
@@ -6251,7 +6140,7 @@ checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f"
|
||||
dependencies = [
|
||||
"futures-channel",
|
||||
"gio",
|
||||
"glib 0.18.5",
|
||||
"glib",
|
||||
"libc",
|
||||
"soup3-sys",
|
||||
]
|
||||
@@ -6262,11 +6151,11 @@ version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27"
|
||||
dependencies = [
|
||||
"gio-sys 0.18.1",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"libc",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6465,26 +6354,13 @@ version = "6.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349"
|
||||
dependencies = [
|
||||
"cfg-expr 0.15.8",
|
||||
"cfg-expr",
|
||||
"heck 0.5.0",
|
||||
"pkg-config",
|
||||
"toml 0.8.2",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "system-deps"
|
||||
version = "7.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "396a35feb67335377e0251fcbc1092fc85c484bd4e3a7a54319399da127796e7"
|
||||
dependencies = [
|
||||
"cfg-expr 0.20.8",
|
||||
"heck 0.5.0",
|
||||
"pkg-config",
|
||||
"toml 1.1.2+spec-1.1.0",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.35.3"
|
||||
@@ -6559,12 +6435,6 @@ version = "0.12.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1"
|
||||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tauri"
|
||||
version = "2.11.5"
|
||||
@@ -6710,36 +6580,6 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-cross-platform-webdriver"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
"cairo-rs",
|
||||
"glib 0.21.5",
|
||||
"gtk",
|
||||
"javascriptcore-rs",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-foundation",
|
||||
"objc2-web-kit",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"tauri-plugin",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"webview2-com",
|
||||
"windows 0.61.3",
|
||||
"windows-core 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-deep-link"
|
||||
version = "2.4.9"
|
||||
@@ -8065,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",
|
||||
]
|
||||
|
||||
@@ -8115,10 +7954,10 @@ dependencies = [
|
||||
"gdk",
|
||||
"gdk-sys",
|
||||
"gio",
|
||||
"gio-sys 0.18.1",
|
||||
"glib 0.18.5",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"gio-sys",
|
||||
"glib",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk",
|
||||
"gtk-sys",
|
||||
"javascriptcore-rs",
|
||||
@@ -8137,15 +7976,15 @@ dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"cairo-sys-rs",
|
||||
"gdk-sys",
|
||||
"gio-sys 0.18.1",
|
||||
"glib-sys 0.18.1",
|
||||
"gobject-sys 0.18.0",
|
||||
"gio-sys",
|
||||
"glib-sys",
|
||||
"gobject-sys",
|
||||
"gtk-sys",
|
||||
"javascriptcore-rs-sys",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"soup3-sys",
|
||||
"system-deps 6.2.2",
|
||||
"system-deps",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -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"
|
||||
@@ -42,7 +42,6 @@ tauri-plugin-macos-permissions = "2"
|
||||
tauri-plugin-log = "2"
|
||||
tauri-plugin-clipboard-manager = "2"
|
||||
tauri-plugin-window-state = "2"
|
||||
tauri-plugin-cross-platform-webdriver = { path = "../../tauri-cross-platform-webdriver/crates/tauri-plugin-cross-platform-webdriver", optional = true }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
@@ -187,4 +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 = ["dep:tauri-plugin-cross-platform-webdriver"]
|
||||
e2e = []
|
||||
|
||||
# wayland-scanner 0.31.10 still pins vulnerable quick-xml 0.39. Upstream fixed
|
||||
# RUSTSEC-2026-0194 and RUSTSEC-2026-0195, but has not published the fix yet.
|
||||
# Remove this patch after the next wayland-scanner release is in the lockfile.
|
||||
[patch.crates-io]
|
||||
wayland-scanner = { git = "https://github.com/Smithay/wayland-rs", rev = "d07c4f91f28b42e5a485823ffd9d8d5a210b1053" }
|
||||
|
||||
+2
-20
@@ -8,26 +8,8 @@
|
||||
<string>Donut needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Donut has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Donut</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Donut</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.donutbrowser</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.donutbrowser</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>donutbrowser</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2025 Donut</string>
|
||||
<string>Copyright © 2026 Donut</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
@@ -57,4 +39,4 @@
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -5,25 +5,24 @@
|
||||
"windows": ["main"],
|
||||
"webviews": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-emit",
|
||||
"core:event:allow-emit-to",
|
||||
"core:event:allow-unlisten",
|
||||
"core:image:default",
|
||||
"core:menu:default",
|
||||
"core:path:default",
|
||||
"core:tray:default",
|
||||
"core:webview:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"opener:default",
|
||||
{
|
||||
"identifier": "opener:allow-open-url",
|
||||
"allow": [
|
||||
{
|
||||
"url": "https://*"
|
||||
},
|
||||
{
|
||||
"url": "http://*"
|
||||
},
|
||||
{
|
||||
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
|
||||
},
|
||||
@@ -32,28 +31,16 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"fs:default",
|
||||
"shell:allow-execute",
|
||||
"shell:allow-kill",
|
||||
"shell:allow-open",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-stdin-write",
|
||||
"deep-link:default",
|
||||
"deep-link:allow-register",
|
||||
"deep-link:allow-unregister",
|
||||
"deep-link:allow-is-registered",
|
||||
"fs:allow-read-text-file",
|
||||
"fs:allow-write-text-file",
|
||||
"deep-link:allow-get-current",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-write-text-file",
|
||||
"macos-permissions:default",
|
||||
"macos-permissions:allow-request-microphone-permission",
|
||||
"macos-permissions:allow-request-camera-permission",
|
||||
"macos-permissions:allow-check-microphone-permission",
|
||||
"macos-permissions:allow-check-camera-permission",
|
||||
"log:default",
|
||||
"clipboard-manager:default",
|
||||
"clipboard-manager:allow-write-text"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import { join, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const MANIFEST_DIR = dirname(fileURLToPath(import.meta.url));
|
||||
const PROFILE = process.env.PROFILE || "debug";
|
||||
const PROFILE =
|
||||
process.argv.includes("--release") || process.env.PROFILE === "release"
|
||||
? "release"
|
||||
: "debug";
|
||||
|
||||
function getTarget() {
|
||||
if (process.env.TARGET) return process.env.TARGET;
|
||||
@@ -48,32 +51,22 @@ function copyBinary(baseName) {
|
||||
if (isWindows) destName += ".exe";
|
||||
const dest = join(destDir, destName);
|
||||
|
||||
if (existsSync(source)) {
|
||||
copyFileSync(source, dest);
|
||||
console.log(`Copied ${binName} to ${dest}`);
|
||||
} else {
|
||||
console.log(`Warning: Binary not found at ${source}`);
|
||||
console.log(`Building ${baseName} binary...`);
|
||||
|
||||
const buildArgs = ["build", "--bin", baseName];
|
||||
if (PROFILE === "release") buildArgs.push("--release");
|
||||
if (TARGET !== "unknown" && TARGET !== HOST_TARGET) {
|
||||
buildArgs.push("--target", TARGET);
|
||||
}
|
||||
|
||||
execFileSync("cargo", buildArgs, {
|
||||
cwd: MANIFEST_DIR,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (existsSync(source)) {
|
||||
copyFileSync(source, dest);
|
||||
console.log(`Built and copied ${binName} to ${dest}`);
|
||||
} else {
|
||||
console.error(`Error: Failed to build ${baseName} binary`);
|
||||
process.exit(1);
|
||||
}
|
||||
const buildArgs = ["build", "--bin", baseName];
|
||||
if (PROFILE === "release") buildArgs.push("--release");
|
||||
if (TARGET !== "unknown" && TARGET !== HOST_TARGET) {
|
||||
buildArgs.push("--target", TARGET);
|
||||
}
|
||||
execFileSync("cargo", buildArgs, {
|
||||
cwd: MANIFEST_DIR,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (!existsSync(source)) {
|
||||
console.error(`Error: Failed to build ${baseName} binary`);
|
||||
process.exit(1);
|
||||
}
|
||||
copyFileSync(source, dest);
|
||||
console.log(`Built and copied ${binName} to ${dest}`);
|
||||
}
|
||||
|
||||
copyBinary("donut-proxy");
|
||||
|
||||
@@ -2,37 +2,9 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>com.apple.security.app-sandbox</key>
|
||||
<false/>
|
||||
<key>com.apple.security.network.client</key>
|
||||
<true/>
|
||||
<key>com.apple.security.network.server</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.user-selected.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-output</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
</plist>
|
||||
|
||||
@@ -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)
|
||||
@@ -2038,7 +2038,7 @@ rm "{}"
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
|
||||
#[cfg(feature = "e2e")]
|
||||
if tauri_plugin_cross_platform_webdriver::automation_enabled()
|
||||
if crate::e2e_automation_enabled()
|
||||
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
|
||||
{
|
||||
log::info!("E2E: skipping automatic app update check");
|
||||
@@ -2099,7 +2099,7 @@ pub async fn restart_application() -> Result<(), String> {
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
|
||||
#[cfg(feature = "e2e")]
|
||||
if tauri_plugin_cross_platform_webdriver::automation_enabled()
|
||||
if crate::e2e_automation_enabled()
|
||||
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
|
||||
{
|
||||
log::info!("E2E: skipping manual app update check");
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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).
|
||||
@@ -129,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')
|
||||
@@ -243,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());
|
||||
}
|
||||
|
||||
@@ -286,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);
|
||||
@@ -380,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;
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
@@ -923,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
|
||||
@@ -1192,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
|
||||
@@ -1209,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}");
|
||||
@@ -1239,7 +1228,7 @@ impl CloudAuthManager {
|
||||
/// Get the current wayfern token, if any.
|
||||
pub async fn get_wayfern_token(&self) -> Option<String> {
|
||||
#[cfg(feature = "e2e")]
|
||||
if tauri_plugin_cross_platform_webdriver::automation_enabled() {
|
||||
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())
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1262,7 +1262,7 @@ pub async fn ensure_active_browsers_downloaded(
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Vec<String>, String> {
|
||||
#[cfg(feature = "e2e")]
|
||||
if tauri_plugin_cross_platform_webdriver::automation_enabled()
|
||||
if crate::e2e_automation_enabled()
|
||||
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
|
||||
{
|
||||
log::info!("E2E: skipping proactive browser download");
|
||||
@@ -1419,7 +1419,7 @@ pub async fn ensure_all_binaries_exist(
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Vec<String>, String> {
|
||||
#[cfg(feature = "e2e")]
|
||||
if tauri_plugin_cross_platform_webdriver::automation_enabled()
|
||||
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");
|
||||
|
||||
@@ -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
|
||||
|
||||
+28
-17
@@ -18,7 +18,8 @@ static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
|
||||
fn e2e_automation_enabled() -> bool {
|
||||
#[cfg(feature = "e2e")]
|
||||
{
|
||||
tauri_plugin_cross_platform_webdriver::automation_enabled()
|
||||
std::env::var("TAURI_AUTOMATION")
|
||||
.is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
|
||||
}
|
||||
#[cfg(not(feature = "e2e"))]
|
||||
{
|
||||
@@ -26,6 +27,13 @@ fn e2e_automation_enabled() -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
#[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;
|
||||
@@ -47,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;
|
||||
@@ -239,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") {
|
||||
@@ -1382,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());
|
||||
}
|
||||
@@ -1405,10 +1421,7 @@ pub fn run() {
|
||||
}),
|
||||
};
|
||||
|
||||
let builder = tauri::Builder::default();
|
||||
|
||||
#[cfg(feature = "e2e")]
|
||||
let builder = builder.plugin(tauri_plugin_cross_platform_webdriver::init());
|
||||
let builder = configure_builder(tauri::Builder::default());
|
||||
|
||||
let builder = builder.plugin(
|
||||
tauri_plugin_log::Builder::new()
|
||||
@@ -1416,11 +1429,10 @@ pub fn run() {
|
||||
.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.
|
||||
// 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::KeepAll)
|
||||
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(10))
|
||||
.level(log::LevelFilter::Info)
|
||||
.format(|out, message, record| {
|
||||
use chrono::Local;
|
||||
@@ -1503,8 +1515,7 @@ pub fn run() {
|
||||
.visible(true);
|
||||
|
||||
#[cfg(feature = "e2e")]
|
||||
let win_builder =
|
||||
match tauri_plugin_cross_platform_webdriver::automation_profile_dir() {
|
||||
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
|
||||
@@ -1514,7 +1525,7 @@ pub fn run() {
|
||||
// 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);
|
||||
@@ -1601,7 +1612,7 @@ pub fn run() {
|
||||
|
||||
for url in urls {
|
||||
let url_string = url.to_string();
|
||||
log::info!("Deep link received: {url_string}");
|
||||
log::info!("Processing deep link URL");
|
||||
let handle_clone = handle.clone();
|
||||
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -1617,7 +1628,7 @@ pub fn run() {
|
||||
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}");
|
||||
}
|
||||
@@ -1828,7 +1839,7 @@ 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}");
|
||||
}
|
||||
|
||||
@@ -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>");
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -1636,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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2526,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 {
|
||||
@@ -2547,10 +2548,6 @@ mod tests {
|
||||
"8080",
|
||||
"--type",
|
||||
"http",
|
||||
"--username",
|
||||
"user",
|
||||
"--password",
|
||||
"pass",
|
||||
];
|
||||
|
||||
// This test verifies the argument structure without actually running the command
|
||||
@@ -2674,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
|
||||
// ──────────────────────────────────────────────────────────────────────
|
||||
@@ -3064,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);
|
||||
|
||||
@@ -11,6 +11,44 @@ lazy_static::lazy_static! {
|
||||
}
|
||||
|
||||
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()?;
|
||||
@@ -277,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)]
|
||||
{
|
||||
@@ -288,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 {
|
||||
@@ -365,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 {
|
||||
@@ -521,7 +565,9 @@ pub async fn stop_all_proxy_processes() -> Result<(), Box<dyn std::error::Error>
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::parse_sidecar_version;
|
||||
use super::{parse_sidecar_version, prune_stale_proxy_logs};
|
||||
use std::fs;
|
||||
use std::time::Duration;
|
||||
|
||||
#[test]
|
||||
fn parses_exact_sidecar_version_output() {
|
||||
@@ -545,4 +591,21 @@ mod tests {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()),
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -1389,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",
|
||||
@@ -1400,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);
|
||||
|
||||
Vendored
+2
-5
@@ -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
|
||||
|
||||
@@ -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,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 {
|
||||
|
||||
@@ -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();
|
||||
}}
|
||||
|
||||
@@ -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] }
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "Copiar registros",
|
||||
"openLogDir": "Abrir carpeta de registros",
|
||||
"copyLogsSuccess": "Registros copiados al portapapeles",
|
||||
"copyLogsDescription": "Une los archivos de registro más 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.",
|
||||
|
||||
@@ -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 l’expurgation 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.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "ログをコピー",
|
||||
"openLogDir": "ログフォルダを開く",
|
||||
"copyLogsSuccess": "ログをクリップボードにコピーしました",
|
||||
"copyLogsDescription": "最新のログファイル(最大 5 MB)をクリップボードにまとめ、不具合報告で共有できるようにします。"
|
||||
"copyLogsDescription": "最近のログを編集したバンドル(最大 5 MB)をコピーします。編集ではすべての種類の個人データを識別できないため、共有前に内容を確認してください。"
|
||||
},
|
||||
"disableAutoUpdates": "アプリの自動更新を無効にする",
|
||||
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "로그 복사",
|
||||
"openLogDir": "로그 폴더 열기",
|
||||
"copyLogsSuccess": "로그가 클립보드에 복사되었습니다",
|
||||
"copyLogsDescription": "최신 로그 파일(최대 5MB)을 클립보드에 묶어 버그 보고서에서 공유할 수 있도록 합니다."
|
||||
"copyLogsDescription": "최근 로그를 민감 정보가 제거된 묶음으로 복사합니다(최대 5MB). 모든 유형의 개인 데이터를 식별할 수는 없으므로 공유하기 전에 검토하세요."
|
||||
},
|
||||
"disableAutoUpdates": "앱 자동 업데이트 사용 안 함",
|
||||
"disableAutoUpdatesDescription": "Donut Browser 업데이트를 앱이 자동으로 확인하고 설치하지 않도록 합니다. 브라우저 업데이트는 영향을 받지 않습니다.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "Скопировать логи",
|
||||
"openLogDir": "Открыть папку логов",
|
||||
"copyLogsSuccess": "Логи скопированы в буфер обмена",
|
||||
"copyLogsDescription": "Собирает последние файлы логов (до 5 МБ) в буфер обмена для прикрепления к багам."
|
||||
"copyLogsDescription": "Копирует отредактированный набор последних логов (до 5 МБ). Проверьте его перед отправкой: редактирование не может выявить все виды персональных данных."
|
||||
},
|
||||
"disableAutoUpdates": "Отключить автообновление приложения",
|
||||
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -192,7 +192,7 @@
|
||||
"copyLogs": "复制日志",
|
||||
"openLogDir": "打开日志文件夹",
|
||||
"copyLogsSuccess": "日志已复制到剪贴板",
|
||||
"copyLogsDescription": "将最近的日志文件(最多 5 MB)合并到剪贴板,便于在反馈问题时分享。"
|
||||
"copyLogsDescription": "复制经过脱敏的近期日志包(最多 5 MB)。脱敏无法识别所有类型的个人数据,请在分享前检查内容。"
|
||||
},
|
||||
"disableAutoUpdates": "禁用应用自动更新",
|
||||
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
Reference in New Issue
Block a user