mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-25 13:50:51 +02:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9624ec846d | |||
| f7daf68b52 | |||
| 8fe38453d4 | |||
| a71dad735e | |||
| a4ed5c855a | |||
| 32fcd2328c | |||
| f84dc3f959 | |||
| bf0d0d59a7 | |||
| f1664b2950 | |||
| 4f7910dd23 | |||
| e1c9ce6525 | |||
| cef522649a | |||
| f95816d70d | |||
| b15231d752 | |||
| af792745fc | |||
| 22f976442b | |||
| 809a95c729 | |||
| cea4ece698 | |||
| ac03b70f94 | |||
| 95f84248ab | |||
| dd5357d6c3 | |||
| 7b7849a54a | |||
| cb0ec75d37 | |||
| ae8afbb158 | |||
| 97f1f52a6d |
@@ -2,6 +2,11 @@ name: Bug Report
|
|||||||
description: Something isn't working
|
description: Something isn't working
|
||||||
labels: ["bug"]
|
labels: ["bug"]
|
||||||
body:
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Do not include passwords, access tokens, proxy credentials, personal information, or other secrets. Automated triage sends the issue title and body to OpenRouter after removing the logs/screenshots field and redacting common sensitive-data patterns.
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
@@ -54,7 +59,7 @@ body:
|
|||||||
id: logs
|
id: logs
|
||||||
attributes:
|
attributes:
|
||||||
label: Error logs or screenshots
|
label: Error logs or screenshots
|
||||||
description: Run from terminal to get logs. Paste errors, screenshots, or screen recordings.
|
description: Use Settings → Advanced → Copy logs for a redacted log bundle. Review it before posting. Never include credentials or personal information.
|
||||||
placeholder: Paste logs here or drag screenshots
|
placeholder: Paste logs here or drag screenshots
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|||||||
@@ -2,6 +2,11 @@ name: Feature Request
|
|||||||
description: Suggest a new feature
|
description: Suggest a new feature
|
||||||
labels: ["enhancement"]
|
labels: ["enhancement"]
|
||||||
body:
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
Do not include passwords, access tokens, personal information, or other secrets. Automated triage sends the issue title and body to OpenRouter after redacting common sensitive-data patterns.
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
name: App E2E Tests
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
smoke_only:
|
||||||
|
description: Run the cross-platform pull-request smoke matrix
|
||||||
|
required: false
|
||||||
|
default: true
|
||||||
|
type: boolean
|
||||||
|
secrets:
|
||||||
|
TAURI_WEBDRIVER_TOKEN:
|
||||||
|
description: Read-only token for the test-driver repository
|
||||||
|
required: false
|
||||||
|
WAYFERN_TEST_TOKEN:
|
||||||
|
description: Token used by the full Wayfern integration suite
|
||||||
|
required: false
|
||||||
|
RESIDENTIAL_PROXY_URL_ONE_SOCKS:
|
||||||
|
description: SOCKS5 residential proxy used by the network suite
|
||||||
|
required: false
|
||||||
|
RESIDENTIAL_PROXY_URL_ONE_HTTP:
|
||||||
|
description: HTTP residential proxy used by the network suite
|
||||||
|
required: false
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
suite:
|
||||||
|
description: E2E suite to run
|
||||||
|
required: false
|
||||||
|
default: full
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- full
|
||||||
|
- smoke
|
||||||
|
- ui
|
||||||
|
- entities
|
||||||
|
- network
|
||||||
|
- integrations
|
||||||
|
- sync
|
||||||
|
- browser
|
||||||
|
push:
|
||||||
|
branches: ["main"]
|
||||||
|
schedule:
|
||||||
|
- cron: "17 3 * * 1"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
smoke:
|
||||||
|
name: Smoke (${{ matrix.os }})
|
||||||
|
if: ${{ inputs.smoke_only == true }}
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
os: [macos-latest, ubuntu-22.04, windows-latest]
|
||||||
|
runs-on: ${{ matrix.os }}
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Disable git core.autocrlf on Windows
|
||||||
|
if: runner.os == 'Windows'
|
||||||
|
run: git config --global core.autocrlf false
|
||||||
|
|
||||||
|
- name: Checkout Donut Browser
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
path: donutbrowser
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Checkout test driver
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY }}
|
||||||
|
token: ${{ secrets.TAURI_WEBDRIVER_TOKEN || github.token }}
|
||||||
|
path: tauri-cross-platform-webdriver
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
with:
|
||||||
|
node-version-file: donutbrowser/.node-version
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: donutbrowser/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||||
|
with:
|
||||||
|
workspaces: |
|
||||||
|
donutbrowser/src-tauri -> target
|
||||||
|
donutbrowser/e2e/app -> target
|
||||||
|
tauri-cross-platform-webdriver -> target
|
||||||
|
|
||||||
|
- name: Install Tauri dependencies
|
||||||
|
if: runner.os == 'Linux'
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev xvfb openvpn
|
||||||
|
|
||||||
|
- name: Install app dependencies
|
||||||
|
working-directory: donutbrowser
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run isolated smoke suite
|
||||||
|
working-directory: donutbrowser
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
if [[ "${RUNNER_OS}" == "Linux" ]]; then
|
||||||
|
xvfb-run -a pnpm e2e:smoke
|
||||||
|
else
|
||||||
|
pnpm e2e:smoke
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Upload E2E diagnostics
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: app-e2e-smoke-${{ matrix.os }}
|
||||||
|
path: ${{ runner.temp }}/donut-e2e-*/diagnostics/**
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
full:
|
||||||
|
name: Full UI, sync, encryption, and Wayfern
|
||||||
|
if: ${{ inputs.smoke_only != true && inputs.suite != 'network' }}
|
||||||
|
runs-on: macos-latest
|
||||||
|
timeout-minutes: 40
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Donut Browser
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
path: donutbrowser
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Checkout test driver
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY }}
|
||||||
|
token: ${{ secrets.TAURI_WEBDRIVER_TOKEN || github.token }}
|
||||||
|
path: tauri-cross-platform-webdriver
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
with:
|
||||||
|
node-version-file: donutbrowser/.node-version
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: donutbrowser/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||||
|
with:
|
||||||
|
workspaces: |
|
||||||
|
donutbrowser/src-tauri -> target
|
||||||
|
donutbrowser/e2e/app -> target
|
||||||
|
tauri-cross-platform-webdriver -> target
|
||||||
|
|
||||||
|
- name: Install app dependencies
|
||||||
|
working-directory: donutbrowser
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run full isolated app suite
|
||||||
|
working-directory: donutbrowser
|
||||||
|
env:
|
||||||
|
DONUT_E2E_SUITE: ${{ inputs.suite || 'full' }}
|
||||||
|
DONUT_E2E_SKIP_NETWORK_TEST: "1"
|
||||||
|
WAYFERN_TEST_TOKEN: ${{ secrets.WAYFERN_TEST_TOKEN }}
|
||||||
|
run: node e2e/run.mjs "--suite=${DONUT_E2E_SUITE}"
|
||||||
|
|
||||||
|
- name: Upload E2E diagnostics
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: app-e2e-full-macos
|
||||||
|
path: ${{ runner.temp }}/donut-e2e-*/diagnostics/**
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
|
|
||||||
|
network:
|
||||||
|
name: Real proxy and local WireGuard browser traffic
|
||||||
|
if: ${{ inputs.smoke_only != true && (inputs.suite == '' || inputs.suite == 'full' || inputs.suite == 'network') }}
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
timeout-minutes: 30
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout Donut Browser
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
path: donutbrowser
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Checkout test driver
|
||||||
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
with:
|
||||||
|
repository: ${{ vars.TAURI_WEBDRIVER_REPOSITORY }}
|
||||||
|
token: ${{ secrets.TAURI_WEBDRIVER_TOKEN || github.token }}
|
||||||
|
path: tauri-cross-platform-webdriver
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
|
with:
|
||||||
|
run_install: false
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
|
with:
|
||||||
|
node-version-file: donutbrowser/.node-version
|
||||||
|
cache: pnpm
|
||||||
|
cache-dependency-path: donutbrowser/pnpm-lock.yaml
|
||||||
|
|
||||||
|
- name: Install Rust
|
||||||
|
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 # master
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
|
||||||
|
- name: Cache Rust dependencies
|
||||||
|
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 # v2.9.1
|
||||||
|
with:
|
||||||
|
workspaces: |
|
||||||
|
donutbrowser/src-tauri -> target
|
||||||
|
donutbrowser/e2e/app -> target
|
||||||
|
tauri-cross-platform-webdriver -> target
|
||||||
|
|
||||||
|
- name: Install Tauri dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y libwebkit2gtk-4.1-dev libayatana-appindicator3-dev librsvg2-dev patchelf libxdo-dev xvfb openvpn
|
||||||
|
|
||||||
|
- name: Install app dependencies
|
||||||
|
working-directory: donutbrowser
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Run isolated real-network suite
|
||||||
|
working-directory: donutbrowser
|
||||||
|
env:
|
||||||
|
WAYFERN_TEST_TOKEN: ${{ secrets.WAYFERN_TEST_TOKEN }}
|
||||||
|
RESIDENTIAL_PROXY_URL_ONE_SOCKS: ${{ secrets.RESIDENTIAL_PROXY_URL_ONE_SOCKS }}
|
||||||
|
RESIDENTIAL_PROXY_URL_ONE_HTTP: ${{ secrets.RESIDENTIAL_PROXY_URL_ONE_HTTP }}
|
||||||
|
run: xvfb-run -a pnpm e2e:network
|
||||||
|
|
||||||
|
- name: Upload E2E diagnostics
|
||||||
|
if: failure()
|
||||||
|
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||||
|
with:
|
||||||
|
name: app-e2e-network-linux
|
||||||
|
path: ${{ runner.temp }}/donut-e2e-*/diagnostics/**
|
||||||
|
if-no-files-found: ignore
|
||||||
|
retention-days: 7
|
||||||
@@ -2,6 +2,12 @@ name: "CodeQL"
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
checkout_ref:
|
||||||
|
description: Optional commit to check out instead of the triggering ref
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -32,6 +38,8 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.checkout_ref }}
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||||
@@ -39,7 +47,7 @@ jobs:
|
|||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version-file: .node-version
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ jobs:
|
|||||||
name: Lint JavaScript/TypeScript
|
name: Lint JavaScript/TypeScript
|
||||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||||
uses: ./.github/workflows/lint-js.yml
|
uses: ./.github/workflows/lint-js.yml
|
||||||
secrets: inherit
|
with:
|
||||||
|
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -38,7 +39,8 @@ jobs:
|
|||||||
name: Lint Rust
|
name: Lint Rust
|
||||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||||
uses: ./.github/workflows/lint-rs.yml
|
uses: ./.github/workflows/lint-rs.yml
|
||||||
secrets: inherit
|
with:
|
||||||
|
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -46,7 +48,8 @@ jobs:
|
|||||||
name: CodeQL
|
name: CodeQL
|
||||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||||
uses: ./.github/workflows/codeql.yml
|
uses: ./.github/workflows/codeql.yml
|
||||||
secrets: inherit
|
with:
|
||||||
|
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||||
permissions:
|
permissions:
|
||||||
security-events: write
|
security-events: write
|
||||||
contents: read
|
contents: read
|
||||||
@@ -57,7 +60,8 @@ jobs:
|
|||||||
name: Spell Check
|
name: Spell Check
|
||||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||||
uses: ./.github/workflows/spellcheck.yml
|
uses: ./.github/workflows/spellcheck.yml
|
||||||
secrets: inherit
|
with:
|
||||||
|
checkout_ref: ${{ github.event.pull_request.head.sha }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,11 @@ on:
|
|||||||
description: "Docker tag (e.g., v1.0.0)"
|
description: "Docker tag (e.g., v1.0.0)"
|
||||||
required: true
|
required: true
|
||||||
type: string
|
type: string
|
||||||
|
secrets:
|
||||||
|
DOCKERHUB_USERNAME:
|
||||||
|
required: true
|
||||||
|
DOCKERHUB_TOKEN:
|
||||||
|
required: true
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
tag:
|
tag:
|
||||||
@@ -43,23 +48,29 @@ jobs:
|
|||||||
|
|
||||||
- name: Determine tags
|
- name: Determine tags
|
||||||
id: tags
|
id: tags
|
||||||
|
env:
|
||||||
|
INPUT_TAG: ${{ inputs.tag }}
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
COMMIT_SHA: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
TAGS=""
|
TAGS=""
|
||||||
INPUT_TAG="${{ inputs.tag }}"
|
|
||||||
|
|
||||||
if [ -n "$INPUT_TAG" ]; then
|
if [ -n "$INPUT_TAG" ]; then
|
||||||
# Called from release workflow or manual dispatch
|
# Called from release workflow or manual dispatch
|
||||||
|
if [[ ! "$INPUT_TAG" =~ ^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$ ]]; then
|
||||||
|
echo "Invalid Docker tag" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${INPUT_TAG}"
|
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${INPUT_TAG}"
|
||||||
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||||
elif [ "${{ github.event_name }}" = "push" ]; then
|
elif [ "$EVENT_NAME" = "push" ]; then
|
||||||
# Push to main (nightly): tag with nightly and commit SHA
|
# Push to main (nightly): tag with nightly and commit SHA
|
||||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
SHORT_SHA=${COMMIT_SHA:0:7}
|
||||||
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly"
|
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly"
|
||||||
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${SHORT_SHA}"
|
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${SHORT_SHA}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
printf 'tags=%s\n' "$TAGS" >> "$GITHUB_OUTPUT"
|
||||||
echo "Tags: ${TAGS}"
|
|
||||||
|
|
||||||
- name: Build and push Docker image
|
- name: Build and push Docker image
|
||||||
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a #v7.3.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a #v7.3.0
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ jobs:
|
|||||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||||
run: |
|
run: |
|
||||||
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
printf '%s' "$ISSUE_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/issue-title.txt
|
||||||
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
|
printf '%s' "${ISSUE_BODY:-}" | node scripts/redact-sensitive-text.mjs --issue-body > /tmp/issue-body.txt
|
||||||
|
|
||||||
- name: Build prompt
|
- name: Build prompt
|
||||||
run: |
|
run: |
|
||||||
@@ -92,11 +92,9 @@ jobs:
|
|||||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||||
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||||
echo "::warning::Model returned non-JSON; treating as compliant"
|
echo "::warning::Model returned non-JSON; treating as compliant"
|
||||||
cat /tmp/raw.txt
|
|
||||||
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
|
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
|
||||||
fi
|
fi
|
||||||
echo "Result:"
|
echo "Compliance response validated"
|
||||||
cat /tmp/result.json
|
|
||||||
|
|
||||||
- name: Build comment
|
- name: Build comment
|
||||||
id: build
|
id: build
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ permissions:
|
|||||||
contents: read
|
contents: read
|
||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
id-token: write
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
# Single source of truth for the model used by both triage and composer.
|
# Single source of truth for the model used by both triage and composer.
|
||||||
@@ -49,8 +48,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||||
run: |
|
run: |
|
||||||
node <<'EOF'
|
node --input-type=module <<'EOF'
|
||||||
const fs = require('node:fs');
|
import fs from 'node:fs';
|
||||||
|
import { redactIssueBody, redactSensitiveText } from './scripts/redact-sensitive-text.mjs';
|
||||||
const body = process.env.ISSUE_BODY || '';
|
const body = process.env.ISSUE_BODY || '';
|
||||||
// GitHub issue templates render fields as `### Heading\nValue` blocks.
|
// GitHub issue templates render fields as `### Heading\nValue` blocks.
|
||||||
// Split on `###` at line start to recover them.
|
// Split on `###` at line start to recover them.
|
||||||
@@ -61,29 +61,27 @@ jobs:
|
|||||||
if (nl < 0) continue;
|
if (nl < 0) continue;
|
||||||
const heading = section.slice(0, nl).trim();
|
const heading = section.slice(0, nl).trim();
|
||||||
const value = section.slice(nl + 1).trim();
|
const value = section.slice(nl + 1).trim();
|
||||||
fields[heading] = value === '_No response_' ? '' : value;
|
const normalized = value === '_No response_' ? '' : value;
|
||||||
|
fields[heading] = heading === 'Error logs or screenshots'
|
||||||
|
? '[omitted from automated processing]'
|
||||||
|
: redactSensitiveText(normalized);
|
||||||
}
|
}
|
||||||
fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2));
|
fs.writeFileSync('/tmp/issue-fields.json', JSON.stringify(fields, null, 2));
|
||||||
// Convenience extractions for the prompt — empty string if missing.
|
|
||||||
const get = (k) => fields[k] || '';
|
const get = (k) => fields[k] || '';
|
||||||
fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
|
fs.writeFileSync('/tmp/issue-os.txt', get('Operating System'));
|
||||||
fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
|
fs.writeFileSync('/tmp/issue-version.txt', get('Donut Browser version'));
|
||||||
fs.writeFileSync('/tmp/issue-wayfern-version.txt', get('Wayfern version'));
|
fs.writeFileSync('/tmp/issue-wayfern-version.txt', get('Wayfern version'));
|
||||||
fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce'));
|
fs.writeFileSync('/tmp/issue-repro.txt', get('Steps to reproduce'));
|
||||||
fs.writeFileSync('/tmp/issue-logs.txt', get('Error logs or screenshots'));
|
|
||||||
fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?'));
|
fs.writeFileSync('/tmp/issue-what.txt', get('What happened?') || get('What do you want?'));
|
||||||
|
fs.writeFileSync('/tmp/issue-body.txt', redactIssueBody(body));
|
||||||
EOF
|
EOF
|
||||||
echo "Parsed fields:"
|
|
||||||
cat /tmp/issue-fields.json
|
|
||||||
|
|
||||||
- name: Build repo context
|
- name: Build repo context
|
||||||
env:
|
env:
|
||||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
|
||||||
run: |
|
run: |
|
||||||
cp CLAUDE.md /tmp/repo-context.txt
|
cp CLAUDE.md /tmp/repo-context.txt
|
||||||
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
printf '%s' "$ISSUE_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/issue-title.txt
|
||||||
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
|
|
||||||
|
|
||||||
# List all source files for the AI to choose from
|
# List all source files for the AI to choose from
|
||||||
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
|
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
|
||||||
@@ -199,7 +197,7 @@ jobs:
|
|||||||
Return ONLY valid JSON. No preamble, no code fences. Schema:
|
Return ONLY valid JSON. No preamble, no code fences. Schema:
|
||||||
{
|
{
|
||||||
"language": "en" or ISO 639-1 code,
|
"language": "en" or ISO 639-1 code,
|
||||||
"classification": one of ["bug-in-scope", "bug-template-violation", "feature-request", "fork-request", "regression", "ai-generated-junk", "question", "other"],
|
"classification": one of ["bug-in-scope", "bug-template-violation", "feature-request", "fork-request", "regression", "automated-content", "question", "other"],
|
||||||
"operating_system": "macos" | "windows" | "linux" | "unknown",
|
"operating_system": "macos" | "windows" | "linux" | "unknown",
|
||||||
"is_paid_feature": true | false,
|
"is_paid_feature": true | false,
|
||||||
"user_followed_template": true | false,
|
"user_followed_template": true | false,
|
||||||
@@ -211,11 +209,11 @@ jobs:
|
|||||||
|
|
||||||
Classification guidance:
|
Classification guidance:
|
||||||
- "bug-template-violation": missing or filled-in nonsense for required template fields.
|
- "bug-template-violation": missing or filled-in nonsense for required template fields.
|
||||||
- "ai-generated-junk": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has the polished AI-spam shape (long, structured, fabricated certainty).
|
- "automated-content": cites fabricated "official docs" (context7, deepwiki, non-donutbrowser URLs) or has a highly structured automated-submission pattern with fabricated certainty.
|
||||||
- "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
|
- "fork-request": asks for support of CloverLabsAI/VulpineOS/etc. forks.
|
||||||
- "regression": user names a prior version that worked.
|
- "regression": user names a prior version that worked.
|
||||||
|
|
||||||
File selection: pick files that an experienced reviewer would actually look at to act on this issue. If the issue is a fork-request or junk, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.
|
File selection: pick files that an experienced reviewer would actually look at to act on this issue. For a fork request or automated-content classification, set files_to_read to []. Otherwise pick concrete files relevant to the symptoms.
|
||||||
TRIAGE_TAIL
|
TRIAGE_TAIL
|
||||||
} > /tmp/triage-system.txt
|
} > /tmp/triage-system.txt
|
||||||
wc -c /tmp/triage-system.txt
|
wc -c /tmp/triage-system.txt
|
||||||
@@ -238,7 +236,7 @@ jobs:
|
|||||||
messages: [
|
messages: [
|
||||||
{ role: "system", content: $system_prompt },
|
{ role: "system", content: $system_prompt },
|
||||||
{ role: "user",
|
{ role: "user",
|
||||||
content: ("Issue title: " + $title + "\n\nBody:\n" + $body + "\n\nParsed template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
|
content: ("Issue title: " + $title + "\n\nSanitized body:\n" + $body + "\n\nSanitized template fields:\n" + $fields + "\n\nAll source files:\n" + $files) }
|
||||||
]
|
]
|
||||||
}')
|
}')
|
||||||
|
|
||||||
@@ -249,14 +247,12 @@ jobs:
|
|||||||
|
|
||||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt
|
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/triage-raw.txt
|
||||||
|
|
||||||
# Strip ```json fences if the model couldn't help itself.
|
# Normalize optional markdown fences before parsing.
|
||||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json
|
sed -E 's/^```(json)?$//; s/```$//' /tmp/triage-raw.txt > /tmp/triage.json
|
||||||
|
|
||||||
# Validate; if the model returned junk, fall back to a minimal stub so the
|
# Fall back to a safe classification when the response is not JSON.
|
||||||
# composer still gets called and produces SOMETHING.
|
|
||||||
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
|
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
|
||||||
echo "::warning::Triage returned non-JSON; using fallback classification"
|
echo "::warning::Triage returned non-JSON; using fallback classification"
|
||||||
cat /tmp/triage-raw.txt
|
|
||||||
jq -n '{
|
jq -n '{
|
||||||
language: "en",
|
language: "en",
|
||||||
classification: "bug-in-scope",
|
classification: "bug-in-scope",
|
||||||
@@ -270,17 +266,16 @@ jobs:
|
|||||||
}' > /tmp/triage.json
|
}' > /tmp/triage.json
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Triage result:"
|
echo "Triage response validated"
|
||||||
cat /tmp/triage.json
|
|
||||||
|
|
||||||
- name: Read files chosen by triage
|
- name: Read files chosen by triage
|
||||||
run: |
|
run: |
|
||||||
: > /tmp/file-context.txt
|
: > /tmp/file-context.txt
|
||||||
# files_to_read may be empty (e.g. fork-request or junk) — that's fine.
|
# An empty file list is valid for classifications that need no source context.
|
||||||
jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do
|
jq -r '.files_to_read[]? // empty' /tmp/triage.json | while IFS= read -r filepath; do
|
||||||
filepath=$(echo "$filepath" | xargs)
|
filepath=$(echo "$filepath" | xargs)
|
||||||
[ -z "$filepath" ] && continue
|
[ -z "$filepath" ] && continue
|
||||||
# Reject paths that escape the repo or look fishy
|
# Reject paths that escape the repository.
|
||||||
case "$filepath" in
|
case "$filepath" in
|
||||||
/*|*..*|*$'\n'*) continue ;;
|
/*|*..*|*$'\n'*) continue ;;
|
||||||
esac
|
esac
|
||||||
@@ -331,7 +326,7 @@ jobs:
|
|||||||
The triage classification (`triage.classification`) determines the response shape:
|
The triage classification (`triage.classification`) determines the response shape:
|
||||||
|
|
||||||
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
|
- `bug-in-scope`: ask for what is missing using the user's reported OS log path. Be concrete about how to obtain logs.
|
||||||
- `bug-template-violation` or `ai-generated-junk`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Wayfern version, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
|
- `bug-template-violation` or `automated-content`: politely ask the user to refile using the bug-report template (the Operating System, Donut Browser version, Wayfern version, Steps to reproduce, Error logs sections). If they cited "documentation" from any non-`donutbrowser.com`/non-`github.com/zhom` URL (e.g. context7, deepwiki), gently note that those are AI-generated third-party summaries and the only authoritative sources are this repo and donutbrowser.com.
|
||||||
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
|
- `feature-request`: one neutral sentence acknowledging, then ask only what is genuinely needed (concrete use case, whether a workaround would suffice). Do NOT validate.
|
||||||
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
|
- `fork-request`: one neutral sentence acknowledging the request. Note that this would substantially increase support burden and the maintainer evaluates such requests on a case-by-case basis. Ask whether the alternative fork supports all platforms the user uses (macOS / Windows / Linux). No "clear enhancement" language.
|
||||||
- `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user's environment between then and now, and the specific delta in symptoms.
|
- `regression`: do NOT call known/expected. Ask which exact previous version was the last working one, what changed in the user's environment between then and now, and the specific delta in symptoms.
|
||||||
@@ -408,8 +403,8 @@ jobs:
|
|||||||
+ "Title: " + $title
|
+ "Title: " + $title
|
||||||
+ "\nAuthor: " + $author
|
+ "\nAuthor: " + $author
|
||||||
+ "\n\n## Triage result\n" + $triage
|
+ "\n\n## Triage result\n" + $triage
|
||||||
+ "\n\n## Parsed template fields\n" + $fields
|
+ "\n\n## Sanitized template fields\n" + $fields
|
||||||
+ "\n\n## Raw issue body\n" + $body
|
+ "\n\n## Sanitized issue body\n" + $body
|
||||||
+ "\n\n## Source files (selected by triage)\n" + $files) }
|
+ "\n\n## Source files (selected by triage)\n" + $files) }
|
||||||
]
|
]
|
||||||
}')
|
}')
|
||||||
@@ -423,8 +418,6 @@ jobs:
|
|||||||
|
|
||||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||||
echo "::error::Composer returned empty response"
|
echo "::error::Composer returned empty response"
|
||||||
echo "Raw response:"
|
|
||||||
echo "$RESPONSE"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -484,8 +477,9 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||||
|
HEAD_REPOSITORY: ${{ github.event.pull_request.head.repo.full_name }}
|
||||||
run: |
|
run: |
|
||||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
|
gh api --paginate "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \
|
||||||
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
|
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
|
||||||
> /tmp/pr-files.txt
|
> /tmp/pr-files.txt
|
||||||
|
|
||||||
@@ -499,14 +493,27 @@ jobs:
|
|||||||
cp CLAUDE.md /tmp/repo-context.txt
|
cp CLAUDE.md /tmp/repo-context.txt
|
||||||
|
|
||||||
: > /tmp/related-file-contents.txt
|
: > /tmp/related-file-contents.txt
|
||||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
|
gh api --paginate "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files?per_page=100" \
|
||||||
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
|
--jq '.[] | select(.status != "removed") | [.filename, .sha] | @tsv' |
|
||||||
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
|
while IFS=$'\t' read -r filepath blob_sha; do
|
||||||
cat "$filepath" >> /tmp/related-file-contents.txt
|
case "$filepath" in
|
||||||
echo "" >> /tmp/related-file-contents.txt
|
/*|*..*|*$'\n'*) continue ;;
|
||||||
|
esac
|
||||||
|
blob_file=$(mktemp)
|
||||||
|
if gh api "/repos/$HEAD_REPOSITORY/git/blobs/$blob_sha" --jq .content \
|
||||||
|
| tr -d '\n' | base64 --decode > "$blob_file" 2>/dev/null \
|
||||||
|
&& file --mime "$blob_file" | grep -q "text/"; then
|
||||||
|
echo "=== $filepath (head revision) ===" >> /tmp/related-file-contents.txt
|
||||||
|
cat "$blob_file" >> /tmp/related-file-contents.txt
|
||||||
|
echo "" >> /tmp/related-file-contents.txt
|
||||||
fi
|
fi
|
||||||
|
rm -f "$blob_file"
|
||||||
done
|
done
|
||||||
head -c 100000 /tmp/related-file-contents.txt > /tmp/pr-file-context.txt
|
head -c 100000 /tmp/related-file-contents.txt > /tmp/pr-file-context.txt
|
||||||
|
node scripts/redact-sensitive-text.mjs < /tmp/pr-diff.txt > /tmp/pr-diff.safe.txt
|
||||||
|
mv /tmp/pr-diff.safe.txt /tmp/pr-diff.txt
|
||||||
|
node scripts/redact-sensitive-text.mjs < /tmp/pr-file-context.txt > /tmp/pr-file-context.safe.txt
|
||||||
|
mv /tmp/pr-file-context.safe.txt /tmp/pr-file-context.txt
|
||||||
|
|
||||||
- name: Analyze PR with AI
|
- name: Analyze PR with AI
|
||||||
env:
|
env:
|
||||||
@@ -525,8 +532,8 @@ jobs:
|
|||||||
GREETING='This is a first-time contributor. Start your comment with: "Thanks for your first PR!"'
|
GREETING='This is a first-time contributor. Start your comment with: "Thanks for your first PR!"'
|
||||||
fi
|
fi
|
||||||
|
|
||||||
printf '%s' "$PR_TITLE" > /tmp/pr-title.txt
|
printf '%s' "$PR_TITLE" | node scripts/redact-sensitive-text.mjs > /tmp/pr-title.txt
|
||||||
printf '%s' "${PR_BODY:-}" > /tmp/pr-body.txt
|
printf '%s' "${PR_BODY:-}" | node scripts/redact-sensitive-text.mjs > /tmp/pr-body.txt
|
||||||
printf '%s' "$PR_AUTHOR" > /tmp/pr-author.txt
|
printf '%s' "$PR_AUTHOR" > /tmp/pr-author.txt
|
||||||
printf '%s' "$PR_BASE" > /tmp/pr-base.txt
|
printf '%s' "$PR_BASE" > /tmp/pr-base.txt
|
||||||
printf '%s' "$PR_HEAD" > /tmp/pr-head.txt
|
printf '%s' "$PR_HEAD" > /tmp/pr-head.txt
|
||||||
@@ -550,7 +557,7 @@ jobs:
|
|||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "system",
|
role: "system",
|
||||||
content: ("You are a code review bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nContributing guidelines:\n" + $contributing + "\n\nYou have access to the full changed files and the diff. Use them to give a substantive review.\n\nReview this PR and produce a single comment. Format:\n\n1. One sentence summarizing what this PR does and whether the approach is sound.\n2. **Code review** - Specific observations about the actual code changes. Mention file names and what you see in the diff. Look for:\n - Bugs or logic errors in the changed code\n - Security issues (SQL injection, path traversal, XSS, command injection)\n - Missing error handling or edge cases\n - Breaking changes to existing APIs or behavior\n - If UI text was added/changed, check if all 7 translation files (en, es, fr, ja, pt, ru, zh) in src/i18n/locales/ were updated\n - If Tauri commands were added/removed, the unused-commands test in lib.rs needs updating\n3. **Suggestions** - Concrete improvements if any. Skip if the PR looks good.\n\nRules:\n- Be substantive. Review the actual diff, not just the description.\n- Do NOT nitpick formatting or style — the project has automated linting (biome + clippy + rustfmt).\n- Do NOT just summarize the PR description back to the user — they wrote it, they know what it says.\n- If the PR is good, say so briefly.\n- Never exceed 20 lines.")
|
content: ("You are a code review bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nContributing guidelines:\n" + $contributing + "\n\nYou have access to sanitized head-revision contents for changed files and a sanitized diff. Use them to give a substantive review.\n\nReview this PR and produce a single comment. Format:\n\n1. One sentence summarizing what this PR does and whether the approach is sound.\n2. **Code review** - Specific observations about the actual code changes. Mention file names and what you see in the diff. Look for:\n - Bugs or logic errors in the changed code\n - Security issues (SQL injection, path traversal, XSS, command injection)\n - Missing error handling or edge cases\n - Breaking changes to existing APIs or behavior\n - If UI text was added or changed, verify the key exists in every JSON file under src/i18n/locales/\n - If Tauri commands were added or removed, verify e2e/coverage-map.mjs is updated exactly once per command\n3. **Suggestions** - Concrete improvements if any. Skip if the PR looks good.\n\nRules:\n- Be substantive. Review the actual diff, not just the description.\n- Do NOT nitpick formatting or style — the project has automated linting (biome + clippy + rustfmt).\n- Do NOT just summarize the PR description back to the user — they wrote it, they know what it says.\n- If the PR is good, say so briefly.\n- Never exceed 20 lines.")
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -577,8 +584,6 @@ jobs:
|
|||||||
|
|
||||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||||
echo "::error::AI response was empty"
|
echo "::error::AI response was empty"
|
||||||
echo "Raw response:"
|
|
||||||
echo "$RESPONSE"
|
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -593,6 +598,9 @@ jobs:
|
|||||||
if: |
|
if: |
|
||||||
github.repository == 'zhom/donutbrowser' &&
|
github.repository == 'zhom/donutbrowser' &&
|
||||||
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||||
|
(github.event.comment.author_association == 'OWNER' ||
|
||||||
|
github.event.comment.author_association == 'MEMBER' ||
|
||||||
|
github.event.comment.author_association == 'COLLABORATOR') &&
|
||||||
(contains(github.event.comment.body, ' /oc') ||
|
(contains(github.event.comment.body, ' /oc') ||
|
||||||
startsWith(github.event.comment.body, '/oc') ||
|
startsWith(github.event.comment.body, '/oc') ||
|
||||||
contains(github.event.comment.body, ' /opencode') ||
|
contains(github.event.comment.body, ' /opencode') ||
|
||||||
@@ -603,7 +611,7 @@ jobs:
|
|||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
|
||||||
- name: Run opencode
|
- name: Run opencode
|
||||||
uses: anomalyco/opencode/github@10c894bdeef3618f5666fb506ef7f9491bb964d8 #v1.17.13
|
uses: anomalyco/opencode/github@127bdb30784d508cc556c71a0f32b508a3061517 #v1.18.3
|
||||||
env:
|
env:
|
||||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ name: Lint Node.js
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
checkout_ref:
|
||||||
|
description: Optional commit to check out instead of the triggering ref
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -35,6 +41,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Checkout repository code
|
- name: Checkout repository code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.checkout_ref }}
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||||
@@ -42,7 +50,7 @@ jobs:
|
|||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version-file: .node-version
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -4,6 +4,12 @@ name: Lint Rust
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
checkout_ref:
|
||||||
|
description: Optional commit to check out instead of the triggering ref
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
push:
|
push:
|
||||||
branches:
|
branches:
|
||||||
- main
|
- main
|
||||||
@@ -42,6 +48,8 @@ jobs:
|
|||||||
|
|
||||||
- name: Checkout repository code
|
- name: Checkout repository code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.checkout_ref }}
|
||||||
|
|
||||||
- name: Set up pnpm package manager
|
- name: Set up pnpm package manager
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||||
@@ -49,7 +57,7 @@ jobs:
|
|||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Set up Node.js
|
- name: Set up Node.js
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version-file: .node-version
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -15,14 +15,12 @@ jobs:
|
|||||||
lint-js:
|
lint-js:
|
||||||
name: Lint JavaScript/TypeScript
|
name: Lint JavaScript/TypeScript
|
||||||
uses: ./.github/workflows/lint-js.yml
|
uses: ./.github/workflows/lint-js.yml
|
||||||
secrets: inherit
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
lint-rust:
|
lint-rust:
|
||||||
name: Lint Rust
|
name: Lint Rust
|
||||||
uses: ./.github/workflows/lint-rs.yml
|
uses: ./.github/workflows/lint-rs.yml
|
||||||
secrets: inherit
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -41,19 +39,27 @@ jobs:
|
|||||||
sync-e2e:
|
sync-e2e:
|
||||||
name: Sync E2E Tests
|
name: Sync E2E Tests
|
||||||
uses: ./.github/workflows/sync-e2e.yml
|
uses: ./.github/workflows/sync-e2e.yml
|
||||||
secrets: inherit
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
app-e2e:
|
||||||
|
name: Cross-platform App E2E
|
||||||
|
uses: ./.github/workflows/app-e2e.yml
|
||||||
|
secrets:
|
||||||
|
TAURI_WEBDRIVER_TOKEN: ${{ secrets.TAURI_WEBDRIVER_TOKEN }}
|
||||||
|
WAYFERN_TEST_TOKEN: ${{ secrets.WAYFERN_TEST_TOKEN }}
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
pr-status:
|
pr-status:
|
||||||
name: PR Status Check
|
name: PR Status Check
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
needs: [lint-js, lint-rust, security-scan, sync-e2e]
|
needs: [lint-js, lint-rust, security-scan, sync-e2e, app-e2e]
|
||||||
if: always()
|
if: always()
|
||||||
steps:
|
steps:
|
||||||
- name: Check all jobs succeeded
|
- name: Check all jobs succeeded
|
||||||
run: |
|
run: |
|
||||||
if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" ]]; then
|
if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" || "${{ needs.app-e2e.result }}" != "success" ]]; then
|
||||||
echo "One or more checks failed"
|
echo "One or more checks failed"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -31,17 +31,24 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
INPUT_TAG: ${{ inputs.tag }}
|
INPUT_TAG: ${{ inputs.tag }}
|
||||||
|
EVENT_NAME: ${{ github.event_name }}
|
||||||
|
WORKFLOW_HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }}
|
||||||
|
REPOSITORY: ${{ github.repository }}
|
||||||
run: |
|
run: |
|
||||||
if [[ -n "${INPUT_TAG:-}" ]]; then
|
if [[ -n "${INPUT_TAG:-}" ]]; then
|
||||||
echo "tag=${INPUT_TAG}" >> "$GITHUB_OUTPUT"
|
TAG="$INPUT_TAG"
|
||||||
elif [[ "${{ github.event_name }}" == "workflow_run" ]]; then
|
elif [[ "$EVENT_NAME" == "workflow_run" ]]; then
|
||||||
# The Release workflow is triggered by a tag push (v*),
|
# The Release workflow is triggered by a tag push (v*),
|
||||||
# so head_branch is the tag name
|
# so head_branch is the tag name
|
||||||
echo "tag=${{ github.event.workflow_run.head_branch }}" >> "$GITHUB_OUTPUT"
|
TAG="$WORKFLOW_HEAD_BRANCH"
|
||||||
else
|
else
|
||||||
TAG=$(gh release view --repo "${{ github.repository }}" --json tagName -q .tagName)
|
TAG=$(gh release view --repo "$REPOSITORY" --json tagName -q .tagName)
|
||||||
echo "tag=${TAG}" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
fi
|
||||||
|
if [[ ! "$TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||||
|
echo "Invalid release tag" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
printf 'tag=%s\n' "$TAG" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
- name: Install tools
|
- name: Install tools
|
||||||
run: |
|
run: |
|
||||||
@@ -59,19 +66,12 @@ jobs:
|
|||||||
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
|
R2_ENDPOINT_URL: ${{ secrets.R2_ENDPOINT_URL }}
|
||||||
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
R2_BUCKET_NAME: ${{ secrets.R2_BUCKET_NAME }}
|
||||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
RELEASE_TAG: ${{ steps.tag.outputs.tag }}
|
||||||
run: |
|
run: |
|
||||||
# GitHub injects secrets verbatim. If a value was pasted with
|
# Normalize accidental quotes and whitespace in configured secrets.
|
||||||
# surrounding quotes or a trailing newline — the local .env wraps all
|
|
||||||
# four R2_* values in double quotes — it reaches the script malformed:
|
|
||||||
# e.g. an endpoint of https://"host" yields
|
|
||||||
# `Could not connect to the endpoint URL`, and a quoted key yields
|
|
||||||
# `Unauthorized`. The local run is unaffected because publish-repo.sh
|
|
||||||
# sources .env through bash, which strips the quotes; CI has no .env,
|
|
||||||
# so strip here. No-op when the secrets are already clean. The script
|
|
||||||
# itself is intentionally left untouched.
|
|
||||||
strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; }
|
strip() { printf '%s' "$1" | tr -d '\r\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e 's/^"\(.*\)"$/\1/' -e "s/^'\(.*\)'\$/\1/"; }
|
||||||
export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
|
export R2_ACCESS_KEY_ID="$(strip "$R2_ACCESS_KEY_ID")"
|
||||||
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
|
export R2_SECRET_ACCESS_KEY="$(strip "$R2_SECRET_ACCESS_KEY")"
|
||||||
export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
|
export R2_ENDPOINT_URL="$(strip "$R2_ENDPOINT_URL")"
|
||||||
export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
|
export R2_BUCKET_NAME="$(strip "$R2_BUCKET_NAME")"
|
||||||
bash scripts/publish-repo.sh "${{ steps.tag.outputs.tag }}"
|
bash scripts/publish-repo.sh "$RELEASE_TAG"
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ jobs:
|
|||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: Lint JavaScript/TypeScript
|
name: Lint JavaScript/TypeScript
|
||||||
uses: ./.github/workflows/lint-js.yml
|
uses: ./.github/workflows/lint-js.yml
|
||||||
secrets: inherit
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -45,7 +44,6 @@ jobs:
|
|||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: Lint Rust
|
name: Lint Rust
|
||||||
uses: ./.github/workflows/lint-rs.yml
|
uses: ./.github/workflows/lint-rs.yml
|
||||||
secrets: inherit
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -53,7 +51,6 @@ jobs:
|
|||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: CodeQL
|
name: CodeQL
|
||||||
uses: ./.github/workflows/codeql.yml
|
uses: ./.github/workflows/codeql.yml
|
||||||
secrets: inherit
|
|
||||||
permissions:
|
permissions:
|
||||||
security-events: write
|
security-events: write
|
||||||
contents: read
|
contents: read
|
||||||
@@ -64,7 +61,6 @@ jobs:
|
|||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: Spell Check
|
name: Spell Check
|
||||||
uses: ./.github/workflows/spellcheck.yml
|
uses: ./.github/workflows/spellcheck.yml
|
||||||
secrets: inherit
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -113,7 +109,7 @@ jobs:
|
|||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version-file: .node-version
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
@@ -160,6 +156,8 @@ jobs:
|
|||||||
- name: Build sidecar binaries
|
- name: Build sidecar binaries
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ./src-tauri
|
working-directory: ./src-tauri
|
||||||
|
env:
|
||||||
|
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||||
run: |
|
run: |
|
||||||
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
|
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
|
||||||
|
|
||||||
@@ -216,6 +214,7 @@ jobs:
|
|||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
# tauri-action invokes `pnpm tauri build`, which runs
|
# tauri-action invokes `pnpm tauri build`, which runs
|
||||||
# `beforeBuildCommand` from tauri.conf.json. That rebuilds the
|
# `beforeBuildCommand` from tauri.conf.json. That rebuilds the
|
||||||
# frontend in its own subprocess, so the env var MUST be forwarded
|
# frontend in its own subprocess, so the env var MUST be forwarded
|
||||||
@@ -557,7 +556,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger Cloudflare Pages deployment
|
- name: Trigger Cloudflare Pages deployment
|
||||||
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
|
env:
|
||||||
|
DEPLOYMENT_HOOK: ${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}
|
||||||
|
run: curl -fsSL -X POST "$DEPLOYMENT_HOOK"
|
||||||
|
|
||||||
docker:
|
docker:
|
||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
@@ -565,7 +566,9 @@ jobs:
|
|||||||
uses: ./.github/workflows/docker-sync.yml
|
uses: ./.github/workflows/docker-sync.yml
|
||||||
with:
|
with:
|
||||||
tag: ${{ github.ref_name }}
|
tag: ${{ github.ref_name }}
|
||||||
secrets: inherit
|
secrets:
|
||||||
|
DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
|
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
|
|
||||||
update-flake:
|
update-flake:
|
||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
|
|||||||
@@ -44,7 +44,6 @@ jobs:
|
|||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: Lint JavaScript/TypeScript
|
name: Lint JavaScript/TypeScript
|
||||||
uses: ./.github/workflows/lint-js.yml
|
uses: ./.github/workflows/lint-js.yml
|
||||||
secrets: inherit
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -52,7 +51,6 @@ jobs:
|
|||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: Lint Rust
|
name: Lint Rust
|
||||||
uses: ./.github/workflows/lint-rs.yml
|
uses: ./.github/workflows/lint-rs.yml
|
||||||
secrets: inherit
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -60,7 +58,6 @@ jobs:
|
|||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: CodeQL
|
name: CodeQL
|
||||||
uses: ./.github/workflows/codeql.yml
|
uses: ./.github/workflows/codeql.yml
|
||||||
secrets: inherit
|
|
||||||
permissions:
|
permissions:
|
||||||
security-events: write
|
security-events: write
|
||||||
contents: read
|
contents: read
|
||||||
@@ -71,7 +68,6 @@ jobs:
|
|||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
name: Spell Check
|
name: Spell Check
|
||||||
uses: ./.github/workflows/spellcheck.yml
|
uses: ./.github/workflows/spellcheck.yml
|
||||||
secrets: inherit
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
@@ -120,7 +116,7 @@ jobs:
|
|||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 #v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version-file: .node-version
|
node-version-file: .node-version
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
@@ -164,9 +160,25 @@ jobs:
|
|||||||
echo "Checking from src-tauri perspective:"
|
echo "Checking from src-tauri perspective:"
|
||||||
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"
|
ls -la src-tauri/../dist || echo "Warning: dist not accessible from src-tauri"
|
||||||
|
|
||||||
|
- name: Generate nightly timestamp
|
||||||
|
id: timestamp
|
||||||
|
shell: bash
|
||||||
|
run: |
|
||||||
|
# Committer date, not wall clock: every job in this run (including
|
||||||
|
# update-nightly-release, which runs much later) must derive the
|
||||||
|
# exact same tag, or a run straddling midnight UTC splits the
|
||||||
|
# release from its checksums.
|
||||||
|
TIMESTAMP=$(git show -s --format=%cs HEAD)
|
||||||
|
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||||
|
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||||
|
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
||||||
|
|
||||||
- name: Build sidecar binaries
|
- name: Build sidecar binaries
|
||||||
shell: bash
|
shell: bash
|
||||||
working-directory: ./src-tauri
|
working-directory: ./src-tauri
|
||||||
|
env:
|
||||||
|
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||||
|
GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||||
run: |
|
run: |
|
||||||
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
|
cargo build --bin donut-proxy --target ${{ matrix.target }} --release
|
||||||
|
|
||||||
@@ -214,19 +226,6 @@ jobs:
|
|||||||
|
|
||||||
rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH
|
rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH
|
||||||
|
|
||||||
- name: Generate nightly timestamp
|
|
||||||
id: timestamp
|
|
||||||
shell: bash
|
|
||||||
run: |
|
|
||||||
# Committer date, not wall clock: every job in this run (including
|
|
||||||
# update-nightly-release, which runs much later) must derive the
|
|
||||||
# exact same tag, or a run straddling midnight UTC splits the
|
|
||||||
# release from its checksums.
|
|
||||||
TIMESTAMP=$(git show -s --format=%cs HEAD)
|
|
||||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
|
||||||
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
|
||||||
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
|
||||||
|
|
||||||
- name: Build Tauri app
|
- name: Build Tauri app
|
||||||
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f #v1.0.0
|
uses: tauri-apps/tauri-action@1deb371b0cd8bd54025b384f1cd735e725c4060f #v1.0.0
|
||||||
env:
|
env:
|
||||||
@@ -238,6 +237,7 @@ jobs:
|
|||||||
APPLE_ID: ${{ secrets.APPLE_ID }}
|
APPLE_ID: ${{ secrets.APPLE_ID }}
|
||||||
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }}
|
||||||
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
|
||||||
|
TARGET: ${{ matrix.target }}
|
||||||
# tauri-action's inner `pnpm tauri build` re-runs beforeBuildCommand
|
# tauri-action's inner `pnpm tauri build` re-runs beforeBuildCommand
|
||||||
# which rebuilds dist/ in a subprocess. The env var must be here too.
|
# which rebuilds dist/ in a subprocess. The env var must be here too.
|
||||||
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
|
NEXT_PUBLIC_TURNSTILE: ${{ secrets.NEXT_PUBLIC_TURNSTILE }}
|
||||||
@@ -416,7 +416,9 @@ jobs:
|
|||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- name: Trigger Cloudflare Pages deployment
|
- name: Trigger Cloudflare Pages deployment
|
||||||
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
|
env:
|
||||||
|
DEPLOYMENT_HOOK: ${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}
|
||||||
|
run: curl -fsSL -X POST "$DEPLOYMENT_HOOK"
|
||||||
|
|
||||||
notify-discord:
|
notify-discord:
|
||||||
if: github.repository == 'zhom/donutbrowser'
|
if: github.repository == 'zhom/donutbrowser'
|
||||||
|
|||||||
@@ -5,6 +5,12 @@ permissions:
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
workflow_call:
|
workflow_call:
|
||||||
|
inputs:
|
||||||
|
checkout_ref:
|
||||||
|
description: Optional commit to check out instead of the triggering ref
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
default: ""
|
||||||
push:
|
push:
|
||||||
branches: ["main"]
|
branches: ["main"]
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -22,5 +28,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Checkout Actions Repository
|
- name: Checkout Actions Repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 #v7.0.0
|
||||||
|
with:
|
||||||
|
ref: ${{ inputs.checkout_ref }}
|
||||||
- name: Spell Check Repo
|
- name: Spell Check Repo
|
||||||
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 #v1.48.0
|
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 #v1.48.0
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
|
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
@@ -73,7 +73,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v7.0.0
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
|
|
||||||
- name: Start MinIO
|
- name: Start MinIO
|
||||||
run: |
|
run: |
|
||||||
@@ -99,7 +99,7 @@ jobs:
|
|||||||
run_install: false
|
run_install: false
|
||||||
|
|
||||||
- name: Setup Node.js
|
- name: Setup Node.js
|
||||||
uses: actions/setup-node@v6
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
|
||||||
with:
|
with:
|
||||||
node-version: "22"
|
node-version: "22"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
|
|
||||||
# testing
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/e2e/app/target/
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# ⛔ ABSOLUTE GIT RULE — READ FIRST (2026-06-11)
|
# ⛔ ABSOLUTE GIT RULE — READ FIRST (2026-06-11)
|
||||||
|
|
||||||
**NEVER run any git command that modifies git history OR the working tree, in ANY repo** (wayfern, wayfern-macos, wayfern-test, donutbrowser, build/src), **unless the user EXPLICITLY authorizes that exact command.** Forbidden without per-command authorization: `commit`, `revert`, `cherry-pick`, `restore`, `checkout` (files/branches), `reset`, `rebase`, `merge`, `stash`, `clean`, `apply`, `add`, `rm`, `push`, any force op. Only read-only git (`status`, `log`, `show`, `diff`, `ls-files`, `rev-parse`) is allowed without asking. **Authorization is per-command: 1 explicit authorization = exactly 1 command.** If a git mutation seems needed, STOP and ask for that one command.
|
**NEVER run any git command that modifies git history OR the working tree, in ANY repo**, **unless the user EXPLICITLY authorizes that exact command.** Forbidden without per-command authorization: `commit`, `revert`, `cherry-pick`, `restore`, `checkout` (files/branches), `reset`, `rebase`, `merge`, `stash`, `clean`, `apply`, `add`, `rm`, `push`, any force op. Only read-only git (`status`, `log`, `show`, `diff`, `ls-files`, `rev-parse`) is allowed without asking. **Authorization is per-command: 1 explicit authorization = exactly 1 command.** If a git mutation seems needed, STOP and ask for that one command.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ donutbrowser/
|
|||||||
│ ├── app/ # App router (page.tsx, layout.tsx)
|
│ ├── app/ # App router (page.tsx, layout.tsx)
|
||||||
│ ├── components/ # 50+ React components (dialogs, tables, UI)
|
│ ├── components/ # 50+ React components (dialogs, tables, UI)
|
||||||
│ ├── hooks/ # Event-driven React hooks
|
│ ├── hooks/ # Event-driven React hooks
|
||||||
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, vi, zh)
|
│ ├── i18n/locales/ # Translations (en, es, fr, ja, ko, pt, ru, tr, vi, zh)
|
||||||
│ ├── lib/ # Utilities (themes, toast, browser-utils)
|
│ ├── lib/ # Utilities (themes, toast, browser-utils)
|
||||||
│ └── types.ts # Shared TypeScript interfaces
|
│ └── types.ts # Shared TypeScript interfaces
|
||||||
├── src-tauri/ # Rust backend (Tauri)
|
├── src-tauri/ # Rust backend (Tauri)
|
||||||
@@ -38,6 +38,10 @@ donutbrowser/
|
|||||||
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
||||||
│ │ ├── settings_manager.rs # App settings persistence
|
│ │ ├── settings_manager.rs # App settings persistence
|
||||||
│ │ ├── cookie_manager.rs # Cookie import/export
|
│ │ ├── cookie_manager.rs # Cookie import/export
|
||||||
|
│ │ ├── profile_importer.rs # Bulk profile import (Chromium-family detection, ZIP, batch)
|
||||||
|
│ │ ├── fingerprint_consistency.rs # Launch-time proxy exit vs fingerprint timezone/language check
|
||||||
|
│ │ ├── dns_blocklist.rs # Hagezi DNS blocklists + user custom lists/allowlist
|
||||||
|
│ │ ├── traffic_stats.rs # Per-profile traffic stats + secure history erase
|
||||||
│ │ ├── extension_manager.rs # Browser extension management
|
│ │ ├── extension_manager.rs # Browser extension management
|
||||||
│ │ ├── group_manager.rs # Profile group management
|
│ │ ├── group_manager.rs # Profile group management
|
||||||
│ │ ├── synchronizer.rs # Real-time profile synchronizer
|
│ │ ├── synchronizer.rs # Real-time profile synchronizer
|
||||||
@@ -47,7 +51,10 @@ donutbrowser/
|
|||||||
│ └── Cargo.toml # Rust dependencies
|
│ └── Cargo.toml # Rust dependencies
|
||||||
├── donut-sync/ # NestJS sync server (self-hostable)
|
├── donut-sync/ # NestJS sync server (self-hostable)
|
||||||
│ └── src/ # Controllers, services, auth, S3 sync
|
│ └── src/ # Controllers, services, auth, S3 sync
|
||||||
├── docs/ # Documentation (self-hosting guide)
|
├── e2e/ # Isolated native UI/sync/Wayfern E2E system
|
||||||
|
│ ├── app/ # Test-only Tauri harness that injects the private driver
|
||||||
|
│ ├── lib/ # WebDriver, CDP, fixtures, app-session helpers
|
||||||
|
│ └── tests/ # Smoke, UI, entity, integration, sync, browser suites
|
||||||
├── flake.nix # Nix development environment
|
├── flake.nix # Nix development environment
|
||||||
└── .github/workflows/ # CI/CD pipelines
|
└── .github/workflows/ # CI/CD pipelines
|
||||||
```
|
```
|
||||||
@@ -60,6 +67,37 @@ donutbrowser/
|
|||||||
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
|
- The full `pnpm test` output dumps every test name (≈400+ lines) which burns context for no signal. Filter:
|
||||||
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
|
`pnpm test 2>&1 | grep -E "test result|panicked|FAILED"` — four "test result: ok" lines means everything passed.
|
||||||
|
|
||||||
|
### Native app E2E tests are mandatory for affected behavior
|
||||||
|
|
||||||
|
The native suites use a sibling private test-driver repository and launch an `e2e`-feature build.
|
||||||
|
Every session gets its own temporary Donut data/cache/log root, home directory,
|
||||||
|
WebView store, ports, and sync bucket. Never point a suite at production or development data.
|
||||||
|
|
||||||
|
After a behavior change, run the smallest affected subset below in addition to the standard
|
||||||
|
format/lint/unit-test command. A code change is not considered verified until its affected native
|
||||||
|
suite passes:
|
||||||
|
|
||||||
|
| Changed area | Required command |
|
||||||
|
| --- | --- |
|
||||||
|
| Startup, settings, persistence, window state, shortcuts, navigation | `pnpm e2e:smoke` |
|
||||||
|
| React components, dialogs, themes/appearance, responsive layout, accessibility, onboarding | `pnpm e2e:ui` |
|
||||||
|
| Profile/import/group/proxy/VPN/extension CRUD, DNS, cookies, passwords, traffic | `pnpm e2e:entities` |
|
||||||
|
| Profile/group/proxy/VPN/extension UI, proxy routing, VPN routing, or their browser-launch integration | `pnpm e2e:network` |
|
||||||
|
| REST API/OpenAPI, MCP, cloud/update contracts, team locks, real-time synchronizer | `pnpm e2e:integrations` |
|
||||||
|
| Sync client/server, manifests, timestamps, deletion, encryption, password rollover | `pnpm e2e:sync` |
|
||||||
|
| Wayfern download/terms/fingerprint, browser runner, CDP, automation endpoints, process cleanup | `pnpm e2e:browser` |
|
||||||
|
| E2E harness, WebDriver plugin/driver, app isolation hooks, or changes spanning multiple rows | Run every affected row; use `pnpm e2e` for cross-cutting changes |
|
||||||
|
|
||||||
|
`e2e:browser` requires `WAYFERN_TEST_TOKEN` in the environment or local `.env`. `e2e:network`
|
||||||
|
and the full suite additionally require Docker plus `RESIDENTIAL_PROXY_URL_ONE_HTTP` and
|
||||||
|
`RESIDENTIAL_PROXY_URL_ONE_SOCKS`. Other individual suites must run without credentials. Use
|
||||||
|
`--no-build` only when the frontend, Rust app, sidecar, and WebDriver binaries are already current.
|
||||||
|
Keep failed artifacts and inspect the per-session app/driver logs and screenshot before changing
|
||||||
|
assertions.
|
||||||
|
|
||||||
|
When adding a Tauri command, assign it exactly once in `e2e/coverage-map.mjs` and add executable
|
||||||
|
evidence to the owning suite. `e2e:smoke` fails if command registration and the coverage map drift.
|
||||||
|
|
||||||
## Logs (when debugging a running app)
|
## Logs (when debugging a running app)
|
||||||
|
|
||||||
Three log surfaces, in order of usefulness:
|
Three log surfaces, in order of usefulness:
|
||||||
@@ -79,12 +117,12 @@ Linux/Windows swap `~/Library/Logs/com.donutbrowser/` for the platform-appropria
|
|||||||
|
|
||||||
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
|
- Never write user-facing strings as raw English literals in JSX, toast messages, dialog titles/descriptions, button labels, placeholders, table headers, tooltips, or empty-state text. Always go through `t("namespace.key")` from `useTranslation()`.
|
||||||
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
|
- This applies to every component under `src/` — including new ones. If a component doesn't already import `useTranslation`, add it.
|
||||||
- Adding a new string means adding the key to ALL nine locale files in `src/i18n/locales/` (en, es, fr, ja, ko, pt, ru, vi, zh) — not just `en.json`. The English version alone is incomplete work.
|
- Adding a new string means adding the key to EVERY locale file in `src/i18n/locales/` — currently en, es, fr, ja, ko, pt, ru, tr, vi, zh — not just `en.json`. The English version alone is incomplete work. Don't trust this list: enumerate `src/i18n/locales/*.json` and update every file you find, because a newly added locale is exactly what a hardcoded list silently skips.
|
||||||
- Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first.
|
- Reuse existing keys (`common.buttons.*`, `common.labels.*`, `createProfile.*`, etc.) before creating new namespaces. Check `en.json` first.
|
||||||
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
|
- Strings excluded from this rule: `console.log/warn/error`, dev-only debug labels, internal IDs, CSS class names, type names. If unsure whether a string renders to the user, assume it does and translate it.
|
||||||
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
|
- **Never use `t(key, "fallback")` with a default-value second argument.** The 2-arg form is forbidden — every key must exist in every locale file before the call site lands. Fallbacks mask missing translations: a key missing from `ru.json` will silently render the English fallback to Russian users, so the bug never surfaces in CI or review. Only call `t("namespace.key")`. If a translation is missing for any locale, that's a bug to fix at the JSON, not a hole to paper over at the call site.
|
||||||
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
|
- Empty-string values in non-English locales are also forbidden — a locale either has the right translation or it has the same content as English; never `""`. If a particular language doesn't need a particular phrase (e.g. a suffix that doesn't grammatically apply), refactor the JSX to use a single interpolated key (`t("foo.bar", { name })` with `"...{{name}}..."` in each locale) instead of splitting prefix/suffix.
|
||||||
- When adding or removing keys across all nine locales, use a one-shot Python script in `/tmp/` that loads each `*.json`, mutates it, and writes it back. Nine sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away.
|
- When adding or removing keys across the locales, use a one-shot Python script in the scratchpad dir that globs `src/i18n/locales/*.json`, mutates each, and writes it back. Sequential `Edit` calls drift (typos, ordering differences) and burn tokens; a single script keeps the locales in lockstep and is easy to throw away. Finish by diffing every locale's flattened key set against `en.json` — zero missing and zero extra, for all of them.
|
||||||
|
|
||||||
## Backend error codes (mandatory)
|
## Backend error codes (mandatory)
|
||||||
|
|
||||||
@@ -98,10 +136,34 @@ User-facing errors returned from a Tauri command MUST be JSON `{ "code": "FOO_BA
|
|||||||
```
|
```
|
||||||
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
|
2. Add `"FOO_BAR"` to the `BackendErrorCode` union in `src/lib/backend-errors.ts`.
|
||||||
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
|
3. Add a `case "FOO_BAR":` in the switch that returns `t("backendErrors.fooBar", …)`.
|
||||||
4. Add `backendErrors.fooBar` to all nine locale files.
|
4. Add `backendErrors.fooBar` to every locale file in `src/i18n/locales/`.
|
||||||
|
|
||||||
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
|
Raw error strings reach the user untranslated; that's the bug pattern this rule blocks.
|
||||||
|
|
||||||
|
## REST API (`src-tauri/src/api_server.rs`) — endpoints must stay in the OpenAPI spec
|
||||||
|
|
||||||
|
The served `/openapi.json` comes from the hand-maintained `ApiDoc` derive (`#[derive(OpenApi)]` with `paths(...)`, `components(schemas(...))`, `tags(...)`) — NOT from the router. The `OpenApiRouter`-generated spec is discarded (`let (v1_routes, _) = ...`), so a handler registered on the router but missing from `ApiDoc` silently disappears from the spec (this happened to the extension and VPN-export endpoints once).
|
||||||
|
|
||||||
|
**Any endpoint modification — adding, removing, or changing a route, request/response schema, or status code — must be reflected in the OpenAPI spec in the same change:**
|
||||||
|
|
||||||
|
1. Keep the handler's `#[utoipa::path]` annotation accurate (path, request body, every reachable response status).
|
||||||
|
2. Add/remove the handler in `ApiDoc`'s `paths(...)` list and any new schema types in `components(schemas(...))`.
|
||||||
|
3. Extend the `openapi_*` regression tests in `api_server.rs::tests` (they assert spec coverage and that optional fields stay optional).
|
||||||
|
4. `#[schema(value_type = Object)]` on an `Option<T>` field erases the optionality and wrongly marks it required — use `value_type = Option<Object>` (or drop the attribute for natively supported types).
|
||||||
|
|
||||||
|
### Error status conventions (known errors)
|
||||||
|
|
||||||
|
Handlers route manager errors through `manager_error_response`, which maps message content onto a consistent status and passes the text through as the response body:
|
||||||
|
|
||||||
|
- `401` — missing/invalid bearer token (auth middleware; empty body).
|
||||||
|
- `402` — the five automation endpoints (`run`, `open-url`, `kill`, `batch/run`, `batch/stop`) without a paid plan, and expired-proxy (`PROXY_PAYMENT_REQUIRED`) checks.
|
||||||
|
- `404` — entity not found (`… not found` / `*_NOT_FOUND`).
|
||||||
|
- `400` — validation, duplicates, empty names, invalid/unsupported/unavailable input.
|
||||||
|
- `409` — conflicts: browser version already being downloaded, profile locked by another team member (run), browser running during cookie import.
|
||||||
|
- `500` — internal failures (IO, network, poisoned locks).
|
||||||
|
|
||||||
|
Error bodies are plain-text diagnostics; some are the JSON `{"code": ...}` strings shared with the Tauri commands (e.g. `NAME_CANNOT_BE_EMPTY`, `GROUP_ALREADY_EXISTS`). The translated-error rule above applies to Tauri commands, not to REST bodies.
|
||||||
|
|
||||||
## Sub-page Dialog mode
|
## Sub-page Dialog mode
|
||||||
|
|
||||||
A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs:
|
A `<Dialog>` becomes a first-class app sub-page (no modal overlay, no center positioning) when `subPage` is passed. Pages like Account, Settings, Proxy Management, and Extension Management use this. The pattern for a sub-page with tabs:
|
||||||
@@ -151,7 +213,7 @@ Reference implementations: `proxy-management-dialog.tsx`, `extension-management-
|
|||||||
|
|
||||||
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
|
All app-wide shortcuts live in `src/lib/shortcuts.ts`:
|
||||||
|
|
||||||
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in all nine locales.
|
- `SHORTCUTS[]` — one entry per shortcut (id, label translation key, group, key, modifier flags). The label key must exist in every locale.
|
||||||
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
|
- `formatShortcut(s)` returns platform-correct token strings (`["⌘", "K"]` on mac, `["Ctrl", "K"]` elsewhere) — used by both the shortcuts page and the command palette.
|
||||||
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
|
- `matchesShortcut(s, event)` matches a real `KeyboardEvent` and rejects the wrong-platform modifier so Ctrl+K on macOS never fires a `mod: true` shortcut.
|
||||||
- `matchesGroupDigit(event)` returns 1–9 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
|
- `matchesGroupDigit(event)` returns 1–9 if Mod+digit was pressed — group switching is dynamic (driven by `orderedGroupTargets` in `page.tsx`) and isn't in the `SHORTCUTS` table.
|
||||||
@@ -161,7 +223,7 @@ Dispatch: the global `keydown` listener and the `runShortcut` callback both live
|
|||||||
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
|
1. Append to `SHORTCUTS` in `src/lib/shortcuts.ts`. Add the `ShortcutId` variant.
|
||||||
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
|
2. Add a `case "yourId":` in `runShortcut` in `page.tsx`.
|
||||||
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
|
3. Add the icon mapping in `src/components/command-palette.tsx::ICONS`.
|
||||||
4. Add `shortcuts.yourId` (label) to all nine locale files.
|
4. Add `shortcuts.yourId` (label) to every locale file in `src/i18n/locales/`.
|
||||||
|
|
||||||
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
|
The command palette (Mod+K) is built on the shadcn `Command` primitive with a token-AND fuzzy filter — `fuzzyFilter` in `command-palette.tsx`. The `CommandDialog` wrapper now forwards `filter`/`shouldFilter` to the inner `Command` for callers that need custom matching.
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,29 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
|
||||||
|
## v0.28.2 (2026-07-12)
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- sha256 checksum for self-updates
|
||||||
|
- progress bar for extraction
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- properly handle location spoofing for socks5 proxies
|
||||||
|
|
||||||
|
### Refactoring
|
||||||
|
|
||||||
|
- api cleanup
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- chore: version bump
|
||||||
|
- chore: linting
|
||||||
|
- ci(deps): bump the github-actions group with 2 updates
|
||||||
|
- chore: update flake.nix for v0.28.1 [skip ci] (#493)
|
||||||
|
|
||||||
|
|
||||||
## v0.28.1 (2026-07-09)
|
## v0.28.1 (2026-07-09)
|
||||||
|
|
||||||
### Refactoring
|
### Refactoring
|
||||||
|
|||||||
@@ -46,7 +46,7 @@
|
|||||||
|
|
||||||
| | Apple Silicon | Intel |
|
| | Apple Silicon | Intel |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_x64.dmg) |
|
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64.dmg) |
|
||||||
|
|
||||||
Or install via Homebrew:
|
Or install via Homebrew:
|
||||||
|
|
||||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
|||||||
|
|
||||||
### Windows
|
### Windows
|
||||||
|
|
||||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_x64-portable.zip)
|
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_x64-portable.zip)
|
||||||
|
|
||||||
### Linux
|
### Linux
|
||||||
|
|
||||||
| Format | x86_64 | ARM64 |
|
| Format | x86_64 | ARM64 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_arm64.deb) |
|
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_arm64.deb) |
|
||||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut-0.28.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut-0.28.1-1.aarch64.rpm) |
|
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.aarch64.rpm) |
|
||||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_aarch64.AppImage) |
|
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage) |
|
||||||
<!-- install-links-end -->
|
<!-- install-links-end -->
|
||||||
|
|
||||||
Or install via package manager:
|
Or install via package manager:
|
||||||
@@ -135,6 +135,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
<sub><b>Hassiy</b></sub>
|
<sub><b>Hassiy</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/xenos1337">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/66328734?v=4" width="100;" alt="xenos1337"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>xenos</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/webees">
|
<a href="https://github.com/webees">
|
||||||
<img src="https://avatars.githubusercontent.com/u/5155291?v=4" width="100;" alt="webees"/>
|
<img src="https://avatars.githubusercontent.com/u/5155291?v=4" width="100;" alt="webees"/>
|
||||||
@@ -156,6 +163,8 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
<sub><b>Huy Le</b></sub>
|
<sub><b>Huy Le</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/drunkod">
|
<a href="https://github.com/drunkod">
|
||||||
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
||||||
@@ -163,8 +172,6 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
<sub><b>drunkod</b></sub>
|
<sub><b>drunkod</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/JorySeverijnse">
|
<a href="https://github.com/JorySeverijnse">
|
||||||
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
|
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
|
||||||
@@ -179,6 +186,13 @@ See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|||||||
<sub><b>Thiago Mafra</b></sub>
|
<sub><b>Thiago Mafra</b></sub>
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<a href="https://github.com/mchnkkc">
|
||||||
|
<img src="https://avatars.githubusercontent.com/u/251900355?v=4" width="100;" alt="mchnkkc"/>
|
||||||
|
<br />
|
||||||
|
<sub><b>mchnkkc</b></sub>
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
<td align="center">
|
<td align="center">
|
||||||
<a href="https://github.com/liasica">
|
<a href="https://github.com/liasica">
|
||||||
<img src="https://avatars.githubusercontent.com/u/671431?v=4" width="100;" alt="liasica"/>
|
<img src="https://avatars.githubusercontent.com/u/671431?v=4" width="100;" alt="liasica"/>
|
||||||
|
|||||||
@@ -329,7 +329,16 @@ export class SyncService implements OnModuleInit {
|
|||||||
Metadata: metadata,
|
Metadata: metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
const metadataHeaders = new Set(
|
||||||
|
Object.keys(metadata ?? {}).map((name) => `x-amz-meta-${name}`),
|
||||||
|
);
|
||||||
|
const url = await getSignedUrl(this.s3Client, command, {
|
||||||
|
expiresIn,
|
||||||
|
// The AWS presigner otherwise hoists user metadata into the query string.
|
||||||
|
// The client echoes the response metadata as headers, so those headers
|
||||||
|
// must remain in the request and be covered by SignedHeaders.
|
||||||
|
unhoistableHeaders: metadataHeaders,
|
||||||
|
});
|
||||||
|
|
||||||
// Report profile usage after upload presign if key is under profiles/
|
// Report profile usage after upload presign if key is under profiles/
|
||||||
if (ctx.mode === "cloud" && dto.key.startsWith("profiles/")) {
|
if (ctx.mode === "cloud" && dto.key.startsWith("profiles/")) {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
interface PresignResponse {
|
interface PresignResponse {
|
||||||
url: string;
|
url: string;
|
||||||
expiresAt: string;
|
expiresAt: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ListResponse {
|
interface ListResponse {
|
||||||
@@ -34,6 +35,7 @@ interface StatResponse {
|
|||||||
exists: boolean;
|
exists: boolean;
|
||||||
size?: number;
|
size?: number;
|
||||||
lastModified?: string;
|
lastModified?: string;
|
||||||
|
metadata?: Record<string, string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SyncController (e2e)", () => {
|
describe("SyncController (e2e)", () => {
|
||||||
@@ -112,6 +114,65 @@ describe("SyncController (e2e)", () => {
|
|||||||
expect(body.url).toContain("test/upload-key.txt");
|
expect(body.url).toContain("test/upload-key.txt");
|
||||||
expect(body.expiresAt).toBeDefined();
|
expect(body.expiresAt).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("should sign and persist echoed object metadata", async () => {
|
||||||
|
const testKey = `vpns/metadata-${Date.now()}.json`;
|
||||||
|
const updatedAt = Math.floor(Date.now() / 1000).toString();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await request(app.getHttpServer())
|
||||||
|
.post("/v1/objects/presign-upload")
|
||||||
|
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||||
|
.send({
|
||||||
|
key: testKey,
|
||||||
|
contentType: "application/json",
|
||||||
|
metadata: {
|
||||||
|
"updated-at": updatedAt,
|
||||||
|
ignored: "not-allowed",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const body = response.body as PresignResponse;
|
||||||
|
expect(body.metadata).toEqual({ "updated-at": updatedAt });
|
||||||
|
|
||||||
|
const uploadUrl = new URL(body.url);
|
||||||
|
const signedHeaders =
|
||||||
|
uploadUrl.searchParams.get("X-Amz-SignedHeaders")?.split(";") ?? [];
|
||||||
|
expect(signedHeaders).toContain("x-amz-meta-updated-at");
|
||||||
|
expect(uploadUrl.searchParams.has("x-amz-meta-updated-at")).toBe(false);
|
||||||
|
|
||||||
|
const uploadResult = await fetch(body.url, {
|
||||||
|
method: "PUT",
|
||||||
|
body: "{}",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"x-amz-meta-updated-at": updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (!uploadResult.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Metadata upload failed with status ${uploadResult.status}: ${await uploadResult.text()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const statResponse = await request(app.getHttpServer())
|
||||||
|
.post("/v1/objects/stat")
|
||||||
|
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||||
|
.send({ key: testKey })
|
||||||
|
.expect(200);
|
||||||
|
|
||||||
|
const statBody = statResponse.body as StatResponse;
|
||||||
|
expect(statBody.exists).toBe(true);
|
||||||
|
expect(statBody.metadata?.["updated-at"]).toBe(updatedAt);
|
||||||
|
} finally {
|
||||||
|
await request(app.getHttpServer())
|
||||||
|
.post("/v1/objects/delete")
|
||||||
|
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||||
|
.send({ key: testKey })
|
||||||
|
.expect(200);
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("POST /v1/objects/presign-download", () => {
|
describe("POST /v1/objects/presign-download", () => {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
# Donut Browser native E2E tests
|
||||||
|
|
||||||
|
These tests exercise the actual Tauri application through a sibling native test driver. They do
|
||||||
|
not replace Rust or React unit tests; they
|
||||||
|
cover the process boundaries those tests cannot: WKWebView/WebView2/WebKitGTK UI, Tauri invokes,
|
||||||
|
REST and MCP servers, two-device sync, S3 payload encryption, Wayfern, CDP, and child-process
|
||||||
|
cleanup.
|
||||||
|
|
||||||
|
## Local setup
|
||||||
|
|
||||||
|
Place both repositories beside each other:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Code/
|
||||||
|
├── donutbrowser/
|
||||||
|
└── <test-driver-checkout>/
|
||||||
|
```
|
||||||
|
|
||||||
|
Install Donut dependencies with `pnpm install`. The browser suite also needs
|
||||||
|
`WAYFERN_TEST_TOKEN`. The runner reads it from the environment or Donut's ignored `.env` without
|
||||||
|
printing it. When a local browser fixture is configured, the runner copies it into the test data
|
||||||
|
root (using an isolated APFS clone on macOS); otherwise the browser suite downloads the current
|
||||||
|
published build into that root.
|
||||||
|
|
||||||
|
Set `DONUT_E2E_WAYFERN_PATH` to use a local browser fixture. Without it, the runner uses an ignored
|
||||||
|
cache fixture when present and otherwise downloads the published test build.
|
||||||
|
|
||||||
|
The real-network suite additionally requires Docker plus
|
||||||
|
`RESIDENTIAL_PROXY_URL_ONE_HTTP` and `RESIDENTIAL_PROXY_URL_ONE_SOCKS`. It creates its own
|
||||||
|
WireGuard server and tunnel-only HTTP target in a disposable container. It never connects a test
|
||||||
|
profile to a developer or production VPN.
|
||||||
|
|
||||||
|
Run one suite:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm e2e:smoke
|
||||||
|
pnpm e2e:ui
|
||||||
|
pnpm e2e:entities
|
||||||
|
pnpm e2e:network
|
||||||
|
pnpm e2e:integrations
|
||||||
|
pnpm e2e:sync
|
||||||
|
pnpm e2e:browser
|
||||||
|
```
|
||||||
|
|
||||||
|
Run everything with `pnpm e2e`. A normal run builds the Next frontend, `donut-proxy`, the
|
||||||
|
private harness in `e2e/app`, and `tauri-wd`. The harness enables Donut's `e2e` feature and injects
|
||||||
|
the sibling WebDriver plugin without making the production crate depend on a private filesystem
|
||||||
|
path. Add `--no-build` to `node e2e/run.mjs --suite=<name>` only when all four outputs are current.
|
||||||
|
`DONUT_E2E_KEEP_ARTIFACTS=1` retains successful local runs; failed runs are always retained and
|
||||||
|
their location is printed. Raw screenshots, captured HTML, logs, and isolated app state stay local.
|
||||||
|
The runner also creates a text-only `diagnostics/` directory whose logs are redacted and checked
|
||||||
|
against active test secrets. CI uploads only that directory on failure. Disposable copied browser
|
||||||
|
binaries are pruned so repeated failures do not consume gigabytes.
|
||||||
|
|
||||||
|
The suites deliberately distinguish visible behavior from command coverage. `e2e:entities`
|
||||||
|
exercises isolated CRUD and persistence through Tauri commands. `e2e:network` visibly creates a
|
||||||
|
profile group, HTTP proxy, WireGuard VPN, extension, extension group, and Wayfern profile; assigns
|
||||||
|
the proxy and VPN in the profile table; validates both residential HTTP and SOCKS5 proxies; then
|
||||||
|
launches Wayfern through the residential proxy and through the local WireGuard tunnel. Normal test
|
||||||
|
sessions start with onboarding completed so the Welcome dialog cannot hide the feature under test.
|
||||||
|
The onboarding and Wayfern-terms scenarios explicitly opt into fresh state and test those dialogs.
|
||||||
|
`e2e:ui` selects predefined, preset, and manually customized themes through the native UI and
|
||||||
|
asserts their persisted settings and rendered CSS variables across rail navigation and app restart.
|
||||||
|
|
||||||
|
## Isolation contract
|
||||||
|
|
||||||
|
Each app session receives a unique root under the operating-system test temp directory. The
|
||||||
|
runner redirects:
|
||||||
|
|
||||||
|
- Donut data, cache, and logs with `DONUTBROWSER_DATA_ROOT`;
|
||||||
|
- `HOME`, `USERPROFILE`, `CFFIXED_USER_HOME`, XDG paths, `APPDATA`, and `LOCALAPPDATA`;
|
||||||
|
- `TMPDIR`, `TMP`, and `TEMP`;
|
||||||
|
- the Tauri WebView store (incognito for WKWebView, whose persistent data-directory API is not
|
||||||
|
honored);
|
||||||
|
- all REST, MCP, WebDriver, fixture, MinIO, and sync-server ports;
|
||||||
|
- each sync test to a new MinIO bucket and random token.
|
||||||
|
|
||||||
|
The E2E feature suppresses automatic updater/download traffic, but explicit browser tests still
|
||||||
|
exercise published Wayfern downloads when no local fixture exists. Entitlement fallback from
|
||||||
|
`WAYFERN_TEST_TOKEN` exists only in the feature-gated test binary. Production builds never include
|
||||||
|
the WebDriver plugin or this fallback.
|
||||||
|
|
||||||
|
## CI
|
||||||
|
|
||||||
|
`.github/workflows/app-e2e.yml` runs smoke tests on macOS, Linux/Xvfb, and Windows for pull
|
||||||
|
requests. Pushes to `main`, weekly schedules, and manual runs execute the full macOS suite,
|
||||||
|
including MinIO-backed sync and real Wayfern automation, plus a Linux/Docker job for residential
|
||||||
|
proxy and local WireGuard browser traffic.
|
||||||
|
|
||||||
|
CI needs a `TAURI_WEBDRIVER_TOKEN` secret with read-only access and a
|
||||||
|
`TAURI_WEBDRIVER_REPOSITORY` repository variable identifying the test-driver checkout. The full
|
||||||
|
job also requires the `WAYFERN_TEST_TOKEN` secret. The network job requires that secret plus
|
||||||
|
`RESIDENTIAL_PROXY_URL_ONE_HTTP` and `RESIDENTIAL_PROXY_URL_ONE_SOCKS`.
|
||||||
Generated
+9073
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,12 @@
|
|||||||
|
[package]
|
||||||
|
name = "donutbrowser-e2e"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
publish = false
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
donutbrowser-lib = { package = "donutbrowser", path = "../../src-tauri", features = ["e2e"] }
|
||||||
|
tauri-plugin-cross-platform-webdriver = { path = "../../../tauri-cross-platform-webdriver/crates/tauri-plugin-cross-platform-webdriver" }
|
||||||
|
|
||||||
|
[patch.crates-io]
|
||||||
|
wayland-scanner = { git = "https://github.com/Smithay/wayland-rs", rev = "d07c4f91f28b42e5a485823ffd9d8d5a210b1053" }
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
donutbrowser_lib::run_with_builder(|builder| {
|
||||||
|
builder.plugin(tauri_plugin_cross_platform_webdriver::init())
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/**
|
||||||
|
* Auditable ownership for every Tauri command. The coverage test compares this
|
||||||
|
* map to generate_handler!, so adding a backend capability without assigning it
|
||||||
|
* to an E2E suite fails immediately.
|
||||||
|
*
|
||||||
|
* "integration" means the suite exercises the command with real isolated state.
|
||||||
|
* "contract" means the command's safe/read-only or unauthenticated path is run.
|
||||||
|
* "host-mutating" is reserved for operations whose purpose is to change the
|
||||||
|
* machine outside Donut's data roots; their reason must remain explicit.
|
||||||
|
*/
|
||||||
|
export const commandCoverage = {
|
||||||
|
lifecycle: {
|
||||||
|
suite: "smoke",
|
||||||
|
level: "integration",
|
||||||
|
commands: [
|
||||||
|
"confirm_quit",
|
||||||
|
"hide_to_tray",
|
||||||
|
"update_tray_menu",
|
||||||
|
"get_app_settings",
|
||||||
|
"save_app_settings",
|
||||||
|
"read_log_files",
|
||||||
|
"get_table_sorting_settings",
|
||||||
|
"save_table_sorting_settings",
|
||||||
|
"get_system_language",
|
||||||
|
"get_system_info",
|
||||||
|
"dismiss_window_resize_warning",
|
||||||
|
"get_window_resize_warning_dismissed",
|
||||||
|
"get_onboarding_completed",
|
||||||
|
"complete_onboarding",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
profileEntities: {
|
||||||
|
suite: "entities",
|
||||||
|
level: "integration",
|
||||||
|
commands: [
|
||||||
|
"delete_profile",
|
||||||
|
"clone_profile",
|
||||||
|
"create_browser_profile_new",
|
||||||
|
"list_browser_profiles",
|
||||||
|
"get_all_tags",
|
||||||
|
"update_profile_proxy",
|
||||||
|
"update_profile_vpn",
|
||||||
|
"update_profile_tags",
|
||||||
|
"update_profile_note",
|
||||||
|
"update_profile_clear_on_close",
|
||||||
|
"update_profile_launch_hook",
|
||||||
|
"update_profile_window_color",
|
||||||
|
"update_profile_proxy_bypass_rules",
|
||||||
|
"update_profile_dns_blocklist",
|
||||||
|
"rename_profile",
|
||||||
|
"detect_existing_profiles",
|
||||||
|
"import_browser_profiles",
|
||||||
|
"scan_folder_for_profiles",
|
||||||
|
"scan_profile_archive",
|
||||||
|
"cleanup_profile_import_scratch",
|
||||||
|
"get_profile_groups",
|
||||||
|
"get_groups_with_profile_counts",
|
||||||
|
"create_profile_group",
|
||||||
|
"update_profile_group",
|
||||||
|
"delete_profile_group",
|
||||||
|
"assign_profiles_to_group",
|
||||||
|
"delete_selected_profiles",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
proxyEntities: {
|
||||||
|
suite: "entities",
|
||||||
|
level: "integration",
|
||||||
|
commands: [
|
||||||
|
"create_stored_proxy",
|
||||||
|
"get_stored_proxies",
|
||||||
|
"update_stored_proxy",
|
||||||
|
"delete_stored_proxy",
|
||||||
|
"check_proxy_validity",
|
||||||
|
"get_cached_proxy_check",
|
||||||
|
"export_proxies",
|
||||||
|
"import_proxies_json",
|
||||||
|
"parse_txt_proxies",
|
||||||
|
"import_proxies_from_parsed",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
extensions: {
|
||||||
|
suite: "entities",
|
||||||
|
level: "integration",
|
||||||
|
commands: [
|
||||||
|
"list_extensions",
|
||||||
|
"get_extension_icon",
|
||||||
|
"add_extension",
|
||||||
|
"update_extension",
|
||||||
|
"delete_extension",
|
||||||
|
"list_extension_groups",
|
||||||
|
"create_extension_group",
|
||||||
|
"update_extension_group",
|
||||||
|
"delete_extension_group",
|
||||||
|
"add_extension_to_group",
|
||||||
|
"remove_extension_from_group",
|
||||||
|
"assign_extension_group_to_profile",
|
||||||
|
"get_extension_group_for_profile",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
vpn: {
|
||||||
|
suite: "entities",
|
||||||
|
level: "integration",
|
||||||
|
commands: [
|
||||||
|
"import_vpn_config",
|
||||||
|
"list_vpn_configs",
|
||||||
|
"get_vpn_config",
|
||||||
|
"delete_vpn_config",
|
||||||
|
"create_vpn_config_manual",
|
||||||
|
"update_vpn_config",
|
||||||
|
"check_vpn_validity",
|
||||||
|
"disconnect_vpn",
|
||||||
|
"get_vpn_status",
|
||||||
|
"list_active_vpn_connections",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cookiesPasswordsAndTraffic: {
|
||||||
|
suite: "entities",
|
||||||
|
level: "integration",
|
||||||
|
commands: [
|
||||||
|
"get_all_traffic_snapshots",
|
||||||
|
"get_profile_traffic_snapshot",
|
||||||
|
"clear_all_traffic_stats",
|
||||||
|
"clear_profile_traffic_stats",
|
||||||
|
"get_traffic_stats_for_period",
|
||||||
|
"read_profile_cookies",
|
||||||
|
"get_profile_cookie_stats",
|
||||||
|
"copy_profile_cookies",
|
||||||
|
"import_cookies_from_file",
|
||||||
|
"export_profile_cookies",
|
||||||
|
"set_profile_password",
|
||||||
|
"change_profile_password",
|
||||||
|
"remove_profile_password",
|
||||||
|
"verify_profile_password",
|
||||||
|
"unlock_profile",
|
||||||
|
"lock_profile",
|
||||||
|
"is_profile_locked",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
dns: {
|
||||||
|
suite: "entities",
|
||||||
|
level: "integration",
|
||||||
|
commands: [
|
||||||
|
"dns_blocklist::get_dns_blocklist_cache_status",
|
||||||
|
"dns_blocklist::refresh_dns_blocklists",
|
||||||
|
"dns_blocklist::get_custom_dns_config",
|
||||||
|
"dns_blocklist::set_custom_dns_config",
|
||||||
|
"dns_blocklist::import_custom_dns_rules",
|
||||||
|
"dns_blocklist::export_custom_dns_rules",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
browser: {
|
||||||
|
suite: "browser",
|
||||||
|
level: "integration",
|
||||||
|
commands: [
|
||||||
|
"get_supported_browsers",
|
||||||
|
"check_browser_exists",
|
||||||
|
"is_browser_supported_on_platform",
|
||||||
|
"download_browser",
|
||||||
|
"cancel_download",
|
||||||
|
"launch_browser_profile",
|
||||||
|
"fetch_browser_versions_with_count",
|
||||||
|
"fetch_browser_versions_cached_first",
|
||||||
|
"fetch_browser_versions_with_count_cached_first",
|
||||||
|
"get_downloaded_browser_versions",
|
||||||
|
"get_browser_release_types",
|
||||||
|
"check_browser_status",
|
||||||
|
"kill_browser_profile",
|
||||||
|
"open_url_with_profile",
|
||||||
|
"check_missing_binaries",
|
||||||
|
"check_missing_geoip_database",
|
||||||
|
"ensure_all_binaries_exist",
|
||||||
|
"ensure_active_browsers_downloaded",
|
||||||
|
"update_wayfern_config",
|
||||||
|
"generate_sample_fingerprint",
|
||||||
|
"is_geoip_database_available",
|
||||||
|
"download_geoip_database",
|
||||||
|
"fingerprint_consistency::check_profile_fingerprint_consistency",
|
||||||
|
"fingerprint_consistency::match_profile_fingerprint_to_exit",
|
||||||
|
"check_wayfern_terms_accepted",
|
||||||
|
"check_wayfern_downloaded",
|
||||||
|
"accept_wayfern_terms",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
localIntegrations: {
|
||||||
|
suite: "integrations",
|
||||||
|
level: "integration",
|
||||||
|
commands: [
|
||||||
|
"start_api_server",
|
||||||
|
"stop_api_server",
|
||||||
|
"get_api_server_status",
|
||||||
|
"start_mcp_server",
|
||||||
|
"stop_mcp_server",
|
||||||
|
"get_mcp_server_status",
|
||||||
|
"get_mcp_config",
|
||||||
|
"list_mcp_agents",
|
||||||
|
"add_mcp_to_agent",
|
||||||
|
"remove_mcp_from_agent",
|
||||||
|
"synchronizer::start_sync_session",
|
||||||
|
"synchronizer::stop_sync_session",
|
||||||
|
"synchronizer::remove_sync_follower",
|
||||||
|
"synchronizer::get_sync_sessions",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
syncAndEncryption: {
|
||||||
|
suite: "sync",
|
||||||
|
level: "integration",
|
||||||
|
commands: [
|
||||||
|
"get_sync_settings",
|
||||||
|
"save_sync_settings",
|
||||||
|
"cloud_auth::restart_sync_service",
|
||||||
|
"set_profile_sync_mode",
|
||||||
|
"cancel_profile_sync",
|
||||||
|
"request_profile_sync",
|
||||||
|
"set_proxy_sync_enabled",
|
||||||
|
"set_group_sync_enabled",
|
||||||
|
"is_proxy_in_use_by_synced_profile",
|
||||||
|
"is_group_in_use_by_synced_profile",
|
||||||
|
"set_vpn_sync_enabled",
|
||||||
|
"is_vpn_in_use_by_synced_profile",
|
||||||
|
"set_extension_sync_enabled",
|
||||||
|
"set_extension_group_sync_enabled",
|
||||||
|
"get_unsynced_entity_counts",
|
||||||
|
"enable_sync_for_all_entities",
|
||||||
|
"set_e2e_password",
|
||||||
|
"check_has_e2e_password",
|
||||||
|
"verify_e2e_password",
|
||||||
|
"delete_e2e_password",
|
||||||
|
"rollover_encryption_for_all_entities",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
cloudContracts: {
|
||||||
|
suite: "integrations",
|
||||||
|
level: "contract",
|
||||||
|
commands: [
|
||||||
|
"get_commercial_trial_status",
|
||||||
|
"acknowledge_trial_expiration",
|
||||||
|
"has_acknowledged_trial_expiration",
|
||||||
|
"cloud_auth::cloud_exchange_device_code",
|
||||||
|
"cloud_auth::cloud_get_user",
|
||||||
|
"cloud_auth::cloud_refresh_profile",
|
||||||
|
"cloud_auth::cloud_logout",
|
||||||
|
"cloud_auth::cloud_get_proxy_usage",
|
||||||
|
"cloud_auth::cloud_get_countries",
|
||||||
|
"cloud_auth::cloud_get_regions",
|
||||||
|
"cloud_auth::cloud_get_cities",
|
||||||
|
"cloud_auth::cloud_get_isps",
|
||||||
|
"cloud_auth::create_cloud_location_proxy",
|
||||||
|
"cloud_auth::cloud_get_wayfern_token",
|
||||||
|
"cloud_auth::cloud_refresh_wayfern_token",
|
||||||
|
"team_lock::get_team_locks",
|
||||||
|
"team_lock::get_team_lock_status",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
updateContracts: {
|
||||||
|
suite: "integrations",
|
||||||
|
level: "contract",
|
||||||
|
commands: [
|
||||||
|
"clear_all_version_cache_and_refetch",
|
||||||
|
"is_default_browser",
|
||||||
|
"trigger_manual_version_update",
|
||||||
|
"get_version_update_status",
|
||||||
|
"check_for_browser_updates",
|
||||||
|
"dismiss_update_notification",
|
||||||
|
"complete_browser_update_with_auto_update",
|
||||||
|
"check_for_app_updates",
|
||||||
|
"check_for_app_updates_manual",
|
||||||
|
"download_and_prepare_app_update",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
hostMutating: {
|
||||||
|
suite: "full",
|
||||||
|
level: "host-mutating",
|
||||||
|
reason:
|
||||||
|
"These commands intentionally change OS registration, launch external file managers, restart the test process, install an external MCP agent, or create a kernel VPN interface. Their surrounding UI and validation paths are automated, but success-path mutation is forbidden on developer and CI hosts.",
|
||||||
|
commands: [
|
||||||
|
"open_log_directory",
|
||||||
|
"set_as_default_browser",
|
||||||
|
"restart_application",
|
||||||
|
"connect_vpn",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function allCoveredCommands() {
|
||||||
|
return Object.values(commandCoverage).flatMap((entry) => entry.commands);
|
||||||
|
}
|
||||||
+490
@@ -0,0 +1,490 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import { WebDriverClient } from "./webdriver.mjs";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
function isolatedEnvironment(root, extra = {}) {
|
||||||
|
const home = path.join(root, "home");
|
||||||
|
const temp = path.join(root, "tmp");
|
||||||
|
return {
|
||||||
|
DONUTBROWSER_DATA_ROOT: path.join(root, "donut"),
|
||||||
|
HOME: home,
|
||||||
|
USERPROFILE: home,
|
||||||
|
...(process.platform === "darwin" ? { CFFIXED_USER_HOME: home } : {}),
|
||||||
|
TMPDIR: temp,
|
||||||
|
TMP: temp,
|
||||||
|
TEMP: temp,
|
||||||
|
XDG_CONFIG_HOME: path.join(root, "xdg", "config"),
|
||||||
|
XDG_CACHE_HOME: path.join(root, "xdg", "cache"),
|
||||||
|
XDG_DATA_HOME: path.join(root, "xdg", "data"),
|
||||||
|
APPDATA: path.join(root, "windows", "roaming"),
|
||||||
|
LOCALAPPDATA: path.join(root, "windows", "local"),
|
||||||
|
LANG: "en_US.UTF-8",
|
||||||
|
LC_ALL: "en_US.UTF-8",
|
||||||
|
NO_PROXY: "127.0.0.1,localhost",
|
||||||
|
no_proxy: "127.0.0.1,localhost",
|
||||||
|
HTTP_PROXY: "",
|
||||||
|
HTTPS_PROXY: "",
|
||||||
|
ALL_PROXY: "",
|
||||||
|
http_proxy: "",
|
||||||
|
https_proxy: "",
|
||||||
|
all_proxy: "",
|
||||||
|
RUST_BACKTRACE: "1",
|
||||||
|
...extra,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AppSession {
|
||||||
|
constructor({
|
||||||
|
name,
|
||||||
|
root,
|
||||||
|
application,
|
||||||
|
driverUrl,
|
||||||
|
cwd,
|
||||||
|
token,
|
||||||
|
extraEnv = {},
|
||||||
|
args = [],
|
||||||
|
seedVersionCache = true,
|
||||||
|
onboardingCompleted = true,
|
||||||
|
wayfernTermsAccepted = true,
|
||||||
|
}) {
|
||||||
|
this.name = name;
|
||||||
|
this.root = root;
|
||||||
|
this.application = application;
|
||||||
|
this.driver = new WebDriverClient(driverUrl);
|
||||||
|
this.cwd = cwd;
|
||||||
|
this.token = token;
|
||||||
|
this.extraEnv = extraEnv;
|
||||||
|
this.args = args;
|
||||||
|
this.seedVersionCache = seedVersionCache;
|
||||||
|
this.onboardingCompleted = onboardingCompleted;
|
||||||
|
this.wayfernTermsAccepted = wayfernTermsAccepted;
|
||||||
|
this.session = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
get dataRoot() {
|
||||||
|
return path.join(this.root, "donut");
|
||||||
|
}
|
||||||
|
|
||||||
|
async start() {
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(path.join(this.root, "home"), { recursive: true }),
|
||||||
|
mkdir(path.join(this.root, "tmp"), { recursive: true }),
|
||||||
|
mkdir(path.join(this.root, "artifacts"), { recursive: true }),
|
||||||
|
]);
|
||||||
|
if (this.onboardingCompleted) {
|
||||||
|
const settingsFile = path.join(
|
||||||
|
this.dataRoot,
|
||||||
|
"data",
|
||||||
|
"settings",
|
||||||
|
"app_settings.json",
|
||||||
|
);
|
||||||
|
await mkdir(path.dirname(settingsFile), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
settingsFile,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
language: "en",
|
||||||
|
onboarding_completed: true,
|
||||||
|
commercial_trial_acknowledged: true,
|
||||||
|
window_resize_warning_dismissed: true,
|
||||||
|
disable_auto_updates: true,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
{ flag: "wx" },
|
||||||
|
).catch((error) => {
|
||||||
|
if (error.code !== "EEXIST") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.wayfernTermsAccepted) {
|
||||||
|
const termsFile =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? path.join(
|
||||||
|
this.root,
|
||||||
|
"home",
|
||||||
|
"Library",
|
||||||
|
"Application Support",
|
||||||
|
"Wayfern",
|
||||||
|
"license-accepted",
|
||||||
|
)
|
||||||
|
: process.platform === "win32"
|
||||||
|
? path.join(
|
||||||
|
this.root,
|
||||||
|
"windows",
|
||||||
|
"roaming",
|
||||||
|
"Wayfern",
|
||||||
|
"license-accepted",
|
||||||
|
)
|
||||||
|
: path.join(
|
||||||
|
this.root,
|
||||||
|
"xdg",
|
||||||
|
"config",
|
||||||
|
"Wayfern",
|
||||||
|
"license-accepted",
|
||||||
|
);
|
||||||
|
await mkdir(path.dirname(termsFile), { recursive: true });
|
||||||
|
await writeFile(termsFile, `${Math.floor(Date.now() / 1000)}\n`, {
|
||||||
|
flag: "wx",
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error.code !== "EEXIST") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (this.seedVersionCache) {
|
||||||
|
const versionCache = path.join(
|
||||||
|
this.root,
|
||||||
|
"donut",
|
||||||
|
"cache",
|
||||||
|
"version_cache",
|
||||||
|
"wayfern_versions.json",
|
||||||
|
);
|
||||||
|
await mkdir(path.dirname(versionCache), { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
versionCache,
|
||||||
|
`${JSON.stringify({
|
||||||
|
releases: [{ version: "150.0.7871.100", date: "2026-07-01" }],
|
||||||
|
timestamp: Math.floor(Date.now() / 1000),
|
||||||
|
})}\n`,
|
||||||
|
{ flag: "wx" },
|
||||||
|
).catch((error) => {
|
||||||
|
if (error.code !== "EEXIST") {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const env = isolatedEnvironment(this.root, {
|
||||||
|
DONUT_E2E_DISABLE_STARTUP_NETWORK: "1",
|
||||||
|
...(process.env.DONUT_E2E_FIXTURE_URL
|
||||||
|
? {
|
||||||
|
DONUT_E2E_DNS_BLOCKLIST_BASE_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/dns`,
|
||||||
|
...(process.env.DONUT_E2E_GEOIP_FIXTURE_READY === "1"
|
||||||
|
? {
|
||||||
|
DONUT_E2E_GEOIP_DOWNLOAD_URL: `${process.env.DONUT_E2E_FIXTURE_URL}/geoip.mmdb`,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(this.token ? { WAYFERN_TEST_TOKEN: this.token } : {}),
|
||||||
|
...this.extraEnv,
|
||||||
|
});
|
||||||
|
this.session = await this.driver.createSession({
|
||||||
|
application: this.application,
|
||||||
|
args: this.args,
|
||||||
|
env,
|
||||||
|
cwd: this.cwd,
|
||||||
|
startupTimeout: 120_000,
|
||||||
|
});
|
||||||
|
await this.session.setTimeouts();
|
||||||
|
await this.waitFor(
|
||||||
|
async () => {
|
||||||
|
const ready = await this.execute(
|
||||||
|
"return document.readyState === 'complete' && Boolean(window.__TAURI_INTERNALS__);",
|
||||||
|
);
|
||||||
|
return ready === true;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: `${this.name} frontend and Tauri bridge`,
|
||||||
|
timeoutMs: 60_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
async restart() {
|
||||||
|
await this.close();
|
||||||
|
return this.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(script, args = []) {
|
||||||
|
assert.ok(this.session, `${this.name} is not started`);
|
||||||
|
return this.session.execute(script, args);
|
||||||
|
}
|
||||||
|
|
||||||
|
async invoke(command, args = {}) {
|
||||||
|
assert.ok(this.session, `${this.name} is not started`);
|
||||||
|
const result = await this.session.executeAsync(
|
||||||
|
`
|
||||||
|
const done = arguments[arguments.length - 1];
|
||||||
|
const command = arguments[0];
|
||||||
|
const args = arguments[1];
|
||||||
|
window.__TAURI_INTERNALS__.invoke(command, args)
|
||||||
|
.then((value) => done({ ok: true, value }))
|
||||||
|
.catch((error) => done({
|
||||||
|
ok: false,
|
||||||
|
error: typeof error === "string" ? error : (error?.message ?? JSON.stringify(error))
|
||||||
|
}));
|
||||||
|
`,
|
||||||
|
[command, args],
|
||||||
|
);
|
||||||
|
if (!result?.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Tauri command ${command} failed: ${result?.error ?? "unknown error"}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async invokeError(command, args = {}) {
|
||||||
|
try {
|
||||||
|
await this.invoke(command, args);
|
||||||
|
} catch (error) {
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
throw new Error(`Expected Tauri command ${command} to fail`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async bodyText() {
|
||||||
|
return this.execute("return document.body?.innerText ?? '';");
|
||||||
|
}
|
||||||
|
|
||||||
|
async html() {
|
||||||
|
return this.execute("return document.documentElement?.outerHTML ?? '';");
|
||||||
|
}
|
||||||
|
|
||||||
|
async visibleTextIncludes(text) {
|
||||||
|
return this.execute(
|
||||||
|
`
|
||||||
|
const wanted = arguments[0];
|
||||||
|
return [...document.querySelectorAll("body *")].some((node) => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||||
|
rect.width > 0 && rect.height > 0 &&
|
||||||
|
(node.innerText ?? "").trim().includes(wanted);
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
[text],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitFor(
|
||||||
|
check,
|
||||||
|
{ timeoutMs = 20_000, intervalMs = 100, description = "condition" } = {},
|
||||||
|
) {
|
||||||
|
const started = Date.now();
|
||||||
|
let lastError;
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const value = await check();
|
||||||
|
if (value) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Timed out after ${timeoutMs}ms waiting for ${description}${lastError ? `: ${lastError}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitForText(text, timeoutMs = 20_000) {
|
||||||
|
return this.waitFor(() => this.visibleTextIncludes(text), {
|
||||||
|
timeoutMs,
|
||||||
|
description: `visible text ${JSON.stringify(text)}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickText(
|
||||||
|
text,
|
||||||
|
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
|
||||||
|
) {
|
||||||
|
const element = await this.execute(
|
||||||
|
`
|
||||||
|
const wanted = arguments[0];
|
||||||
|
const exact = arguments[1];
|
||||||
|
const roles = new Set(arguments[2]);
|
||||||
|
const candidates = [...document.querySelectorAll("button, a, [role], [data-slot='button']")];
|
||||||
|
const visible = (node) => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||||
|
rect.width > 0 && rect.height > 0;
|
||||||
|
};
|
||||||
|
return candidates.find((node) => {
|
||||||
|
const role = node.getAttribute("role") || (node.tagName === "A" ? "link" : "button");
|
||||||
|
const label = (node.getAttribute("aria-label") || node.innerText || node.textContent || "").trim();
|
||||||
|
return roles.has(role) && visible(node) && (exact ? label === wanted : label.includes(wanted));
|
||||||
|
}) ?? null;
|
||||||
|
`,
|
||||||
|
[text, exact, roles],
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
element,
|
||||||
|
`No visible interactive element matched ${JSON.stringify(text)}`,
|
||||||
|
);
|
||||||
|
await this.session.click(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickTextIn(
|
||||||
|
containerSelector,
|
||||||
|
text,
|
||||||
|
{ exact = true, roles = ["button", "tab", "menuitem", "link"] } = {},
|
||||||
|
) {
|
||||||
|
const element = await this.execute(
|
||||||
|
`
|
||||||
|
const containers = [...document.querySelectorAll(arguments[0])];
|
||||||
|
const wanted = arguments[1];
|
||||||
|
const exact = arguments[2];
|
||||||
|
const roles = new Set(arguments[3]);
|
||||||
|
const visible = (node) => {
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||||
|
rect.width > 0 && rect.height > 0;
|
||||||
|
};
|
||||||
|
for (const container of containers.reverse()) {
|
||||||
|
if (!visible(container)) continue;
|
||||||
|
const candidates = [...container.querySelectorAll("button, a, [role], [data-slot='button']")];
|
||||||
|
const match = candidates.find((node) => {
|
||||||
|
const role = node.getAttribute("role") || (node.tagName === "A" ? "link" : "button");
|
||||||
|
const label = (node.getAttribute("aria-label") || node.innerText || node.textContent || "").trim();
|
||||||
|
return roles.has(role) && visible(node) && (exact ? label === wanted : label.includes(wanted));
|
||||||
|
});
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
`,
|
||||||
|
[containerSelector, text, exact, roles],
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
element,
|
||||||
|
`No visible interactive element inside ${containerSelector} matched ${JSON.stringify(text)}`,
|
||||||
|
);
|
||||||
|
await this.session.click(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clickSelector(selector) {
|
||||||
|
const element = await this.waitFor(
|
||||||
|
() =>
|
||||||
|
this.execute(
|
||||||
|
`
|
||||||
|
const node = document.querySelector(arguments[0]);
|
||||||
|
if (!node) return null;
|
||||||
|
const style = getComputedStyle(node);
|
||||||
|
const rect = node.getBoundingClientRect();
|
||||||
|
return style.visibility !== "hidden" && style.display !== "none" &&
|
||||||
|
rect.width > 0 && rect.height > 0 ? node : null;
|
||||||
|
`,
|
||||||
|
[selector],
|
||||||
|
),
|
||||||
|
{ description: `visible selector ${selector}` },
|
||||||
|
);
|
||||||
|
await this.session.click(element);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fillSelector(selector, value) {
|
||||||
|
const element = await this.waitFor(
|
||||||
|
() =>
|
||||||
|
this.execute("return document.querySelector(arguments[0]);", [
|
||||||
|
selector,
|
||||||
|
]),
|
||||||
|
{ description: `selector ${selector}` },
|
||||||
|
);
|
||||||
|
await this.session.clear(element);
|
||||||
|
await this.session.sendKeys(element, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
async pressShortcut({
|
||||||
|
key,
|
||||||
|
meta = false,
|
||||||
|
ctrl = false,
|
||||||
|
alt = false,
|
||||||
|
shift = false,
|
||||||
|
}) {
|
||||||
|
await this.execute(
|
||||||
|
`
|
||||||
|
window.dispatchEvent(new KeyboardEvent("keydown", {
|
||||||
|
key: arguments[0],
|
||||||
|
code: arguments[1],
|
||||||
|
metaKey: arguments[2],
|
||||||
|
ctrlKey: arguments[3],
|
||||||
|
altKey: arguments[4],
|
||||||
|
shiftKey: arguments[5],
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true
|
||||||
|
}));
|
||||||
|
`,
|
||||||
|
[
|
||||||
|
key,
|
||||||
|
key.length === 1 ? `Key${key.toUpperCase()}` : key,
|
||||||
|
meta,
|
||||||
|
ctrl,
|
||||||
|
alt,
|
||||||
|
shift,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async capture(label) {
|
||||||
|
if (!this.session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const safe = label.replace(/[^a-z0-9_.-]+/gi, "-");
|
||||||
|
try {
|
||||||
|
const png = await this.session.screenshot();
|
||||||
|
await writeFile(
|
||||||
|
path.join(this.root, "artifacts", `${safe}.png`),
|
||||||
|
Buffer.from(png, "base64"),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best-effort diagnostics must never hide the original test failure.
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await writeFile(
|
||||||
|
path.join(this.root, "artifacts", `${safe}.html`),
|
||||||
|
await this.html(),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
// Best-effort diagnostics must never hide the original test failure.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
if (!this.session) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const session = this.session;
|
||||||
|
this.session = null;
|
||||||
|
await session.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appFromEnvironment(name, options = {}) {
|
||||||
|
const runRoot = process.env.DONUT_E2E_RUN_ROOT;
|
||||||
|
assert.ok(runRoot, "DONUT_E2E_RUN_ROOT is required");
|
||||||
|
return new AppSession({
|
||||||
|
name,
|
||||||
|
root: options.root ?? path.join(runRoot, "sessions", name),
|
||||||
|
application: process.env.DONUT_E2E_APP,
|
||||||
|
driverUrl: process.env.DONUT_E2E_DRIVER_URL,
|
||||||
|
cwd: process.env.DONUT_E2E_PROJECT_ROOT,
|
||||||
|
token: process.env.WAYFERN_TEST_TOKEN,
|
||||||
|
extraEnv: options.extraEnv,
|
||||||
|
args: options.args,
|
||||||
|
seedVersionCache: options.seedVersionCache,
|
||||||
|
onboardingCompleted: options.onboardingCompleted,
|
||||||
|
wayfernTermsAccepted: options.wayfernTermsAccepted,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function withApp(name, callback, options = {}) {
|
||||||
|
const app = appFromEnvironment(name, options);
|
||||||
|
try {
|
||||||
|
await app.start();
|
||||||
|
return await callback(app);
|
||||||
|
} catch (error) {
|
||||||
|
await app.capture("failure");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
+135
@@ -0,0 +1,135 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
|
||||||
|
export class CdpClient {
|
||||||
|
constructor(socket) {
|
||||||
|
this.socket = socket;
|
||||||
|
this.nextId = 1;
|
||||||
|
this.pending = new Map();
|
||||||
|
socket.addEventListener("message", (event) => {
|
||||||
|
const message = JSON.parse(String(event.data));
|
||||||
|
if (message.id === undefined) return;
|
||||||
|
const pending = this.pending.get(message.id);
|
||||||
|
if (!pending) return;
|
||||||
|
this.pending.delete(message.id);
|
||||||
|
if (message.error) {
|
||||||
|
pending.reject(
|
||||||
|
new Error(
|
||||||
|
`CDP ${pending.method} failed: ${JSON.stringify(message.error)}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
pending.resolve(message.result ?? {});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
socket.addEventListener("close", () => {
|
||||||
|
for (const pending of this.pending.values()) {
|
||||||
|
pending.reject(
|
||||||
|
new Error(`CDP socket closed while waiting for ${pending.method}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.pending.clear();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static async connect(port, { timeoutMs = 30_000 } = {}) {
|
||||||
|
assert.equal(
|
||||||
|
typeof WebSocket,
|
||||||
|
"function",
|
||||||
|
"This E2E suite requires Node.js 22+ WebSocket",
|
||||||
|
);
|
||||||
|
const started = Date.now();
|
||||||
|
let lastError;
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/json`, {
|
||||||
|
signal: AbortSignal.timeout(1_000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||||
|
const targets = await response.json();
|
||||||
|
const target = targets.find(
|
||||||
|
(item) => item.type === "page" && item.webSocketDebuggerUrl,
|
||||||
|
);
|
||||||
|
if (!target) throw new Error("no debuggable page target");
|
||||||
|
const socket = new WebSocket(target.webSocketDebuggerUrl);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const timeout = setTimeout(
|
||||||
|
() => reject(new Error("CDP WebSocket open timed out")),
|
||||||
|
5_000,
|
||||||
|
);
|
||||||
|
socket.addEventListener(
|
||||||
|
"open",
|
||||||
|
() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
resolve();
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
socket.addEventListener(
|
||||||
|
"error",
|
||||||
|
() => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
reject(new Error("CDP WebSocket failed to open"));
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return new CdpClient(socket);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Timed out connecting to Wayfern CDP on ${port}: ${lastError}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
command(method, params = {}) {
|
||||||
|
const id = this.nextId++;
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject, method });
|
||||||
|
this.socket.send(JSON.stringify({ id, method, params }));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate(expression) {
|
||||||
|
const result = await this.command("Runtime.evaluate", {
|
||||||
|
expression,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true,
|
||||||
|
userGesture: true,
|
||||||
|
});
|
||||||
|
if (result.exceptionDetails) {
|
||||||
|
throw new Error(
|
||||||
|
`CDP evaluation failed: ${JSON.stringify(result.exceptionDetails)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result.result?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async waitFor(
|
||||||
|
expression,
|
||||||
|
{ timeoutMs = 20_000, description = expression } = {},
|
||||||
|
) {
|
||||||
|
const started = Date.now();
|
||||||
|
let lastError;
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const value = await this.evaluate(expression);
|
||||||
|
if (value) return value;
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Timed out waiting for ${description}${lastError ? `: ${lastError}` : ""}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.socket.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
import { chmod, mkdir, readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import {
|
||||||
|
redactSensitiveText,
|
||||||
|
sensitiveVariants,
|
||||||
|
} from "../../scripts/redact-sensitive-text.mjs";
|
||||||
|
|
||||||
|
const MAX_LOG_BYTES = 512 * 1024;
|
||||||
|
|
||||||
|
async function logFiles(directory, fileNamePattern = /\.(?:log|txt)$/iu) {
|
||||||
|
const entries = await readdir(directory, { withFileTypes: true }).catch(
|
||||||
|
() => [],
|
||||||
|
);
|
||||||
|
return entries
|
||||||
|
.filter((entry) => entry.isFile() && fileNamePattern.test(entry.name))
|
||||||
|
.map((entry) => path.join(directory, entry.name))
|
||||||
|
.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function diagnosticSources(runRoot) {
|
||||||
|
const sources = await logFiles(path.join(runRoot, "logs"));
|
||||||
|
const sessions = await readdir(path.join(runRoot, "sessions"), {
|
||||||
|
withFileTypes: true,
|
||||||
|
}).catch(() => []);
|
||||||
|
for (const session of sessions.filter((entry) => entry.isDirectory())) {
|
||||||
|
const root = path.join(runRoot, "sessions", session.name);
|
||||||
|
sources.push(...(await logFiles(path.join(root, "donut", "logs"))));
|
||||||
|
sources.push(
|
||||||
|
...(await logFiles(path.join(root, "tmp"), /^donut-proxy-.*\.log$/iu)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return sources;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function assertSafeDiagnostics(
|
||||||
|
diagnosticsRoot,
|
||||||
|
sensitiveValues = [],
|
||||||
|
) {
|
||||||
|
const entries = await readdir(diagnosticsRoot, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (!entry.isFile() || !/\.(?:json|log)$/iu.test(entry.name)) {
|
||||||
|
throw new Error(`Unsafe diagnostics entry: ${entry.name}`);
|
||||||
|
}
|
||||||
|
const content = await readFile(
|
||||||
|
path.join(diagnosticsRoot, entry.name),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
for (const value of sensitiveVariants(sensitiveValues)) {
|
||||||
|
if (content.includes(value)) {
|
||||||
|
throw new Error(
|
||||||
|
`Sensitive value survived diagnostics redaction in ${entry.name}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSafeDiagnostics(
|
||||||
|
runRoot,
|
||||||
|
{ suite, failed, sensitiveValues = [] },
|
||||||
|
) {
|
||||||
|
const diagnosticsRoot = path.join(runRoot, "diagnostics");
|
||||||
|
await mkdir(diagnosticsRoot, { recursive: true, mode: 0o700 });
|
||||||
|
await chmod(diagnosticsRoot, 0o700);
|
||||||
|
|
||||||
|
const sources = await diagnosticSources(runRoot);
|
||||||
|
for (const [index, source] of sources.entries()) {
|
||||||
|
const content = await readFile(source, "utf8").catch(() => "");
|
||||||
|
const tail = content.slice(-MAX_LOG_BYTES);
|
||||||
|
const destination = path.join(
|
||||||
|
diagnosticsRoot,
|
||||||
|
`${String(index + 1).padStart(3, "0")}.log`,
|
||||||
|
);
|
||||||
|
await writeFile(
|
||||||
|
destination,
|
||||||
|
redactSensitiveText(tail, { sensitiveValues }),
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
await chmod(destination, 0o600);
|
||||||
|
}
|
||||||
|
|
||||||
|
const summaryPath = path.join(diagnosticsRoot, "summary.json");
|
||||||
|
await writeFile(
|
||||||
|
summaryPath,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
suite,
|
||||||
|
status: failed ? "failed" : "passed",
|
||||||
|
sanitized_log_files: sources.length,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
{ mode: 0o600 },
|
||||||
|
);
|
||||||
|
await chmod(summaryPath, 0o600);
|
||||||
|
await assertSafeDiagnostics(diagnosticsRoot, sensitiveValues);
|
||||||
|
return diagnosticsRoot;
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { chmod, copyFile, cp, mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
export const TEST_BROWSER_VERSION = "150.0.7871.100";
|
||||||
|
|
||||||
|
export function defaultWayfernPath(projectRoot) {
|
||||||
|
if (process.env.DONUT_E2E_WAYFERN_PATH) {
|
||||||
|
return path.resolve(process.env.DONUT_E2E_WAYFERN_PATH);
|
||||||
|
}
|
||||||
|
const fixtureRoot = path.join(projectRoot, ".cache", "e2e-wayfern-fixture");
|
||||||
|
return process.platform === "darwin"
|
||||||
|
? path.join(fixtureRoot, "Wayfern.app")
|
||||||
|
: path.join(
|
||||||
|
fixtureRoot,
|
||||||
|
process.platform === "win32" ? "Wayfern.exe" : "wayfern",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wayfernExecutable(bundlePath) {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
return path.join(bundlePath, "Contents", "MacOS", "Wayfern");
|
||||||
|
}
|
||||||
|
return bundlePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function inspectWayfern(bundlePath) {
|
||||||
|
const executable = wayfernExecutable(bundlePath);
|
||||||
|
assert.ok(
|
||||||
|
existsSync(executable),
|
||||||
|
`Wayfern executable is missing: ${executable}`,
|
||||||
|
);
|
||||||
|
const output =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? execFileSync(
|
||||||
|
"/usr/bin/plutil",
|
||||||
|
[
|
||||||
|
"-extract",
|
||||||
|
"CFBundleShortVersionString",
|
||||||
|
"raw",
|
||||||
|
"-o",
|
||||||
|
"-",
|
||||||
|
path.join(bundlePath, "Contents", "Info.plist"),
|
||||||
|
],
|
||||||
|
{ encoding: "utf8" },
|
||||||
|
).trim()
|
||||||
|
: execFileSync(executable, ["--version"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 15_000,
|
||||||
|
}).trim();
|
||||||
|
const match = output.match(/(\d+\.\d+\.\d+\.\d+)/);
|
||||||
|
assert.ok(match, `Could not parse Wayfern version from: ${output}`);
|
||||||
|
return { bundlePath, executable, version: match[1], output };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function cloneAppBundle(source, destination) {
|
||||||
|
await mkdir(path.dirname(destination), { recursive: true });
|
||||||
|
try {
|
||||||
|
execFileSync("/bin/cp", ["-cR", source, destination]);
|
||||||
|
} catch (_error) {
|
||||||
|
await cp(source, destination, {
|
||||||
|
recursive: true,
|
||||||
|
preserveTimestamps: true,
|
||||||
|
errorOnExist: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function seedWayfern(dataRoot, wayfern) {
|
||||||
|
const installDir = path.join(
|
||||||
|
dataRoot,
|
||||||
|
"data",
|
||||||
|
"binaries",
|
||||||
|
"wayfern",
|
||||||
|
wayfern.version,
|
||||||
|
);
|
||||||
|
await mkdir(installDir, { recursive: true });
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
await cloneAppBundle(
|
||||||
|
wayfern.bundlePath,
|
||||||
|
path.join(installDir, "Wayfern.app"),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const name = process.platform === "win32" ? "wayfern.exe" : "wayfern";
|
||||||
|
const destination = path.join(installDir, name);
|
||||||
|
await copyFile(wayfern.executable, destination);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
await chmod(destination, 0o755);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const registry = {
|
||||||
|
browsers: {
|
||||||
|
wayfern: {
|
||||||
|
[wayfern.version]: {
|
||||||
|
browser: "wayfern",
|
||||||
|
version: wayfern.version,
|
||||||
|
file_path: installDir,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const registryPath = path.join(
|
||||||
|
dataRoot,
|
||||||
|
"data",
|
||||||
|
"data",
|
||||||
|
"downloaded_browsers.json",
|
||||||
|
);
|
||||||
|
await mkdir(path.dirname(registryPath), { recursive: true });
|
||||||
|
await writeFile(registryPath, `${JSON.stringify(registry, null, 2)}\n`);
|
||||||
|
return installDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function prepareWayfern(app, projectRoot) {
|
||||||
|
const localBundle = defaultWayfernPath(projectRoot);
|
||||||
|
if (existsSync(localBundle)) {
|
||||||
|
const wayfern = inspectWayfern(localBundle);
|
||||||
|
await seedWayfern(app.dataRoot, wayfern);
|
||||||
|
return { version: wayfern.version, source: "local fixture" };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!app.session) await app.start();
|
||||||
|
const current = await app.invoke("fetch_browser_versions_with_count", {
|
||||||
|
browserStr: "wayfern",
|
||||||
|
});
|
||||||
|
assert.ok(
|
||||||
|
current.versions.length > 0,
|
||||||
|
"No Wayfern build is published for this platform",
|
||||||
|
);
|
||||||
|
const version = current.versions[0];
|
||||||
|
await app.invoke("download_browser", {
|
||||||
|
browserStr: "wayfern",
|
||||||
|
version,
|
||||||
|
});
|
||||||
|
return { version, source: "published download" };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wireGuardFixture() {
|
||||||
|
return [
|
||||||
|
"[Interface]",
|
||||||
|
"PrivateKey = AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
|
||||||
|
"Address = 10.88.0.2/32",
|
||||||
|
"DNS = 1.1.1.1",
|
||||||
|
"",
|
||||||
|
"[Peer]",
|
||||||
|
"PublicKey = AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=",
|
||||||
|
"Endpoint = 127.0.0.1:51820",
|
||||||
|
"AllowedIPs = 0.0.0.0/0",
|
||||||
|
"PersistentKeepalive = 25",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extensionZipBase64() {
|
||||||
|
// A deterministic Manifest V3 ZIP containing only manifest.json. Generated
|
||||||
|
// once and kept inline so the suite has no archiver dependency.
|
||||||
|
return "UEsDBBQAAAAAAE8K9Fxo1IfNawAAAGsAAAANAAAAbWFuaWZlc3QuanNvbnsibWFuaWZlc3RfdmVyc2lvbiI6MywibmFtZSI6IkRvbnV0IEUyRSBGaXh0dXJlIiwidmVyc2lvbiI6IjEuMC4wIiwiZGVzY3JpcHRpb24iOiJJc29sYXRlZCB0ZXN0IGV4dGVuc2lvbiJ9UEsBAhQDFAAAAAAATwr0XGjUh81rAAAAawAAAA0AAAAAAAAAAAAAAIABAAAAAG1hbmlmZXN0Lmpzb25QSwUGAAAAAAEAAQA7AAAAlgAAAAAA";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function currentHostOs() {
|
||||||
|
return os.platform() === "darwin"
|
||||||
|
? "macos"
|
||||||
|
: os.platform() === "win32"
|
||||||
|
? "windows"
|
||||||
|
: "linux";
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
|
||||||
|
export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
|
||||||
|
|
||||||
|
function abortAfter(timeoutMs) {
|
||||||
|
return AbortSignal.timeout(timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebDriverClient {
|
||||||
|
constructor(baseUrl) {
|
||||||
|
this.baseUrl = baseUrl.replace(/\/$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
async request(method, pathname, body, timeoutMs = 330_000) {
|
||||||
|
const response = await fetch(`${this.baseUrl}${pathname}`, {
|
||||||
|
method,
|
||||||
|
headers:
|
||||||
|
body === undefined ? undefined : { "content-type": "application/json" },
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
signal: abortAfter(timeoutMs),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let payload = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
throw new Error(
|
||||||
|
`WebDriver ${method} ${pathname} returned non-JSON HTTP ${response.status}: ${text.slice(0, 500)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const error = payload?.value?.error;
|
||||||
|
if (!response.ok) {
|
||||||
|
const message =
|
||||||
|
payload?.value?.message ?? text ?? `HTTP ${response.status}`;
|
||||||
|
throw new Error(
|
||||||
|
`WebDriver ${method} ${pathname} failed (${error ?? response.status}): ${message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return payload?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async status() {
|
||||||
|
return this.request("GET", "/status");
|
||||||
|
}
|
||||||
|
|
||||||
|
async createSession({
|
||||||
|
application,
|
||||||
|
args = [],
|
||||||
|
env = {},
|
||||||
|
cwd,
|
||||||
|
startupTimeout = 90_000,
|
||||||
|
}) {
|
||||||
|
const options = { application, args, env, startupTimeout };
|
||||||
|
if (cwd) {
|
||||||
|
options.cwd = cwd;
|
||||||
|
}
|
||||||
|
const value = await this.request(
|
||||||
|
"POST",
|
||||||
|
"/session",
|
||||||
|
{
|
||||||
|
capabilities: {
|
||||||
|
alwaysMatch: {
|
||||||
|
"tauri:options": options,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
startupTimeout + 10_000,
|
||||||
|
);
|
||||||
|
assert.ok(value?.sessionId, "WebDriver did not return a session id");
|
||||||
|
return new WebDriverSession(
|
||||||
|
this,
|
||||||
|
value.sessionId,
|
||||||
|
value.capabilities ?? {},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WebDriverSession {
|
||||||
|
constructor(client, id, capabilities) {
|
||||||
|
this.client = client;
|
||||||
|
this.id = id;
|
||||||
|
this.capabilities = capabilities;
|
||||||
|
this.closed = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
path(suffix = "") {
|
||||||
|
return `/session/${encodeURIComponent(this.id)}${suffix}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async command(method, suffix, body, timeoutMs) {
|
||||||
|
return this.client.request(method, this.path(suffix), body, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async execute(script, args = []) {
|
||||||
|
return this.command("POST", "/execute/sync", { script, args });
|
||||||
|
}
|
||||||
|
|
||||||
|
async executeAsync(script, args = [], timeoutMs = 330_000) {
|
||||||
|
return this.command("POST", "/execute/async", { script, args }, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async setTimeouts({
|
||||||
|
implicit = 0,
|
||||||
|
pageLoad = 300_000,
|
||||||
|
script = 300_000,
|
||||||
|
} = {}) {
|
||||||
|
await this.command("POST", "/timeouts", { implicit, pageLoad, script });
|
||||||
|
}
|
||||||
|
|
||||||
|
async find(using, value) {
|
||||||
|
const element = await this.command("POST", "/element", { using, value });
|
||||||
|
assert.ok(
|
||||||
|
element?.[ELEMENT_KEY],
|
||||||
|
`Element not found using ${using}: ${value}`,
|
||||||
|
);
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findCss(selector) {
|
||||||
|
return this.find("css selector", selector);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findXpath(xpath) {
|
||||||
|
return this.find("xpath", xpath);
|
||||||
|
}
|
||||||
|
|
||||||
|
async click(element) {
|
||||||
|
await this.command(
|
||||||
|
"POST",
|
||||||
|
`/element/${encodeURIComponent(element[ELEMENT_KEY])}/click`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendKeys(element, text) {
|
||||||
|
const chars = [...String(text)];
|
||||||
|
await this.command(
|
||||||
|
"POST",
|
||||||
|
`/element/${encodeURIComponent(element[ELEMENT_KEY])}/value`,
|
||||||
|
{
|
||||||
|
text: String(text),
|
||||||
|
value: chars,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async clear(element) {
|
||||||
|
await this.command(
|
||||||
|
"POST",
|
||||||
|
`/element/${encodeURIComponent(element[ELEMENT_KEY])}/clear`,
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async title() {
|
||||||
|
return this.command("GET", "/title");
|
||||||
|
}
|
||||||
|
|
||||||
|
async screenshot() {
|
||||||
|
return this.command("GET", "/screenshot");
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
if (this.closed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.closed = true;
|
||||||
|
try {
|
||||||
|
await this.command("DELETE", "");
|
||||||
|
} catch (error) {
|
||||||
|
if (!String(error).includes("invalid session id")) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+794
@@ -0,0 +1,794 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { spawn, spawnSync } from "node:child_process";
|
||||||
|
import {
|
||||||
|
createReadStream,
|
||||||
|
createWriteStream,
|
||||||
|
existsSync,
|
||||||
|
statSync,
|
||||||
|
} from "node:fs";
|
||||||
|
import {
|
||||||
|
chmod,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readdir,
|
||||||
|
readFile,
|
||||||
|
rename,
|
||||||
|
rm,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import http from "node:http";
|
||||||
|
import https from "node:https";
|
||||||
|
import net from "node:net";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { pipeline } from "node:stream/promises";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { createSafeDiagnostics } from "./lib/diagnostics.mjs";
|
||||||
|
|
||||||
|
const dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = path.resolve(dirname, "..");
|
||||||
|
const webdriverRoot = path.resolve(
|
||||||
|
projectRoot,
|
||||||
|
"../tauri-cross-platform-webdriver",
|
||||||
|
);
|
||||||
|
const isWindows = process.platform === "win32";
|
||||||
|
const executableSuffix = isWindows ? ".exe" : "";
|
||||||
|
const appBinary = path.join(
|
||||||
|
projectRoot,
|
||||||
|
"e2e",
|
||||||
|
"app",
|
||||||
|
"target",
|
||||||
|
"debug",
|
||||||
|
`donutbrowser-e2e${executableSuffix}`,
|
||||||
|
);
|
||||||
|
const driverBinary = path.join(
|
||||||
|
webdriverRoot,
|
||||||
|
"target",
|
||||||
|
"debug",
|
||||||
|
`tauri-wd${executableSuffix}`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const suiteFiles = {
|
||||||
|
smoke: ["diagnostics.test.mjs", "smoke.test.mjs", "coverage.test.mjs"],
|
||||||
|
ui: ["ui.test.mjs"],
|
||||||
|
entities: ["entities.test.mjs"],
|
||||||
|
network: ["network.test.mjs"],
|
||||||
|
integrations: ["integrations.test.mjs"],
|
||||||
|
sync: ["sync.test.mjs"],
|
||||||
|
browser: ["browser.test.mjs"],
|
||||||
|
full: [
|
||||||
|
"diagnostics.test.mjs",
|
||||||
|
"coverage.test.mjs",
|
||||||
|
"smoke.test.mjs",
|
||||||
|
"ui.test.mjs",
|
||||||
|
"entities.test.mjs",
|
||||||
|
"network.test.mjs",
|
||||||
|
"integrations.test.mjs",
|
||||||
|
"sync.test.mjs",
|
||||||
|
"browser.test.mjs",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const options = {
|
||||||
|
suite: "full",
|
||||||
|
build: true,
|
||||||
|
keep: process.env.DONUT_E2E_KEEP_ARTIFACTS === "1",
|
||||||
|
verbose: process.env.DONUT_E2E_VERBOSE === "1",
|
||||||
|
};
|
||||||
|
for (const arg of argv) {
|
||||||
|
if (arg.startsWith("--suite=")) {
|
||||||
|
options.suite = arg.slice("--suite=".length);
|
||||||
|
} else if (arg === "--no-build") {
|
||||||
|
options.build = false;
|
||||||
|
} else if (arg === "--keep") {
|
||||||
|
options.keep = true;
|
||||||
|
} else if (arg === "--verbose") {
|
||||||
|
options.verbose = true;
|
||||||
|
} else {
|
||||||
|
throw new Error(`Unknown E2E option: ${arg}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!suiteFiles[options.suite]) {
|
||||||
|
throw new Error(
|
||||||
|
`Unknown suite ${options.suite}; expected ${Object.keys(suiteFiles).join(", ")}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
function log(message) {
|
||||||
|
process.stdout.write(`[donut-e2e] ${message}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function run(command, args, cwd, env = process.env) {
|
||||||
|
log(`${command} ${args.join(" ")}`);
|
||||||
|
const result = spawnSync(command, args, { cwd, env, stdio: "inherit" });
|
||||||
|
if (result.error) {
|
||||||
|
throw result.error;
|
||||||
|
}
|
||||||
|
if (result.status !== 0) {
|
||||||
|
throw new Error(`${command} exited with status ${result.status}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function freePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.unref();
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
const address = server.address();
|
||||||
|
server.close(() => resolve(address.port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForUrl(url, timeoutMs, processRecord) {
|
||||||
|
const started = Date.now();
|
||||||
|
let lastError;
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
if (processRecord?.process.exitCode !== null) {
|
||||||
|
throw new Error(
|
||||||
|
`${processRecord.name} exited early with ${processRecord.process.exitCode}; see ${processRecord.logPath}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal: AbortSignal.timeout(1_000) });
|
||||||
|
if (response.ok) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastError = new Error(`HTTP ${response.status}`);
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${url}: ${lastError}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startProcess(name, command, args, { cwd, env, runRoot, verbose }) {
|
||||||
|
const logPath = path.join(runRoot, "logs", `${name}.log`);
|
||||||
|
const stream = createWriteStream(logPath, { flags: "a" });
|
||||||
|
const child = spawn(command, args, {
|
||||||
|
cwd,
|
||||||
|
env,
|
||||||
|
detached: !isWindows,
|
||||||
|
stdio: ["ignore", "pipe", "pipe"],
|
||||||
|
});
|
||||||
|
child.stdout.pipe(stream, { end: false });
|
||||||
|
child.stderr.pipe(stream, { end: false });
|
||||||
|
if (verbose) {
|
||||||
|
child.stdout.on("data", (chunk) =>
|
||||||
|
process.stdout.write(`[${name}] ${chunk}`),
|
||||||
|
);
|
||||||
|
child.stderr.on("data", (chunk) =>
|
||||||
|
process.stderr.write(`[${name}] ${chunk}`),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
child.on("error", (error) => {
|
||||||
|
process.stderr.write(`[donut-e2e] ${name} process error: ${error}\n`);
|
||||||
|
});
|
||||||
|
return { name, process: child, stream, logPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopProcess(record) {
|
||||||
|
if (!record || record.process.exitCode !== null) {
|
||||||
|
record?.stream.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (isWindows) {
|
||||||
|
spawnSync("taskkill", ["/PID", String(record.process.pid), "/T", "/F"], {
|
||||||
|
stdio: "ignore",
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
process.kill(-record.process.pid, "SIGTERM");
|
||||||
|
} catch {
|
||||||
|
// The process group may already be gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await Promise.race([
|
||||||
|
new Promise((resolve) => record.process.once("exit", resolve)),
|
||||||
|
new Promise((resolve) => setTimeout(resolve, 5_000)),
|
||||||
|
]);
|
||||||
|
if (record.process.exitCode === null && !isWindows) {
|
||||||
|
try {
|
||||||
|
process.kill(-record.process.pid, "SIGKILL");
|
||||||
|
} catch {
|
||||||
|
// The process group may already be gone.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
record.stream.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
function unquoteEnvValue(value) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
const quote = trimmed[0];
|
||||||
|
if ((quote === '"' || quote === "'") && trimmed.at(-1) === quote) {
|
||||||
|
return trimmed.slice(1, -1);
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLocalValues(names) {
|
||||||
|
const values = Object.fromEntries(
|
||||||
|
names
|
||||||
|
.filter((name) => process.env[name])
|
||||||
|
.map((name) => [name, process.env[name]]),
|
||||||
|
);
|
||||||
|
for (const file of [path.join(projectRoot, ".env")]) {
|
||||||
|
try {
|
||||||
|
const content = await readFile(file, "utf8");
|
||||||
|
for (const name of names) {
|
||||||
|
if (values[name]) continue;
|
||||||
|
const escapedName = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
const match = content.match(
|
||||||
|
new RegExp(
|
||||||
|
`^\\s*(?:export\\s+)?${escapedName}\\s*=\\s*(.+?)\\s*$`,
|
||||||
|
"m",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (match) {
|
||||||
|
values[name] = unquoteEnvValue(match[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Individual suites validate the secrets they require.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAll() {
|
||||||
|
if (!existsSync(webdriverRoot)) {
|
||||||
|
throw new Error(`Missing sibling webdriver repository: ${webdriverRoot}`);
|
||||||
|
}
|
||||||
|
run("pnpm", ["build"], projectRoot);
|
||||||
|
run("pnpm", ["copy-proxy-binary"], projectRoot);
|
||||||
|
run(
|
||||||
|
"cargo",
|
||||||
|
["build", "--locked", "--manifest-path", "e2e/app/Cargo.toml"],
|
||||||
|
projectRoot,
|
||||||
|
);
|
||||||
|
run(
|
||||||
|
"cargo",
|
||||||
|
["build", "--package", "tauri-cross-platform-webdriver"],
|
||||||
|
webdriverRoot,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startFixtureServer(geoIpFixture) {
|
||||||
|
const server = http.createServer((request, response) => {
|
||||||
|
const url = new URL(request.url, "http://127.0.0.1");
|
||||||
|
if (url.pathname === "/health") {
|
||||||
|
response.writeHead(200, { "content-type": "text/plain" });
|
||||||
|
response.end("ok");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/api/echo") {
|
||||||
|
const chunks = [];
|
||||||
|
request.on("data", (chunk) => chunks.push(chunk));
|
||||||
|
request.on("end", () => {
|
||||||
|
response.writeHead(200, {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"set-cookie": "donut_e2e=browser-ok; Path=/; SameSite=Lax",
|
||||||
|
});
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({
|
||||||
|
method: request.method,
|
||||||
|
body: Buffer.concat(chunks).toString("utf8"),
|
||||||
|
userAgent: request.headers["user-agent"],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname.startsWith("/dns/")) {
|
||||||
|
response.writeHead(200, {
|
||||||
|
"content-type": "text/plain; charset=utf-8",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
});
|
||||||
|
response.end("ads.e2e.invalid\ntracker.e2e.invalid\n");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (url.pathname === "/geoip.mmdb" && geoIpFixture) {
|
||||||
|
response.writeHead(200, {
|
||||||
|
"content-type": "application/octet-stream",
|
||||||
|
"content-length": String(statSync(geoIpFixture).size),
|
||||||
|
});
|
||||||
|
createReadStream(geoIpFixture).pipe(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.writeHead(200, {
|
||||||
|
"content-type": "text/html; charset=utf-8",
|
||||||
|
"cache-control": "no-store",
|
||||||
|
});
|
||||||
|
response.end(`<!doctype html>
|
||||||
|
<html>
|
||||||
|
<head><title>Donut E2E Browser Fixture</title></head>
|
||||||
|
<body>
|
||||||
|
<h1 id="fixture-title">Donut E2E Browser Fixture</h1>
|
||||||
|
<p id="path">${url.pathname}</p>
|
||||||
|
<button id="fixture-button" onclick="this.dataset.clicked='yes'; this.textContent='Clicked'">Click fixture</button>
|
||||||
|
<script>window.__fixtureReady = true;</script>
|
||||||
|
</body>
|
||||||
|
</html>`);
|
||||||
|
});
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", () => {
|
||||||
|
resolve({ server, port: server.address().port });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureGeoIpFixture() {
|
||||||
|
if (process.env.DONUT_E2E_GEOIP_FIXTURE) {
|
||||||
|
const fixture = path.resolve(process.env.DONUT_E2E_GEOIP_FIXTURE);
|
||||||
|
if (!existsSync(fixture)) {
|
||||||
|
throw new Error(`DONUT_E2E_GEOIP_FIXTURE does not exist: ${fixture}`);
|
||||||
|
}
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
const toolsDir = path.join(os.tmpdir(), "donut-e2e-tools");
|
||||||
|
const fixture = path.join(toolsDir, "GeoLite2-City.mmdb");
|
||||||
|
await mkdir(toolsDir, { recursive: true });
|
||||||
|
if (existsSync(fixture)) return fixture;
|
||||||
|
|
||||||
|
log("Downloading GeoLite City E2E dependency");
|
||||||
|
const releases = await fetch(
|
||||||
|
"https://api.github.com/repos/P3TERX/GeoLite.mmdb/releases",
|
||||||
|
{
|
||||||
|
headers: { "user-agent": "donut-browser-e2e" },
|
||||||
|
signal: AbortSignal.timeout(30_000),
|
||||||
|
},
|
||||||
|
).then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`GeoLite release lookup failed with HTTP ${response.status}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
});
|
||||||
|
const url = releases
|
||||||
|
.flatMap((release) => release.assets ?? [])
|
||||||
|
.find((asset) => asset.name.endsWith("-City.mmdb"))?.browser_download_url;
|
||||||
|
if (!url) throw new Error("No GeoLite City MMDB asset was found");
|
||||||
|
const temporary = `${fixture}.${process.pid}.tmp`;
|
||||||
|
await download(url, temporary);
|
||||||
|
await rename(temporary, fixture);
|
||||||
|
return fixture;
|
||||||
|
}
|
||||||
|
|
||||||
|
function minioUrl() {
|
||||||
|
const arch = os.arch() === "arm64" ? "arm64" : "amd64";
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
return `https://dl.min.io/server/minio/release/darwin-${arch}/minio`;
|
||||||
|
}
|
||||||
|
if (process.platform === "linux") {
|
||||||
|
return `https://dl.min.io/server/minio/release/linux-${arch}/minio`;
|
||||||
|
}
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return "https://dl.min.io/server/minio/release/windows-amd64/minio.exe";
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`Unsupported MinIO platform ${process.platform}-${os.arch()}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function download(url, destination) {
|
||||||
|
const transport = url.startsWith("https:") ? https : http;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
transport
|
||||||
|
.get(url, (response) => {
|
||||||
|
if ([301, 302, 307, 308].includes(response.statusCode)) {
|
||||||
|
response.resume();
|
||||||
|
download(
|
||||||
|
new URL(response.headers.location, url).href,
|
||||||
|
destination,
|
||||||
|
).then(resolve, reject);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.statusCode !== 200) {
|
||||||
|
response.resume();
|
||||||
|
reject(
|
||||||
|
new Error(`Failed to download ${url}: HTTP ${response.statusCode}`),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const output = createWriteStream(destination, { mode: 0o755 });
|
||||||
|
pipeline(response, output).then(resolve, reject);
|
||||||
|
})
|
||||||
|
.on("error", reject);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ensureMinio() {
|
||||||
|
if (process.env.DONUT_E2E_MINIO_BIN) {
|
||||||
|
return path.resolve(process.env.DONUT_E2E_MINIO_BIN);
|
||||||
|
}
|
||||||
|
const existingHarnessBinary = path.join(
|
||||||
|
projectRoot,
|
||||||
|
".cache",
|
||||||
|
"sync-test",
|
||||||
|
isWindows ? "minio.exe" : "minio",
|
||||||
|
);
|
||||||
|
if (existsSync(existingHarnessBinary)) {
|
||||||
|
return existingHarnessBinary;
|
||||||
|
}
|
||||||
|
const toolsDir = path.join(os.tmpdir(), "donut-e2e-tools");
|
||||||
|
const binary = path.join(
|
||||||
|
toolsDir,
|
||||||
|
`minio-${process.platform}-${os.arch()}${executableSuffix}`,
|
||||||
|
);
|
||||||
|
await mkdir(toolsDir, { recursive: true });
|
||||||
|
if (!existsSync(binary)) {
|
||||||
|
log("Downloading isolated MinIO test dependency");
|
||||||
|
const temporary = `${binary}.${process.pid}.tmp`;
|
||||||
|
await download(minioUrl(), temporary);
|
||||||
|
await chmod(temporary, 0o755);
|
||||||
|
await rm(binary, { force: true });
|
||||||
|
await import("node:fs/promises").then(({ rename }) =>
|
||||||
|
rename(temporary, binary),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return binary;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startSyncInfrastructure(runRoot, options, records) {
|
||||||
|
const minioBinary = await ensureMinio();
|
||||||
|
const minioPort = await freePort();
|
||||||
|
const minioConsolePort = await freePort();
|
||||||
|
const syncPort = await freePort();
|
||||||
|
const syncToken = "donut-e2e-sync-token-0123456789abcdef";
|
||||||
|
const minio = startProcess(
|
||||||
|
"minio",
|
||||||
|
minioBinary,
|
||||||
|
[
|
||||||
|
"server",
|
||||||
|
path.join(runRoot, "minio-data"),
|
||||||
|
"--address",
|
||||||
|
`127.0.0.1:${minioPort}`,
|
||||||
|
"--console-address",
|
||||||
|
`127.0.0.1:${minioConsolePort}`,
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: projectRoot,
|
||||||
|
runRoot,
|
||||||
|
verbose: options.verbose,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
MINIO_ROOT_USER: "minioadmin",
|
||||||
|
MINIO_ROOT_PASSWORD: "minioadmin",
|
||||||
|
MINIO_BROWSER: "off",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
records.push(minio);
|
||||||
|
await waitForUrl(
|
||||||
|
`http://127.0.0.1:${minioPort}/minio/health/live`,
|
||||||
|
30_000,
|
||||||
|
minio,
|
||||||
|
);
|
||||||
|
|
||||||
|
const syncRoot = path.join(projectRoot, "donut-sync");
|
||||||
|
await rm(path.join(syncRoot, "tsconfig.build.tsbuildinfo"), { force: true });
|
||||||
|
await rm(path.join(syncRoot, "dist"), { recursive: true, force: true });
|
||||||
|
run("pnpm", ["build"], syncRoot);
|
||||||
|
const sync = startProcess("donut-sync", "node", ["dist/main.js"], {
|
||||||
|
cwd: syncRoot,
|
||||||
|
runRoot,
|
||||||
|
verbose: options.verbose,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
PORT: String(syncPort),
|
||||||
|
SYNC_TOKEN: syncToken,
|
||||||
|
S3_ENDPOINT: `http://127.0.0.1:${minioPort}`,
|
||||||
|
S3_REGION: "us-east-1",
|
||||||
|
S3_ACCESS_KEY_ID: "minioadmin",
|
||||||
|
S3_SECRET_ACCESS_KEY: "minioadmin",
|
||||||
|
S3_BUCKET: `donut-e2e-${process.pid}`,
|
||||||
|
S3_FORCE_PATH_STYLE: "true",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
records.push(sync);
|
||||||
|
await waitForUrl(`http://127.0.0.1:${syncPort}/health`, 30_000, sync);
|
||||||
|
return {
|
||||||
|
minioUrl: `http://127.0.0.1:${minioPort}`,
|
||||||
|
syncUrl: `http://127.0.0.1:${syncPort}`,
|
||||||
|
syncToken,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDocker(args, { allowFailure = false } = {}) {
|
||||||
|
const result = spawnSync("docker", args, {
|
||||||
|
encoding: "utf8",
|
||||||
|
timeout: 120_000,
|
||||||
|
maxBuffer: 10 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
if (!allowFailure && (result.error || result.status !== 0)) {
|
||||||
|
throw new Error(
|
||||||
|
`docker ${args[0]} failed: ${result.error?.message ?? result.stderr?.trim() ?? `exit ${result.status}`}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
function dockerAvailable() {
|
||||||
|
const result = runDocker(["version"], { allowFailure: true });
|
||||||
|
return !result.error && result.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startWireGuardInfrastructure() {
|
||||||
|
if (!dockerAvailable()) {
|
||||||
|
throw new Error(
|
||||||
|
"The network E2E suite requires a running Docker daemon for its local WireGuard peer",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const port = await freePort();
|
||||||
|
const name = `donut-wg-e2e-${process.pid}-${Date.now()}`;
|
||||||
|
const image =
|
||||||
|
process.env.DONUT_E2E_WIREGUARD_IMAGE ??
|
||||||
|
"lscr.io/linuxserver/wireguard:latest";
|
||||||
|
log("Starting isolated local WireGuard peer");
|
||||||
|
runDocker([
|
||||||
|
"run",
|
||||||
|
"-d",
|
||||||
|
"--name",
|
||||||
|
name,
|
||||||
|
"--cap-add=NET_ADMIN",
|
||||||
|
"-p",
|
||||||
|
`${port}:51820/udp`,
|
||||||
|
"-e",
|
||||||
|
"PEERS=1",
|
||||||
|
"-e",
|
||||||
|
"SERVERURL=127.0.0.1",
|
||||||
|
"-e",
|
||||||
|
"SERVERPORT=51820",
|
||||||
|
"-e",
|
||||||
|
"PEERDNS=auto",
|
||||||
|
"-e",
|
||||||
|
"INTERNAL_SUBNET=10.64.0.0",
|
||||||
|
image,
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const deadline = Date.now() + 45_000;
|
||||||
|
let config = "";
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||||
|
const configResult = runDocker(
|
||||||
|
["exec", name, "cat", "/config/peer1/peer1.conf"],
|
||||||
|
{ allowFailure: true },
|
||||||
|
);
|
||||||
|
const statusResult = runDocker(["exec", name, "wg", "show"], {
|
||||||
|
allowFailure: true,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
configResult.status === 0 &&
|
||||||
|
statusResult.status === 0 &&
|
||||||
|
statusResult.stdout.includes("listening port")
|
||||||
|
) {
|
||||||
|
config = configResult.stdout;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!config) {
|
||||||
|
throw new Error("Local WireGuard peer did not become ready within 45s");
|
||||||
|
}
|
||||||
|
|
||||||
|
const server = runDocker([
|
||||||
|
"exec",
|
||||||
|
"-d",
|
||||||
|
name,
|
||||||
|
"sh",
|
||||||
|
"-c",
|
||||||
|
'while true; do printf "HTTP/1.1 200 OK\\r\\nContent-Length: 13\\r\\nConnection: close\\r\\n\\r\\nWG-TUNNEL-OK\\n" | nc -l -p 8080 >> /tmp/donut-e2e-target-requests 2>/dev/null; done',
|
||||||
|
]);
|
||||||
|
if (server.status !== 0) {
|
||||||
|
throw new Error("Failed to start the WireGuard tunnel target server");
|
||||||
|
}
|
||||||
|
config = config.replace(
|
||||||
|
/^Endpoint\s*=.*$/m,
|
||||||
|
`Endpoint = 127.0.0.1:${port}`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
config,
|
||||||
|
targetUrl: "http://10.64.0.1:8080/donut-e2e-wireguard",
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
runDocker(["rm", "-f", name], { allowFailure: true });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareRetainedArtifacts(
|
||||||
|
runRoot,
|
||||||
|
{ suite, failed, sensitiveValues },
|
||||||
|
) {
|
||||||
|
const sessionsRoot = path.join(runRoot, "sessions");
|
||||||
|
const sessions = await readdir(sessionsRoot, {
|
||||||
|
withFileTypes: true,
|
||||||
|
}).catch(() => []);
|
||||||
|
await Promise.all(
|
||||||
|
sessions
|
||||||
|
.filter((entry) => entry.isDirectory())
|
||||||
|
.map((entry) =>
|
||||||
|
rm(path.join(sessionsRoot, entry.name, "donut", "data", "binaries"), {
|
||||||
|
recursive: true,
|
||||||
|
force: true,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await createSafeDiagnostics(runRoot, { suite, failed, sensitiveValues });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const runRoot = await mkdtemp(path.join(os.tmpdir(), "donut-e2e-"));
|
||||||
|
await mkdir(path.join(runRoot, "logs"), { recursive: true });
|
||||||
|
const records = [];
|
||||||
|
let fixture;
|
||||||
|
let wireGuard;
|
||||||
|
let failed = false;
|
||||||
|
const sensitiveValues = [
|
||||||
|
"donut-e2e-sync-token-0123456789abcdef",
|
||||||
|
"minioadmin",
|
||||||
|
];
|
||||||
|
const cleanup = async () => {
|
||||||
|
await Promise.all(records.reverse().map(stopProcess));
|
||||||
|
if (fixture) {
|
||||||
|
await new Promise((resolve) => fixture.server.close(resolve));
|
||||||
|
}
|
||||||
|
if (wireGuard) {
|
||||||
|
runDocker(["rm", "-f", wireGuard.name], { allowFailure: true });
|
||||||
|
}
|
||||||
|
if (!options.keep && !failed) {
|
||||||
|
await rm(runRoot, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
await prepareRetainedArtifacts(runRoot, {
|
||||||
|
suite: options.suite,
|
||||||
|
failed,
|
||||||
|
sensitiveValues,
|
||||||
|
});
|
||||||
|
log(`Artifacts retained at ${runRoot}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const signal of ["SIGINT", "SIGTERM"]) {
|
||||||
|
process.once(signal, () => {
|
||||||
|
failed = true;
|
||||||
|
cleanup().finally(() => process.exit(signal === "SIGINT" ? 130 : 143));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
log(`Run root: ${runRoot}`);
|
||||||
|
if (options.build) {
|
||||||
|
buildAll();
|
||||||
|
} else if (!existsSync(appBinary) || !existsSync(driverBinary)) {
|
||||||
|
throw new Error(
|
||||||
|
"--no-build requested but the E2E app or driver binary is missing",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const driverPort = await freePort();
|
||||||
|
const driver = startProcess(
|
||||||
|
"tauri-wd",
|
||||||
|
driverBinary,
|
||||||
|
[
|
||||||
|
"--port",
|
||||||
|
String(driverPort),
|
||||||
|
"--max-sessions",
|
||||||
|
"4",
|
||||||
|
"--startup-timeout",
|
||||||
|
"120",
|
||||||
|
"--command-timeout",
|
||||||
|
"330",
|
||||||
|
"--log",
|
||||||
|
options.verbose ? "debug" : "info",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
cwd: webdriverRoot,
|
||||||
|
env: process.env,
|
||||||
|
runRoot,
|
||||||
|
verbose: options.verbose,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
records.push(driver);
|
||||||
|
await waitForUrl(`http://127.0.0.1:${driverPort}/status`, 15_000, driver);
|
||||||
|
|
||||||
|
const needsBrowser =
|
||||||
|
options.suite === "browser" ||
|
||||||
|
options.suite === "network" ||
|
||||||
|
options.suite === "full";
|
||||||
|
const networkEnabled =
|
||||||
|
(options.suite === "network" || options.suite === "full") &&
|
||||||
|
process.env.DONUT_E2E_SKIP_NETWORK_TEST !== "1";
|
||||||
|
const geoIpFixture = needsBrowser ? await ensureGeoIpFixture() : null;
|
||||||
|
fixture = await startFixtureServer(geoIpFixture);
|
||||||
|
let sync = {};
|
||||||
|
if (options.suite === "sync" || options.suite === "full") {
|
||||||
|
sync = await startSyncInfrastructure(runRoot, options, records);
|
||||||
|
}
|
||||||
|
if (networkEnabled && process.env.DONUT_E2E_SKIP_VPN_TUNNEL !== "1") {
|
||||||
|
wireGuard = await startWireGuardInfrastructure();
|
||||||
|
}
|
||||||
|
|
||||||
|
const localValues = await loadLocalValues([
|
||||||
|
"WAYFERN_TEST_TOKEN",
|
||||||
|
"RESIDENTIAL_PROXY_URL_ONE_SOCKS",
|
||||||
|
"RESIDENTIAL_PROXY_URL_ONE_HTTP",
|
||||||
|
]);
|
||||||
|
sensitiveValues.push(...Object.values(localValues));
|
||||||
|
const token = localValues.WAYFERN_TEST_TOKEN ?? "";
|
||||||
|
if (needsBrowser && !token) {
|
||||||
|
throw new Error("WAYFERN_TEST_TOKEN is required by the browser suite");
|
||||||
|
}
|
||||||
|
if (wireGuard) {
|
||||||
|
sensitiveValues.push(
|
||||||
|
wireGuard.config,
|
||||||
|
Buffer.from(wireGuard.config).toString("base64"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = suiteFiles[options.suite]
|
||||||
|
.filter((file) => networkEnabled || file !== "network.test.mjs")
|
||||||
|
.map((file) => path.join(dirname, "tests", file));
|
||||||
|
const testArgs = [
|
||||||
|
"--test",
|
||||||
|
"--test-concurrency=1",
|
||||||
|
"--test-reporter=spec",
|
||||||
|
...files,
|
||||||
|
];
|
||||||
|
const child = spawn(process.execPath, testArgs, {
|
||||||
|
cwd: projectRoot,
|
||||||
|
env: {
|
||||||
|
...process.env,
|
||||||
|
DONUT_E2E_RUN_ROOT: runRoot,
|
||||||
|
DONUT_E2E_PROJECT_ROOT: projectRoot,
|
||||||
|
DONUT_E2E_WEBDRIVER_ROOT: webdriverRoot,
|
||||||
|
DONUT_E2E_APP: appBinary,
|
||||||
|
DONUT_E2E_DRIVER_URL: `http://127.0.0.1:${driverPort}`,
|
||||||
|
DONUT_E2E_FIXTURE_URL: `http://127.0.0.1:${fixture.port}`,
|
||||||
|
DONUT_E2E_GEOIP_FIXTURE_READY: geoIpFixture ? "1" : "0",
|
||||||
|
WAYFERN_TEST_TOKEN: token,
|
||||||
|
RESIDENTIAL_PROXY_URL_ONE_SOCKS:
|
||||||
|
localValues.RESIDENTIAL_PROXY_URL_ONE_SOCKS ?? "",
|
||||||
|
RESIDENTIAL_PROXY_URL_ONE_HTTP:
|
||||||
|
localValues.RESIDENTIAL_PROXY_URL_ONE_HTTP ?? "",
|
||||||
|
DONUT_E2E_SYNC_URL: sync.syncUrl ?? "",
|
||||||
|
DONUT_E2E_SYNC_TOKEN: sync.syncToken ?? "",
|
||||||
|
DONUT_E2E_MINIO_URL: sync.minioUrl ?? "",
|
||||||
|
DONUT_E2E_WIREGUARD_CONFIG_BASE64: wireGuard
|
||||||
|
? Buffer.from(wireGuard.config).toString("base64")
|
||||||
|
: "",
|
||||||
|
DONUT_E2E_WIREGUARD_TARGET_URL: wireGuard?.targetUrl ?? "",
|
||||||
|
DONUT_E2E_WIREGUARD_CONTAINER: wireGuard?.name ?? "",
|
||||||
|
},
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
const exitCode = await new Promise((resolve, reject) => {
|
||||||
|
child.once("error", reject);
|
||||||
|
child.once("exit", (code, signal) => {
|
||||||
|
resolve(code ?? (signal ? 1 : 0));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
if (exitCode !== 0) {
|
||||||
|
throw new Error(
|
||||||
|
`E2E suite ${options.suite} failed with status ${exitCode}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
log(`Suite ${options.suite} passed`);
|
||||||
|
} catch (error) {
|
||||||
|
failed = true;
|
||||||
|
process.stderr.write(`[donut-e2e] ERROR: ${error.stack ?? error}\n`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await cleanup();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await main();
|
||||||
@@ -0,0 +1,426 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { execFileSync } from "node:child_process";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { readFile, stat } from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { appFromEnvironment } from "../lib/app.mjs";
|
||||||
|
import { CdpClient } from "../lib/cdp.mjs";
|
||||||
|
import { defaultWayfernPath, prepareWayfern } from "../lib/fixtures.mjs";
|
||||||
|
|
||||||
|
const fixtureUrl = process.env.DONUT_E2E_FIXTURE_URL;
|
||||||
|
|
||||||
|
async function request(url, { method = "GET", token, body } = {}) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let value = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
value = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
value = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { response, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
function processExists(pid) {
|
||||||
|
if (!pid) return false;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForProcessExit(app, pid) {
|
||||||
|
await app.waitFor(() => !processExists(pid), {
|
||||||
|
timeoutMs: 20_000,
|
||||||
|
description: `Wayfern process ${pid} to exit`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertIdleResourceBounds(pid) {
|
||||||
|
if (process.platform === "win32") return;
|
||||||
|
const output = execFileSync("ps", ["-o", "rss=,%cpu=", "-p", String(pid)], {
|
||||||
|
encoding: "utf8",
|
||||||
|
}).trim();
|
||||||
|
const [rssText, cpuText] = output.split(/\s+/);
|
||||||
|
const rssKiB = Number(rssText);
|
||||||
|
const cpuPercent = Number(cpuText);
|
||||||
|
assert.ok(
|
||||||
|
rssKiB > 0 && rssKiB < 2_000_000,
|
||||||
|
`Wayfern main process RSS is ${rssKiB} KiB`,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
cpuPercent >= 0 && cpuPercent < 200,
|
||||||
|
`Wayfern main process CPU is ${cpuPercent}%`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function realWayfernTermsPath() {
|
||||||
|
if (process.platform === "darwin") {
|
||||||
|
return path.join(
|
||||||
|
os.homedir(),
|
||||||
|
"Library",
|
||||||
|
"Application Support",
|
||||||
|
"Wayfern",
|
||||||
|
"license-accepted",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (process.platform === "win32") {
|
||||||
|
return path.join(
|
||||||
|
process.env.APPDATA ?? path.join(os.homedir(), "AppData", "Roaming"),
|
||||||
|
"Wayfern",
|
||||||
|
"license-accepted",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return path.join(
|
||||||
|
process.env.XDG_CONFIG_HOME ?? path.join(os.homedir(), ".config"),
|
||||||
|
"Wayfern",
|
||||||
|
"license-accepted",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function snapshotFile(file) {
|
||||||
|
try {
|
||||||
|
const [contents, metadata] = await Promise.all([
|
||||||
|
readFile(file),
|
||||||
|
stat(file, { bigint: true }),
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
contents: contents.toString("base64"),
|
||||||
|
size: metadata.size.toString(),
|
||||||
|
mtime: metadata.mtimeNs.toString(),
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === "ENOENT") return { exists: false };
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createRealProfile(app, version, name, fingerprint = null) {
|
||||||
|
return app.invoke("create_browser_profile_new", {
|
||||||
|
name,
|
||||||
|
browserStr: "wayfern",
|
||||||
|
version,
|
||||||
|
releaseType: "stable",
|
||||||
|
proxyId: null,
|
||||||
|
vpnId: null,
|
||||||
|
wayfernConfig: {
|
||||||
|
fingerprint,
|
||||||
|
randomize_fingerprint_on_launch: false,
|
||||||
|
geoip: false,
|
||||||
|
},
|
||||||
|
groupId: null,
|
||||||
|
ephemeral: false,
|
||||||
|
dnsBlocklist: null,
|
||||||
|
launchHook: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and process cleanup", async () => {
|
||||||
|
assert.ok(process.env.WAYFERN_TEST_TOKEN, "WAYFERN_TEST_TOKEN is required");
|
||||||
|
const realTermsFile = realWayfernTermsPath();
|
||||||
|
const realTermsBefore = await snapshotFile(realTermsFile);
|
||||||
|
const hasLocalWayfern = existsSync(
|
||||||
|
defaultWayfernPath(process.env.DONUT_E2E_PROJECT_ROOT),
|
||||||
|
);
|
||||||
|
const app = appFromEnvironment("browser-wayfern", {
|
||||||
|
seedVersionCache: hasLocalWayfern,
|
||||||
|
wayfernTermsAccepted: false,
|
||||||
|
});
|
||||||
|
let cdp;
|
||||||
|
let browserPid;
|
||||||
|
try {
|
||||||
|
const prepared = await prepareWayfern(
|
||||||
|
app,
|
||||||
|
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||||
|
);
|
||||||
|
if (!app.session) await app.start();
|
||||||
|
|
||||||
|
assert.equal(await app.invoke("check_wayfern_downloaded"), true);
|
||||||
|
assert.equal(await app.invoke("check_wayfern_terms_accepted"), false);
|
||||||
|
await app.invoke("accept_wayfern_terms");
|
||||||
|
assert.equal(await app.invoke("check_wayfern_terms_accepted"), true);
|
||||||
|
assert.ok(
|
||||||
|
(
|
||||||
|
await app.invoke("get_downloaded_browser_versions", {
|
||||||
|
browserStr: "wayfern",
|
||||||
|
})
|
||||||
|
).includes(prepared.version),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("check_browser_exists", {
|
||||||
|
browserStr: "wayfern",
|
||||||
|
version: prepared.version,
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.deepEqual(await app.invoke("check_missing_binaries"), []);
|
||||||
|
assert.deepEqual(await app.invoke("ensure_all_binaries_exist"), []);
|
||||||
|
assert.deepEqual(await app.invoke("ensure_active_browsers_downloaded"), []);
|
||||||
|
assert.deepEqual(await app.invoke("get_supported_browsers"), ["wayfern"]);
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("is_browser_supported_on_platform", {
|
||||||
|
browserStr: "wayfern",
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
(
|
||||||
|
await app.invoke("fetch_browser_versions_cached_first", {
|
||||||
|
browserStr: "wayfern",
|
||||||
|
})
|
||||||
|
).some((item) => item.version === prepared.version),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
(
|
||||||
|
await app.invoke("fetch_browser_versions_with_count_cached_first", {
|
||||||
|
browserStr: "wayfern",
|
||||||
|
})
|
||||||
|
).versions.includes(prepared.version),
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await app.invoke("get_browser_release_types", { browserStr: "wayfern" }))
|
||||||
|
.stable,
|
||||||
|
prepared.version,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await app.invokeError("cancel_download", {
|
||||||
|
browserStr: "wayfern",
|
||||||
|
version: prepared.version,
|
||||||
|
}),
|
||||||
|
/No active download/,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sample = await app.invoke("generate_sample_fingerprint", {
|
||||||
|
browser: "wayfern",
|
||||||
|
version: prepared.version,
|
||||||
|
configJson: JSON.stringify({ geoip: false }),
|
||||||
|
});
|
||||||
|
const fingerprint = JSON.parse(sample);
|
||||||
|
assert.ok(
|
||||||
|
Object.keys(fingerprint).length >= 10,
|
||||||
|
"Wayfern returned an incomplete fingerprint",
|
||||||
|
);
|
||||||
|
|
||||||
|
const profile = await createRealProfile(
|
||||||
|
app,
|
||||||
|
prepared.version,
|
||||||
|
`Real Wayfern (${prepared.source})`,
|
||||||
|
);
|
||||||
|
assert.ok(profile.wayfern_config.fingerprint);
|
||||||
|
assert.ok(
|
||||||
|
Object.keys(JSON.parse(profile.wayfern_config.fingerprint)).length >= 10,
|
||||||
|
);
|
||||||
|
assert.equal(await app.invoke("check_missing_geoip_database"), true);
|
||||||
|
assert.equal(await app.invoke("is_geoip_database_available"), false);
|
||||||
|
await app.invoke("download_geoip_database");
|
||||||
|
assert.equal(await app.invoke("is_geoip_database_available"), true);
|
||||||
|
assert.equal(await app.invoke("check_missing_geoip_database"), false);
|
||||||
|
await app.invoke("update_wayfern_config", {
|
||||||
|
profileId: profile.id,
|
||||||
|
config: profile.wayfern_config,
|
||||||
|
});
|
||||||
|
await app.invoke("match_profile_fingerprint_to_exit", {
|
||||||
|
profileId: profile.id,
|
||||||
|
exitIp: "8.8.8.8",
|
||||||
|
});
|
||||||
|
const consistency = await app.invoke(
|
||||||
|
"check_profile_fingerprint_consistency",
|
||||||
|
{
|
||||||
|
profileId: profile.id,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(typeof consistency, "object");
|
||||||
|
|
||||||
|
const directProfile = (await app.invoke("list_browser_profiles")).find(
|
||||||
|
(item) => item.id === profile.id,
|
||||||
|
);
|
||||||
|
const directLaunch = await app.invoke("launch_browser_profile", {
|
||||||
|
profile: directProfile,
|
||||||
|
url: `${fixtureUrl}/direct-command`,
|
||||||
|
});
|
||||||
|
assert.ok(directLaunch.process_id);
|
||||||
|
await app.invoke("open_url_with_profile", {
|
||||||
|
profileId: profile.id,
|
||||||
|
url: `${fixtureUrl}/direct-open`,
|
||||||
|
});
|
||||||
|
await app.invoke("kill_browser_profile", { profile: directLaunch });
|
||||||
|
await waitForProcessExit(app, directLaunch.process_id);
|
||||||
|
|
||||||
|
const settings = await app.invoke("get_app_settings");
|
||||||
|
const saved = await app.invoke("save_app_settings", {
|
||||||
|
settings: {
|
||||||
|
...settings,
|
||||||
|
api_enabled: true,
|
||||||
|
api_port: 0,
|
||||||
|
api_token: null,
|
||||||
|
onboarding_completed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const port = await app.invoke("start_api_server", { port: 0 });
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
const launched = await request(`${base}/v1/profiles/${profile.id}/run`, {
|
||||||
|
method: "POST",
|
||||||
|
token: saved.api_token,
|
||||||
|
body: { url: `${fixtureUrl}/wayfern`, headless: true },
|
||||||
|
});
|
||||||
|
assert.equal(launched.response.status, 200, JSON.stringify(launched.value));
|
||||||
|
assert.equal(launched.value.headless, true);
|
||||||
|
|
||||||
|
cdp = await CdpClient.connect(launched.value.remote_debugging_port);
|
||||||
|
await cdp.waitFor(`document.title === "Donut E2E Browser Fixture"`, {
|
||||||
|
description: "fixture page title",
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
await cdp.evaluate("document.querySelector('#path').textContent"),
|
||||||
|
"/wayfern",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await cdp.evaluate(
|
||||||
|
"document.querySelector('#fixture-button').click(); document.querySelector('#fixture-button').dataset.clicked",
|
||||||
|
),
|
||||||
|
"yes",
|
||||||
|
);
|
||||||
|
const echo = await cdp.evaluate(
|
||||||
|
`fetch(${JSON.stringify(`${fixtureUrl}/api/echo`)}, {
|
||||||
|
method: "POST",
|
||||||
|
body: "wayfern-cdp-body"
|
||||||
|
}).then((response) => response.json())`,
|
||||||
|
);
|
||||||
|
assert.equal(echo.method, "POST");
|
||||||
|
assert.equal(echo.body, "wayfern-cdp-body");
|
||||||
|
assert.ok(echo.userAgent.length > 20);
|
||||||
|
assert.match(await cdp.evaluate("document.cookie"), /donut_e2e=browser-ok/);
|
||||||
|
|
||||||
|
const runningProfile = (await app.invoke("list_browser_profiles")).find(
|
||||||
|
(item) => item.id === profile.id,
|
||||||
|
);
|
||||||
|
browserPid = runningProfile.process_id;
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("check_browser_status", { profile: runningProfile }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assertIdleResourceBounds(browserPid);
|
||||||
|
if (process.platform !== "win32") {
|
||||||
|
const command = execFileSync(
|
||||||
|
"ps",
|
||||||
|
["-ww", "-o", "command=", "-p", String(browserPid)],
|
||||||
|
{
|
||||||
|
encoding: "utf8",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
command,
|
||||||
|
new RegExp(app.dataRoot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const opened = await request(`${base}/v1/profiles/${profile.id}/open-url`, {
|
||||||
|
method: "POST",
|
||||||
|
token: saved.api_token,
|
||||||
|
body: { url: `${fixtureUrl}/opened-via-api` },
|
||||||
|
});
|
||||||
|
assert.equal(opened.response.status, 200);
|
||||||
|
await app.waitFor(
|
||||||
|
async () => {
|
||||||
|
const targets = await fetch(
|
||||||
|
`http://127.0.0.1:${launched.value.remote_debugging_port}/json`,
|
||||||
|
).then((response) => response.json());
|
||||||
|
return targets.some((target) => target.url.includes("/opened-via-api"));
|
||||||
|
},
|
||||||
|
{ timeoutMs: 20_000, description: "API-opened Wayfern target" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const killed = await request(`${base}/v1/profiles/${profile.id}/kill`, {
|
||||||
|
method: "POST",
|
||||||
|
token: saved.api_token,
|
||||||
|
});
|
||||||
|
assert.equal(killed.response.status, 204);
|
||||||
|
cdp.close();
|
||||||
|
cdp = null;
|
||||||
|
await waitForProcessExit(app, browserPid);
|
||||||
|
const stoppedProfile = (await app.invoke("list_browser_profiles")).find(
|
||||||
|
(item) => item.id === profile.id,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("check_browser_status", { profile: stoppedProfile }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const batchProfile = await createRealProfile(
|
||||||
|
app,
|
||||||
|
prepared.version,
|
||||||
|
"Wayfern Batch Automation",
|
||||||
|
sample,
|
||||||
|
);
|
||||||
|
const batchRun = await request(`${base}/v1/profiles/batch/run`, {
|
||||||
|
method: "POST",
|
||||||
|
token: saved.api_token,
|
||||||
|
body: {
|
||||||
|
profile_ids: [batchProfile.id],
|
||||||
|
url: `${fixtureUrl}/batch`,
|
||||||
|
headless: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(batchRun.response.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
batchRun.value.results[0].ok,
|
||||||
|
true,
|
||||||
|
batchRun.value.results[0].error,
|
||||||
|
);
|
||||||
|
const batchCdp = await CdpClient.connect(
|
||||||
|
batchRun.value.results[0].remote_debugging_port,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await batchCdp.waitFor("window.__fixtureReady === true"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
batchCdp.close();
|
||||||
|
const batchStop = await request(`${base}/v1/profiles/batch/stop`, {
|
||||||
|
method: "POST",
|
||||||
|
token: saved.api_token,
|
||||||
|
body: { profile_ids: [batchProfile.id] },
|
||||||
|
});
|
||||||
|
assert.equal(batchStop.response.status, 200);
|
||||||
|
assert.equal(
|
||||||
|
batchStop.value.results[0].ok,
|
||||||
|
true,
|
||||||
|
batchStop.value.results[0].error,
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.invoke("stop_api_server");
|
||||||
|
await app.invoke("delete_profile", { profileId: profile.id });
|
||||||
|
await app.invoke("delete_profile", { profileId: batchProfile.id });
|
||||||
|
} catch (error) {
|
||||||
|
await app.capture("failure");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
cdp?.close();
|
||||||
|
if (app.session && browserPid && processExists(browserPid)) {
|
||||||
|
const profile = (
|
||||||
|
await app.invoke("list_browser_profiles").catch(() => [])
|
||||||
|
).find((item) => item.process_id === browserPid);
|
||||||
|
if (profile)
|
||||||
|
await app.invoke("kill_browser_profile", { profile }).catch(() => {});
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
assert.deepEqual(
|
||||||
|
await snapshotFile(realTermsFile),
|
||||||
|
realTermsBefore,
|
||||||
|
"the browser suite modified the real Wayfern terms marker",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
lstat,
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import http from "node:http";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { allCoveredCommands, commandCoverage } from "../coverage-map.mjs";
|
||||||
|
import { seedWayfern } from "../lib/fixtures.mjs";
|
||||||
|
import { WebDriverClient } from "../lib/webdriver.mjs";
|
||||||
|
|
||||||
|
function registeredCommands(source) {
|
||||||
|
const match = source.match(
|
||||||
|
/invoke_handler\(tauri::generate_handler!\[(.*?)\]\)/s,
|
||||||
|
);
|
||||||
|
assert.ok(match, "Could not locate Tauri generate_handler! command registry");
|
||||||
|
const withoutComments = match[1].replace(/\/\/[^\n]*/g, "");
|
||||||
|
return [
|
||||||
|
...withoutComments.matchAll(/([A-Za-z_]\w*(?:::[A-Za-z_]\w*)*)\s*,/g),
|
||||||
|
].map((item) => item[1]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandHasExecutableEvidence(source, command) {
|
||||||
|
const name = command
|
||||||
|
.split("::")
|
||||||
|
.at(-1)
|
||||||
|
.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||||
|
return new RegExp(
|
||||||
|
`(?:invoke|invokeError)\\(\\s*["']${name}["']|invokeContract\\(\\s*\\w+\\s*,\\s*["']${name}["']`,
|
||||||
|
).test(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("every Tauri command has exactly one E2E owner and evidence level", async () => {
|
||||||
|
const root =
|
||||||
|
process.env.DONUT_E2E_PROJECT_ROOT ??
|
||||||
|
path.resolve(import.meta.dirname, "../..");
|
||||||
|
const source = await readFile(
|
||||||
|
path.join(root, "src-tauri", "src", "lib.rs"),
|
||||||
|
"utf8",
|
||||||
|
);
|
||||||
|
const registered = registeredCommands(source);
|
||||||
|
const covered = allCoveredCommands();
|
||||||
|
assert.deepEqual(
|
||||||
|
[...new Set(covered)].sort(),
|
||||||
|
covered.slice().sort(),
|
||||||
|
"The E2E coverage map contains duplicate command ownership",
|
||||||
|
);
|
||||||
|
assert.deepEqual(covered.slice().sort(), registered.slice().sort());
|
||||||
|
|
||||||
|
for (const [name, entry] of Object.entries(commandCoverage)) {
|
||||||
|
assert.ok(
|
||||||
|
["integration", "contract", "host-mutating"].includes(entry.level),
|
||||||
|
name,
|
||||||
|
);
|
||||||
|
assert.ok(entry.commands.length > 0, `${name} has no commands`);
|
||||||
|
if (entry.level === "host-mutating") {
|
||||||
|
assert.ok(
|
||||||
|
entry.reason?.length > 80,
|
||||||
|
`${name} needs an explicit safety reason`,
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const evidenceFiles = [
|
||||||
|
path.join(root, "e2e", "tests", `${entry.suite}.test.mjs`),
|
||||||
|
...(entry.suite === "browser"
|
||||||
|
? [path.join(root, "e2e", "lib", "fixtures.mjs")]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const suiteSource = (
|
||||||
|
await Promise.all(evidenceFiles.map((file) => readFile(file, "utf8")))
|
||||||
|
).join("\n");
|
||||||
|
for (const command of entry.commands) {
|
||||||
|
assert.equal(
|
||||||
|
commandHasExecutableEvidence(suiteSource, command),
|
||||||
|
true,
|
||||||
|
`${command} is assigned to ${entry.suite} but has no executable invoke evidence`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("WebDriver client preserves application values that contain an error field", async () => {
|
||||||
|
const server = http.createServer((_request, response) => {
|
||||||
|
response.writeHead(200, { "content-type": "application/json" });
|
||||||
|
response.end(
|
||||||
|
JSON.stringify({ value: { ok: false, error: "application error" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
server.once("error", reject);
|
||||||
|
server.listen(0, "127.0.0.1", resolve);
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
const address = server.address();
|
||||||
|
const client = new WebDriverClient(`http://127.0.0.1:${address.port}`);
|
||||||
|
assert.deepEqual(await client.request("GET", "/value"), {
|
||||||
|
ok: false,
|
||||||
|
error: "application error",
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
await new Promise((resolve) => server.close(resolve));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Wayfern fixtures are copied into the isolated data root, never linked", async (t) => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "donut-wayfern-copy-"));
|
||||||
|
t.after(() => rm(root, { recursive: true, force: true }));
|
||||||
|
const source =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? path.join(root, "source", "Wayfern.app", "Contents", "MacOS", "Wayfern")
|
||||||
|
: path.join(
|
||||||
|
root,
|
||||||
|
"source",
|
||||||
|
process.platform === "win32" ? "Wayfern.exe" : "wayfern",
|
||||||
|
);
|
||||||
|
await mkdir(path.dirname(source), { recursive: true });
|
||||||
|
await writeFile(source, "source-fixture");
|
||||||
|
const bundlePath =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? path.join(root, "source", "Wayfern.app")
|
||||||
|
: source;
|
||||||
|
const installDir = await seedWayfern(path.join(root, "isolated"), {
|
||||||
|
bundlePath,
|
||||||
|
executable: source,
|
||||||
|
version: "1.2.3.4",
|
||||||
|
});
|
||||||
|
const destination =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? path.join(installDir, "Wayfern.app", "Contents", "MacOS", "Wayfern")
|
||||||
|
: path.join(
|
||||||
|
installDir,
|
||||||
|
process.platform === "win32" ? "wayfern.exe" : "wayfern",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal((await lstat(destination)).isSymbolicLink(), false);
|
||||||
|
await writeFile(destination, "isolated-mutation");
|
||||||
|
assert.equal(await readFile(source, "utf8"), "source-fixture");
|
||||||
|
});
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import {
|
||||||
|
mkdir,
|
||||||
|
mkdtemp,
|
||||||
|
readdir,
|
||||||
|
readFile,
|
||||||
|
rm,
|
||||||
|
writeFile,
|
||||||
|
} from "node:fs/promises";
|
||||||
|
import os from "node:os";
|
||||||
|
import path from "node:path";
|
||||||
|
import { after, test } from "node:test";
|
||||||
|
import { redactIssueBody } from "../../scripts/redact-sensitive-text.mjs";
|
||||||
|
import { createSafeDiagnostics } from "../lib/diagnostics.mjs";
|
||||||
|
|
||||||
|
const roots = [];
|
||||||
|
after(async () => {
|
||||||
|
await Promise.all(
|
||||||
|
roots.map((root) => rm(root, { recursive: true, force: true })),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("shared E2E diagnostics contain only redacted text logs", async () => {
|
||||||
|
const root = await mkdtemp(path.join(os.tmpdir(), "donut-diagnostics-test-"));
|
||||||
|
roots.push(root);
|
||||||
|
const secretUrl = "http://real-user:real-password@proxy.example:8080";
|
||||||
|
const token = ["github", "pat", "example", "token", "0123456789"].join("_");
|
||||||
|
const logText = [
|
||||||
|
`proxy=${secretUrl}`,
|
||||||
|
`Authorization: Bearer ${token}`,
|
||||||
|
"visited https://example.com/callback?code=private-code",
|
||||||
|
"exit IP 203.0.113.42",
|
||||||
|
"home /Users/private-person/Library/Application Support",
|
||||||
|
"email private.person@example.com",
|
||||||
|
"PrivateKey = wireguard-private-key",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
mkdir(path.join(root, "logs"), { recursive: true }),
|
||||||
|
mkdir(path.join(root, "sessions", "network", "donut", "logs"), {
|
||||||
|
recursive: true,
|
||||||
|
}),
|
||||||
|
mkdir(path.join(root, "sessions", "network", "donut", "data", "proxies"), {
|
||||||
|
recursive: true,
|
||||||
|
}),
|
||||||
|
mkdir(path.join(root, "sessions", "network", "artifacts"), {
|
||||||
|
recursive: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
await Promise.all([
|
||||||
|
writeFile(path.join(root, "logs", "driver.log"), logText),
|
||||||
|
writeFile(
|
||||||
|
path.join(root, "sessions", "network", "donut", "logs", "app.log"),
|
||||||
|
logText,
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(
|
||||||
|
root,
|
||||||
|
"sessions",
|
||||||
|
"network",
|
||||||
|
"donut",
|
||||||
|
"data",
|
||||||
|
"proxies",
|
||||||
|
"real.json",
|
||||||
|
),
|
||||||
|
JSON.stringify({ upstream_url: secretUrl, token }),
|
||||||
|
),
|
||||||
|
writeFile(
|
||||||
|
path.join(root, "sessions", "network", "artifacts", "page.html"),
|
||||||
|
`<html>${secretUrl}</html>`,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const diagnostics = await createSafeDiagnostics(root, {
|
||||||
|
suite: "network",
|
||||||
|
failed: true,
|
||||||
|
sensitiveValues: [secretUrl, token],
|
||||||
|
});
|
||||||
|
const files = await readdir(diagnostics);
|
||||||
|
assert.deepEqual(files.sort(), ["001.log", "002.log", "summary.json"]);
|
||||||
|
const combined = (
|
||||||
|
await Promise.all(
|
||||||
|
files.map((file) => readFile(path.join(diagnostics, file), "utf8")),
|
||||||
|
)
|
||||||
|
).join("\n");
|
||||||
|
for (const value of [
|
||||||
|
secretUrl,
|
||||||
|
"real-user",
|
||||||
|
"real-password",
|
||||||
|
"proxy.example",
|
||||||
|
token,
|
||||||
|
"private-code",
|
||||||
|
"203.0.113.42",
|
||||||
|
"private-person",
|
||||||
|
"private.person@example.com",
|
||||||
|
"wireguard-private-key",
|
||||||
|
]) {
|
||||||
|
assert.ok(!combined.includes(value), `diagnostics leaked ${value}`);
|
||||||
|
}
|
||||||
|
assert.ok(
|
||||||
|
!files.some(
|
||||||
|
(file) => /\.(?:html|json)$/u.test(file) && file !== "summary.json",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("automated issue processing omits the complete log field", () => {
|
||||||
|
const safe = redactIssueBody(
|
||||||
|
`### What happened?\nA failure at user@example.com\n\n### Error logs or screenshots\nARBITRARY_PRIVATE_LOG_CONTENT\npassword=hunter2\n\n### Operating System\nLinux`,
|
||||||
|
);
|
||||||
|
assert.ok(!safe.includes("ARBITRARY_PRIVATE_LOG_CONTENT"));
|
||||||
|
assert.ok(!safe.includes("hunter2"));
|
||||||
|
assert.ok(!safe.includes("user@example.com"));
|
||||||
|
assert.match(safe, /omitted from automated processing/u);
|
||||||
|
assert.match(safe, /Operating System\nLinux/u);
|
||||||
|
});
|
||||||
@@ -0,0 +1,501 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { withApp } from "../lib/app.mjs";
|
||||||
|
import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
|
||||||
|
|
||||||
|
async function createProfile(app, name = "Entity Profile") {
|
||||||
|
return app.invoke("create_browser_profile_new", {
|
||||||
|
name,
|
||||||
|
browserStr: "wayfern",
|
||||||
|
version: "150.0.7871.100",
|
||||||
|
releaseType: "stable",
|
||||||
|
proxyId: null,
|
||||||
|
vpnId: null,
|
||||||
|
// CRUD-focused suites use a deterministic stored fingerprint. The browser
|
||||||
|
// suite separately exercises real Wayfern fingerprint generation.
|
||||||
|
wayfernConfig: { fingerprint: "{}" },
|
||||||
|
groupId: null,
|
||||||
|
ephemeral: false,
|
||||||
|
dnsBlocklist: null,
|
||||||
|
launchHook: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", async () => {
|
||||||
|
await withApp("entities-core", async (app) => {
|
||||||
|
const group = await app.invoke("create_profile_group", {
|
||||||
|
name: "Research",
|
||||||
|
});
|
||||||
|
assert.equal(group.name, "Research");
|
||||||
|
const renamedGroup = await app.invoke("update_profile_group", {
|
||||||
|
groupId: group.id,
|
||||||
|
name: "Research Team",
|
||||||
|
});
|
||||||
|
assert.equal(renamedGroup.name, "Research Team");
|
||||||
|
|
||||||
|
const duplicateError = await app.invokeError("create_profile_group", {
|
||||||
|
name: "Research Team",
|
||||||
|
});
|
||||||
|
assert.match(duplicateError, /GROUP_ALREADY_EXISTS|already exists/i);
|
||||||
|
|
||||||
|
const proxy = await app.invoke("create_stored_proxy", {
|
||||||
|
name: "Local Dead Proxy",
|
||||||
|
proxySettings: {
|
||||||
|
proxy_type: "http",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 9,
|
||||||
|
username: "e2e-user",
|
||||||
|
password: "e2e-pass",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(proxy.proxy_settings.password, "e2e-pass");
|
||||||
|
const updatedProxy = await app.invoke("update_stored_proxy", {
|
||||||
|
proxyId: proxy.id,
|
||||||
|
name: "Updated Proxy",
|
||||||
|
proxySettings: {
|
||||||
|
proxy_type: "socks5",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 9,
|
||||||
|
username: null,
|
||||||
|
password: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(updatedProxy.name, "Updated Proxy");
|
||||||
|
assert.equal(updatedProxy.updated_at >= proxy.updated_at, true);
|
||||||
|
|
||||||
|
const parsed = await app.invoke("parse_txt_proxies", {
|
||||||
|
content: [
|
||||||
|
"http://one.example:8080",
|
||||||
|
"two.example:1080:user:pass",
|
||||||
|
"not a proxy",
|
||||||
|
].join("\n"),
|
||||||
|
});
|
||||||
|
assert.equal(parsed.length, 3);
|
||||||
|
assert.ok(parsed.some((result) => result.status === "parsed"));
|
||||||
|
assert.ok(parsed.some((result) => result.status === "invalid"));
|
||||||
|
const parsedProxy = parsed.find((result) => result.status === "parsed");
|
||||||
|
const { status: _status, ...parsedProxyFields } = parsedProxy;
|
||||||
|
const parsedImport = await app.invoke("import_proxies_from_parsed", {
|
||||||
|
parsedProxies: [parsedProxyFields],
|
||||||
|
namePrefix: "Parsed",
|
||||||
|
});
|
||||||
|
assert.equal(parsedImport.imported_count, 1);
|
||||||
|
|
||||||
|
const validityError = await app.invokeError("check_proxy_validity", {
|
||||||
|
proxyId: proxy.id,
|
||||||
|
proxySettings: null,
|
||||||
|
});
|
||||||
|
assert.match(validityError, /Proxy check failed|Could not connect/i);
|
||||||
|
const cachedValidity = await app.invoke("get_cached_proxy_check", {
|
||||||
|
proxyId: proxy.id,
|
||||||
|
});
|
||||||
|
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
|
||||||
|
|
||||||
|
const exported = JSON.parse(
|
||||||
|
await app.invoke("export_proxies", { format: "json" }),
|
||||||
|
);
|
||||||
|
assert.equal(exported.proxies.length, 2);
|
||||||
|
assert.ok(exported.proxies.some((item) => item.name === "Updated Proxy"));
|
||||||
|
assert.ok(exported.proxies.some((item) => item.name === "Parsed Proxy 1"));
|
||||||
|
const importResult = await app.invoke("import_proxies_json", {
|
||||||
|
content: JSON.stringify({
|
||||||
|
version: "1",
|
||||||
|
source: "Donut Browser",
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
proxies: [
|
||||||
|
{
|
||||||
|
name: "Imported Proxy",
|
||||||
|
type: "http",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 8081,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
assert.equal(importResult.imported_count, 1);
|
||||||
|
|
||||||
|
const profile = await createProfile(app);
|
||||||
|
assert.equal(profile.name, "Entity Profile");
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await app.invoke("update_profile_proxy", {
|
||||||
|
profileId: profile.id,
|
||||||
|
proxyId: proxy.id,
|
||||||
|
})
|
||||||
|
).proxy_id,
|
||||||
|
proxy.id,
|
||||||
|
);
|
||||||
|
await app.invoke("assign_profiles_to_group", {
|
||||||
|
profileIds: [profile.id],
|
||||||
|
groupId: group.id,
|
||||||
|
});
|
||||||
|
await app.invoke("rename_profile", {
|
||||||
|
profileId: profile.id,
|
||||||
|
newName: "Renamed Profile",
|
||||||
|
});
|
||||||
|
await app.invoke("update_profile_tags", {
|
||||||
|
profileId: profile.id,
|
||||||
|
tags: ["alpha", "automation"],
|
||||||
|
});
|
||||||
|
await app.invoke("update_profile_note", {
|
||||||
|
profileId: profile.id,
|
||||||
|
note: "Extensive E2E metadata",
|
||||||
|
});
|
||||||
|
await app.invoke("update_profile_window_color", {
|
||||||
|
profileId: profile.id,
|
||||||
|
windowColor: "#123456",
|
||||||
|
});
|
||||||
|
await app.invoke("update_profile_launch_hook", {
|
||||||
|
profileId: profile.id,
|
||||||
|
launchHook: `${process.env.DONUT_E2E_FIXTURE_URL}/launch-hook`,
|
||||||
|
});
|
||||||
|
const invalidHook = await app.invokeError("update_profile_launch_hook", {
|
||||||
|
profileId: profile.id,
|
||||||
|
launchHook: "file:///etc/passwd",
|
||||||
|
});
|
||||||
|
assert.match(invalidHook, /INVALID_LAUNCH_HOOK_URL/);
|
||||||
|
await app.invoke("update_profile_proxy_bypass_rules", {
|
||||||
|
profileId: profile.id,
|
||||||
|
rules: ["localhost", "*.internal.example"],
|
||||||
|
});
|
||||||
|
await app.invoke("update_profile_dns_blocklist", {
|
||||||
|
profileId: profile.id,
|
||||||
|
dnsBlocklist: "light",
|
||||||
|
});
|
||||||
|
await app.invoke("update_profile_clear_on_close", {
|
||||||
|
profileId: profile.id,
|
||||||
|
clearOnClose: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profiles = await app.invoke("list_browser_profiles");
|
||||||
|
const changed = profiles.find((item) => item.id === profile.id);
|
||||||
|
assert.deepEqual(changed.tags, ["alpha", "automation"]);
|
||||||
|
assert.equal(changed.note, "Extensive E2E metadata");
|
||||||
|
assert.equal(changed.window_color, "#123456");
|
||||||
|
assert.equal(changed.group_id, group.id);
|
||||||
|
assert.deepEqual(changed.proxy_bypass_rules, [
|
||||||
|
"localhost",
|
||||||
|
"*.internal.example",
|
||||||
|
]);
|
||||||
|
assert.equal(changed.dns_blocklist, "light");
|
||||||
|
assert.equal(changed.clear_on_close, true);
|
||||||
|
assert.deepEqual((await app.invoke("get_all_tags")).sort(), [
|
||||||
|
"alpha",
|
||||||
|
"automation",
|
||||||
|
]);
|
||||||
|
|
||||||
|
assert.ok(Array.isArray(await app.invoke("detect_existing_profiles")));
|
||||||
|
const importRoot = path.join(app.root, "profile-import-fixture");
|
||||||
|
const importProfile = path.join(importRoot, "Default");
|
||||||
|
await mkdir(importProfile, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(importProfile, "Preferences"),
|
||||||
|
JSON.stringify({ profile: { name: "Imported fixture" } }),
|
||||||
|
);
|
||||||
|
const scanned = await app.invoke("scan_folder_for_profiles", {
|
||||||
|
folderPath: importRoot,
|
||||||
|
});
|
||||||
|
assert.equal(scanned.length, 1);
|
||||||
|
assert.equal(scanned[0].mapped_browser, "wayfern");
|
||||||
|
const importBatch = await app.invoke("import_browser_profiles", {
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
source_path: scanned[0].path,
|
||||||
|
browser_type: scanned[0].browser,
|
||||||
|
new_profile_name: "Imported Profile",
|
||||||
|
proxy_id: null,
|
||||||
|
vpn_id: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
groupId: null,
|
||||||
|
duplicateStrategy: "rename",
|
||||||
|
wayfernConfig: null,
|
||||||
|
});
|
||||||
|
assert.equal(importBatch.imported_count + importBatch.failed_count, 1);
|
||||||
|
const archivePath = path.join(app.root, "profile-import-fixture.zip");
|
||||||
|
await writeFile(archivePath, Buffer.from(extensionZipBase64(), "base64"));
|
||||||
|
const archiveScan = await app.invoke("scan_profile_archive", {
|
||||||
|
archivePath,
|
||||||
|
});
|
||||||
|
assert.ok(Array.isArray(archiveScan.profiles));
|
||||||
|
await app.invoke("cleanup_profile_import_scratch", {
|
||||||
|
extractedDir: archiveScan.extracted_dir,
|
||||||
|
});
|
||||||
|
|
||||||
|
const clone = await app.invoke("clone_profile", {
|
||||||
|
profileId: profile.id,
|
||||||
|
name: "Cloned Profile",
|
||||||
|
});
|
||||||
|
assert.notEqual(clone.id, profile.id);
|
||||||
|
assert.equal(clone.name, "Cloned Profile");
|
||||||
|
const counts = await app.invoke("get_groups_with_profile_counts");
|
||||||
|
assert.equal(counts.find((item) => item.id === group.id).count, 2);
|
||||||
|
assert.equal((await app.invoke("get_profile_groups")).length, 1);
|
||||||
|
|
||||||
|
await app.invoke("delete_selected_profiles", {
|
||||||
|
profileIds: [profile.id, clone.id],
|
||||||
|
});
|
||||||
|
assert.deepEqual(await app.invoke("list_browser_profiles"), []);
|
||||||
|
await app.invoke("delete_profile_group", { groupId: group.id });
|
||||||
|
await app.invoke("delete_stored_proxy", { proxyId: proxy.id });
|
||||||
|
for (const importedProxy of (await app.invoke("get_stored_proxies")).filter(
|
||||||
|
(item) =>
|
||||||
|
item.name === "Imported Proxy" || item.name.startsWith("Parsed Proxy"),
|
||||||
|
)) {
|
||||||
|
await app.invoke("delete_stored_proxy", { proxyId: importedProxy.id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("extensions, extension groups, VPN storage, DNS rules, and event-backed assignments", async () => {
|
||||||
|
await withApp("entities-network-extension", async (app) => {
|
||||||
|
const profile = await createProfile(app, "Assignment Profile");
|
||||||
|
const extension = await app.invoke("add_extension", {
|
||||||
|
name: "E2E Fixture Extension",
|
||||||
|
fileName: "fixture.zip",
|
||||||
|
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
||||||
|
});
|
||||||
|
assert.equal(extension.name, "Donut E2E Fixture");
|
||||||
|
assert.equal(extension.version, "1.0.0");
|
||||||
|
const extensionGroup = await app.invoke("create_extension_group", {
|
||||||
|
name: "Automation Extensions",
|
||||||
|
});
|
||||||
|
const populated = await app.invoke("add_extension_to_group", {
|
||||||
|
groupId: extensionGroup.id,
|
||||||
|
extensionId: extension.id,
|
||||||
|
});
|
||||||
|
assert.deepEqual(populated.extension_ids, [extension.id]);
|
||||||
|
await app.invoke("assign_extension_group_to_profile", {
|
||||||
|
profileId: profile.id,
|
||||||
|
extensionGroupId: extensionGroup.id,
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await app.invoke("get_extension_group_for_profile", {
|
||||||
|
profileId: profile.id,
|
||||||
|
})
|
||||||
|
).id,
|
||||||
|
extensionGroup.id,
|
||||||
|
);
|
||||||
|
const renamed = await app.invoke("update_extension", {
|
||||||
|
extensionId: extension.id,
|
||||||
|
name: "Renamed Fixture Extension",
|
||||||
|
fileName: null,
|
||||||
|
fileData: null,
|
||||||
|
});
|
||||||
|
assert.equal(renamed.name, "Renamed Fixture Extension");
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("get_extension_icon", { extensionId: extension.id }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
const changedGroup = await app.invoke("update_extension_group", {
|
||||||
|
groupId: extensionGroup.id,
|
||||||
|
name: "Renamed Extension Group",
|
||||||
|
extensionIds: [extension.id],
|
||||||
|
});
|
||||||
|
assert.equal(changedGroup.name, "Renamed Extension Group");
|
||||||
|
assert.equal((await app.invoke("list_extensions")).length, 1);
|
||||||
|
assert.equal((await app.invoke("list_extension_groups")).length, 1);
|
||||||
|
await app.invoke("remove_extension_from_group", {
|
||||||
|
groupId: extensionGroup.id,
|
||||||
|
extensionId: extension.id,
|
||||||
|
});
|
||||||
|
await app.invoke("assign_extension_group_to_profile", {
|
||||||
|
profileId: profile.id,
|
||||||
|
extensionGroupId: null,
|
||||||
|
});
|
||||||
|
await app.invoke("delete_extension_group", { groupId: extensionGroup.id });
|
||||||
|
await app.invoke("delete_extension", { extensionId: extension.id });
|
||||||
|
|
||||||
|
const vpn = await app.invoke("create_vpn_config_manual", {
|
||||||
|
name: "E2E WireGuard",
|
||||||
|
vpnType: "WireGuard",
|
||||||
|
configData: wireGuardFixture(),
|
||||||
|
});
|
||||||
|
assert.equal(vpn.name, "E2E WireGuard");
|
||||||
|
assert.equal(
|
||||||
|
(await app.invoke("get_vpn_config", { vpnId: vpn.id })).id,
|
||||||
|
vpn.id,
|
||||||
|
);
|
||||||
|
assert.equal((await app.invoke("list_vpn_configs")).length, 1);
|
||||||
|
const updatedVpn = await app.invoke("update_vpn_config", {
|
||||||
|
vpnId: vpn.id,
|
||||||
|
name: "Updated WireGuard",
|
||||||
|
});
|
||||||
|
assert.equal(updatedVpn.name, "Updated WireGuard");
|
||||||
|
assert.equal(
|
||||||
|
(await app.invoke("get_vpn_status", { vpnId: vpn.id })).connected,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await app.invoke("update_profile_vpn", {
|
||||||
|
profileId: profile.id,
|
||||||
|
vpnId: vpn.id,
|
||||||
|
})
|
||||||
|
).vpn_id,
|
||||||
|
vpn.id,
|
||||||
|
);
|
||||||
|
assert.deepEqual(await app.invoke("list_active_vpn_connections"), []);
|
||||||
|
await app.invoke("disconnect_vpn", { vpnId: vpn.id });
|
||||||
|
const unknownVpnError = await app.invokeError("check_vpn_validity", {
|
||||||
|
vpnId: "missing-vpn",
|
||||||
|
});
|
||||||
|
assert.match(unknownVpnError, /not found|Failed to start VPN worker/i);
|
||||||
|
const importedVpn = await app.invoke("import_vpn_config", {
|
||||||
|
content: wireGuardFixture(),
|
||||||
|
filename: "imported.conf",
|
||||||
|
name: "Imported WireGuard",
|
||||||
|
});
|
||||||
|
assert.equal(importedVpn.success, true);
|
||||||
|
await app.invoke("delete_vpn_config", { vpnId: importedVpn.vpn_id });
|
||||||
|
await app.invoke("delete_vpn_config", { vpnId: vpn.id });
|
||||||
|
|
||||||
|
const dns = await app.invoke("set_custom_dns_config", {
|
||||||
|
sources: [`${process.env.DONUT_E2E_FIXTURE_URL}/dns.txt`],
|
||||||
|
blockDomains: [" Ads.Example.com ", "tracker.example"],
|
||||||
|
allowDomains: ["safe.example"],
|
||||||
|
allowlistMode: false,
|
||||||
|
});
|
||||||
|
assert.deepEqual(dns.block_domains, ["ads.example.com", "tracker.example"]);
|
||||||
|
const textExport = await app.invoke("export_custom_dns_rules", {
|
||||||
|
format: "txt",
|
||||||
|
});
|
||||||
|
assert.match(textExport, /ads\.example\.com/);
|
||||||
|
await app.invoke("import_custom_dns_rules", {
|
||||||
|
format: "txt",
|
||||||
|
content: "||malware.example^\n@@||allowed.example^\n",
|
||||||
|
});
|
||||||
|
const importedDns = await app.invoke("get_custom_dns_config");
|
||||||
|
assert.ok(importedDns.block_domains.includes("malware.example"));
|
||||||
|
assert.ok(importedDns.allow_domains.includes("allowed.example"));
|
||||||
|
await app.invoke("refresh_dns_blocklists");
|
||||||
|
const blocklistStatus = await app.invoke("get_dns_blocklist_cache_status");
|
||||||
|
assert.equal(blocklistStatus.length, 5);
|
||||||
|
assert.ok(
|
||||||
|
blocklistStatus.every(
|
||||||
|
(entry) => entry.is_cached && entry.is_fresh && entry.entry_count === 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.invoke("delete_profile", { profileId: profile.id });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("cookie import/copy/export, profile encryption, and traffic-stat read/clear paths", async () => {
|
||||||
|
await withApp("entities-cookies-password", async (app) => {
|
||||||
|
const source = await createProfile(app, "Cookie Source");
|
||||||
|
const target = await createProfile(app, "Cookie Target");
|
||||||
|
const cookieJson = JSON.stringify([
|
||||||
|
{
|
||||||
|
name: "session",
|
||||||
|
value: "isolated-secret-cookie",
|
||||||
|
domain: "fixture.local",
|
||||||
|
path: "/",
|
||||||
|
secure: false,
|
||||||
|
httpOnly: true,
|
||||||
|
sameSite: "lax",
|
||||||
|
expirationDate: 2_000_000_000,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const imported = await app.invoke("import_cookies_from_file", {
|
||||||
|
profileId: source.id,
|
||||||
|
content: cookieJson,
|
||||||
|
});
|
||||||
|
assert.equal(imported.cookies_imported, 1);
|
||||||
|
const cookies = await app.invoke("read_profile_cookies", {
|
||||||
|
profileId: source.id,
|
||||||
|
});
|
||||||
|
assert.equal(cookies.total_count, 1);
|
||||||
|
assert.equal(cookies.domains[0].cookies[0].value, "isolated-secret-cookie");
|
||||||
|
const stats = await app.invoke("get_profile_cookie_stats", {
|
||||||
|
profileId: source.id,
|
||||||
|
});
|
||||||
|
assert.equal(stats.total_count, 1);
|
||||||
|
const copied = await app.invoke("copy_profile_cookies", {
|
||||||
|
request: {
|
||||||
|
source_profile_id: source.id,
|
||||||
|
target_profile_ids: [target.id],
|
||||||
|
selected_cookies: [{ domain: "fixture.local", name: "session" }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(copied[0].cookies_copied, 1);
|
||||||
|
assert.match(
|
||||||
|
await app.invoke("export_profile_cookies", {
|
||||||
|
profileId: target.id,
|
||||||
|
format: "json",
|
||||||
|
}),
|
||||||
|
/isolated-secret-cookie/,
|
||||||
|
);
|
||||||
|
assert.match(
|
||||||
|
await app.invoke("export_profile_cookies", {
|
||||||
|
profileId: target.id,
|
||||||
|
format: "netscape",
|
||||||
|
}),
|
||||||
|
/fixture\.local/,
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.invoke("set_profile_password", {
|
||||||
|
profileId: source.id,
|
||||||
|
password: "correct horse battery staple",
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("is_profile_locked", { profileId: source.id }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const wrong = await app.invokeError("verify_profile_password", {
|
||||||
|
profileId: source.id,
|
||||||
|
password: "wrong password",
|
||||||
|
});
|
||||||
|
assert.match(wrong, /INCORRECT_PASSWORD/);
|
||||||
|
await app.invoke("verify_profile_password", {
|
||||||
|
profileId: source.id,
|
||||||
|
password: "correct horse battery staple",
|
||||||
|
});
|
||||||
|
await app.invoke("change_profile_password", {
|
||||||
|
profileId: source.id,
|
||||||
|
oldPassword: "correct horse battery staple",
|
||||||
|
newPassword: "new correct horse battery staple",
|
||||||
|
});
|
||||||
|
await app.invoke("lock_profile", { profileId: source.id });
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("is_profile_locked", { profileId: source.id }),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await app.invoke("unlock_profile", {
|
||||||
|
profileId: source.id,
|
||||||
|
password: "new correct horse battery staple",
|
||||||
|
});
|
||||||
|
await app.invoke("remove_profile_password", {
|
||||||
|
profileId: source.id,
|
||||||
|
password: "new correct horse battery staple",
|
||||||
|
});
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("is_profile_locked", { profileId: source.id }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(await app.invoke("get_all_traffic_snapshots"), []);
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("get_profile_traffic_snapshot", {
|
||||||
|
profileId: source.id,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("get_traffic_stats_for_period", {
|
||||||
|
profileId: source.id,
|
||||||
|
seconds: 3600,
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
await app.invoke("clear_profile_traffic_stats", { profileId: source.id });
|
||||||
|
await app.invoke("clear_all_traffic_stats");
|
||||||
|
|
||||||
|
await app.invoke("delete_selected_profiles", {
|
||||||
|
profileIds: [source.id, target.id],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,403 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { withApp } from "../lib/app.mjs";
|
||||||
|
|
||||||
|
async function jsonRequest(
|
||||||
|
url,
|
||||||
|
{ method = "GET", token, body, headers = {} } = {},
|
||||||
|
) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||||
|
...headers,
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let value = null;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
value = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
value = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { response, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedTerms(app) {
|
||||||
|
const home = path.join(app.root, "home");
|
||||||
|
const directory =
|
||||||
|
process.platform === "darwin"
|
||||||
|
? path.join(home, "Library", "Application Support", "Wayfern")
|
||||||
|
: process.platform === "win32"
|
||||||
|
? path.join(app.root, "windows", "roaming", "Wayfern")
|
||||||
|
: path.join(app.root, "xdg", "config", "Wayfern");
|
||||||
|
await mkdir(directory, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(directory, "license-accepted"),
|
||||||
|
String(Math.floor(Date.now() / 1000)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function invokeContract(app, command, args = {}) {
|
||||||
|
try {
|
||||||
|
return { ok: true, value: await app.invoke(command, args) };
|
||||||
|
} catch (error) {
|
||||||
|
return { ok: false, error: String(error) };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test("authenticated REST API serves its complete OpenAPI contract and CRUD lifecycle", async () => {
|
||||||
|
await withApp("integrations-rest", async (app) => {
|
||||||
|
await seedTerms(app);
|
||||||
|
const settings = await app.invoke("get_app_settings");
|
||||||
|
const saved = await app.invoke("save_app_settings", {
|
||||||
|
settings: {
|
||||||
|
...settings,
|
||||||
|
api_enabled: true,
|
||||||
|
api_port: 0,
|
||||||
|
api_token: null,
|
||||||
|
onboarding_completed: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.ok(saved.api_token?.length >= 32);
|
||||||
|
const port = await app.invoke("start_api_server", { port: 0 });
|
||||||
|
assert.equal(await app.invoke("get_api_server_status"), port);
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
|
||||||
|
const openapi = await jsonRequest(`${base}/openapi.json`);
|
||||||
|
assert.equal(openapi.response.status, 200);
|
||||||
|
assert.equal(openapi.value.openapi.startsWith("3."), true);
|
||||||
|
const paths = Object.keys(openapi.value.paths);
|
||||||
|
for (const required of [
|
||||||
|
"/v1/profiles",
|
||||||
|
"/v1/profiles/{id}/run",
|
||||||
|
"/v1/groups",
|
||||||
|
"/v1/proxies",
|
||||||
|
"/v1/vpns/{id}/export",
|
||||||
|
"/v1/extensions",
|
||||||
|
"/v1/browsers/{browser}/versions",
|
||||||
|
]) {
|
||||||
|
assert.ok(paths.includes(required), `OpenAPI is missing ${required}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const unauthorized = await jsonRequest(`${base}/v1/profiles`);
|
||||||
|
assert.equal(unauthorized.response.status, 401);
|
||||||
|
const wrongToken = await jsonRequest(`${base}/v1/profiles`, {
|
||||||
|
token: "wrong",
|
||||||
|
});
|
||||||
|
assert.equal(wrongToken.response.status, 401);
|
||||||
|
|
||||||
|
const groupsInitially = await jsonRequest(`${base}/v1/groups`, {
|
||||||
|
token: saved.api_token,
|
||||||
|
});
|
||||||
|
assert.equal(groupsInitially.response.status, 200);
|
||||||
|
assert.deepEqual(groupsInitially.value, []);
|
||||||
|
const createdGroup = await jsonRequest(`${base}/v1/groups`, {
|
||||||
|
method: "POST",
|
||||||
|
token: saved.api_token,
|
||||||
|
body: { name: "REST Group" },
|
||||||
|
});
|
||||||
|
assert.equal(createdGroup.response.status, 200);
|
||||||
|
assert.equal(createdGroup.value.name, "REST Group");
|
||||||
|
const groupId = createdGroup.value.id;
|
||||||
|
const updatedGroup = await jsonRequest(`${base}/v1/groups/${groupId}`, {
|
||||||
|
method: "PUT",
|
||||||
|
token: saved.api_token,
|
||||||
|
body: { name: "REST Group Updated" },
|
||||||
|
});
|
||||||
|
assert.equal(updatedGroup.value.name, "REST Group Updated");
|
||||||
|
|
||||||
|
const createdProxy = await jsonRequest(`${base}/v1/proxies`, {
|
||||||
|
method: "POST",
|
||||||
|
token: saved.api_token,
|
||||||
|
body: {
|
||||||
|
name: "REST Proxy",
|
||||||
|
proxy_settings: {
|
||||||
|
proxy_type: "http",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 8080,
|
||||||
|
username: null,
|
||||||
|
password: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(createdProxy.response.status, 200);
|
||||||
|
assert.equal(createdProxy.value.proxy_settings.port, 8080);
|
||||||
|
const proxyId = createdProxy.value.id;
|
||||||
|
const fetchedProxy = await jsonRequest(`${base}/v1/proxies/${proxyId}`, {
|
||||||
|
token: saved.api_token,
|
||||||
|
});
|
||||||
|
assert.equal(fetchedProxy.value.name, "REST Proxy");
|
||||||
|
const imported = await jsonRequest(`${base}/v1/proxies/import`, {
|
||||||
|
method: "POST",
|
||||||
|
token: saved.api_token,
|
||||||
|
body: {
|
||||||
|
format: "txt",
|
||||||
|
content: "http://127.0.0.1:8081",
|
||||||
|
name_prefix: "API",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(imported.response.status, 200);
|
||||||
|
assert.equal(imported.value.imported_count, 1);
|
||||||
|
|
||||||
|
const missing = await jsonRequest(`${base}/v1/groups/missing`, {
|
||||||
|
token: saved.api_token,
|
||||||
|
});
|
||||||
|
assert.equal(missing.response.status, 404);
|
||||||
|
const invalidProfile = await jsonRequest(`${base}/v1/profiles`, {
|
||||||
|
method: "POST",
|
||||||
|
token: saved.api_token,
|
||||||
|
body: { name: "Bad", browser: "unsupported", version: "latest" },
|
||||||
|
});
|
||||||
|
assert.equal(invalidProfile.response.status, 400);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await jsonRequest(`${base}/v1/proxies/${proxyId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
token: saved.api_token,
|
||||||
|
})
|
||||||
|
).response.status,
|
||||||
|
204,
|
||||||
|
);
|
||||||
|
for (const importedProxy of imported.value.proxies) {
|
||||||
|
await jsonRequest(`${base}/v1/proxies/${importedProxy.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
token: saved.api_token,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await jsonRequest(`${base}/v1/groups/${groupId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
token: saved.api_token,
|
||||||
|
})
|
||||||
|
).response.status,
|
||||||
|
204,
|
||||||
|
);
|
||||||
|
await app.invoke("stop_api_server");
|
||||||
|
assert.equal(await app.invoke("get_api_server_status"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated agent install", async () => {
|
||||||
|
await withApp("integrations-mcp", async (app) => {
|
||||||
|
await seedTerms(app);
|
||||||
|
const port = await app.invoke("start_mcp_server");
|
||||||
|
assert.equal(await app.invoke("get_mcp_server_status"), true);
|
||||||
|
const config = await app.invoke("get_mcp_config");
|
||||||
|
assert.equal(config.port, port);
|
||||||
|
assert.ok(config.token.length >= 32);
|
||||||
|
const base = `http://127.0.0.1:${port}`;
|
||||||
|
assert.equal((await fetch(`${base}/health`)).status, 200);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await jsonRequest(`${base}/mcp`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { jsonrpc: "2.0", id: 1, method: "initialize", params: {} },
|
||||||
|
})
|
||||||
|
).response.status,
|
||||||
|
401,
|
||||||
|
);
|
||||||
|
|
||||||
|
const initialized = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||||
|
method: "POST",
|
||||||
|
body: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "initialize",
|
||||||
|
params: {
|
||||||
|
protocolVersion: "2025-11-25",
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: "donut-e2e", version: "1" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(initialized.response.status, 200);
|
||||||
|
assert.equal(initialized.value.result.serverInfo.name, "donut-browser");
|
||||||
|
const sessionId = initialized.response.headers.get("mcp-session-id");
|
||||||
|
assert.ok(sessionId);
|
||||||
|
const mcpHeaders = { "mcp-session-id": sessionId };
|
||||||
|
const notification = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: mcpHeaders,
|
||||||
|
body: { jsonrpc: "2.0", method: "notifications/initialized" },
|
||||||
|
});
|
||||||
|
assert.equal(notification.response.status, 202);
|
||||||
|
const tools = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: mcpHeaders,
|
||||||
|
body: { jsonrpc: "2.0", id: 2, method: "tools/list", params: {} },
|
||||||
|
});
|
||||||
|
assert.equal(tools.response.status, 200);
|
||||||
|
const names = tools.value.result.tools.map((tool) => tool.name);
|
||||||
|
for (const name of [
|
||||||
|
"list_profiles",
|
||||||
|
"create_profile",
|
||||||
|
"run_profile",
|
||||||
|
"list_proxies",
|
||||||
|
"get_page_content",
|
||||||
|
"get_interactive_elements",
|
||||||
|
]) {
|
||||||
|
assert.ok(names.includes(name), `MCP is missing ${name}`);
|
||||||
|
}
|
||||||
|
const listed = await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: mcpHeaders,
|
||||||
|
body: {
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 3,
|
||||||
|
method: "tools/call",
|
||||||
|
params: { name: "list_profiles", arguments: {} },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(listed.response.status, 200);
|
||||||
|
assert.equal(listed.value.error, undefined);
|
||||||
|
assert.ok(listed.value.result);
|
||||||
|
|
||||||
|
const agents = await app.invoke("list_mcp_agents");
|
||||||
|
assert.ok(agents.some((agent) => agent.id === "cursor"));
|
||||||
|
await app.invoke("add_mcp_to_agent", { agentId: "cursor" });
|
||||||
|
assert.equal(
|
||||||
|
(await app.invoke("list_mcp_agents")).find(
|
||||||
|
(agent) => agent.id === "cursor",
|
||||||
|
).connected,
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
await app.invoke("remove_mcp_from_agent", { agentId: "cursor" });
|
||||||
|
assert.equal(
|
||||||
|
(await app.invoke("list_mcp_agents")).find(
|
||||||
|
(agent) => agent.id === "cursor",
|
||||||
|
).connected,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(
|
||||||
|
await jsonRequest(`${base}/mcp/${config.token}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: mcpHeaders,
|
||||||
|
})
|
||||||
|
).response.status,
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
await app.invoke("stop_mcp_server");
|
||||||
|
assert.equal(await app.invoke("get_mcp_server_status"), false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("offline cloud, update, team-lock, trial, and synchronizer contracts are deterministic", async () => {
|
||||||
|
await withApp("integrations-contracts", async (app) => {
|
||||||
|
assert.equal(await app.invoke("cloud_get_user"), null);
|
||||||
|
assert.equal(await app.invoke("cloud_get_proxy_usage"), null);
|
||||||
|
assert.ok(await app.invoke("cloud_get_wayfern_token"));
|
||||||
|
assert.deepEqual(await app.invoke("get_team_locks"), []);
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("get_team_lock_status", {
|
||||||
|
profileId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.deepEqual(await app.invoke("get_sync_sessions"), []);
|
||||||
|
const startResult = await invokeContract(app, "start_sync_session", {
|
||||||
|
leaderProfileId: "00000000-0000-0000-0000-000000000001",
|
||||||
|
followerProfileIds: ["00000000-0000-0000-0000-000000000002"],
|
||||||
|
});
|
||||||
|
assert.equal(startResult.ok, false);
|
||||||
|
const stopError = await app.invokeError("stop_sync_session", {
|
||||||
|
sessionId: "missing",
|
||||||
|
});
|
||||||
|
assert.match(stopError, /not found|session/i);
|
||||||
|
const removeError = await app.invokeError("remove_sync_follower", {
|
||||||
|
sessionId: "missing",
|
||||||
|
followerProfileId: "missing",
|
||||||
|
});
|
||||||
|
assert.match(removeError, /not found|session/i);
|
||||||
|
|
||||||
|
assert.equal(await app.invoke("check_for_app_updates"), null);
|
||||||
|
assert.equal(await app.invoke("check_for_app_updates_manual"), null);
|
||||||
|
assert.ok(
|
||||||
|
await invokeContract(app, "cloud_exchange_device_code", {
|
||||||
|
code: "DONUT-E2E-INVALID-CODE",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.ok(await invokeContract(app, "cloud_refresh_profile"));
|
||||||
|
assert.ok(await invokeContract(app, "cloud_get_countries"));
|
||||||
|
assert.ok(
|
||||||
|
await invokeContract(app, "cloud_get_regions", {
|
||||||
|
country: "ZZ",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
await invokeContract(app, "cloud_get_cities", {
|
||||||
|
country: "ZZ",
|
||||||
|
region: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
await invokeContract(app, "cloud_get_isps", {
|
||||||
|
country: "ZZ",
|
||||||
|
region: null,
|
||||||
|
city: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.ok(
|
||||||
|
await invokeContract(app, "create_cloud_location_proxy", {
|
||||||
|
name: "E2E unavailable cloud proxy",
|
||||||
|
country: "ZZ",
|
||||||
|
region: null,
|
||||||
|
city: null,
|
||||||
|
isp: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
assert.ok(await invokeContract(app, "cloud_refresh_wayfern_token"));
|
||||||
|
|
||||||
|
assert.ok(await invokeContract(app, "trigger_manual_version_update"));
|
||||||
|
assert.ok(await invokeContract(app, "clear_all_version_cache_and_refetch"));
|
||||||
|
assert.ok(await invokeContract(app, "check_for_browser_updates"));
|
||||||
|
await app.invoke("dismiss_update_notification", {
|
||||||
|
notificationId: "missing-e2e-notification",
|
||||||
|
});
|
||||||
|
assert.deepEqual(
|
||||||
|
await app.invoke("complete_browser_update_with_auto_update", {
|
||||||
|
browser: "wayfern",
|
||||||
|
newVersion: "150.0.7871.100",
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
const prepareError = await app.invokeError(
|
||||||
|
"download_and_prepare_app_update",
|
||||||
|
{
|
||||||
|
updateInfo: {
|
||||||
|
current_version: "0.0.0",
|
||||||
|
new_version: "0.0.1-e2e",
|
||||||
|
release_notes: "E2E invalid update contract",
|
||||||
|
download_url: `${process.env.DONUT_E2E_FIXTURE_URL}/invalid-update.zip`,
|
||||||
|
is_nightly: false,
|
||||||
|
published_at: "2026-01-01T00:00:00Z",
|
||||||
|
manual_update_required: false,
|
||||||
|
release_page_url: null,
|
||||||
|
repo_update: false,
|
||||||
|
checksums_url: null,
|
||||||
|
asset_digest: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.match(prepareError, /checksum|verif|Failed to download/i);
|
||||||
|
const versionStatus = await app.invoke("get_version_update_status");
|
||||||
|
assert.ok(versionStatus && typeof versionStatus === "object");
|
||||||
|
assert.equal(typeof (await app.invoke("is_default_browser")), "boolean");
|
||||||
|
|
||||||
|
const trial = await app.invoke("get_commercial_trial_status");
|
||||||
|
assert.ok(trial && typeof trial === "object");
|
||||||
|
await app.invoke("acknowledge_trial_expiration");
|
||||||
|
assert.equal(await app.invoke("has_acknowledged_trial_expiration"), true);
|
||||||
|
await app.invoke("cloud_logout");
|
||||||
|
assert.equal(await app.invoke("cloud_get_user"), null);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { spawnSync } from "node:child_process";
|
||||||
|
import { readdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { appFromEnvironment } from "../lib/app.mjs";
|
||||||
|
import { CdpClient } from "../lib/cdp.mjs";
|
||||||
|
import {
|
||||||
|
extensionZipBase64,
|
||||||
|
prepareWayfern,
|
||||||
|
wireGuardFixture,
|
||||||
|
} from "../lib/fixtures.mjs";
|
||||||
|
|
||||||
|
function proxySettings(raw, expectedKind) {
|
||||||
|
assert.ok(raw, `${expectedKind} residential proxy URL is required`);
|
||||||
|
const url = new URL(raw);
|
||||||
|
const rawType = url.protocol.slice(0, -1).toLowerCase();
|
||||||
|
const proxyType =
|
||||||
|
rawType === "socks" || rawType === "socks5h" ? "socks5" : rawType;
|
||||||
|
if (expectedKind === "HTTP") {
|
||||||
|
assert.ok(
|
||||||
|
proxyType === "http" || proxyType === "https",
|
||||||
|
`Expected an HTTP proxy URL, got ${rawType}`,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
assert.equal(proxyType, "socks5");
|
||||||
|
}
|
||||||
|
const port = Number(url.port);
|
||||||
|
assert.ok(url.hostname && port > 0 && port <= 65535);
|
||||||
|
return {
|
||||||
|
proxy_type: proxyType,
|
||||||
|
host: url.hostname,
|
||||||
|
port,
|
||||||
|
username: url.username ? decodeURIComponent(url.username) : null,
|
||||||
|
password: url.password ? decodeURIComponent(url.password) : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireGuardFields(config) {
|
||||||
|
let section = "";
|
||||||
|
const fields = new Map();
|
||||||
|
for (const rawLine of config.split(/\r?\n/)) {
|
||||||
|
const line = rawLine.trim();
|
||||||
|
if (!line || line.startsWith("#")) continue;
|
||||||
|
if (line === "[Interface]") {
|
||||||
|
section = "interface";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (line === "[Peer]") {
|
||||||
|
section = "peer";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const separator = line.indexOf("=");
|
||||||
|
if (separator === -1) continue;
|
||||||
|
fields.set(
|
||||||
|
`${section}.${line.slice(0, separator).trim()}`,
|
||||||
|
line.slice(separator + 1).trim(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
privateKey: fields.get("interface.PrivateKey"),
|
||||||
|
address: fields.get("interface.Address"),
|
||||||
|
dns: fields.get("interface.DNS") ?? "",
|
||||||
|
peerPublicKey: fields.get("peer.PublicKey"),
|
||||||
|
peerEndpoint: fields.get("peer.Endpoint"),
|
||||||
|
allowedIps: fields.get("peer.AllowedIPs") ?? "0.0.0.0/0",
|
||||||
|
persistentKeepalive: fields.get("peer.PersistentKeepalive") ?? "",
|
||||||
|
presharedKey: fields.get("peer.PresharedKey") ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(url, { method = "GET", token, body } = {}) {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
...(token ? { authorization: `Bearer ${token}` } : {}),
|
||||||
|
...(body === undefined ? {} : { "content-type": "application/json" }),
|
||||||
|
},
|
||||||
|
body: body === undefined ? undefined : JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
let value = text;
|
||||||
|
if (text) {
|
||||||
|
try {
|
||||||
|
value = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
// Plain-text responses are intentional for some endpoints.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { response, value };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createGroupThroughUi(app) {
|
||||||
|
await app.clickSelector('[aria-label="Groups"]');
|
||||||
|
await app.waitForText("Profile groups");
|
||||||
|
await app.clickSelector('[aria-label="Create"]');
|
||||||
|
await app.waitForText("Create New Group");
|
||||||
|
await app.fillSelector("#group-name", "Visible UI Group");
|
||||||
|
await app.clickTextIn('[role="dialog"]', "Create", { roles: ["button"] });
|
||||||
|
await app.waitForText("Visible UI Group");
|
||||||
|
const groups = await app.invoke("get_profile_groups");
|
||||||
|
return groups.find((group) => group.name === "Visible UI Group");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProxyThroughUi(app, settings) {
|
||||||
|
await app.clickSelector('[aria-label="Network"]');
|
||||||
|
await app.waitForText("New proxy");
|
||||||
|
await app.clickSelector('[aria-label="New proxy"]');
|
||||||
|
await app.waitForText("Add Proxy");
|
||||||
|
await app.fillSelector("#proxy-name", "Visible Residential HTTP");
|
||||||
|
await app.fillSelector("#proxy-host", settings.host);
|
||||||
|
await app.fillSelector("#proxy-port", String(settings.port));
|
||||||
|
if (settings.username)
|
||||||
|
await app.fillSelector("#proxy-username", settings.username);
|
||||||
|
if (settings.password)
|
||||||
|
await app.fillSelector("#proxy-password", settings.password);
|
||||||
|
await app.clickTextIn('[role="dialog"]', "Add Proxy", {
|
||||||
|
roles: ["button"],
|
||||||
|
});
|
||||||
|
await app.waitForText("Visible Residential HTTP");
|
||||||
|
const proxies = await app.invoke("get_stored_proxies");
|
||||||
|
return proxies.find((proxy) => proxy.name === "Visible Residential HTTP");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createVpnThroughUi(app, config) {
|
||||||
|
const fields = wireGuardFields(config);
|
||||||
|
assert.ok(
|
||||||
|
fields.privateKey &&
|
||||||
|
fields.address &&
|
||||||
|
fields.peerPublicKey &&
|
||||||
|
fields.peerEndpoint,
|
||||||
|
"WireGuard fixture is missing required fields",
|
||||||
|
);
|
||||||
|
await app.clickText("VPNs", { exact: false, roles: ["tab"] });
|
||||||
|
await app.clickSelector('[aria-label="New VPN"]');
|
||||||
|
await app.waitForText("Create WireGuard VPN");
|
||||||
|
await app.fillSelector("#wg-name", "Visible Local WireGuard");
|
||||||
|
await app.fillSelector("#wg-private-key", fields.privateKey);
|
||||||
|
await app.fillSelector("#wg-address", fields.address);
|
||||||
|
if (fields.dns) await app.fillSelector("#wg-dns", fields.dns);
|
||||||
|
await app.fillSelector("#wg-peer-public-key", fields.peerPublicKey);
|
||||||
|
await app.fillSelector("#wg-peer-endpoint", fields.peerEndpoint);
|
||||||
|
await app.fillSelector("#wg-allowed-ips", fields.allowedIps);
|
||||||
|
if (fields.persistentKeepalive) {
|
||||||
|
await app.fillSelector("#wg-keepalive", fields.persistentKeepalive);
|
||||||
|
}
|
||||||
|
if (fields.presharedKey) {
|
||||||
|
await app.fillSelector("#wg-preshared-key", fields.presharedKey);
|
||||||
|
}
|
||||||
|
await app.clickTextIn('[role="dialog"]', "Create VPN", {
|
||||||
|
roles: ["button"],
|
||||||
|
});
|
||||||
|
await app.waitForText("Visible Local WireGuard");
|
||||||
|
const vpns = await app.invoke("list_vpn_configs");
|
||||||
|
return vpns.find((vpn) => vpn.name === "Visible Local WireGuard");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createExtensionsThroughUi(app) {
|
||||||
|
const extensionFile = path.join(app.root, "visible-extension.zip");
|
||||||
|
await writeFile(extensionFile, Buffer.from(extensionZipBase64(), "base64"));
|
||||||
|
await app.clickSelector('[aria-label="Extensions"]');
|
||||||
|
await app.waitForText("Upload");
|
||||||
|
|
||||||
|
await app.execute(`
|
||||||
|
const input = document.querySelector("#ext-file-input");
|
||||||
|
input.classList.remove("hidden");
|
||||||
|
input.style.position = "fixed";
|
||||||
|
input.style.left = "12px";
|
||||||
|
input.style.bottom = "12px";
|
||||||
|
`);
|
||||||
|
const input = await app.session.findCss("#ext-file-input");
|
||||||
|
await app.session.sendKeys(input, extensionFile);
|
||||||
|
await app.waitForText("visible-extension.zip");
|
||||||
|
await app.fillSelector(
|
||||||
|
'input[placeholder="Extension name"]',
|
||||||
|
"Visible UI Extension",
|
||||||
|
);
|
||||||
|
await app.clickText("Add", { roles: ["button"] });
|
||||||
|
await app.waitForText("Donut E2E Fixture");
|
||||||
|
|
||||||
|
await app.clickText("Groups", { exact: false, roles: ["tab"] });
|
||||||
|
await app.clickSelector('[aria-label="New group"]');
|
||||||
|
await app.fillSelector(
|
||||||
|
'input[placeholder="Group name"]',
|
||||||
|
"Visible Extension Group",
|
||||||
|
);
|
||||||
|
await app.clickText("Create", { roles: ["button"] });
|
||||||
|
await app.waitForText("Visible Extension Group");
|
||||||
|
|
||||||
|
let [extensions, groups] = await Promise.all([
|
||||||
|
app.invoke("list_extensions"),
|
||||||
|
app.invoke("list_extension_groups"),
|
||||||
|
]);
|
||||||
|
const extension = extensions.find(
|
||||||
|
(item) => item.name === "Donut E2E Fixture",
|
||||||
|
);
|
||||||
|
let group = groups.find((item) => item.name === "Visible Extension Group");
|
||||||
|
assert.ok(extension && group);
|
||||||
|
|
||||||
|
const editButton = await app.execute(
|
||||||
|
`
|
||||||
|
const row = [...document.querySelectorAll("tr")].find((candidate) =>
|
||||||
|
(candidate.innerText || "").includes(arguments[0])
|
||||||
|
);
|
||||||
|
return row?.querySelector("td:last-child button") ?? null;
|
||||||
|
`,
|
||||||
|
[group.name],
|
||||||
|
);
|
||||||
|
assert.ok(editButton, "Extension group edit control was not visible");
|
||||||
|
await app.session.click(editButton);
|
||||||
|
await app.waitForText("Edit Group");
|
||||||
|
const extensionPicker = await app.execute(`
|
||||||
|
const dialogs = [...document.querySelectorAll('[role="dialog"]')];
|
||||||
|
return dialogs.reverse().find(
|
||||||
|
(dialog) => (dialog.innerText || "").includes("Edit Group")
|
||||||
|
)?.querySelector('[role="combobox"]') ?? null;
|
||||||
|
`);
|
||||||
|
assert.ok(extensionPicker, "Extension picker was not visible");
|
||||||
|
await app.session.click(extensionPicker);
|
||||||
|
await app.clickText(extension.name, { roles: ["option"] });
|
||||||
|
await app.clickTextIn('[role="dialog"]', "Save", { roles: ["button"] });
|
||||||
|
await app.waitFor(
|
||||||
|
async () => {
|
||||||
|
groups = await app.invoke("list_extension_groups");
|
||||||
|
group = groups.find((item) => item.name === "Visible Extension Group");
|
||||||
|
return group?.extension_ids.includes(extension.id);
|
||||||
|
},
|
||||||
|
{ description: "uploaded extension added to visible extension group" },
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
extension,
|
||||||
|
group,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProfileThroughUi(app, groupName) {
|
||||||
|
await app.clickSelector('[aria-label="Profiles"]');
|
||||||
|
await app.clickText(groupName, { exact: false, roles: ["button"] });
|
||||||
|
await app.clickText("New", { roles: ["button"] });
|
||||||
|
await app.waitFor(
|
||||||
|
async () => {
|
||||||
|
const text = await app.bodyText();
|
||||||
|
return (
|
||||||
|
text.includes("Create New Profile") ||
|
||||||
|
text.includes("Create New Chromium Profile")
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ description: "profile creation dialog" },
|
||||||
|
);
|
||||||
|
if (!(await app.visibleTextIncludes("Create New Chromium Profile"))) {
|
||||||
|
await app.clickText("Chromium", { exact: false, roles: ["button"] });
|
||||||
|
await app.waitForText("Create New Chromium Profile");
|
||||||
|
}
|
||||||
|
await app.fillSelector("#profile-name", "Visible Network Profile");
|
||||||
|
await app.clickTextIn('[role="dialog"]', "Create", { roles: ["button"] });
|
||||||
|
await app.waitForText("Visible Network Profile", 60_000);
|
||||||
|
await app.waitFor(
|
||||||
|
async () =>
|
||||||
|
!(await app.execute(`
|
||||||
|
return [...document.querySelectorAll('[role="dialog"]')].some(
|
||||||
|
(dialog) =>
|
||||||
|
(dialog.innerText || "").includes("Create New Chromium Profile")
|
||||||
|
);
|
||||||
|
`)),
|
||||||
|
{ description: "profile creation dialog to unmount" },
|
||||||
|
);
|
||||||
|
const profiles = await app.invoke("list_browser_profiles");
|
||||||
|
return profiles.find((profile) => profile.name === "Visible Network Profile");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignNetworkThroughUi(app, profileName, currentName, newName) {
|
||||||
|
const trigger = await app.execute(
|
||||||
|
`
|
||||||
|
const row = [...document.querySelectorAll("tr")].find((candidate) =>
|
||||||
|
(candidate.innerText || "").includes(arguments[0])
|
||||||
|
);
|
||||||
|
const expected = arguments[1].toLocaleLowerCase();
|
||||||
|
return [...(row?.querySelectorAll('[aria-haspopup="dialog"]') ?? [])].find(
|
||||||
|
(trigger) => (trigger.innerText || trigger.textContent || "")
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.includes(expected)
|
||||||
|
) ?? null;
|
||||||
|
`,
|
||||||
|
[profileName, currentName],
|
||||||
|
);
|
||||||
|
assert.ok(trigger, `Network selector for ${profileName} was not visible`);
|
||||||
|
await app.session.click(trigger);
|
||||||
|
await app.clickText(newName, { exact: false, roles: ["option"] });
|
||||||
|
await app.waitFor(
|
||||||
|
() =>
|
||||||
|
app.execute(
|
||||||
|
`
|
||||||
|
return ![...document.querySelectorAll('[data-slot="popover-content"]')]
|
||||||
|
.some((content) => (content.innerText || "").includes(arguments[0]));
|
||||||
|
`,
|
||||||
|
[newName],
|
||||||
|
),
|
||||||
|
{ description: `${newName} network picker to unmount` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assignExtensionGroupThroughUi(
|
||||||
|
app,
|
||||||
|
profileName,
|
||||||
|
currentName,
|
||||||
|
newName,
|
||||||
|
) {
|
||||||
|
const trigger = await app.execute(
|
||||||
|
`
|
||||||
|
const row = [...document.querySelectorAll("tr")].find((candidate) =>
|
||||||
|
(candidate.innerText || "").includes(arguments[0])
|
||||||
|
);
|
||||||
|
return [...(row?.querySelectorAll("button") ?? [])].find(
|
||||||
|
(button) => (button.innerText || button.textContent || "")
|
||||||
|
.trim()
|
||||||
|
.includes(arguments[1])
|
||||||
|
) ?? null;
|
||||||
|
`,
|
||||||
|
[profileName, currentName],
|
||||||
|
);
|
||||||
|
assert.ok(trigger, `Extension selector for ${profileName} was not visible`);
|
||||||
|
await app.session.click(trigger);
|
||||||
|
await app.clickText(newName, { exact: false, roles: ["option"] });
|
||||||
|
await app.waitFor(
|
||||||
|
() =>
|
||||||
|
app.execute(
|
||||||
|
`
|
||||||
|
return ![...document.querySelectorAll('[data-slot="popover-content"]')]
|
||||||
|
.some((content) => (content.innerText || "").includes(arguments[0]));
|
||||||
|
`,
|
||||||
|
[newName],
|
||||||
|
),
|
||||||
|
{ description: `${newName} extension picker to unmount` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runProfile(_app, base, token, profileId, url) {
|
||||||
|
const launched = await request(`${base}/v1/profiles/${profileId}/run`, {
|
||||||
|
method: "POST",
|
||||||
|
token,
|
||||||
|
body: { url, headless: true },
|
||||||
|
});
|
||||||
|
assert.equal(launched.response.status, 200, JSON.stringify(launched.value));
|
||||||
|
const cdp = await CdpClient.connect(launched.value.remote_debugging_port);
|
||||||
|
return { launched: launched.value, cdp };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stopProfile(app, base, token, profileId, cdp) {
|
||||||
|
cdp.close();
|
||||||
|
const stopped = await request(`${base}/v1/profiles/${profileId}/kill`, {
|
||||||
|
method: "POST",
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
assert.equal(stopped.response.status, 204);
|
||||||
|
await app.waitFor(
|
||||||
|
async () => {
|
||||||
|
const profile = (await app.invoke("list_browser_profiles")).find(
|
||||||
|
(item) => item.id === profileId,
|
||||||
|
);
|
||||||
|
return !profile?.process_id;
|
||||||
|
},
|
||||||
|
{ timeoutMs: 20_000, description: "network profile process cleanup" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertProxyWorkerLogsRedacted(app, settings) {
|
||||||
|
const files = (await readdir(path.join(app.root, "tmp"))).filter(
|
||||||
|
(file) => file.startsWith("donut-proxy-") && file.endsWith(".log"),
|
||||||
|
);
|
||||||
|
assert.ok(files.length > 0, "No proxy worker diagnostic logs were created");
|
||||||
|
const contents = (
|
||||||
|
await Promise.all(
|
||||||
|
files.map((file) => readFile(path.join(app.root, "tmp", file), "utf8")),
|
||||||
|
)
|
||||||
|
).join("\n");
|
||||||
|
for (const item of settings) {
|
||||||
|
if (!item.username) continue;
|
||||||
|
const rawAuth = `${item.username}:${item.password ?? ""}@`;
|
||||||
|
const encodedAuth = `${encodeURIComponent(item.username)}:${encodeURIComponent(item.password ?? "")}@`;
|
||||||
|
assert.equal(
|
||||||
|
contents.includes(rawAuth) || contents.includes(encodedAuth),
|
||||||
|
false,
|
||||||
|
"Proxy worker logs exposed upstream credentials",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function wireGuardTargetWasReached() {
|
||||||
|
const container = process.env.DONUT_E2E_WIREGUARD_CONTAINER;
|
||||||
|
assert.ok(container, "WireGuard fixture container name is required");
|
||||||
|
return (
|
||||||
|
spawnSync(
|
||||||
|
"docker",
|
||||||
|
[
|
||||||
|
"exec",
|
||||||
|
container,
|
||||||
|
"grep",
|
||||||
|
"-q",
|
||||||
|
"GET /donut-e2e-wireguard ",
|
||||||
|
"/tmp/donut-e2e-target-requests",
|
||||||
|
],
|
||||||
|
{ stdio: "ignore", timeout: 2_000 },
|
||||||
|
).status === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("visible UI creates and assigns profiles, groups, proxies, VPNs, extensions, and extension groups", async () => {
|
||||||
|
const httpSettings = proxySettings(
|
||||||
|
process.env.RESIDENTIAL_PROXY_URL_ONE_HTTP,
|
||||||
|
"HTTP",
|
||||||
|
);
|
||||||
|
const socksSettings = proxySettings(
|
||||||
|
process.env.RESIDENTIAL_PROXY_URL_ONE_SOCKS,
|
||||||
|
"SOCKS",
|
||||||
|
);
|
||||||
|
const realWireGuardConfig = process.env.DONUT_E2E_WIREGUARD_CONFIG_BASE64
|
||||||
|
? Buffer.from(
|
||||||
|
process.env.DONUT_E2E_WIREGUARD_CONFIG_BASE64,
|
||||||
|
"base64",
|
||||||
|
).toString("utf8")
|
||||||
|
: null;
|
||||||
|
const app = appFromEnvironment("network-visible-ui", {
|
||||||
|
wayfernTermsAccepted: false,
|
||||||
|
});
|
||||||
|
let apiPort;
|
||||||
|
let activeCdp;
|
||||||
|
let activeVpnId;
|
||||||
|
try {
|
||||||
|
const prepared = await prepareWayfern(
|
||||||
|
app,
|
||||||
|
process.env.DONUT_E2E_PROJECT_ROOT,
|
||||||
|
);
|
||||||
|
if (!app.session) await app.start();
|
||||||
|
if (!(await app.invoke("check_wayfern_terms_accepted"))) {
|
||||||
|
await app.invoke("accept_wayfern_terms");
|
||||||
|
await app.restart();
|
||||||
|
}
|
||||||
|
assert.equal(
|
||||||
|
await app.visibleTextIncludes("Welcome to Donut Browser"),
|
||||||
|
false,
|
||||||
|
"completed test sessions must not leave the Welcome dialog over the UI",
|
||||||
|
);
|
||||||
|
const group = await createGroupThroughUi(app);
|
||||||
|
assert.ok(group);
|
||||||
|
await app.capture("01-profile-group-created");
|
||||||
|
|
||||||
|
const httpProxy = await createProxyThroughUi(app, httpSettings);
|
||||||
|
assert.ok(httpProxy);
|
||||||
|
assert.equal(
|
||||||
|
httpProxy.proxy_settings.proxy_type === httpSettings.proxy_type &&
|
||||||
|
httpProxy.proxy_settings.host === httpSettings.host &&
|
||||||
|
httpProxy.proxy_settings.port === httpSettings.port &&
|
||||||
|
httpProxy.proxy_settings.username === httpSettings.username &&
|
||||||
|
httpProxy.proxy_settings.password === httpSettings.password,
|
||||||
|
true,
|
||||||
|
"The HTTP proxy created through the UI did not preserve its settings",
|
||||||
|
);
|
||||||
|
const vpn = await createVpnThroughUi(
|
||||||
|
app,
|
||||||
|
realWireGuardConfig ?? wireGuardFixture(),
|
||||||
|
);
|
||||||
|
assert.ok(vpn);
|
||||||
|
activeVpnId = vpn.id;
|
||||||
|
await app.capture("02-proxy-and-vpn-created");
|
||||||
|
|
||||||
|
const extensionEntities = await createExtensionsThroughUi(app);
|
||||||
|
assert.ok(extensionEntities.extension);
|
||||||
|
assert.ok(extensionEntities.group);
|
||||||
|
await app.capture("03-extension-and-group-created");
|
||||||
|
|
||||||
|
const profile = await createProfileThroughUi(app, group.name);
|
||||||
|
assert.ok(profile);
|
||||||
|
assert.equal(profile.version, prepared.version);
|
||||||
|
assert.equal(profile.group_id, group.id);
|
||||||
|
await assignExtensionGroupThroughUi(
|
||||||
|
app,
|
||||||
|
profile.name,
|
||||||
|
"Default",
|
||||||
|
extensionEntities.group.name,
|
||||||
|
);
|
||||||
|
await app.waitFor(
|
||||||
|
async () =>
|
||||||
|
(await app.invoke("list_browser_profiles")).find(
|
||||||
|
(item) => item.id === profile.id,
|
||||||
|
)?.extension_group_id === extensionEntities.group.id,
|
||||||
|
{ description: "extension group assignment persisted" },
|
||||||
|
);
|
||||||
|
await app.capture("04-profile-created");
|
||||||
|
|
||||||
|
const socksProxy = await app.invoke("create_stored_proxy", {
|
||||||
|
name: "Residential SOCKS5",
|
||||||
|
proxySettings: socksSettings,
|
||||||
|
});
|
||||||
|
const [httpCheck, socksCheck] = await Promise.all([
|
||||||
|
app.invoke("check_proxy_validity", {
|
||||||
|
proxyId: httpProxy.id,
|
||||||
|
proxySettings: null,
|
||||||
|
}),
|
||||||
|
app.invoke("check_proxy_validity", {
|
||||||
|
proxyId: socksProxy.id,
|
||||||
|
proxySettings: null,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
assert.equal(httpCheck.is_valid, true);
|
||||||
|
assert.equal(socksCheck.is_valid, true);
|
||||||
|
assert.ok(isIP(httpCheck.ip));
|
||||||
|
assert.ok(isIP(socksCheck.ip));
|
||||||
|
|
||||||
|
await assignNetworkThroughUi(
|
||||||
|
app,
|
||||||
|
profile.name,
|
||||||
|
"Not selected",
|
||||||
|
httpProxy.name,
|
||||||
|
);
|
||||||
|
await app.waitFor(
|
||||||
|
async () =>
|
||||||
|
(await app.invoke("list_browser_profiles")).find(
|
||||||
|
(item) => item.id === profile.id,
|
||||||
|
)?.proxy_id === httpProxy.id,
|
||||||
|
{ description: "HTTP proxy assignment persisted" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = await app.invoke("get_app_settings");
|
||||||
|
const saved = await app.invoke("save_app_settings", {
|
||||||
|
settings: {
|
||||||
|
...settings,
|
||||||
|
api_enabled: true,
|
||||||
|
api_port: 0,
|
||||||
|
api_token: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
apiPort = await app.invoke("start_api_server", { port: 0 });
|
||||||
|
const base = `http://127.0.0.1:${apiPort}`;
|
||||||
|
|
||||||
|
const proxied = await runProfile(
|
||||||
|
app,
|
||||||
|
base,
|
||||||
|
saved.api_token,
|
||||||
|
profile.id,
|
||||||
|
"https://api.ipify.org/",
|
||||||
|
);
|
||||||
|
activeCdp = proxied.cdp;
|
||||||
|
const browserExitIp = await activeCdp.waitFor(
|
||||||
|
`(() => {
|
||||||
|
const value = document.body?.innerText?.trim() ?? "";
|
||||||
|
return /^[0-9a-f:.]+$/i.test(value) ? value : false;
|
||||||
|
})()`,
|
||||||
|
{ timeoutMs: 30_000, description: "Wayfern residential proxy exit IP" },
|
||||||
|
);
|
||||||
|
assert.ok(isIP(browserExitIp));
|
||||||
|
await stopProfile(app, base, saved.api_token, profile.id, activeCdp);
|
||||||
|
activeCdp = null;
|
||||||
|
await assertProxyWorkerLogsRedacted(app, [httpSettings, socksSettings]);
|
||||||
|
|
||||||
|
await assignNetworkThroughUi(app, profile.name, httpProxy.name, vpn.name);
|
||||||
|
await app.waitFor(
|
||||||
|
async () =>
|
||||||
|
(await app.invoke("list_browser_profiles")).find(
|
||||||
|
(item) => item.id === profile.id,
|
||||||
|
)?.vpn_id === vpn.id,
|
||||||
|
{ description: "WireGuard assignment persisted" },
|
||||||
|
);
|
||||||
|
await app.capture("05-proxy-and-vpn-assigned");
|
||||||
|
|
||||||
|
if (realWireGuardConfig) {
|
||||||
|
const tunneled = await runProfile(
|
||||||
|
app,
|
||||||
|
base,
|
||||||
|
saved.api_token,
|
||||||
|
profile.id,
|
||||||
|
process.env.DONUT_E2E_WIREGUARD_TARGET_URL,
|
||||||
|
);
|
||||||
|
activeCdp = tunneled.cdp;
|
||||||
|
await app.waitFor(wireGuardTargetWasReached, {
|
||||||
|
timeoutMs: 30_000,
|
||||||
|
description: "Wayfern GET through local WireGuard peer",
|
||||||
|
});
|
||||||
|
await stopProfile(app, base, saved.api_token, profile.id, activeCdp);
|
||||||
|
activeCdp = null;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await app.capture("failure");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
activeCdp?.close();
|
||||||
|
if (app.session) {
|
||||||
|
if (apiPort) await app.invoke("stop_api_server").catch(() => {});
|
||||||
|
for (const profile of await app
|
||||||
|
.invoke("list_browser_profiles")
|
||||||
|
.catch(() => [])) {
|
||||||
|
if (profile.process_id) {
|
||||||
|
await app.invoke("kill_browser_profile", { profile }).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (activeVpnId) {
|
||||||
|
await app
|
||||||
|
.invoke("disconnect_vpn", { vpnId: activeVpnId })
|
||||||
|
.catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { access, readFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { appFromEnvironment, withApp } from "../lib/app.mjs";
|
||||||
|
|
||||||
|
test("fresh app renders, completes onboarding, persists settings, and never touches real app roots", async () => {
|
||||||
|
await withApp(
|
||||||
|
"smoke-fresh",
|
||||||
|
async (app) => {
|
||||||
|
assert.equal(typeof (await app.session.title()), "string");
|
||||||
|
assert.match(await app.bodyText(), /New/);
|
||||||
|
await app.waitForText("No profiles yet");
|
||||||
|
|
||||||
|
const initial = await app.invoke("get_app_settings");
|
||||||
|
assert.equal(typeof initial.onboarding_completed, "boolean");
|
||||||
|
await app.invoke("complete_onboarding");
|
||||||
|
assert.equal(await app.invoke("get_onboarding_completed"), true);
|
||||||
|
await app.invoke("dismiss_window_resize_warning");
|
||||||
|
assert.equal(
|
||||||
|
await app.invoke("get_window_resize_warning_dismissed"),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
|
||||||
|
const saved = await app.invoke("save_app_settings", {
|
||||||
|
settings: {
|
||||||
|
...initial,
|
||||||
|
theme: "dark",
|
||||||
|
language: "en",
|
||||||
|
onboarding_completed: true,
|
||||||
|
disable_auto_updates: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(saved.theme, "dark");
|
||||||
|
assert.equal(saved.language, "en");
|
||||||
|
|
||||||
|
await app.invoke("save_table_sorting_settings", {
|
||||||
|
sorting: { column: "browser", direction: "desc" },
|
||||||
|
});
|
||||||
|
assert.deepEqual(await app.invoke("get_table_sorting_settings"), {
|
||||||
|
column: "browser",
|
||||||
|
direction: "desc",
|
||||||
|
});
|
||||||
|
assert.ok((await app.invoke("get_system_language")).length >= 2);
|
||||||
|
const system = await app.invoke("get_system_info");
|
||||||
|
assert.ok(system && typeof system === "object");
|
||||||
|
assert.equal(typeof (await app.invoke("read_log_files")), "string");
|
||||||
|
|
||||||
|
await app.restart();
|
||||||
|
const afterRestart = await app.invoke("get_app_settings");
|
||||||
|
assert.equal(afterRestart.theme, "dark");
|
||||||
|
assert.equal(afterRestart.language, "en");
|
||||||
|
assert.equal(afterRestart.onboarding_completed, true);
|
||||||
|
|
||||||
|
const settingsFile = path.join(
|
||||||
|
app.dataRoot,
|
||||||
|
"data",
|
||||||
|
"settings",
|
||||||
|
"app_settings.json",
|
||||||
|
);
|
||||||
|
await access(settingsFile);
|
||||||
|
const persisted = JSON.parse(await readFile(settingsFile, "utf8"));
|
||||||
|
assert.equal(persisted.api_token, null);
|
||||||
|
assert.equal(persisted.mcp_token, null);
|
||||||
|
},
|
||||||
|
{ onboardingCompleted: false },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("two isolated sessions run concurrently and do not share frontend or backend state", async () => {
|
||||||
|
const first = appFromEnvironment("smoke-isolation-a");
|
||||||
|
const second = appFromEnvironment("smoke-isolation-b");
|
||||||
|
try {
|
||||||
|
await Promise.all([first.start(), second.start()]);
|
||||||
|
const firstSettings = await first.invoke("get_app_settings");
|
||||||
|
await first.invoke("save_app_settings", {
|
||||||
|
settings: { ...firstSettings, theme: "dark", onboarding_completed: true },
|
||||||
|
});
|
||||||
|
const secondSettings = await second.invoke("get_app_settings");
|
||||||
|
assert.equal(secondSettings.theme, "system");
|
||||||
|
assert.notEqual(secondSettings.theme, "dark");
|
||||||
|
|
||||||
|
await first.execute("localStorage.setItem('donut-e2e-only-a', 'yes');");
|
||||||
|
assert.equal(
|
||||||
|
await second.execute("return localStorage.getItem('donut-e2e-only-a');"),
|
||||||
|
null,
|
||||||
|
"native WebView data leaked across sessions",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await Promise.all([first.capture("failure"), second.capture("failure")]);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await Promise.all([first.close(), second.close()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("keyboard command palette and major navigation surfaces are operable through native WebDriver", async () => {
|
||||||
|
await withApp("smoke-ui", async (app) => {
|
||||||
|
const modifier =
|
||||||
|
process.platform === "darwin" ? { meta: true } : { ctrl: true };
|
||||||
|
await app.waitFor(
|
||||||
|
async () => {
|
||||||
|
await app.pressShortcut({ key: "k", ...modifier });
|
||||||
|
return app.execute(
|
||||||
|
`return Boolean(document.querySelector("[cmdk-input][placeholder='Type a command or search...']"));`,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
{ description: "open command palette" },
|
||||||
|
);
|
||||||
|
|
||||||
|
const input = await app.session.findCss("[cmdk-input]");
|
||||||
|
await app.session.sendKeys(input, "settings");
|
||||||
|
const body = await app.bodyText();
|
||||||
|
assert.match(body, /Settings/i);
|
||||||
|
|
||||||
|
// Exercise native WebDriver element marshalling and click, not just script execution.
|
||||||
|
const close = await app.execute(
|
||||||
|
`return [...document.querySelectorAll("button")].find(
|
||||||
|
(button) => /close/i.test(button.getAttribute("aria-label") || button.textContent || "")
|
||||||
|
) ?? null;`,
|
||||||
|
);
|
||||||
|
if (close) {
|
||||||
|
await app.session.click(close);
|
||||||
|
} else {
|
||||||
|
await app.pressShortcut({ key: "Escape" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tray labels, hide-to-tray, and confirmed quit follow the native lifecycle", async () => {
|
||||||
|
const app = appFromEnvironment("smoke-lifecycle");
|
||||||
|
try {
|
||||||
|
await app.start();
|
||||||
|
await app.invoke("update_tray_menu", {
|
||||||
|
showLabel: "Show Donut E2E",
|
||||||
|
quitLabel: "Quit Donut E2E",
|
||||||
|
});
|
||||||
|
await app.invoke("hide_to_tray");
|
||||||
|
assert.equal(
|
||||||
|
typeof (await app.invoke("get_onboarding_completed")),
|
||||||
|
"boolean",
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.restart();
|
||||||
|
const exitingSession = app.session;
|
||||||
|
await app
|
||||||
|
.execute(
|
||||||
|
`window.__TAURI_INTERNALS__.invoke("confirm_quit").catch(() => {});
|
||||||
|
return true;`,
|
||||||
|
)
|
||||||
|
.catch(() => {});
|
||||||
|
await app.waitFor(
|
||||||
|
async () => {
|
||||||
|
try {
|
||||||
|
await exitingSession.title();
|
||||||
|
return false;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ timeoutMs: 10_000, description: "confirmed app exit" },
|
||||||
|
);
|
||||||
|
app.session = null;
|
||||||
|
await exitingSession.close().catch(() => {});
|
||||||
|
} catch (error) {
|
||||||
|
await app.capture("failure");
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await app.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,577 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||||
|
import path from "node:path";
|
||||||
|
import test from "node:test";
|
||||||
|
import { appFromEnvironment } from "../lib/app.mjs";
|
||||||
|
import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
|
||||||
|
|
||||||
|
const syncUrl = process.env.DONUT_E2E_SYNC_URL;
|
||||||
|
const syncToken = process.env.DONUT_E2E_SYNC_TOKEN;
|
||||||
|
|
||||||
|
async function syncRequest(endpoint, body) {
|
||||||
|
const response = await fetch(`${syncUrl}/v1/objects/${endpoint}`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
authorization: `Bearer ${syncToken}`,
|
||||||
|
"content-type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`Sync ${endpoint} failed with HTTP ${response.status}: ${text}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return text ? JSON.parse(text) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listRemote(prefix = "") {
|
||||||
|
const result = await syncRequest("list", {
|
||||||
|
prefix,
|
||||||
|
maxKeys: 1000,
|
||||||
|
continuationToken: null,
|
||||||
|
});
|
||||||
|
return result.objects;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadRemote(key) {
|
||||||
|
const presigned = await syncRequest("presign-download", {
|
||||||
|
key,
|
||||||
|
expiresIn: 300,
|
||||||
|
});
|
||||||
|
const response = await fetch(presigned.url);
|
||||||
|
assert.equal(response.status, 200, `Could not download remote object ${key}`);
|
||||||
|
return Buffer.from(await response.arrayBuffer());
|
||||||
|
}
|
||||||
|
|
||||||
|
async function configureSync(app) {
|
||||||
|
const saved = await app.invoke("save_sync_settings", {
|
||||||
|
syncServerUrl: syncUrl,
|
||||||
|
syncToken,
|
||||||
|
});
|
||||||
|
assert.equal(saved.sync_server_url, syncUrl);
|
||||||
|
assert.equal(saved.sync_token, syncToken);
|
||||||
|
assert.deepEqual(await app.invoke("get_sync_settings"), saved);
|
||||||
|
await app.invoke("restart_sync_service");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 750));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createProfile(app, name) {
|
||||||
|
return app.invoke("create_browser_profile_new", {
|
||||||
|
name,
|
||||||
|
browserStr: "wayfern",
|
||||||
|
version: "150.0.7871.100",
|
||||||
|
releaseType: "stable",
|
||||||
|
proxyId: null,
|
||||||
|
vpnId: null,
|
||||||
|
// Keep sync tests deterministic and network-free; browser.test.mjs covers
|
||||||
|
// generation through the real Wayfern binary.
|
||||||
|
wayfernConfig: { fingerprint: "{}" },
|
||||||
|
groupId: null,
|
||||||
|
ephemeral: false,
|
||||||
|
dnsBlocklist: null,
|
||||||
|
launchHook: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitFor(app, callback, description, timeoutMs = 45_000) {
|
||||||
|
return app.waitFor(callback, { description, timeoutMs, intervalMs: 250 });
|
||||||
|
}
|
||||||
|
|
||||||
|
test("two real app devices reconcile profile files and every config entity with last-write-wins", async () => {
|
||||||
|
assert.ok(syncUrl && syncToken, "Sync infrastructure was not started");
|
||||||
|
const deviceA = appFromEnvironment("sync-regular-a");
|
||||||
|
const deviceB = appFromEnvironment("sync-regular-b");
|
||||||
|
try {
|
||||||
|
await Promise.all([deviceA.start(), deviceB.start()]);
|
||||||
|
await Promise.all([configureSync(deviceA), configureSync(deviceB)]);
|
||||||
|
|
||||||
|
const group = await deviceA.invoke("create_profile_group", {
|
||||||
|
name: "Synced Group A",
|
||||||
|
});
|
||||||
|
const proxy = await deviceA.invoke("create_stored_proxy", {
|
||||||
|
name: "Synced Proxy A",
|
||||||
|
proxySettings: {
|
||||||
|
proxy_type: "http",
|
||||||
|
host: "127.0.0.1",
|
||||||
|
port: 8089,
|
||||||
|
username: null,
|
||||||
|
password: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const vpn = await deviceA.invoke("create_vpn_config_manual", {
|
||||||
|
name: "Synced VPN A",
|
||||||
|
vpnType: "WireGuard",
|
||||||
|
configData: wireGuardFixture(),
|
||||||
|
});
|
||||||
|
const extension = await deviceA.invoke("add_extension", {
|
||||||
|
name: "Synced Extension A",
|
||||||
|
fileName: "synced-fixture.zip",
|
||||||
|
fileData: [...Buffer.from(extensionZipBase64(), "base64")],
|
||||||
|
});
|
||||||
|
const extensionGroup = await deviceA.invoke("create_extension_group", {
|
||||||
|
name: "Synced Extension Group A",
|
||||||
|
});
|
||||||
|
await deviceA.invoke("add_extension_to_group", {
|
||||||
|
groupId: extensionGroup.id,
|
||||||
|
extensionId: extension.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
deviceA.invoke("set_group_sync_enabled", {
|
||||||
|
groupId: group.id,
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
deviceA.invoke("set_proxy_sync_enabled", {
|
||||||
|
proxyId: proxy.id,
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
deviceA.invoke("set_vpn_sync_enabled", { vpnId: vpn.id, enabled: true }),
|
||||||
|
deviceA.invoke("set_extension_sync_enabled", {
|
||||||
|
extensionId: extension.id,
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
deviceA.invoke("set_extension_group_sync_enabled", {
|
||||||
|
extensionGroupId: extensionGroup.id,
|
||||||
|
enabled: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const profile = await createProfile(deviceA, "Synced Profile A");
|
||||||
|
const profileData = path.join(
|
||||||
|
deviceA.dataRoot,
|
||||||
|
"data",
|
||||||
|
"profiles",
|
||||||
|
profile.id,
|
||||||
|
"profile",
|
||||||
|
"Default",
|
||||||
|
);
|
||||||
|
await mkdir(profileData, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(profileData, "Preferences"),
|
||||||
|
JSON.stringify({ donutE2E: "regular-profile-payload" }),
|
||||||
|
);
|
||||||
|
await deviceA.invoke("update_profile_tags", {
|
||||||
|
profileId: profile.id,
|
||||||
|
tags: ["sync", "device-a"],
|
||||||
|
});
|
||||||
|
await deviceA.invoke("update_profile_note", {
|
||||||
|
profileId: profile.id,
|
||||||
|
note: "regular sync metadata",
|
||||||
|
});
|
||||||
|
await deviceA.invoke("set_profile_sync_mode", {
|
||||||
|
profileId: profile.id,
|
||||||
|
syncMode: "Regular",
|
||||||
|
});
|
||||||
|
await deviceA.invoke("request_profile_sync", { profileId: profile.id });
|
||||||
|
assert.equal(
|
||||||
|
await deviceA.invoke("cancel_profile_sync", {
|
||||||
|
profileId: "not-running-sync",
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
await waitFor(
|
||||||
|
deviceA,
|
||||||
|
async () => {
|
||||||
|
const keys = (await listRemote("")).map((object) => object.key);
|
||||||
|
return [
|
||||||
|
`groups/${group.id}.json`,
|
||||||
|
`proxies/${proxy.id}.json`,
|
||||||
|
`vpns/${vpn.id}.json`,
|
||||||
|
`extensions/${extension.id}.json`,
|
||||||
|
`extension_groups/${extensionGroup.id}.json`,
|
||||||
|
`profiles/${profile.id}/manifest.json`,
|
||||||
|
`profiles/${profile.id}/files/profile/Default/Preferences`,
|
||||||
|
].every((key) => keys.includes(key));
|
||||||
|
},
|
||||||
|
"all regular entities uploaded",
|
||||||
|
);
|
||||||
|
|
||||||
|
await deviceB.invoke("restart_sync_service");
|
||||||
|
await waitFor(
|
||||||
|
deviceB,
|
||||||
|
async () => {
|
||||||
|
const [profiles, groups, proxies, vpns, extensions, extensionGroups] =
|
||||||
|
await Promise.all([
|
||||||
|
deviceB.invoke("list_browser_profiles"),
|
||||||
|
deviceB.invoke("get_profile_groups"),
|
||||||
|
deviceB.invoke("get_stored_proxies"),
|
||||||
|
deviceB.invoke("list_vpn_configs"),
|
||||||
|
deviceB.invoke("list_extensions"),
|
||||||
|
deviceB.invoke("list_extension_groups"),
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
profiles.some((item) => item.id === profile.id) &&
|
||||||
|
groups.some((item) => item.id === group.id) &&
|
||||||
|
proxies.some((item) => item.id === proxy.id) &&
|
||||||
|
vpns.some((item) => item.id === vpn.id) &&
|
||||||
|
extensions.some((item) => item.id === extension.id) &&
|
||||||
|
extensionGroups.some((item) => item.id === extensionGroup.id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"device B receives every entity",
|
||||||
|
);
|
||||||
|
const downloadedPreferences = path.join(
|
||||||
|
deviceB.dataRoot,
|
||||||
|
"data",
|
||||||
|
"profiles",
|
||||||
|
profile.id,
|
||||||
|
"profile",
|
||||||
|
"Default",
|
||||||
|
"Preferences",
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
deviceB,
|
||||||
|
async () =>
|
||||||
|
(
|
||||||
|
await readFile(downloadedPreferences, "utf8").catch(() => "")
|
||||||
|
).includes("regular-profile-payload"),
|
||||||
|
"device B receives profile browser files",
|
||||||
|
);
|
||||||
|
|
||||||
|
// updated_at has one-second resolution. Make the device-B edits
|
||||||
|
// unambiguously newer, then verify last-write-wins in both directions.
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 1_100));
|
||||||
|
await deviceB.invoke("update_stored_proxy", {
|
||||||
|
proxyId: proxy.id,
|
||||||
|
name: "Synced Proxy B Wins",
|
||||||
|
proxySettings: null,
|
||||||
|
});
|
||||||
|
await deviceB.invoke("rename_profile", {
|
||||||
|
profileId: profile.id,
|
||||||
|
newName: "Synced Profile B Wins",
|
||||||
|
});
|
||||||
|
await deviceB.invoke("request_profile_sync", { profileId: profile.id });
|
||||||
|
await deviceA.invoke("restart_sync_service");
|
||||||
|
await waitFor(
|
||||||
|
deviceA,
|
||||||
|
async () => {
|
||||||
|
const proxies = await deviceA.invoke("get_stored_proxies");
|
||||||
|
const profiles = await deviceA.invoke("list_browser_profiles");
|
||||||
|
return (
|
||||||
|
proxies.find((item) => item.id === proxy.id)?.name ===
|
||||||
|
"Synced Proxy B Wins" &&
|
||||||
|
profiles.find((item) => item.id === profile.id)?.name ===
|
||||||
|
"Synced Profile B Wins"
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"newer device-B edits win on device A",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.equal(
|
||||||
|
await deviceA.invoke("is_proxy_in_use_by_synced_profile", {
|
||||||
|
proxyId: proxy.id,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await deviceA.invoke("is_group_in_use_by_synced_profile", {
|
||||||
|
groupId: group.id,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await deviceA.invoke("is_vpn_in_use_by_synced_profile", {
|
||||||
|
vpnId: vpn.id,
|
||||||
|
}),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const counts = await deviceA.invoke("get_unsynced_entity_counts");
|
||||||
|
assert.equal(typeof counts.proxies, "number");
|
||||||
|
await deviceA.invoke("enable_sync_for_all_entities");
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
deviceB.invoke("delete_extension_group", {
|
||||||
|
groupId: extensionGroup.id,
|
||||||
|
}),
|
||||||
|
deviceB.invoke("delete_extension", { extensionId: extension.id }),
|
||||||
|
deviceB.invoke("delete_vpn_config", { vpnId: vpn.id }),
|
||||||
|
deviceB.invoke("delete_profile_group", { groupId: group.id }),
|
||||||
|
deviceB.invoke("delete_stored_proxy", { proxyId: proxy.id }),
|
||||||
|
deviceB.invoke("delete_profile", { profileId: profile.id }),
|
||||||
|
]);
|
||||||
|
await waitFor(
|
||||||
|
deviceB,
|
||||||
|
async () => {
|
||||||
|
const keys = (await listRemote("")).map((object) => object.key);
|
||||||
|
return [
|
||||||
|
`tombstones/groups/${group.id}.json`,
|
||||||
|
`tombstones/proxies/${proxy.id}.json`,
|
||||||
|
`tombstones/vpns/${vpn.id}.json`,
|
||||||
|
`tombstones/extensions/${extension.id}.json`,
|
||||||
|
`tombstones/extension_groups/${extensionGroup.id}.json`,
|
||||||
|
`tombstones/profiles/${profile.id}.json`,
|
||||||
|
].every((key) => keys.includes(key));
|
||||||
|
},
|
||||||
|
"deletions create every remote tombstone",
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
deviceA,
|
||||||
|
async () => {
|
||||||
|
const [profiles, groups, proxies, vpns, extensions, extensionGroups] =
|
||||||
|
await Promise.all([
|
||||||
|
deviceA.invoke("list_browser_profiles"),
|
||||||
|
deviceA.invoke("get_profile_groups"),
|
||||||
|
deviceA.invoke("get_stored_proxies"),
|
||||||
|
deviceA.invoke("list_vpn_configs"),
|
||||||
|
deviceA.invoke("list_extensions"),
|
||||||
|
deviceA.invoke("list_extension_groups"),
|
||||||
|
]);
|
||||||
|
return (
|
||||||
|
!profiles.some((item) => item.id === profile.id) &&
|
||||||
|
!groups.some((item) => item.id === group.id) &&
|
||||||
|
!proxies.some((item) => item.id === proxy.id) &&
|
||||||
|
!vpns.some((item) => item.id === vpn.id) &&
|
||||||
|
!extensions.some((item) => item.id === extension.id) &&
|
||||||
|
!extensionGroups.some((item) => item.id === extensionGroup.id)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"remote tombstones delete every entity from device A",
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
await Promise.all([deviceA.capture("failure"), deviceB.capture("failure")]);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await Promise.all([deviceA.close(), deviceB.close()]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test("global config sealing and encrypted profile sync reject a wrong password, round-trip with the right one, and roll over", async () => {
|
||||||
|
const source = appFromEnvironment("sync-encrypted-source");
|
||||||
|
const receiver = appFromEnvironment("sync-encrypted-receiver");
|
||||||
|
const rolloverReceiver = appFromEnvironment(
|
||||||
|
"sync-encrypted-rollover-receiver",
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
await Promise.all([source.start(), receiver.start()]);
|
||||||
|
await Promise.all([configureSync(source), configureSync(receiver)]);
|
||||||
|
await source.invoke("set_e2e_password", {
|
||||||
|
password: "shared encryption password",
|
||||||
|
});
|
||||||
|
await receiver.invoke("set_e2e_password", {
|
||||||
|
password: "intentionally wrong password",
|
||||||
|
});
|
||||||
|
assert.equal(await source.invoke("check_has_e2e_password"), true);
|
||||||
|
assert.equal(
|
||||||
|
await source.invoke("verify_e2e_password", {
|
||||||
|
password: "shared encryption password",
|
||||||
|
}),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await source.invoke("verify_e2e_password", { password: "wrong" }),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
const sealedProxy = await source.invoke("create_stored_proxy", {
|
||||||
|
name: "SECRET-CONFIG-MARKER",
|
||||||
|
proxySettings: {
|
||||||
|
proxy_type: "http",
|
||||||
|
host: "secret-proxy.invalid",
|
||||||
|
port: 8443,
|
||||||
|
username: "secret-user",
|
||||||
|
password: "secret-password",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await source.invoke("set_proxy_sync_enabled", {
|
||||||
|
proxyId: sealedProxy.id,
|
||||||
|
enabled: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const encryptedProfile = await createProfile(source, "Encrypted Profile");
|
||||||
|
const encryptedData = path.join(
|
||||||
|
source.dataRoot,
|
||||||
|
"data",
|
||||||
|
"profiles",
|
||||||
|
encryptedProfile.id,
|
||||||
|
"profile",
|
||||||
|
);
|
||||||
|
await mkdir(encryptedData, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
path.join(encryptedData, "Local State"),
|
||||||
|
"SECRET-PROFILE-MARKER that must never appear remotely",
|
||||||
|
);
|
||||||
|
await source.invoke("set_profile_sync_mode", {
|
||||||
|
profileId: encryptedProfile.id,
|
||||||
|
syncMode: "Encrypted",
|
||||||
|
});
|
||||||
|
await source.invoke("request_profile_sync", {
|
||||||
|
profileId: encryptedProfile.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const proxyKey = `proxies/${sealedProxy.id}.json`;
|
||||||
|
const profileMetadataKey = `profiles/${encryptedProfile.id}/metadata.json`;
|
||||||
|
const profileFileKey = `profiles/${encryptedProfile.id}/files/profile/Local State`;
|
||||||
|
await waitFor(
|
||||||
|
source,
|
||||||
|
async () => {
|
||||||
|
const keys = (await listRemote("")).map((object) => object.key);
|
||||||
|
return (
|
||||||
|
keys.includes(proxyKey) &&
|
||||||
|
keys.includes(profileMetadataKey) &&
|
||||||
|
keys.includes(profileFileKey)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
"sealed config and encrypted profile uploaded",
|
||||||
|
);
|
||||||
|
const sealedBefore = await downloadRemote(proxyKey);
|
||||||
|
const metadataBefore = await downloadRemote(profileMetadataKey);
|
||||||
|
const encryptedFile = await downloadRemote(profileFileKey);
|
||||||
|
assert.equal(
|
||||||
|
sealedBefore.includes(Buffer.from("SECRET-CONFIG-MARKER")),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert.equal(sealedBefore.includes(Buffer.from("secret-password")), false);
|
||||||
|
assert.equal(
|
||||||
|
encryptedFile.includes(Buffer.from("SECRET-PROFILE-MARKER")),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
const envelope = JSON.parse(sealedBefore.toString("utf8"));
|
||||||
|
assert.equal(envelope.v, 1);
|
||||||
|
assert.ok(envelope.salt && envelope.ct);
|
||||||
|
|
||||||
|
await receiver.invoke("restart_sync_service");
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 2_000));
|
||||||
|
assert.equal(
|
||||||
|
(await receiver.invoke("get_stored_proxies")).some(
|
||||||
|
(item) => item.id === sealedProxy.id,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
"wrong password must not materialize sealed config",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
(await receiver.invoke("list_browser_profiles")).some(
|
||||||
|
(item) => item.id === encryptedProfile.id,
|
||||||
|
),
|
||||||
|
false,
|
||||||
|
"wrong password must not materialize encrypted profiles",
|
||||||
|
);
|
||||||
|
|
||||||
|
await receiver.invoke("set_e2e_password", {
|
||||||
|
password: "shared encryption password",
|
||||||
|
});
|
||||||
|
await receiver.invoke("restart_sync_service");
|
||||||
|
await waitFor(
|
||||||
|
receiver,
|
||||||
|
async () =>
|
||||||
|
(await receiver.invoke("get_stored_proxies")).some(
|
||||||
|
(item) =>
|
||||||
|
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
||||||
|
) &&
|
||||||
|
(await receiver.invoke("list_browser_profiles")).some(
|
||||||
|
(item) => item.id === encryptedProfile.id,
|
||||||
|
),
|
||||||
|
"correct password decrypts config and profile metadata",
|
||||||
|
);
|
||||||
|
const receiverFile = path.join(
|
||||||
|
receiver.dataRoot,
|
||||||
|
"data",
|
||||||
|
"profiles",
|
||||||
|
encryptedProfile.id,
|
||||||
|
"profile",
|
||||||
|
"Local State",
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
receiver,
|
||||||
|
async () =>
|
||||||
|
(await readFile(receiverFile, "utf8").catch(() => "")).includes(
|
||||||
|
"SECRET-PROFILE-MARKER",
|
||||||
|
),
|
||||||
|
"correct password decrypts profile browser file",
|
||||||
|
);
|
||||||
|
|
||||||
|
await source.invoke("set_e2e_password", {
|
||||||
|
password: "rolled encryption password",
|
||||||
|
});
|
||||||
|
await source.invoke("rollover_encryption_for_all_entities");
|
||||||
|
await waitFor(
|
||||||
|
source,
|
||||||
|
async () => {
|
||||||
|
const [proxy, metadata] = await Promise.all([
|
||||||
|
downloadRemote(proxyKey),
|
||||||
|
downloadRemote(profileMetadataKey),
|
||||||
|
]);
|
||||||
|
return !proxy.equals(sealedBefore) && !metadata.equals(metadataBefore);
|
||||||
|
},
|
||||||
|
"password rollover rewrites sealed config and profile metadata",
|
||||||
|
);
|
||||||
|
const sealedAfter = await downloadRemote(proxyKey);
|
||||||
|
assert.equal(
|
||||||
|
sealedAfter.includes(Buffer.from("SECRET-CONFIG-MARKER")),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
await receiver.invoke("set_e2e_password", {
|
||||||
|
password: "rolled encryption password",
|
||||||
|
});
|
||||||
|
await receiver.invoke("restart_sync_service");
|
||||||
|
await waitFor(
|
||||||
|
receiver,
|
||||||
|
async () =>
|
||||||
|
(await receiver.invoke("get_stored_proxies")).some(
|
||||||
|
(item) =>
|
||||||
|
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
||||||
|
),
|
||||||
|
"receiver accepts rolled password",
|
||||||
|
);
|
||||||
|
|
||||||
|
await rolloverReceiver.start();
|
||||||
|
await rolloverReceiver.invoke("set_e2e_password", {
|
||||||
|
password: "rolled encryption password",
|
||||||
|
});
|
||||||
|
await configureSync(rolloverReceiver);
|
||||||
|
await waitFor(
|
||||||
|
rolloverReceiver,
|
||||||
|
async () =>
|
||||||
|
(await rolloverReceiver.invoke("get_stored_proxies")).some(
|
||||||
|
(item) =>
|
||||||
|
item.id === sealedProxy.id && item.name === "SECRET-CONFIG-MARKER",
|
||||||
|
) &&
|
||||||
|
(await rolloverReceiver.invoke("list_browser_profiles")).some(
|
||||||
|
(item) => item.id === encryptedProfile.id,
|
||||||
|
),
|
||||||
|
"fresh receiver decrypts rolled config and profile metadata",
|
||||||
|
);
|
||||||
|
const rolloverFile = path.join(
|
||||||
|
rolloverReceiver.dataRoot,
|
||||||
|
"data",
|
||||||
|
"profiles",
|
||||||
|
encryptedProfile.id,
|
||||||
|
"profile",
|
||||||
|
"Local State",
|
||||||
|
);
|
||||||
|
await waitFor(
|
||||||
|
rolloverReceiver,
|
||||||
|
async () =>
|
||||||
|
(await readFile(rolloverFile, "utf8").catch(() => "")).includes(
|
||||||
|
"SECRET-PROFILE-MARKER",
|
||||||
|
),
|
||||||
|
"fresh receiver decrypts rolled profile browser file",
|
||||||
|
);
|
||||||
|
|
||||||
|
await source.invoke("set_profile_sync_mode", {
|
||||||
|
profileId: encryptedProfile.id,
|
||||||
|
syncMode: "Disabled",
|
||||||
|
});
|
||||||
|
await source.invoke("delete_e2e_password");
|
||||||
|
assert.equal(await source.invoke("check_has_e2e_password"), false);
|
||||||
|
const missingPassword = await source.invokeError("verify_e2e_password", {
|
||||||
|
password: "rolled encryption password",
|
||||||
|
});
|
||||||
|
assert.match(missingPassword, /NO_E2E_PASSWORD_SET/);
|
||||||
|
} catch (error) {
|
||||||
|
await Promise.all([
|
||||||
|
source.capture("failure"),
|
||||||
|
receiver.capture("failure"),
|
||||||
|
rolloverReceiver.capture("failure"),
|
||||||
|
]);
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
await Promise.all([
|
||||||
|
source.close(),
|
||||||
|
receiver.close(),
|
||||||
|
rolloverReceiver.close(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,449 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import test from "node:test";
|
||||||
|
import { withApp } from "../lib/app.mjs";
|
||||||
|
|
||||||
|
const THEME_VARIABLES = [
|
||||||
|
"--background",
|
||||||
|
"--foreground",
|
||||||
|
"--card",
|
||||||
|
"--card-foreground",
|
||||||
|
"--popover",
|
||||||
|
"--popover-foreground",
|
||||||
|
"--primary",
|
||||||
|
"--primary-foreground",
|
||||||
|
"--secondary",
|
||||||
|
"--secondary-foreground",
|
||||||
|
"--muted",
|
||||||
|
"--muted-foreground",
|
||||||
|
"--accent",
|
||||||
|
"--accent-foreground",
|
||||||
|
"--destructive",
|
||||||
|
"--destructive-foreground",
|
||||||
|
"--success",
|
||||||
|
"--success-foreground",
|
||||||
|
"--warning",
|
||||||
|
"--warning-foreground",
|
||||||
|
"--border",
|
||||||
|
"--chart-1",
|
||||||
|
"--chart-2",
|
||||||
|
"--chart-3",
|
||||||
|
"--chart-4",
|
||||||
|
"--chart-5",
|
||||||
|
];
|
||||||
|
|
||||||
|
const DRACULA_THEME = {
|
||||||
|
"--background": "#282a36",
|
||||||
|
"--foreground": "#f8f8f2",
|
||||||
|
"--card": "#44475a",
|
||||||
|
"--card-foreground": "#f8f8f2",
|
||||||
|
"--popover": "#44475a",
|
||||||
|
"--popover-foreground": "#f8f8f2",
|
||||||
|
"--primary": "#bd93f9",
|
||||||
|
"--primary-foreground": "#282a36",
|
||||||
|
"--secondary": "#8be9fd",
|
||||||
|
"--secondary-foreground": "#282a36",
|
||||||
|
"--muted": "#6272a4",
|
||||||
|
"--muted-foreground": "#f8f8f2",
|
||||||
|
"--accent": "#ff79c6",
|
||||||
|
"--accent-foreground": "#282a36",
|
||||||
|
"--destructive": "#ff5555",
|
||||||
|
"--destructive-foreground": "#f8f8f2",
|
||||||
|
"--success": "#50fa7b",
|
||||||
|
"--success-foreground": "#282a36",
|
||||||
|
"--warning": "#ffb86c",
|
||||||
|
"--warning-foreground": "#282a36",
|
||||||
|
"--border": "#6272a4",
|
||||||
|
"--chart-1": "#bd93f9",
|
||||||
|
"--chart-2": "#50fa7b",
|
||||||
|
"--chart-3": "#ff79c6",
|
||||||
|
"--chart-4": "#8be9fd",
|
||||||
|
"--chart-5": "#ffb86c",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function dismissSurface(app) {
|
||||||
|
await app.pressShortcut({ key: "Escape" });
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function themeSnapshot(app) {
|
||||||
|
return app.execute(
|
||||||
|
`
|
||||||
|
const root = document.documentElement;
|
||||||
|
const rootStyle = getComputedStyle(root);
|
||||||
|
const bodyStyle = getComputedStyle(document.body);
|
||||||
|
const variables = arguments[0];
|
||||||
|
return {
|
||||||
|
mode: root.classList.contains("light")
|
||||||
|
? "light"
|
||||||
|
: root.classList.contains("dark")
|
||||||
|
? "dark"
|
||||||
|
: "unset",
|
||||||
|
inline: Object.fromEntries(
|
||||||
|
variables.map((key) => [key, root.style.getPropertyValue(key).trim()])
|
||||||
|
),
|
||||||
|
resolved: Object.fromEntries(
|
||||||
|
variables.map((key) => [key, rootStyle.getPropertyValue(key).trim()])
|
||||||
|
),
|
||||||
|
bodyBackground: bodyStyle.backgroundColor,
|
||||||
|
bodyForeground: bodyStyle.color,
|
||||||
|
};
|
||||||
|
`,
|
||||||
|
[THEME_VARIABLES],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForTheme(app, predicate, description) {
|
||||||
|
return app.waitFor(
|
||||||
|
async () => {
|
||||||
|
const snapshot = await themeSnapshot(app);
|
||||||
|
return predicate(snapshot) ? snapshot : false;
|
||||||
|
},
|
||||||
|
{ description },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function themeVariablesEqual(actual, expected) {
|
||||||
|
return THEME_VARIABLES.every(
|
||||||
|
(key) => actual[key]?.toLowerCase() === expected[key]?.toLowerCase(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function chooseSelectOption(app, triggerSelector, option) {
|
||||||
|
await app.clickSelector(triggerSelector);
|
||||||
|
await app.clickText(option, { roles: ["option"] });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveSettings(app) {
|
||||||
|
await app.clickText("Save Settings", { roles: ["button"] });
|
||||||
|
await app.waitFor(
|
||||||
|
() =>
|
||||||
|
app.execute(`return document.querySelector("#theme-select") === null;`),
|
||||||
|
{ description: "Settings to close after saving" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertThemeAcrossNavigation(app, expected) {
|
||||||
|
for (const surface of ["Network", "Extensions", "Profiles"]) {
|
||||||
|
await app.clickSelector(`[aria-label="${surface}"]`);
|
||||||
|
await app.waitFor(
|
||||||
|
async () =>
|
||||||
|
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(expected),
|
||||||
|
{ description: `theme to remain unchanged on ${surface}` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragBackgroundColorPicker(app) {
|
||||||
|
await app.clickSelector('[aria-label="Background"]');
|
||||||
|
const drag = await app.waitFor(
|
||||||
|
() =>
|
||||||
|
app.execute(`
|
||||||
|
const popover = document.querySelector('[data-slot="popover-content"]');
|
||||||
|
const selection = [...(popover?.querySelectorAll("div") ?? [])].find(
|
||||||
|
(node) => node.style.background.includes("linear-gradient")
|
||||||
|
);
|
||||||
|
if (!selection) return null;
|
||||||
|
const rect = selection.getBoundingClientRect();
|
||||||
|
const points = [];
|
||||||
|
for (const yf of [0.2, 0.4, 0.6, 0.8]) {
|
||||||
|
for (const xf of [0.2, 0.4, 0.6, 0.8]) {
|
||||||
|
const point = {
|
||||||
|
x: Math.round(rect.left + rect.width * xf),
|
||||||
|
y: Math.round(rect.top + rect.height * yf),
|
||||||
|
};
|
||||||
|
const hit = document.elementFromPoint(point.x, point.y);
|
||||||
|
if (hit === selection || selection.contains(hit)) points.push(point);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return points.length >= 2
|
||||||
|
? { start: points[0], end: points[points.length - 1] }
|
||||||
|
: null;
|
||||||
|
`),
|
||||||
|
{ description: "two pointer-interactive background color picker points" },
|
||||||
|
);
|
||||||
|
await app.execute(`
|
||||||
|
window.__donutE2eThemePointerEvents = [];
|
||||||
|
for (const type of ["pointermove", "pointerdown", "pointerup"]) {
|
||||||
|
window.addEventListener(type, (event) => {
|
||||||
|
window.__donutE2eThemePointerEvents.push({
|
||||||
|
type,
|
||||||
|
x: event.clientX,
|
||||||
|
y: event.clientY,
|
||||||
|
buttons: event.buttons,
|
||||||
|
target: event.target?.className ?? event.target?.tagName ?? "",
|
||||||
|
});
|
||||||
|
}, true);
|
||||||
|
}
|
||||||
|
`);
|
||||||
|
await app.session.command("POST", "/actions", {
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: "pointer",
|
||||||
|
id: "theme-color-pointer",
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: "pointerMove",
|
||||||
|
x: drag.start.x,
|
||||||
|
y: drag.start.y,
|
||||||
|
origin: "viewport",
|
||||||
|
},
|
||||||
|
{ type: "pointerDown", button: 0 },
|
||||||
|
{ type: "pause", duration: 150 },
|
||||||
|
{
|
||||||
|
type: "pointerMove",
|
||||||
|
x: drag.end.x,
|
||||||
|
y: drag.end.y,
|
||||||
|
duration: 100,
|
||||||
|
origin: "viewport",
|
||||||
|
},
|
||||||
|
{ type: "pointerUp", button: 0 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const pointerEvents = await app.execute(
|
||||||
|
`return window.__donutE2eThemePointerEvents ?? [];`,
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
pointerEvents.map((event) => event.type),
|
||||||
|
["pointermove", "pointerdown", "pointermove", "pointerup"],
|
||||||
|
);
|
||||||
|
assert.match(pointerEvents[1].target, /cursor-pointer/);
|
||||||
|
assert.match(pointerEvents[2].target, /cursor-pointer/);
|
||||||
|
assert.equal(pointerEvents[2].buttons, 1);
|
||||||
|
await app.waitFor(
|
||||||
|
() =>
|
||||||
|
app.execute(
|
||||||
|
`return document.querySelector("#theme-preset-select")?.textContent?.includes("Your Own") === true;`,
|
||||||
|
),
|
||||||
|
{
|
||||||
|
description: `customized theme to be marked as Your Own after ${JSON.stringify(pointerEvents)}`,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
await app.clickSelector('[aria-label="Background"]');
|
||||||
|
await app.waitFor(
|
||||||
|
() =>
|
||||||
|
app.execute(
|
||||||
|
`return document.querySelector('[data-slot="popover-content"]') === null;`,
|
||||||
|
),
|
||||||
|
{ description: "color picker to close" },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
test("all primary navigation buttons and sub-page tabs render and remain interactive", async () => {
|
||||||
|
await withApp("ui-navigation", async (app) => {
|
||||||
|
const surfaces = [
|
||||||
|
["Settings", /General|Appearance|Sync/i],
|
||||||
|
["Network", /Proxies|VPNs|DNS/i],
|
||||||
|
["Extensions", /Extensions|Groups/i],
|
||||||
|
["Integrations", /API|MCP/i],
|
||||||
|
["Account", /Account|Sign in/i],
|
||||||
|
];
|
||||||
|
for (const [label, expected] of surfaces) {
|
||||||
|
await app.clickSelector(`[aria-label="${label}"]`);
|
||||||
|
await app.waitFor(async () => expected.test(await app.bodyText()), {
|
||||||
|
description: `${label} surface`,
|
||||||
|
});
|
||||||
|
assert.match(await app.bodyText(), expected);
|
||||||
|
await dismissSurface(app);
|
||||||
|
}
|
||||||
|
|
||||||
|
await app.clickSelector('[aria-label="Groups"]');
|
||||||
|
await app.waitForText("Create");
|
||||||
|
await dismissSurface(app);
|
||||||
|
|
||||||
|
await app.clickSelector('[aria-label="More"]');
|
||||||
|
await app.waitFor(
|
||||||
|
() =>
|
||||||
|
app.execute(`return Boolean(document.querySelector("[role='menu']"));`),
|
||||||
|
{ description: "More menu" },
|
||||||
|
);
|
||||||
|
await dismissSurface(app);
|
||||||
|
|
||||||
|
await app.clickSelector('[aria-label="Profiles"]');
|
||||||
|
await app.clickText("New");
|
||||||
|
await app.waitFor(
|
||||||
|
() =>
|
||||||
|
app.execute(
|
||||||
|
`return Boolean(document.querySelector("[role='dialog']"));`,
|
||||||
|
),
|
||||||
|
{ description: "new profile dialog" },
|
||||||
|
);
|
||||||
|
assert.match(await app.bodyText(), /profile/i);
|
||||||
|
await dismissSurface(app);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("settings tabs, command palette filtering, and responsive layout survive resize", async () => {
|
||||||
|
await withApp("ui-settings-responsive", async (app) => {
|
||||||
|
await app.clickSelector('[aria-label="Settings"]');
|
||||||
|
await app.waitForText("Appearance");
|
||||||
|
for (const tab of ["Appearance", "Sync", "Encryption"]) {
|
||||||
|
const exists = await app.execute(
|
||||||
|
`return [...document.querySelectorAll("[role='tab']")].some(
|
||||||
|
(node) => (node.textContent || "").trim() === arguments[0]
|
||||||
|
);`,
|
||||||
|
[tab],
|
||||||
|
);
|
||||||
|
if (exists) {
|
||||||
|
await app.clickText(tab, { roles: ["tab"] });
|
||||||
|
await app.waitFor(
|
||||||
|
() =>
|
||||||
|
app.execute(
|
||||||
|
`return [...document.querySelectorAll("[role='tab']")].some(
|
||||||
|
(node) => (node.textContent || "").trim() === arguments[0] &&
|
||||||
|
node.getAttribute("data-state") === "active"
|
||||||
|
);`,
|
||||||
|
[tab],
|
||||||
|
),
|
||||||
|
{ description: `${tab} settings tab` },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await dismissSurface(app);
|
||||||
|
|
||||||
|
const modifier =
|
||||||
|
process.platform === "darwin" ? { meta: true } : { ctrl: true };
|
||||||
|
await app.pressShortcut({ key: "k", ...modifier });
|
||||||
|
await app.waitFor(
|
||||||
|
() =>
|
||||||
|
app.execute(`return Boolean(document.querySelector("[cmdk-input]"));`),
|
||||||
|
{ description: "command palette" },
|
||||||
|
);
|
||||||
|
const input = await app.session.findCss("[cmdk-input]");
|
||||||
|
await app.session.sendKeys(input, "proxy vpn");
|
||||||
|
assert.match(await app.bodyText(), /Network|Proxy|VPN/i);
|
||||||
|
await dismissSurface(app);
|
||||||
|
|
||||||
|
// The native driver owns the top-level window. Resize through the WebDriver
|
||||||
|
// protocol and assert the app still has usable controls at the minimum size.
|
||||||
|
await app.session.command("POST", "/window/rect", {
|
||||||
|
width: 640,
|
||||||
|
height: 400,
|
||||||
|
});
|
||||||
|
const viewport = await app.execute(
|
||||||
|
"return { width: innerWidth, height: innerHeight };",
|
||||||
|
);
|
||||||
|
assert.ok(viewport.width >= 600);
|
||||||
|
assert.ok(viewport.height >= 350);
|
||||||
|
assert.equal(
|
||||||
|
await app.execute(
|
||||||
|
`return document.querySelector('[aria-label="Settings"]').getBoundingClientRect().width > 0;`,
|
||||||
|
),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("predefined theme remains rendered across navigation and restart", async () => {
|
||||||
|
await withApp("ui-theme-predefined", async (app) => {
|
||||||
|
await app.clickSelector('[aria-label="Settings"]');
|
||||||
|
await app.waitForText("Appearance");
|
||||||
|
await chooseSelectOption(app, "#theme-select", "Light");
|
||||||
|
await saveSettings(app);
|
||||||
|
|
||||||
|
const persisted = await app.invoke("get_app_settings");
|
||||||
|
assert.equal(persisted.theme, "light");
|
||||||
|
const selected = await waitForTheme(
|
||||||
|
app,
|
||||||
|
(snapshot) =>
|
||||||
|
snapshot.mode === "light" &&
|
||||||
|
Object.values(snapshot.inline).every((value) => value === ""),
|
||||||
|
"predefined light theme to render without custom variables",
|
||||||
|
);
|
||||||
|
assert.notEqual(selected.bodyBackground, "");
|
||||||
|
assert.notEqual(selected.bodyForeground, "");
|
||||||
|
await assertThemeAcrossNavigation(app, selected);
|
||||||
|
|
||||||
|
await app.restart();
|
||||||
|
assert.equal((await app.invoke("get_app_settings")).theme, "light");
|
||||||
|
await app.waitFor(
|
||||||
|
async () =>
|
||||||
|
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(selected),
|
||||||
|
{ description: "predefined light theme after restart" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("preset and manually customized themes survive navigation and restart", async () => {
|
||||||
|
await withApp("ui-theme-custom", async (app) => {
|
||||||
|
await app.clickSelector('[aria-label="Settings"]');
|
||||||
|
await app.waitForText("Appearance");
|
||||||
|
await chooseSelectOption(app, "#theme-select", "Custom");
|
||||||
|
await chooseSelectOption(app, "#theme-preset-select", "Dracula");
|
||||||
|
await saveSettings(app);
|
||||||
|
|
||||||
|
const presetSettings = await app.invoke("get_app_settings");
|
||||||
|
assert.equal(presetSettings.theme, "custom");
|
||||||
|
assert.deepEqual(presetSettings.custom_theme, DRACULA_THEME);
|
||||||
|
const preset = await waitForTheme(
|
||||||
|
app,
|
||||||
|
(snapshot) =>
|
||||||
|
snapshot.mode === "dark" &&
|
||||||
|
themeVariablesEqual(snapshot.inline, DRACULA_THEME) &&
|
||||||
|
themeVariablesEqual(snapshot.resolved, DRACULA_THEME),
|
||||||
|
"Dracula preset variables to render",
|
||||||
|
);
|
||||||
|
await assertThemeAcrossNavigation(app, preset);
|
||||||
|
|
||||||
|
await app.restart();
|
||||||
|
assert.deepEqual(
|
||||||
|
(await app.invoke("get_app_settings")).custom_theme,
|
||||||
|
DRACULA_THEME,
|
||||||
|
);
|
||||||
|
await app.waitFor(
|
||||||
|
async () =>
|
||||||
|
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(preset),
|
||||||
|
{ description: "Dracula preset after restart" },
|
||||||
|
);
|
||||||
|
|
||||||
|
await app.clickSelector('[aria-label="Settings"]');
|
||||||
|
await app.waitForText("Appearance");
|
||||||
|
assert.equal(
|
||||||
|
await app.execute(
|
||||||
|
`return document.querySelector("#theme-select")?.textContent?.trim();`,
|
||||||
|
),
|
||||||
|
"Custom",
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
await app.execute(
|
||||||
|
`return document.querySelector("#theme-preset-select")?.textContent?.trim();`,
|
||||||
|
),
|
||||||
|
"Dracula",
|
||||||
|
);
|
||||||
|
await dragBackgroundColorPicker(app);
|
||||||
|
await saveSettings(app);
|
||||||
|
|
||||||
|
const customizedSettings = await app.invoke("get_app_settings");
|
||||||
|
assert.equal(customizedSettings.theme, "custom");
|
||||||
|
assert.notEqual(
|
||||||
|
customizedSettings.custom_theme["--background"].toLowerCase(),
|
||||||
|
DRACULA_THEME["--background"],
|
||||||
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
Object.keys(customizedSettings.custom_theme).sort(),
|
||||||
|
[...THEME_VARIABLES].sort(),
|
||||||
|
);
|
||||||
|
const customized = await waitForTheme(
|
||||||
|
app,
|
||||||
|
(snapshot) =>
|
||||||
|
snapshot.mode === "dark" &&
|
||||||
|
themeVariablesEqual(snapshot.inline, customizedSettings.custom_theme) &&
|
||||||
|
themeVariablesEqual(snapshot.resolved, customizedSettings.custom_theme),
|
||||||
|
"manually customized variables to render",
|
||||||
|
);
|
||||||
|
assert.notEqual(customized.bodyBackground, preset.bodyBackground);
|
||||||
|
await assertThemeAcrossNavigation(app, customized);
|
||||||
|
|
||||||
|
await app.restart();
|
||||||
|
assert.deepEqual(
|
||||||
|
(await app.invoke("get_app_settings")).custom_theme,
|
||||||
|
customizedSettings.custom_theme,
|
||||||
|
);
|
||||||
|
await app.waitFor(
|
||||||
|
async () =>
|
||||||
|
JSON.stringify(await themeSnapshot(app)) === JSON.stringify(customized),
|
||||||
|
{ description: "manually customized theme after restart" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -96,17 +96,17 @@
|
|||||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||||
);
|
);
|
||||||
releaseVersion = "0.28.1";
|
releaseVersion = "0.28.2";
|
||||||
releaseAppImage =
|
releaseAppImage =
|
||||||
if system == "x86_64-linux" then
|
if system == "x86_64-linux" then
|
||||||
pkgs.fetchurl {
|
pkgs.fetchurl {
|
||||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_amd64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage";
|
||||||
hash = "sha256-ItjyK206mpwMXzTNnSHHM8L2R8EjvEP3mrKnz6ze4oI=";
|
hash = "sha256-+CqHiPMg4oczNiPg+MC6jvp0CUcK4kb5yeyk+QDbAWY=";
|
||||||
}
|
}
|
||||||
else if system == "aarch64-linux" then
|
else if system == "aarch64-linux" then
|
||||||
pkgs.fetchurl {
|
pkgs.fetchurl {
|
||||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.1/Donut_0.28.1_aarch64.AppImage";
|
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage";
|
||||||
hash = "sha256-ZdQKOJRwVB9Wb/ncC8j9ezoaOqPuj0zjuDB3N3r2JYY=";
|
hash = "sha256-HodokW2ySIpdpW7Hyqpwsm8whQ0hHldlSg11Sl1UW3k=";
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
null;
|
null;
|
||||||
|
|||||||
+12
-5
@@ -2,7 +2,7 @@
|
|||||||
"name": "donutbrowser",
|
"name": "donutbrowser",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "AGPL-3.0",
|
"license": "AGPL-3.0",
|
||||||
"version": "0.28.1",
|
"version": "0.28.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev --turbopack -p 12341",
|
"dev": "next dev --turbopack -p 12341",
|
||||||
@@ -12,22 +12,30 @@
|
|||||||
"test:rust": "cd src-tauri && cargo test",
|
"test:rust": "cd src-tauri && cargo test",
|
||||||
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
|
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration && cargo test --test vpn_integration",
|
||||||
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
|
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
|
||||||
|
"e2e": "node e2e/run.mjs --suite=full",
|
||||||
|
"e2e:smoke": "node e2e/run.mjs --suite=smoke",
|
||||||
|
"e2e:ui": "node e2e/run.mjs --suite=ui",
|
||||||
|
"e2e:entities": "node e2e/run.mjs --suite=entities",
|
||||||
|
"e2e:network": "node e2e/run.mjs --suite=network",
|
||||||
|
"e2e:integrations": "node e2e/run.mjs --suite=integrations",
|
||||||
|
"e2e:sync": "node e2e/run.mjs --suite=sync",
|
||||||
|
"e2e:browser": "node e2e/run.mjs --suite=browser",
|
||||||
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
|
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
|
||||||
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
|
"lint:js": "biome check src/ e2e/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
|
||||||
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||||
"lint:spell": "typos .",
|
"lint:spell": "typos .",
|
||||||
"tauri": "node scripts/run-with-env.mjs tauri",
|
"tauri": "node scripts/run-with-env.mjs tauri",
|
||||||
"shadcn:add": "pnpm dlx shadcn@latest add",
|
"shadcn:add": "pnpm dlx shadcn@latest add",
|
||||||
"prepare": "husky && husky install",
|
"prepare": "husky && husky install",
|
||||||
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||||
"format:js": "biome check src/ --write --unsafe && cd donut-sync && biome check src/ --write --unsafe",
|
"format:js": "biome check src/ e2e/ --write --unsafe && cd donut-sync && biome check src/ --write --unsafe",
|
||||||
"format": "pnpm format:js && pnpm format:rust",
|
"format": "pnpm format:js && pnpm format:rust",
|
||||||
"build:sync": "cd donut-sync && pnpm build",
|
"build:sync": "cd donut-sync && pnpm build",
|
||||||
"cargo": "cd src-tauri && cargo",
|
"cargo": "cd src-tauri && cargo",
|
||||||
"unused-exports:js": "ts-unused-exports tsconfig.json",
|
"unused-exports:js": "ts-unused-exports tsconfig.json",
|
||||||
"check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands",
|
"check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands",
|
||||||
"copy-proxy-binary": "node src-tauri/copy-proxy-binary.mjs",
|
"copy-proxy-binary": "node src-tauri/copy-proxy-binary.mjs",
|
||||||
"prebuild": "pnpm copy-proxy-binary",
|
"copy-proxy-binary:release": "node src-tauri/copy-proxy-binary.mjs --release",
|
||||||
"pretauri:dev": "pnpm copy-proxy-binary",
|
"pretauri:dev": "pnpm copy-proxy-binary",
|
||||||
"precargo": "pnpm copy-proxy-binary"
|
"precargo": "pnpm copy-proxy-binary"
|
||||||
},
|
},
|
||||||
@@ -61,7 +69,6 @@
|
|||||||
"cmdk": "^1.1.1",
|
"cmdk": "^1.1.1",
|
||||||
"color": "^5.0.3",
|
"color": "^5.0.3",
|
||||||
"flag-icons": "^7.5.0",
|
"flag-icons": "^7.5.0",
|
||||||
"framer-motion": "^12.42.2",
|
|
||||||
"i18next": "^26.3.4",
|
"i18next": "^26.3.4",
|
||||||
"lucide-react": "^1.23.0",
|
"lucide-react": "^1.23.0",
|
||||||
"motion": "^12.42.2",
|
"motion": "^12.42.2",
|
||||||
|
|||||||
Generated
-3
@@ -109,9 +109,6 @@ importers:
|
|||||||
flag-icons:
|
flag-icons:
|
||||||
specifier: ^7.5.0
|
specifier: ^7.5.0
|
||||||
version: 7.5.0
|
version: 7.5.0
|
||||||
framer-motion:
|
|
||||||
specifier: ^12.42.2
|
|
||||||
version: 12.42.2(react-dom@19.2.7(react@19.2.7))(react@19.2.7)
|
|
||||||
i18next:
|
i18next:
|
||||||
specifier: ^26.3.4
|
specifier: ^26.3.4
|
||||||
version: 26.3.4(typescript@6.0.3)
|
version: 26.3.4(typescript@6.0.3)
|
||||||
|
|||||||
@@ -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
+9
-19
@@ -1797,7 +1797,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.28.1"
|
version = "0.28.2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"aes 0.9.1",
|
"aes 0.9.1",
|
||||||
"aes-gcm 0.11.0",
|
"aes-gcm 0.11.0",
|
||||||
@@ -1836,7 +1836,7 @@ dependencies = [
|
|||||||
"objc2",
|
"objc2",
|
||||||
"objc2-app-kit",
|
"objc2-app-kit",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"quick-xml 0.41.0",
|
"quick-xml",
|
||||||
"rand 0.10.2",
|
"rand 0.10.2",
|
||||||
"regex-lite",
|
"regex-lite",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
@@ -4686,7 +4686,7 @@ checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"indexmap 2.14.0",
|
"indexmap 2.14.0",
|
||||||
"quick-xml 0.41.0",
|
"quick-xml",
|
||||||
"serde",
|
"serde",
|
||||||
"time",
|
"time",
|
||||||
]
|
]
|
||||||
@@ -4941,15 +4941,6 @@ version = "2.0.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "quick-xml"
|
|
||||||
version = "0.39.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "cdcc8dd4e2f670d309a5f0e83fe36dfdc05af317008fea29144da1a2ac858e5e"
|
|
||||||
dependencies = [
|
|
||||||
"memchr",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.41.0"
|
version = "0.41.0"
|
||||||
@@ -7032,9 +7023,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio"
|
name = "tokio"
|
||||||
version = "1.52.3"
|
version = "1.53.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe"
|
checksum = "d988bcd52dbe076d3d46903332f58c912b87a2c49b1428419a5845154762ffee"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bytes",
|
"bytes",
|
||||||
"libc",
|
"libc",
|
||||||
@@ -7694,9 +7685,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "uuid"
|
name = "uuid"
|
||||||
version = "1.23.4"
|
version = "1.24.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53"
|
checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"getrandom 0.4.3",
|
"getrandom 0.4.3",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
@@ -7914,11 +7905,10 @@ dependencies = [
|
|||||||
[[package]]
|
[[package]]
|
||||||
name = "wayland-scanner"
|
name = "wayland-scanner"
|
||||||
version = "0.31.10"
|
version = "0.31.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "git+https://github.com/Smithay/wayland-rs?rev=d07c4f91f28b42e5a485823ffd9d8d5a210b1053#d07c4f91f28b42e5a485823ffd9d8d5a210b1053"
|
||||||
checksum = "9c324a910fd86ebdc364a3e61ec1f11737d3b1d6c273c0239ee8ff4bc0d24b4a"
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quick-xml 0.39.4",
|
"quick-xml",
|
||||||
"quote",
|
"quote",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "donutbrowser"
|
name = "donutbrowser"
|
||||||
version = "0.28.1"
|
version = "0.28.2"
|
||||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||||
authors = ["zhom@github"]
|
authors = ["zhom@github"]
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
@@ -31,7 +31,7 @@ resvg = "0.47"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
tauri = { version = "2", features = ["devtools", "test", "tray-icon", "image-png"] }
|
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||||
tauri-plugin-opener = "2"
|
tauri-plugin-opener = "2"
|
||||||
tauri-plugin-fs = "2"
|
tauri-plugin-fs = "2"
|
||||||
tauri-plugin-shell = "2"
|
tauri-plugin-shell = "2"
|
||||||
@@ -186,3 +186,10 @@ default = ["custom-protocol"]
|
|||||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||||
# DO NOT remove this
|
# DO NOT remove this
|
||||||
custom-protocol = ["tauri/custom-protocol"]
|
custom-protocol = ["tauri/custom-protocol"]
|
||||||
|
e2e = []
|
||||||
|
|
||||||
|
# wayland-scanner 0.31.10 still pins vulnerable quick-xml 0.39. Upstream fixed
|
||||||
|
# RUSTSEC-2026-0194 and RUSTSEC-2026-0195, but has not published the fix yet.
|
||||||
|
# Remove this patch after the next wayland-scanner release is in the lockfile.
|
||||||
|
[patch.crates-io]
|
||||||
|
wayland-scanner = { git = "https://github.com/Smithay/wayland-rs", rev = "d07c4f91f28b42e5a485823ffd9d8d5a210b1053" }
|
||||||
|
|||||||
+1
-19
@@ -8,26 +8,8 @@
|
|||||||
<string>Donut needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually.</string>
|
<string>Donut needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually.</string>
|
||||||
<key>NSLocalNetworkUsageDescription</key>
|
<key>NSLocalNetworkUsageDescription</key>
|
||||||
<string>Donut has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
|
<string>Donut has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
|
||||||
<key>CFBundleDisplayName</key>
|
|
||||||
<string>Donut</string>
|
|
||||||
<key>CFBundleName</key>
|
|
||||||
<string>Donut</string>
|
|
||||||
<key>CFBundleIdentifier</key>
|
|
||||||
<string>com.donutbrowser</string>
|
|
||||||
<key>CFBundlePackageType</key>
|
|
||||||
<string>APPL</string>
|
|
||||||
<key>CFBundleURLName</key>
|
|
||||||
<string>com.donutbrowser</string>
|
|
||||||
<key>CFBundleExecutable</key>
|
|
||||||
<string>donutbrowser</string>
|
|
||||||
<key>CFBundleVersion</key>
|
|
||||||
<string>1</string>
|
|
||||||
<key>CFBundleIconFile</key>
|
|
||||||
<string>icon.icns</string>
|
|
||||||
<key>LSApplicationCategoryType</key>
|
|
||||||
<string>public.app-category.productivity</string>
|
|
||||||
<key>NSHumanReadableCopyright</key>
|
<key>NSHumanReadableCopyright</key>
|
||||||
<string>Copyright © 2025 Donut</string>
|
<string>Copyright © 2026 Donut</string>
|
||||||
<key>CFBundleDocumentTypes</key>
|
<key>CFBundleDocumentTypes</key>
|
||||||
<array>
|
<array>
|
||||||
<dict>
|
<dict>
|
||||||
|
|||||||
@@ -5,25 +5,24 @@
|
|||||||
"windows": ["main"],
|
"windows": ["main"],
|
||||||
"webviews": ["main"],
|
"webviews": ["main"],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"core:default",
|
|
||||||
"core:event:allow-listen",
|
"core:event:allow-listen",
|
||||||
"core:event:allow-emit",
|
"core:event:allow-emit",
|
||||||
"core:event:allow-emit-to",
|
"core:event:allow-emit-to",
|
||||||
"core:event:allow-unlisten",
|
"core:event:allow-unlisten",
|
||||||
"core:image:default",
|
|
||||||
"core:menu:default",
|
|
||||||
"core:path:default",
|
|
||||||
"core:tray:default",
|
|
||||||
"core:webview:default",
|
|
||||||
"core:window:default",
|
|
||||||
"core:window:allow-start-dragging",
|
"core:window:allow-start-dragging",
|
||||||
"core:window:allow-close",
|
"core:window:allow-close",
|
||||||
|
"core:window:allow-is-maximized",
|
||||||
"core:window:allow-minimize",
|
"core:window:allow-minimize",
|
||||||
"core:window:allow-toggle-maximize",
|
"core:window:allow-toggle-maximize",
|
||||||
"opener:default",
|
|
||||||
{
|
{
|
||||||
"identifier": "opener:allow-open-url",
|
"identifier": "opener:allow-open-url",
|
||||||
"allow": [
|
"allow": [
|
||||||
|
{
|
||||||
|
"url": "https://*"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"url": "http://*"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
|
"url": "x-apple.systempreferences:com.apple.preference.security?Privacy_Microphone"
|
||||||
},
|
},
|
||||||
@@ -32,28 +31,16 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"fs:default",
|
"fs:allow-read-text-file",
|
||||||
"shell:allow-execute",
|
"fs:allow-write-text-file",
|
||||||
"shell:allow-kill",
|
|
||||||
"shell:allow-open",
|
|
||||||
"shell:allow-spawn",
|
|
||||||
"shell:allow-stdin-write",
|
|
||||||
"deep-link:default",
|
|
||||||
"deep-link:allow-register",
|
|
||||||
"deep-link:allow-unregister",
|
|
||||||
"deep-link:allow-is-registered",
|
|
||||||
"deep-link:allow-get-current",
|
"deep-link:allow-get-current",
|
||||||
"dialog:default",
|
|
||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"fs:allow-write-text-file",
|
|
||||||
"macos-permissions:default",
|
|
||||||
"macos-permissions:allow-request-microphone-permission",
|
"macos-permissions:allow-request-microphone-permission",
|
||||||
"macos-permissions:allow-request-camera-permission",
|
"macos-permissions:allow-request-camera-permission",
|
||||||
"macos-permissions:allow-check-microphone-permission",
|
"macos-permissions:allow-check-microphone-permission",
|
||||||
"macos-permissions:allow-check-camera-permission",
|
"macos-permissions:allow-check-camera-permission",
|
||||||
"log:default",
|
"log:default",
|
||||||
"clipboard-manager:default",
|
|
||||||
"clipboard-manager:allow-write-text"
|
"clipboard-manager:allow-write-text"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import { join, dirname } from "node:path";
|
|||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
const MANIFEST_DIR = dirname(fileURLToPath(import.meta.url));
|
const MANIFEST_DIR = dirname(fileURLToPath(import.meta.url));
|
||||||
const PROFILE = process.env.PROFILE || "debug";
|
const PROFILE =
|
||||||
|
process.argv.includes("--release") || process.env.PROFILE === "release"
|
||||||
|
? "release"
|
||||||
|
: "debug";
|
||||||
|
|
||||||
function getTarget() {
|
function getTarget() {
|
||||||
if (process.env.TARGET) return process.env.TARGET;
|
if (process.env.TARGET) return process.env.TARGET;
|
||||||
@@ -48,32 +51,22 @@ function copyBinary(baseName) {
|
|||||||
if (isWindows) destName += ".exe";
|
if (isWindows) destName += ".exe";
|
||||||
const dest = join(destDir, destName);
|
const dest = join(destDir, destName);
|
||||||
|
|
||||||
if (existsSync(source)) {
|
const buildArgs = ["build", "--bin", baseName];
|
||||||
copyFileSync(source, dest);
|
if (PROFILE === "release") buildArgs.push("--release");
|
||||||
console.log(`Copied ${binName} to ${dest}`);
|
if (TARGET !== "unknown" && TARGET !== HOST_TARGET) {
|
||||||
} else {
|
buildArgs.push("--target", TARGET);
|
||||||
console.log(`Warning: Binary not found at ${source}`);
|
|
||||||
console.log(`Building ${baseName} binary...`);
|
|
||||||
|
|
||||||
const buildArgs = ["build", "--bin", baseName];
|
|
||||||
if (PROFILE === "release") buildArgs.push("--release");
|
|
||||||
if (TARGET !== "unknown" && TARGET !== HOST_TARGET) {
|
|
||||||
buildArgs.push("--target", TARGET);
|
|
||||||
}
|
|
||||||
|
|
||||||
execFileSync("cargo", buildArgs, {
|
|
||||||
cwd: MANIFEST_DIR,
|
|
||||||
stdio: "inherit",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (existsSync(source)) {
|
|
||||||
copyFileSync(source, dest);
|
|
||||||
console.log(`Built and copied ${binName} to ${dest}`);
|
|
||||||
} else {
|
|
||||||
console.error(`Error: Failed to build ${baseName} binary`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
execFileSync("cargo", buildArgs, {
|
||||||
|
cwd: MANIFEST_DIR,
|
||||||
|
stdio: "inherit",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!existsSync(source)) {
|
||||||
|
console.error(`Error: Failed to build ${baseName} binary`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
copyFileSync(source, dest);
|
||||||
|
console.log(`Built and copied ${binName} to ${dest}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
copyBinary("donut-proxy");
|
copyBinary("donut-proxy");
|
||||||
|
|||||||
@@ -2,37 +2,9 @@
|
|||||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
<plist version="1.0">
|
<plist version="1.0">
|
||||||
<dict>
|
<dict>
|
||||||
<key>com.apple.security.app-sandbox</key>
|
|
||||||
<false/>
|
|
||||||
<key>com.apple.security.network.client</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.network.server</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.files.user-selected.read-write</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.files.downloads.read-write</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.device.camera</key>
|
<key>com.apple.security.device.camera</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.device.audio-output</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.device.microphone</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.device.audio-input</key>
|
<key>com.apple.security.device.audio-input</key>
|
||||||
<true/>
|
<true/>
|
||||||
<key>com.apple.security.cs.allow-jit</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.cs.disable-library-validation</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.automation.apple-events</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.device.usb</key>
|
|
||||||
<true/>
|
|
||||||
<key>com.apple.security.inherit</key>
|
|
||||||
<true/>
|
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
!macro NSIS_HOOK_PREINSTALL
|
||||||
|
IfFileExists "$INSTDIR\donut-proxy.exe" 0 donut_proxy_preinstall_done
|
||||||
|
|
||||||
|
DetailPrint "Stopping Donut proxy workers before replacing application files"
|
||||||
|
nsExec::ExecToStack '"$SYSDIR\taskkill.exe" /F /T /IM "donut-proxy.exe"'
|
||||||
|
Pop $0
|
||||||
|
Pop $1
|
||||||
|
Sleep 1000
|
||||||
|
|
||||||
|
; Removing the old sidecar first prevents NSIS from retaining a same-version
|
||||||
|
; or previously locked executable while updating the main application.
|
||||||
|
Delete "$INSTDIR\donut-proxy.exe"
|
||||||
|
|
||||||
|
donut_proxy_preinstall_done:
|
||||||
|
!macroend
|
||||||
@@ -278,7 +278,7 @@ impl ApiClient {
|
|||||||
{
|
{
|
||||||
Ok(response) => {
|
Ok(response) => {
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
last_err = Some(format!("HTTP {}", response.status()));
|
last_err = Some(format!("HTTP {}", response.status().as_u16()));
|
||||||
} else {
|
} else {
|
||||||
match response.json::<WayfernVersionInfo>().await {
|
match response.json::<WayfernVersionInfo>().await {
|
||||||
Ok(info) => {
|
Ok(info) => {
|
||||||
|
|||||||
+510
-163
File diff suppressed because it is too large
Load Diff
@@ -967,7 +967,7 @@ impl AppAutoUpdater {
|
|||||||
// rejected before the multi-hundred-MB download, not after.
|
// rejected before the multi-hundred-MB download, not after.
|
||||||
let expected_sha256 = self.fetch_expected_checksum(update_info, &filename).await?;
|
let expected_sha256 = self.fetch_expected_checksum(update_info, &filename).await?;
|
||||||
|
|
||||||
log::info!("Downloading update from: {}", update_info.download_url);
|
log::info!("Downloading update");
|
||||||
|
|
||||||
let download_path = self
|
let download_path = self
|
||||||
.download_update_silent(&update_info.download_url, &temp_dir, &filename)
|
.download_update_silent(&update_info.download_url, &temp_dir, &filename)
|
||||||
@@ -1698,6 +1698,95 @@ impl AppAutoUpdater {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(any(target_os = "windows", test))]
|
||||||
|
async fn prepare_windows_installer() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
|
let profiles = match crate::profile::ProfileManager::instance().list_profiles() {
|
||||||
|
Ok(profiles) => profiles,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to inspect running profiles before app update: {e}");
|
||||||
|
return Err(
|
||||||
|
serde_json::json!({
|
||||||
|
"code": "UPDATE_PREPARATION_FAILED"
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_running_profiles = profiles.into_iter().any(|profile| {
|
||||||
|
profile
|
||||||
|
.process_id
|
||||||
|
.is_some_and(|pid| pid != 0 && crate::proxy_storage::is_process_running(pid))
|
||||||
|
});
|
||||||
|
if has_running_profiles {
|
||||||
|
return Err(
|
||||||
|
serde_json::json!({
|
||||||
|
"code": "UPDATE_PROFILES_RUNNING"
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let proxy_configs = crate::proxy_storage::list_proxy_configs();
|
||||||
|
let vpn_configs = crate::vpn_worker_storage::list_vpn_worker_configs();
|
||||||
|
let mut worker_pids: Vec<u32> = proxy_configs
|
||||||
|
.iter()
|
||||||
|
.filter_map(|config| config.pid)
|
||||||
|
.chain(vpn_configs.iter().filter_map(|config| config.pid))
|
||||||
|
.collect();
|
||||||
|
worker_pids.sort_unstable();
|
||||||
|
worker_pids.dedup();
|
||||||
|
|
||||||
|
let proxy_ids: Vec<String> = proxy_configs.into_iter().map(|config| config.id).collect();
|
||||||
|
let vpn_ids: Vec<String> = vpn_configs.into_iter().map(|config| config.id).collect();
|
||||||
|
|
||||||
|
let stop_proxies = futures_util::future::join_all(
|
||||||
|
proxy_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| crate::proxy_runner::stop_proxy_process(id)),
|
||||||
|
);
|
||||||
|
let stop_vpns = futures_util::future::join_all(
|
||||||
|
vpn_ids
|
||||||
|
.iter()
|
||||||
|
.map(|id| crate::vpn_worker_runner::stop_vpn_worker(id)),
|
||||||
|
);
|
||||||
|
let (proxy_results, vpn_results) = tokio::join!(stop_proxies, stop_vpns);
|
||||||
|
|
||||||
|
for result in proxy_results.into_iter().chain(vpn_results) {
|
||||||
|
if let Err(e) = result {
|
||||||
|
log::warn!("Failed to stop a network worker before app update: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _ in 0..20 {
|
||||||
|
if worker_pids
|
||||||
|
.iter()
|
||||||
|
.all(|pid| !crate::proxy_storage::is_process_running(*pid))
|
||||||
|
{
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||||
|
}
|
||||||
|
|
||||||
|
let remaining: Vec<u32> = worker_pids
|
||||||
|
.into_iter()
|
||||||
|
.filter(|pid| crate::proxy_storage::is_process_running(*pid))
|
||||||
|
.collect();
|
||||||
|
log::error!(
|
||||||
|
"App update aborted because donut-proxy worker PIDs are still running: {:?}",
|
||||||
|
remaining
|
||||||
|
);
|
||||||
|
Err(
|
||||||
|
serde_json::json!({
|
||||||
|
"code": "UPDATE_PREPARATION_FAILED"
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// Restart the application
|
/// Restart the application
|
||||||
async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
@@ -1764,6 +1853,11 @@ rm "{}"
|
|||||||
let pending = PENDING_INSTALLER_PATH.lock().unwrap().take();
|
let pending = PENDING_INSTALLER_PATH.lock().unwrap().take();
|
||||||
|
|
||||||
if let Some(installer_path) = pending {
|
if let Some(installer_path) = pending {
|
||||||
|
if let Err(e) = Self::prepare_windows_installer().await {
|
||||||
|
*PENDING_INSTALLER_PATH.lock().unwrap() = Some(installer_path);
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
|
||||||
// Use ShellExecuteW to run the installer directly — no batch script,
|
// Use ShellExecuteW to run the installer directly — no batch script,
|
||||||
// no cmd.exe console window. The NSIS/MSI installer handles killing the
|
// no cmd.exe console window. The NSIS/MSI installer handles killing the
|
||||||
// old process and restarting the app natively (via /UPDATE and
|
// old process and restarting the app natively (via /UPDATE and
|
||||||
@@ -1943,6 +2037,14 @@ rm "{}"
|
|||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
|
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
|
||||||
|
#[cfg(feature = "e2e")]
|
||||||
|
if crate::e2e_automation_enabled()
|
||||||
|
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
|
||||||
|
{
|
||||||
|
log::info!("E2E: skipping automatic app update check");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
if crate::app_dirs::is_portable() {
|
if crate::app_dirs::is_portable() {
|
||||||
log::info!("App auto-updates disabled in portable mode");
|
log::info!("App auto-updates disabled in portable mode");
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -1991,11 +2093,19 @@ pub async fn restart_application() -> Result<(), String> {
|
|||||||
updater
|
updater
|
||||||
.restart_application()
|
.restart_application()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to restart application: {e}"))
|
.map_err(|e| crate::wrap_backend_error(e, "Failed to restart application"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
|
pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, String> {
|
||||||
|
#[cfg(feature = "e2e")]
|
||||||
|
if crate::e2e_automation_enabled()
|
||||||
|
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
|
||||||
|
{
|
||||||
|
log::info!("E2E: skipping manual app update check");
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
log::info!("Manual app update check triggered");
|
log::info!("Manual app update check triggered");
|
||||||
let updater = AppAutoUpdater::instance();
|
let updater = AppAutoUpdater::instance();
|
||||||
updater
|
updater
|
||||||
@@ -2197,6 +2307,25 @@ not-a-hash Donut_0.29.0_amd64.deb
|
|||||||
assert_eq!(with.digest.as_deref(), Some("sha256:ab12"));
|
assert_eq!(with.digest.as_deref(), Some("sha256:ab12"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_windows_installer_hook_protects_sidecar_replacement() {
|
||||||
|
let _ = AppAutoUpdater::prepare_windows_installer;
|
||||||
|
|
||||||
|
let config: serde_json::Value =
|
||||||
|
serde_json::from_str(include_str!("../tauri.conf.json")).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
config["bundle"]["windows"]["nsis"]["installerHooks"].as_str(),
|
||||||
|
Some("installer-hooks.nsh")
|
||||||
|
);
|
||||||
|
|
||||||
|
let hooks = include_str!("../installer-hooks.nsh");
|
||||||
|
assert!(hooks.contains("NSIS_HOOK_PREINSTALL"));
|
||||||
|
assert!(hooks.contains("IfFileExists \"$INSTDIR\\donut-proxy.exe\""));
|
||||||
|
assert!(hooks.contains("taskkill.exe"));
|
||||||
|
assert!(hooks.contains("donut-proxy.exe"));
|
||||||
|
assert!(hooks.contains("Delete \"$INSTDIR\\donut-proxy.exe\""));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_platform_specific_download_urls() {
|
fn test_platform_specific_download_urls() {
|
||||||
let updater = AppAutoUpdater::instance();
|
let updater = AppAutoUpdater::instance();
|
||||||
|
|||||||
@@ -209,6 +209,30 @@ pub fn restrict_to_owner(path: &std::path::Path) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write sensitive data without creating a wider-permission file first.
|
||||||
|
pub fn create_owner_only(path: &std::path::Path) -> std::io::Result<std::fs::File> {
|
||||||
|
if path.exists() {
|
||||||
|
restrict_to_owner(path);
|
||||||
|
}
|
||||||
|
let mut options = std::fs::OpenOptions::new();
|
||||||
|
options.create(true).truncate(true).write(true);
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::OpenOptionsExt;
|
||||||
|
options.mode(0o600);
|
||||||
|
}
|
||||||
|
let file = options.open(path)?;
|
||||||
|
restrict_to_owner(path);
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn write_owner_only(path: &std::path::Path, content: &[u8]) -> std::io::Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
let mut file = create_owner_only(path)?;
|
||||||
|
file.write_all(content)?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -222,6 +246,19 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
#[test]
|
||||||
|
fn owner_only_writer_uses_private_permissions() {
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
let path = temp.path().join("secret.json");
|
||||||
|
write_owner_only(&path, b"secret").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
|
||||||
|
0o600
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_data_dir_returns_path() {
|
fn test_data_dir_returns_path() {
|
||||||
let dir = data_dir();
|
let dir = data_dir();
|
||||||
|
|||||||
@@ -668,6 +668,7 @@ mod tests {
|
|||||||
created_by_email: None,
|
created_by_email: None,
|
||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
|
clear_on_close: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
updated_at: None,
|
updated_at: None,
|
||||||
}
|
}
|
||||||
@@ -802,13 +803,13 @@ mod tests {
|
|||||||
let test_settings_manager = TestSettingsManager::new(temp_dir.path().to_path_buf());
|
let test_settings_manager = TestSettingsManager::new(temp_dir.path().to_path_buf());
|
||||||
|
|
||||||
let mut state = AutoUpdateState::default();
|
let mut state = AutoUpdateState::default();
|
||||||
state.disabled_browsers.insert("firefox".to_string());
|
state.disabled_browsers.insert("testbrowser".to_string());
|
||||||
state
|
state
|
||||||
.auto_update_downloads
|
.auto_update_downloads
|
||||||
.insert("firefox-1.1.0".to_string());
|
.insert("testbrowser-1.1.0".to_string());
|
||||||
state.pending_updates.push(UpdateNotification {
|
state.pending_updates.push(UpdateNotification {
|
||||||
id: "test".to_string(),
|
id: "test".to_string(),
|
||||||
browser: "firefox".to_string(),
|
browser: "testbrowser".to_string(),
|
||||||
current_version: "1.0.0".to_string(),
|
current_version: "1.0.0".to_string(),
|
||||||
new_version: "1.1.0".to_string(),
|
new_version: "1.1.0".to_string(),
|
||||||
affected_profiles: vec!["profile1".to_string()],
|
affected_profiles: vec!["profile1".to_string()],
|
||||||
@@ -830,9 +831,11 @@ mod tests {
|
|||||||
serde_json::from_str(&content).expect("Failed to deserialize state");
|
serde_json::from_str(&content).expect("Failed to deserialize state");
|
||||||
|
|
||||||
assert_eq!(loaded_state.disabled_browsers.len(), 1);
|
assert_eq!(loaded_state.disabled_browsers.len(), 1);
|
||||||
assert!(loaded_state.disabled_browsers.contains("firefox"));
|
assert!(loaded_state.disabled_browsers.contains("testbrowser"));
|
||||||
assert_eq!(loaded_state.auto_update_downloads.len(), 1);
|
assert_eq!(loaded_state.auto_update_downloads.len(), 1);
|
||||||
assert!(loaded_state.auto_update_downloads.contains("firefox-1.1.0"));
|
assert!(loaded_state
|
||||||
|
.auto_update_downloads
|
||||||
|
.contains("testbrowser-1.1.0"));
|
||||||
assert_eq!(loaded_state.pending_updates.len(), 1);
|
assert_eq!(loaded_state.pending_updates.len(), 1);
|
||||||
assert_eq!(loaded_state.pending_updates[0].id, "test");
|
assert_eq!(loaded_state.pending_updates[0].id, "test");
|
||||||
}
|
}
|
||||||
@@ -871,16 +874,16 @@ mod tests {
|
|||||||
// Initially not disabled (empty state file means default state)
|
// Initially not disabled (empty state file means default state)
|
||||||
let state = AutoUpdateState::default();
|
let state = AutoUpdateState::default();
|
||||||
assert!(
|
assert!(
|
||||||
!state.disabled_browsers.contains("firefox"),
|
!state.disabled_browsers.contains("testbrowser"),
|
||||||
"Firefox should not be disabled initially"
|
"testbrowser should not be disabled initially"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Start update (should disable)
|
// Start update (should disable)
|
||||||
let mut state = AutoUpdateState::default();
|
let mut state = AutoUpdateState::default();
|
||||||
state.disabled_browsers.insert("firefox".to_string());
|
state.disabled_browsers.insert("testbrowser".to_string());
|
||||||
state
|
state
|
||||||
.auto_update_downloads
|
.auto_update_downloads
|
||||||
.insert("firefox-1.1.0".to_string());
|
.insert("testbrowser-1.1.0".to_string());
|
||||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize state");
|
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize state");
|
||||||
std::fs::write(&state_file, json).expect("Failed to write state file");
|
std::fs::write(&state_file, json).expect("Failed to write state file");
|
||||||
|
|
||||||
@@ -889,18 +892,20 @@ mod tests {
|
|||||||
let loaded_state: AutoUpdateState =
|
let loaded_state: AutoUpdateState =
|
||||||
serde_json::from_str(&content).expect("Failed to deserialize state");
|
serde_json::from_str(&content).expect("Failed to deserialize state");
|
||||||
assert!(
|
assert!(
|
||||||
loaded_state.disabled_browsers.contains("firefox"),
|
loaded_state.disabled_browsers.contains("testbrowser"),
|
||||||
"Firefox should be disabled"
|
"testbrowser should be disabled"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
loaded_state.auto_update_downloads.contains("firefox-1.1.0"),
|
loaded_state
|
||||||
"Firefox download should be tracked"
|
.auto_update_downloads
|
||||||
|
.contains("testbrowser-1.1.0"),
|
||||||
|
"testbrowser download should be tracked"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Complete update (should enable)
|
// Complete update (should enable)
|
||||||
let mut state = loaded_state;
|
let mut state = loaded_state;
|
||||||
state.disabled_browsers.remove("firefox");
|
state.disabled_browsers.remove("testbrowser");
|
||||||
state.auto_update_downloads.remove("firefox-1.1.0");
|
state.auto_update_downloads.remove("testbrowser-1.1.0");
|
||||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize final state");
|
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize final state");
|
||||||
std::fs::write(&state_file, json).expect("Failed to write final state file");
|
std::fs::write(&state_file, json).expect("Failed to write final state file");
|
||||||
|
|
||||||
@@ -909,12 +914,14 @@ mod tests {
|
|||||||
let final_state: AutoUpdateState =
|
let final_state: AutoUpdateState =
|
||||||
serde_json::from_str(&content).expect("Failed to deserialize final state");
|
serde_json::from_str(&content).expect("Failed to deserialize final state");
|
||||||
assert!(
|
assert!(
|
||||||
!final_state.disabled_browsers.contains("firefox"),
|
!final_state.disabled_browsers.contains("testbrowser"),
|
||||||
"Firefox should be enabled again"
|
"testbrowser should be enabled again"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!final_state.auto_update_downloads.contains("firefox-1.1.0"),
|
!final_state
|
||||||
"Firefox download should not be tracked anymore"
|
.auto_update_downloads
|
||||||
|
.contains("testbrowser-1.1.0"),
|
||||||
|
"testbrowser download should not be tracked anymore"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -945,7 +952,7 @@ mod tests {
|
|||||||
let mut state = AutoUpdateState::default();
|
let mut state = AutoUpdateState::default();
|
||||||
state.pending_updates.push(UpdateNotification {
|
state.pending_updates.push(UpdateNotification {
|
||||||
id: "test_notification".to_string(),
|
id: "test_notification".to_string(),
|
||||||
browser: "firefox".to_string(),
|
browser: "testbrowser".to_string(),
|
||||||
current_version: "1.0.0".to_string(),
|
current_version: "1.0.0".to_string(),
|
||||||
new_version: "1.1.0".to_string(),
|
new_version: "1.1.0".to_string(),
|
||||||
affected_profiles: vec!["profile1".to_string()],
|
affected_profiles: vec!["profile1".to_string()],
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ use clap::{Arg, Command};
|
|||||||
use donutbrowser_lib::proxy_runner::{
|
use donutbrowser_lib::proxy_runner::{
|
||||||
start_proxy_process_with_profile, stop_all_proxy_processes, stop_proxy_process,
|
start_proxy_process_with_profile, stop_all_proxy_processes, stop_proxy_process,
|
||||||
};
|
};
|
||||||
use donutbrowser_lib::proxy_server::run_proxy_server;
|
use donutbrowser_lib::proxy_server::{redacted_upstream, run_proxy_server};
|
||||||
use donutbrowser_lib::proxy_storage::get_proxy_config;
|
use donutbrowser_lib::proxy_storage::{build_proxy_url, get_proxy_config};
|
||||||
use std::process;
|
use std::process;
|
||||||
|
|
||||||
fn set_high_priority() {
|
fn set_high_priority() {
|
||||||
@@ -55,31 +55,6 @@ fn set_high_priority() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_proxy_url(
|
|
||||||
proxy_type: &str,
|
|
||||||
host: &str,
|
|
||||||
port: u16,
|
|
||||||
username: Option<&str>,
|
|
||||||
password: Option<&str>,
|
|
||||||
) -> String {
|
|
||||||
let mut url = format!("{}://", proxy_type.to_lowercase());
|
|
||||||
|
|
||||||
if let (Some(user), Some(pass)) = (username, password) {
|
|
||||||
let encoded_user = urlencoding::encode(user);
|
|
||||||
let encoded_pass = urlencoding::encode(pass);
|
|
||||||
url.push_str(&format!("{}:{}@", encoded_user, encoded_pass));
|
|
||||||
} else if let Some(user) = username {
|
|
||||||
let encoded_user = urlencoding::encode(user);
|
|
||||||
url.push_str(&format!("{}@", encoded_user));
|
|
||||||
}
|
|
||||||
|
|
||||||
url.push_str(host);
|
|
||||||
url.push(':');
|
|
||||||
url.push_str(&port.to_string());
|
|
||||||
|
|
||||||
url
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::main(flavor = "multi_thread")]
|
#[tokio::main(flavor = "multi_thread")]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
// Initialize logger to write to stderr (which will be redirected to file).
|
// Initialize logger to write to stderr (which will be redirected to file).
|
||||||
@@ -110,6 +85,7 @@ async fn main() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let matches = Command::new("donut-proxy")
|
let matches = Command::new("donut-proxy")
|
||||||
|
.version(env!("BUILD_VERSION"))
|
||||||
.subcommand(
|
.subcommand(
|
||||||
Command::new("proxy")
|
Command::new("proxy")
|
||||||
.about("Manage proxy servers")
|
.about("Manage proxy servers")
|
||||||
@@ -128,8 +104,6 @@ async fn main() {
|
|||||||
.long("type")
|
.long("type")
|
||||||
.help("Proxy type (http, https, socks4, socks5, ss)"),
|
.help("Proxy type (http, https, socks4, socks5, ss)"),
|
||||||
)
|
)
|
||||||
.arg(Arg::new("username").long("username").help("Proxy username"))
|
|
||||||
.arg(Arg::new("password").long("password").help("Proxy password"))
|
|
||||||
.arg(
|
.arg(
|
||||||
Arg::new("port")
|
Arg::new("port")
|
||||||
.short('p')
|
.short('p')
|
||||||
@@ -163,6 +137,12 @@ async fn main() {
|
|||||||
.long("blocklist-file")
|
.long("blocklist-file")
|
||||||
.help("Path to DNS blocklist file (one domain per line)"),
|
.help("Path to DNS blocklist file (one domain per line)"),
|
||||||
)
|
)
|
||||||
|
.arg(
|
||||||
|
Arg::new("dns-allowlist-mode")
|
||||||
|
.long("dns-allowlist-mode")
|
||||||
|
.num_args(0)
|
||||||
|
.help("Treat --blocklist-file as an allowlist (block all domains not listed)"),
|
||||||
|
)
|
||||||
.arg(
|
.arg(
|
||||||
Arg::new("local-protocol")
|
Arg::new("local-protocol")
|
||||||
.long("local-protocol")
|
.long("local-protocol")
|
||||||
@@ -236,16 +216,22 @@ async fn main() {
|
|||||||
start_matches.get_one::<u16>("proxy-port"),
|
start_matches.get_one::<u16>("proxy-port"),
|
||||||
start_matches.get_one::<String>("type"),
|
start_matches.get_one::<String>("type"),
|
||||||
) {
|
) {
|
||||||
let username = start_matches.get_one::<String>("username");
|
let username = std::env::var("DONUT_PROXY_USERNAME").ok();
|
||||||
let password = start_matches.get_one::<String>("password");
|
let password = std::env::var("DONUT_PROXY_PASSWORD").ok();
|
||||||
upstream_url = Some(build_proxy_url(
|
upstream_url = Some(build_proxy_url(
|
||||||
proxy_type,
|
proxy_type,
|
||||||
host,
|
host,
|
||||||
*port,
|
*port,
|
||||||
username.map(|s| s.as_str()),
|
username.as_deref(),
|
||||||
password.map(|s| s.as_str()),
|
password.as_deref(),
|
||||||
));
|
));
|
||||||
} else if let Some(upstream) = start_matches.get_one::<String>("upstream") {
|
} else if let Some(upstream) = start_matches.get_one::<String>("upstream") {
|
||||||
|
if url::Url::parse(upstream)
|
||||||
|
.is_ok_and(|parsed| !parsed.username().is_empty() || parsed.password().is_some())
|
||||||
|
{
|
||||||
|
eprintln!("Credentialed upstream URLs are not accepted as process arguments");
|
||||||
|
process::exit(2);
|
||||||
|
}
|
||||||
upstream_url = Some(upstream.clone());
|
upstream_url = Some(upstream.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +242,7 @@ async fn main() {
|
|||||||
.and_then(|s| serde_json::from_str(s).ok())
|
.and_then(|s| serde_json::from_str(s).ok())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
|
let blocklist_file = start_matches.get_one::<String>("blocklist-file").cloned();
|
||||||
|
let dns_allowlist_mode = start_matches.get_flag("dns-allowlist-mode");
|
||||||
let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
|
let local_protocol = start_matches.get_one::<String>("local-protocol").cloned();
|
||||||
|
|
||||||
match start_proxy_process_with_profile(
|
match start_proxy_process_with_profile(
|
||||||
@@ -264,6 +251,7 @@ async fn main() {
|
|||||||
profile_id,
|
profile_id,
|
||||||
bypass_rules,
|
bypass_rules,
|
||||||
blocklist_file,
|
blocklist_file,
|
||||||
|
dns_allowlist_mode,
|
||||||
local_protocol,
|
local_protocol,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
@@ -277,7 +265,7 @@ async fn main() {
|
|||||||
"id": config.id,
|
"id": config.id,
|
||||||
"localPort": config.local_port,
|
"localPort": config.local_port,
|
||||||
"localUrl": config.local_url,
|
"localUrl": config.local_url,
|
||||||
"upstreamUrl": config.upstream_url,
|
"upstreamUrl": redacted_upstream(&config.upstream_url),
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
process::exit(0);
|
process::exit(0);
|
||||||
@@ -371,7 +359,7 @@ async fn main() {
|
|||||||
"Found config: id={}, port={:?}, upstream={}",
|
"Found config: id={}, port={:?}, upstream={}",
|
||||||
config.id,
|
config.id,
|
||||||
config.local_port,
|
config.local_port,
|
||||||
config.upstream_url
|
redacted_upstream(&config.upstream_url)
|
||||||
);
|
);
|
||||||
break config;
|
break config;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -642,6 +642,7 @@ mod tests {
|
|||||||
created_by_email: None,
|
created_by_email: None,
|
||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
|
clear_on_close: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
updated_at: None,
|
updated_at: None,
|
||||||
};
|
};
|
||||||
|
|||||||
+117
-102
@@ -34,24 +34,29 @@ impl BrowserRunner {
|
|||||||
crate::app_dirs::binaries_dir()
|
crate::app_dirs::binaries_dir()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the DNS blocklist level to a cached file path.
|
/// Resolve the DNS blocklist level to a cached file path plus whether that
|
||||||
/// If a level is set but the cache is missing, fetches on demand (blocks until done).
|
/// file should be treated as an allowlist. If a level is set but the cache
|
||||||
|
/// is missing, fetches/compiles on demand (blocks until done).
|
||||||
async fn resolve_blocklist_file(
|
async fn resolve_blocklist_file(
|
||||||
profile: &crate::profile::BrowserProfile,
|
profile: &crate::profile::BrowserProfile,
|
||||||
) -> Result<Option<String>, String> {
|
) -> Result<(Option<String>, bool), String> {
|
||||||
let Some(ref level_str) = profile.dns_blocklist else {
|
let Some(ref level_str) = profile.dns_blocklist else {
|
||||||
return Ok(None);
|
return Ok((None, false));
|
||||||
};
|
};
|
||||||
let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else {
|
let Some(level) = crate::dns_blocklist::BlocklistLevel::parse_level(level_str) else {
|
||||||
return Ok(None);
|
return Ok((None, false));
|
||||||
};
|
};
|
||||||
if level == crate::dns_blocklist::BlocklistLevel::None {
|
if level == crate::dns_blocklist::BlocklistLevel::None {
|
||||||
return Ok(None);
|
return Ok((None, false));
|
||||||
}
|
}
|
||||||
|
// Only the user's custom list can be an allowlist; the Hagezi tiers are
|
||||||
|
// always block lists.
|
||||||
|
let allowlist_mode = level == crate::dns_blocklist::BlocklistLevel::Custom
|
||||||
|
&& crate::dns_blocklist::CustomDnsConfig::load().allowlist_mode;
|
||||||
let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level)
|
let path = crate::dns_blocklist::BlocklistManager::ensure_cached(level)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?;
|
.map_err(|e| format!("Failed to fetch DNS blocklist: {e}"))?;
|
||||||
Ok(Some(path.to_string_lossy().to_string()))
|
Ok((Some(path.to_string_lossy().to_string()), allowlist_mode))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
|
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
|
||||||
@@ -110,10 +115,9 @@ impl BrowserRunner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let url = parsed.to_string();
|
let url = parsed.to_string();
|
||||||
let profile_name = profile.name.clone();
|
let url_label = crate::log_redaction::url_label(&url);
|
||||||
let profile_id = profile.id.to_string();
|
|
||||||
|
|
||||||
log::info!("Firing launch hook GET {url} for profile {profile_name} (ID: {profile_id})");
|
log::info!("Firing launch hook GET {url_label}");
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let client = match reqwest::Client::builder()
|
let client = match reqwest::Client::builder()
|
||||||
@@ -122,20 +126,23 @@ impl BrowserRunner {
|
|||||||
{
|
{
|
||||||
Ok(c) => c,
|
Ok(c) => c,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Launch hook client build failed for {url}: {e}");
|
log::warn!(
|
||||||
|
"Launch hook client build failed: {}",
|
||||||
|
crate::log_redaction::text(&e.to_string())
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
match client.get(&url).send().await {
|
match client.get(&url).send().await {
|
||||||
Ok(resp) => {
|
Ok(resp) => {
|
||||||
log::info!(
|
log::info!("Launch hook {url_label} returned status {}", resp.status());
|
||||||
"Launch hook {url} for profile {profile_name} returned status {}",
|
|
||||||
resp.status()
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::warn!("Launch hook {url} for profile {profile_name} failed: {e}");
|
log::warn!(
|
||||||
|
"Launch hook {url_label} failed: {}",
|
||||||
|
crate::log_redaction::text(&e.to_string())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -247,15 +254,20 @@ impl BrowserRunner {
|
|||||||
// Start the proxy and get local proxy settings
|
// Start the proxy and get local proxy settings
|
||||||
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
|
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
|
||||||
let profile_id_str = profile.id.to_string();
|
let profile_id_str = profile.id.to_string();
|
||||||
let blocklist_file = Self::resolve_blocklist_file(profile).await?;
|
let (blocklist_file, dns_allowlist_mode) = Self::resolve_blocklist_file(profile).await?;
|
||||||
|
// Unique per-launch key: a shared constant here would let concurrent
|
||||||
|
// launches overwrite each other's active_proxies entry, ending with one
|
||||||
|
// browser's worker tracked under another browser's PID.
|
||||||
|
let launch_placeholder_pid = crate::proxy_manager::next_launch_placeholder_pid();
|
||||||
let local_proxy = PROXY_MANAGER
|
let local_proxy = PROXY_MANAGER
|
||||||
.start_proxy(
|
.start_proxy(
|
||||||
app_handle.clone(),
|
app_handle.clone(),
|
||||||
upstream_proxy.as_ref(),
|
upstream_proxy.as_ref(),
|
||||||
0, // Use 0 as temporary PID, will be updated later
|
launch_placeholder_pid,
|
||||||
Some(&profile_id_str),
|
Some(&profile_id_str),
|
||||||
profile.proxy_bypass_rules.clone(),
|
profile.proxy_bypass_rules.clone(),
|
||||||
blocklist_file,
|
blocklist_file,
|
||||||
|
dns_allowlist_mode,
|
||||||
// Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC
|
// Wayfern (Chromium) uses a local SOCKS5 proxy so QUIC and WebRTC
|
||||||
// UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without
|
// UDP can be routed through it (via SOCKS5 UDP ASSOCIATE) without
|
||||||
// leaking the real IP, rather than being forced direct as they
|
// leaking the real IP, rather than being forced direct as they
|
||||||
@@ -264,11 +276,45 @@ impl BrowserRunner {
|
|||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
let error_msg = format!("Failed to start local proxy for Wayfern: {e}");
|
let error_msg = crate::wrap_backend_error(e, "Failed to start local proxy for Wayfern");
|
||||||
log::error!("{}", error_msg);
|
log::error!("{}", error_msg);
|
||||||
error_msg
|
error_msg
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// If any step below fails before the browser is up, the detached worker
|
||||||
|
// must be stopped here: its config never gets a browser_pid, so neither
|
||||||
|
// the GUI sweeps nor the worker's own watchdog would ever reap it — it
|
||||||
|
// would survive until machine reboot.
|
||||||
|
struct ProxyLaunchGuard {
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
placeholder_pid: u32,
|
||||||
|
profile_name: String,
|
||||||
|
armed: bool,
|
||||||
|
}
|
||||||
|
impl Drop for ProxyLaunchGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if self.armed {
|
||||||
|
log::warn!(
|
||||||
|
"Launch failed after local proxy start for profile {}; stopping proxy worker",
|
||||||
|
self.profile_name
|
||||||
|
);
|
||||||
|
let app_handle = self.app_handle.clone();
|
||||||
|
let pid = self.placeholder_pid;
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
if let Err(e) = PROXY_MANAGER.stop_proxy(app_handle, pid).await {
|
||||||
|
log::warn!("Failed to stop proxy worker after failed launch: {e}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let mut proxy_launch_guard = ProxyLaunchGuard {
|
||||||
|
app_handle: app_handle.clone(),
|
||||||
|
placeholder_pid: launch_placeholder_pid,
|
||||||
|
profile_name: profile.name.clone(),
|
||||||
|
armed: true,
|
||||||
|
};
|
||||||
|
|
||||||
// Format proxy URL for wayfern - use SOCKS5 for the local proxy so
|
// Format proxy URL for wayfern - use SOCKS5 for the local proxy so
|
||||||
// Chromium proxies UDP (QUIC/WebRTC), not just TCP.
|
// Chromium proxies UDP (QUIC/WebRTC), not just TCP.
|
||||||
let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port);
|
let proxy_url = format!("socks5://{}:{}", local_proxy.host, local_proxy.port);
|
||||||
@@ -317,11 +363,10 @@ impl BrowserRunner {
|
|||||||
if wayfern_config.os.is_some() {
|
if wayfern_config.os.is_some() {
|
||||||
updated_wayfern_config.os = wayfern_config.os.clone();
|
updated_wayfern_config.os = wayfern_config.os.clone();
|
||||||
}
|
}
|
||||||
// The fresh fingerprint's location matches the current routing; record
|
// Record which routing this fresh fingerprint's geolocation was built
|
||||||
// its signature so launches keep it in sync with the non-randomize
|
// for (provenance only — nothing rewrites the fingerprint from it).
|
||||||
// path. Only when geolocation actually applied — otherwise leave it
|
// Only when geolocation actually applied; otherwise leave it unset so a
|
||||||
// unset so the refresh path can repair the location if the user later
|
// later on-demand match can tell the location was never resolved.
|
||||||
// turns randomize off.
|
|
||||||
updated_wayfern_config.geo_proxy_signature = if geolocation_applied {
|
updated_wayfern_config.geo_proxy_signature = if geolocation_applied {
|
||||||
Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
Some(crate::wayfern_manager::WayfernManager::geo_signature(
|
||||||
upstream_proxy.as_ref(),
|
upstream_proxy.as_ref(),
|
||||||
@@ -338,63 +383,15 @@ impl BrowserRunner {
|
|||||||
profile.name,
|
profile.name,
|
||||||
updated_wayfern_config.fingerprint.as_ref().map(|f| f.len()).unwrap_or(0)
|
updated_wayfern_config.fingerprint.as_ref().map(|f| f.len()).unwrap_or(0)
|
||||||
);
|
);
|
||||||
} else {
|
|
||||||
// Safety net: the stored fingerprint's timezone and geolocation were
|
|
||||||
// computed for whatever proxy was set when the fingerprint was
|
|
||||||
// generated. If the profile's proxy or VPN has changed since (the
|
|
||||||
// common case being a user who forgot to set a proxy at creation and
|
|
||||||
// added one afterwards), that location data is stale and the user would
|
|
||||||
// see the wrong timezone on first launch. When the routing signature no
|
|
||||||
// longer matches, refresh just the location fields of the stored
|
|
||||||
// fingerprint through the current proxy. Wayfern only; the randomize
|
|
||||||
// path above already regenerates the whole fingerprint each launch.
|
|
||||||
let current_geo_sig = crate::wayfern_manager::WayfernManager::geo_signature(
|
|
||||||
upstream_proxy.as_ref(),
|
|
||||||
profile.vpn_id.as_deref(),
|
|
||||||
wayfern_config.geoip.as_ref(),
|
|
||||||
);
|
|
||||||
let geo_enabled = !matches!(
|
|
||||||
wayfern_config.geoip.as_ref(),
|
|
||||||
Some(serde_json::Value::Bool(false))
|
|
||||||
);
|
|
||||||
if geo_enabled
|
|
||||||
&& wayfern_config.geo_proxy_signature.as_deref() != Some(current_geo_sig.as_str())
|
|
||||||
{
|
|
||||||
if let Some(stored_fp) = wayfern_config.fingerprint.clone() {
|
|
||||||
log::info!(
|
|
||||||
"Routing changed for Wayfern profile {} since its fingerprint was generated (was {:?}, now {}); refreshing timezone and geolocation",
|
|
||||||
profile.name,
|
|
||||||
wayfern_config.geo_proxy_signature,
|
|
||||||
current_geo_sig
|
|
||||||
);
|
|
||||||
match crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
|
|
||||||
&stored_fp,
|
|
||||||
wayfern_config.proxy.as_deref(),
|
|
||||||
wayfern_config.geoip.as_ref(),
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
Some(refreshed) => {
|
|
||||||
// Use the refreshed fingerprint for this launch...
|
|
||||||
wayfern_config.fingerprint = Some(refreshed.clone());
|
|
||||||
wayfern_config.geo_proxy_signature = Some(current_geo_sig.clone());
|
|
||||||
// ...and persist it so the corrected location sticks and we do
|
|
||||||
// not refresh again on the next launch with the same proxy.
|
|
||||||
let mut cfg = updated_profile.wayfern_config.clone().unwrap_or_default();
|
|
||||||
cfg.fingerprint = Some(refreshed);
|
|
||||||
cfg.geo_proxy_signature = Some(current_geo_sig);
|
|
||||||
updated_profile.wayfern_config = Some(cfg);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
log::warn!(
|
|
||||||
"Could not refresh geolocation for Wayfern profile {} (proxy unreachable?); launching with existing location and will retry next launch",
|
|
||||||
profile.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
// A non-randomize profile keeps its configured fingerprint verbatim, even
|
||||||
|
// when its proxy/VPN routing has changed since the fingerprint was built.
|
||||||
|
// We deliberately do NOT silently rewrite its timezone/language to match
|
||||||
|
// the new exit: that hid every real fingerprint-vs-exit mismatch (a US
|
||||||
|
// fingerprint behind a German exit would be quietly relabelled German
|
||||||
|
// before the launch-time consistency check could see it). The check now
|
||||||
|
// surfaces the mismatch, and the user re-matches on demand via
|
||||||
|
// `match_profile_fingerprint_to_exit`.
|
||||||
|
|
||||||
// Create ephemeral dir for ephemeral or password-protected profiles
|
// Create ephemeral dir for ephemeral or password-protected profiles
|
||||||
if profile.password_protected {
|
if profile.password_protected {
|
||||||
@@ -457,6 +454,10 @@ impl BrowserRunner {
|
|||||||
format!("Failed to launch Wayfern: {e}").into()
|
format!("Failed to launch Wayfern: {e}").into()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Browser is up and using the worker — failures past this point must
|
||||||
|
// not stop it.
|
||||||
|
proxy_launch_guard.armed = false;
|
||||||
|
|
||||||
// Get the process ID from launch result
|
// Get the process ID from launch result
|
||||||
let process_id = wayfern_result.processId.unwrap_or(0);
|
let process_id = wayfern_result.processId.unwrap_or(0);
|
||||||
log::info!("Wayfern launched successfully with PID: {process_id}");
|
log::info!("Wayfern launched successfully with PID: {process_id}");
|
||||||
@@ -483,11 +484,18 @@ impl BrowserRunner {
|
|||||||
updated_profile.process_id = Some(process_id);
|
updated_profile.process_id = Some(process_id);
|
||||||
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
||||||
|
|
||||||
// Update the proxy manager with the correct PID
|
// Update the proxy manager with the correct PID. When the browser
|
||||||
if let Err(e) = PROXY_MANAGER.update_proxy_pid(0, process_id) {
|
// reported no PID, keep the entry keyed by its unique placeholder (which
|
||||||
log::warn!("Warning: Failed to update proxy PID mapping: {e}");
|
// the cleanup sweep skips) rather than remapping to a shared 0 key that
|
||||||
} else {
|
// concurrent launches could collide on.
|
||||||
log::info!("Updated proxy PID mapping from temp (0) to actual PID: {process_id}");
|
if process_id != 0 {
|
||||||
|
if let Err(e) = PROXY_MANAGER.update_proxy_pid(launch_placeholder_pid, process_id) {
|
||||||
|
log::warn!("Warning: Failed to update proxy PID mapping: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!(
|
||||||
|
"Updated proxy PID mapping from launch placeholder {launch_placeholder_pid} to actual PID: {process_id}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist the real browser PID so the detached proxy worker self-reaps
|
// Persist the real browser PID so the detached proxy worker self-reaps
|
||||||
@@ -669,18 +677,18 @@ impl BrowserRunner {
|
|||||||
.unwrap_or_else(|| updated_profile.clone());
|
.unwrap_or_else(|| updated_profile.clone());
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
"Browser status check - Profile: {} (ID: {}), Running: {}, URL: {:?}, PID: {:?}",
|
"Browser status check: running={is_running}, URL requested={}, PID present={}",
|
||||||
final_profile.name,
|
url.is_some(),
|
||||||
final_profile.id,
|
final_profile.process_id.is_some()
|
||||||
is_running,
|
|
||||||
url,
|
|
||||||
final_profile.process_id
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if is_running && url.is_some() {
|
if is_running && url.is_some() {
|
||||||
// Browser is running and we have a URL to open
|
// Browser is running and we have a URL to open
|
||||||
if let Some(url_ref) = url.as_ref() {
|
if let Some(url_ref) = url.as_ref() {
|
||||||
log::info!("Opening URL in existing browser: {url_ref}");
|
log::info!(
|
||||||
|
"Opening {} in existing browser",
|
||||||
|
crate::log_redaction::url_label(url_ref)
|
||||||
|
);
|
||||||
|
|
||||||
match self
|
match self
|
||||||
.open_url_in_existing_browser(
|
.open_url_in_existing_browser(
|
||||||
@@ -696,7 +704,10 @@ impl BrowserRunner {
|
|||||||
Ok(final_profile)
|
Ok(final_profile)
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
log::info!("Failed to open URL in existing browser: {e}");
|
log::info!(
|
||||||
|
"Failed to open URL in existing browser: {}",
|
||||||
|
crate::log_redaction::text(&e.to_string())
|
||||||
|
);
|
||||||
|
|
||||||
// Fall back to launching a new instance
|
// Fall back to launching a new instance
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -1096,6 +1107,10 @@ impl BrowserRunner {
|
|||||||
crate::profile::password::complete_after_quit_and_wait(profile).await;
|
crate::profile::password::complete_after_quit_and_wait(profile).await;
|
||||||
} else if profile.ephemeral {
|
} else if profile.ephemeral {
|
||||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||||
|
} else if profile.clear_on_close {
|
||||||
|
// Awaited for the same reason as re-encryption above: a queued sync
|
||||||
|
// must see the cleared dir, not the pre-clear snapshot.
|
||||||
|
crate::profile::clear_on_close::clear_profile_browsing_data(profile).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -1153,18 +1168,21 @@ impl BrowserRunner {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("Opening URL '{url}' with profile '{profile_id}'");
|
log::info!("Opening URL with selected profile");
|
||||||
|
|
||||||
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
|
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
|
||||||
self
|
self
|
||||||
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
|
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
log::info!("Failed to open URL with profile '{profile_id}': {e}");
|
log::info!(
|
||||||
|
"Failed to open URL with selected profile: {}",
|
||||||
|
crate::log_redaction::text(&e.to_string())
|
||||||
|
);
|
||||||
format!("Failed to open URL with profile: {e}")
|
format!("Failed to open URL with profile: {e}")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
log::info!("Successfully opened URL '{url}' with profile '{profile_id}'");
|
log::info!("Successfully opened URL with selected profile");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1287,7 +1305,7 @@ pub async fn launch_browser_profile_impl(
|
|||||||
return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH);
|
return format!("Failed to launch browser: Executable format error. This browser version is not compatible with your system architecture ({}). Please try a different browser or version that supports your platform.", std::env::consts::ARCH);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
format!("Failed to launch browser or open URL: {e}")
|
crate::wrap_backend_error(e, "Failed to launch browser or open URL")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
log::info!(
|
log::info!(
|
||||||
@@ -1296,11 +1314,8 @@ pub async fn launch_browser_profile_impl(
|
|||||||
updated_profile.id
|
updated_profile.id
|
||||||
);
|
);
|
||||||
|
|
||||||
// Now update the proxy with the correct PID if we have one
|
// The proxy PID mapping was already reconciled inside launch_browser_internal
|
||||||
if let Some(actual_pid) = updated_profile.process_id {
|
// (placeholder → real browser PID); nothing is ever keyed by a constant here.
|
||||||
// Update the proxy manager with the correct PID (we always started with temp pid 1)
|
|
||||||
let _ = PROXY_MANAGER.update_proxy_pid(1u32, actual_pid);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(updated_profile)
|
Ok(updated_profile)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ impl BrowserVersionManager {
|
|||||||
self.api_client.is_cache_expired(browser)
|
self.api_client.is_cache_expired(browser)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the latest Wayfern version (cached first)
|
/// Get the latest Wayfern version (fresh cache first)
|
||||||
pub async fn get_browser_release_types(
|
pub async fn get_browser_release_types(
|
||||||
&self,
|
&self,
|
||||||
browser: &str,
|
browser: &str,
|
||||||
@@ -118,17 +118,30 @@ impl BrowserVersionManager {
|
|||||||
return Err(format!("Unsupported browser: {browser}").into());
|
return Err(format!("Unsupported browser: {browser}").into());
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) {
|
// Only trust an unexpired cache. A stale entry can point at a version that
|
||||||
return Ok(BrowserReleaseTypes {
|
// is no longer published — the downloader rejects such requests, so serving
|
||||||
stable: cached_versions.first().map(|v| v.version.clone()),
|
// it here would make every download started from this list fail.
|
||||||
});
|
if !self.api_client.is_cache_expired(browser) {
|
||||||
|
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) {
|
||||||
|
return Ok(BrowserReleaseTypes {
|
||||||
|
stable: cached_versions.first().map(|v| v.version.clone()),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?;
|
// Expired or missing cache: fetch fresh, falling back to whatever cache
|
||||||
|
// exists when the network is unavailable.
|
||||||
Ok(BrowserReleaseTypes {
|
match self.fetch_browser_versions_detailed(browser, false).await {
|
||||||
stable: detailed_versions.first().map(|v| v.version.clone()),
|
Ok(detailed_versions) => Ok(BrowserReleaseTypes {
|
||||||
})
|
stable: detailed_versions.first().map(|v| v.version.clone()),
|
||||||
|
}),
|
||||||
|
Err(e) => match self.get_cached_browser_versions_detailed(browser) {
|
||||||
|
Some(cached_versions) => Ok(BrowserReleaseTypes {
|
||||||
|
stable: cached_versions.first().map(|v| v.version.clone()),
|
||||||
|
}),
|
||||||
|
None => Err(e),
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch browser versions with optional caching
|
/// Fetch browser versions with optional caching
|
||||||
@@ -440,7 +453,7 @@ mod tests {
|
|||||||
|
|
||||||
assert!(wayfern_info.url.contains("download.wayfern.com"));
|
assert!(wayfern_info.url.contains("download.wayfern.com"));
|
||||||
|
|
||||||
let unsupported_result = service.get_download_info("firefox", "1.0.0");
|
let unsupported_result = service.get_download_info("testbrowser", "1.0.0");
|
||||||
assert!(unsupported_result.is_err());
|
assert!(unsupported_result.is_err());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+32
-19
@@ -609,9 +609,8 @@ impl CloudAuthManager {
|
|||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
log::warn!("Token refresh failed ({status})");
|
||||||
log::warn!("Token refresh failed ({status}): {body}");
|
return Err(format!("Token refresh failed ({status})"));
|
||||||
return Err(format!("Token refresh failed ({status}): {body}"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: RefreshTokenResponse = response
|
let result: RefreshTokenResponse = response
|
||||||
@@ -779,6 +778,13 @@ impl CloudAuthManager {
|
|||||||
|
|
||||||
/// Launch/drive profiles programmatically (local API + MCP automation).
|
/// Launch/drive profiles programmatically (local API + MCP automation).
|
||||||
pub async fn can_use_browser_automation(&self) -> bool {
|
pub async fn can_use_browser_automation(&self) -> bool {
|
||||||
|
#[cfg(feature = "e2e")]
|
||||||
|
if crate::e2e_automation_enabled()
|
||||||
|
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
self
|
self
|
||||||
.entitlements()
|
.entitlements()
|
||||||
.await
|
.await
|
||||||
@@ -788,6 +794,13 @@ impl CloudAuthManager {
|
|||||||
|
|
||||||
/// Edit fingerprints / use a non-native OS fingerprint.
|
/// Edit fingerprints / use a non-native OS fingerprint.
|
||||||
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
|
pub async fn can_use_cross_os_fingerprints(&self) -> bool {
|
||||||
|
#[cfg(feature = "e2e")]
|
||||||
|
if crate::e2e_automation_enabled()
|
||||||
|
&& std::env::var_os("WAYFERN_TEST_TOKEN").is_some_and(|token| !token.is_empty())
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
self
|
self
|
||||||
.entitlements()
|
.entitlements()
|
||||||
.await
|
.await
|
||||||
@@ -909,14 +922,12 @@ impl CloudAuthManager {
|
|||||||
|
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
if status == reqwest::StatusCode::FORBIDDEN {
|
if status == reqwest::StatusCode::FORBIDDEN {
|
||||||
let body = response.text().await.unwrap_or_default();
|
log::warn!("Proxy config returned 403");
|
||||||
log::warn!("Proxy config returned 403: {body}");
|
|
||||||
return Err("__403__".to_string());
|
return Err("__403__".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let body = response.text().await.unwrap_or_default();
|
return Err(format!("Proxy config fetch failed ({status})"));
|
||||||
return Err(format!("Proxy config fetch failed ({status}): {body}"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
response
|
response
|
||||||
@@ -1178,8 +1189,7 @@ impl CloudAuthManager {
|
|||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
let status = response.status();
|
let status = response.status();
|
||||||
let body = response.text().await.unwrap_or_default();
|
return Err(format!("Wayfern token request failed ({status})"));
|
||||||
return Err(format!("Wayfern token request failed ({status}): {body}"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let result: WayfernTokenResponse = response
|
let result: WayfernTokenResponse = response
|
||||||
@@ -1195,17 +1205,10 @@ impl CloudAuthManager {
|
|||||||
let token = match result {
|
let token = match result {
|
||||||
Ok(token) => token,
|
Ok(token) => token,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
// The backend returns 403 (ForbiddenException) for paid-feature blocks:
|
// A 403 rejects the entitlement without invalidating the login session.
|
||||||
// token-reuse throttle, "active subscription required", and the
|
// Clear the browser token and refresh account state before notifying UI.
|
||||||
// primary-device restriction (see donutbrowser-infra wayfern.service.ts).
|
|
||||||
// This is distinct from a 401 (dead access token) — the session is still
|
|
||||||
// valid, the user is just temporarily/conditionally not entitled. So we
|
|
||||||
// do NOT invalidate the session. Instead: drop the stale wayfern token so
|
|
||||||
// no browser launches half-authenticated, re-fetch the profile so the
|
|
||||||
// cached plan reflects the backend's real state (it may have changed),
|
|
||||||
// and signal the UI so the user learns why automation stopped working.
|
|
||||||
if e.contains("(403") || e.contains("Forbidden") {
|
if e.contains("(403") || e.contains("Forbidden") {
|
||||||
log::warn!("Wayfern token blocked by backend (403): {e}");
|
log::warn!("Wayfern token blocked by backend (403)");
|
||||||
self.clear_wayfern_token().await;
|
self.clear_wayfern_token().await;
|
||||||
if let Err(fetch_err) = self.fetch_profile().await {
|
if let Err(fetch_err) = self.fetch_profile().await {
|
||||||
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
|
log::warn!("Profile re-fetch after wayfern block failed: {fetch_err}");
|
||||||
@@ -1224,6 +1227,16 @@ impl CloudAuthManager {
|
|||||||
|
|
||||||
/// Get the current wayfern token, if any.
|
/// Get the current wayfern token, if any.
|
||||||
pub async fn get_wayfern_token(&self) -> Option<String> {
|
pub async fn get_wayfern_token(&self) -> Option<String> {
|
||||||
|
#[cfg(feature = "e2e")]
|
||||||
|
if crate::e2e_automation_enabled() {
|
||||||
|
if let Some(token) = std::env::var_os("WAYFERN_TEST_TOKEN")
|
||||||
|
.filter(|token| !token.is_empty())
|
||||||
|
.and_then(|token| token.into_string().ok())
|
||||||
|
{
|
||||||
|
return Some(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let wt = self.wayfern_token.lock().await;
|
let wt = self.wayfern_token.lock().await;
|
||||||
wt.clone()
|
wt.clone()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1057,6 +1057,23 @@ impl CookieManager {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const SYNTHETIC_COOKIE_HOST: &str = ".example.test";
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const SYNTHETIC_COOKIE_VALUE: &str = "synthetic-cookie-value";
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const SYNTHETIC_OS_CRYPT_PASSWORD: &[u8] = b"donut-synthetic-cookie-key";
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
const SYNTHETIC_ENCRYPTED_COOKIE_HEX: &str = "763130d83b9fd3e6d1b1c793769f55251f5e9d1193be72c0c08ea32e2cf068a85d9d0b97d8b2e6deca93a2b3c290e98e1a851f83d5566f9aa9314befe56dc6bdbd423d";
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn synthetic_encrypted_cookie() -> Vec<u8> {
|
||||||
|
(0..SYNTHETIC_ENCRYPTED_COOKIE_HEX.len())
|
||||||
|
.step_by(2)
|
||||||
|
.map(|i| u8::from_str_radix(&SYNTHETIC_ENCRYPTED_COOKIE_HEX[i..i + 2], 16).unwrap())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_netscape_cookies_valid() {
|
fn test_parse_netscape_cookies_valid() {
|
||||||
let content = "# Netscape HTTP Cookie File\n\
|
let content = "# Netscape HTTP Cookie File\n\
|
||||||
@@ -1427,7 +1444,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
// Imported session cookies are promoted to persistent with a far-future
|
// Imported session cookies are promoted to persistent with a far-future
|
||||||
// expiry so an imported login survives relaunch (mirrors the Firefox writer).
|
// expiry so an imported login survives relaunch.
|
||||||
assert_eq!(has_expires, 1);
|
assert_eq!(has_expires, 1);
|
||||||
assert_eq!(is_persistent, 1);
|
assert_eq!(is_persistent, 1);
|
||||||
// Must be a real future expiry, not 0 (which Chromium reads as 1601-01-01).
|
// Must be a real future expiry, not 0 (which Chromium reads as 1601-01-01).
|
||||||
@@ -1482,50 +1499,29 @@ mod tests {
|
|||||||
let _ = std::fs::remove_file(&tmp);
|
let _ = std::fs::remove_file(&tmp);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Regression: decrypting a real v10-encrypted Chromium cookie with the
|
|
||||||
/// correct PBKDF2 iterations and the `SHA-256(host_key)` integrity-prefix
|
|
||||||
/// strip. Captured from a real Wayfern profile:
|
|
||||||
/// host_key = ".github.com"
|
|
||||||
/// name = "_octo"
|
|
||||||
/// password = "OSfgzI5GUqy/pK4ANrYugw==" (contents of os_crypt_key)
|
|
||||||
/// value = "GH1.1.2077424036.1774792325"
|
|
||||||
///
|
|
||||||
/// If PBKDF2 iterations or the host-hash prefix handling ever regress,
|
|
||||||
/// this test fails and we instantly know why all copied cookies end up
|
|
||||||
/// with empty values — which is exactly the bug that shipped and made
|
|
||||||
/// issue-265-style silent failures reappear.
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn test_decrypt_v10_cookie_with_real_vector() {
|
fn test_decrypt_v10_cookie_with_synthetic_vector() {
|
||||||
let profile_dir =
|
let profile_dir =
|
||||||
std::env::temp_dir().join(format!("donut_decrypt_vector_{}", uuid::Uuid::new_v4()));
|
std::env::temp_dir().join(format!("donut_decrypt_vector_{}", uuid::Uuid::new_v4()));
|
||||||
std::fs::create_dir_all(&profile_dir).unwrap();
|
std::fs::create_dir_all(&profile_dir).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
profile_dir.join("os_crypt_key"),
|
profile_dir.join("os_crypt_key"),
|
||||||
b"OSfgzI5GUqy/pK4ANrYugw==",
|
SYNTHETIC_OS_CRYPT_PASSWORD,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let key = chrome_decrypt::get_encryption_key(&profile_dir)
|
let key = chrome_decrypt::get_encryption_key(&profile_dir)
|
||||||
.expect("should derive key from os_crypt_key file");
|
.expect("should derive key from os_crypt_key file");
|
||||||
|
|
||||||
let encrypted_hex = "76313077ad5b27e78f685a6ccc7b92a8a242e279e54b8d2ba8e55b433ca7e2421bec52369e29a57b593c02c839f50962245da3ed8617dce142fff67778950a271d2c07";
|
let decrypted =
|
||||||
let encrypted: Vec<u8> = (0..encrypted_hex.len())
|
chrome_decrypt::decrypt(&synthetic_encrypted_cookie(), SYNTHETIC_COOKIE_HOST, &key)
|
||||||
.step_by(2)
|
.expect("decryption must succeed with correct key and host");
|
||||||
.map(|i| u8::from_str_radix(&encrypted_hex[i..i + 2], 16).unwrap())
|
assert_eq!(decrypted, SYNTHETIC_COOKIE_VALUE);
|
||||||
.collect();
|
|
||||||
|
|
||||||
let decrypted = chrome_decrypt::decrypt(&encrypted, ".github.com", &key)
|
|
||||||
.expect("decryption must succeed with correct key and host");
|
|
||||||
assert_eq!(decrypted, "GH1.1.2077424036.1774792325");
|
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&profile_dir);
|
let _ = std::fs::remove_dir_all(&profile_dir);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sanity: decrypting with the wrong host_key (hash mismatch) must not
|
|
||||||
/// return a half-garbage value — it should fall back to the full
|
|
||||||
/// decrypted bytes, which for a modern cookie includes the 32-byte hash
|
|
||||||
/// prefix and therefore won't be valid UTF-8 → `None`.
|
|
||||||
#[test]
|
#[test]
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(target_os = "macos")]
|
||||||
fn test_decrypt_with_wrong_host_returns_none_or_raw() {
|
fn test_decrypt_with_wrong_host_returns_none_or_raw() {
|
||||||
@@ -1534,25 +1530,16 @@ mod tests {
|
|||||||
std::fs::create_dir_all(&profile_dir).unwrap();
|
std::fs::create_dir_all(&profile_dir).unwrap();
|
||||||
std::fs::write(
|
std::fs::write(
|
||||||
profile_dir.join("os_crypt_key"),
|
profile_dir.join("os_crypt_key"),
|
||||||
b"OSfgzI5GUqy/pK4ANrYugw==",
|
SYNTHETIC_OS_CRYPT_PASSWORD,
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
let key = chrome_decrypt::get_encryption_key(&profile_dir).unwrap();
|
let key = chrome_decrypt::get_encryption_key(&profile_dir).unwrap();
|
||||||
let encrypted_hex = "76313077ad5b27e78f685a6ccc7b92a8a242e279e54b8d2ba8e55b433ca7e2421bec52369e29a57b593c02c839f50962245da3ed8617dce142fff67778950a271d2c07";
|
let result =
|
||||||
let encrypted: Vec<u8> = (0..encrypted_hex.len())
|
chrome_decrypt::decrypt(&synthetic_encrypted_cookie(), ".wrong.example.test", &key);
|
||||||
.step_by(2)
|
|
||||||
.map(|i| u8::from_str_radix(&encrypted_hex[i..i + 2], 16).unwrap())
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
// Wrong host: the prefix won't match, so we fall through to
|
|
||||||
// `String::from_utf8(full_decrypted)` which fails on the binary hash
|
|
||||||
// bytes and returns `None`. Either way, we must NOT return the real
|
|
||||||
// value "GH1.1.2077424036.1774792325".
|
|
||||||
let result = chrome_decrypt::decrypt(&encrypted, ".facebook.com", &key);
|
|
||||||
assert!(
|
assert!(
|
||||||
result.as_deref() != Some("GH1.1.2077424036.1774792325"),
|
result.as_deref() != Some(SYNTHETIC_COOKIE_VALUE),
|
||||||
"decrypt must not return the real cookie value when host_key is wrong"
|
"decrypt must not return the cookie value when host_key is wrong"
|
||||||
);
|
);
|
||||||
|
|
||||||
let _ = std::fs::remove_dir_all(&profile_dir);
|
let _ = std::fs::remove_dir_all(&profile_dir);
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ pub enum BlocklistLevel {
|
|||||||
Pro,
|
Pro,
|
||||||
ProPlus,
|
ProPlus,
|
||||||
Ultimate,
|
Ultimate,
|
||||||
|
/// User-defined list: compiled from custom source URLs + custom block
|
||||||
|
/// domains, with custom allow domains removed (allowlist overrides).
|
||||||
|
Custom,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BlocklistLevel {
|
impl BlocklistLevel {
|
||||||
@@ -26,6 +29,7 @@ impl BlocklistLevel {
|
|||||||
"pro" => Some(Self::Pro),
|
"pro" => Some(Self::Pro),
|
||||||
"pro_plus" => Some(Self::ProPlus),
|
"pro_plus" => Some(Self::ProPlus),
|
||||||
"ultimate" => Some(Self::Ultimate),
|
"ultimate" => Some(Self::Ultimate),
|
||||||
|
"custom" => Some(Self::Custom),
|
||||||
"none" => Some(Self::None),
|
"none" => Some(Self::None),
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
@@ -39,6 +43,7 @@ impl BlocklistLevel {
|
|||||||
Self::Pro => "pro",
|
Self::Pro => "pro",
|
||||||
Self::ProPlus => "pro_plus",
|
Self::ProPlus => "pro_plus",
|
||||||
Self::Ultimate => "ultimate",
|
Self::Ultimate => "ultimate",
|
||||||
|
Self::Custom => "custom",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,12 +55,13 @@ impl BlocklistLevel {
|
|||||||
Self::Pro => "Pro",
|
Self::Pro => "Pro",
|
||||||
Self::ProPlus => "Pro++",
|
Self::ProPlus => "Pro++",
|
||||||
Self::Ultimate => "Ultimate",
|
Self::Ultimate => "Ultimate",
|
||||||
|
Self::Custom => "Custom",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn url(&self) -> Option<&'static str> {
|
pub fn url(&self) -> Option<&'static str> {
|
||||||
match self {
|
match self {
|
||||||
Self::None => None,
|
Self::None | Self::Custom => None,
|
||||||
Self::Light => {
|
Self::Light => {
|
||||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
|
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
|
||||||
}
|
}
|
||||||
@@ -80,6 +86,7 @@ impl BlocklistLevel {
|
|||||||
Self::Pro => Some("pro.txt"),
|
Self::Pro => Some("pro.txt"),
|
||||||
Self::ProPlus => Some("pro.plus.txt"),
|
Self::ProPlus => Some("pro.plus.txt"),
|
||||||
Self::Ultimate => Some("ultimate.txt"),
|
Self::Ultimate => Some("ultimate.txt"),
|
||||||
|
Self::Custom => Some("custom.txt"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +101,150 @@ impl BlocklistLevel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// User-defined DNS filtering: extra blocklist source URLs plus manual block /
|
||||||
|
/// allow domain rules. Allow rules override blocks (the standard exceptions
|
||||||
|
/// model). Stored as one JSON blob; the compiled result lands in `custom.txt`.
|
||||||
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||||
|
pub struct CustomDnsConfig {
|
||||||
|
#[serde(default)]
|
||||||
|
pub sources: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub block_domains: Vec<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub allow_domains: Vec<String>,
|
||||||
|
/// When true the custom list is a strict allowlist: the browser may only
|
||||||
|
/// reach `allow_domains` (and their subdomains); everything else is blocked.
|
||||||
|
/// Sources/block_domains are ignored in this mode.
|
||||||
|
#[serde(default)]
|
||||||
|
pub allowlist_mode: bool,
|
||||||
|
#[serde(default)]
|
||||||
|
pub updated_at: Option<u64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalize_domain(raw: &str) -> Option<String> {
|
||||||
|
let mut line = raw.trim();
|
||||||
|
if line.is_empty() || line.starts_with('#') || line.starts_with('!') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Hosts-file format ("0.0.0.0 ads.example.com", "127.0.0.1 tracker.net") is
|
||||||
|
// common in public blocklists — take the domain after the sink IP rather
|
||||||
|
// than rejecting the whole line on the embedded space.
|
||||||
|
if let Some((first, rest)) = line.split_once(char::is_whitespace) {
|
||||||
|
if matches!(first, "0.0.0.0" | "127.0.0.1" | "::" | "::1") {
|
||||||
|
line = rest.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Strip a trailing comment ("0.0.0.0 ads.example.com # AdGuard"). Public
|
||||||
|
// hosts lists annotate entries this way; without this the whitespace guard
|
||||||
|
// below rejects every annotated line, silently dropping most of the source.
|
||||||
|
for marker in ['#', '!'] {
|
||||||
|
if let Some((before, _)) = line.split_once(marker) {
|
||||||
|
line = before.trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let d = line
|
||||||
|
.trim_start_matches("*.")
|
||||||
|
.trim_start_matches("||")
|
||||||
|
.trim_end_matches('^')
|
||||||
|
.trim_end_matches('.')
|
||||||
|
.to_lowercase();
|
||||||
|
if d.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
// Reject anything still containing whitespace or a scheme — not a bare domain.
|
||||||
|
if d.contains(char::is_whitespace) || d.contains("://") {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
Some(d)
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CustomDnsConfig {
|
||||||
|
fn path() -> PathBuf {
|
||||||
|
app_dirs::data_subdir().join("custom_dns.json")
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load() -> Self {
|
||||||
|
match std::fs::read_to_string(Self::path()) {
|
||||||
|
Ok(content) => serde_json::from_str(&content).unwrap_or_default(),
|
||||||
|
Err(_) => Self::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save(&self) -> Result<(), String> {
|
||||||
|
let path = Self::path();
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent).map_err(|e| e.to_string())?;
|
||||||
|
}
|
||||||
|
let json = serde_json::to_string_pretty(self).map_err(|e| e.to_string())?;
|
||||||
|
std::fs::write(&path, json).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialize to a plain-text rule list (uBlock-ish): `! source:` comments for
|
||||||
|
/// source URLs, `@@domain` for allow rules, bare domains for blocks.
|
||||||
|
pub fn to_txt(&self) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for s in &self.sources {
|
||||||
|
out.push_str("! source: ");
|
||||||
|
out.push_str(s);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
for d in &self.allow_domains {
|
||||||
|
out.push_str("@@");
|
||||||
|
out.push_str(d);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
for d in &self.block_domains {
|
||||||
|
out.push_str(d);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse a plain-text rule list back into a config (sources from
|
||||||
|
/// `! source:` lines, `@@`-prefixed as allow, bare domains as block).
|
||||||
|
pub fn from_txt(content: &str) -> Self {
|
||||||
|
let mut cfg = CustomDnsConfig::default();
|
||||||
|
for line in content.lines() {
|
||||||
|
let line = line.trim();
|
||||||
|
if line.is_empty() {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(src) = line.strip_prefix("! source:") {
|
||||||
|
let src = src.trim();
|
||||||
|
if !src.is_empty() {
|
||||||
|
cfg.sources.push(src.to_string());
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if line.starts_with('#') || line.starts_with('!') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(allow) = line.strip_prefix("@@") {
|
||||||
|
if let Some(d) = normalize_domain(allow) {
|
||||||
|
cfg.allow_domains.push(d);
|
||||||
|
}
|
||||||
|
} else if let Some(d) = normalize_domain(line) {
|
||||||
|
cfg.block_domains.push(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cfg.dedup();
|
||||||
|
cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop duplicates, preserving first-seen order so an exported rule list
|
||||||
|
/// still reads the way the user wrote it.
|
||||||
|
fn dedup(&mut self) {
|
||||||
|
for v in [
|
||||||
|
&mut self.sources,
|
||||||
|
&mut self.block_domains,
|
||||||
|
&mut self.allow_domains,
|
||||||
|
] {
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
v.retain(|item| seen.insert(item.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct BlocklistCacheStatus {
|
pub struct BlocklistCacheStatus {
|
||||||
pub level: String,
|
pub level: String,
|
||||||
@@ -144,9 +295,23 @@ impl BlocklistManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
|
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
|
||||||
let url = level
|
let production_url = level
|
||||||
.url()
|
.url()
|
||||||
.ok_or_else(|| format!("No URL for level {:?}", level))?;
|
.ok_or_else(|| format!("No URL for level {:?}", level))?;
|
||||||
|
#[cfg(feature = "e2e")]
|
||||||
|
let url = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
|
||||||
|
.ok()
|
||||||
|
.filter(|base| !base.is_empty())
|
||||||
|
.map(|base| {
|
||||||
|
format!(
|
||||||
|
"{}/{}",
|
||||||
|
base.trim_end_matches('/'),
|
||||||
|
level.filename().unwrap_or("blocklist.txt")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.unwrap_or_else(|| production_url.to_string());
|
||||||
|
#[cfg(not(feature = "e2e"))]
|
||||||
|
let url = production_url.to_string();
|
||||||
let path =
|
let path =
|
||||||
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
|
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
|
||||||
|
|
||||||
@@ -160,7 +325,7 @@ impl BlocklistManager {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let response = HTTP_CLIENT
|
let response = HTTP_CLIENT
|
||||||
.get(url)
|
.get(&url)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
|
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
|
||||||
@@ -193,6 +358,19 @@ impl BlocklistManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn ensure_cached(level: BlocklistLevel) -> Result<PathBuf, String> {
|
pub async fn ensure_cached(level: BlocklistLevel) -> Result<PathBuf, String> {
|
||||||
|
if level == BlocklistLevel::Custom {
|
||||||
|
// Recompile only when the compiled file is missing or stale. Edits call
|
||||||
|
// compile_custom_blocklist directly and refresh_all_stale keeps sources
|
||||||
|
// current, so recompiling unconditionally would re-download every source
|
||||||
|
// on every profile launch — and this is awaited on the blocking launch
|
||||||
|
// path, so the browser cannot start until it finishes.
|
||||||
|
if let Some(path) = Self::cached_file_path(level) {
|
||||||
|
if Self::is_cache_fresh(level) {
|
||||||
|
return Ok(path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Self::compile_custom_blocklist().await;
|
||||||
|
}
|
||||||
if let Some(path) = Self::cached_file_path(level) {
|
if let Some(path) = Self::cached_file_path(level) {
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
return Ok(path);
|
return Ok(path);
|
||||||
@@ -201,6 +379,120 @@ impl BlocklistManager {
|
|||||||
Self::fetch_blocklist(level).await
|
Self::fetch_blocklist(level).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Compile the user's custom DNS file. In blocklist mode: fetch every source,
|
||||||
|
/// union with the manual block domains, then remove the allow domains
|
||||||
|
/// (allowlist overrides). In allowlist mode: the file is just the allow
|
||||||
|
/// domains — the worker then blocks everything NOT listed. Always rewrites
|
||||||
|
/// `custom.txt`.
|
||||||
|
pub async fn compile_custom_blocklist() -> Result<PathBuf, String> {
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let config = CustomDnsConfig::load();
|
||||||
|
let mut domains: HashSet<String> = HashSet::new();
|
||||||
|
let path =
|
||||||
|
Self::cached_file_path(BlocklistLevel::Custom).ok_or("No filename for custom level")?;
|
||||||
|
|
||||||
|
if config.allowlist_mode {
|
||||||
|
// Strict allowlist: the compiled file is the set of permitted domains.
|
||||||
|
for d in &config.allow_domains {
|
||||||
|
if let Some(n) = normalize_domain(d) {
|
||||||
|
domains.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Fetch every source concurrently. Sequential awaits make this the sum of
|
||||||
|
// all source latencies, and it runs on the blocking profile-launch path.
|
||||||
|
let fetches = config.sources.iter().map(|source| async move {
|
||||||
|
match HTTP_CLIENT.get(source).send().await {
|
||||||
|
Ok(resp) if resp.status().is_success() => resp
|
||||||
|
.text()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("custom source {source} body read failed: {e}")),
|
||||||
|
Ok(resp) => Err(format!(
|
||||||
|
"custom source {source} returned HTTP {}",
|
||||||
|
resp.status()
|
||||||
|
)),
|
||||||
|
Err(e) => Err(format!("custom source {source} failed: {e}")),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut source_failures = 0usize;
|
||||||
|
for result in futures_util::future::join_all(fetches).await {
|
||||||
|
match result {
|
||||||
|
Ok(body) => {
|
||||||
|
for line in body.lines() {
|
||||||
|
if let Some(d) = normalize_domain(line) {
|
||||||
|
domains.insert(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("[dns-blocklist] {e}");
|
||||||
|
source_failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A failed source must never shrink the compiled list. Overwriting with a
|
||||||
|
// short (or empty) result would silently stop blocking whatever that
|
||||||
|
// source contributed — and `is_blocked` fails open on an empty set, so a
|
||||||
|
// single offline launch would disable custom filtering entirely and
|
||||||
|
// destroy the good cached list. Re-seed from the cached file so a network
|
||||||
|
// failure degrades to "stale" instead of "off"; manual rule edits below
|
||||||
|
// still apply, and the next successful compile rewrites cleanly.
|
||||||
|
if source_failures > 0 {
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(cached) => {
|
||||||
|
let before = domains.len();
|
||||||
|
for line in cached.lines() {
|
||||||
|
if let Some(d) = normalize_domain(line) {
|
||||||
|
domains.insert(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
log::warn!(
|
||||||
|
"[dns-blocklist] {source_failures} custom source(s) failed; retained {} domain(s) from the cached list",
|
||||||
|
domains.len().saturating_sub(before)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(_) => log::warn!(
|
||||||
|
"[dns-blocklist] {source_failures} custom source(s) failed and no cached list exists; custom filtering is incomplete"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for d in &config.block_domains {
|
||||||
|
if let Some(n) = normalize_domain(d) {
|
||||||
|
domains.insert(n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Allow rules override blocks (exceptions).
|
||||||
|
for d in &config.allow_domains {
|
||||||
|
if let Some(n) = normalize_domain(d) {
|
||||||
|
domains.remove(&n);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let cache_dir = Self::cache_dir();
|
||||||
|
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
|
||||||
|
|
||||||
|
let mut sorted: Vec<&str> = domains.iter().map(String::as_str).collect();
|
||||||
|
sorted.sort_unstable();
|
||||||
|
let body = sorted.join("\n");
|
||||||
|
|
||||||
|
let tmp_path = path.with_extension("tmp");
|
||||||
|
std::fs::write(&tmp_path, &body)
|
||||||
|
.map_err(|e| format!("Failed to write custom blocklist: {e}"))?;
|
||||||
|
std::fs::rename(&tmp_path, &path)
|
||||||
|
.map_err(|e| format!("Failed to rename custom blocklist: {e}"))?;
|
||||||
|
|
||||||
|
log::info!(
|
||||||
|
"[dns-blocklist] Compiled custom blocklist ({} domains)",
|
||||||
|
domains.len()
|
||||||
|
);
|
||||||
|
Ok(path)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn refresh_all_stale(&self) {
|
pub async fn refresh_all_stale(&self) {
|
||||||
for &level in BlocklistLevel::all_downloadable() {
|
for &level in BlocklistLevel::all_downloadable() {
|
||||||
if !Self::is_cache_fresh(level) {
|
if !Self::is_cache_fresh(level) {
|
||||||
@@ -219,6 +511,13 @@ impl BlocklistManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Recompile the custom list too so its sources track upstream changes.
|
||||||
|
let config = CustomDnsConfig::load();
|
||||||
|
if !config.sources.is_empty() || !config.block_domains.is_empty() {
|
||||||
|
if let Err(e) = Self::compile_custom_blocklist().await {
|
||||||
|
log::error!("[dns-blocklist] Failed to recompile custom list: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option<PathBuf> {
|
pub fn get_blocklist_file_path(level: BlocklistLevel) -> Option<PathBuf> {
|
||||||
@@ -289,6 +588,102 @@ pub async fn refresh_dns_blocklists() -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_custom_dns_config() -> Result<CustomDnsConfig, String> {
|
||||||
|
Ok(CustomDnsConfig::load())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Normalize, persist, recompile and announce a custom DNS config. Shared by
|
||||||
|
/// the set and import commands so both apply the same normalization and the
|
||||||
|
/// same side effects — a step added here reaches both.
|
||||||
|
async fn persist_custom_config(mut config: CustomDnsConfig) -> Result<CustomDnsConfig, String> {
|
||||||
|
config.sources = std::mem::take(&mut config.sources)
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
config.block_domains = config
|
||||||
|
.block_domains
|
||||||
|
.iter()
|
||||||
|
.filter_map(|d| normalize_domain(d))
|
||||||
|
.collect();
|
||||||
|
config.allow_domains = config
|
||||||
|
.allow_domains
|
||||||
|
.iter()
|
||||||
|
.filter_map(|d| normalize_domain(d))
|
||||||
|
.collect();
|
||||||
|
config.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
config.dedup();
|
||||||
|
config
|
||||||
|
.save()
|
||||||
|
.map_err(|_| serde_json::json!({ "code": "DNS_RULES_SAVE_FAILED" }).to_string())?;
|
||||||
|
// Recompile so a profile relaunch picks up the new rules immediately.
|
||||||
|
let _ = BlocklistManager::compile_custom_blocklist().await;
|
||||||
|
let _ = crate::events::emit_empty("custom-dns-changed");
|
||||||
|
Ok(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Persist the custom DNS config and recompile the `custom.txt` list. Domains
|
||||||
|
/// are normalized; blank/comment entries are dropped.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn set_custom_dns_config(
|
||||||
|
sources: Vec<String>,
|
||||||
|
block_domains: Vec<String>,
|
||||||
|
allow_domains: Vec<String>,
|
||||||
|
allowlist_mode: bool,
|
||||||
|
) -> Result<CustomDnsConfig, String> {
|
||||||
|
persist_custom_config(CustomDnsConfig {
|
||||||
|
sources,
|
||||||
|
block_domains,
|
||||||
|
allow_domains,
|
||||||
|
allowlist_mode,
|
||||||
|
updated_at: None,
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Import custom DNS rules. `format` is "json" (a full CustomDnsConfig) or
|
||||||
|
/// "txt" (uBlock-ish: `! source:`, `@@allow`, bare block domains).
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_custom_dns_rules(
|
||||||
|
content: String,
|
||||||
|
format: String,
|
||||||
|
) -> Result<CustomDnsConfig, String> {
|
||||||
|
let config = match format.as_str() {
|
||||||
|
"json" => serde_json::from_str::<CustomDnsConfig>(&content)
|
||||||
|
.map_err(|_| serde_json::json!({ "code": "INVALID_DNS_RULES_JSON" }).to_string())?,
|
||||||
|
"txt" => CustomDnsConfig::from_txt(&content),
|
||||||
|
other => {
|
||||||
|
return Err(
|
||||||
|
serde_json::json!({
|
||||||
|
"code": "UNSUPPORTED_DNS_RULES_FORMAT",
|
||||||
|
"params": { "format": other },
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
persist_custom_config(config).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Export the custom DNS rules as "json" or "txt".
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn export_custom_dns_rules(format: String) -> Result<String, String> {
|
||||||
|
let config = CustomDnsConfig::load();
|
||||||
|
match format.as_str() {
|
||||||
|
"json" => serde_json::to_string_pretty(&config)
|
||||||
|
.map_err(|_| serde_json::json!({ "code": "DNS_RULES_EXPORT_FAILED" }).to_string()),
|
||||||
|
"txt" => Ok(config.to_txt()),
|
||||||
|
other => Err(
|
||||||
|
serde_json::json!({
|
||||||
|
"code": "UNSUPPORTED_DNS_RULES_FORMAT",
|
||||||
|
"params": { "format": other },
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -306,6 +701,83 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_custom_config_txt_roundtrip() {
|
||||||
|
let txt = "! source: https://example.com/list.txt\n@@allowed.com\nblocked.com\n*.tracker.net\n";
|
||||||
|
let cfg = CustomDnsConfig::from_txt(txt);
|
||||||
|
assert_eq!(cfg.sources, vec!["https://example.com/list.txt"]);
|
||||||
|
assert_eq!(cfg.allow_domains, vec!["allowed.com"]);
|
||||||
|
assert_eq!(cfg.block_domains, vec!["blocked.com", "tracker.net"]);
|
||||||
|
// Re-exporting produces parseable text.
|
||||||
|
let reparsed = CustomDnsConfig::from_txt(&cfg.to_txt());
|
||||||
|
assert_eq!(reparsed.block_domains, cfg.block_domains);
|
||||||
|
assert_eq!(reparsed.allow_domains, cfg.allow_domains);
|
||||||
|
assert_eq!(reparsed.sources, cfg.sources);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_domain() {
|
||||||
|
assert_eq!(
|
||||||
|
normalize_domain("*.Example.COM"),
|
||||||
|
Some("example.com".into())
|
||||||
|
);
|
||||||
|
assert_eq!(normalize_domain("||ads.net^"), Some("ads.net".into()));
|
||||||
|
assert_eq!(normalize_domain(" "), None);
|
||||||
|
assert_eq!(normalize_domain("# comment"), None);
|
||||||
|
assert_eq!(normalize_domain("http://x.com"), None);
|
||||||
|
// Hosts-file format lines yield the domain, not None.
|
||||||
|
assert_eq!(
|
||||||
|
normalize_domain("0.0.0.0 ads.example.com"),
|
||||||
|
Some("ads.example.com".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_domain("127.0.0.1\ttracker.net"),
|
||||||
|
Some("tracker.net".into())
|
||||||
|
);
|
||||||
|
// A bare two-token line that isn't hosts-format is still rejected.
|
||||||
|
assert_eq!(normalize_domain("foo bar"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_normalize_domain_strips_trailing_comments() {
|
||||||
|
// Public hosts lists (StevenBlack, AdAway) annotate entries inline. Without
|
||||||
|
// comment stripping the whitespace guard drops every annotated line, so a
|
||||||
|
// source compiles down to a fraction of its real domain count.
|
||||||
|
assert_eq!(
|
||||||
|
normalize_domain("0.0.0.0 ads.example.com # AdGuard"),
|
||||||
|
Some("ads.example.com".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_domain("127.0.0.1 tracker.net #comment"),
|
||||||
|
Some("tracker.net".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_domain("plain.example.com # trailing note"),
|
||||||
|
Some("plain.example.com".into())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
normalize_domain("ads.example.com ! adblock note"),
|
||||||
|
Some("ads.example.com".into())
|
||||||
|
);
|
||||||
|
// A sink IP followed only by a comment has no domain left.
|
||||||
|
assert_eq!(normalize_domain("0.0.0.0 # just a comment"), None);
|
||||||
|
// Full-line comments stay rejected.
|
||||||
|
assert_eq!(normalize_domain("! adblock header"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_custom_level_roundtrip() {
|
||||||
|
assert_eq!(
|
||||||
|
BlocklistLevel::parse_level("custom"),
|
||||||
|
Some(BlocklistLevel::Custom)
|
||||||
|
);
|
||||||
|
assert_eq!(BlocklistLevel::Custom.as_str(), "custom");
|
||||||
|
assert_eq!(BlocklistLevel::Custom.filename(), Some("custom.txt"));
|
||||||
|
assert!(BlocklistLevel::Custom.url().is_none());
|
||||||
|
// Custom must NOT be in the auto-refresh set (no upstream URL).
|
||||||
|
assert!(!BlocklistLevel::all_downloadable().contains(&BlocklistLevel::Custom));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_level_urls_all_present() {
|
fn test_level_urls_all_present() {
|
||||||
for &level in BlocklistLevel::all_downloadable() {
|
for &level in BlocklistLevel::all_downloadable() {
|
||||||
|
|||||||
@@ -1057,15 +1057,15 @@ mod tests {
|
|||||||
fn test_add_and_get_browser() {
|
fn test_add_and_get_browser() {
|
||||||
let registry = DownloadedBrowsersRegistry::new();
|
let registry = DownloadedBrowsersRegistry::new();
|
||||||
let info = DownloadedBrowserInfo {
|
let info = DownloadedBrowserInfo {
|
||||||
browser: "firefox".to_string(),
|
browser: "testbrowser".to_string(),
|
||||||
version: "139.0".to_string(),
|
version: "139.0".to_string(),
|
||||||
file_path: PathBuf::from("/test/path"),
|
file_path: PathBuf::from("/test/path"),
|
||||||
};
|
};
|
||||||
|
|
||||||
registry.add_browser(info.clone());
|
registry.add_browser(info.clone());
|
||||||
|
|
||||||
assert!(registry.is_browser_registered("firefox", "139.0"));
|
assert!(registry.is_browser_registered("testbrowser", "139.0"));
|
||||||
assert!(!registry.is_browser_registered("firefox", "140.0"));
|
assert!(!registry.is_browser_registered("testbrowser", "140.0"));
|
||||||
assert!(!registry.is_browser_registered("chrome", "139.0"));
|
assert!(!registry.is_browser_registered("chrome", "139.0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1074,19 +1074,19 @@ mod tests {
|
|||||||
let registry = DownloadedBrowsersRegistry::new();
|
let registry = DownloadedBrowsersRegistry::new();
|
||||||
|
|
||||||
let info1 = DownloadedBrowserInfo {
|
let info1 = DownloadedBrowserInfo {
|
||||||
browser: "firefox".to_string(),
|
browser: "testbrowser".to_string(),
|
||||||
version: "139.0".to_string(),
|
version: "139.0".to_string(),
|
||||||
file_path: PathBuf::from("/test/path1"),
|
file_path: PathBuf::from("/test/path1"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let info2 = DownloadedBrowserInfo {
|
let info2 = DownloadedBrowserInfo {
|
||||||
browser: "firefox".to_string(),
|
browser: "testbrowser".to_string(),
|
||||||
version: "140.0".to_string(),
|
version: "140.0".to_string(),
|
||||||
file_path: PathBuf::from("/test/path2"),
|
file_path: PathBuf::from("/test/path2"),
|
||||||
};
|
};
|
||||||
|
|
||||||
let info3 = DownloadedBrowserInfo {
|
let info3 = DownloadedBrowserInfo {
|
||||||
browser: "firefox".to_string(),
|
browser: "testbrowser".to_string(),
|
||||||
version: "141.0".to_string(),
|
version: "141.0".to_string(),
|
||||||
file_path: PathBuf::from("/test/path3"),
|
file_path: PathBuf::from("/test/path3"),
|
||||||
};
|
};
|
||||||
@@ -1095,7 +1095,7 @@ mod tests {
|
|||||||
registry.add_browser(info2);
|
registry.add_browser(info2);
|
||||||
registry.add_browser(info3);
|
registry.add_browser(info3);
|
||||||
|
|
||||||
let versions = registry.get_downloaded_versions("firefox");
|
let versions = registry.get_downloaded_versions("testbrowser");
|
||||||
assert_eq!(versions.len(), 3);
|
assert_eq!(versions.len(), 3);
|
||||||
assert!(versions.contains(&"139.0".to_string()));
|
assert!(versions.contains(&"139.0".to_string()));
|
||||||
assert!(versions.contains(&"140.0".to_string()));
|
assert!(versions.contains(&"140.0".to_string()));
|
||||||
@@ -1107,22 +1107,22 @@ mod tests {
|
|||||||
let registry = DownloadedBrowsersRegistry::new();
|
let registry = DownloadedBrowsersRegistry::new();
|
||||||
|
|
||||||
// Mark download started
|
// Mark download started
|
||||||
registry.mark_download_started("firefox", "139.0", PathBuf::from("/test/path"));
|
registry.mark_download_started("testbrowser", "139.0", PathBuf::from("/test/path"));
|
||||||
|
|
||||||
// Should NOT be registered until verification completes
|
// Should NOT be registered until verification completes
|
||||||
assert!(
|
assert!(
|
||||||
!registry.is_browser_registered("firefox", "139.0"),
|
!registry.is_browser_registered("testbrowser", "139.0"),
|
||||||
"Browser should NOT be registered after marking as started (only after verification)"
|
"Browser should NOT be registered after marking as started (only after verification)"
|
||||||
);
|
);
|
||||||
|
|
||||||
// Mark as completed (after verification)
|
// Mark as completed (after verification)
|
||||||
registry
|
registry
|
||||||
.mark_download_completed("firefox", "139.0", PathBuf::from("/test/path"))
|
.mark_download_completed("testbrowser", "139.0", PathBuf::from("/test/path"))
|
||||||
.expect("Failed to mark download as completed");
|
.expect("Failed to mark download as completed");
|
||||||
|
|
||||||
// Should now be registered
|
// Should now be registered
|
||||||
assert!(
|
assert!(
|
||||||
registry.is_browser_registered("firefox", "139.0"),
|
registry.is_browser_registered("testbrowser", "139.0"),
|
||||||
"Browser should be registered after verification completes"
|
"Browser should be registered after verification completes"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1131,24 +1131,24 @@ mod tests {
|
|||||||
fn test_remove_browser() {
|
fn test_remove_browser() {
|
||||||
let registry = DownloadedBrowsersRegistry::new();
|
let registry = DownloadedBrowsersRegistry::new();
|
||||||
let info = DownloadedBrowserInfo {
|
let info = DownloadedBrowserInfo {
|
||||||
browser: "firefox".to_string(),
|
browser: "testbrowser".to_string(),
|
||||||
version: "139.0".to_string(),
|
version: "139.0".to_string(),
|
||||||
file_path: PathBuf::from("/test/path"),
|
file_path: PathBuf::from("/test/path"),
|
||||||
};
|
};
|
||||||
|
|
||||||
registry.add_browser(info);
|
registry.add_browser(info);
|
||||||
assert!(
|
assert!(
|
||||||
registry.is_browser_registered("firefox", "139.0"),
|
registry.is_browser_registered("testbrowser", "139.0"),
|
||||||
"Browser should be registered after adding"
|
"Browser should be registered after adding"
|
||||||
);
|
);
|
||||||
|
|
||||||
let removed = registry.remove_browser("firefox", "139.0");
|
let removed = registry.remove_browser("testbrowser", "139.0");
|
||||||
assert!(
|
assert!(
|
||||||
removed.is_some(),
|
removed.is_some(),
|
||||||
"Remove operation should return the removed browser info"
|
"Remove operation should return the removed browser info"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
!registry.is_browser_registered("firefox", "139.0"),
|
!registry.is_browser_registered("testbrowser", "139.0"),
|
||||||
"Browser should not be registered after removal"
|
"Browser should not be registered after removal"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1182,11 +1182,11 @@ mod tests {
|
|||||||
fn test_last_version_kept_during_cleanup() {
|
fn test_last_version_kept_during_cleanup() {
|
||||||
let registry = DownloadedBrowsersRegistry::new();
|
let registry = DownloadedBrowsersRegistry::new();
|
||||||
|
|
||||||
// Add a single version for "firefox"
|
// Add a single version for "testbrowser"
|
||||||
registry.add_browser(DownloadedBrowserInfo {
|
registry.add_browser(DownloadedBrowserInfo {
|
||||||
browser: "firefox".to_string(),
|
browser: "testbrowser".to_string(),
|
||||||
version: "139.0".to_string(),
|
version: "139.0".to_string(),
|
||||||
file_path: PathBuf::from("/test/firefox/139.0"),
|
file_path: PathBuf::from("/test/testbrowser/139.0"),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add two versions for "chromium"
|
// Add two versions for "chromium"
|
||||||
@@ -1206,11 +1206,11 @@ mod tests {
|
|||||||
.cleanup_unused_binaries_internal(&[], &[])
|
.cleanup_unused_binaries_internal(&[], &[])
|
||||||
.expect("cleanup should succeed");
|
.expect("cleanup should succeed");
|
||||||
|
|
||||||
// firefox 139.0 should be kept (last version), chromium should lose one but keep one
|
// testbrowser 139.0 should be kept (last version), chromium should lose one but keep one
|
||||||
// The exact one kept depends on iteration order, but at least one must remain
|
// The exact one kept depends on iteration order, but at least one must remain
|
||||||
assert!(
|
assert!(
|
||||||
!result.contains(&"firefox 139.0".to_string()),
|
!result.contains(&"testbrowser 139.0".to_string()),
|
||||||
"Last version of firefox should not be cleaned up"
|
"Last version of testbrowser should not be cleaned up"
|
||||||
);
|
);
|
||||||
// At most one chromium version should have been cleaned up
|
// At most one chromium version should have been cleaned up
|
||||||
let chromium_cleaned: Vec<_> = result
|
let chromium_cleaned: Vec<_> = result
|
||||||
@@ -1223,10 +1223,10 @@ mod tests {
|
|||||||
chromium_cleaned
|
chromium_cleaned
|
||||||
);
|
);
|
||||||
|
|
||||||
// Verify firefox is still registered
|
// Verify testbrowser is still registered
|
||||||
assert!(
|
assert!(
|
||||||
registry.is_browser_registered("firefox", "139.0"),
|
registry.is_browser_registered("testbrowser", "139.0"),
|
||||||
"Last firefox version should still be registered"
|
"Last testbrowser version should still be registered"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1234,7 +1234,7 @@ mod tests {
|
|||||||
fn test_is_browser_registered_vs_downloaded() {
|
fn test_is_browser_registered_vs_downloaded() {
|
||||||
let registry = DownloadedBrowsersRegistry::new();
|
let registry = DownloadedBrowsersRegistry::new();
|
||||||
let info = DownloadedBrowserInfo {
|
let info = DownloadedBrowserInfo {
|
||||||
browser: "firefox".to_string(),
|
browser: "testbrowser".to_string(),
|
||||||
version: "139.0".to_string(),
|
version: "139.0".to_string(),
|
||||||
file_path: PathBuf::from("/test/path"),
|
file_path: PathBuf::from("/test/path"),
|
||||||
};
|
};
|
||||||
@@ -1244,14 +1244,14 @@ mod tests {
|
|||||||
|
|
||||||
// Should be registered (in-memory check)
|
// Should be registered (in-memory check)
|
||||||
assert!(
|
assert!(
|
||||||
registry.is_browser_registered("firefox", "139.0"),
|
registry.is_browser_registered("testbrowser", "139.0"),
|
||||||
"Browser should be registered after adding to registry"
|
"Browser should be registered after adding to registry"
|
||||||
);
|
);
|
||||||
|
|
||||||
// is_browser_downloaded should return false in test environment because files don't exist
|
// is_browser_downloaded should return false in test environment because files don't exist
|
||||||
// This tests the difference between registered (in registry) vs downloaded (files exist)
|
// This tests the difference between registered (in registry) vs downloaded (files exist)
|
||||||
assert!(
|
assert!(
|
||||||
!registry.is_browser_downloaded("firefox", "139.0"),
|
!registry.is_browser_downloaded("testbrowser", "139.0"),
|
||||||
"Browser should not be considered downloaded when files don't exist on disk"
|
"Browser should not be considered downloaded when files don't exist on disk"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1261,6 +1261,14 @@ mod tests {
|
|||||||
pub async fn ensure_active_browsers_downloaded(
|
pub async fn ensure_active_browsers_downloaded(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, String> {
|
||||||
|
#[cfg(feature = "e2e")]
|
||||||
|
if crate::e2e_automation_enabled()
|
||||||
|
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
|
||||||
|
{
|
||||||
|
log::info!("E2E: skipping proactive browser download");
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
let registry = DownloadedBrowsersRegistry::instance();
|
let registry = DownloadedBrowsersRegistry::instance();
|
||||||
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
|
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
|
||||||
let mut downloaded = Vec::new();
|
let mut downloaded = Vec::new();
|
||||||
@@ -1277,21 +1285,38 @@ pub async fn ensure_active_browsers_downloaded(
|
|||||||
}
|
}
|
||||||
log::info!("ensure_active: No {browser} versions found, will download");
|
log::info!("ensure_active: No {browser} versions found, will download");
|
||||||
|
|
||||||
// Get the latest release type for this browser
|
// Resolve the version to download. For wayfern, only the currently
|
||||||
let release_types = match version_manager.get_browser_release_types(browser).await {
|
// published version is downloadable, so ask the API fresh — the release-type
|
||||||
Ok(rt) => rt,
|
// cache can be stale right after a new version is published, and the
|
||||||
Err(e) => {
|
// downloader rejects requests for versions that are no longer available.
|
||||||
log::warn!("Failed to get release types for {browser}: {e}");
|
let version = if *browser == "wayfern" {
|
||||||
continue;
|
match crate::api_client::ApiClient::instance()
|
||||||
|
.fetch_wayfern_version_with_caching(true)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(info) => info.version,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to resolve current {browser} version: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
} else {
|
||||||
|
// Get the latest release type for this browser
|
||||||
|
let release_types = match version_manager.get_browser_release_types(browser).await {
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to get release types for {browser}: {e}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Use stable version (the only release type for these browsers)
|
// Use stable version (the only release type for these browsers)
|
||||||
let version = match release_types.stable {
|
match release_types.stable {
|
||||||
Some(v) => v,
|
Some(v) => v,
|
||||||
None => {
|
None => {
|
||||||
log::debug!("No stable version available for {browser} on this platform, skipping");
|
log::debug!("No stable version available for {browser} on this platform, skipping");
|
||||||
continue;
|
continue;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1330,8 +1355,8 @@ pub async fn ensure_active_browsers_downloaded(
|
|||||||
Err(_) => {
|
Err(_) => {
|
||||||
// The download future itself hung past the overall timeout and was dropped,
|
// The download future itself hung past the overall timeout and was dropped,
|
||||||
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping
|
// so its own cleanup never ran. Clear any leftover in-progress bookkeeping
|
||||||
// (the future may have re-resolved to a different version, so clear by
|
// (by browser prefix, as a catch-all for whatever key was in flight) and
|
||||||
// browser prefix) and emit a terminal error event so the UI stops spinning.
|
// emit a terminal error event so the UI stops spinning.
|
||||||
log::warn!(
|
log::warn!(
|
||||||
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
|
"Auto-download of {browser} {version} timed out after {}s (attempt {attempt}/{MAX_ATTEMPTS})",
|
||||||
ATTEMPT_TIMEOUT.as_secs()
|
ATTEMPT_TIMEOUT.as_secs()
|
||||||
@@ -1393,6 +1418,14 @@ pub async fn check_missing_binaries() -> Result<Vec<(String, String, String)>, S
|
|||||||
pub async fn ensure_all_binaries_exist(
|
pub async fn ensure_all_binaries_exist(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
) -> Result<Vec<String>, String> {
|
) -> Result<Vec<String>, String> {
|
||||||
|
#[cfg(feature = "e2e")]
|
||||||
|
if crate::e2e_automation_enabled()
|
||||||
|
&& std::env::var_os("DONUT_E2E_DISABLE_STARTUP_NETWORK").is_some()
|
||||||
|
{
|
||||||
|
log::info!("E2E: skipping proactive binary and GeoIP downloads");
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
let registry = DownloadedBrowsersRegistry::instance();
|
let registry = DownloadedBrowsersRegistry::instance();
|
||||||
registry
|
registry
|
||||||
.ensure_all_binaries_exist(&app_handle)
|
.ensure_all_binaries_exist(&app_handle)
|
||||||
|
|||||||
+72
-74
@@ -23,6 +23,22 @@ lazy_static::lazy_static! {
|
|||||||
std::sync::Arc::new(Mutex::new(std::collections::HashMap::new()));
|
std::sync::Arc::new(Mutex::new(std::collections::HashMap::new()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clears a browser-version pair from the in-flight download maps on every
|
||||||
|
/// exit path of `download_browser_full`. A leaked key would permanently report
|
||||||
|
/// "already being downloaded" for that version until app restart.
|
||||||
|
struct InFlightDownload(String);
|
||||||
|
|
||||||
|
impl Drop for InFlightDownload {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
if let Ok(mut downloading) = DOWNLOADING_BROWSERS.lock() {
|
||||||
|
downloading.remove(&self.0);
|
||||||
|
}
|
||||||
|
if let Ok(mut tokens) = DOWNLOAD_CANCELLATION_TOKENS.lock() {
|
||||||
|
tokens.remove(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
pub struct DownloadProgress {
|
pub struct DownloadProgress {
|
||||||
pub browser: String,
|
pub browser: String,
|
||||||
@@ -97,7 +113,13 @@ impl Downloader {
|
|||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
return Err(
|
||||||
|
format!(
|
||||||
|
"Download failed with HTTP status {}",
|
||||||
|
response.status().as_u16()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut file = std::fs::OpenOptions::new()
|
let mut file = std::fs::OpenOptions::new()
|
||||||
@@ -131,10 +153,16 @@ impl Downloader {
|
|||||||
.fetch_wayfern_version_with_caching(true)
|
.fetch_wayfern_version_with_caching(true)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// Never substitute: downloading the current build into the requested
|
||||||
|
// version's directory would register a mislabeled install.
|
||||||
if version_info.version != version {
|
if version_info.version != version {
|
||||||
log::info!(
|
return Err(
|
||||||
"Wayfern: requested version {version}, using available version {}",
|
serde_json::json!({
|
||||||
version_info.version
|
"code": "WAYFERN_VERSION_NOT_AVAILABLE",
|
||||||
|
"params": { "requested": version, "current": version_info.version }
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,7 +231,7 @@ impl Downloader {
|
|||||||
let download_url = self
|
let download_url = self
|
||||||
.resolve_download_url(browser_type.clone(), version, download_info)
|
.resolve_download_url(browser_type.clone(), version, download_info)
|
||||||
.await?;
|
.await?;
|
||||||
log::info!("Download URL resolved: {}", download_url);
|
log::info!("Download URL resolved");
|
||||||
|
|
||||||
// In-session resume: a large (~1GB) download over a flaky connection can
|
// In-session resume: a large (~1GB) download over a flaky connection can
|
||||||
// drop mid-stream. Rather than surfacing the first stall/chunk error as a
|
// drop mid-stream. Rather than surfacing the first stall/chunk error as a
|
||||||
@@ -301,7 +329,13 @@ impl Downloader {
|
|||||||
|
|
||||||
// Check if the response is successful (200 OK or 206 Partial Content)
|
// Check if the response is successful (200 OK or 206 Partial Content)
|
||||||
if !(response.status().is_success() || response.status().as_u16() == 206) {
|
if !(response.status().is_success() || response.status().as_u16() == 206) {
|
||||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
return Err(
|
||||||
|
format!(
|
||||||
|
"Download failed with HTTP status {}",
|
||||||
|
response.status().as_u16()
|
||||||
|
)
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine total size
|
// Determine total size
|
||||||
@@ -504,25 +538,36 @@ impl Downloader {
|
|||||||
return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into());
|
return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into());
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Wayfern, resolve the actual available version from the API
|
// Validate the browser type before touching the in-flight maps so a bad
|
||||||
let version = if browser_str == "wayfern" {
|
// request can't leave state behind.
|
||||||
match self
|
let browser_type =
|
||||||
|
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||||
|
let browser = create_browser(browser_type.clone());
|
||||||
|
|
||||||
|
// For Wayfern, only the currently published version can be fetched.
|
||||||
|
// Requesting any other not-yet-downloaded version is an error — silently
|
||||||
|
// substituting the latest would install a version the caller never asked
|
||||||
|
// for while the response still echoes the requested one. The fetch must
|
||||||
|
// succeed too: proceeding unverified would let resolve_download_url fetch
|
||||||
|
// the current build into the requested version's directory (a mislabeled
|
||||||
|
// install), and that URL resolution needs the same endpoint anyway.
|
||||||
|
if browser_str == "wayfern" && !self.registry.is_browser_downloaded(&browser_str, &version) {
|
||||||
|
let info = self
|
||||||
.api_client
|
.api_client
|
||||||
.fetch_wayfern_version_with_caching(true)
|
.fetch_wayfern_version_with_caching(true)
|
||||||
.await
|
.await
|
||||||
{
|
.map_err(|e| format!("Failed to determine the current Wayfern version: {e}"))?;
|
||||||
Ok(info) if info.version != version => {
|
if info.version != version {
|
||||||
log::info!(
|
return Err(
|
||||||
"Wayfern: requested {version}, using available {}",
|
serde_json::json!({
|
||||||
info.version
|
"code": "WAYFERN_VERSION_NOT_AVAILABLE",
|
||||||
);
|
"params": { "requested": version, "current": info.version }
|
||||||
info.version
|
})
|
||||||
}
|
.to_string()
|
||||||
_ => version,
|
.into(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
version
|
|
||||||
};
|
|
||||||
|
|
||||||
// Check if this browser-version pair is already being downloaded
|
// Check if this browser-version pair is already being downloaded
|
||||||
let download_key = format!("{browser_str}-{version}");
|
let download_key = format!("{browser_str}-{version}");
|
||||||
@@ -539,10 +584,8 @@ impl Downloader {
|
|||||||
tokens.insert(download_key.clone(), token.clone());
|
tokens.insert(download_key.clone(), token.clone());
|
||||||
token
|
token
|
||||||
};
|
};
|
||||||
|
// Cleared on drop, whatever exit path this function takes.
|
||||||
let browser_type =
|
let _in_flight = InFlightDownload(download_key.clone());
|
||||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
|
||||||
let browser = create_browser(browser_type.clone());
|
|
||||||
|
|
||||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||||
|
|
||||||
@@ -551,12 +594,6 @@ impl Downloader {
|
|||||||
let actually_exists = browser.is_version_downloaded(&version, &binaries_dir);
|
let actually_exists = browser.is_version_downloaded(&version, &binaries_dir);
|
||||||
|
|
||||||
if actually_exists {
|
if actually_exists {
|
||||||
// Remove from downloading set since it's already downloaded
|
|
||||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
|
||||||
downloading.remove(&download_key);
|
|
||||||
drop(downloading);
|
|
||||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
|
||||||
tokens.remove(&download_key);
|
|
||||||
return Ok(version);
|
return Ok(version);
|
||||||
} else {
|
} else {
|
||||||
// Registry says it's downloaded but files don't exist - clean up registry
|
// Registry says it's downloaded but files don't exist - clean up registry
|
||||||
@@ -575,12 +612,6 @@ impl Downloader {
|
|||||||
.is_browser_supported(&browser_str)
|
.is_browser_supported(&browser_str)
|
||||||
.unwrap_or(false)
|
.unwrap_or(false)
|
||||||
{
|
{
|
||||||
// Remove from downloading set on error
|
|
||||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
|
||||||
downloading.remove(&download_key);
|
|
||||||
drop(downloading);
|
|
||||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
|
||||||
tokens.remove(&download_key);
|
|
||||||
return Err(
|
return Err(
|
||||||
format!(
|
format!(
|
||||||
"Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}",
|
"Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}",
|
||||||
@@ -630,11 +661,6 @@ impl Downloader {
|
|||||||
// Clean registry entry and stop here so the UI can show a single, clear error.
|
// Clean registry entry and stop here so the UI can show a single, clear error.
|
||||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||||
let _ = self.registry.save();
|
let _ = self.registry.save();
|
||||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
|
||||||
downloading.remove(&download_key);
|
|
||||||
drop(downloading);
|
|
||||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
|
||||||
tokens.remove(&download_key);
|
|
||||||
|
|
||||||
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
|
// Emit a terminal stage so the UI stops spinning. A user cancellation maps to
|
||||||
// "cancelled"; any other failure (network error, stall timeout, bad status)
|
// "cancelled"; any other failure (network error, stall timeout, bad status)
|
||||||
@@ -687,14 +713,6 @@ impl Downloader {
|
|||||||
|
|
||||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||||
let _ = self.registry.save();
|
let _ = self.registry.save();
|
||||||
{
|
|
||||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
|
||||||
downloading.remove(&download_key);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
|
||||||
tokens.remove(&download_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emit error stage so the UI shows a toast
|
// Emit error stage so the UI shows a toast
|
||||||
let progress = DownloadProgress {
|
let progress = DownloadProgress {
|
||||||
@@ -777,15 +795,6 @@ impl Downloader {
|
|||||||
};
|
};
|
||||||
let _ = events::emit("download-progress", &progress);
|
let _ = events::emit("download-progress", &progress);
|
||||||
|
|
||||||
// Remove browser-version pair from downloading set on verification failure
|
|
||||||
{
|
|
||||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
|
||||||
downloading.remove(&download_key);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
|
||||||
tokens.remove(&download_key);
|
|
||||||
}
|
|
||||||
return Err(error_details.into());
|
return Err(error_details.into());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -825,16 +834,6 @@ impl Downloader {
|
|||||||
};
|
};
|
||||||
let _ = events::emit("download-progress", &progress);
|
let _ = events::emit("download-progress", &progress);
|
||||||
|
|
||||||
// Remove browser-version pair from downloading set and cancel token
|
|
||||||
{
|
|
||||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
|
||||||
downloading.remove(&download_key);
|
|
||||||
}
|
|
||||||
{
|
|
||||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
|
||||||
tokens.remove(&download_key);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-update non-running profiles to the latest installed version and cleanup unused binaries
|
// Auto-update non-running profiles to the latest installed version and cleanup unused binaries
|
||||||
{
|
{
|
||||||
let app_handle_for_update = app_handle.clone();
|
let app_handle_for_update = app_handle.clone();
|
||||||
@@ -883,10 +882,9 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
|
|||||||
/// Clear all in-progress download bookkeeping for a browser.
|
/// Clear all in-progress download bookkeeping for a browser.
|
||||||
///
|
///
|
||||||
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
|
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
|
||||||
/// by an outer timeout) before its own error path could run. Because
|
/// by an outer timeout) before its own error path could run. Matches by the
|
||||||
/// `download_browser_full` may re-resolve to a different version than requested, this
|
/// `"{browser}-"` key prefix rather than an exact version so no stuck key is left
|
||||||
/// matches by the `"{browser}-"` key prefix rather than an exact version so no stuck
|
/// behind even when the caller doesn't know which version was actually in flight.
|
||||||
/// key is left behind regardless of which version was actually in flight.
|
|
||||||
pub fn clear_download_state_for_browser(browser: &str) {
|
pub fn clear_download_state_for_browser(browser: &str) {
|
||||||
let prefix = format!("{browser}-");
|
let prefix = format!("{browser}-");
|
||||||
{
|
{
|
||||||
@@ -909,7 +907,7 @@ pub async fn download_browser(
|
|||||||
downloader
|
downloader
|
||||||
.download_browser_full(&app_handle, browser_str, version)
|
.download_browser_full(&app_handle, browser_str, version)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to download browser: {e}"))
|
.map_err(|e| crate::wrap_backend_error(e, "Failed to download browser"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -280,6 +280,7 @@ mod tests {
|
|||||||
created_by_email: None,
|
created_by_email: None,
|
||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
|
clear_on_close: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
updated_at: None,
|
updated_at: None,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -280,12 +280,21 @@ impl ExtensionManager {
|
|||||||
let (manifest_name, version, description, author, homepage_url) =
|
let (manifest_name, version, description, author, homepage_url) =
|
||||||
extract_manifest_metadata(&file_data, &file_type);
|
extract_manifest_metadata(&file_data, &file_type);
|
||||||
|
|
||||||
let final_name = if manifest_name.is_some() {
|
// An empty/whitespace-only manifest name counts as absent so the
|
||||||
manifest_name.clone().unwrap_or(name)
|
// user-provided name still applies.
|
||||||
} else {
|
let final_name = match manifest_name.clone() {
|
||||||
name
|
Some(n) if !n.trim().is_empty() => n,
|
||||||
|
_ => name,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if final_name.trim().is_empty() {
|
||||||
|
return Err(
|
||||||
|
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let ext = Extension {
|
let ext = Extension {
|
||||||
id: uuid::Uuid::new_v4().to_string(),
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
name: final_name,
|
name: final_name,
|
||||||
@@ -510,6 +519,14 @@ impl ExtensionManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn create_group(&self, name: String) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
|
pub fn create_group(&self, name: String) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(
|
||||||
|
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut data = self.load_groups_data()?;
|
let mut data = self.load_groups_data()?;
|
||||||
|
|
||||||
if data.groups.iter().any(|g| g.name == name) {
|
if data.groups.iter().any(|g| g.name == name) {
|
||||||
@@ -566,6 +583,14 @@ impl ExtensionManager {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
extension_ids: Option<Vec<String>>,
|
extension_ids: Option<Vec<String>>,
|
||||||
) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
|
) -> Result<ExtensionGroup, Box<dyn std::error::Error>> {
|
||||||
|
if name.as_deref().is_some_and(|n| n.trim().is_empty()) {
|
||||||
|
return Err(
|
||||||
|
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut data = self.load_groups_data()?;
|
let mut data = self.load_groups_data()?;
|
||||||
|
|
||||||
if let Some(ref new_name) = name {
|
if let Some(ref new_name) = name {
|
||||||
@@ -1080,7 +1105,7 @@ pub async fn add_extension(
|
|||||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||||
mgr
|
mgr
|
||||||
.add_extension(name, file_name, file_data)
|
.add_extension(name, file_name, file_data)
|
||||||
.map_err(|e| format!("Failed to add extension: {e}"))
|
.map_err(|e| crate::wrap_backend_error(e, "Failed to add extension"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1120,7 +1145,7 @@ pub async fn create_extension_group(name: String) -> Result<ExtensionGroup, Stri
|
|||||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||||
mgr
|
mgr
|
||||||
.create_group(name)
|
.create_group(name)
|
||||||
.map_err(|e| format!("Failed to create extension group: {e}"))
|
.map_err(|e| crate::wrap_backend_error(e, "Failed to create extension group"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1132,7 +1157,7 @@ pub async fn update_extension_group(
|
|||||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||||
mgr
|
mgr
|
||||||
.update_group(&group_id, name, extension_ids)
|
.update_group(&group_id, name, extension_ids)
|
||||||
.map_err(|e| format!("Failed to update extension group: {e}"))
|
.map_err(|e| crate::wrap_backend_error(e, "Failed to update extension group"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -0,0 +1,381 @@
|
|||||||
|
//! Launch-time consistency check: resolve the proxy's exit IP, geolocate it
|
||||||
|
//! with the bundled MaxMind database (the same source the fingerprint generator
|
||||||
|
//! uses), then compare its timezone and country against the profile
|
||||||
|
//! fingerprint's timezone and language. A mismatch (e.g. a US fingerprint
|
||||||
|
//! behind a German exit IP) is a strong anti-bot tell even though the real
|
||||||
|
//! device never leaks — so we warn the user after launch and offer to match the
|
||||||
|
//! fingerprint to the exit. Launches never rewrite the fingerprint silently, so
|
||||||
|
//! a real mismatch always surfaces here.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::profile::types::BrowserProfile;
|
||||||
|
use crate::proxy_manager::PROXY_MANAGER;
|
||||||
|
|
||||||
|
/// Exit-node lookups are cached per proxy for this long. A stored proxy's exit
|
||||||
|
/// geolocation is stable enough that re-resolving the exit IP through the proxy
|
||||||
|
/// on every launch is wasteful.
|
||||||
|
const EXIT_CACHE_TTL_SECS: u64 = 30 * 60;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct CachedExit {
|
||||||
|
fetched_at: u64,
|
||||||
|
/// The proxy URL this exit was measured through. Editing a stored proxy keeps
|
||||||
|
/// its id, so without this an entry outlives the endpoint it describes: the
|
||||||
|
/// check would compare a re-generated fingerprint against the *old* exit and
|
||||||
|
/// either warn about a correct profile or — worse — call a genuinely
|
||||||
|
/// mismatched one consistent, which is exactly the tell it exists to catch.
|
||||||
|
proxy_url: String,
|
||||||
|
timezone: Option<String>,
|
||||||
|
country_code: Option<String>,
|
||||||
|
ip: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
lazy_static::lazy_static! {
|
||||||
|
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||||
|
pub struct ConsistencyResult {
|
||||||
|
/// True when everything we could check lines up (or there was nothing to
|
||||||
|
/// check — no proxy assigned).
|
||||||
|
pub consistent: bool,
|
||||||
|
/// True when we actually reached an exit node and compared something.
|
||||||
|
pub checked: bool,
|
||||||
|
pub exit_ip: Option<String>,
|
||||||
|
pub exit_country_code: Option<String>,
|
||||||
|
pub exit_timezone: Option<String>,
|
||||||
|
pub fingerprint_timezone: Option<String>,
|
||||||
|
pub fingerprint_language: Option<String>,
|
||||||
|
/// One of "timezone", "language" — the dimensions that disagree.
|
||||||
|
pub mismatches: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ConsistencyResult {
|
||||||
|
fn skip() -> Self {
|
||||||
|
Self {
|
||||||
|
consistent: true,
|
||||||
|
checked: false,
|
||||||
|
exit_ip: None,
|
||||||
|
exit_country_code: None,
|
||||||
|
exit_timezone: None,
|
||||||
|
fingerprint_timezone: None,
|
||||||
|
fingerprint_language: None,
|
||||||
|
mismatches: Vec::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// URL for handing this proxy to reqwest, or None for upstreams reqwest can't
|
||||||
|
/// drive (Shadowsocks).
|
||||||
|
fn proxy_url(settings: &crate::browser::ProxySettings) -> Option<String> {
|
||||||
|
match settings.proxy_type.to_lowercase().as_str() {
|
||||||
|
"http" | "https" | "socks4" | "socks5" => Some(
|
||||||
|
crate::proxy_manager::ProxyManager::build_proxy_url(settings),
|
||||||
|
),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether the fingerprint's language is plausible for the exit country.
|
||||||
|
///
|
||||||
|
/// Validated against the same CLDR data the fingerprint generator samples from
|
||||||
|
/// (`geolocation::LocaleSelector`), not a hand-written country->language table.
|
||||||
|
/// The generator picks a language at random weighted by CLDR speaker share, so
|
||||||
|
/// any table naming one "expected" language per country flags fingerprints
|
||||||
|
/// Donut itself produced — roughly 10% of US profiles legitimately get `es-US`
|
||||||
|
/// and ~23% of Canadian ones get `fr-CA`. `None` means the country has no CLDR
|
||||||
|
/// data and the check is skipped.
|
||||||
|
fn language_matches_country(cc: &str, language: &str) -> Option<bool> {
|
||||||
|
crate::geolocation::locale_selector()?.region_speaks(cc, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract (timezone, language) from a profile's stored fingerprint JSON.
|
||||||
|
fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<String>) {
|
||||||
|
let Some(config) = &profile.wayfern_config else {
|
||||||
|
return (None, None);
|
||||||
|
};
|
||||||
|
let Some(fp_str) = &config.fingerprint else {
|
||||||
|
return (None, None);
|
||||||
|
};
|
||||||
|
let Ok(fp) = serde_json::from_str::<serde_json::Value>(fp_str) else {
|
||||||
|
return (None, None);
|
||||||
|
};
|
||||||
|
let timezone = fp
|
||||||
|
.get("timezone")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
let language = fp
|
||||||
|
.get("language")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(str::to_string);
|
||||||
|
(timezone, language)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the check for a profile. No-ops (consistent, unchecked) when the
|
||||||
|
/// profile has no proxy or the exit node can't be reached.
|
||||||
|
pub async fn check_profile_consistency(
|
||||||
|
profile: &BrowserProfile,
|
||||||
|
) -> Result<ConsistencyResult, String> {
|
||||||
|
let Some(proxy_id) = &profile.proxy_id else {
|
||||||
|
return Ok(ConsistencyResult::skip());
|
||||||
|
};
|
||||||
|
let Some(settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) else {
|
||||||
|
return Ok(ConsistencyResult::skip());
|
||||||
|
};
|
||||||
|
let Some(url) = proxy_url(&settings) else {
|
||||||
|
return Ok(ConsistencyResult::skip());
|
||||||
|
};
|
||||||
|
|
||||||
|
let now = crate::proxy_manager::now_secs();
|
||||||
|
|
||||||
|
// Serve a fresh cached exit lookup for this proxy if we have one, but only if
|
||||||
|
// it was measured through the proxy's current endpoint and credentials.
|
||||||
|
let cached = {
|
||||||
|
let cache = EXIT_CACHE.lock().unwrap();
|
||||||
|
cache
|
||||||
|
.get(proxy_id)
|
||||||
|
.filter(|c| c.proxy_url == url && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS)
|
||||||
|
.cloned()
|
||||||
|
};
|
||||||
|
|
||||||
|
let (exit_tz, exit_cc, exit_ip) = if let Some(c) = cached {
|
||||||
|
(c.timezone, c.country_code, c.ip)
|
||||||
|
} else {
|
||||||
|
// Resolve the exit IP through the proxy, then geolocate it with the SAME
|
||||||
|
// bundled MaxMind database the fingerprint generator (and the on-demand
|
||||||
|
// match) use. Using one geo source everywhere means the check can never
|
||||||
|
// disagree with what generation produced — a second source (e.g. ip-api)
|
||||||
|
// routinely reports a different IANA zone for the same IP in multi-zone
|
||||||
|
// countries, which would flag correctly-generated fingerprints and would
|
||||||
|
// leave the "match to proxy" fix unable to satisfy the check.
|
||||||
|
let exit_ip = crate::ip_utils::fetch_public_ip(Some(&url))
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("exit-node lookup failed: {e}"))?;
|
||||||
|
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||||
|
Ok(geo) => {
|
||||||
|
let tz = Some(geo.timezone);
|
||||||
|
let cc = geo.locale.region.clone();
|
||||||
|
let ip = Some(exit_ip);
|
||||||
|
EXIT_CACHE.lock().unwrap().insert(
|
||||||
|
proxy_id.clone(),
|
||||||
|
CachedExit {
|
||||||
|
fetched_at: now,
|
||||||
|
proxy_url: url.clone(),
|
||||||
|
timezone: tz.clone(),
|
||||||
|
country_code: cc.clone(),
|
||||||
|
ip: ip.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
(tz, cc, ip)
|
||||||
|
}
|
||||||
|
// Reached the exit but couldn't place it (database missing, or a private
|
||||||
|
// exit IP). Skip rather than warn on an unknown location — the same
|
||||||
|
// database gates fingerprint geo, so there's nothing to disagree with.
|
||||||
|
Err(e) => {
|
||||||
|
log::debug!("Consistency check: could not geolocate exit IP: {e}");
|
||||||
|
return Ok(ConsistencyResult::skip());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let (fp_tz, fp_lang) = fingerprint_locale(profile);
|
||||||
|
let mut mismatches = Vec::new();
|
||||||
|
|
||||||
|
if let (Some(exit), Some(fp)) = (&exit_tz, &fp_tz) {
|
||||||
|
if !exit.eq_ignore_ascii_case(fp) {
|
||||||
|
mismatches.push("timezone".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let (Some(cc), Some(lang)) = (&exit_cc, &fp_lang) {
|
||||||
|
if language_matches_country(cc, lang) == Some(false) {
|
||||||
|
mismatches.push("language".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(ConsistencyResult {
|
||||||
|
consistent: mismatches.is_empty(),
|
||||||
|
checked: true,
|
||||||
|
exit_ip,
|
||||||
|
exit_country_code: exit_cc,
|
||||||
|
exit_timezone: exit_tz,
|
||||||
|
fingerprint_timezone: fp_tz,
|
||||||
|
fingerprint_language: fp_lang,
|
||||||
|
mismatches,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn check_profile_fingerprint_consistency(
|
||||||
|
profile_id: String,
|
||||||
|
) -> Result<ConsistencyResult, String> {
|
||||||
|
let profiles = crate::profile::ProfileManager::instance()
|
||||||
|
.list_profiles()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let profile = profiles
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.id.to_string() == profile_id)
|
||||||
|
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
|
||||||
|
check_profile_consistency(&profile).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Rewrite a profile's stored fingerprint so its geolocation (timezone,
|
||||||
|
/// language, coordinates) matches `exit_ip`, and persist it. This is the
|
||||||
|
/// on-demand resolution for the consistency warning: launches no longer rewrite
|
||||||
|
/// the fingerprint silently, so the user opts into matching it here.
|
||||||
|
///
|
||||||
|
/// `exit_ip` is the exit the consistency check already resolved, applied via
|
||||||
|
/// MaxMind directly — no second proxy round-trip — and it forces geolocation
|
||||||
|
/// even when the profile has geo spoofing disabled, since the user explicitly
|
||||||
|
/// asked to match. Takes effect on the next launch of the profile.
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn match_profile_fingerprint_to_exit(
|
||||||
|
profile_id: String,
|
||||||
|
exit_ip: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let manager = crate::profile::ProfileManager::instance();
|
||||||
|
let mut profile = manager
|
||||||
|
.list_profiles()
|
||||||
|
.map_err(|e| e.to_string())?
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.id.to_string() == profile_id)
|
||||||
|
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
|
||||||
|
|
||||||
|
let mut config = profile
|
||||||
|
.wayfern_config
|
||||||
|
.clone()
|
||||||
|
.filter(|c| c.fingerprint.is_some())
|
||||||
|
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
|
||||||
|
let fingerprint = config.fingerprint.clone().unwrap();
|
||||||
|
|
||||||
|
let geoip_override = serde_json::Value::String(exit_ip);
|
||||||
|
let refreshed = crate::wayfern_manager::WayfernManager::refresh_fingerprint_geolocation(
|
||||||
|
&fingerprint,
|
||||||
|
None,
|
||||||
|
Some(&geoip_override),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.ok_or_else(|| serde_json::json!({ "code": "FINGERPRINT_MATCH_FAILED" }).to_string())?;
|
||||||
|
|
||||||
|
config.fingerprint = Some(refreshed);
|
||||||
|
profile.wayfern_config = Some(config);
|
||||||
|
manager.save_profile(&profile).map_err(|e| {
|
||||||
|
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e.to_string() } })
|
||||||
|
.to_string()
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn language_check_accepts_the_main_language_of_the_country() {
|
||||||
|
assert_eq!(language_matches_country("US", "en-US"), Some(true));
|
||||||
|
assert_eq!(language_matches_country("de", "de-DE"), Some(true));
|
||||||
|
assert_eq!(language_matches_country("BR", "pt-BR"), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn language_check_accepts_what_the_fingerprint_generator_emits() {
|
||||||
|
// These are not the "expected" language for the country, but the generator
|
||||||
|
// samples the CLDR distribution and produces them routinely — CLDR puts es
|
||||||
|
// at 9.6% in the US and fr at 30% in Canada. Flagging them warns the user
|
||||||
|
// about a fingerprint Donut itself created.
|
||||||
|
assert_eq!(language_matches_country("US", "es-US"), Some(true));
|
||||||
|
assert_eq!(language_matches_country("CA", "fr-CA"), Some(true));
|
||||||
|
assert_eq!(language_matches_country("CA", "en-CA"), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn language_check_flags_us_fingerprint_behind_armenian_exit() {
|
||||||
|
// The reported scenario: a US (en) fingerprint routed through an Armenian
|
||||||
|
// exit. Armenia's CLDR lists hy/ku/az, never en, so this must flag.
|
||||||
|
assert_eq!(language_matches_country("AM", "en-US"), Some(false));
|
||||||
|
// And a fingerprint actually matched to Armenia must not flag.
|
||||||
|
assert_eq!(language_matches_country("AM", "hy-AM"), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn language_check_still_flags_implausible_combinations() {
|
||||||
|
// Nothing in CLDR associates these with the country, so they remain the
|
||||||
|
// anti-bot tell the check exists to surface. (Japan lists ja/ryu/ko only.)
|
||||||
|
assert_eq!(language_matches_country("JP", "pt-BR"), Some(false));
|
||||||
|
assert_eq!(language_matches_country("JP", "de-DE"), Some(false));
|
||||||
|
assert_eq!(language_matches_country("BR", "ru-RU"), Some(false));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn language_check_tolerates_minority_languages_the_generator_can_emit() {
|
||||||
|
// CLDR lists ja for Brazil (0.21%, the Japanese-Brazilian community) and de
|
||||||
|
// for the US (0.47%), and the generator samples both. They are weak signals
|
||||||
|
// but flagging them would contradict our own fingerprints, so the check
|
||||||
|
// accepts anything CLDR lists at all. That is the deliberate ceiling on
|
||||||
|
// this dimension — timezone, compared exactly, carries the real signal.
|
||||||
|
assert_eq!(language_matches_country("BR", "ja-JP"), Some(true));
|
||||||
|
assert_eq!(language_matches_country("US", "de-DE"), Some(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn language_check_skips_countries_without_cldr_data() {
|
||||||
|
// The old hand-written table returned None for ~180 countries and silently
|
||||||
|
// skipped the check; only genuinely unknown territories should do that now.
|
||||||
|
assert_eq!(language_matches_country("ZZ", "en-US"), None);
|
||||||
|
// Countries the old table never covered are now checked.
|
||||||
|
assert!(language_matches_country("ID", "id-ID").is_some());
|
||||||
|
assert!(language_matches_country("CH", "de-CH").is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_url_percent_encodes_credentials_and_skips_shadowsocks() {
|
||||||
|
let http = crate::browser::ProxySettings {
|
||||||
|
proxy_type: "http".into(),
|
||||||
|
host: "h".into(),
|
||||||
|
port: 8080,
|
||||||
|
username: Some("u".into()),
|
||||||
|
password: Some("p".into()),
|
||||||
|
};
|
||||||
|
assert_eq!(proxy_url(&http).as_deref(), Some("http://u:p@h:8080"));
|
||||||
|
|
||||||
|
// A password with URL-reserved characters must not break the authority —
|
||||||
|
// unencoded, the `/` truncates the host and reqwest targets `u` instead.
|
||||||
|
let reserved = crate::browser::ProxySettings {
|
||||||
|
proxy_type: "http".into(),
|
||||||
|
host: "gw.provider.io".into(),
|
||||||
|
port: 8080,
|
||||||
|
username: Some("user".into()),
|
||||||
|
password: Some("ab/cd@ef".into()),
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
proxy_url(&reserved).as_deref(),
|
||||||
|
Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080")
|
||||||
|
);
|
||||||
|
|
||||||
|
// Username-only proxies keep their auth.
|
||||||
|
let user_only = crate::browser::ProxySettings {
|
||||||
|
proxy_type: "socks5".into(),
|
||||||
|
host: "h".into(),
|
||||||
|
port: 1080,
|
||||||
|
username: Some("justuser".into()),
|
||||||
|
password: None,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
proxy_url(&user_only).as_deref(),
|
||||||
|
Some("socks5://justuser@h:1080")
|
||||||
|
);
|
||||||
|
|
||||||
|
let ss = crate::browser::ProxySettings {
|
||||||
|
proxy_type: "ss".into(),
|
||||||
|
host: "h".into(),
|
||||||
|
port: 8080,
|
||||||
|
username: None,
|
||||||
|
password: None,
|
||||||
|
};
|
||||||
|
assert_eq!(proxy_url(&ss), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -141,13 +141,22 @@ impl GeoIPDownloader {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Fetch latest release from GitHub
|
#[cfg(feature = "e2e")]
|
||||||
let releases = self.fetch_geoip_releases().await?;
|
let fixture_url = std::env::var("DONUT_E2E_GEOIP_DOWNLOAD_URL")
|
||||||
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
|
.ok()
|
||||||
|
.filter(|url| !url.is_empty());
|
||||||
|
#[cfg(not(feature = "e2e"))]
|
||||||
|
let fixture_url: Option<String> = None;
|
||||||
|
|
||||||
let download_url = self
|
let download_url = if let Some(url) = fixture_url {
|
||||||
.find_city_mmdb_asset(latest_release)
|
url
|
||||||
.ok_or("No compatible GeoIP database asset found")?;
|
} else {
|
||||||
|
let releases = self.fetch_geoip_releases().await?;
|
||||||
|
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
|
||||||
|
self
|
||||||
|
.find_city_mmdb_asset(latest_release)
|
||||||
|
.ok_or("No compatible GeoIP database asset found")?
|
||||||
|
};
|
||||||
|
|
||||||
// Create cache directory
|
// Create cache directory
|
||||||
let cache_dir = Self::get_cache_dir();
|
let cache_dir = Self::get_cache_dir();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ use rand::RngExt;
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::net::IpAddr;
|
use std::net::IpAddr;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
|
||||||
const TERRITORY_INFO_XML: &str = include_str!("territory_info.xml");
|
const TERRITORY_INFO_XML: &str = include_str!("territory_info.xml");
|
||||||
|
|
||||||
@@ -41,6 +42,27 @@ pub enum GeolocationError {
|
|||||||
Ip(#[from] IpError),
|
Ip(#[from] IpError),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The language part of a locale or language tag: `"en"` from `"en-US"`,
|
||||||
|
/// `"zh"` from `"zh_Hant"`.
|
||||||
|
pub fn primary_subtag(tag: &str) -> &str {
|
||||||
|
tag.split(['-', '_']).next().unwrap_or(tag)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared selector over the bundled CLDR territory data. Parsing the XML costs
|
||||||
|
/// real time, and the data is immutable, so build it once.
|
||||||
|
pub fn locale_selector() -> Option<&'static LocaleSelector> {
|
||||||
|
static SELECTOR: OnceLock<Option<LocaleSelector>> = OnceLock::new();
|
||||||
|
SELECTOR
|
||||||
|
.get_or_init(|| match LocaleSelector::new() {
|
||||||
|
Ok(s) => Some(s),
|
||||||
|
Err(e) => {
|
||||||
|
log::warn!("Failed to build the CLDR locale selector: {e}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.as_ref()
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Locale {
|
pub struct Locale {
|
||||||
pub language: String,
|
pub language: String,
|
||||||
@@ -152,6 +174,28 @@ impl LocaleSelector {
|
|||||||
Ok(Self { territories })
|
Ok(Self { territories })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether CLDR associates `language` with `region` at all.
|
||||||
|
///
|
||||||
|
/// Returns `None` for a territory with no language data, so callers can skip
|
||||||
|
/// rather than guess. The test is deliberately "is this language listed for
|
||||||
|
/// this country", not "is this the country's main language": `from_region`
|
||||||
|
/// samples the whole listed distribution weighted by speaker share, so every
|
||||||
|
/// listed language is one this selector itself can emit. Anything stricter
|
||||||
|
/// would flag fingerprints Donut generated on purpose — CLDR puts `es` at
|
||||||
|
/// 9.6% in the US and `fr` at 30% in Canada, and those get picked.
|
||||||
|
pub fn region_speaks(&self, region: &str, language: &str) -> Option<bool> {
|
||||||
|
let languages = self.territories.get(®ion.to_uppercase())?;
|
||||||
|
if languages.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let primary = primary_subtag(language);
|
||||||
|
Some(
|
||||||
|
languages
|
||||||
|
.iter()
|
||||||
|
.any(|l| primary_subtag(&l.language).eq_ignore_ascii_case(primary)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
#[allow(clippy::wrong_self_convention)]
|
#[allow(clippy::wrong_self_convention)]
|
||||||
pub fn from_region(&self, region: &str) -> Result<Locale, GeolocationError> {
|
pub fn from_region(&self, region: &str) -> Result<Locale, GeolocationError> {
|
||||||
let region_upper = region.to_uppercase();
|
let region_upper = region.to_uppercase();
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ impl GroupManager {
|
|||||||
_app_handle: &tauri::AppHandle,
|
_app_handle: &tauri::AppHandle,
|
||||||
name: String,
|
name: String,
|
||||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(
|
||||||
|
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut groups_data = self.load_groups_data()?;
|
let mut groups_data = self.load_groups_data()?;
|
||||||
|
|
||||||
// Check if group with this name already exists
|
// Check if group with this name already exists
|
||||||
@@ -127,6 +135,14 @@ impl GroupManager {
|
|||||||
id: String,
|
id: String,
|
||||||
name: String,
|
name: String,
|
||||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(
|
||||||
|
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
let mut groups_data = self.load_groups_data()?;
|
let mut groups_data = self.load_groups_data()?;
|
||||||
|
|
||||||
// Check if another group with this name already exists
|
// Check if another group with this name already exists
|
||||||
|
|||||||
+275
-198
@@ -3,6 +3,7 @@ use std::env;
|
|||||||
use std::sync::atomic::{AtomicBool, Ordering};
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||||
|
#[cfg(not(feature = "e2e"))]
|
||||||
use tauri_plugin_deep_link::DeepLinkExt;
|
use tauri_plugin_deep_link::DeepLinkExt;
|
||||||
use tauri_plugin_log::{Target, TargetKind};
|
use tauri_plugin_log::{Target, TargetKind};
|
||||||
|
|
||||||
@@ -14,6 +15,25 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
|||||||
// to the confirmation dialog.
|
// to the confirmation dialog.
|
||||||
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
|
static QUIT_CONFIRMED: AtomicBool = AtomicBool::new(false);
|
||||||
|
|
||||||
|
fn e2e_automation_enabled() -> bool {
|
||||||
|
#[cfg(feature = "e2e")]
|
||||||
|
{
|
||||||
|
std::env::var("TAURI_AUTOMATION")
|
||||||
|
.is_ok_and(|value| value == "1" || value.eq_ignore_ascii_case("true"))
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "e2e"))]
|
||||||
|
{
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "e2e")]
|
||||||
|
fn e2e_automation_profile_dir() -> Option<std::path::PathBuf> {
|
||||||
|
e2e_automation_enabled()
|
||||||
|
.then(|| std::env::var_os("TAURI_AUTOMATION_PROFILE_DIR").map(std::path::PathBuf::from))
|
||||||
|
.flatten()
|
||||||
|
}
|
||||||
|
|
||||||
mod api_client;
|
mod api_client;
|
||||||
mod api_server;
|
mod api_server;
|
||||||
mod app_auto_updater;
|
mod app_auto_updater;
|
||||||
@@ -29,11 +49,13 @@ mod downloader;
|
|||||||
mod ephemeral_dirs;
|
mod ephemeral_dirs;
|
||||||
mod extension_manager;
|
mod extension_manager;
|
||||||
mod extraction;
|
mod extraction;
|
||||||
|
mod fingerprint_consistency;
|
||||||
mod geoip_downloader;
|
mod geoip_downloader;
|
||||||
mod geolocation;
|
mod geolocation;
|
||||||
mod group_manager;
|
mod group_manager;
|
||||||
mod human_typing;
|
mod human_typing;
|
||||||
mod ip_utils;
|
mod ip_utils;
|
||||||
|
mod log_redaction;
|
||||||
mod platform_browser;
|
mod platform_browser;
|
||||||
mod profile;
|
mod profile;
|
||||||
mod profile_importer;
|
mod profile_importer;
|
||||||
@@ -68,9 +90,10 @@ use browser_runner::{
|
|||||||
|
|
||||||
use profile::manager::{
|
use profile::manager::{
|
||||||
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
|
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
|
||||||
list_browser_profiles, rename_profile, update_profile_dns_blocklist, update_profile_launch_hook,
|
list_browser_profiles, rename_profile, update_profile_clear_on_close,
|
||||||
update_profile_note, update_profile_proxy, update_profile_proxy_bypass_rules,
|
update_profile_dns_blocklist, update_profile_launch_hook, update_profile_note,
|
||||||
update_profile_tags, update_profile_vpn, update_profile_window_color, update_wayfern_config,
|
update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
|
||||||
|
update_profile_window_color, update_wayfern_config,
|
||||||
};
|
};
|
||||||
|
|
||||||
use profile::password::{
|
use profile::password::{
|
||||||
@@ -125,7 +148,10 @@ use app_auto_updater::{
|
|||||||
restart_application,
|
restart_application,
|
||||||
};
|
};
|
||||||
|
|
||||||
use profile_importer::{detect_existing_profiles, import_browser_profile};
|
use profile_importer::{
|
||||||
|
cleanup_profile_import_scratch, detect_existing_profiles, import_browser_profiles,
|
||||||
|
scan_folder_for_profiles, scan_profile_archive,
|
||||||
|
};
|
||||||
|
|
||||||
use extension_manager::{
|
use extension_manager::{
|
||||||
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
|
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
|
||||||
@@ -222,7 +248,7 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
|||||||
// Called internally for deep-link / startup URL handling — not invoked from the
|
// Called internally for deep-link / startup URL handling — not invoked from the
|
||||||
// frontend, so it is intentionally not a `#[tauri::command]`.
|
// frontend, so it is intentionally not a `#[tauri::command]`.
|
||||||
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||||
log::info!("handle_url_open called with URL: {url}");
|
log::info!("Handling URL open request");
|
||||||
|
|
||||||
// Check if the main window exists and is ready
|
// Check if the main window exists and is ready
|
||||||
if let Some(window) = app.get_webview_window("main") {
|
if let Some(window) = app.get_webview_window("main") {
|
||||||
@@ -245,6 +271,18 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Prefix a command error with context, but pass structured `{"code": ...}`
|
||||||
|
/// backend errors through untouched — the frontend can only translate a code
|
||||||
|
/// when the JSON is the entire message (see src/lib/backend-errors.ts).
|
||||||
|
pub(crate) fn wrap_backend_error(e: impl std::fmt::Display, context: &str) -> String {
|
||||||
|
let msg = e.to_string();
|
||||||
|
if msg.starts_with('{') {
|
||||||
|
msg
|
||||||
|
} else {
|
||||||
|
format!("{context}: {msg}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn create_stored_proxy(
|
async fn create_stored_proxy(
|
||||||
app_handle: tauri::AppHandle,
|
app_handle: tauri::AppHandle,
|
||||||
@@ -254,7 +292,7 @@ async fn create_stored_proxy(
|
|||||||
if let Some(settings) = proxy_settings {
|
if let Some(settings) = proxy_settings {
|
||||||
crate::proxy_manager::PROXY_MANAGER
|
crate::proxy_manager::PROXY_MANAGER
|
||||||
.create_stored_proxy(&app_handle, name, settings)
|
.create_stored_proxy(&app_handle, name, settings)
|
||||||
.map_err(|e| format!("Failed to create stored proxy: {e}"))
|
.map_err(|e| wrap_backend_error(e, "Failed to create stored proxy"))
|
||||||
} else {
|
} else {
|
||||||
Err("proxy_settings is required".to_string())
|
Err("proxy_settings is required".to_string())
|
||||||
}
|
}
|
||||||
@@ -274,7 +312,7 @@ async fn update_stored_proxy(
|
|||||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||||
crate::proxy_manager::PROXY_MANAGER
|
crate::proxy_manager::PROXY_MANAGER
|
||||||
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
||||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
.map_err(|e| wrap_backend_error(e, "Failed to update stored proxy"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -769,6 +807,13 @@ async fn clear_all_traffic_stats() -> Result<(), String> {
|
|||||||
.map_err(|e| format!("Failed to clear traffic stats: {e}"))
|
.map_err(|e| format!("Failed to clear traffic stats: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
async fn clear_profile_traffic_stats(profile_id: String) -> Result<(), String> {
|
||||||
|
crate::traffic_stats::delete_traffic_stats(&profile_id);
|
||||||
|
let _ = events::emit_empty("traffic-stats-changed");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn get_traffic_stats_for_period(
|
async fn get_traffic_stats_for_period(
|
||||||
profile_id: String,
|
profile_id: String,
|
||||||
@@ -1202,6 +1247,7 @@ async fn generate_sample_fingerprint(
|
|||||||
created_by_email: None,
|
created_by_email: None,
|
||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
|
clear_on_close: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
updated_at: None,
|
updated_at: None,
|
||||||
};
|
};
|
||||||
@@ -1238,6 +1284,7 @@ fn hide_to_tray(app_handle: tauri::AppHandle) -> Result<(), String> {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "e2e"))]
|
||||||
fn show_main_window(app_handle: &tauri::AppHandle) {
|
fn show_main_window(app_handle: &tauri::AppHandle) {
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
let _ = window.show();
|
let _ = window.show();
|
||||||
@@ -1277,6 +1324,7 @@ fn update_tray_menu(
|
|||||||
/// Build the system tray. Best-effort: on Linux the tray depends on
|
/// Build the system tray. Best-effort: on Linux the tray depends on
|
||||||
/// libayatana-appindicator at runtime, so any failure here must not abort app
|
/// libayatana-appindicator at runtime, so any failure here must not abort app
|
||||||
/// startup — the caller logs and continues without a tray.
|
/// startup — the caller logs and continues without a tray.
|
||||||
|
#[cfg(not(feature = "e2e"))]
|
||||||
fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
use std::sync::atomic::Ordering;
|
use std::sync::atomic::Ordering;
|
||||||
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
use tauri::menu::{MenuBuilder, MenuItemBuilder};
|
||||||
@@ -1343,11 +1391,18 @@ fn setup_system_tray(app: &tauri::AppHandle) -> Result<(), Box<dyn std::error::E
|
|||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
|
run_with_builder(|builder| builder);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[doc(hidden)]
|
||||||
|
pub fn run_with_builder(
|
||||||
|
configure_builder: impl FnOnce(tauri::Builder<tauri::Wry>) -> tauri::Builder<tauri::Wry>,
|
||||||
|
) {
|
||||||
let args: Vec<String> = env::args().collect();
|
let args: Vec<String> = env::args().collect();
|
||||||
let startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned();
|
let startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned();
|
||||||
|
|
||||||
if let Some(url) = startup_url.clone() {
|
if let Some(url) = startup_url.clone() {
|
||||||
log::info!("Found startup URL in command line: {url}");
|
log::info!("Found startup URL in command line");
|
||||||
let mut pending = PENDING_URLS.lock().unwrap();
|
let mut pending = PENDING_URLS.lock().unwrap();
|
||||||
pending.push(url.clone());
|
pending.push(url.clone());
|
||||||
}
|
}
|
||||||
@@ -1366,54 +1421,61 @@ pub fn run() {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
tauri::Builder::default()
|
let builder = configure_builder(tauri::Builder::default());
|
||||||
.plugin(
|
|
||||||
tauri_plugin_log::Builder::new()
|
let builder = builder.plugin(
|
||||||
.clear_targets() // Clear default targets to avoid duplicates
|
tauri_plugin_log::Builder::new()
|
||||||
.target(Target::new(TargetKind::Stdout))
|
.clear_targets() // Clear default targets to avoid duplicates
|
||||||
.target(Target::new(TargetKind::Webview))
|
.target(Target::new(TargetKind::Stdout))
|
||||||
.target(file_log_target)
|
.target(Target::new(TargetKind::Webview))
|
||||||
// 5 MB per rotated file × KeepAll — the previous 100 KB limit
|
.target(file_log_target)
|
||||||
// truncated useful context in customer support reports; 50 MB
|
// Keep enough context for customer support without letting a long-running
|
||||||
// turned out to be excessive disk pressure.
|
// installation accumulate logs without bound.
|
||||||
.max_file_size(5 * 1024 * 1024)
|
.max_file_size(5 * 1024 * 1024)
|
||||||
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepAll)
|
.rotation_strategy(tauri_plugin_log::RotationStrategy::KeepSome(10))
|
||||||
.level(log::LevelFilter::Info)
|
.level(log::LevelFilter::Info)
|
||||||
.format(|out, message, record| {
|
.format(|out, message, record| {
|
||||||
use chrono::Local;
|
use chrono::Local;
|
||||||
let now = Local::now();
|
let now = Local::now();
|
||||||
let timestamp = format!(
|
let timestamp = format!(
|
||||||
"{}.{:03}",
|
"{}.{:03}",
|
||||||
now.format("%Y-%m-%d %H:%M:%S"),
|
now.format("%Y-%m-%d %H:%M:%S"),
|
||||||
now.timestamp_subsec_millis()
|
now.timestamp_subsec_millis()
|
||||||
);
|
);
|
||||||
out.finish(format_args!(
|
out.finish(format_args!(
|
||||||
"[{}][{}][{}] {}",
|
"[{}][{}][{}] {}",
|
||||||
timestamp,
|
timestamp,
|
||||||
record.target(),
|
record.target(),
|
||||||
record.level(),
|
record.level(),
|
||||||
message
|
message
|
||||||
))
|
))
|
||||||
})
|
})
|
||||||
.build(),
|
.build(),
|
||||||
)
|
);
|
||||||
.plugin(tauri_plugin_single_instance::init(
|
|
||||||
|app_handle, args, _cwd| {
|
#[cfg(not(feature = "e2e"))]
|
||||||
log::info!("Single instance triggered with args: {args:?}");
|
let builder = builder.plugin(tauri_plugin_single_instance::init(
|
||||||
if let Some(window) = app_handle.get_webview_window("main") {
|
|app_handle, args, _cwd| {
|
||||||
let _ = window.show();
|
log::info!("Single instance triggered with args: {args:?}");
|
||||||
let _ = window.set_focus();
|
if let Some(window) = app_handle.get_webview_window("main") {
|
||||||
let _ = window.unminimize();
|
let _ = window.show();
|
||||||
}
|
let _ = window.set_focus();
|
||||||
},
|
let _ = window.unminimize();
|
||||||
))
|
}
|
||||||
|
},
|
||||||
|
));
|
||||||
|
|
||||||
|
let builder = builder
|
||||||
.plugin(tauri_plugin_deep_link::init())
|
.plugin(tauri_plugin_deep_link::init())
|
||||||
.plugin(tauri_plugin_fs::init())
|
.plugin(tauri_plugin_fs::init())
|
||||||
.plugin(tauri_plugin_opener::init())
|
.plugin(tauri_plugin_opener::init())
|
||||||
.plugin(tauri_plugin_shell::init())
|
.plugin(tauri_plugin_shell::init())
|
||||||
.plugin(tauri_plugin_dialog::init())
|
.plugin(tauri_plugin_dialog::init())
|
||||||
.plugin(tauri_plugin_macos_permissions::init())
|
.plugin(tauri_plugin_macos_permissions::init())
|
||||||
.plugin(tauri_plugin_clipboard_manager::init())
|
.plugin(tauri_plugin_clipboard_manager::init());
|
||||||
|
|
||||||
|
#[cfg(not(feature = "e2e"))]
|
||||||
|
let builder = builder
|
||||||
// Persist window size/position across restarts. VISIBLE is excluded
|
// Persist window size/position across restarts. VISIBLE is excluded
|
||||||
// because the app hides to tray: restoring visibility would otherwise
|
// because the app hides to tray: restoring visibility would otherwise
|
||||||
// relaunch with an invisible window after quitting from the tray while
|
// relaunch with an invisible window after quitting from the tray while
|
||||||
@@ -1428,8 +1490,9 @@ pub fn run() {
|
|||||||
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||||
)
|
)
|
||||||
.build(),
|
.build(),
|
||||||
)
|
);
|
||||||
.setup(|app| {
|
|
||||||
|
builder.setup(|app| {
|
||||||
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
||||||
ephemeral_dirs::recover_ephemeral_dirs();
|
ephemeral_dirs::recover_ephemeral_dirs();
|
||||||
|
|
||||||
@@ -1451,6 +1514,19 @@ pub fn run() {
|
|||||||
.focused(true)
|
.focused(true)
|
||||||
.visible(true);
|
.visible(true);
|
||||||
|
|
||||||
|
#[cfg(feature = "e2e")]
|
||||||
|
let win_builder = match e2e_automation_profile_dir() {
|
||||||
|
Some(profile_dir) => win_builder
|
||||||
|
.data_directory(profile_dir.join("webview"))
|
||||||
|
// WKWebView ignores data_directory on macOS. Incognito gives every
|
||||||
|
// launched app process a non-persistent data store there, and also
|
||||||
|
// prevents WebView2/WebKitGTK caches from escaping the session on
|
||||||
|
// the other platforms. Durable app state is still exercised via
|
||||||
|
// DONUTBROWSER_DATA_ROOT; only browser-engine storage is ephemeral.
|
||||||
|
.incognito(true),
|
||||||
|
None => win_builder,
|
||||||
|
};
|
||||||
|
|
||||||
#[cfg(target_os = "windows")]
|
#[cfg(target_os = "windows")]
|
||||||
let win_builder = win_builder.decorations(false);
|
let win_builder = win_builder.decorations(false);
|
||||||
|
|
||||||
@@ -1461,8 +1537,11 @@ pub fn run() {
|
|||||||
// dialog's "Minimize" action hides the window. Best-effort: a tray
|
// dialog's "Minimize" action hides the window. Best-effort: a tray
|
||||||
// failure (e.g. missing libayatana-appindicator on Linux) must never
|
// failure (e.g. missing libayatana-appindicator on Linux) must never
|
||||||
// prevent the app from launching, so we log and continue without it.
|
// prevent the app from launching, so we log and continue without it.
|
||||||
if let Err(e) = setup_system_tray(app.handle()) {
|
#[cfg(not(feature = "e2e"))]
|
||||||
log::warn!("System tray unavailable, continuing without it: {e}");
|
{
|
||||||
|
if let Err(e) = setup_system_tray(app.handle()) {
|
||||||
|
log::warn!("System tray unavailable, continuing without it: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Intercept the window close so the frontend can ask the user whether
|
// Intercept the window close so the frontend can ask the user whether
|
||||||
@@ -1505,7 +1584,7 @@ pub fn run() {
|
|||||||
log::warn!("Failed to set global event emitter: {e}");
|
log::warn!("Failed to set global event emitter: {e}");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(windows)]
|
#[cfg(all(windows, not(feature = "e2e")))]
|
||||||
{
|
{
|
||||||
// For Windows, register all deep links at runtime
|
// For Windows, register all deep links at runtime
|
||||||
if let Err(e) = app.deep_link().register_all() {
|
if let Err(e) = app.deep_link().register_all() {
|
||||||
@@ -1513,7 +1592,7 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(target_os = "macos")]
|
#[cfg(all(target_os = "macos", not(feature = "e2e")))]
|
||||||
{
|
{
|
||||||
// On macOS, try to register deep links for development builds
|
// On macOS, try to register deep links for development builds
|
||||||
if let Err(e) = app.deep_link().register_all() {
|
if let Err(e) = app.deep_link().register_all() {
|
||||||
@@ -1523,63 +1602,62 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
app.deep_link().on_open_url({
|
#[cfg(not(feature = "e2e"))]
|
||||||
let handle = handle.clone();
|
{
|
||||||
move |event| {
|
app.deep_link().on_open_url({
|
||||||
let urls = event.urls();
|
let handle = handle.clone();
|
||||||
log::info!("Deep link event received with {} URLs", urls.len());
|
move |event| {
|
||||||
|
let urls = event.urls();
|
||||||
|
log::info!("Deep link event received with {} URLs", urls.len());
|
||||||
|
|
||||||
for url in urls {
|
for url in urls {
|
||||||
let url_string = url.to_string();
|
let url_string = url.to_string();
|
||||||
log::info!("Deep link received: {url_string}");
|
log::info!("Processing deep link URL");
|
||||||
|
let handle_clone = handle.clone();
|
||||||
|
|
||||||
// Clone the handle for each async task
|
tauri::async_runtime::spawn(async move {
|
||||||
let handle_clone = handle.clone();
|
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
|
||||||
|
log::error!("Failed to handle deep link URL: {e}");
|
||||||
// Handle the URL asynchronously
|
}
|
||||||
tauri::async_runtime::spawn(async move {
|
});
|
||||||
if let Err(e) = handle_url_open(handle_clone, url_string.clone()).await {
|
}
|
||||||
log::error!("Failed to handle deep link URL: {e}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
if let Some(startup_url) = startup_url {
|
if let Some(startup_url) = startup_url {
|
||||||
let handle_clone = handle.clone();
|
let handle_clone = handle.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
log::info!("Processing startup URL from command line: {startup_url}");
|
log::info!("Processing startup URL from command line");
|
||||||
if let Err(e) = handle_url_open(handle_clone, startup_url.clone()).await {
|
if let Err(e) = handle_url_open(handle_clone, startup_url.clone()).await {
|
||||||
log::error!("Failed to handle startup URL: {e}");
|
log::error!("Failed to handle startup URL: {e}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize and start background version updater
|
if !e2e_automation_enabled() {
|
||||||
let app_handle = app.handle().clone();
|
// Initialize and start background version updater
|
||||||
tauri::async_runtime::spawn(async move {
|
let app_handle = app.handle().clone();
|
||||||
let version_updater = get_version_updater();
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let version_updater = get_version_updater();
|
||||||
|
|
||||||
// Set the app handle
|
{
|
||||||
{
|
let mut updater_guard = version_updater.lock().await;
|
||||||
let mut updater_guard = version_updater.lock().await;
|
updater_guard.set_app_handle(app_handle);
|
||||||
updater_guard.set_app_handle(app_handle);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Run startup check without holding the lock
|
|
||||||
{
|
|
||||||
let updater_guard = version_updater.lock().await;
|
|
||||||
if let Err(e) = updater_guard.start_background_updates().await {
|
|
||||||
log::error!("Failed to start background updates: {e}");
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the background update task separately
|
{
|
||||||
tauri::async_runtime::spawn(async move {
|
let updater_guard = version_updater.lock().await;
|
||||||
version_updater::VersionUpdater::run_background_task().await;
|
if let Err(e) = updater_guard.start_background_updates().await {
|
||||||
});
|
log::error!("Failed to start background updates: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
version_updater::VersionUpdater::run_background_task().await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Auto-start MCP server if it was previously enabled. Always log the
|
// Auto-start MCP server if it was previously enabled. Always log the
|
||||||
// decision so customer logs reveal whether MCP is actually running —
|
// decision so customer logs reveal whether MCP is actually running —
|
||||||
@@ -1740,12 +1818,12 @@ pub fn run() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let app_handle_auto_updater = app.handle().clone();
|
if !e2e_automation_enabled() {
|
||||||
|
let app_handle_auto_updater = app.handle().clone();
|
||||||
// Start the auto-update check task separately
|
tauri::async_runtime::spawn(async move {
|
||||||
tauri::async_runtime::spawn(async move {
|
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
|
||||||
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
|
});
|
||||||
});
|
}
|
||||||
|
|
||||||
// Handle any pending URLs that were received before the window was ready
|
// Handle any pending URLs that were received before the window was ready
|
||||||
let handle_pending = handle.clone();
|
let handle_pending = handle.clone();
|
||||||
@@ -1761,114 +1839,92 @@ pub fn run() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
for url in pending_urls {
|
for url in pending_urls {
|
||||||
log::info!("Processing pending URL: {url}");
|
log::info!("Processing pending URL");
|
||||||
if let Err(e) = handle_url_open(handle_pending.clone(), url).await {
|
if let Err(e) = handle_url_open(handle_pending.clone(), url).await {
|
||||||
log::error!("Failed to handle pending URL: {e}");
|
log::error!("Failed to handle pending URL: {e}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Start periodic cleanup task for unused binaries
|
if !e2e_automation_enabled() {
|
||||||
// Only runs when sync is not in progress to avoid deleting browsers
|
// Start periodic cleanup task for unused binaries.
|
||||||
// that might be needed for profiles being synced from the cloud
|
tauri::async_runtime::spawn(async move {
|
||||||
tauri::async_runtime::spawn(async move {
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
|
||||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); // Every 12 hours
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
loop {
|
if let Some(scheduler) = sync::get_global_scheduler() {
|
||||||
interval.tick().await;
|
if scheduler.is_sync_in_progress().await {
|
||||||
|
log::debug!("Skipping cleanup: sync is in progress");
|
||||||
// Check if sync is in progress before running cleanup
|
continue;
|
||||||
if let Some(scheduler) = sync::get_global_scheduler() {
|
|
||||||
if scheduler.is_sync_in_progress().await {
|
|
||||||
log::debug!("Skipping cleanup: sync is in progress");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let registry =
|
|
||||||
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
|
||||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
|
||||||
log::error!("Periodic cleanup failed: {e}");
|
|
||||||
} else {
|
|
||||||
log::debug!("Periodic cleanup completed successfully");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// DNS blocklist refresh task (every 12 hours)
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let manager = dns_blocklist::BlocklistManager::instance();
|
|
||||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
|
|
||||||
interval.tick().await; // Skip the immediate first tick
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
manager.refresh_all_stale().await;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
|
|
||||||
|
|
||||||
loop {
|
|
||||||
interval.tick().await;
|
|
||||||
|
|
||||||
log::info!("Checking for app updates...");
|
|
||||||
// Route through check_for_app_updates (not the raw check_for_updates)
|
|
||||||
// so the background loop respects portable mode and the
|
|
||||||
// disable_auto_updates setting. Previously it bypassed both, so a
|
|
||||||
// portable install would auto-download and run the NSIS installer,
|
|
||||||
// clobbering the portable folder instead of updating in place.
|
|
||||||
match app_auto_updater::check_for_app_updates().await {
|
|
||||||
Ok(Some(update_info)) => {
|
|
||||||
log::info!(
|
|
||||||
"App update available: {} -> {}",
|
|
||||||
update_info.current_version,
|
|
||||||
update_info.new_version
|
|
||||||
);
|
|
||||||
if let Err(e) = events::emit("app-update-available", &update_info) {
|
|
||||||
log::error!("Failed to emit app update event: {e}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(None) => {
|
|
||||||
log::debug!("No app updates available");
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::error!("Failed to check for app updates: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check and download GeoIP database at startup if needed
|
let registry =
|
||||||
let app_handle_geoip = app.handle().clone();
|
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||||
tauri::async_runtime::spawn(async move {
|
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||||
// Wait a bit for the app to fully initialize
|
log::error!("Periodic cleanup failed: {e}");
|
||||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
|
||||||
|
|
||||||
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
|
|
||||||
match geoip_downloader.check_missing_geoip_database() {
|
|
||||||
Ok(true) => {
|
|
||||||
log::info!(
|
|
||||||
"GeoIP database is missing for Wayfern profiles, downloading at startup..."
|
|
||||||
);
|
|
||||||
let geoip_downloader = GeoIPDownloader::instance();
|
|
||||||
if let Err(e) = geoip_downloader
|
|
||||||
.download_geoip_database(&app_handle_geoip)
|
|
||||||
.await
|
|
||||||
{
|
|
||||||
log::error!("Failed to download GeoIP database at startup: {e}");
|
|
||||||
} else {
|
} else {
|
||||||
log::info!("GeoIP database downloaded successfully at startup");
|
log::debug!("Periodic cleanup completed successfully");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(false) => {
|
});
|
||||||
// No Wayfern profiles or GeoIP database already available
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let manager = dns_blocklist::BlocklistManager::instance();
|
||||||
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200));
|
||||||
|
interval.tick().await;
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
manager.refresh_all_stale().await;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
});
|
||||||
log::error!("Failed to check GeoIP database status at startup: {e}");
|
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
log::info!("Checking for app updates...");
|
||||||
|
match app_auto_updater::check_for_app_updates().await {
|
||||||
|
Ok(Some(update_info)) => {
|
||||||
|
log::info!(
|
||||||
|
"App update available: {} -> {}",
|
||||||
|
update_info.current_version,
|
||||||
|
update_info.new_version
|
||||||
|
);
|
||||||
|
if let Err(e) = events::emit("app-update-available", &update_info) {
|
||||||
|
log::error!("Failed to emit app update event: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => log::debug!("No app updates available"),
|
||||||
|
Err(e) => log::error!("Failed to check for app updates: {e}"),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
});
|
|
||||||
|
let app_handle_geoip = app.handle().clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||||
|
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
|
||||||
|
match geoip_downloader.check_missing_geoip_database() {
|
||||||
|
Ok(true) => {
|
||||||
|
log::info!(
|
||||||
|
"GeoIP database is missing for Wayfern profiles, downloading at startup..."
|
||||||
|
);
|
||||||
|
let geoip_downloader = GeoIPDownloader::instance();
|
||||||
|
if let Err(e) = geoip_downloader
|
||||||
|
.download_geoip_database(&app_handle_geoip)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
log::error!("Failed to download GeoIP database at startup: {e}");
|
||||||
|
} else {
|
||||||
|
log::info!("GeoIP database downloaded successfully at startup");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(false) => {}
|
||||||
|
Err(e) => log::error!("Failed to check GeoIP database status at startup: {e}"),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Start proxy cleanup task for dead browser processes
|
// Start proxy cleanup task for dead browser processes
|
||||||
let app_handle_proxy_cleanup = app.handle().clone();
|
let app_handle_proxy_cleanup = app.handle().clone();
|
||||||
@@ -2029,6 +2085,16 @@ pub fn run() {
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clear-on-close for natural exits (user closed the window).
|
||||||
|
// The explicit kill path in browser_runner.rs handles
|
||||||
|
// app-driven stops. Must also run before
|
||||||
|
// `mark_profile_stopped` so a queued sync sees the cleared
|
||||||
|
// dir rather than re-uploading the wiped browsing data.
|
||||||
|
if !is_running {
|
||||||
|
crate::profile::clear_on_close::clear_profile_browsing_data(&profile)
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
|
||||||
// Notify sync scheduler of running state changes
|
// Notify sync scheduler of running state changes
|
||||||
if let Some(scheduler) = sync::get_global_scheduler() {
|
if let Some(scheduler) = sync::get_global_scheduler() {
|
||||||
if is_running {
|
if is_running {
|
||||||
@@ -2223,6 +2289,7 @@ pub fn run() {
|
|||||||
update_profile_vpn,
|
update_profile_vpn,
|
||||||
update_profile_tags,
|
update_profile_tags,
|
||||||
update_profile_note,
|
update_profile_note,
|
||||||
|
update_profile_clear_on_close,
|
||||||
update_profile_launch_hook,
|
update_profile_launch_hook,
|
||||||
update_profile_window_color,
|
update_profile_window_color,
|
||||||
update_profile_proxy_bypass_rules,
|
update_profile_proxy_bypass_rules,
|
||||||
@@ -2256,7 +2323,10 @@ pub fn run() {
|
|||||||
download_and_prepare_app_update,
|
download_and_prepare_app_update,
|
||||||
restart_application,
|
restart_application,
|
||||||
detect_existing_profiles,
|
detect_existing_profiles,
|
||||||
import_browser_profile,
|
import_browser_profiles,
|
||||||
|
scan_folder_for_profiles,
|
||||||
|
scan_profile_archive,
|
||||||
|
cleanup_profile_import_scratch,
|
||||||
check_missing_binaries,
|
check_missing_binaries,
|
||||||
check_missing_geoip_database,
|
check_missing_geoip_database,
|
||||||
ensure_all_binaries_exist,
|
ensure_all_binaries_exist,
|
||||||
@@ -2301,7 +2371,10 @@ pub fn run() {
|
|||||||
get_all_traffic_snapshots,
|
get_all_traffic_snapshots,
|
||||||
get_profile_traffic_snapshot,
|
get_profile_traffic_snapshot,
|
||||||
clear_all_traffic_stats,
|
clear_all_traffic_stats,
|
||||||
|
clear_profile_traffic_stats,
|
||||||
get_traffic_stats_for_period,
|
get_traffic_stats_for_period,
|
||||||
|
fingerprint_consistency::check_profile_fingerprint_consistency,
|
||||||
|
fingerprint_consistency::match_profile_fingerprint_to_exit,
|
||||||
get_sync_settings,
|
get_sync_settings,
|
||||||
save_sync_settings,
|
save_sync_settings,
|
||||||
set_profile_sync_mode,
|
set_profile_sync_mode,
|
||||||
@@ -2377,6 +2450,10 @@ pub fn run() {
|
|||||||
// DNS blocklist commands
|
// DNS blocklist commands
|
||||||
dns_blocklist::get_dns_blocklist_cache_status,
|
dns_blocklist::get_dns_blocklist_cache_status,
|
||||||
dns_blocklist::refresh_dns_blocklists,
|
dns_blocklist::refresh_dns_blocklists,
|
||||||
|
dns_blocklist::get_custom_dns_config,
|
||||||
|
dns_blocklist::set_custom_dns_config,
|
||||||
|
dns_blocklist::import_custom_dns_rules,
|
||||||
|
dns_blocklist::export_custom_dns_rules,
|
||||||
// Profile password commands
|
// Profile password commands
|
||||||
set_profile_password,
|
set_profile_password,
|
||||||
change_profile_password,
|
change_profile_password,
|
||||||
|
|||||||
@@ -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>");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -638,6 +638,64 @@ impl McpServer {
|
|||||||
"required": ["name", "browser"]
|
"required": ["name", "browser"]
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
McpTool {
|
||||||
|
name: "detect_browser_profiles".to_string(),
|
||||||
|
description: "Detect importable Chromium-family browser profiles (Chrome, Chromium, Brave) on this machine, or scan a custom folder for profile directories".to_string(),
|
||||||
|
input_schema: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"folder": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional folder to scan instead of the default browser locations. Accepts a single profile dir, a Chromium user-data dir, or a folder holding one profile dir per child."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
McpTool {
|
||||||
|
name: "import_browser_profiles".to_string(),
|
||||||
|
description: "Bulk-import browser profiles from on-disk profile folders (e.g. paths returned by detect_browser_profiles). Each imported profile becomes a Wayfern profile; items are isolated so one failure doesn't stop the rest".to_string(),
|
||||||
|
input_schema: serde_json::json!({
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"items": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"source_path": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Path to the source profile directory"
|
||||||
|
},
|
||||||
|
"new_profile_name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name for the imported profile"
|
||||||
|
},
|
||||||
|
"proxy_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional proxy UUID to assign to this profile"
|
||||||
|
},
|
||||||
|
"vpn_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional VPN UUID to assign to this profile"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["source_path", "new_profile_name"]
|
||||||
|
},
|
||||||
|
"description": "Profiles to import"
|
||||||
|
},
|
||||||
|
"group_id": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Optional group UUID assigned to every imported profile"
|
||||||
|
},
|
||||||
|
"duplicate_strategy": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": ["skip", "rename"],
|
||||||
|
"description": "How to handle an already-taken profile name (default: rename with a numeric suffix)"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["items"]
|
||||||
|
}),
|
||||||
|
},
|
||||||
McpTool {
|
McpTool {
|
||||||
name: "update_profile".to_string(),
|
name: "update_profile".to_string(),
|
||||||
description: "Update an existing browser profile's settings".to_string(),
|
description: "Update an existing browser profile's settings".to_string(),
|
||||||
@@ -677,6 +735,10 @@ impl McpServer {
|
|||||||
"type": "array",
|
"type": "array",
|
||||||
"items": { "type": "string" },
|
"items": { "type": "string" },
|
||||||
"description": "Proxy bypass rules (replaces existing rules)"
|
"description": "Proxy bypass rules (replaces existing rules)"
|
||||||
|
},
|
||||||
|
"clear_on_close": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Wipe browsing data (keeping extensions and bookmarks) when the browser exits. Not available for ephemeral or password-protected profiles."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": ["profile_id"]
|
"required": ["profile_id"]
|
||||||
@@ -1731,6 +1793,9 @@ impl McpServer {
|
|||||||
self.handle_batch_stop_profiles(arguments).await
|
self.handle_batch_stop_profiles(arguments).await
|
||||||
}
|
}
|
||||||
"create_profile" => self.handle_create_profile(arguments).await,
|
"create_profile" => self.handle_create_profile(arguments).await,
|
||||||
|
// Profile import (free, like create_profile — importing is not automation)
|
||||||
|
"detect_browser_profiles" => self.handle_detect_browser_profiles(arguments).await,
|
||||||
|
"import_browser_profiles" => self.handle_import_browser_profiles(arguments).await,
|
||||||
"update_profile" => self.handle_update_profile(arguments).await,
|
"update_profile" => self.handle_update_profile(arguments).await,
|
||||||
"delete_profile" => self.handle_delete_profile(arguments).await,
|
"delete_profile" => self.handle_delete_profile(arguments).await,
|
||||||
"list_tags" => self.handle_list_tags().await,
|
"list_tags" => self.handle_list_tags().await,
|
||||||
@@ -2490,6 +2555,14 @@ impl McpServer {
|
|||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Some(clear_on_close) = arguments.get("clear_on_close").and_then(|v| v.as_bool()) {
|
||||||
|
pm.update_profile_clear_on_close(app_handle, profile_id, clear_on_close)
|
||||||
|
.map_err(|e| McpError {
|
||||||
|
code: -32000,
|
||||||
|
message: format!("Failed to update clear-on-close: {e}"),
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"content": [{
|
"content": [{
|
||||||
"type": "text",
|
"type": "text",
|
||||||
@@ -3201,6 +3274,95 @@ impl McpServer {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Profile import handlers
|
||||||
|
async fn handle_detect_browser_profiles(
|
||||||
|
&self,
|
||||||
|
arguments: &serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, McpError> {
|
||||||
|
let importer = crate::profile_importer::ProfileImporter::instance();
|
||||||
|
let profiles = match arguments.get("folder").and_then(|v| v.as_str()) {
|
||||||
|
Some(folder) => importer.scan_folder(std::path::Path::new(folder)),
|
||||||
|
None => importer.detect_existing_profiles(),
|
||||||
|
}
|
||||||
|
.map_err(|e| McpError {
|
||||||
|
code: -32000,
|
||||||
|
message: format!("Failed to detect profiles: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": serde_json::to_string_pretty(&profiles).unwrap_or_else(|_| "[]".to_string())
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_import_browser_profiles(
|
||||||
|
&self,
|
||||||
|
arguments: &serde_json::Value,
|
||||||
|
) -> Result<serde_json::Value, McpError> {
|
||||||
|
let items: Vec<crate::profile_importer::ImportProfileItem> = arguments
|
||||||
|
.get("items")
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: "Missing items".to_string(),
|
||||||
|
})
|
||||||
|
.and_then(|v| {
|
||||||
|
serde_json::from_value(v).map_err(|e| McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: format!("Invalid items: {e}"),
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let group_id = arguments
|
||||||
|
.get("group_id")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string());
|
||||||
|
|
||||||
|
let duplicate_strategy = arguments
|
||||||
|
.get("duplicate_strategy")
|
||||||
|
.cloned()
|
||||||
|
.map(serde_json::from_value::<crate::profile_importer::DuplicateStrategy>)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| McpError {
|
||||||
|
code: -32602,
|
||||||
|
message: format!("Invalid duplicate_strategy: {e}"),
|
||||||
|
})?
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
// Clone the handle instead of holding the inner lock across a potentially
|
||||||
|
// multi-GB copy.
|
||||||
|
let app_handle = {
|
||||||
|
let inner = self.inner.lock().await;
|
||||||
|
inner.app_handle.clone().ok_or_else(|| McpError {
|
||||||
|
code: -32000,
|
||||||
|
message: "MCP server not properly initialized".to_string(),
|
||||||
|
})?
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = crate::profile_importer::ProfileImporter::instance()
|
||||||
|
.import_profiles(&app_handle, items, group_id, duplicate_strategy, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| McpError {
|
||||||
|
code: -32000,
|
||||||
|
message: format!("Failed to import profiles: {e}"),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"content": [{
|
||||||
|
"type": "text",
|
||||||
|
"text": format!(
|
||||||
|
"Import complete: {} imported, {} skipped, {} failed\n{}",
|
||||||
|
result.imported_count,
|
||||||
|
result.skipped_count,
|
||||||
|
result.failed_count,
|
||||||
|
serde_json::to_string_pretty(&result.results).unwrap_or_default()
|
||||||
|
)
|
||||||
|
}]
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
// Cookie management handlers
|
// Cookie management handlers
|
||||||
async fn handle_import_profile_cookies(
|
async fn handle_import_profile_cookies(
|
||||||
&self,
|
&self,
|
||||||
@@ -5304,6 +5466,9 @@ mod tests {
|
|||||||
assert!(tool_names.contains(&"run_profile"));
|
assert!(tool_names.contains(&"run_profile"));
|
||||||
assert!(tool_names.contains(&"kill_profile"));
|
assert!(tool_names.contains(&"kill_profile"));
|
||||||
assert!(tool_names.contains(&"get_profile_status"));
|
assert!(tool_names.contains(&"get_profile_status"));
|
||||||
|
// Profile import tools
|
||||||
|
assert!(tool_names.contains(&"detect_browser_profiles"));
|
||||||
|
assert!(tool_names.contains(&"import_browser_profiles"));
|
||||||
// Group tools
|
// Group tools
|
||||||
assert!(tool_names.contains(&"list_groups"));
|
assert!(tool_names.contains(&"list_groups"));
|
||||||
assert!(tool_names.contains(&"get_group"));
|
assert!(tool_names.contains(&"get_group"));
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ use std::process::Command;
|
|||||||
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
|
fn cmd_matches_profile_path(cmd: &[std::ffi::OsString], profile_path: &str) -> bool {
|
||||||
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
|
let args: Vec<&str> = cmd.iter().filter_map(|a| a.to_str()).collect();
|
||||||
for (i, arg) in args.iter().enumerate() {
|
for (i, arg) in args.iter().enumerate() {
|
||||||
// Exact argument equality (Firefox: `-profile <path>`; some launchers
|
// Exact argument equality (some launchers pass the path as its own arg).
|
||||||
// pass the path as its own arg).
|
|
||||||
if *arg == profile_path {
|
if *arg == profile_path {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -85,59 +84,6 @@ pub mod macos {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn open_url_in_existing_browser_firefox_like(
|
|
||||||
profile: &BrowserProfile,
|
|
||||||
url: &str,
|
|
||||||
browser_type: BrowserType,
|
|
||||||
browser_dir: &Path,
|
|
||||||
profiles_dir: &Path,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let pid = profile.process_id.unwrap();
|
|
||||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
|
||||||
|
|
||||||
// First try: Use Firefox remote command
|
|
||||||
log::info!("Trying Firefox remote command for PID: {pid}");
|
|
||||||
let browser = create_browser(browser_type);
|
|
||||||
if let Ok(executable_path) = browser.get_executable_path(browser_dir) {
|
|
||||||
let remote_args = vec![
|
|
||||||
"-profile".to_string(),
|
|
||||||
profile_data_path.to_string_lossy().to_string(),
|
|
||||||
"-new-tab".to_string(),
|
|
||||||
url.to_string(),
|
|
||||||
];
|
|
||||||
|
|
||||||
let remote_output = Command::new(executable_path).args(&remote_args).output();
|
|
||||||
|
|
||||||
match remote_output {
|
|
||||||
Ok(output) if output.status.success() => {
|
|
||||||
log::info!("Firefox remote command succeeded");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
Ok(output) => {
|
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
||||||
log::info!(
|
|
||||||
"Firefox remote command failed with stderr: {stderr}, trying AppleScript fallback"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
log::info!("Firefox remote command error: {e}, trying AppleScript fallback");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Firefox `-new-tab` remote command failed. We intentionally do NOT
|
|
||||||
// fall back to an AppleScript `System Events` keystroke path: that would
|
|
||||||
// send Apple Events to another application and trigger the macOS TCC
|
|
||||||
// "<Donut> wants control of <Browser>" / "prevented from modifying other
|
|
||||||
// apps" prompts. Donut must never touch other apps on the user's Mac.
|
|
||||||
Err(
|
|
||||||
format!(
|
|
||||||
"Firefox remote command failed for PID {pid}; cannot open URL in existing window without touching other apps"
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn kill_browser_process_impl(
|
pub async fn kill_browser_process_impl(
|
||||||
pid: u32,
|
pid: u32,
|
||||||
profile_data_path: Option<&str>,
|
profile_data_path: Option<&str>,
|
||||||
@@ -399,77 +345,6 @@ pub mod windows {
|
|||||||
Ok(child)
|
Ok(child)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn open_url_in_existing_browser_firefox_like(
|
|
||||||
profile: &BrowserProfile,
|
|
||||||
url: &str,
|
|
||||||
browser_type: BrowserType,
|
|
||||||
browser_dir: &Path,
|
|
||||||
profiles_dir: &Path,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let browser = create_browser(browser_type);
|
|
||||||
let executable_path = browser
|
|
||||||
.get_executable_path(browser_dir)
|
|
||||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
|
||||||
|
|
||||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
|
||||||
|
|
||||||
// For Windows, try using the -requestPending approach for Firefox
|
|
||||||
let mut cmd = Command::new(executable_path);
|
|
||||||
cmd.args([
|
|
||||||
"-profile",
|
|
||||||
&profile_data_path.to_string_lossy(),
|
|
||||||
"-requestPending",
|
|
||||||
"-new-tab",
|
|
||||||
url,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// Set working directory
|
|
||||||
if let Some(parent_dir) = browser_dir
|
|
||||||
.parent()
|
|
||||||
.or_else(|| browser_dir.ancestors().nth(1))
|
|
||||||
{
|
|
||||||
cmd.current_dir(parent_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
let output = cmd.output()?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
// Fallback: try without -requestPending
|
|
||||||
let executable_path = browser
|
|
||||||
.get_executable_path(browser_dir)
|
|
||||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
|
||||||
let mut fallback_cmd = Command::new(executable_path);
|
|
||||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
|
||||||
fallback_cmd.args([
|
|
||||||
"-profile",
|
|
||||||
&profile_data_path.to_string_lossy(),
|
|
||||||
"-new-tab",
|
|
||||||
url,
|
|
||||||
]);
|
|
||||||
|
|
||||||
if let Some(parent_dir) = browser_dir
|
|
||||||
.parent()
|
|
||||||
.or_else(|| browser_dir.ancestors().nth(1))
|
|
||||||
{
|
|
||||||
fallback_cmd.current_dir(parent_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
let fallback_output = fallback_cmd.output()?;
|
|
||||||
|
|
||||||
if !fallback_output.status.success() {
|
|
||||||
return Err(
|
|
||||||
format!(
|
|
||||||
"Failed to open URL in existing browser: {}",
|
|
||||||
String::from_utf8_lossy(&fallback_output.stderr)
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn open_url_in_existing_browser_chromium(
|
pub async fn open_url_in_existing_browser_chromium(
|
||||||
profile: &BrowserProfile,
|
profile: &BrowserProfile,
|
||||||
url: &str,
|
url: &str,
|
||||||
@@ -588,7 +463,7 @@ pub mod linux {
|
|||||||
let mut cmd = Command::new(executable_path);
|
let mut cmd = Command::new(executable_path);
|
||||||
cmd.args(args);
|
cmd.args(args);
|
||||||
|
|
||||||
// For Firefox-based browsers, ensure library path includes the installation directory
|
// Ensure the library path includes the installation directory
|
||||||
if let Some(install_dir) = executable_path.parent() {
|
if let Some(install_dir) = executable_path.parent() {
|
||||||
let mut ld_library_path = Vec::new();
|
let mut ld_library_path = Vec::new();
|
||||||
|
|
||||||
@@ -606,16 +481,15 @@ pub mod linux {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For Firefox specifically, add common system library paths that might be needed
|
// Add common system library paths that might be needed
|
||||||
let firefox_lib_paths = [
|
let system_lib_paths = [
|
||||||
"/usr/lib/firefox",
|
|
||||||
"/usr/lib/x86_64-linux-gnu",
|
"/usr/lib/x86_64-linux-gnu",
|
||||||
"/usr/lib/aarch64-linux-gnu",
|
"/usr/lib/aarch64-linux-gnu",
|
||||||
"/lib/x86_64-linux-gnu",
|
"/lib/x86_64-linux-gnu",
|
||||||
"/lib/aarch64-linux-gnu",
|
"/lib/aarch64-linux-gnu",
|
||||||
];
|
];
|
||||||
|
|
||||||
for lib_path in &firefox_lib_paths {
|
for lib_path in &system_lib_paths {
|
||||||
let path = std::path::Path::new(lib_path);
|
let path = std::path::Path::new(lib_path);
|
||||||
if path.exists() {
|
if path.exists() {
|
||||||
ld_library_path.push(lib_path.to_string());
|
ld_library_path.push(lib_path.to_string());
|
||||||
@@ -686,41 +560,6 @@ pub mod linux {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn open_url_in_existing_browser_firefox_like(
|
|
||||||
profile: &BrowserProfile,
|
|
||||||
url: &str,
|
|
||||||
browser_type: BrowserType,
|
|
||||||
browser_dir: &Path,
|
|
||||||
profiles_dir: &Path,
|
|
||||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
|
||||||
let browser = create_browser(browser_type);
|
|
||||||
let executable_path = browser
|
|
||||||
.get_executable_path(browser_dir)
|
|
||||||
.map_err(|e| format!("Failed to get executable path: {}", e))?;
|
|
||||||
|
|
||||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
|
||||||
let output = Command::new(executable_path)
|
|
||||||
.args([
|
|
||||||
"-profile",
|
|
||||||
&profile_data_path.to_string_lossy(),
|
|
||||||
"-new-tab",
|
|
||||||
url,
|
|
||||||
])
|
|
||||||
.output()?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
|
||||||
return Err(
|
|
||||||
format!(
|
|
||||||
"Failed to open URL in existing browser: {}",
|
|
||||||
String::from_utf8_lossy(&output.stderr)
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub async fn open_url_in_existing_browser_chromium(
|
pub async fn open_url_in_existing_browser_chromium(
|
||||||
profile: &BrowserProfile,
|
profile: &BrowserProfile,
|
||||||
url: &str,
|
url: &str,
|
||||||
|
|||||||
@@ -0,0 +1,292 @@
|
|||||||
|
//! Clear-on-close: wipe a profile's browsing data when the browser exits,
|
||||||
|
//! keeping extensions and bookmarks (and the settings files Chromium needs to
|
||||||
|
//! keep those working). The middle ground between a fully persistent profile
|
||||||
|
//! and a RAM-backed ephemeral one.
|
||||||
|
|
||||||
|
use std::fs;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use crate::profile::types::BrowserProfile;
|
||||||
|
|
||||||
|
/// Entries kept inside a Chromium profile directory (one with `Preferences`).
|
||||||
|
/// Everything else — cookies, History, caches, storage, sessions, autofill,
|
||||||
|
/// saved logins — is browsing data and gets removed.
|
||||||
|
const PROFILE_KEEP: &[&str] = &[
|
||||||
|
"Bookmarks",
|
||||||
|
"Bookmarks.bak",
|
||||||
|
"Extensions",
|
||||||
|
"Extension State",
|
||||||
|
"Extension Rules",
|
||||||
|
"Extension Scripts",
|
||||||
|
"Extension Cookies",
|
||||||
|
"Local Extension Settings",
|
||||||
|
"Managed Extension Settings",
|
||||||
|
// Preferences hold the extension registry + user settings; deleting them
|
||||||
|
// disables every installed extension, so they stay.
|
||||||
|
"Preferences",
|
||||||
|
"Secure Preferences",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Entries kept at the user-data-dir top level (outside profile subdirs).
|
||||||
|
const TOP_KEEP: &[&str] = &["Local State", "First Run"];
|
||||||
|
|
||||||
|
fn is_kept(name: &str) -> bool {
|
||||||
|
PROFILE_KEEP.contains(&name) || TOP_KEEP.contains(&name)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a top-level entry name is one of Chromium's profile directories.
|
||||||
|
///
|
||||||
|
/// Identity must not rest on `Preferences` existing. Chromium writes it lazily,
|
||||||
|
/// so a crash — or a user deleting a corrupt copy, a standard troubleshooting
|
||||||
|
/// step since it regenerates — leaves a populated `Default/` without it. Such a
|
||||||
|
/// directory would then be treated as stale and removed wholesale, destroying the
|
||||||
|
/// Extensions and Bookmarks this feature exists to preserve.
|
||||||
|
fn is_profile_dir_name(name: &str) -> bool {
|
||||||
|
matches!(name, "Default" | "Guest Profile" | "System Profile")
|
||||||
|
|| name
|
||||||
|
.strip_prefix("Profile ")
|
||||||
|
.is_some_and(|n| !n.is_empty() && n.chars().all(|c| c.is_ascii_digit()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_entry(path: &Path) {
|
||||||
|
let result = if path.is_dir() {
|
||||||
|
fs::remove_dir_all(path)
|
||||||
|
} else {
|
||||||
|
fs::remove_file(path)
|
||||||
|
};
|
||||||
|
if let Err(e) = result {
|
||||||
|
log::warn!("clear-on-close: failed to remove {}: {e}", path.display());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wipe browsing data inside a Chromium profile dir, keeping `PROFILE_KEEP`
|
||||||
|
/// (and `TOP_KEEP`, harmless at this level).
|
||||||
|
fn clear_chromium_profile_dir(profile_dir: &Path) -> usize {
|
||||||
|
let mut cleared = 0usize;
|
||||||
|
let Ok(entries) = fs::read_dir(profile_dir) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
if is_kept(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
remove_entry(&entry.path());
|
||||||
|
cleared += 1;
|
||||||
|
}
|
||||||
|
cleared
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear browsing data in a Wayfern user-data directory. Handles both
|
||||||
|
/// layouts: profile content at the root (imported profiles copy a Chromium
|
||||||
|
/// profile dir directly) and the standard `Default` / `Profile N` subdirs
|
||||||
|
/// Chromium creates itself. Top-level cache dirs (ShaderCache, GrShaderCache,
|
||||||
|
/// component_crx_cache, …) are removed; `Local State` stays because it holds
|
||||||
|
/// the os_crypt key extensions may rely on.
|
||||||
|
pub fn clear_user_data_dir(user_data_dir: &Path) -> usize {
|
||||||
|
if !user_data_dir.exists() {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Root itself is a profile dir (legacy/imported layout).
|
||||||
|
if user_data_dir.join("Preferences").exists() {
|
||||||
|
return clear_chromium_profile_dir(user_data_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut cleared = 0usize;
|
||||||
|
let Ok(entries) = fs::read_dir(user_data_dir) else {
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let name = entry.file_name();
|
||||||
|
let Some(name) = name.to_str() else { continue };
|
||||||
|
if is_kept(name) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_dir() && (path.join("Preferences").exists() || is_profile_dir_name(name)) {
|
||||||
|
// A profile subdir (Default / Profile N) — clear inside, keep the dir.
|
||||||
|
cleared += clear_chromium_profile_dir(&path);
|
||||||
|
} else {
|
||||||
|
remove_entry(&path);
|
||||||
|
cleared += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cleared
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear a profile's browsing data if its `clear_on_close` flag is set.
|
||||||
|
/// No-ops for ephemeral (already wiped) and password-protected (dir is
|
||||||
|
/// re-encrypted; plaintext never persists) profiles. Runs the filesystem work
|
||||||
|
/// on a blocking thread.
|
||||||
|
pub async fn clear_profile_browsing_data(profile: &BrowserProfile) {
|
||||||
|
if !profile.clear_on_close || profile.ephemeral || profile.password_protected {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let profiles_dir = crate::profile::ProfileManager::instance().get_profiles_dir();
|
||||||
|
let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||||
|
let name = profile.name.clone();
|
||||||
|
|
||||||
|
let cleared = tokio::task::spawn_blocking(move || clear_user_data_dir(&user_data_dir))
|
||||||
|
.await
|
||||||
|
.unwrap_or(0);
|
||||||
|
|
||||||
|
log::info!("clear-on-close: cleared {cleared} browsing-data entries for profile '{name}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
fn touch(dir: &Path, name: &str) {
|
||||||
|
fs::write(dir.join(name), "x").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mkdir(dir: &Path, name: &str) {
|
||||||
|
fs::create_dir_all(dir.join(name)).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clears_root_profile_layout_keeping_extensions_and_bookmarks() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let dir = tmp.path();
|
||||||
|
touch(dir, "Preferences");
|
||||||
|
touch(dir, "Secure Preferences");
|
||||||
|
touch(dir, "Bookmarks");
|
||||||
|
touch(dir, "History");
|
||||||
|
touch(dir, "Web Data");
|
||||||
|
touch(dir, "Login Data");
|
||||||
|
mkdir(dir, "Extensions");
|
||||||
|
mkdir(dir, "Local Extension Settings");
|
||||||
|
mkdir(dir, "Cache");
|
||||||
|
mkdir(dir, "Network");
|
||||||
|
touch(&dir.join("Network"), "Cookies");
|
||||||
|
mkdir(dir, "Local Storage");
|
||||||
|
|
||||||
|
let cleared = clear_user_data_dir(dir);
|
||||||
|
assert!(
|
||||||
|
cleared >= 5,
|
||||||
|
"should clear History/WebData/Login/Cache/Network/LocalStorage, got {cleared}"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(dir.join("Preferences").exists());
|
||||||
|
assert!(dir.join("Bookmarks").exists());
|
||||||
|
assert!(dir.join("Extensions").exists());
|
||||||
|
assert!(dir.join("Local Extension Settings").exists());
|
||||||
|
assert!(!dir.join("History").exists());
|
||||||
|
assert!(!dir.join("Web Data").exists());
|
||||||
|
assert!(!dir.join("Login Data").exists());
|
||||||
|
assert!(!dir.join("Cache").exists());
|
||||||
|
assert!(!dir.join("Network").exists(), "Network/Cookies must go");
|
||||||
|
assert!(!dir.join("Local Storage").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clears_standard_user_data_layout() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let dir = tmp.path();
|
||||||
|
touch(dir, "Local State");
|
||||||
|
mkdir(dir, "ShaderCache");
|
||||||
|
mkdir(dir, "Default");
|
||||||
|
let default = dir.join("Default");
|
||||||
|
touch(&default, "Preferences");
|
||||||
|
touch(&default, "Bookmarks");
|
||||||
|
touch(&default, "History");
|
||||||
|
mkdir(&default, "Extensions");
|
||||||
|
mkdir(&default, "IndexedDB");
|
||||||
|
|
||||||
|
clear_user_data_dir(dir);
|
||||||
|
|
||||||
|
assert!(dir.join("Local State").exists());
|
||||||
|
assert!(!dir.join("ShaderCache").exists());
|
||||||
|
assert!(default.join("Preferences").exists());
|
||||||
|
assert!(default.join("Bookmarks").exists());
|
||||||
|
assert!(default.join("Extensions").exists());
|
||||||
|
assert!(!default.join("History").exists());
|
||||||
|
assert!(!default.join("IndexedDB").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_subdir_without_preferences_keeps_extensions_and_bookmarks() {
|
||||||
|
// Chromium writes Preferences lazily, so a crash (or a user removing a
|
||||||
|
// corrupt copy) can leave a populated Default/ without it. Identifying
|
||||||
|
// profile dirs by Preferences alone would delete the whole directory.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let dir = tmp.path();
|
||||||
|
touch(dir, "Local State");
|
||||||
|
mkdir(dir, "Default");
|
||||||
|
let default = dir.join("Default");
|
||||||
|
touch(&default, "Bookmarks");
|
||||||
|
touch(&default, "History");
|
||||||
|
mkdir(&default, "Extensions");
|
||||||
|
mkdir(&default, "Local Extension Settings");
|
||||||
|
mkdir(&default, "IndexedDB");
|
||||||
|
|
||||||
|
clear_user_data_dir(dir);
|
||||||
|
|
||||||
|
assert!(default.exists(), "the profile dir must survive");
|
||||||
|
assert!(default.join("Bookmarks").exists());
|
||||||
|
assert!(default.join("Extensions").exists());
|
||||||
|
assert!(default.join("Local Extension Settings").exists());
|
||||||
|
// Browsing data inside it is still cleared.
|
||||||
|
assert!(!default.join("History").exists());
|
||||||
|
assert!(!default.join("IndexedDB").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn numbered_profile_dirs_are_recognised() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let dir = tmp.path();
|
||||||
|
mkdir(dir, "Profile 2");
|
||||||
|
let p2 = dir.join("Profile 2");
|
||||||
|
touch(&p2, "Bookmarks");
|
||||||
|
touch(&p2, "History");
|
||||||
|
|
||||||
|
clear_user_data_dir(dir);
|
||||||
|
|
||||||
|
assert!(p2.join("Bookmarks").exists());
|
||||||
|
assert!(!p2.join("History").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn non_profile_dirs_are_still_removed_wholesale() {
|
||||||
|
// The permissive branch must not turn into "never delete a directory":
|
||||||
|
// top-level caches are browsing data and have to go.
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
let dir = tmp.path();
|
||||||
|
mkdir(dir, "ShaderCache");
|
||||||
|
mkdir(dir, "component_crx_cache");
|
||||||
|
mkdir(dir, "Profileless");
|
||||||
|
|
||||||
|
clear_user_data_dir(dir);
|
||||||
|
|
||||||
|
assert!(!dir.join("ShaderCache").exists());
|
||||||
|
assert!(!dir.join("component_crx_cache").exists());
|
||||||
|
assert!(
|
||||||
|
!dir.join("Profileless").exists(),
|
||||||
|
"a name that merely starts with 'Profile' is not a profile dir"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn profile_dir_name_matching() {
|
||||||
|
assert!(is_profile_dir_name("Default"));
|
||||||
|
assert!(is_profile_dir_name("Profile 1"));
|
||||||
|
assert!(is_profile_dir_name("Profile 42"));
|
||||||
|
assert!(is_profile_dir_name("Guest Profile"));
|
||||||
|
assert!(is_profile_dir_name("System Profile"));
|
||||||
|
assert!(!is_profile_dir_name("Profile"));
|
||||||
|
assert!(!is_profile_dir_name("Profile "));
|
||||||
|
assert!(!is_profile_dir_name("Profile abc"));
|
||||||
|
assert!(!is_profile_dir_name("ShaderCache"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn missing_dir_is_a_noop() {
|
||||||
|
let tmp = TempDir::new().unwrap();
|
||||||
|
assert_eq!(clear_user_data_dir(&tmp.path().join("nope")), 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -82,6 +82,14 @@ impl ProfileManager {
|
|||||||
dns_blocklist: Option<String>,
|
dns_blocklist: Option<String>,
|
||||||
launch_hook: Option<String>,
|
launch_hook: Option<String>,
|
||||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(
|
||||||
|
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if proxy_id.is_some() && vpn_id.is_some() {
|
if proxy_id.is_some() && vpn_id.is_some() {
|
||||||
return Err("Cannot set both proxy_id and vpn_id".into());
|
return Err("Cannot set both proxy_id and vpn_id".into());
|
||||||
}
|
}
|
||||||
@@ -205,6 +213,7 @@ impl ProfileManager {
|
|||||||
created_by_email: None,
|
created_by_email: None,
|
||||||
dns_blocklist: None,
|
dns_blocklist: None,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
|
clear_on_close: false,
|
||||||
created_at: None,
|
created_at: None,
|
||||||
updated_at: None,
|
updated_at: None,
|
||||||
};
|
};
|
||||||
@@ -291,6 +300,7 @@ impl ProfileManager {
|
|||||||
created_by_email: None,
|
created_by_email: None,
|
||||||
dns_blocklist,
|
dns_blocklist,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
|
clear_on_close: false,
|
||||||
created_at: Some(
|
created_at: Some(
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@@ -399,6 +409,14 @@ impl ProfileManager {
|
|||||||
profile_id: &str,
|
profile_id: &str,
|
||||||
new_name: &str,
|
new_name: &str,
|
||||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||||
|
if new_name.trim().is_empty() {
|
||||||
|
return Err(
|
||||||
|
serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" })
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if new name already exists (case insensitive)
|
// Check if new name already exists (case insensitive)
|
||||||
let existing_profiles = self.list_profiles()?;
|
let existing_profiles = self.list_profiles()?;
|
||||||
if existing_profiles
|
if existing_profiles
|
||||||
@@ -730,6 +748,44 @@ impl ProfileManager {
|
|||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn update_profile_clear_on_close(
|
||||||
|
&self,
|
||||||
|
_app_handle: &tauri::AppHandle,
|
||||||
|
profile_id: &str,
|
||||||
|
clear_on_close: bool,
|
||||||
|
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||||
|
let profile_uuid =
|
||||||
|
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||||
|
let profiles = self.list_profiles()?;
|
||||||
|
let mut profile = profiles
|
||||||
|
.into_iter()
|
||||||
|
.find(|p| p.id == profile_uuid)
|
||||||
|
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||||
|
|
||||||
|
// Ephemeral profiles are already wiped on close; password-protected ones
|
||||||
|
// re-encrypt and never persist plaintext — the flag is meaningless there.
|
||||||
|
if clear_on_close && (profile.ephemeral || profile.password_protected) {
|
||||||
|
return Err(
|
||||||
|
serde_json::json!({ "code": "CLEAR_ON_CLOSE_UNAVAILABLE" })
|
||||||
|
.to_string()
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
profile.clear_on_close = clear_on_close;
|
||||||
|
profile.updated_at = Some(crate::proxy_manager::now_secs());
|
||||||
|
|
||||||
|
self.save_profile(&profile)?;
|
||||||
|
|
||||||
|
crate::sync::queue_profile_sync_if_eligible(&profile);
|
||||||
|
|
||||||
|
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||||
|
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(profile)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn update_profile_window_color(
|
pub fn update_profile_window_color(
|
||||||
&self,
|
&self,
|
||||||
_app_handle: &tauri::AppHandle,
|
_app_handle: &tauri::AppHandle,
|
||||||
@@ -994,6 +1050,7 @@ impl ProfileManager {
|
|||||||
created_by_email: None,
|
created_by_email: None,
|
||||||
dns_blocklist: source.dns_blocklist,
|
dns_blocklist: source.dns_blocklist,
|
||||||
password_protected: false,
|
password_protected: false,
|
||||||
|
clear_on_close: false,
|
||||||
created_at: Some(
|
created_at: Some(
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
@@ -1285,7 +1342,7 @@ impl ProfileManager {
|
|||||||
let profile_data_path_str = profile_data_path.to_string_lossy();
|
let profile_data_path_str = profile_data_path.to_string_lossy();
|
||||||
let profile_path_match = cmd.iter().any(|s| {
|
let profile_path_match = cmd.iter().any(|s| {
|
||||||
let arg = s.to_str().unwrap_or("");
|
let arg = s.to_str().unwrap_or("");
|
||||||
// For Firefox-based browsers, check for exact profile path match
|
// Match the Chromium --user-data-dir flag or an exact profile path argument
|
||||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||||
|| arg == profile_data_path_str
|
|| arg == profile_data_path_str
|
||||||
});
|
});
|
||||||
@@ -1323,7 +1380,7 @@ impl ProfileManager {
|
|||||||
let profile_data_path_str = profile_data_path.to_string_lossy();
|
let profile_data_path_str = profile_data_path.to_string_lossy();
|
||||||
let profile_path_match = cmd.iter().any(|s| {
|
let profile_path_match = cmd.iter().any(|s| {
|
||||||
let arg = s.to_str().unwrap_or("");
|
let arg = s.to_str().unwrap_or("");
|
||||||
// For Firefox-based browsers, check for exact profile path match
|
// Match the Chromium --user-data-dir flag or an exact profile path argument
|
||||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||||
|| arg == profile_data_path_str
|
|| arg == profile_data_path_str
|
||||||
});
|
});
|
||||||
@@ -1649,7 +1706,7 @@ pub async fn create_browser_profile_with_group(
|
|||||||
launch_hook,
|
launch_hook,
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("Failed to create profile: {e}"))
|
.map_err(|e| crate::wrap_backend_error(e, "Failed to create profile"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
@@ -1721,6 +1778,17 @@ pub fn update_profile_window_color(
|
|||||||
.map_err(|e| format!("Failed to update profile window color: {e}"))
|
.map_err(|e| format!("Failed to update profile window color: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub fn update_profile_clear_on_close(
|
||||||
|
app_handle: tauri::AppHandle,
|
||||||
|
profile_id: String,
|
||||||
|
clear_on_close: bool,
|
||||||
|
) -> Result<BrowserProfile, String> {
|
||||||
|
ProfileManager::instance()
|
||||||
|
.update_profile_clear_on_close(&app_handle, &profile_id, clear_on_close)
|
||||||
|
.map_err(crate::profile_importer::error_to_code_string)
|
||||||
|
}
|
||||||
|
|
||||||
/// Validate a launch hook value. Returns `Ok(None)` for "clear the hook"
|
/// Validate a launch hook value. Returns `Ok(None)` for "clear the hook"
|
||||||
/// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s)
|
/// (`None`, empty, or whitespace-only), `Ok(Some(_))` for a valid http(s)
|
||||||
/// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload.
|
/// URL, or `Err` with the `INVALID_LAUNCH_HOOK_URL` code payload.
|
||||||
@@ -1799,7 +1867,7 @@ pub fn rename_profile(
|
|||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
profile_manager
|
profile_manager
|
||||||
.rename_profile(&app_handle, &profile_id, &new_name)
|
.rename_profile(&app_handle, &profile_id, &new_name)
|
||||||
.map_err(|e| format!("Failed to rename profile: {e}"))
|
.map_err(|e| crate::wrap_backend_error(e, "Failed to rename profile"))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod clear_on_close;
|
||||||
pub mod encryption;
|
pub mod encryption;
|
||||||
pub mod manager;
|
pub mod manager;
|
||||||
pub mod password;
|
pub mod password;
|
||||||
|
|||||||
@@ -72,6 +72,10 @@ pub struct BrowserProfile {
|
|||||||
/// Decryption goes to a RAM-backed ephemeral dir, never to disk.
|
/// Decryption goes to a RAM-backed ephemeral dir, never to disk.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub password_protected: bool,
|
pub password_protected: bool,
|
||||||
|
/// Wipe browsing data (except extensions and bookmarks) when the browser
|
||||||
|
/// exits. Ignored for ephemeral and password-protected profiles.
|
||||||
|
#[serde(default)]
|
||||||
|
pub clear_on_close: bool,
|
||||||
/// Profile creation timestamp (epoch seconds, UTC). `None` for legacy
|
/// Profile creation timestamp (epoch seconds, UTC). `None` for legacy
|
||||||
/// profiles that pre-date this field — those are treated as ancient by
|
/// profiles that pre-date this field — those are treated as ancient by
|
||||||
/// any staleness check.
|
/// any staleness check.
|
||||||
|
|||||||
+845
-121
File diff suppressed because it is too large
Load Diff
+110
-74
@@ -137,6 +137,25 @@ pub fn now_secs() -> u64 {
|
|||||||
.as_secs()
|
.as_secs()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keys in `active_proxies` at or above this value are in-flight launch
|
||||||
|
/// placeholders, not real browser PIDs. Real OS PIDs never reach this range
|
||||||
|
/// (Linux pid_max caps at 2^22, macOS at ~100k, Windows PIDs are small DWORD
|
||||||
|
/// multiples of 4), so a placeholder can never shadow a live process ID.
|
||||||
|
pub const LAUNCH_PLACEHOLDER_PID_MIN: u32 = u32::MAX - 0x00FF_FFFF;
|
||||||
|
|
||||||
|
static NEXT_LAUNCH_PLACEHOLDER_PID: std::sync::atomic::AtomicU32 =
|
||||||
|
std::sync::atomic::AtomicU32::new(u32::MAX);
|
||||||
|
|
||||||
|
/// Allocate a unique `active_proxies` key for a launch in flight, so concurrent
|
||||||
|
/// launches can never overwrite each other's placeholder entry.
|
||||||
|
pub fn next_launch_placeholder_pid() -> u32 {
|
||||||
|
NEXT_LAUNCH_PLACEHOLDER_PID.fetch_sub(1, std::sync::atomic::Ordering::Relaxed)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_launch_placeholder_pid(pid: u32) -> bool {
|
||||||
|
pid >= LAUNCH_PLACEHOLDER_PID_MIN
|
||||||
|
}
|
||||||
|
|
||||||
impl StoredProxy {
|
impl StoredProxy {
|
||||||
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
|
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
|
||||||
let sync_enabled = crate::sync::is_sync_configured();
|
let sync_enabled = crate::sync::is_sync_configured();
|
||||||
@@ -260,7 +279,7 @@ impl ProxyManager {
|
|||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let cache_file = self.get_proxy_check_cache_file(proxy_id)?;
|
let cache_file = self.get_proxy_check_cache_file(proxy_id)?;
|
||||||
let content = serde_json::to_string_pretty(result)?;
|
let content = serde_json::to_string_pretty(result)?;
|
||||||
fs::write(&cache_file, content)?;
|
crate::app_dirs::write_owner_only(&cache_file, content.as_bytes())?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +404,7 @@ impl ProxyManager {
|
|||||||
|
|
||||||
let proxy_file = self.get_proxy_file_path(&proxy.id);
|
let proxy_file = self.get_proxy_file_path(&proxy.id);
|
||||||
let content = serde_json::to_string_pretty(proxy)?;
|
let content = serde_json::to_string_pretty(proxy)?;
|
||||||
fs::write(&proxy_file, content)?;
|
crate::app_dirs::write_owner_only(&proxy_file, content.as_bytes())?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -406,6 +425,10 @@ impl ProxyManager {
|
|||||||
name: String,
|
name: String,
|
||||||
proxy_settings: ProxySettings,
|
proxy_settings: ProxySettings,
|
||||||
) -> Result<StoredProxy, String> {
|
) -> Result<StoredProxy, String> {
|
||||||
|
if name.trim().is_empty() {
|
||||||
|
return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// Check if name already exists
|
// Check if name already exists
|
||||||
{
|
{
|
||||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||||
@@ -795,6 +818,10 @@ impl ProxyManager {
|
|||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
proxy_settings: Option<ProxySettings>,
|
proxy_settings: Option<ProxySettings>,
|
||||||
) -> Result<StoredProxy, String> {
|
) -> Result<StoredProxy, String> {
|
||||||
|
if name.as_deref().is_some_and(|n| n.trim().is_empty()) {
|
||||||
|
return Err(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
// First, check for conflicts without holding a mutable reference
|
// First, check for conflicts without holding a mutable reference
|
||||||
{
|
{
|
||||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||||
@@ -1032,8 +1059,10 @@ impl ProxyManager {
|
|||||||
format!("Proxy check failed for {proxy_addr}. Could not connect through the proxy.")
|
format!("Proxy check failed for {proxy_addr}. Could not connect through the proxy.")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build proxy URL string from ProxySettings
|
// Build proxy URL string from ProxySettings. Credentials are percent-encoded:
|
||||||
fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
|
// a password containing `/`, `#`, `?` or `@` otherwise breaks the URL
|
||||||
|
// authority and silently retargets the request at the wrong host.
|
||||||
|
pub fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
|
||||||
let mut url = format!("{}://", proxy_settings.proxy_type);
|
let mut url = format!("{}://", proxy_settings.proxy_type);
|
||||||
|
|
||||||
if let (Some(username), Some(password)) = (&proxy_settings.username, &proxy_settings.password) {
|
if let (Some(username), Some(password)) = (&proxy_settings.username, &proxy_settings.password) {
|
||||||
@@ -1073,9 +1102,17 @@ impl ProxyManager {
|
|||||||
.map_err(|e| e.to_string());
|
.map_err(|e| e.to_string());
|
||||||
|
|
||||||
let ip_result = match proxy_start_result {
|
let ip_result = match proxy_start_result {
|
||||||
Ok(proxy_config) => {
|
Ok(mut proxy_config) => {
|
||||||
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
|
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
|
||||||
let config_id = proxy_config.id.clone();
|
let config_id = proxy_config.id.clone();
|
||||||
|
// Tie the check worker's lifetime to this GUI process: the worker's
|
||||||
|
// PID watchdog self-exits when browser_pid dies, so if the app is
|
||||||
|
// killed mid-check the worker follows instead of idling until the
|
||||||
|
// next app launch.
|
||||||
|
proxy_config.browser_pid = Some(std::process::id());
|
||||||
|
if !crate::proxy_storage::update_proxy_config(&proxy_config) {
|
||||||
|
log::warn!("Failed to tag check worker {config_id} with app PID for self-expiry");
|
||||||
|
}
|
||||||
// Wrap in a timeout so the check worker doesn't stay alive indefinitely
|
// Wrap in a timeout so the check worker doesn't stay alive indefinitely
|
||||||
// if the upstream is slow or unreachable.
|
// if the upstream is slow or unreachable.
|
||||||
let result = tokio::time::timeout(
|
let result = tokio::time::timeout(
|
||||||
@@ -1485,6 +1522,7 @@ impl ProxyManager {
|
|||||||
profile_id: Option<&str>,
|
profile_id: Option<&str>,
|
||||||
bypass_rules: Vec<String>,
|
bypass_rules: Vec<String>,
|
||||||
blocklist_file: Option<String>,
|
blocklist_file: Option<String>,
|
||||||
|
dns_allowlist_mode: bool,
|
||||||
// Protocol the local worker serves the browser: "socks5" (Wayfern). Reflected in
|
// Protocol the local worker serves the browser: "socks5" (Wayfern). Reflected in
|
||||||
// the returned ProxySettings.proxy_type so the caller formats the right local proxy URL scheme.
|
// the returned ProxySettings.proxy_type so the caller formats the right local proxy URL scheme.
|
||||||
local_protocol: &str,
|
local_protocol: &str,
|
||||||
@@ -1576,6 +1614,10 @@ impl ProxyManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
crate::proxy_runner::ensure_sidecar_version()
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
// Start a new proxy using the donut-proxy binary with the correct CLI interface
|
// Start a new proxy using the donut-proxy binary with the correct CLI interface
|
||||||
let mut proxy_cmd = app_handle
|
let mut proxy_cmd = app_handle
|
||||||
.shell()
|
.shell()
|
||||||
@@ -1594,12 +1636,13 @@ impl ProxyManager {
|
|||||||
.arg("--type")
|
.arg("--type")
|
||||||
.arg(&proxy_settings.proxy_type);
|
.arg(&proxy_settings.proxy_type);
|
||||||
|
|
||||||
// Add credentials if provided
|
// Keep credentials out of process arguments. The short-lived sidecar
|
||||||
|
// removes these variables before it spawns the detached worker.
|
||||||
if let Some(username) = &proxy_settings.username {
|
if let Some(username) = &proxy_settings.username {
|
||||||
proxy_cmd = proxy_cmd.arg("--username").arg(username);
|
proxy_cmd = proxy_cmd.env("DONUT_PROXY_USERNAME", username);
|
||||||
}
|
}
|
||||||
if let Some(password) = &proxy_settings.password {
|
if let Some(password) = &proxy_settings.password {
|
||||||
proxy_cmd = proxy_cmd.arg("--password").arg(password);
|
proxy_cmd = proxy_cmd.env("DONUT_PROXY_PASSWORD", password);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1618,6 +1661,9 @@ impl ProxyManager {
|
|||||||
// Add blocklist file path if provided
|
// Add blocklist file path if provided
|
||||||
if let Some(ref path) = blocklist_file {
|
if let Some(ref path) = blocklist_file {
|
||||||
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
|
proxy_cmd = proxy_cmd.arg("--blocklist-file").arg(path);
|
||||||
|
if dns_allowlist_mode {
|
||||||
|
proxy_cmd = proxy_cmd.arg("--dns-allowlist-mode");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tell the worker which protocol to serve the browser (http or socks5)
|
// Tell the worker which protocol to serve the browser (http or socks5)
|
||||||
@@ -1688,6 +1734,10 @@ impl ProxyManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !ready {
|
if !ready {
|
||||||
|
// The detached worker is already running with its config on disk, but
|
||||||
|
// it was never registered in active_proxies — no cleanup pass could
|
||||||
|
// ever find it, so it must be killed before this error propagates.
|
||||||
|
let _ = crate::proxy_runner::stop_proxy_process(&proxy_info.id).await;
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"Local proxy on 127.0.0.1:{} did not become ready in time",
|
"Local proxy on 127.0.0.1:{} did not become ready in time",
|
||||||
proxy_info.local_port
|
proxy_info.local_port
|
||||||
@@ -1860,9 +1910,9 @@ impl ProxyManager {
|
|||||||
/// Persist the real browser PID onto the worker's on-disk config so the
|
/// Persist the real browser PID onto the worker's on-disk config so the
|
||||||
/// detached worker can self-terminate when that browser dies, independent of
|
/// detached worker can self-terminate when that browser dies, independent of
|
||||||
/// the GUI being alive. Resolved via the profile→proxy_id map rather than the
|
/// the GUI being alive. Resolved via the profile→proxy_id map rather than the
|
||||||
/// PID-keyed `active_proxies` map: the latter uses a placeholder key 0 during
|
/// PID-keyed `active_proxies` map: the latter is keyed by a per-launch
|
||||||
/// launch that collides across concurrent launches, which could tag a live
|
/// placeholder until `update_proxy_pid` runs, so it is not a reliable way to
|
||||||
/// worker with the wrong (dead) PID and make it self-exit. Safe on the reuse
|
/// find the worker for a profile mid-launch. Safe on the reuse
|
||||||
/// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid`
|
/// path — it simply rewrites `browser_pid` to the new live PID. A `browser_pid`
|
||||||
/// of 0 (launch failed to report a PID) is ignored so the worker never
|
/// of 0 (launch failed to report a PID) is ignored so the worker never
|
||||||
/// self-exits against a bogus PID.
|
/// self-exits against a bogus PID.
|
||||||
@@ -2082,9 +2132,9 @@ impl ProxyManager {
|
|||||||
let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
let mut snapshot_pids: std::collections::HashSet<u32> = std::collections::HashSet::new();
|
||||||
for (browser_pid, proxy_id, profile_id) in snapshot {
|
for (browser_pid, proxy_id, profile_id) in snapshot {
|
||||||
snapshot_pids.insert(browser_pid);
|
snapshot_pids.insert(browser_pid);
|
||||||
// The sentinel PID=0 is used as a placeholder during launch,
|
// Launch placeholders (and the legacy 0 sentinel) are not real
|
||||||
// before update_proxy_pid has recorded the real browser PID.
|
// browser PIDs — update_proxy_pid hasn't recorded the real one yet.
|
||||||
if browser_pid == 0 {
|
if browser_pid == 0 || is_launch_placeholder_pid(browser_pid) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if system
|
if system
|
||||||
@@ -2477,7 +2527,7 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that validates the command line arguments are constructed correctly
|
// Validate that non-secret proxy settings remain command arguments.
|
||||||
#[test]
|
#[test]
|
||||||
fn test_proxy_command_construction() {
|
fn test_proxy_command_construction() {
|
||||||
let proxy_settings = ProxySettings {
|
let proxy_settings = ProxySettings {
|
||||||
@@ -2498,10 +2548,6 @@ mod tests {
|
|||||||
"8080",
|
"8080",
|
||||||
"--type",
|
"--type",
|
||||||
"http",
|
"http",
|
||||||
"--username",
|
|
||||||
"user",
|
|
||||||
"--password",
|
|
||||||
"pass",
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// This test verifies the argument structure without actually running the command
|
// This test verifies the argument structure without actually running the command
|
||||||
@@ -2625,61 +2671,6 @@ mod tests {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Test that validates URL encoding for special characters in credentials
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_proxy_credentials_encoding() -> Result<(), Box<dyn std::error::Error>> {
|
|
||||||
let proxy_path = ensure_donut_proxy_binary().await?;
|
|
||||||
|
|
||||||
// Test with credentials that include special characters
|
|
||||||
let mut cmd = Command::new(&proxy_path);
|
|
||||||
cmd
|
|
||||||
.arg("proxy")
|
|
||||||
.arg("start")
|
|
||||||
.arg("--host")
|
|
||||||
.arg("test.example.com")
|
|
||||||
.arg("--proxy-port")
|
|
||||||
.arg("8080")
|
|
||||||
.arg("--type")
|
|
||||||
.arg("http")
|
|
||||||
.arg("--username")
|
|
||||||
.arg("user@domain.com")
|
|
||||||
.arg("--password")
|
|
||||||
.arg("pass word!");
|
|
||||||
|
|
||||||
let output = tokio::time::timeout(Duration::from_secs(10), cmd.output()).await??;
|
|
||||||
|
|
||||||
if output.status.success() {
|
|
||||||
let stdout = String::from_utf8(output.stdout)?;
|
|
||||||
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
|
||||||
|
|
||||||
let upstream_url = config["upstreamUrl"].as_str().unwrap();
|
|
||||||
|
|
||||||
println!("Generated upstream URL: {upstream_url}");
|
|
||||||
|
|
||||||
// Verify that special characters are properly encoded
|
|
||||||
assert!(upstream_url.contains("user%40domain.com"));
|
|
||||||
assert!(upstream_url.contains("pass%20word"));
|
|
||||||
|
|
||||||
println!("URL encoding test passed - special characters handled correctly");
|
|
||||||
|
|
||||||
// Clean up
|
|
||||||
let proxy_id = config["id"].as_str().unwrap();
|
|
||||||
let mut stop_cmd = Command::new(&proxy_path);
|
|
||||||
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
|
||||||
let _ = stop_cmd.output().await;
|
|
||||||
} else {
|
|
||||||
let stdout = String::from_utf8(output.stdout)?;
|
|
||||||
let stderr = String::from_utf8(output.stderr)?;
|
|
||||||
println!("Command failed (expected for non-existent upstream):");
|
|
||||||
println!("Stdout: {stdout}");
|
|
||||||
println!("Stderr: {stderr}");
|
|
||||||
|
|
||||||
println!("URL encoding test completed - credentials should be properly encoded");
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
// Complex proxy process monitoring tests
|
// Complex proxy process monitoring tests
|
||||||
// ──────────────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────────────
|
||||||
@@ -2750,6 +2741,35 @@ mod tests {
|
|||||||
assert!(result.unwrap_err().contains("No proxy found for PID 777"));
|
assert!(result.unwrap_err().contains("No proxy found for PID 777"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_launch_placeholder_pids_unique_and_reconcile_independently() {
|
||||||
|
let a = next_launch_placeholder_pid();
|
||||||
|
let b = next_launch_placeholder_pid();
|
||||||
|
assert_ne!(a, b);
|
||||||
|
assert!(is_launch_placeholder_pid(a));
|
||||||
|
assert!(is_launch_placeholder_pid(b));
|
||||||
|
// Real PIDs (and the legacy 0 sentinel) are never in the placeholder range.
|
||||||
|
assert!(!is_launch_placeholder_pid(0));
|
||||||
|
assert!(!is_launch_placeholder_pid(1));
|
||||||
|
assert!(!is_launch_placeholder_pid(4_194_304)); // Linux pid_max
|
||||||
|
assert!(!is_launch_placeholder_pid(std::process::id()));
|
||||||
|
|
||||||
|
// Two concurrent launches with distinct placeholder keys: finishing
|
||||||
|
// launch A must not remap or evict launch B's in-flight entry.
|
||||||
|
let pm = ProxyManager::new();
|
||||||
|
pm.insert_active_proxy(a, make_proxy_info("px_launch_a", 9201, Some("prof_a")));
|
||||||
|
pm.insert_active_proxy(b, make_proxy_info("px_launch_b", 9202, Some("prof_b")));
|
||||||
|
|
||||||
|
pm.update_proxy_pid(a, 3001).unwrap();
|
||||||
|
assert_eq!(pm.get_active_proxy(3001).unwrap().id, "px_launch_a");
|
||||||
|
assert_eq!(pm.get_active_proxy(b).unwrap().id, "px_launch_b");
|
||||||
|
|
||||||
|
pm.update_proxy_pid(b, 3002).unwrap();
|
||||||
|
assert_eq!(pm.get_active_proxy(3002).unwrap().id, "px_launch_b");
|
||||||
|
assert!(pm.get_active_proxy(a).is_none());
|
||||||
|
assert!(pm.get_active_proxy(b).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_profile_proxy_id_mapping_tracks_active_proxy() {
|
fn test_profile_proxy_id_mapping_tracks_active_proxy() {
|
||||||
let pm = ProxyManager::new();
|
let pm = ProxyManager::new();
|
||||||
@@ -2922,6 +2942,7 @@ mod tests {
|
|||||||
profile_id: None,
|
profile_id: None,
|
||||||
bypass_rules: Vec::new(),
|
bypass_rules: Vec::new(),
|
||||||
blocklist_file: None,
|
blocklist_file: None,
|
||||||
|
dns_allowlist_mode: false,
|
||||||
local_protocol: None,
|
local_protocol: None,
|
||||||
browser_pid: None,
|
browser_pid: None,
|
||||||
};
|
};
|
||||||
@@ -2935,6 +2956,7 @@ mod tests {
|
|||||||
profile_id: None,
|
profile_id: None,
|
||||||
bypass_rules: Vec::new(),
|
bypass_rules: Vec::new(),
|
||||||
blocklist_file: None,
|
blocklist_file: None,
|
||||||
|
dns_allowlist_mode: false,
|
||||||
local_protocol: None,
|
local_protocol: None,
|
||||||
browser_pid: None,
|
browser_pid: None,
|
||||||
};
|
};
|
||||||
@@ -2976,6 +2998,7 @@ mod tests {
|
|||||||
profile_id: Some("prof_abc".to_string()),
|
profile_id: Some("prof_abc".to_string()),
|
||||||
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
bypass_rules: vec!["*.local".to_string(), "192.168.*".to_string()],
|
||||||
blocklist_file: None,
|
blocklist_file: None,
|
||||||
|
dns_allowlist_mode: false,
|
||||||
local_protocol: None,
|
local_protocol: None,
|
||||||
browser_pid: None,
|
browser_pid: None,
|
||||||
};
|
};
|
||||||
@@ -2983,6 +3006,18 @@ mod tests {
|
|||||||
// Save
|
// Save
|
||||||
save_proxy_config(&config).unwrap();
|
save_proxy_config(&config).unwrap();
|
||||||
|
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
let mode =
|
||||||
|
std::fs::metadata(crate::proxy_storage::get_storage_dir().join(format!("{id}.json")))
|
||||||
|
.unwrap()
|
||||||
|
.permissions()
|
||||||
|
.mode()
|
||||||
|
& 0o777;
|
||||||
|
assert_eq!(mode, 0o600, "proxy credentials must be owner-only");
|
||||||
|
}
|
||||||
|
|
||||||
// Load and compare
|
// Load and compare
|
||||||
let loaded = get_proxy_config(&id).expect("Config should be loadable");
|
let loaded = get_proxy_config(&id).expect("Config should be loadable");
|
||||||
assert_eq!(loaded.id, config.id);
|
assert_eq!(loaded.id, config.id);
|
||||||
@@ -3296,6 +3331,7 @@ mod tests {
|
|||||||
profile_id: None,
|
profile_id: None,
|
||||||
bypass_rules: Vec::new(),
|
bypass_rules: Vec::new(),
|
||||||
blocklist_file: None,
|
blocklist_file: None,
|
||||||
|
dns_allowlist_mode: false,
|
||||||
local_protocol: None,
|
local_protocol: None,
|
||||||
browser_pid: None,
|
browser_pid: None,
|
||||||
};
|
};
|
||||||
|
|||||||
+186
-19
@@ -4,11 +4,52 @@ use crate::proxy_storage::{
|
|||||||
};
|
};
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::process::Stdio;
|
use std::process::Stdio;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
lazy_static::lazy_static! {
|
lazy_static::lazy_static! {
|
||||||
static ref PROXY_PROCESSES: std::sync::Mutex<std::collections::HashMap<String, u32>> =
|
static ref PROXY_PROCESSES: std::sync::Mutex<std::collections::HashMap<String, u32>> =
|
||||||
std::sync::Mutex::new(std::collections::HashMap::new());
|
std::sync::Mutex::new(std::collections::HashMap::new());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static SIDECAR_VERSION_VERIFIED: AtomicBool = AtomicBool::new(false);
|
||||||
|
const RETAINED_PROXY_LOGS: usize = 20;
|
||||||
|
|
||||||
|
fn prune_stale_proxy_logs(temp_dir: &Path, retain: usize) {
|
||||||
|
let active_ids = PROXY_PROCESSES
|
||||||
|
.lock()
|
||||||
|
.map(|processes| processes.keys().cloned().collect::<Vec<_>>())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let Ok(entries) = std::fs::read_dir(temp_dir) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut logs = entries
|
||||||
|
.flatten()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
let file_name = entry.file_name();
|
||||||
|
let file_name = file_name.to_str()?;
|
||||||
|
let id = file_name
|
||||||
|
.strip_prefix("donut-proxy-")?
|
||||||
|
.strip_suffix(".log")?;
|
||||||
|
if active_ids.iter().any(|active_id| active_id == id) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let modified = entry
|
||||||
|
.metadata()
|
||||||
|
.and_then(|metadata| metadata.modified())
|
||||||
|
.unwrap_or(std::time::UNIX_EPOCH);
|
||||||
|
Some((modified, entry.path()))
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
logs.sort_unstable_by_key(|entry| std::cmp::Reverse(entry.0));
|
||||||
|
for (_, path) in logs.into_iter().skip(retain) {
|
||||||
|
if let Err(error) = std::fs::remove_file(&path) {
|
||||||
|
log::debug!(
|
||||||
|
"Failed to prune stale proxy log {}: {error}",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn target_binary_name(base_name: &str) -> Option<String> {
|
fn target_binary_name(base_name: &str) -> Option<String> {
|
||||||
let target = std::env::var("TARGET").ok()?;
|
let target = std::env::var("TARGET").ok()?;
|
||||||
|
|
||||||
@@ -156,21 +197,96 @@ pub(crate) fn find_sidecar_executable(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn parse_sidecar_version(stdout: &[u8]) -> Option<String> {
|
||||||
|
let output = std::str::from_utf8(stdout).ok()?.trim();
|
||||||
|
output
|
||||||
|
.strip_prefix("donut-proxy ")
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|version| !version.is_empty() && !version.contains(char::is_whitespace))
|
||||||
|
.map(str::to_string)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sidecar_version_mismatch_error() -> Box<dyn std::error::Error> {
|
||||||
|
serde_json::json!({
|
||||||
|
"code": "PROXY_SIDECAR_VERSION_MISMATCH"
|
||||||
|
})
|
||||||
|
.to_string()
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify that the installed sidecar was built for the same release as the
|
||||||
|
/// main app. Windows can otherwise retain an executing, locked sidecar while
|
||||||
|
/// NSIS replaces the app, leaving an incompatible mixed-version installation.
|
||||||
|
pub(crate) async fn ensure_sidecar_version() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
if SIDECAR_VERSION_VERIFIED.load(Ordering::Acquire) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let executable = match find_sidecar_executable("donut-proxy") {
|
||||||
|
Ok(executable) => executable,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!("Failed to locate donut-proxy for version verification: {e}");
|
||||||
|
return Err(sidecar_version_mismatch_error());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let mut command = std::process::Command::new(&executable);
|
||||||
|
command.arg("--version");
|
||||||
|
|
||||||
|
#[cfg(windows)]
|
||||||
|
{
|
||||||
|
use std::os::windows::process::CommandExt;
|
||||||
|
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||||
|
command.creation_flags(CREATE_NO_WINDOW);
|
||||||
|
}
|
||||||
|
|
||||||
|
let output = match command.output() {
|
||||||
|
Ok(output) => output,
|
||||||
|
Err(e) => {
|
||||||
|
log::error!(
|
||||||
|
"Failed to run {} for version verification: {e}",
|
||||||
|
executable.display()
|
||||||
|
);
|
||||||
|
return Err(sidecar_version_mismatch_error());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let actual_version = parse_sidecar_version(&output.stdout);
|
||||||
|
let expected_version = env!("BUILD_VERSION");
|
||||||
|
|
||||||
|
if output.status.success() && actual_version.as_deref() == Some(expected_version) {
|
||||||
|
SIDECAR_VERSION_VERIFIED.store(true, Ordering::Release);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
log::error!(
|
||||||
|
"donut-proxy version mismatch: expected {}, got {:?}; status={}, stdout={:?}, stderr={:?}",
|
||||||
|
expected_version,
|
||||||
|
actual_version,
|
||||||
|
output.status,
|
||||||
|
String::from_utf8_lossy(&output.stdout),
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
Err(sidecar_version_mismatch_error())
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn start_proxy_process(
|
pub async fn start_proxy_process(
|
||||||
upstream_url: Option<String>,
|
upstream_url: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, None).await
|
start_proxy_process_with_profile(upstream_url, port, None, Vec::new(), None, false, None).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
pub async fn start_proxy_process_with_profile(
|
pub async fn start_proxy_process_with_profile(
|
||||||
upstream_url: Option<String>,
|
upstream_url: Option<String>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
profile_id: Option<String>,
|
profile_id: Option<String>,
|
||||||
bypass_rules: Vec<String>,
|
bypass_rules: Vec<String>,
|
||||||
blocklist_file: Option<String>,
|
blocklist_file: Option<String>,
|
||||||
|
dns_allowlist_mode: bool,
|
||||||
local_protocol: Option<String>,
|
local_protocol: Option<String>,
|
||||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||||
|
ensure_sidecar_version().await?;
|
||||||
|
|
||||||
let id = generate_proxy_id();
|
let id = generate_proxy_id();
|
||||||
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
|
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
|
||||||
|
|
||||||
@@ -185,6 +301,7 @@ pub async fn start_proxy_process_with_profile(
|
|||||||
.with_profile_id(profile_id.clone())
|
.with_profile_id(profile_id.clone())
|
||||||
.with_bypass_rules(bypass_rules)
|
.with_bypass_rules(bypass_rules)
|
||||||
.with_blocklist_file(blocklist_file)
|
.with_blocklist_file(blocklist_file)
|
||||||
|
.with_dns_allowlist_mode(dns_allowlist_mode)
|
||||||
.with_local_protocol(local_protocol);
|
.with_local_protocol(local_protocol);
|
||||||
save_proxy_config(&config)?;
|
save_proxy_config(&config)?;
|
||||||
|
|
||||||
@@ -198,6 +315,10 @@ pub async fn start_proxy_process_with_profile(
|
|||||||
// Spawn proxy worker process in the background using std::process::Command
|
// Spawn proxy worker process in the background using std::process::Command
|
||||||
// This ensures proper process detachment on Unix systems
|
// This ensures proper process detachment on Unix systems
|
||||||
let exe = find_sidecar_executable("donut-proxy")?;
|
let exe = find_sidecar_executable("donut-proxy")?;
|
||||||
|
let temp_dir = std::env::temp_dir();
|
||||||
|
let log_path = temp_dir.join(format!("donut-proxy-{id}.log"));
|
||||||
|
let log_file = crate::app_dirs::create_owner_only(&log_path);
|
||||||
|
prune_stale_proxy_logs(&temp_dir, RETAINED_PROXY_LOGS);
|
||||||
|
|
||||||
#[cfg(unix)]
|
#[cfg(unix)]
|
||||||
{
|
{
|
||||||
@@ -209,13 +330,14 @@ pub async fn start_proxy_process_with_profile(
|
|||||||
cmd.arg("start");
|
cmd.arg("start");
|
||||||
cmd.arg("--id");
|
cmd.arg("--id");
|
||||||
cmd.arg(&id);
|
cmd.arg(&id);
|
||||||
|
cmd.env_remove("DONUT_PROXY_USERNAME");
|
||||||
|
cmd.env_remove("DONUT_PROXY_PASSWORD");
|
||||||
|
|
||||||
cmd.stdin(Stdio::null());
|
cmd.stdin(Stdio::null());
|
||||||
cmd.stdout(Stdio::null());
|
cmd.stdout(Stdio::null());
|
||||||
|
|
||||||
// Always log to file for diagnostics (both debug and release builds)
|
// Always log to file for diagnostics (both debug and release builds)
|
||||||
let log_path = std::env::temp_dir().join(format!("donut-proxy-{}.log", id));
|
if let Ok(file) = log_file {
|
||||||
if let Ok(file) = std::fs::File::create(&log_path) {
|
|
||||||
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
|
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
|
||||||
cmd.stderr(Stdio::from(file));
|
cmd.stderr(Stdio::from(file));
|
||||||
} else {
|
} else {
|
||||||
@@ -286,13 +408,14 @@ pub async fn start_proxy_process_with_profile(
|
|||||||
cmd.arg("start");
|
cmd.arg("start");
|
||||||
cmd.arg("--id");
|
cmd.arg("--id");
|
||||||
cmd.arg(&id);
|
cmd.arg(&id);
|
||||||
|
cmd.env_remove("DONUT_PROXY_USERNAME");
|
||||||
|
cmd.env_remove("DONUT_PROXY_PASSWORD");
|
||||||
|
|
||||||
cmd.stdin(Stdio::null());
|
cmd.stdin(Stdio::null());
|
||||||
cmd.stdout(Stdio::null());
|
cmd.stdout(Stdio::null());
|
||||||
|
|
||||||
// Log to file for diagnostics (matching Unix behavior)
|
// Log to file for diagnostics (matching Unix behavior)
|
||||||
let log_path = std::env::temp_dir().join(format!("donut-proxy-{}.log", id));
|
if let Ok(file) = log_file {
|
||||||
if let Ok(file) = std::fs::File::create(&log_path) {
|
|
||||||
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
|
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
|
||||||
cmd.stderr(Stdio::from(file));
|
cmd.stderr(Stdio::from(file));
|
||||||
} else {
|
} else {
|
||||||
@@ -370,24 +493,21 @@ pub async fn start_proxy_process_with_profile(
|
|||||||
attempts += 1;
|
attempts += 1;
|
||||||
if attempts >= max_attempts {
|
if attempts >= max_attempts {
|
||||||
// Try to get the config one more time for better error message
|
// Try to get the config one more time for better error message
|
||||||
if let Some(config) = get_proxy_config(&id) {
|
let detail = if let Some(config) = get_proxy_config(&id) {
|
||||||
// Check if process is still running
|
// Check if process is still running
|
||||||
let process_running = config.pid.map(is_process_running).unwrap_or(false);
|
let process_running = config.pid.map(is_process_running).unwrap_or(false);
|
||||||
return Err(
|
|
||||||
format!(
|
|
||||||
"Proxy worker failed to start in time. Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}",
|
|
||||||
config.id, config.local_url, config.local_port, config.pid, process_running
|
|
||||||
)
|
|
||||||
.into(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Err(
|
|
||||||
format!(
|
format!(
|
||||||
"Proxy worker failed to start in time. Config not found for id: {}",
|
"Config: id={}, local_url={:?}, local_port={:?}, pid={:?}, process_running={}",
|
||||||
id
|
config.id, config.local_url, config.local_port, config.pid, process_running
|
||||||
)
|
)
|
||||||
.into(),
|
} else {
|
||||||
);
|
format!("Config not found for id: {}", id)
|
||||||
|
};
|
||||||
|
// The detached worker (if it did spawn) would otherwise outlive this
|
||||||
|
// failed start with nothing tracking it — callers only get an error
|
||||||
|
// string, so this is the last place that can still kill it.
|
||||||
|
let _ = stop_proxy_process(&id).await;
|
||||||
|
return Err(format!("Proxy worker failed to start in time. {detail}").into());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -442,3 +562,50 @@ pub async fn stop_all_proxy_processes() -> Result<(), Box<dyn std::error::Error>
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::{parse_sidecar_version, prune_stale_proxy_logs};
|
||||||
|
use std::fs;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_exact_sidecar_version_output() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_sidecar_version(b"donut-proxy v0.28.2\n").as_deref(),
|
||||||
|
Some("v0.28.2")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_sidecar_version(b"donut-proxy nightly-2026-07-19-a4ed5c8\r\n").as_deref(),
|
||||||
|
Some("nightly-2026-07-19-a4ed5c8")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_missing_or_ambiguous_sidecar_version_output() {
|
||||||
|
assert_eq!(parse_sidecar_version(b""), None);
|
||||||
|
assert_eq!(parse_sidecar_version(b"donut-proxy"), None);
|
||||||
|
assert_eq!(parse_sidecar_version(b"other-proxy v0.28.2"), None);
|
||||||
|
assert_eq!(
|
||||||
|
parse_sidecar_version(b"donut-proxy v0.28.2\nunexpected"),
|
||||||
|
None
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prunes_only_old_proxy_logs() {
|
||||||
|
let temp = tempfile::tempdir().unwrap();
|
||||||
|
for id in ["oldest", "middle", "newest"] {
|
||||||
|
fs::write(temp.path().join(format!("donut-proxy-{id}.log")), id).unwrap();
|
||||||
|
std::thread::sleep(Duration::from_millis(10));
|
||||||
|
}
|
||||||
|
fs::write(temp.path().join("unrelated.log"), "keep").unwrap();
|
||||||
|
|
||||||
|
prune_stale_proxy_logs(temp.path(), 2);
|
||||||
|
|
||||||
|
assert!(!temp.path().join("donut-proxy-oldest.log").exists());
|
||||||
|
assert!(temp.path().join("donut-proxy-middle.log").exists());
|
||||||
|
assert!(temp.path().join("donut-proxy-newest.log").exists());
|
||||||
|
assert!(temp.path().join("unrelated.log").exists());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+645
-376
File diff suppressed because it is too large
Load Diff
@@ -16,6 +16,10 @@ pub struct ProxyConfig {
|
|||||||
pub bypass_rules: Vec<String>,
|
pub bypass_rules: Vec<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub blocklist_file: Option<String>,
|
pub blocklist_file: Option<String>,
|
||||||
|
/// When true, `blocklist_file` is treated as an ALLOW list: the browser may
|
||||||
|
/// only reach domains in the file; everything else is blocked.
|
||||||
|
#[serde(default)]
|
||||||
|
pub dns_allowlist_mode: bool,
|
||||||
/// Protocol the local worker serves to the browser: "socks5" (Wayfern/Chromium so QUIC and
|
/// Protocol the local worker serves to the browser: "socks5" (Wayfern/Chromium so QUIC and
|
||||||
/// WebRTC UDP can be proxied without leaking the real IP). Independent of
|
/// WebRTC UDP can be proxied without leaking the real IP). Independent of
|
||||||
/// `upstream_url`, which is the real upstream proxy/VPN this worker dials.
|
/// `upstream_url`, which is the real upstream proxy/VPN this worker dials.
|
||||||
@@ -42,6 +46,7 @@ impl ProxyConfig {
|
|||||||
profile_id: None,
|
profile_id: None,
|
||||||
bypass_rules: Vec::new(),
|
bypass_rules: Vec::new(),
|
||||||
blocklist_file: None,
|
blocklist_file: None,
|
||||||
|
dns_allowlist_mode: false,
|
||||||
local_protocol: None,
|
local_protocol: None,
|
||||||
browser_pid: None,
|
browser_pid: None,
|
||||||
}
|
}
|
||||||
@@ -62,6 +67,11 @@ impl ProxyConfig {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_dns_allowlist_mode(mut self, allowlist_mode: bool) -> Self {
|
||||||
|
self.dns_allowlist_mode = allowlist_mode;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn with_local_protocol(mut self, local_protocol: Option<String>) -> Self {
|
pub fn with_local_protocol(mut self, local_protocol: Option<String>) -> Self {
|
||||||
self.local_protocol = local_protocol;
|
self.local_protocol = local_protocol;
|
||||||
self
|
self
|
||||||
@@ -77,6 +87,29 @@ impl ProxyConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn build_proxy_url(
|
||||||
|
proxy_type: &str,
|
||||||
|
host: &str,
|
||||||
|
port: u16,
|
||||||
|
username: Option<&str>,
|
||||||
|
password: Option<&str>,
|
||||||
|
) -> String {
|
||||||
|
let mut url = format!("{}://", proxy_type.to_lowercase());
|
||||||
|
if let (Some(user), Some(pass)) = (username, password) {
|
||||||
|
url.push_str(&format!(
|
||||||
|
"{}:{}@",
|
||||||
|
urlencoding::encode(user),
|
||||||
|
urlencoding::encode(pass)
|
||||||
|
));
|
||||||
|
} else if let Some(user) = username {
|
||||||
|
url.push_str(&format!("{}@", urlencoding::encode(user)));
|
||||||
|
}
|
||||||
|
url.push_str(host);
|
||||||
|
url.push(':');
|
||||||
|
url.push_str(&port.to_string());
|
||||||
|
url
|
||||||
|
}
|
||||||
|
|
||||||
pub fn get_storage_dir() -> PathBuf {
|
pub fn get_storage_dir() -> PathBuf {
|
||||||
crate::app_dirs::proxy_workers_dir()
|
crate::app_dirs::proxy_workers_dir()
|
||||||
}
|
}
|
||||||
@@ -87,7 +120,7 @@ pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box<dyn std::error:
|
|||||||
|
|
||||||
let file_path = storage_dir.join(format!("{}.json", config.id));
|
let file_path = storage_dir.join(format!("{}.json", config.id));
|
||||||
let content = serde_json::to_string_pretty(config)?;
|
let content = serde_json::to_string_pretty(config)?;
|
||||||
fs::write(&file_path, content)?;
|
crate::app_dirs::write_owner_only(&file_path, content.as_bytes())?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -149,10 +182,13 @@ pub fn update_proxy_config(config: &ProxyConfig) -> bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
match serde_json::to_string_pretty(config) {
|
let Ok(content) = serde_json::to_string_pretty(config) else {
|
||||||
Ok(content) => fs::write(&file_path, content).is_ok(),
|
return false;
|
||||||
Err(_) => false,
|
};
|
||||||
|
if crate::app_dirs::write_owner_only(&file_path, content.as_bytes()).is_err() {
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_proxy_id() -> String {
|
pub fn generate_proxy_id() -> String {
|
||||||
@@ -167,17 +203,39 @@ pub fn generate_proxy_id() -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn is_process_running(pid: u32) -> bool {
|
pub fn is_process_running(pid: u32) -> bool {
|
||||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
use sysinfo::{ProcessRefreshKind, ProcessesToUpdate, System};
|
||||||
let system = System::new_with_specifics(
|
let pid = sysinfo::Pid::from_u32(pid);
|
||||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
// Refresh only the queried PID with the minimal refresh kind: this is a
|
||||||
|
// pure existence check, and callers (worker supervisors every 15s, GUI
|
||||||
|
// cleanup loops) must not pay for a full system process-table scan.
|
||||||
|
let mut system = System::new();
|
||||||
|
system.refresh_processes_specifics(
|
||||||
|
ProcessesToUpdate::Some(&[pid]),
|
||||||
|
true,
|
||||||
|
ProcessRefreshKind::nothing(),
|
||||||
);
|
);
|
||||||
system.process(sysinfo::Pid::from_u32(pid)).is_some()
|
system.process(pid).is_some()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn proxy_url_encodes_credentials() {
|
||||||
|
let url = build_proxy_url(
|
||||||
|
"HTTP",
|
||||||
|
"test.example.com",
|
||||||
|
8080,
|
||||||
|
Some("user@domain.com"),
|
||||||
|
Some("pass word!"),
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
url,
|
||||||
|
"http://user%40domain.com:pass%20word%21@test.example.com:8080"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_is_process_running_detects_current_process() {
|
fn test_is_process_running_detects_current_process() {
|
||||||
let pid = std::process::id();
|
let pid = std::process::id();
|
||||||
|
|||||||
@@ -852,7 +852,13 @@ pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, Stri
|
|||||||
const MAX_BYTES: usize = 5 * 1024 * 1024;
|
const MAX_BYTES: usize = 5 * 1024 * 1024;
|
||||||
let mut out = String::with_capacity(64 * 1024);
|
let mut out = String::with_capacity(64 * 1024);
|
||||||
for (path, _) in entries.iter().rev() {
|
for (path, _) in entries.iter().rev() {
|
||||||
let header = format!("===== {} =====\n", path.display());
|
let header = format!(
|
||||||
|
"===== {} =====\n",
|
||||||
|
path
|
||||||
|
.file_name()
|
||||||
|
.and_then(|name| name.to_str())
|
||||||
|
.unwrap_or("log")
|
||||||
|
);
|
||||||
if out.len() + header.len() >= MAX_BYTES {
|
if out.len() + header.len() >= MAX_BYTES {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -884,7 +890,7 @@ pub async fn read_log_files(app_handle: tauri::AppHandle) -> Result<String, Stri
|
|||||||
.map(|s| format!("===== {s}"))
|
.map(|s| format!("===== {s}"))
|
||||||
.collect::<String>();
|
.collect::<String>();
|
||||||
|
|
||||||
Ok(final_out)
|
Ok(crate::log_redaction::text(&final_out))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Reveal the log directory in the OS file manager.
|
/// Reveal the log directory in the OS file manager.
|
||||||
|
|||||||
+282
-13
@@ -13,13 +13,21 @@
|
|||||||
//! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses
|
//! carry UDP (HTTP/HTTPS/SOCKS4/Shadowsocks, or a SOCKS5 upstream that refuses
|
||||||
//! the association) the request is refused, so Chromium falls back to proxied
|
//! the association) the request is refused, so Chromium falls back to proxied
|
||||||
//! TCP rather than sending UDP from the real IP.
|
//! TCP rather than sending UDP from the real IP.
|
||||||
|
//!
|
||||||
|
//! Both commands enforce the same DNS blocklist and feed the same traffic
|
||||||
|
//! tracker as the HTTP front-end: CONNECT via `handle_connect`, and every UDP
|
||||||
|
//! datagram via [`UdpRelayContext`]. Without that, QUIC and WebRTC would be an
|
||||||
|
//! unfiltered, unmetered side channel around the TCP filter.
|
||||||
|
|
||||||
use crate::proxy_server::{
|
use crate::proxy_server::{
|
||||||
connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
|
connect_to_target_via_upstream, tunnel_streams, BlocklistMatcher, BypassMatcher,
|
||||||
};
|
};
|
||||||
use crate::traffic_stats::get_traffic_tracker;
|
use crate::traffic_stats::{get_traffic_tracker, LiveTrafficTracker};
|
||||||
use async_socks5::{AddrKind, Auth, SocksDatagram};
|
use async_socks5::{AddrKind, Auth, SocksDatagram};
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||||
use tokio::net::{TcpStream, UdpSocket};
|
use tokio::net::{TcpStream, UdpSocket};
|
||||||
use url::Url;
|
use url::Url;
|
||||||
@@ -37,6 +45,127 @@ const CMD_UDP_ASSOCIATE: u8 = 0x03;
|
|||||||
// Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack.
|
// Max UDP datagram payload; sized for a full 64 KiB datagram plus header slack.
|
||||||
const UDP_BUF: usize = 65_536;
|
const UDP_BUF: usize = 65_536;
|
||||||
|
|
||||||
|
// How often per-domain UDP byte totals are pushed into the traffic tracker.
|
||||||
|
// Datagram relay is a per-packet path, so totals accumulate locally and flush
|
||||||
|
// on this interval rather than taking the tracker's domain write lock per
|
||||||
|
// packet. Global byte counters are plain atomics and update inline, so live
|
||||||
|
// bandwidth stays real-time exactly as it does for TCP tunnels.
|
||||||
|
const UDP_STATS_FLUSH_INTERVAL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
|
// Cap on remembered destination->host flows per association. A long-lived
|
||||||
|
// association fanning out to many peers must not grow this map without bound.
|
||||||
|
const UDP_FLOW_MAP_MAX: usize = 1024;
|
||||||
|
|
||||||
|
/// Per-association filtering and traffic accounting for the UDP relay loops.
|
||||||
|
///
|
||||||
|
/// UDP has no per-datagram error channel (RFC 1928 §7 has no reply code), so a
|
||||||
|
/// blocked destination is dropped silently — which is what the browser sees for
|
||||||
|
/// any unreachable UDP peer, and makes it fall back to proxied TCP.
|
||||||
|
struct UdpRelayContext {
|
||||||
|
blocklist_matcher: BlocklistMatcher,
|
||||||
|
tracker: Option<Arc<LiveTrafficTracker>>,
|
||||||
|
/// Pending per-domain (sent, received) deltas awaiting flush.
|
||||||
|
pending: HashMap<String, (u64, u64)>,
|
||||||
|
/// Destinations already counted as a request, so each is recorded once.
|
||||||
|
seen: std::collections::HashSet<String>,
|
||||||
|
/// Maps a peer key back to the destination host we sent to, so reply bytes
|
||||||
|
/// are attributed to the domain rather than to a bare IP.
|
||||||
|
flow: HashMap<String, String>,
|
||||||
|
last_flush: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UdpRelayContext {
|
||||||
|
fn new(blocklist_matcher: BlocklistMatcher) -> Self {
|
||||||
|
Self {
|
||||||
|
blocklist_matcher,
|
||||||
|
tracker: get_traffic_tracker(),
|
||||||
|
pending: HashMap::new(),
|
||||||
|
seen: std::collections::HashSet::new(),
|
||||||
|
flow: HashMap::new(),
|
||||||
|
last_flush: Instant::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Apply the DNS blocklist to a datagram destination. Mirrors the TCP path
|
||||||
|
/// exactly: the host string is matched whether it is a domain or an IP
|
||||||
|
/// literal, so allowlist mode rejects unlisted IP destinations here too.
|
||||||
|
fn is_blocked(&self, host: &str) -> bool {
|
||||||
|
self.blocklist_matcher.is_blocked(host)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Account a datagram relayed browser -> destination.
|
||||||
|
fn record_sent(&mut self, host: &str, peer_key: String, bytes: u64) {
|
||||||
|
let Some(tracker) = &self.tracker else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
tracker.add_bytes_sent(bytes);
|
||||||
|
if self.seen.insert(host.to_string()) {
|
||||||
|
// First datagram to this destination: count it once so UDP peers show up
|
||||||
|
// in the domain list the way CONNECT targets do.
|
||||||
|
tracker.record_request(host, 0, 0);
|
||||||
|
}
|
||||||
|
if self.flow.len() < UDP_FLOW_MAP_MAX {
|
||||||
|
self.flow.insert(peer_key, host.to_string());
|
||||||
|
}
|
||||||
|
self.pending.entry(host.to_string()).or_insert((0, 0)).0 += bytes;
|
||||||
|
self.maybe_flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Account a datagram relayed destination -> browser. `peer_key` is looked up
|
||||||
|
/// against the flows recorded by `record_sent`; an unmatched reply (an
|
||||||
|
/// upstream that reports a different address than we addressed) is attributed
|
||||||
|
/// to `fallback_host`.
|
||||||
|
fn record_received(&mut self, peer_key: &str, fallback_host: &str, bytes: u64) {
|
||||||
|
let Some(tracker) = &self.tracker else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
tracker.add_bytes_received(bytes);
|
||||||
|
let host = self
|
||||||
|
.flow
|
||||||
|
.get(peer_key)
|
||||||
|
.cloned()
|
||||||
|
.unwrap_or_else(|| fallback_host.to_string());
|
||||||
|
self.pending.entry(host).or_insert((0, 0)).1 += bytes;
|
||||||
|
self.maybe_flush();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn maybe_flush(&mut self) {
|
||||||
|
if self.last_flush.elapsed() >= UDP_STATS_FLUSH_INTERVAL {
|
||||||
|
self.flush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push accumulated per-domain totals into the tracker.
|
||||||
|
fn flush(&mut self) {
|
||||||
|
let Some(tracker) = &self.tracker else {
|
||||||
|
self.pending.clear();
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
for (domain, (sent, recv)) in self.pending.drain() {
|
||||||
|
tracker.update_domain_bytes(&domain, sent, recv);
|
||||||
|
}
|
||||||
|
self.last_flush = Instant::now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Host portion of a UDP destination, for blocklist matching. An IP literal is
|
||||||
|
/// rendered without its port so it matches the same way a CONNECT host does.
|
||||||
|
fn addrkind_host(addr: &AddrKind) -> String {
|
||||||
|
match addr {
|
||||||
|
AddrKind::Ip(s) => s.ip().to_string(),
|
||||||
|
AddrKind::Domain(domain, _) => domain.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stable key identifying a UDP peer, used to tie replies back to the
|
||||||
|
/// destination host they belong to.
|
||||||
|
fn addrkind_key(addr: &AddrKind) -> String {
|
||||||
|
match addr {
|
||||||
|
AddrKind::Ip(s) => s.to_string(),
|
||||||
|
AddrKind::Domain(domain, port) => format!("{domain}:{port}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// How a UDP ASSOCIATE request must be served for a given upstream so the real
|
/// How a UDP ASSOCIATE request must be served for a given upstream so the real
|
||||||
/// IP never leaks.
|
/// IP never leaks.
|
||||||
#[derive(Debug, PartialEq, Eq)]
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
@@ -108,7 +237,7 @@ pub async fn handle_socks5_connection(
|
|||||||
.await;
|
.await;
|
||||||
}
|
}
|
||||||
CMD_UDP_ASSOCIATE => {
|
CMD_UDP_ASSOCIATE => {
|
||||||
handle_udp_associate(stream, upstream_url).await;
|
handle_udp_associate(stream, upstream_url, blocklist_matcher).await;
|
||||||
}
|
}
|
||||||
other => {
|
other => {
|
||||||
log::debug!("SOCKS5 unsupported command {other:#04x}");
|
log::debug!("SOCKS5 unsupported command {other:#04x}");
|
||||||
@@ -294,8 +423,13 @@ async fn handle_connect(
|
|||||||
///
|
///
|
||||||
/// `control` is the TCP control connection; the UDP association lives exactly
|
/// `control` is the TCP control connection; the UDP association lives exactly
|
||||||
/// as long as it stays open (RFC 1928 §6), so the relay loop tears down when
|
/// as long as it stays open (RFC 1928 §6), so the relay loop tears down when
|
||||||
/// the browser closes it.
|
/// the browser closes it. Every relayed datagram is filtered and metered via
|
||||||
async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<String>) {
|
/// [`UdpRelayContext`], matching the TCP CONNECT path.
|
||||||
|
async fn handle_udp_associate(
|
||||||
|
mut control: TcpStream,
|
||||||
|
upstream_url: Option<String>,
|
||||||
|
blocklist_matcher: BlocklistMatcher,
|
||||||
|
) {
|
||||||
let mode = udp_mode(upstream_url.as_deref());
|
let mode = udp_mode(upstream_url.as_deref());
|
||||||
|
|
||||||
if mode == UdpMode::Refuse {
|
if mode == UdpMode::Refuse {
|
||||||
@@ -344,7 +478,7 @@ async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<Strin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
|
log::info!("SOCKS5 UDP ASSOCIATE (direct) relaying on {relay_addr}");
|
||||||
run_udp_relay_direct(control, relay, out).await;
|
run_udp_relay_direct(control, relay, out, UdpRelayContext::new(blocklist_matcher)).await;
|
||||||
}
|
}
|
||||||
UdpMode::Socks5Upstream => {
|
UdpMode::Socks5Upstream => {
|
||||||
// Establish the upstream association FIRST; if the upstream refuses UDP,
|
// Establish the upstream association FIRST; if the upstream refuses UDP,
|
||||||
@@ -367,7 +501,13 @@ async fn handle_udp_associate(mut control: TcpStream, upstream_url: Option<Strin
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
|
log::info!("SOCKS5 UDP ASSOCIATE (via SOCKS5 upstream) relaying on {relay_addr}");
|
||||||
run_udp_relay_socks5(control, relay, datagram).await;
|
run_udp_relay_socks5(
|
||||||
|
control,
|
||||||
|
relay,
|
||||||
|
datagram,
|
||||||
|
UdpRelayContext::new(blocklist_matcher),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
}
|
}
|
||||||
UdpMode::Refuse => unreachable!("handled above"),
|
UdpMode::Refuse => unreachable!("handled above"),
|
||||||
}
|
}
|
||||||
@@ -389,10 +529,20 @@ async fn associate_upstream(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
let proxy_stream = TcpStream::connect((host, port)).await?;
|
let proxy_stream = tokio::time::timeout(
|
||||||
|
crate::proxy_server::UPSTREAM_DIAL_TIMEOUT,
|
||||||
|
TcpStream::connect((host, port)),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| format!("upstream SOCKS5 connect to {host}:{port} timed out"))??;
|
||||||
let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?;
|
let bind_sock = UdpSocket::bind((Ipv4Addr::UNSPECIFIED, 0)).await?;
|
||||||
// association_addr None => 0.0.0.0:0 (we accept replies from any peer).
|
// association_addr None => 0.0.0.0:0 (we accept replies from any peer).
|
||||||
let datagram = SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>).await?;
|
let datagram = tokio::time::timeout(
|
||||||
|
crate::proxy_server::UPSTREAM_DIAL_TIMEOUT,
|
||||||
|
SocksDatagram::associate(proxy_stream, bind_sock, auth, None::<AddrKind>),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|_| "upstream SOCKS5 UDP ASSOCIATE handshake timed out")??;
|
||||||
Ok(datagram)
|
Ok(datagram)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +617,12 @@ fn build_udp_response(peer: SocketAddr, data: &[u8]) -> Vec<u8> {
|
|||||||
|
|
||||||
/// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when
|
/// Direct UDP relay: browser <-> a plain egress UDP socket. Used only when
|
||||||
/// there is no upstream proxy, so the host IP is the profile's own IP.
|
/// there is no upstream proxy, so the host IP is the profile's own IP.
|
||||||
async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: UdpSocket) {
|
async fn run_udp_relay_direct(
|
||||||
|
mut control: TcpStream,
|
||||||
|
relay: UdpSocket,
|
||||||
|
out: UdpSocket,
|
||||||
|
mut ctx: UdpRelayContext,
|
||||||
|
) {
|
||||||
let mut client_addr: Option<SocketAddr> = None;
|
let mut client_addr: Option<SocketAddr> = None;
|
||||||
let mut from_client = vec![0u8; UDP_BUF];
|
let mut from_client = vec![0u8; UDP_BUF];
|
||||||
let mut from_target = vec![0u8; UDP_BUF];
|
let mut from_target = vec![0u8; UDP_BUF];
|
||||||
@@ -490,23 +645,37 @@ async fn run_udp_relay_direct(mut control: TcpStream, relay: UdpSocket, out: Udp
|
|||||||
if header.frag != 0 {
|
if header.frag != 0 {
|
||||||
continue; // fragmentation unsupported
|
continue; // fragmentation unsupported
|
||||||
}
|
}
|
||||||
|
let host = addrkind_host(&header.dst);
|
||||||
|
if ctx.is_blocked(&host) {
|
||||||
|
log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
let payload = &from_client[header.data_offset..n];
|
let payload = &from_client[header.data_offset..n];
|
||||||
|
// Resolve after the blocklist check so a blocked host is never even
|
||||||
|
// looked up, and count bytes against the resolved peer so replies from
|
||||||
|
// it are attributed back to this host.
|
||||||
let dst = match resolve_addr(&header.dst).await {
|
let dst = match resolve_addr(&header.dst).await {
|
||||||
Some(d) => d,
|
Some(d) => d,
|
||||||
None => continue,
|
None => continue,
|
||||||
};
|
};
|
||||||
let _ = out.send_to(payload, dst).await;
|
if out.send_to(payload, dst).await.is_ok() {
|
||||||
|
ctx.record_sent(&host, dst.to_string(), payload.len() as u64);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Target -> browser.
|
// Target -> browser.
|
||||||
r = out.recv_from(&mut from_target) => {
|
r = out.recv_from(&mut from_target) => {
|
||||||
let Ok((n, peer)) = r else { continue };
|
let Ok((n, peer)) = r else { continue };
|
||||||
if let Some(client) = client_addr {
|
if let Some(client) = client_addr {
|
||||||
let resp = build_udp_response(peer, &from_target[..n]);
|
let resp = build_udp_response(peer, &from_target[..n]);
|
||||||
let _ = relay.send_to(&resp, client).await;
|
if relay.send_to(&resp, client).await.is_ok() {
|
||||||
|
ctx.record_received(&peer.to_string(), &peer.ip().to_string(), n as u64);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE.
|
/// UDP relay tunneled through a SOCKS5 upstream that granted UDP ASSOCIATE.
|
||||||
@@ -514,6 +683,7 @@ async fn run_udp_relay_socks5(
|
|||||||
mut control: TcpStream,
|
mut control: TcpStream,
|
||||||
relay: UdpSocket,
|
relay: UdpSocket,
|
||||||
datagram: SocksDatagram<TcpStream>,
|
datagram: SocksDatagram<TcpStream>,
|
||||||
|
mut ctx: UdpRelayContext,
|
||||||
) {
|
) {
|
||||||
let mut client_addr: Option<SocketAddr> = None;
|
let mut client_addr: Option<SocketAddr> = None;
|
||||||
let mut from_client = vec![0u8; UDP_BUF];
|
let mut from_client = vec![0u8; UDP_BUF];
|
||||||
@@ -536,19 +706,33 @@ async fn run_udp_relay_socks5(
|
|||||||
if header.frag != 0 {
|
if header.frag != 0 {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
let host = addrkind_host(&header.dst);
|
||||||
|
if ctx.is_blocked(&host) {
|
||||||
|
log::debug!("[blocklist] Blocked SOCKS5 UDP datagram to {host}");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let peer_key = addrkind_key(&header.dst);
|
||||||
let payload = from_client[header.data_offset..n].to_vec();
|
let payload = from_client[header.data_offset..n].to_vec();
|
||||||
let _ = datagram.send_to(&payload, header.dst).await;
|
if datagram.send_to(&payload, header.dst).await.is_ok() {
|
||||||
|
ctx.record_sent(&host, peer_key, payload.len() as u64);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// Upstream -> browser.
|
// Upstream -> browser.
|
||||||
r = datagram.recv_from(&mut from_upstream) => {
|
r = datagram.recv_from(&mut from_upstream) => {
|
||||||
let Ok((n, peer)) = r else { continue };
|
let Ok((n, peer)) = r else { continue };
|
||||||
if let Some(client) = client_addr {
|
if let Some(client) = client_addr {
|
||||||
let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]);
|
let resp = build_udp_response(addrkind_to_socketaddr(&peer), &from_upstream[..n]);
|
||||||
let _ = relay.send_to(&resp, client).await;
|
if relay.send_to(&resp, client).await.is_ok() {
|
||||||
|
// The upstream usually reports the peer by IP even when we
|
||||||
|
// addressed a domain, so an unmatched flow falls back to that IP.
|
||||||
|
ctx.record_received(&addrkind_key(&peer), &addrkind_host(&peer), n as u64);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve a UDP destination to a concrete socket address for direct relay.
|
/// Resolve a UDP destination to a concrete socket address for direct relay.
|
||||||
@@ -637,6 +821,91 @@ mod tests {
|
|||||||
assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none());
|
assert!(parse_udp_header(&[0, 0, 0, 0x01, 1, 2]).is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addrkind_host_strips_port_for_blocklist_matching() {
|
||||||
|
// The blocklist matches CONNECT hosts without a port, so UDP destinations
|
||||||
|
// must be rendered the same way or IP rules would never match.
|
||||||
|
assert_eq!(
|
||||||
|
addrkind_host(&AddrKind::Ip(SocketAddr::new(
|
||||||
|
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
|
||||||
|
443
|
||||||
|
))),
|
||||||
|
"1.2.3.4"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
addrkind_host(&AddrKind::Domain("ads.example.com".into(), 443)),
|
||||||
|
"ads.example.com"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn addrkind_key_distinguishes_ports() {
|
||||||
|
assert_eq!(
|
||||||
|
addrkind_key(&AddrKind::Domain("example.com".into(), 443)),
|
||||||
|
"example.com:443"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
addrkind_key(&AddrKind::Ip(SocketAddr::new(
|
||||||
|
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
|
||||||
|
53
|
||||||
|
))),
|
||||||
|
"1.2.3.4:53"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udp_relay_context_blocks_like_the_tcp_path() {
|
||||||
|
let dir = std::env::temp_dir().join(format!("donut-udp-blocklist-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("block.txt");
|
||||||
|
std::fs::write(&path, "ads.example.com\n").unwrap();
|
||||||
|
|
||||||
|
let matcher = BlocklistMatcher::from_file(path.to_str().unwrap()).unwrap();
|
||||||
|
let ctx = UdpRelayContext::new(matcher);
|
||||||
|
assert!(ctx.is_blocked("ads.example.com"));
|
||||||
|
// Subdomains are blocked by the parent rule, as on the CONNECT path.
|
||||||
|
assert!(ctx.is_blocked("cdn.ads.example.com"));
|
||||||
|
assert!(!ctx.is_blocked("example.com"));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udp_relay_context_allowlist_mode_blocks_unlisted_ip_destinations() {
|
||||||
|
// Parity check: allowlist mode blocks a bare IP destination over UDP the
|
||||||
|
// same way is_blocked does for a CONNECT to an IP literal — otherwise QUIC
|
||||||
|
// to a hardcoded IP would walk straight through a strict allowlist.
|
||||||
|
let dir = std::env::temp_dir().join(format!("donut-udp-allowlist-{}", std::process::id()));
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let path = dir.join("allow.txt");
|
||||||
|
std::fs::write(&path, "trusted.example.com\n").unwrap();
|
||||||
|
|
||||||
|
let matcher = BlocklistMatcher::from_file_with_mode(path.to_str().unwrap(), true).unwrap();
|
||||||
|
let ctx = UdpRelayContext::new(matcher);
|
||||||
|
assert!(!ctx.is_blocked("trusted.example.com"));
|
||||||
|
assert!(ctx.is_blocked("evil.example.com"));
|
||||||
|
assert!(ctx.is_blocked(&addrkind_host(&AddrKind::Ip(SocketAddr::new(
|
||||||
|
IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)),
|
||||||
|
443
|
||||||
|
)))));
|
||||||
|
|
||||||
|
std::fs::remove_dir_all(&dir).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn udp_relay_context_attributes_replies_to_the_destination_host() {
|
||||||
|
// No tracker is initialised in tests, so this exercises the flow map that
|
||||||
|
// decides which domain reply bytes belong to.
|
||||||
|
let mut ctx = UdpRelayContext::new(BlocklistMatcher::new());
|
||||||
|
ctx.flow.insert("1.2.3.4:443".into(), "example.com".into());
|
||||||
|
assert_eq!(
|
||||||
|
ctx.flow.get("1.2.3.4:443").map(String::as_str),
|
||||||
|
Some("example.com")
|
||||||
|
);
|
||||||
|
// An unknown peer has no flow and falls back to the peer's own host.
|
||||||
|
assert!(!ctx.flow.contains_key("9.9.9.9:53"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_udp_response_prefixes_header() {
|
fn build_udp_response_prefixes_header() {
|
||||||
let resp = build_udp_response(
|
let resp = build_udp_response(
|
||||||
|
|||||||
+120
-150
@@ -492,6 +492,9 @@ impl SyncEngine {
|
|||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
|
||||||
|
let profile = &reconciled_profile;
|
||||||
|
|
||||||
// Derive encryption key if encrypted sync
|
// Derive encryption key if encrypted sync
|
||||||
let encryption_key = if profile.is_encrypted_sync() {
|
let encryption_key = if profile.is_encrypted_sync() {
|
||||||
let password = encryption::load_e2e_password()
|
let password = encryption::load_e2e_password()
|
||||||
@@ -697,11 +700,6 @@ impl SyncEngine {
|
|||||||
log::debug!("Deleted remote file: {}", path);
|
log::debug!("Deleted remote file: {}", path);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upload metadata.json (sanitized profile)
|
|
||||||
self
|
|
||||||
.upload_profile_metadata(&profile_id, profile, &key_prefix)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// If this sync changed the local profile directory (downloaded files and/or
|
// If this sync changed the local profile directory (downloaded files and/or
|
||||||
// deleted local files), the manifest generated at the START of the sync is
|
// deleted local files), the manifest generated at the START of the sync is
|
||||||
// now stale. Uploading it would advertise wrong hashes/mtimes for the files
|
// now stale. Uploading it would advertise wrong hashes/mtimes for the files
|
||||||
@@ -747,37 +745,18 @@ impl SyncEngine {
|
|||||||
let _ = self.sync_vpn(vpn_id, Some(app_handle)).await;
|
let _ = self.sync_vpn(vpn_id, Some(app_handle)).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Download remote metadata and merge changes (name, tags, notes, etc.)
|
let mut updated_profile = profile.clone();
|
||||||
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
|
updated_profile.last_sync = Some(
|
||||||
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
|
std::time::SystemTime::now()
|
||||||
let mut updated_profile = profile.clone();
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
// Merge fields that can be changed on other devices
|
.unwrap()
|
||||||
updated_profile.name = remote_meta.name;
|
.as_secs(),
|
||||||
updated_profile.tags = remote_meta.tags;
|
);
|
||||||
updated_profile.note = remote_meta.note;
|
profile_manager
|
||||||
updated_profile.proxy_id = remote_meta.proxy_id;
|
.save_profile(&updated_profile)
|
||||||
updated_profile.vpn_id = remote_meta.vpn_id;
|
.map_err(|e| {
|
||||||
updated_profile.group_id = remote_meta.group_id;
|
SyncError::IoError(format!("Failed to save reconciled profile metadata: {e}"))
|
||||||
updated_profile.extension_group_id = remote_meta.extension_group_id;
|
})?;
|
||||||
updated_profile.window_color = remote_meta.window_color;
|
|
||||||
updated_profile.last_sync = Some(
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs(),
|
|
||||||
);
|
|
||||||
let _ = profile_manager.save_profile(&updated_profile);
|
|
||||||
} else {
|
|
||||||
// Fallback: just update last_sync
|
|
||||||
let mut updated_profile = profile.clone();
|
|
||||||
updated_profile.last_sync = Some(
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs(),
|
|
||||||
);
|
|
||||||
let _ = profile_manager.save_profile(&updated_profile);
|
|
||||||
}
|
|
||||||
let _ = events::emit("profiles-changed", ());
|
let _ = events::emit("profiles-changed", ());
|
||||||
|
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
@@ -881,6 +860,45 @@ impl SyncEngine {
|
|||||||
Ok(profile)
|
Ok(profile)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn reconcile_profile_metadata(
|
||||||
|
&self,
|
||||||
|
profile: &BrowserProfile,
|
||||||
|
) -> SyncResult<BrowserProfile> {
|
||||||
|
let profile_id = profile.id.to_string();
|
||||||
|
let key_prefix = Self::get_team_key_prefix(profile).await;
|
||||||
|
let remote_key = format!("{key_prefix}profiles/{profile_id}/metadata.json");
|
||||||
|
let stat = self.client.stat(&remote_key).await?;
|
||||||
|
|
||||||
|
if !stat.exists {
|
||||||
|
self
|
||||||
|
.upload_profile_metadata(&profile_id, profile, &key_prefix)
|
||||||
|
.await?;
|
||||||
|
return Ok(profile.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let local_updated = profile.updated_at.unwrap_or(0);
|
||||||
|
let remote_updated = self.remote_updated_at(&stat, &remote_key).await;
|
||||||
|
if local_updated > remote_updated {
|
||||||
|
self
|
||||||
|
.upload_profile_metadata(&profile_id, profile, &key_prefix)
|
||||||
|
.await?;
|
||||||
|
return Ok(profile.clone());
|
||||||
|
}
|
||||||
|
if remote_updated <= local_updated {
|
||||||
|
return Ok(profile.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut remote = self.download_profile_metadata(&remote_key).await?;
|
||||||
|
// Process state is device-local and deliberately stripped from uploads.
|
||||||
|
remote.process_id = profile.process_id;
|
||||||
|
remote.last_launch = profile.last_launch;
|
||||||
|
remote.last_sync = profile.last_sync;
|
||||||
|
ProfileManager::instance()
|
||||||
|
.save_profile(&remote)
|
||||||
|
.map_err(|e| SyncError::IoError(format!("Failed to save remote profile metadata: {e}")))?;
|
||||||
|
Ok(remote)
|
||||||
|
}
|
||||||
|
|
||||||
/// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups).
|
/// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups).
|
||||||
/// No browser files are synced.
|
/// No browser files are synced.
|
||||||
async fn sync_cross_os_metadata(
|
async fn sync_cross_os_metadata(
|
||||||
@@ -889,34 +907,8 @@ impl SyncEngine {
|
|||||||
profile: &BrowserProfile,
|
profile: &BrowserProfile,
|
||||||
) -> SyncResult<()> {
|
) -> SyncResult<()> {
|
||||||
let profile_id = profile.id.to_string();
|
let profile_id = profile.id.to_string();
|
||||||
let key_prefix = Self::get_team_key_prefix(profile).await;
|
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile = &reconciled_profile;
|
||||||
|
|
||||||
// Upload our metadata
|
|
||||||
self
|
|
||||||
.upload_profile_metadata(&profile_id, profile, &key_prefix)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
// Download remote metadata and merge if remote has changes
|
|
||||||
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
|
|
||||||
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
|
|
||||||
let mut updated = profile.clone();
|
|
||||||
updated.name = remote_meta.name;
|
|
||||||
updated.tags = remote_meta.tags;
|
|
||||||
updated.note = remote_meta.note;
|
|
||||||
updated.proxy_id = remote_meta.proxy_id;
|
|
||||||
updated.vpn_id = remote_meta.vpn_id;
|
|
||||||
updated.group_id = remote_meta.group_id;
|
|
||||||
updated.extension_group_id = remote_meta.extension_group_id;
|
|
||||||
updated.window_color = remote_meta.window_color;
|
|
||||||
updated.last_sync = Some(
|
|
||||||
std::time::SystemTime::now()
|
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
|
||||||
.unwrap()
|
|
||||||
.as_secs(),
|
|
||||||
);
|
|
||||||
let _ = profile_manager.save_profile(&updated);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync associated entities
|
// Sync associated entities
|
||||||
if let Some(proxy_id) = &profile.proxy_id {
|
if let Some(proxy_id) = &profile.proxy_id {
|
||||||
@@ -954,18 +946,9 @@ impl SyncEngine {
|
|||||||
let json = serde_json::to_string_pretty(&sanitized)
|
let json = serde_json::to_string_pretty(&sanitized)
|
||||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
|
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
|
||||||
|
|
||||||
let (payload, content_type) = encryption::maybe_seal_for_upload(json.as_bytes())
|
|
||||||
.map_err(|e| SyncError::InvalidData(format!("Failed to seal profile metadata: {e}")))?;
|
|
||||||
|
|
||||||
let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
|
let remote_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
|
||||||
let presign = self
|
|
||||||
.client
|
|
||||||
.presign_upload(&remote_key, Some(content_type))
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
self
|
self
|
||||||
.client
|
.upload_config_json(&remote_key, &json, sanitized.updated_at.unwrap_or(0))
|
||||||
.upload_bytes(&presign.url, &payload, Some(content_type))
|
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
@@ -4023,28 +4006,55 @@ pub async fn rollover_encryption_for_all_entities(
|
|||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
let _ = events::emit("e2e-rollover-started", ());
|
let _ = events::emit("e2e-rollover-started", ());
|
||||||
|
|
||||||
|
let internal_error = |detail: String| {
|
||||||
|
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": detail } }).to_string()
|
||||||
|
};
|
||||||
|
let engine = SyncEngine::create_from_settings(&app_handle)
|
||||||
|
.await
|
||||||
|
.map_err(&internal_error)?;
|
||||||
let profile_manager = ProfileManager::instance();
|
let profile_manager = ProfileManager::instance();
|
||||||
let profiles = profile_manager
|
let profiles = profile_manager
|
||||||
.list_profiles()
|
.list_profiles()
|
||||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
.map_err(|e| internal_error(format!("Failed to list profiles: {e}")))?;
|
||||||
|
|
||||||
let synced_profiles: Vec<_> = profiles
|
let synced_profiles: Vec<_> = profiles
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|p| p.sync_mode != SyncMode::Disabled)
|
.filter(|p| p.sync_mode != SyncMode::Disabled)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let total_profiles = synced_profiles.len();
|
if synced_profiles
|
||||||
let mut running_profile_ids: std::collections::HashSet<uuid::Uuid> =
|
.iter()
|
||||||
std::collections::HashSet::new();
|
.any(|profile| profile.process_id.is_some())
|
||||||
|
{
|
||||||
|
return Err(serde_json::json!({ "code": "PROFILE_RUNNING" }).to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
let total_profiles = synced_profiles.len();
|
||||||
for (i, profile) in synced_profiles.iter().enumerate() {
|
for (i, profile) in synced_profiles.iter().enumerate() {
|
||||||
if profile.process_id.is_some() {
|
|
||||||
running_profile_ids.insert(profile.id);
|
|
||||||
}
|
|
||||||
let id_str = profile.id.to_string();
|
let id_str = profile.id.to_string();
|
||||||
if let Err(e) = trigger_sync_for_profile(app_handle.clone(), id_str.clone()).await {
|
// The remote manifest may be encrypted with the previous password. Delete
|
||||||
log::warn!("Rollover: profile {} re-sync failed: {e}", id_str);
|
// only that manifest so the normal sync path treats every local file as an
|
||||||
}
|
// upload and rewrites it with the current password. Existing remote files
|
||||||
|
// remain available until their replacements have uploaded.
|
||||||
|
let key_prefix = SyncEngine::get_team_key_prefix(profile).await;
|
||||||
|
engine
|
||||||
|
.upload_profile_metadata(&id_str, profile, &key_prefix)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
internal_error(format!(
|
||||||
|
"Failed to roll over profile metadata {id_str}: {e}"
|
||||||
|
))
|
||||||
|
})?;
|
||||||
|
let manifest_key = format!("{key_prefix}profiles/{id_str}/manifest.json");
|
||||||
|
engine
|
||||||
|
.client
|
||||||
|
.delete(&manifest_key, None)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(format!("Failed to reset profile manifest: {e}")))?;
|
||||||
|
engine
|
||||||
|
.sync_profile(&app_handle, profile)
|
||||||
|
.await
|
||||||
|
.map_err(|e| internal_error(format!("Failed to roll over profile {id_str}: {e}")))?;
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
@@ -4055,37 +4065,14 @@ pub async fn rollover_encryption_for_all_entities(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine which entity ids are referenced by running profiles, so we can
|
|
||||||
// defer their re-upload (changing their files mid-session would cause the
|
|
||||||
// running browser to see a different proxy/extension config than what it
|
|
||||||
// launched with).
|
|
||||||
let mut deferred_proxy_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
let mut deferred_vpn_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
let mut deferred_group_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
|
|
||||||
for p in &profiles {
|
|
||||||
if running_profile_ids.contains(&p.id) {
|
|
||||||
if let Some(id) = &p.proxy_id {
|
|
||||||
deferred_proxy_ids.insert(id.clone());
|
|
||||||
}
|
|
||||||
if let Some(id) = &p.vpn_id {
|
|
||||||
deferred_vpn_ids.insert(id.clone());
|
|
||||||
}
|
|
||||||
if let Some(id) = &p.group_id {
|
|
||||||
deferred_group_ids.insert(id.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
|
let proxies = crate::proxy_manager::PROXY_MANAGER.get_stored_proxies();
|
||||||
let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
|
let synced_proxies: Vec<_> = proxies.iter().filter(|p| p.sync_enabled).collect();
|
||||||
let total_proxies = synced_proxies.len();
|
let total_proxies = synced_proxies.len();
|
||||||
let mut deferred = Vec::new();
|
|
||||||
for (i, proxy) in synced_proxies.iter().enumerate() {
|
for (i, proxy) in synced_proxies.iter().enumerate() {
|
||||||
if deferred_proxy_ids.contains(&proxy.id) {
|
engine
|
||||||
deferred.push(proxy.id.clone());
|
.upload_proxy(proxy)
|
||||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
.await
|
||||||
scheduler.queue_proxy_sync(proxy.id.clone()).await;
|
.map_err(|e| internal_error(format!("Failed to roll over proxy {}: {e}", proxy.id)))?;
|
||||||
}
|
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}),
|
serde_json::json!({"stage": "proxies", "done": i + 1, "total": total_proxies}),
|
||||||
@@ -4095,17 +4082,15 @@ pub async fn rollover_encryption_for_all_entities(
|
|||||||
let groups = {
|
let groups = {
|
||||||
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
let gm = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
||||||
gm.get_all_groups()
|
gm.get_all_groups()
|
||||||
.map_err(|e| format!("Failed to get groups: {e}"))?
|
.map_err(|e| internal_error(format!("Failed to get groups: {e}")))?
|
||||||
};
|
};
|
||||||
let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect();
|
let synced_groups: Vec<_> = groups.iter().filter(|g| g.sync_enabled).collect();
|
||||||
let total_groups = synced_groups.len();
|
let total_groups = synced_groups.len();
|
||||||
let mut deferred_groups = Vec::new();
|
|
||||||
for (i, group) in synced_groups.iter().enumerate() {
|
for (i, group) in synced_groups.iter().enumerate() {
|
||||||
if deferred_group_ids.contains(&group.id) {
|
engine
|
||||||
deferred_groups.push(group.id.clone());
|
.upload_group(group)
|
||||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
.await
|
||||||
scheduler.queue_group_sync(group.id.clone()).await;
|
.map_err(|e| internal_error(format!("Failed to roll over group {}: {e}", group.id)))?;
|
||||||
}
|
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}),
|
serde_json::json!({"stage": "groups", "done": i + 1, "total": total_groups}),
|
||||||
@@ -4116,17 +4101,15 @@ pub async fn rollover_encryption_for_all_entities(
|
|||||||
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||||
storage
|
storage
|
||||||
.list_configs()
|
.list_configs()
|
||||||
.map_err(|e| format!("Failed to list VPN configs: {e}"))?
|
.map_err(|e| internal_error(format!("Failed to list VPN configs: {e}")))?
|
||||||
};
|
};
|
||||||
let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect();
|
let synced_vpns: Vec<_> = vpns.iter().filter(|v| v.sync_enabled).collect();
|
||||||
let total_vpns = synced_vpns.len();
|
let total_vpns = synced_vpns.len();
|
||||||
let mut deferred_vpns = Vec::new();
|
|
||||||
for (i, config) in synced_vpns.iter().enumerate() {
|
for (i, config) in synced_vpns.iter().enumerate() {
|
||||||
if deferred_vpn_ids.contains(&config.id) {
|
engine
|
||||||
deferred_vpns.push(config.id.clone());
|
.upload_vpn(config)
|
||||||
} else if let Some(scheduler) = super::get_global_scheduler() {
|
.await
|
||||||
scheduler.queue_vpn_sync(config.id.clone()).await;
|
.map_err(|e| internal_error(format!("Failed to roll over VPN {}: {e}", config.id)))?;
|
||||||
}
|
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}),
|
serde_json::json!({"stage": "vpns", "done": i + 1, "total": total_vpns}),
|
||||||
@@ -4136,14 +4119,15 @@ pub async fn rollover_encryption_for_all_entities(
|
|||||||
let extensions = {
|
let extensions = {
|
||||||
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
em.list_extensions()
|
em.list_extensions()
|
||||||
.map_err(|e| format!("Failed to list extensions: {e}"))?
|
.map_err(|e| internal_error(format!("Failed to list extensions: {e}")))?
|
||||||
};
|
};
|
||||||
let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect();
|
let synced_exts: Vec<_> = extensions.iter().filter(|e| e.sync_enabled).collect();
|
||||||
let total_exts = synced_exts.len();
|
let total_exts = synced_exts.len();
|
||||||
for (i, ext) in synced_exts.iter().enumerate() {
|
for (i, ext) in synced_exts.iter().enumerate() {
|
||||||
if let Some(scheduler) = super::get_global_scheduler() {
|
engine
|
||||||
scheduler.queue_extension_sync(ext.id.clone()).await;
|
.upload_extension(ext)
|
||||||
}
|
.await
|
||||||
|
.map_err(|e| internal_error(format!("Failed to roll over extension {}: {e}", ext.id)))?;
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}),
|
serde_json::json!({"stage": "extensions", "done": i + 1, "total": total_exts}),
|
||||||
@@ -4153,37 +4137,23 @@ pub async fn rollover_encryption_for_all_entities(
|
|||||||
let ext_groups = {
|
let ext_groups = {
|
||||||
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
let em = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||||
em.list_groups()
|
em.list_groups()
|
||||||
.map_err(|e| format!("Failed to list extension groups: {e}"))?
|
.map_err(|e| internal_error(format!("Failed to list extension groups: {e}")))?
|
||||||
};
|
};
|
||||||
let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect();
|
let synced_ext_groups: Vec<_> = ext_groups.iter().filter(|g| g.sync_enabled).collect();
|
||||||
let total_eg = synced_ext_groups.len();
|
let total_eg = synced_ext_groups.len();
|
||||||
for (i, group) in synced_ext_groups.iter().enumerate() {
|
for (i, group) in synced_ext_groups.iter().enumerate() {
|
||||||
if let Some(scheduler) = super::get_global_scheduler() {
|
engine.upload_extension_group(group).await.map_err(|e| {
|
||||||
scheduler.queue_extension_group_sync(group.id.clone()).await;
|
internal_error(format!(
|
||||||
}
|
"Failed to roll over extension group {}: {e}",
|
||||||
|
group.id
|
||||||
|
))
|
||||||
|
})?;
|
||||||
let _ = events::emit(
|
let _ = events::emit(
|
||||||
"e2e-rollover-progress",
|
"e2e-rollover-progress",
|
||||||
serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}),
|
serde_json::json!({"stage": "extension_groups", "done": i + 1, "total": total_eg}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if !deferred.is_empty() || !deferred_groups.is_empty() || !deferred_vpns.is_empty() {
|
|
||||||
tauri::async_runtime::spawn(async move {
|
|
||||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
|
||||||
if let Some(scheduler) = super::get_global_scheduler() {
|
|
||||||
for id in deferred {
|
|
||||||
scheduler.queue_proxy_sync(id).await;
|
|
||||||
}
|
|
||||||
for id in deferred_groups {
|
|
||||||
scheduler.queue_group_sync(id).await;
|
|
||||||
}
|
|
||||||
for id in deferred_vpns {
|
|
||||||
scheduler.queue_vpn_sync(id).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
let _ = events::emit("e2e-rollover-completed", ());
|
let _ = events::emit("e2e-rollover-completed", ());
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ use std::path::Path;
|
|||||||
use std::time::SystemTime;
|
use std::time::SystemTime;
|
||||||
|
|
||||||
use super::types::{SyncError, SyncResult};
|
use super::types::{SyncError, SyncResult};
|
||||||
use crate::profile::types::BrowserProfile;
|
|
||||||
|
|
||||||
/// Default exclude patterns for volatile browser profile files.
|
/// Default exclude patterns for volatile browser profile files.
|
||||||
/// Patterns use `**/` prefix to match at any directory depth, since the sync
|
/// Patterns use `**/` prefix to match at any directory depth, since the sync
|
||||||
@@ -61,6 +60,10 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
|||||||
"**/DawnWebGPUCache/**",
|
"**/DawnWebGPUCache/**",
|
||||||
"**/BrowserMetrics*",
|
"**/BrowserMetrics*",
|
||||||
"**/.DS_Store",
|
"**/.DS_Store",
|
||||||
|
// Profile metadata is a separately reconciled LWW config object. Including
|
||||||
|
// it in the browser-file manifest creates two competing sync mechanisms and
|
||||||
|
// lets a stale in-memory profile overwrite a metadata download.
|
||||||
|
"metadata.json",
|
||||||
".donut-sync/**",
|
".donut-sync/**",
|
||||||
// Orphaned local-only marker from earlier rollover-based fingerprint
|
// Orphaned local-only marker from earlier rollover-based fingerprint
|
||||||
// regeneration. Keep excluding it so any markers left on disk from
|
// regeneration. Keep excluding it so any markers left on disk from
|
||||||
@@ -224,39 +227,6 @@ fn hash_file(path: &Path) -> Result<Option<String>, SyncError> {
|
|||||||
Ok(Some(hasher.finalize().to_hex().to_string()))
|
Ok(Some(hasher.finalize().to_hex().to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compute blake3 hash of metadata.json after sanitizing volatile fields.
|
|
||||||
/// This prevents infinite sync loops where updating last_sync triggers a new sync.
|
|
||||||
fn hash_sanitized_metadata(path: &Path) -> Result<Option<String>, SyncError> {
|
|
||||||
let content = match fs::read_to_string(path) {
|
|
||||||
Ok(c) => c,
|
|
||||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
|
||||||
Err(e) => {
|
|
||||||
return Err(SyncError::IoError(format!(
|
|
||||||
"Failed to read metadata at {}: {e}",
|
|
||||||
path.display()
|
|
||||||
)));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
let mut profile: BrowserProfile = serde_json::from_str(&content).map_err(|e| {
|
|
||||||
SyncError::SerializationError(format!("Failed to parse metadata for hashing: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
// Sanitize volatile fields that should not trigger a re-sync
|
|
||||||
profile.last_sync = None;
|
|
||||||
profile.process_id = None;
|
|
||||||
profile.last_launch = None;
|
|
||||||
|
|
||||||
let sanitized_json = serde_json::to_string(&profile).map_err(|e| {
|
|
||||||
SyncError::SerializationError(format!("Failed to serialize sanitized metadata: {e}"))
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let mut hasher = blake3::Hasher::new();
|
|
||||||
hasher.update(sanitized_json.as_bytes());
|
|
||||||
|
|
||||||
Ok(Some(hasher.finalize().to_hex().to_string()))
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get mtime as unix timestamp
|
/// Get mtime as unix timestamp
|
||||||
/// Returns None if the file doesn't exist (was deleted)
|
/// Returns None if the file doesn't exist (was deleted)
|
||||||
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
|
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
|
||||||
@@ -372,19 +342,7 @@ pub fn generate_manifest(
|
|||||||
*max_mtime = (*max_mtime).max(mtime);
|
*max_mtime = (*max_mtime).max(mtime);
|
||||||
|
|
||||||
// Check cache for existing hash
|
// Check cache for existing hash
|
||||||
let hash = if relative_path == "metadata.json" {
|
let hash = if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
|
||||||
// Special case: sanitize metadata.json before hashing to prevent sync loops
|
|
||||||
match hash_sanitized_metadata(&path)? {
|
|
||||||
Some(computed_hash) => computed_hash,
|
|
||||||
None => {
|
|
||||||
log::debug!(
|
|
||||||
"File disappeared during manifest generation, skipping: {}",
|
|
||||||
path.display()
|
|
||||||
);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
|
|
||||||
cached_hash.to_string()
|
cached_hash.to_string()
|
||||||
} else {
|
} else {
|
||||||
match hash_file(&path)? {
|
match hash_file(&path)? {
|
||||||
@@ -651,21 +609,15 @@ mod tests {
|
|||||||
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
|
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
|
||||||
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
|
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
|
||||||
|
|
||||||
// metadata.json at root
|
fs::write(profile_dir.join("metadata.json"), "{}").unwrap();
|
||||||
let profile = BrowserProfile::default();
|
|
||||||
fs::write(
|
|
||||||
profile_dir.join("metadata.json"),
|
|
||||||
serde_json::to_string(&profile).unwrap(),
|
|
||||||
)
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut cache = HashCache::default();
|
let mut cache = HashCache::default();
|
||||||
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
|
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
|
||||||
|
|
||||||
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
|
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
|
||||||
assert!(
|
assert!(
|
||||||
paths.contains(&"metadata.json"),
|
!paths.contains(&"metadata.json"),
|
||||||
"metadata.json should be synced"
|
"metadata.json is reconciled separately from browser files"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
paths.contains(&"profile/Default/Cookies"),
|
paths.contains(&"profile/Default/Cookies"),
|
||||||
@@ -865,85 +817,4 @@ mod tests {
|
|||||||
assert!(diff.files_to_delete_remote.is_empty());
|
assert!(diff.files_to_delete_remote.is_empty());
|
||||||
assert!(diff.files_to_delete_local.is_empty());
|
assert!(diff.files_to_delete_local.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_generate_manifest_sanitizes_metadata() {
|
|
||||||
let temp_dir = TempDir::new().unwrap();
|
|
||||||
let profile_dir = temp_dir.path().join("profile");
|
|
||||||
fs::create_dir_all(&profile_dir).unwrap();
|
|
||||||
|
|
||||||
let profile_id = uuid::Uuid::new_v4();
|
|
||||||
let metadata_path = profile_dir.join("metadata.json");
|
|
||||||
|
|
||||||
let profile = BrowserProfile {
|
|
||||||
id: profile_id,
|
|
||||||
name: "test-profile".to_string(),
|
|
||||||
last_sync: Some(100),
|
|
||||||
process_id: Some(1234),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
fs::write(&metadata_path, serde_json::to_string(&profile).unwrap()).unwrap();
|
|
||||||
|
|
||||||
let mut cache = HashCache::default();
|
|
||||||
let manifest1 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
|
|
||||||
let hash1 = manifest1
|
|
||||||
.files
|
|
||||||
.iter()
|
|
||||||
.find(|f| f.path == "metadata.json")
|
|
||||||
.unwrap()
|
|
||||||
.hash
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
// Update volatile fields
|
|
||||||
let profile2 = BrowserProfile {
|
|
||||||
id: profile_id,
|
|
||||||
name: "test-profile".to_string(),
|
|
||||||
last_sync: Some(200),
|
|
||||||
process_id: Some(5678),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
fs::write(&metadata_path, serde_json::to_string(&profile2).unwrap()).unwrap();
|
|
||||||
|
|
||||||
let manifest2 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
|
|
||||||
let hash2 = manifest2
|
|
||||||
.files
|
|
||||||
.iter()
|
|
||||||
.find(|f| f.path == "metadata.json")
|
|
||||||
.unwrap()
|
|
||||||
.hash
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
// Hash should be identical because volatile fields are sanitized
|
|
||||||
assert_eq!(
|
|
||||||
hash1, hash2,
|
|
||||||
"Metadata hash should be stable across last_sync/process_id updates"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Change a non-volatile field
|
|
||||||
let profile3 = BrowserProfile {
|
|
||||||
id: profile_id,
|
|
||||||
name: "changed-name".to_string(),
|
|
||||||
last_sync: Some(200),
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
fs::write(&metadata_path, serde_json::to_string(&profile3).unwrap()).unwrap();
|
|
||||||
|
|
||||||
let manifest3 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
|
|
||||||
let hash3 = manifest3
|
|
||||||
.files
|
|
||||||
.iter()
|
|
||||||
.find(|f| f.path == "metadata.json")
|
|
||||||
.unwrap()
|
|
||||||
.hash
|
|
||||||
.clone();
|
|
||||||
|
|
||||||
// Hash should be different because name changed
|
|
||||||
assert_ne!(
|
|
||||||
hash1, hash3,
|
|
||||||
"Metadata hash should change when non-volatile fields change"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -207,7 +207,7 @@ impl SyncSubscription {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("Connected to sync subscription at {url}");
|
log::info!("Connected to sync subscription");
|
||||||
let _ = events::emit("sync-subscription-status", "connected");
|
let _ = events::emit("sync-subscription-status", "connected");
|
||||||
|
|
||||||
let mut buffer = String::new();
|
let mut buffer = String::new();
|
||||||
|
|||||||
@@ -359,7 +359,7 @@ impl SynchronizerManager {
|
|||||||
log::info!("Synchronizer: leader CDP port = {leader_port}, getting WS URL");
|
log::info!("Synchronizer: leader CDP port = {leader_port}, getting WS URL");
|
||||||
let leader_ws_url = Self::get_page_ws_url(leader_port).await?;
|
let leader_ws_url = Self::get_page_ws_url(leader_port).await?;
|
||||||
|
|
||||||
log::info!("Synchronizer: connecting to leader page at {leader_ws_url}");
|
log::info!("Synchronizer: connecting to leader page");
|
||||||
|
|
||||||
let (mut ws_stream, _) = connect_async(&leader_ws_url)
|
let (mut ws_stream, _) = connect_async(&leader_ws_url)
|
||||||
.await
|
.await
|
||||||
@@ -504,7 +504,7 @@ impl SynchronizerManager {
|
|||||||
Ok(url) => {
|
Ok(url) => {
|
||||||
match tokio_tungstenite::connect_async(&url).await {
|
match tokio_tungstenite::connect_async(&url).await {
|
||||||
Ok((ws, _)) => {
|
Ok((ws, _)) => {
|
||||||
log::info!("Synchronizer: follower {} connected at {}", fp.name, url);
|
log::info!("Synchronizer: follower connected");
|
||||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<CapturedEvent>();
|
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<CapturedEvent>();
|
||||||
follower_senders.insert(fid.clone(), tx);
|
follower_senders.insert(fid.clone(), tx);
|
||||||
|
|
||||||
@@ -674,7 +674,7 @@ impl SynchronizerManager {
|
|||||||
if is_top {
|
if is_top {
|
||||||
if let Some(url) = frame.get("url").and_then(|v| v.as_str()) {
|
if let Some(url) = frame.get("url").and_then(|v| v.as_str()) {
|
||||||
if !url.starts_with("about:") && !url.starts_with("chrome://") {
|
if !url.starts_with("about:") && !url.starts_with("chrome://") {
|
||||||
log::info!("Synchronizer: replaying address-bar navigation to {url}");
|
log::info!("Synchronizer: replaying address-bar navigation");
|
||||||
let nav_event = CapturedEvent {
|
let nav_event = CapturedEvent {
|
||||||
event_type: "navigate".to_string(),
|
event_type: "navigate".to_string(),
|
||||||
url: Some(url.to_string()),
|
url: Some(url.to_string()),
|
||||||
|
|||||||
+103
-33
@@ -347,7 +347,12 @@ pub fn save_traffic_stats(stats: &TrafficStats) -> Result<(), Box<dyn std::error
|
|||||||
let key = get_stats_storage_key(stats);
|
let key = get_stats_storage_key(stats);
|
||||||
let file_path = storage_dir.join(format!("{key}.json"));
|
let file_path = storage_dir.join(format!("{key}.json"));
|
||||||
let content = serde_json::to_string(stats)?;
|
let content = serde_json::to_string(stats)?;
|
||||||
fs::write(&file_path, content)?;
|
|
||||||
|
// Write atomically via temp file + rename so readers never observe a
|
||||||
|
// partially-written file (same pattern as write_session_snapshot)
|
||||||
|
let temp_path = storage_dir.join(format!("{key}.json.tmp"));
|
||||||
|
fs::write(&temp_path, content)?;
|
||||||
|
fs::rename(&temp_path, &file_path)?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -370,15 +375,18 @@ pub fn load_traffic_stats_by_profile(profile_id: &str) -> Option<TrafficStats> {
|
|||||||
load_traffic_stats(profile_id)
|
load_traffic_stats(profile_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// List all traffic stats files and migrate old proxy-id based files to profile-id based
|
/// Load all traffic stats files keyed by storage key, migrating old
|
||||||
pub fn list_traffic_stats() -> Vec<TrafficStats> {
|
/// proxy-id based files to profile-id based ones. Only writes to disk when a
|
||||||
|
/// migration or merge actually changed data; the common case is read-only.
|
||||||
|
fn collect_traffic_stats() -> HashMap<String, TrafficStats> {
|
||||||
let storage_dir = get_traffic_stats_dir();
|
let storage_dir = get_traffic_stats_dir();
|
||||||
|
|
||||||
if !storage_dir.exists() {
|
if !storage_dir.exists() {
|
||||||
return Vec::new();
|
return HashMap::new();
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut stats_map: HashMap<String, TrafficStats> = HashMap::new();
|
let mut stats_map: HashMap<String, TrafficStats> = HashMap::new();
|
||||||
|
let mut dirty_keys: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
let mut files_to_delete: Vec<std::path::PathBuf> = Vec::new();
|
let mut files_to_delete: Vec<std::path::PathBuf> = Vec::new();
|
||||||
|
|
||||||
if let Ok(entries) = fs::read_dir(&storage_dir) {
|
if let Ok(entries) = fs::read_dir(&storage_dir) {
|
||||||
@@ -399,12 +407,14 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
|
|||||||
if let Some(existing) = stats_map.get_mut(&key) {
|
if let Some(existing) = stats_map.get_mut(&key) {
|
||||||
// Merge stats from this file into existing
|
// Merge stats from this file into existing
|
||||||
merge_traffic_stats(existing, &s);
|
merge_traffic_stats(existing, &s);
|
||||||
|
dirty_keys.insert(key);
|
||||||
if is_old_proxy_file {
|
if is_old_proxy_file {
|
||||||
files_to_delete.push(path.clone());
|
files_to_delete.push(path.clone());
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
stats_map.insert(key.clone(), s);
|
stats_map.insert(key.clone(), s);
|
||||||
if is_old_proxy_file {
|
if is_old_proxy_file {
|
||||||
|
dirty_keys.insert(key);
|
||||||
files_to_delete.push(path.clone());
|
files_to_delete.push(path.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -414,10 +424,12 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Save merged stats and delete old files
|
// Persist only entries actually changed by a merge or migration
|
||||||
for stats in stats_map.values() {
|
for key in &dirty_keys {
|
||||||
if let Err(e) = save_traffic_stats(stats) {
|
if let Some(stats) = stats_map.get(key) {
|
||||||
log::warn!("Failed to save merged traffic stats: {}", e);
|
if let Err(e) = save_traffic_stats(stats) {
|
||||||
|
log::warn!("Failed to save merged traffic stats: {}", e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -427,7 +439,12 @@ pub fn list_traffic_stats() -> Vec<TrafficStats> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
stats_map.into_values().collect()
|
stats_map
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List all traffic stats files and migrate old proxy-id based files to profile-id based
|
||||||
|
pub fn list_traffic_stats() -> Vec<TrafficStats> {
|
||||||
|
collect_traffic_stats().into_values().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Merge traffic stats from source into destination
|
/// Merge traffic stats from source into destination
|
||||||
@@ -487,27 +504,82 @@ fn merge_traffic_stats(dest: &mut TrafficStats, src: &TrafficStats) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Delete traffic stats by id (profile_id or proxy_id)
|
/// Delete traffic stats by id (profile_id or proxy_id).
|
||||||
|
///
|
||||||
|
/// Removes the session snapshot and any temp orphans alongside the main file.
|
||||||
|
/// The snapshot is what a running worker writes every second, and
|
||||||
|
/// `get_all_traffic_snapshots_realtime` rebuilds an entry straight from it when
|
||||||
|
/// no main file exists — so clearing only `{id}.json` resurrects the very
|
||||||
|
/// totals the user asked to erase, and leaves the snapshot's copy on disk.
|
||||||
pub fn delete_traffic_stats(id: &str) -> bool {
|
pub fn delete_traffic_stats(id: &str) -> bool {
|
||||||
let storage_dir = get_traffic_stats_dir();
|
let storage_dir = get_traffic_stats_dir();
|
||||||
let file_path = storage_dir.join(format!("{id}.json"));
|
let mut removed = false;
|
||||||
|
|
||||||
if file_path.exists() {
|
for name in [
|
||||||
fs::remove_file(&file_path).is_ok()
|
format!("{id}.json"),
|
||||||
} else {
|
format!("{id}.json.tmp"),
|
||||||
false
|
format!("{id}.session.json"),
|
||||||
|
format!("{id}.session.json.tmp"),
|
||||||
|
] {
|
||||||
|
let path = storage_dir.join(name);
|
||||||
|
if path.exists() && secure_remove_file(&path).is_ok() {
|
||||||
|
removed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
removed
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Clear all traffic stats (used when clearing cache)
|
/// Best-effort secure erase: overwrite the file's bytes with zeros and flush
|
||||||
|
/// before unlinking, so the traffic history isn't trivially recoverable from
|
||||||
|
/// the freed blocks. On copy-on-write / SSD storage the OS may still retain
|
||||||
|
/// old blocks — this is a best-effort mitigation, not a guarantee.
|
||||||
|
fn secure_remove_file(path: &std::path::Path) -> std::io::Result<()> {
|
||||||
|
use std::io::Write;
|
||||||
|
if let Ok(meta) = fs::metadata(path) {
|
||||||
|
let len = meta.len();
|
||||||
|
if len > 0 {
|
||||||
|
if let Ok(mut f) = fs::OpenOptions::new().write(true).open(path) {
|
||||||
|
let zeros = vec![0u8; 8192];
|
||||||
|
let mut remaining = len;
|
||||||
|
// The overwrite is best-effort and must never gate the unlink: a write
|
||||||
|
// failure part-way (ENOSPC on a copy-on-write volume, EIO) would
|
||||||
|
// otherwise leave the file both un-wiped and un-deleted, which is
|
||||||
|
// strictly worse than the plain remove this replaced — and the caller
|
||||||
|
// reports success either way, so the history would silently survive a
|
||||||
|
// clear.
|
||||||
|
while remaining > 0 {
|
||||||
|
let chunk = remaining.min(zeros.len() as u64) as usize;
|
||||||
|
if f.write_all(&zeros[..chunk]).is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
remaining -= chunk as u64;
|
||||||
|
}
|
||||||
|
let _ = f.flush();
|
||||||
|
let _ = f.sync_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fs::remove_file(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all traffic stats (used when clearing cache), securely erasing each
|
||||||
|
/// file first.
|
||||||
pub fn clear_all_traffic_stats() -> Result<(), Box<dyn std::error::Error>> {
|
pub fn clear_all_traffic_stats() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let storage_dir = get_traffic_stats_dir();
|
let storage_dir = get_traffic_stats_dir();
|
||||||
|
|
||||||
if storage_dir.exists() {
|
if storage_dir.exists() {
|
||||||
for entry in fs::read_dir(&storage_dir)?.flatten() {
|
for entry in fs::read_dir(&storage_dir)?.flatten() {
|
||||||
let path = entry.path();
|
let path = entry.path();
|
||||||
if path.extension().is_some_and(|ext| ext == "json") {
|
// Wipe the live stats files and any temp orphan left by an interrupted
|
||||||
let _ = fs::remove_file(&path);
|
// atomic save, which still holds a copy of the history. This directory
|
||||||
|
// holds nothing but traffic stats, so matching every `.json`/`.tmp` also
|
||||||
|
// catches orphans from older temp-naming schemes.
|
||||||
|
let is_stats = path
|
||||||
|
.extension()
|
||||||
|
.is_some_and(|ext| ext == "json" || ext == "tmp");
|
||||||
|
if is_stats {
|
||||||
|
let _ = secure_remove_file(&path);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -584,8 +656,10 @@ impl LiveTrafficTracker {
|
|||||||
fs::create_dir_all(&storage_dir)?;
|
fs::create_dir_all(&storage_dir)?;
|
||||||
let session_file = storage_dir.join(format!("{}.session.json", storage_key));
|
let session_file = storage_dir.join(format!("{}.session.json", storage_key));
|
||||||
|
|
||||||
// Write atomically using a temp file
|
// Write atomically using a temp file. Named by suffix rather than
|
||||||
let temp_file = session_file.with_extension("tmp");
|
// `with_extension`, which would replace `.json` and yield
|
||||||
|
// `{key}.session.tmp` — a name the cleanup sweeps did not recognise.
|
||||||
|
let temp_file = storage_dir.join(format!("{}.session.json.tmp", storage_key));
|
||||||
let content = serde_json::to_string(&snapshot)?;
|
let content = serde_json::to_string(&snapshot)?;
|
||||||
fs::write(&temp_file, content)?;
|
fs::write(&temp_file, content)?;
|
||||||
fs::rename(&temp_file, &session_file)?;
|
fs::rename(&temp_file, &session_file)?;
|
||||||
@@ -1035,14 +1109,14 @@ fn load_session_snapshot(profile_id: &str) -> Option<SessionSnapshot> {
|
|||||||
pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> {
|
pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> {
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
// Start with disk-stored stats
|
// Start with disk-stored stats, keeping last_flush_timestamp per key so the
|
||||||
let mut snapshots: HashMap<String, TrafficSnapshot> = list_traffic_stats()
|
// session-snapshot merge below doesn't need to re-read the files
|
||||||
.into_iter()
|
let mut snapshots: HashMap<String, TrafficSnapshot> = HashMap::new();
|
||||||
.map(|s| {
|
let mut last_flush_by_key: HashMap<String, u64> = HashMap::new();
|
||||||
let key = s.profile_id.clone().unwrap_or_else(|| s.proxy_id.clone());
|
for (key, s) in collect_traffic_stats() {
|
||||||
(key, s.to_snapshot())
|
last_flush_by_key.insert(key.clone(), s.last_flush_timestamp);
|
||||||
})
|
snapshots.insert(key, s.to_snapshot());
|
||||||
.collect();
|
}
|
||||||
|
|
||||||
// Try to merge in real-time data from active tracker (if in same process)
|
// Try to merge in real-time data from active tracker (if in same process)
|
||||||
if let Some(tracker) = get_traffic_tracker() {
|
if let Some(tracker) = get_traffic_tracker() {
|
||||||
@@ -1068,11 +1142,7 @@ pub fn get_all_traffic_snapshots_realtime() -> Vec<TrafficSnapshot> {
|
|||||||
// Only merge session data if it's newer than the last flush
|
// Only merge session data if it's newer than the last flush
|
||||||
// Session snapshots written before the last flush contain bytes already
|
// Session snapshots written before the last flush contain bytes already
|
||||||
// included in disk totals, so merging them would cause double-counting
|
// included in disk totals, so merging them would cause double-counting
|
||||||
let disk_stats = load_traffic_stats(profile_id);
|
let last_flush = last_flush_by_key.get(profile_id).copied().unwrap_or(0);
|
||||||
let last_flush = disk_stats
|
|
||||||
.as_ref()
|
|
||||||
.map(|s| s.last_flush_timestamp)
|
|
||||||
.unwrap_or(0);
|
|
||||||
|
|
||||||
if session.timestamp > last_flush {
|
if session.timestamp > last_flush {
|
||||||
// Session data contains in-memory counters not yet flushed to disk
|
// Session data contains in-memory counters not yet flushed to disk
|
||||||
|
|||||||
@@ -1158,7 +1158,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_key_valid() {
|
fn test_parse_key_valid() {
|
||||||
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
|
let key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
assert!(parse_key(key).is_ok());
|
assert!(parse_key(key).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -349,12 +349,11 @@ mod tests {
|
|||||||
|
|
||||||
fn create_test_config() -> WireGuardConfig {
|
fn create_test_config() -> WireGuardConfig {
|
||||||
WireGuardConfig {
|
WireGuardConfig {
|
||||||
// These are test keys, not real ones
|
private_key: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=".to_string(),
|
||||||
private_key: "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=".to_string(),
|
|
||||||
address: "10.0.0.2/24".to_string(),
|
address: "10.0.0.2/24".to_string(),
|
||||||
dns: Some("1.1.1.1".to_string()),
|
dns: Some("1.1.1.1".to_string()),
|
||||||
mtu: Some(1420),
|
mtu: Some(1420),
|
||||||
peer_public_key: "aGnF7JlG+U5t0BqB1PVf1yOuELHrWLGGcUJb0eCK9Aw=".to_string(),
|
peer_public_key: "AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQE=".to_string(),
|
||||||
peer_endpoint: "127.0.0.1:51820".to_string(),
|
peer_endpoint: "127.0.0.1:51820".to_string(),
|
||||||
allowed_ips: vec!["0.0.0.0/0".to_string()],
|
allowed_ips: vec!["0.0.0.0/0".to_string()],
|
||||||
persistent_keepalive: Some(25),
|
persistent_keepalive: Some(25),
|
||||||
@@ -375,8 +374,7 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_parse_key_valid() {
|
fn test_parse_key_valid() {
|
||||||
// Valid base64-encoded 32-byte key
|
let key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=";
|
||||||
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
|
|
||||||
let result = WireGuardTunnel::parse_key(key);
|
let result = WireGuardTunnel::parse_key(key);
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert_eq!(result.unwrap().len(), 32);
|
assert_eq!(result.unwrap().len(), 32);
|
||||||
|
|||||||
@@ -100,6 +100,8 @@ async fn wait_for_vpn_worker_ready(
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
|
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
|
||||||
|
crate::proxy_runner::ensure_sidecar_version().await?;
|
||||||
|
|
||||||
for config in list_vpn_worker_configs() {
|
for config in list_vpn_worker_configs() {
|
||||||
if let Some(pid) = config.pid {
|
if let Some(pid) = config.pid {
|
||||||
if !is_process_running(pid) {
|
if !is_process_running(pid) {
|
||||||
|
|||||||
@@ -910,11 +910,6 @@ impl WayfernManager {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let Some(ref token) = wayfern_token {
|
|
||||||
args.push(format!("--wayfern-token={token}"));
|
|
||||||
log::info!("Wayfern token passed as CLI flag (length: {})", token.len());
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Some(proxy) = proxy_url {
|
if let Some(proxy) = proxy_url {
|
||||||
// Map the local proxy scheme to the matching PAC directive. SOCKS5 lets
|
// Map the local proxy scheme to the matching PAC directive. SOCKS5 lets
|
||||||
// Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy;
|
// Chromium route UDP (QUIC/WebRTC) and resolve DNS through the proxy;
|
||||||
@@ -942,6 +937,10 @@ impl WayfernManager {
|
|||||||
.stdin(Stdio::null())
|
.stdin(Stdio::null())
|
||||||
.stdout(Stdio::null())
|
.stdout(Stdio::null())
|
||||||
.stderr(Stdio::null());
|
.stderr(Stdio::null());
|
||||||
|
if let Some(ref token) = wayfern_token {
|
||||||
|
command.env("WAYFERN_TOKEN", token);
|
||||||
|
log::info!("Wayfern authorization configured for browser process");
|
||||||
|
}
|
||||||
|
|
||||||
let child = command
|
let child = command
|
||||||
.spawn()
|
.spawn()
|
||||||
@@ -1040,16 +1039,13 @@ impl WayfernManager {
|
|||||||
|
|
||||||
for target in &page_targets {
|
for target in &page_targets {
|
||||||
if let Some(ws_url) = &target.websocket_debugger_url {
|
if let Some(ws_url) = &target.websocket_debugger_url {
|
||||||
log::info!("Applying fingerprint to target via WebSocket: {}", ws_url);
|
log::info!("Applying fingerprint to page target");
|
||||||
match self
|
match self
|
||||||
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
|
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(result) => {
|
Ok(result) => {
|
||||||
log::info!(
|
log::info!("Successfully applied fingerprint to page target");
|
||||||
"Successfully applied fingerprint to page target: {:?}",
|
|
||||||
result
|
|
||||||
);
|
|
||||||
// Wayfern.setFingerprint echoes back the fingerprint it actually
|
// Wayfern.setFingerprint echoes back the fingerprint it actually
|
||||||
// used, which may be UPGRADED from what we sent (e.g. when the
|
// used, which may be UPGRADED from what we sent (e.g. when the
|
||||||
// stored fingerprint targets an older browser version). Capture
|
// stored fingerprint targets an older browser version). Capture
|
||||||
@@ -1080,7 +1076,7 @@ impl WayfernManager {
|
|||||||
// Geolocation is handled internally by the browser binary.
|
// Geolocation is handled internally by the browser binary.
|
||||||
|
|
||||||
if let Some(url) = url {
|
if let Some(url) = url {
|
||||||
log::info!("Navigating to URL via CDP: {}", url);
|
log::info!("Navigating to URL via CDP");
|
||||||
if let Some(target) = page_targets.first() {
|
if let Some(target) = page_targets.first() {
|
||||||
if let Some(ws_url) = &target.websocket_debugger_url {
|
if let Some(ws_url) = &target.websocket_debugger_url {
|
||||||
if let Err(e) = self
|
if let Err(e) = self
|
||||||
@@ -1212,7 +1208,7 @@ impl WayfernManager {
|
|||||||
return Err(format!("CDP /json/new returned HTTP {}", resp.status()).into());
|
return Err(format!("CDP /json/new returned HTTP {}", resp.status()).into());
|
||||||
}
|
}
|
||||||
|
|
||||||
log::info!("Opened URL in new tab via CDP: {}", url);
|
log::info!("Opened URL in new tab via CDP");
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,29 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Donut",
|
"productName": "Donut",
|
||||||
"version": "0.28.1",
|
"version": "0.28.2",
|
||||||
"identifier": "com.donutbrowser",
|
"identifier": "com.donutbrowser",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||||
"devUrl": "http://localhost:12341",
|
"devUrl": "http://localhost:12341",
|
||||||
"beforeBuildCommand": "pnpm copy-proxy-binary && (test -d ../dist || pnpm build)",
|
"beforeBuildCommand": "pnpm copy-proxy-binary:release && (test -d ../dist || pnpm build)",
|
||||||
"frontendDist": "../dist"
|
"frontendDist": "../dist"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
"windows": [],
|
"windows": [],
|
||||||
"security": {
|
"security": {
|
||||||
"csp": null
|
"csp": {
|
||||||
|
"default-src": "'self' customprotocol: asset:",
|
||||||
|
"connect-src": "ipc: http://ipc.localhost http: https: ws: wss:",
|
||||||
|
"font-src": "'self' data:",
|
||||||
|
"img-src": "'self' asset: http://asset.localhost blob: data:",
|
||||||
|
"style-src": "'self' 'unsafe-inline'",
|
||||||
|
"script-src": "'self'",
|
||||||
|
"object-src": "'none'",
|
||||||
|
"base-uri": "'none'",
|
||||||
|
"form-action": "'none'",
|
||||||
|
"frame-ancestors": "'none'"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"bundle": {
|
"bundle": {
|
||||||
@@ -35,9 +46,7 @@
|
|||||||
"signingIdentity": null,
|
"signingIdentity": null,
|
||||||
"providerShortName": null,
|
"providerShortName": null,
|
||||||
"entitlements": "entitlements.plist",
|
"entitlements": "entitlements.plist",
|
||||||
"files": {
|
"infoPlist": "Info.plist"
|
||||||
"Info.plist": "Info.plist"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"linux": {
|
"linux": {
|
||||||
"deb": {
|
"deb": {
|
||||||
@@ -57,7 +66,10 @@
|
|||||||
"windows": {
|
"windows": {
|
||||||
"certificateThumbprint": null,
|
"certificateThumbprint": null,
|
||||||
"digestAlgorithm": "sha256",
|
"digestAlgorithm": "sha256",
|
||||||
"timestampUrl": ""
|
"timestampUrl": "",
|
||||||
|
"nsis": {
|
||||||
|
"installerHooks": "installer-hooks.nsh"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"plugins": {
|
"plugins": {
|
||||||
|
|||||||
@@ -66,7 +66,17 @@ async fn setup_test() -> Result<std::path::PathBuf, Box<dyn std::error::Error +
|
|||||||
.join("debug")
|
.join("debug")
|
||||||
.join(proxy_binary_name);
|
.join(proxy_binary_name);
|
||||||
|
|
||||||
if !proxy_binary.exists() {
|
let binary_is_current = proxy_binary.exists()
|
||||||
|
&& std::process::Command::new(&proxy_binary)
|
||||||
|
.arg("--version")
|
||||||
|
.output()
|
||||||
|
.is_ok_and(|output| {
|
||||||
|
output.status.success()
|
||||||
|
&& String::from_utf8_lossy(&output.stdout).trim()
|
||||||
|
== format!("donut-proxy {}", env!("BUILD_VERSION"))
|
||||||
|
});
|
||||||
|
|
||||||
|
if !binary_is_current {
|
||||||
println!("Building donut-proxy binary for integration tests...");
|
println!("Building donut-proxy binary for integration tests...");
|
||||||
let build_status = std::process::Command::new("cargo")
|
let build_status = std::process::Command::new("cargo")
|
||||||
.args(["build", "--bin", "donut-proxy"])
|
.args(["build", "--bin", "donut-proxy"])
|
||||||
@@ -127,6 +137,25 @@ impl Drop for ProxyTestTracker {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
#[serial]
|
||||||
|
async fn test_sidecar_reports_build_version() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||||
|
{
|
||||||
|
let binary_path = setup_test().await?;
|
||||||
|
let output = TestUtils::execute_command(&binary_path, &["--version"]).await?;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
output.status.success(),
|
||||||
|
"donut-proxy --version failed: {}",
|
||||||
|
String::from_utf8_lossy(&output.stderr)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
String::from_utf8(output.stdout)?.trim(),
|
||||||
|
format!("donut-proxy {}", env!("BUILD_VERSION"))
|
||||||
|
);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Test starting a local proxy without upstream proxy (DIRECT)
|
/// Test starting a local proxy without upstream proxy (DIRECT)
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
#[serial]
|
#[serial]
|
||||||
@@ -1360,9 +1389,8 @@ async fn test_local_proxy_with_shadowsocks_upstream(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start donut-proxy with Shadowsocks upstream
|
// Start donut-proxy with Shadowsocks upstream
|
||||||
let output = TestUtils::execute_command(
|
let output = tokio::process::Command::new(&binary_path)
|
||||||
&binary_path,
|
.args([
|
||||||
&[
|
|
||||||
"proxy",
|
"proxy",
|
||||||
"start",
|
"start",
|
||||||
"--host",
|
"--host",
|
||||||
@@ -1371,13 +1399,11 @@ async fn test_local_proxy_with_shadowsocks_upstream(
|
|||||||
&ss_port.to_string(),
|
&ss_port.to_string(),
|
||||||
"--type",
|
"--type",
|
||||||
"ss",
|
"ss",
|
||||||
"--username",
|
])
|
||||||
ss_method,
|
.env("DONUT_PROXY_USERNAME", ss_method)
|
||||||
"--password",
|
.env("DONUT_PROXY_PASSWORD", ss_password)
|
||||||
ss_password,
|
.output()
|
||||||
],
|
.await?;
|
||||||
)
|
|
||||||
.await?;
|
|
||||||
|
|
||||||
if !output.status.success() {
|
if !output.status.success() {
|
||||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user