mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-11 05:30:29 +02:00
Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b1c4559b74 | ||
|
|
5afde36790 | ||
|
|
325d8fae31 | ||
|
|
929f5a0ead | ||
|
|
32a1728dee | ||
|
|
a6b79341b3 | ||
|
|
a6b4108d82 | ||
|
|
11b130df46 | ||
|
|
b8e5b4f4e6 | ||
|
|
d80e127cd3 | ||
|
|
e11967509d | ||
|
|
6d9a44faad | ||
|
|
f8532be8af | ||
|
|
70a8deb7eb | ||
|
|
b89f002c1d | ||
|
|
3b1feb3f1b | ||
|
|
bc2b93d902 | ||
|
|
5c24e84eaf | ||
|
|
ffbbaa732a | ||
|
|
f12a84e18f | ||
|
|
39bbdcb547 | ||
|
|
29cb83d063 |
@@ -30,4 +30,9 @@ messages:
|
||||
### Documentation
|
||||
### Dependencies
|
||||
### Developer Experience
|
||||
model: openai/gpt-4.1
|
||||
# `auto` lets the Copilot CLI pick. Deliberately not a pinned model id: it is
|
||||
# the only value valid on every Copilot plan (Free and Student get auto
|
||||
# selection only), and it cannot go stale the way `openai/gpt-4.1` did when
|
||||
# GitHub Models was retired on 2026-07-30 and took both of these workflows
|
||||
# down with it.
|
||||
model: auto
|
||||
|
||||
@@ -20,4 +20,9 @@ messages:
|
||||
{{commits}}
|
||||
|
||||
Format: one short opening sentence, a blank line, then bullets starting with "- " (one per line). Nothing else.
|
||||
model: openai/gpt-4.1
|
||||
# `auto` lets the Copilot CLI pick. Deliberately not a pinned model id: it is
|
||||
# the only value valid on every Copilot plan (Free and Student get auto
|
||||
# selection only), and it cannot go stale the way `openai/gpt-4.1` did when
|
||||
# GitHub Models was retired on 2026-07-30 and took both of these workflows
|
||||
# down with it.
|
||||
model: auto
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -96,7 +96,8 @@ jobs:
|
||||
-d "$PAYLOAD" || echo "000")
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "::warning::GitHub Models returned HTTP $STATUS; treating as compliant"
|
||||
echo "::error::GitHub Models returned HTTP $STATUS; treating as compliant"
|
||||
printf '%s\n' "inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
|
||||
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
|
||||
exit 0
|
||||
fi
|
||||
@@ -107,7 +108,8 @@ jobs:
|
||||
# to a compliant result so a flaky model never closes a legitimate issue.
|
||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||
echo "::warning::Model returned non-JSON; treating as compliant"
|
||||
echo "::error::Model returned non-JSON; treating as compliant"
|
||||
printf '%s\n' "model returned output that was not JSON" >> /tmp/ai-degraded
|
||||
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
|
||||
fi
|
||||
echo "Compliance response validated"
|
||||
@@ -145,3 +147,16 @@ jobs:
|
||||
run: |
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
|
||||
gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --reason "not planned"
|
||||
|
||||
# The steps above deliberately degrade rather than block: an inference
|
||||
# outage must never close a contributor's issue or flag their pull
|
||||
# request. But a run that skipped the check it exists to perform has not
|
||||
# succeeded, and reporting green hides that the automation is dead.
|
||||
- name: Fail if the AI check did not actually run
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ai-degraded ]; then
|
||||
echo "::error::This check degraded to a no-op and its result was not verified:"
|
||||
sed 's/^/ - /' /tmp/ai-degraded
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -257,7 +257,8 @@ jobs:
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
jq -r '.choices[0].message.content // empty' /tmp/triage-response.json > /tmp/triage-raw.txt || : > /tmp/triage-raw.txt
|
||||
else
|
||||
echo "::warning::GitHub Models returned HTTP $STATUS for triage"
|
||||
echo "::error::GitHub Models returned HTTP $STATUS for triage"
|
||||
printf '%s\n' "triage inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
|
||||
: > /tmp/triage-raw.txt
|
||||
fi
|
||||
|
||||
@@ -266,7 +267,8 @@ jobs:
|
||||
|
||||
# Fall back to a safe classification when the response is not JSON.
|
||||
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
|
||||
echo "::warning::Triage returned non-JSON; using fallback classification"
|
||||
echo "::error::Triage returned non-JSON; using fallback classification"
|
||||
printf '%s\n' "triage returned output that was not JSON" >> /tmp/ai-degraded
|
||||
jq -n '{
|
||||
language: "en",
|
||||
classification: "bug-in-scope",
|
||||
@@ -436,7 +438,8 @@ jobs:
|
||||
-d "$PAYLOAD" || echo "000")
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "::warning::GitHub Models returned HTTP $STATUS; skipping the triage comment"
|
||||
echo "::error::GitHub Models returned HTTP $STATUS; skipping the triage comment"
|
||||
printf '%s\n' "composer inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
|
||||
echo "has_comment=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -444,7 +447,8 @@ jobs:
|
||||
jq -r '.choices[0].message.content // empty' /tmp/compose-response.json > /tmp/ai-comment.txt || : > /tmp/ai-comment.txt
|
||||
|
||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||
echo "::warning::Composer returned empty response; skipping the triage comment"
|
||||
echo "::error::Composer returned empty response; skipping the triage comment"
|
||||
printf '%s\n' "composer returned an empty response" >> /tmp/ai-degraded
|
||||
echo "has_comment=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -482,6 +486,20 @@ jobs:
|
||||
run: |
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
|
||||
|
||||
|
||||
# The steps above deliberately degrade rather than block: an inference
|
||||
# outage must never close a contributor's issue or flag their pull
|
||||
# request. But a run that skipped the check it exists to perform has not
|
||||
# succeeded, and reporting green hides that the automation is dead.
|
||||
- name: Fail if the AI check did not actually run
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ai-degraded ]; then
|
||||
echo "::error::This check degraded to a no-op and its result was not verified:"
|
||||
sed 's/^/ - /' /tmp/ai-degraded
|
||||
exit 1
|
||||
fi
|
||||
|
||||
analyze-pr:
|
||||
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -619,7 +637,8 @@ jobs:
|
||||
-d "$PAYLOAD" || echo "000")
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "::warning::GitHub Models returned HTTP $STATUS; skipping the review comment"
|
||||
echo "::error::GitHub Models returned HTTP $STATUS; skipping the review comment"
|
||||
printf '%s\n' "PR review inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
|
||||
echo "has_comment=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -627,7 +646,8 @@ jobs:
|
||||
jq -r '.choices[0].message.content // empty' /tmp/pr-response.json > /tmp/ai-comment.txt || : > /tmp/ai-comment.txt
|
||||
|
||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||
echo "::warning::AI response was empty; skipping the review comment"
|
||||
echo "::error::AI response was empty; skipping the review comment"
|
||||
printf '%s\n' "PR review returned an empty response" >> /tmp/ai-degraded
|
||||
echo "has_comment=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -642,6 +662,20 @@ jobs:
|
||||
run: |
|
||||
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
|
||||
|
||||
|
||||
# The steps above deliberately degrade rather than block: an inference
|
||||
# outage must never close a contributor's issue or flag their pull
|
||||
# request. But a run that skipped the check it exists to perform has not
|
||||
# succeeded, and reporting green hides that the automation is dead.
|
||||
- name: Fail if the AI check did not actually run
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ai-degraded ]; then
|
||||
echo "::error::This check degraded to a no-op and its result was not verified:"
|
||||
sed 's/^/ - /' /tmp/ai-degraded
|
||||
exit 1
|
||||
fi
|
||||
|
||||
opencode-command:
|
||||
if: |
|
||||
github.repository == 'zhom/donutbrowser' &&
|
||||
@@ -659,7 +693,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@4da7bb44c84e013fa53e9c5d02ac753d1435c81a #v1.18.9
|
||||
uses: anomalyco/opencode/github@65cf14df16c191f3e9684f0d9a8bae69103ced6d #v1.18.14
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
models: read
|
||||
copilot-requests: write
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
@@ -123,17 +123,27 @@ jobs:
|
||||
echo "previous-tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "Collected $(wc -l < commits.txt) commits between ${PREV_TAG} and ${TAG}."
|
||||
|
||||
# The Copilot CLI is not preinstalled on GitHub-hosted runners, and
|
||||
# ai-inference v3 shells out to it.
|
||||
- name: Install Copilot CLI
|
||||
if: steps.gate.outputs.skip != 'true'
|
||||
run: npm install -g @github/copilot
|
||||
|
||||
- name: Generate summary with AI
|
||||
id: ai
|
||||
if: steps.gate.outputs.skip != 'true'
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3
|
||||
with:
|
||||
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
|
||||
input: |
|
||||
version: ${{ steps.tag.outputs.tag }}
|
||||
file_input: |
|
||||
commits: ./commits.txt
|
||||
max-tokens: 1024
|
||||
env:
|
||||
# The Copilot CLI reads its credential from the environment; the
|
||||
# workflow token carries it under the `copilot-requests` permission
|
||||
# granted above, so no PAT is needed.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release announcement to Telegram
|
||||
if: steps.gate.outputs.skip != 'true'
|
||||
|
||||
@@ -134,7 +134,8 @@ jobs:
|
||||
-d "$PAYLOAD" || echo "000")
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "::warning::GitHub Models returned HTTP $STATUS; treating as compliant"
|
||||
echo "::error::GitHub Models returned HTTP $STATUS; treating as compliant"
|
||||
printf '%s\n' "inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
|
||||
echo '{"compliant": true, "violations": []}' > /tmp/result.json
|
||||
exit 0
|
||||
fi
|
||||
@@ -146,7 +147,8 @@ jobs:
|
||||
# The deterministic trailer scan still stands on its own below.
|
||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||
echo "::warning::Model returned non-JSON; treating as compliant"
|
||||
echo "::error::Model returned non-JSON; treating as compliant"
|
||||
printf '%s\n' "model returned output that was not JSON" >> /tmp/ai-degraded
|
||||
echo '{"compliant": true, "violations": []}' > /tmp/result.json
|
||||
fi
|
||||
echo "Policy response validated"
|
||||
@@ -205,3 +207,16 @@ jobs:
|
||||
run: |
|
||||
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
|
||||
gh pr close "$PR_NUMBER" --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# The steps above deliberately degrade rather than block: an inference
|
||||
# outage must never close a contributor's issue or flag their pull
|
||||
# request. But a run that skipped the check it exists to perform has not
|
||||
# succeeded, and reporting green hides that the automation is dead.
|
||||
- name: Fail if the AI check did not actually run
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ai-degraded ]; then
|
||||
echo "::error::This check degraded to a no-op and its result was not verified:"
|
||||
sed 's/^/ - /' /tmp/ai-degraded
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
models: read
|
||||
copilot-requests: write
|
||||
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
@@ -79,17 +79,27 @@ jobs:
|
||||
echo "commits-file=commits.txt" >> $GITHUB_OUTPUT
|
||||
echo "changes-file=changes.txt" >> $GITHUB_OUTPUT
|
||||
|
||||
# The Copilot CLI is not preinstalled on GitHub-hosted runners, and
|
||||
# ai-inference v3 shells out to it.
|
||||
- name: Install Copilot CLI
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
run: npm install -g @github/copilot
|
||||
|
||||
- name: Generate release notes with AI
|
||||
id: generate-notes
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3
|
||||
with:
|
||||
prompt-file: .github/prompts/release-notes.prompt.yml
|
||||
input: |
|
||||
version: ${{ steps.get-previous-tag.outputs.current-tag }}
|
||||
file_input: |
|
||||
commits: ./commits.txt
|
||||
max-tokens: 4096
|
||||
env:
|
||||
# The Copilot CLI reads its credential from the environment; the
|
||||
# workflow token carries it under the `copilot-requests` permission
|
||||
# granted above, so no PAT is needed.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update release with generated notes
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 #v1.48.0
|
||||
uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f #v1.49.0
|
||||
|
||||
@@ -64,3 +64,10 @@ nodecar/nodecar-bin
|
||||
|
||||
# claude
|
||||
.claude/
|
||||
# Claude Code session-recovery runtime state
|
||||
HANDOFF.md
|
||||
.claude/settings.local.json
|
||||
.claude/rate-limit-state.json
|
||||
.claude/stop-failure-events.jsonl
|
||||
.claude/quota-blocked.json
|
||||
session-recover.yaml
|
||||
|
||||
+151
@@ -1,6 +1,157 @@
|
||||
# Changelog
|
||||
|
||||
|
||||
## v0.29.1 (2026-08-08)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- prevent settings page from crashing on some systems
|
||||
|
||||
### Refactoring
|
||||
|
||||
- update logic and locks around vpn extensions
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: linting
|
||||
- chore: update pnpm
|
||||
- chore: switch to ai-inference v3 and fail workflows on 410
|
||||
- chore: version bump
|
||||
- chore: update flake.nix for v0.29.0 [skip ci] (#542)
|
||||
|
||||
|
||||
## v0.29.0 (2026-08-08)
|
||||
|
||||
### Features
|
||||
|
||||
- prevent launch with inconsistent geodata
|
||||
- cookie bot
|
||||
- remote sessions
|
||||
- xray support
|
||||
- mass import via gui, api, and mcp
|
||||
- add Turkish (tr) language support
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- properly handle x-amz-meta-updated-at
|
||||
- improve UI interactions and page consistency
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cleanup
|
||||
- cleanup
|
||||
- improve proxy lifetime management
|
||||
- cleanup
|
||||
- remote cleanup
|
||||
- cleanup cloud sync
|
||||
- cleanup
|
||||
- harden tests
|
||||
- block windows app update if the browser is running
|
||||
- ui refresh
|
||||
|
||||
### Documentation
|
||||
|
||||
- update CHANGELOG.md and README.md for v0.29.0 [skip ci] (#539)
|
||||
- contrib-readme-action has updated readme
|
||||
- contrib-readme-action has updated readme
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- ci(deps): bump the github-actions group with 3 updates (#538)
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: ci
|
||||
- chore: upload sidecars to cdn
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group with 4 updates
|
||||
- chore: linting
|
||||
- chore: disable e2e in ci
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: ai compliance
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group across 1 directory with 3 updates (#514)
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: add cross-platform webdriver tests
|
||||
- ci(deps): bump the github-actions group with 2 updates
|
||||
- chore: update flake.nix for v0.28.2 [skip ci] (#501)
|
||||
|
||||
### Other
|
||||
|
||||
- deps(deps): bump next from 16.2.10 to 16.2.11 (#515)
|
||||
- refactors: animations cleanup
|
||||
- restore settings redirect
|
||||
- fix group create translation key
|
||||
|
||||
|
||||
## v0.29.0 (2026-08-08)
|
||||
|
||||
### Features
|
||||
|
||||
- prevent launch with inconsistent geodata
|
||||
- cookie bot
|
||||
- remote sessions
|
||||
- xray support
|
||||
- mass import via gui, api, and mcp
|
||||
- add Turkish (tr) language support
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- properly handle x-amz-meta-updated-at
|
||||
- improve UI interactions and page consistency
|
||||
|
||||
### Refactoring
|
||||
|
||||
- cleanup
|
||||
- cleanup
|
||||
- improve proxy lifetime management
|
||||
- cleanup
|
||||
- remote cleanup
|
||||
- cleanup cloud sync
|
||||
- cleanup
|
||||
- harden tests
|
||||
- block windows app update if the browser is running
|
||||
- ui refresh
|
||||
|
||||
### Documentation
|
||||
|
||||
- contrib-readme-action has updated readme
|
||||
- contrib-readme-action has updated readme
|
||||
|
||||
### Maintenance
|
||||
|
||||
- ci(deps): bump the github-actions group with 3 updates (#538)
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: ci
|
||||
- chore: upload sidecars to cdn
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group with 4 updates
|
||||
- chore: linting
|
||||
- chore: disable e2e in ci
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: ai compliance
|
||||
- chore: linting
|
||||
- ci(deps): bump the github-actions group across 1 directory with 3 updates (#514)
|
||||
- chore: linting
|
||||
- chore: linting
|
||||
- chore: add cross-platform webdriver tests
|
||||
- ci(deps): bump the github-actions group with 2 updates
|
||||
- chore: update flake.nix for v0.28.2 [skip ci] (#501)
|
||||
|
||||
### Other
|
||||
|
||||
- deps(deps): bump next from 16.2.10 to 16.2.11 (#515)
|
||||
- refactors: animations cleanup
|
||||
- restore settings redirect
|
||||
- fix group create translation key
|
||||
|
||||
|
||||
## v0.28.2 (2026-07-12)
|
||||
|
||||
### Features
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **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) |
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
@@ -56,15 +56,15 @@ brew install --cask donut
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.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)
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut-0.28.2-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage) |
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut-0.29.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut-0.29.1-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
|
||||
@@ -12,3 +12,7 @@ extend-exclude = [
|
||||
[default.extend-words]
|
||||
DBE = "DBE"
|
||||
nd = "nd"
|
||||
|
||||
[default.extend-identifiers]
|
||||
# Chrome Web Store extension name in the known-VPN list.
|
||||
VeePN = "VeePN"
|
||||
|
||||
@@ -13,3 +13,12 @@ S3_ACCESS_KEY_ID=CHANGE_ME
|
||||
S3_SECRET_ACCESS_KEY=CHANGE_ME
|
||||
S3_BUCKET=donut-sync
|
||||
S3_FORCE_PATH_STYLE=true
|
||||
|
||||
# The address Donut Browser is sent to for file transfers. Set this whenever
|
||||
# S3_ENDPOINT is only reachable from the server — running MinIO in the same
|
||||
# compose file makes S3_ENDPOINT a container name like http://minio:9000, which
|
||||
# resolves on the container network and nowhere else. Presigned URLs are signed
|
||||
# against the host they name, so leaving this unset there hands every client a
|
||||
# URL it cannot open: /health and /readyz stay green while every transfer fails.
|
||||
# Defaults to S3_ENDPOINT, which is correct when storage is already public.
|
||||
# S3_PUBLIC_ENDPOINT=https://storage.example.com
|
||||
|
||||
@@ -19,15 +19,25 @@ export class AppController {
|
||||
return { status: "ok" };
|
||||
}
|
||||
|
||||
// `storageEndpoint` is the host clients are handed in presigned URLs. The
|
||||
// server cannot tell whether a client can reach it, so report it and let
|
||||
// whoever is debugging a failing sync compare it against their network.
|
||||
// Self-hosted only — see getDiagnosticStorageEndpoint.
|
||||
@Get("readyz")
|
||||
async getReadiness(): Promise<{ status: string; s3: boolean }> {
|
||||
async getReadiness(): Promise<{
|
||||
status: string;
|
||||
s3: boolean;
|
||||
storageEndpoint?: string;
|
||||
}> {
|
||||
const s3Ready = await this.syncService.checkS3Connectivity();
|
||||
const storageEndpoint = this.syncService.getDiagnosticStorageEndpoint();
|
||||
const diagnostic = storageEndpoint ? { storageEndpoint } : {};
|
||||
if (!s3Ready) {
|
||||
throw new HttpException(
|
||||
{ status: "not ready", s3: false },
|
||||
{ status: "not ready", s3: false, ...diagnostic },
|
||||
HttpStatus.SERVICE_UNAVAILABLE,
|
||||
);
|
||||
}
|
||||
return { status: "ready", s3: true };
|
||||
return { status: "ready", s3: true, ...diagnostic };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,6 +82,10 @@ function sanitizeMetadata(
|
||||
export class SyncService implements OnModuleInit {
|
||||
private readonly logger = new Logger(SyncService.name);
|
||||
private s3Client: S3Client;
|
||||
// Signs the URLs handed to clients. Same instance as `s3Client` unless
|
||||
// `S3_PUBLIC_ENDPOINT` names a different, client-reachable address.
|
||||
private presignClient: S3Client;
|
||||
private publicEndpoint: string;
|
||||
private bucket: string;
|
||||
// Upper bound on presign batch array length (DoS guard).
|
||||
private static readonly MAX_BATCH_ITEMS = 1000;
|
||||
@@ -112,16 +116,34 @@ export class SyncService implements OnModuleInit {
|
||||
|
||||
this.bucket = requireEnv("S3_BUCKET");
|
||||
|
||||
const credentials = { accessKeyId, secretAccessKey };
|
||||
this.s3Client = new S3Client({
|
||||
endpoint,
|
||||
region,
|
||||
credentials: {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
},
|
||||
credentials,
|
||||
forcePathStyle,
|
||||
});
|
||||
|
||||
// Presigned URLs are handed to a desktop client on another machine, so they
|
||||
// must name a host that client can reach. `S3_ENDPOINT` is often reachable
|
||||
// only from the server: the documented compose file points it at
|
||||
// `http://minio:9000`, a Docker service name that resolves on the compose
|
||||
// network and nowhere else. Signing is bound to the host, so the presign
|
||||
// client is a second client pinned to the public address rather than a
|
||||
// string rewrite of the signed URL.
|
||||
const publicEndpoint =
|
||||
this.configService.get<string>("S3_PUBLIC_ENDPOINT") || endpoint;
|
||||
this.publicEndpoint = publicEndpoint;
|
||||
this.presignClient =
|
||||
publicEndpoint === endpoint
|
||||
? this.s3Client
|
||||
: new S3Client({
|
||||
endpoint: publicEndpoint,
|
||||
region,
|
||||
credentials,
|
||||
forcePathStyle,
|
||||
});
|
||||
|
||||
this.backendInternalUrl = this.configService.get<string>(
|
||||
"BACKEND_INTERNAL_URL",
|
||||
);
|
||||
@@ -132,6 +154,51 @@ export class SyncService implements OnModuleInit {
|
||||
|
||||
async onModuleInit() {
|
||||
await this.ensureBucketExists();
|
||||
this.warnIfPresignEndpointIsServerOnly();
|
||||
}
|
||||
|
||||
/**
|
||||
* The address clients are sent to for object transfers, for `/readyz` to
|
||||
* report when a self-hoster is debugging a failing sync.
|
||||
*
|
||||
* Withheld in cloud mode: `/readyz` is unauthenticated, and a managed
|
||||
* deployment should not publish its storage host to anyone who can reach the
|
||||
* probe. Self-hosters own both ends, and the value is the whole point of the
|
||||
* diagnostic there.
|
||||
*/
|
||||
getDiagnosticStorageEndpoint(): string | undefined {
|
||||
const isCloud = Boolean(
|
||||
this.configService.get<string>("SYNC_JWT_PUBLIC_KEY"),
|
||||
);
|
||||
return isCloud ? undefined : this.publicEndpoint;
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-label host (`minio`, `s3`) only resolves inside the container
|
||||
* network, so every presigned URL built from it is unreachable for the
|
||||
* desktop client even though the server's own S3 calls succeed. That failure
|
||||
* shows up as healthy `/health` and `/readyz` with every file transfer
|
||||
* failing at connect, which is near-impossible to diagnose from the client.
|
||||
* Say it once at boot instead.
|
||||
*/
|
||||
private warnIfPresignEndpointIsServerOnly(): void {
|
||||
let host: string;
|
||||
try {
|
||||
host = new URL(this.publicEndpoint).hostname;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSingleLabel =
|
||||
!host.includes(".") && !host.includes(":") && host !== "localhost";
|
||||
if (!isSingleLabel) return;
|
||||
|
||||
this.logger.warn(
|
||||
`Storage endpoint '${this.publicEndpoint}' uses the container-only host '${host}'. ` +
|
||||
"Presigned URLs built from it cannot be reached by Donut Browser, so every " +
|
||||
"transfer will fail while /health and /readyz stay green. Set S3_PUBLIC_ENDPOINT " +
|
||||
"to an address your devices can reach (and publish that port).",
|
||||
);
|
||||
}
|
||||
|
||||
private async ensureBucketExists(): Promise<void> {
|
||||
@@ -332,7 +399,7 @@ export class SyncService implements OnModuleInit {
|
||||
const metadataHeaders = new Set(
|
||||
Object.keys(metadata ?? {}).map((name) => `x-amz-meta-${name}`),
|
||||
);
|
||||
const url = await getSignedUrl(this.s3Client, command, {
|
||||
const url = await getSignedUrl(this.presignClient, command, {
|
||||
expiresIn,
|
||||
// The AWS presigner otherwise hoists user metadata into the query string.
|
||||
// The client echoes the response metadata as headers, so those headers
|
||||
@@ -374,7 +441,7 @@ export class SyncService implements OnModuleInit {
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
const url = await getSignedUrl(this.presignClient, command, { expiresIn });
|
||||
|
||||
return {
|
||||
url,
|
||||
@@ -505,7 +572,9 @@ export class SyncService implements OnModuleInit {
|
||||
ContentType: item.contentType || "application/octet-stream",
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
const url = await getSignedUrl(this.presignClient, command, {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
return {
|
||||
key: item.key,
|
||||
@@ -565,7 +634,9 @@ export class SyncService implements OnModuleInit {
|
||||
Key: key,
|
||||
});
|
||||
|
||||
const url = await getSignedUrl(this.s3Client, command, { expiresIn });
|
||||
const url = await getSignedUrl(this.presignClient, command, {
|
||||
expiresIn,
|
||||
});
|
||||
|
||||
return {
|
||||
key: rawKey,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
import { INestApplication } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { Test, TestingModule } from "@nestjs/testing";
|
||||
import request from "supertest";
|
||||
import { App } from "supertest/types";
|
||||
import { AppController } from "./../src/app.controller.js";
|
||||
import { AppService } from "./../src/app.service.js";
|
||||
import { SyncModule } from "./../src/sync/sync.module.js";
|
||||
import {
|
||||
configureTestEnv,
|
||||
TEST_S3_ENDPOINT,
|
||||
TEST_SYNC_TOKEN,
|
||||
waitForTestS3,
|
||||
} from "./test-env.js";
|
||||
|
||||
// Presigning is offline, so this host never has to accept a connection — the
|
||||
// assertions are about which host ends up in the signed URL.
|
||||
const PUBLIC_ENDPOINT = "https://storage.example.com";
|
||||
|
||||
// Only needs to be present for the server to consider itself cloud-mode; no
|
||||
// token is verified against it in these assertions.
|
||||
const CLOUD_PUBLIC_KEY =
|
||||
"-----BEGIN PUBLIC KEY-----\nnot-a-real-key\n-----END PUBLIC KEY-----";
|
||||
|
||||
interface PresignResponse {
|
||||
url: string;
|
||||
}
|
||||
|
||||
interface PresignBatchResponse {
|
||||
items: Array<{ key: string; url: string }>;
|
||||
}
|
||||
|
||||
interface ReadyResponse {
|
||||
status: string;
|
||||
s3: boolean;
|
||||
storageEndpoint: string;
|
||||
}
|
||||
|
||||
async function bootstrap(publicEndpoint: string | undefined) {
|
||||
configureTestEnv();
|
||||
if (publicEndpoint) {
|
||||
process.env.S3_PUBLIC_ENDPOINT = publicEndpoint;
|
||||
} else {
|
||||
delete process.env.S3_PUBLIC_ENDPOINT;
|
||||
}
|
||||
await waitForTestS3();
|
||||
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
imports: [ConfigModule.forRoot({ isGlobal: true }), SyncModule],
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
const app = moduleFixture.createNestApplication<INestApplication<App>>();
|
||||
await app.listen(0);
|
||||
return app;
|
||||
}
|
||||
|
||||
// A self-hosted server usually reaches its storage over a private address the
|
||||
// desktop client has no route to. Signing client URLs against that address
|
||||
// handed every client a URL it could not open, so uploads failed at connect
|
||||
// while /health and /readyz stayed green.
|
||||
describe("presigned URL host", () => {
|
||||
describe("with S3_PUBLIC_ENDPOINT set", () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await bootstrap(PUBLIC_ENDPOINT);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
delete process.env.S3_PUBLIC_ENDPOINT;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("signs single upload URLs against the public endpoint", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-upload")
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: "endpoint/single.txt" })
|
||||
.expect(200);
|
||||
|
||||
const { url } = response.body as PresignResponse;
|
||||
expect(url.startsWith(PUBLIC_ENDPOINT)).toBe(true);
|
||||
expect(url).not.toContain(TEST_S3_ENDPOINT);
|
||||
});
|
||||
|
||||
it("signs batch upload URLs against the public endpoint", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-upload-batch")
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ items: [{ key: "endpoint/a.txt" }, { key: "endpoint/b.txt" }] })
|
||||
.expect(200);
|
||||
|
||||
const { items } = response.body as PresignBatchResponse;
|
||||
expect(items).toHaveLength(2);
|
||||
for (const item of items) {
|
||||
expect(item.url.startsWith(PUBLIC_ENDPOINT)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("signs download URLs against the public endpoint", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-download")
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: "endpoint/single.txt" })
|
||||
.expect(200);
|
||||
|
||||
const { url } = response.body as PresignResponse;
|
||||
expect(url.startsWith(PUBLIC_ENDPOINT)).toBe(true);
|
||||
});
|
||||
|
||||
// The server's own S3 calls must keep using the private endpoint, or
|
||||
// pointing clients at a public address would break the server itself.
|
||||
it("still reaches storage over the private endpoint", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/stat")
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: "endpoint/does-not-exist" })
|
||||
.expect(200);
|
||||
|
||||
expect(response.body).toEqual({ exists: false });
|
||||
});
|
||||
|
||||
it("reports the client-facing endpoint from /readyz", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/readyz")
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as ReadyResponse;
|
||||
expect(body.s3).toBe(true);
|
||||
expect(body.storageEndpoint).toBe(PUBLIC_ENDPOINT);
|
||||
});
|
||||
});
|
||||
|
||||
// /readyz has no auth, so a managed deployment must not publish its storage
|
||||
// host to anyone who can reach the probe.
|
||||
describe("in cloud mode", () => {
|
||||
let app: INestApplication<App>;
|
||||
const previousKey = process.env.SYNC_JWT_PUBLIC_KEY;
|
||||
|
||||
beforeAll(async () => {
|
||||
process.env.SYNC_JWT_PUBLIC_KEY = CLOUD_PUBLIC_KEY;
|
||||
app = await bootstrap(PUBLIC_ENDPOINT);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (previousKey === undefined) {
|
||||
delete process.env.SYNC_JWT_PUBLIC_KEY;
|
||||
} else {
|
||||
process.env.SYNC_JWT_PUBLIC_KEY = previousKey;
|
||||
}
|
||||
delete process.env.S3_PUBLIC_ENDPOINT;
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("withholds the storage endpoint from /readyz", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/readyz")
|
||||
.expect(200);
|
||||
|
||||
const body = response.body as ReadyResponse;
|
||||
expect(body.s3).toBe(true);
|
||||
expect(body.storageEndpoint).toBeUndefined();
|
||||
expect(JSON.stringify(body)).not.toContain("storage.example.com");
|
||||
});
|
||||
});
|
||||
|
||||
describe("without S3_PUBLIC_ENDPOINT", () => {
|
||||
let app: INestApplication<App>;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await bootstrap(undefined);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("falls back to S3_ENDPOINT", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post("/v1/objects/presign-upload")
|
||||
.set("Authorization", `Bearer ${TEST_SYNC_TOKEN}`)
|
||||
.send({ key: "endpoint/fallback.txt" })
|
||||
.expect(200);
|
||||
|
||||
const { url } = response.body as PresignResponse;
|
||||
expect(url.startsWith(TEST_S3_ENDPOINT)).toBe(true);
|
||||
});
|
||||
|
||||
it("reports the fallback endpoint from /readyz", async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.get("/readyz")
|
||||
.expect(200);
|
||||
|
||||
expect((response.body as ReadyResponse).storageEndpoint).toBe(
|
||||
TEST_S3_ENDPOINT,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Generated
+79
-3
@@ -680,6 +680,15 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.4.2"
|
||||
@@ -967,6 +976,15 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher 0.4.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.2.1"
|
||||
@@ -1785,7 +1803,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.28.2"
|
||||
version = "0.29.2"
|
||||
dependencies = [
|
||||
"aes 0.9.1",
|
||||
"aes-gcm 0.11.0",
|
||||
@@ -1797,7 +1815,7 @@ dependencies = [
|
||||
"blake3",
|
||||
"boringtun",
|
||||
"bzip2",
|
||||
"cbc",
|
||||
"cbc 0.2.1",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
@@ -1809,6 +1827,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"globset",
|
||||
"gtk",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
@@ -1831,6 +1850,8 @@ dependencies = [
|
||||
"resvg",
|
||||
"ring",
|
||||
"rusqlite",
|
||||
"secret-service",
|
||||
"security-framework",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -3378,6 +3399,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding 0.3.3",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -3387,7 +3409,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"block-padding 0.4.2",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
@@ -4084,6 +4106,20 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
@@ -4094,6 +4130,15 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -4120,6 +4165,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
@@ -5712,6 +5767,25 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "5.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
|
||||
dependencies = [
|
||||
"aes 0.8.4",
|
||||
"cbc 0.1.2",
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"getrandom 0.2.17",
|
||||
"hkdf",
|
||||
"num",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -7197,6 +7271,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -8984,6 +9059,7 @@ dependencies = [
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
|
||||
@@ -25,6 +25,7 @@ export const commandCoverage = {
|
||||
"get_system_info",
|
||||
"dismiss_window_resize_warning",
|
||||
"get_window_resize_warning_dismissed",
|
||||
"window_decorations::get_window_decoration_layout",
|
||||
"get_onboarding_completed",
|
||||
"complete_onboarding",
|
||||
],
|
||||
@@ -71,6 +72,7 @@ export const commandCoverage = {
|
||||
"update_stored_proxy",
|
||||
"delete_stored_proxy",
|
||||
"check_proxy_validity",
|
||||
"validate_vless_uri",
|
||||
"get_cached_proxy_check",
|
||||
"export_proxies",
|
||||
"import_proxies_json",
|
||||
@@ -174,8 +176,9 @@ export const commandCoverage = {
|
||||
"generate_sample_fingerprint",
|
||||
"is_geoip_database_available",
|
||||
"download_geoip_database",
|
||||
"fingerprint_consistency::check_profile_fingerprint_consistency",
|
||||
"fingerprint_consistency::match_profile_fingerprint_to_exit",
|
||||
"launch_gate::get_profile_pre_launch_checks",
|
||||
"launch_gate::ack_launch_gate",
|
||||
"check_wayfern_terms_accepted",
|
||||
"check_wayfern_downloaded",
|
||||
"accept_wayfern_terms",
|
||||
@@ -255,6 +258,7 @@ export const commandCoverage = {
|
||||
"list_remote_sessions",
|
||||
"get_remote_session",
|
||||
"stop_remote_session",
|
||||
"get_remote_handoff_states",
|
||||
"start_remote_session_events",
|
||||
"stop_remote_session_events",
|
||||
"get_remote_session_events_status",
|
||||
@@ -275,6 +279,10 @@ export const commandCoverage = {
|
||||
"get_cookie_bot_presets",
|
||||
"get_remote_hours_quota",
|
||||
"get_cookie_bot_usage",
|
||||
"cookie_bot::get_cookie_bot_user_templates",
|
||||
"cookie_bot::create_cookie_bot_user_template",
|
||||
"cookie_bot::update_cookie_bot_user_template",
|
||||
"cookie_bot::delete_cookie_bot_user_template",
|
||||
],
|
||||
},
|
||||
updateContracts: {
|
||||
|
||||
@@ -68,6 +68,7 @@ export class AppSession {
|
||||
extraEnv = {},
|
||||
args = [],
|
||||
seedVersionCache = true,
|
||||
seedDownloadedBrowser = false,
|
||||
onboardingCompleted = true,
|
||||
wayfernTermsAccepted = true,
|
||||
}) {
|
||||
@@ -80,6 +81,7 @@ export class AppSession {
|
||||
this.extraEnv = extraEnv;
|
||||
this.args = args;
|
||||
this.seedVersionCache = seedVersionCache;
|
||||
this.seedDownloadedBrowser = seedDownloadedBrowser;
|
||||
this.onboardingCompleted = onboardingCompleted;
|
||||
this.wayfernTermsAccepted = wayfernTermsAccepted;
|
||||
this.session = null;
|
||||
@@ -184,6 +186,54 @@ export class AppSession {
|
||||
}
|
||||
});
|
||||
}
|
||||
if (this.seedDownloadedBrowser) {
|
||||
// Registers a Wayfern version as "downloaded" without installing a
|
||||
// binary. Profile import derives its version from this registry and
|
||||
// fails with BROWSER_NOT_DOWNLOADED otherwise, so suites that exercise
|
||||
// import but never launch a browser need the entry and nothing else.
|
||||
const seededVersion =
|
||||
typeof this.seedDownloadedBrowser === "string"
|
||||
? this.seedDownloadedBrowser
|
||||
: "150.0.7871.100";
|
||||
const installDir = path.join(
|
||||
this.dataRoot,
|
||||
"data",
|
||||
"binaries",
|
||||
"wayfern",
|
||||
seededVersion,
|
||||
);
|
||||
await mkdir(installDir, { recursive: true });
|
||||
const registryPath = path.join(
|
||||
this.dataRoot,
|
||||
"data",
|
||||
"data",
|
||||
"downloaded_browsers.json",
|
||||
);
|
||||
await mkdir(path.dirname(registryPath), { recursive: true });
|
||||
await writeFile(
|
||||
registryPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
browsers: {
|
||||
wayfern: {
|
||||
[seededVersion]: {
|
||||
browser: "wayfern",
|
||||
version: seededVersion,
|
||||
file_path: installDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\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
|
||||
@@ -525,6 +575,7 @@ export function appFromEnvironment(name, options = {}) {
|
||||
extraEnv: options.extraEnv,
|
||||
args: options.args,
|
||||
seedVersionCache: options.seedVersionCache,
|
||||
seedDownloadedBrowser: options.seedDownloadedBrowser,
|
||||
onboardingCompleted: options.onboardingCompleted,
|
||||
wayfernTermsAccepted: options.wayfernTermsAccepted,
|
||||
});
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
|
||||
export const TEST_BROWSER_VERSION = "150.0.7871.100";
|
||||
|
||||
@@ -220,3 +221,101 @@ export function currentHostOs() {
|
||||
? "windows"
|
||||
: "linux";
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a Chromium cookie store at schema version 24 with plaintext values.
|
||||
*
|
||||
* Plaintext is deliberate: it is what a store looks like when the source
|
||||
* browser could not reach its keyring, and it lets the suite assert that
|
||||
* import seals every row with the target profile's key. Chromium reads a row
|
||||
* whose `encrypted_value` is empty, and drops any row where both columns are
|
||||
* set, so "value cleared and encrypted_value populated" is the only shape that
|
||||
* actually loads.
|
||||
*/
|
||||
export function writeChromiumCookies(dbPath, cookies) {
|
||||
const db = new DatabaseSync(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE cookies(
|
||||
creation_utc INTEGER NOT NULL,
|
||||
host_key TEXT NOT NULL,
|
||||
top_frame_site_key TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
value TEXT NOT NULL,
|
||||
encrypted_value BLOB NOT NULL DEFAULT '',
|
||||
path TEXT NOT NULL,
|
||||
expires_utc INTEGER NOT NULL,
|
||||
is_secure INTEGER NOT NULL,
|
||||
is_httponly INTEGER NOT NULL,
|
||||
last_access_utc INTEGER NOT NULL,
|
||||
has_expires INTEGER NOT NULL DEFAULT 1,
|
||||
is_persistent INTEGER NOT NULL DEFAULT 1,
|
||||
priority INTEGER NOT NULL DEFAULT 1,
|
||||
samesite INTEGER NOT NULL DEFAULT -1,
|
||||
source_scheme INTEGER NOT NULL DEFAULT 0,
|
||||
source_port INTEGER NOT NULL DEFAULT -1,
|
||||
last_update_utc INTEGER NOT NULL DEFAULT 0,
|
||||
source_type INTEGER NOT NULL DEFAULT 0,
|
||||
has_cross_site_ancestor INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE UNIQUE INDEX cookies_unique_index
|
||||
ON cookies(host_key, top_frame_site_key, name, path);
|
||||
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
|
||||
INSERT INTO meta VALUES('version', '24');
|
||||
INSERT INTO meta VALUES('last_compatible_version', '24');
|
||||
`);
|
||||
const insert = db.prepare(
|
||||
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
|
||||
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
|
||||
VALUES(?, ?, '', ?, ?, ?, '/', 0, 0, 0, 0)`,
|
||||
);
|
||||
// `encrypted` cookies are written the way Chromium's v23->v24 migration
|
||||
// does: BindString into a BLOB column, which leaves the storage class as
|
||||
// TEXT. Reading that as a strict blob returns empty and silently blanks the
|
||||
// cookie, so the suite has to reproduce it rather than only binding blobs.
|
||||
const insertAsText = db.prepare(
|
||||
`INSERT INTO cookies(creation_utc, host_key, top_frame_site_key, name, value,
|
||||
encrypted_value, path, expires_utc, is_secure, is_httponly, last_access_utc)
|
||||
VALUES(?, ?, '', ?, '', CAST(? AS TEXT), '/', 0, 0, 0, 0)`,
|
||||
);
|
||||
let creation = 13000000000000000;
|
||||
for (const cookie of cookies) {
|
||||
if (cookie.encryptedValueText === undefined) {
|
||||
insert.run(creation++, cookie.host, cookie.name, cookie.value, "");
|
||||
} else {
|
||||
insertAsText.run(
|
||||
creation++,
|
||||
cookie.host,
|
||||
cookie.name,
|
||||
cookie.encryptedValueText,
|
||||
);
|
||||
}
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
/** Write a Chromium History database holding the given URLs. */
|
||||
export function writeChromiumHistory(dbPath, urls) {
|
||||
const db = new DatabaseSync(dbPath);
|
||||
db.exec(`
|
||||
CREATE TABLE urls(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
url LONGVARCHAR,
|
||||
title LONGVARCHAR,
|
||||
visit_count INTEGER DEFAULT 0 NOT NULL,
|
||||
typed_count INTEGER DEFAULT 0 NOT NULL,
|
||||
last_visit_time INTEGER NOT NULL,
|
||||
hidden INTEGER DEFAULT 0 NOT NULL
|
||||
);
|
||||
CREATE TABLE meta(key LONGVARCHAR NOT NULL UNIQUE PRIMARY KEY, value LONGVARCHAR);
|
||||
INSERT INTO meta VALUES('version', '69');
|
||||
INSERT INTO meta VALUES('last_compatible_version', '16');
|
||||
`);
|
||||
const insert = db.prepare(
|
||||
"INSERT INTO urls(url, title, visit_count, typed_count, last_visit_time, hidden) VALUES(?, ?, 1, 0, ?, 0)",
|
||||
);
|
||||
let visit = 13000000000000000;
|
||||
for (const url of urls) {
|
||||
insert.run(url, url, visit++);
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
|
||||
+123
-7
@@ -1,7 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { readdir, readFile, stat } from "node:fs/promises";
|
||||
import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
@@ -244,13 +244,129 @@ test("real Wayfern fingerprinting, terms, API automation, CDP, cookies, and proc
|
||||
profileId: profile.id,
|
||||
exitIp: "8.8.8.8",
|
||||
});
|
||||
const consistency = await app.invoke(
|
||||
"check_profile_fingerprint_consistency",
|
||||
{
|
||||
profileId: profile.id,
|
||||
},
|
||||
// Pre-launch gate: local-only checks that must answer without starting a
|
||||
// proxy, an Xray worker or the browser.
|
||||
const checks = await app.invoke("get_profile_pre_launch_checks", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
assert.ok(Array.isArray(checks.vpn_extensions));
|
||||
assert.equal(
|
||||
typeof checks.scan_state,
|
||||
"string",
|
||||
"the scan must report whether it saw the whole profile",
|
||||
);
|
||||
assert.equal(typeof checks.consistency, "object");
|
||||
assert.equal(typeof checks.exit_probe_pending, "boolean");
|
||||
assert.equal(typeof checks.exit_measurement_unreliable, "boolean");
|
||||
// This profile has no VPN extension, so nothing may block its launch.
|
||||
assert.equal(
|
||||
checks.vpn_extensions.length,
|
||||
0,
|
||||
"a clean profile must not report a VPN extension",
|
||||
);
|
||||
assert.equal(
|
||||
checks.consent_token,
|
||||
null,
|
||||
"a consent token is only minted when a cached mismatch is blocking",
|
||||
);
|
||||
|
||||
// Extension detection, against manifests written where Chromium puts
|
||||
// them. The three cases are the whole point of the classifier: a real VPN
|
||||
// is named as one, a known VPN with an unrevealing name is caught by its
|
||||
// id, and a download manager holding the same `proxy` permission is
|
||||
// reported as a capability and never as a VPN.
|
||||
// `DONUTBROWSER_DATA_ROOT` puts the data dir at <dataRoot>/data, so this
|
||||
// is app_dirs::profiles_dir() plus the layout Chromium itself uses.
|
||||
const extensionsDir = path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
profile.id,
|
||||
"profile",
|
||||
"Default",
|
||||
"Extensions",
|
||||
);
|
||||
const seedExtension = async (id, version, manifest) => {
|
||||
const dir = path.join(extensionsDir, id, `${version}_0`);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dir, "manifest.json"),
|
||||
JSON.stringify(manifest),
|
||||
);
|
||||
};
|
||||
const IDM_ID = "ngpampappnmepgilojfohadhhmbhlaek";
|
||||
const HOTSPOT_SHIELD_ID = "nlbejmccbhkncgokjcmghpfloaajcffj";
|
||||
const NAMED_VPN_ID = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
await seedExtension(IDM_ID, "6.43.1", {
|
||||
name: "IDM Integration Module",
|
||||
version: "6.43.1",
|
||||
description: "Download files with Internet Download Manager",
|
||||
permissions: ["downloads", "storage", "proxy", "nativeMessaging"],
|
||||
});
|
||||
await seedExtension(HOTSPOT_SHIELD_ID, "10.0.0", {
|
||||
name: "Hotspot Shield",
|
||||
version: "10.0.0",
|
||||
permissions: ["proxy"],
|
||||
});
|
||||
await seedExtension(NAMED_VPN_ID, "1.0.0", {
|
||||
name: "Turbo VPN Free",
|
||||
version: "1.0.0",
|
||||
permissions: ["proxy"],
|
||||
});
|
||||
|
||||
const withExtensions = await app.invoke("get_profile_pre_launch_checks", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
const detected = new Map(
|
||||
withExtensions.vpn_extensions.map((item) => [item.key, item]),
|
||||
);
|
||||
assert.equal(detected.size, 3, "every seeded extension must be reported");
|
||||
assert.equal(detected.get(`crx:${NAMED_VPN_ID}`).confidence, "confirmed");
|
||||
assert.equal(
|
||||
detected.get(`crx:${HOTSPOT_SHIELD_ID}`).confidence,
|
||||
"confirmed",
|
||||
"a known VPN id must be named even when its name gives nothing away",
|
||||
);
|
||||
assert.equal(
|
||||
detected.get(`crx:${IDM_ID}`).confidence,
|
||||
"capability",
|
||||
"a download manager holding the proxy permission is not a VPN",
|
||||
);
|
||||
assert.ok(
|
||||
detected.get(`crx:${IDM_ID}`).proxy_control,
|
||||
"it does still hold the permission, which is why it is listed at all",
|
||||
);
|
||||
assert.equal(
|
||||
withExtensions.exit_measurement_unreliable,
|
||||
true,
|
||||
"a proxy-capable extension makes the exit measurement a caveat",
|
||||
);
|
||||
|
||||
// Acknowledgements are per-profile and must be accepted for both kinds.
|
||||
await app.invoke("ack_launch_gate", {
|
||||
profileId: profile.id,
|
||||
ackFingerprint: false,
|
||||
ackExtensionKeys: ["crx:e2e-nonexistent-extension"],
|
||||
});
|
||||
await app.invoke("ack_launch_gate", {
|
||||
profileId: profile.id,
|
||||
ackFingerprint: true,
|
||||
ackExtensionKeys: [`crx:${IDM_ID}`],
|
||||
});
|
||||
const afterAck = await app.invoke("get_profile_pre_launch_checks", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
assert.deepEqual(
|
||||
afterAck.vpn_extensions.map((item) => item.key).sort(),
|
||||
[`crx:${HOTSPOT_SHIELD_ID}`, `crx:${NAMED_VPN_ID}`].sort(),
|
||||
"an acknowledged extension stops being reported, the others do not",
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("get_profile_pre_launch_checks", {
|
||||
profileId: "00000000-0000-0000-0000-000000000000",
|
||||
}),
|
||||
/PROFILE_NOT_FOUND/,
|
||||
);
|
||||
assert.equal(typeof consistency, "object");
|
||||
|
||||
const directProfile = (await app.invoke("list_browser_profiles")).find(
|
||||
(item) => item.id === profile.id,
|
||||
|
||||
+458
-212
@@ -1,9 +1,16 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdir, writeFile } from "node:fs/promises";
|
||||
import { existsSync } from "node:fs";
|
||||
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import test from "node:test";
|
||||
import { withApp } from "../lib/app.mjs";
|
||||
import { extensionZipBase64, wireGuardFixture } from "../lib/fixtures.mjs";
|
||||
import {
|
||||
extensionZipBase64,
|
||||
wireGuardFixture,
|
||||
writeChromiumCookies,
|
||||
writeChromiumHistory,
|
||||
} from "../lib/fixtures.mjs";
|
||||
|
||||
async function createProfile(app, name = "Entity Profile") {
|
||||
return app.invoke("create_browser_profile_new", {
|
||||
@@ -24,229 +31,468 @@ async function createProfile(app, name = "Entity Profile") {
|
||||
}
|
||||
|
||||
test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", async () => {
|
||||
await withApp("entities-core", async (app) => {
|
||||
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");
|
||||
// Profile import derives its browser version from the downloaded-browsers
|
||||
// registry, so without an entry every import fails with
|
||||
// BROWSER_NOT_DOWNLOADED before it touches a single file.
|
||||
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 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 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 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 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: [
|
||||
// Donut accepts one VLESS shape (REALITY + XTLS Vision over TCP). The form
|
||||
// uses this to tell the user WHICH part of their setup is unsupported
|
||||
// instead of implying they mistyped, so the reason must survive the IPC hop.
|
||||
const goodVless =
|
||||
"vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@example.com:443" +
|
||||
"?security=reality&flow=xtls-rprx-vision&encryption=none&type=tcp" +
|
||||
"&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome";
|
||||
assert.equal(
|
||||
await app.invoke("validate_vless_uri", { uri: goodVless }),
|
||||
null,
|
||||
);
|
||||
|
||||
for (const [uri, reason] of [
|
||||
[goodVless.replace("security=reality", "security=tls"), "security"],
|
||||
[goodVless.replace("type=tcp", "type=ws"), "transport"],
|
||||
[goodVless.replace("flow=xtls-rprx-vision", "flow=none"), "flow"],
|
||||
]) {
|
||||
// invokeError returns the command's error wrapped in a message, so match
|
||||
// rather than JSON.parse the whole string.
|
||||
const error = await app.invokeError("validate_vless_uri", { uri });
|
||||
assert.match(error, /VLESS_CONFIG_INVALID/);
|
||||
assert.match(
|
||||
error,
|
||||
new RegExp(`"reason":"${reason}"`),
|
||||
`expected reason ${reason} for ${uri}, got: ${error}`,
|
||||
);
|
||||
}
|
||||
|
||||
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", exit_type: "Crashed" },
|
||||
download: { default_directory: "/Users/someone-else/Downloads" },
|
||||
}),
|
||||
);
|
||||
// A Secure Preferences with MACs that can never validate under Wayfern,
|
||||
// one real (relative-path) extension and one component extension that
|
||||
// belongs to the source browser's bundle.
|
||||
await writeFile(
|
||||
path.join(importProfile, "Secure Preferences"),
|
||||
JSON.stringify({
|
||||
protection: { super_mac: "deadbeef", macs: { extensions: {} } },
|
||||
extensions: {
|
||||
settings: {
|
||||
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: {
|
||||
path: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/1.0_0",
|
||||
},
|
||||
bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb: {
|
||||
path: "/Applications/Chromium.app/Contents/Resources/component",
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
// Caches must not be copied, and site data must be.
|
||||
await mkdir(path.join(importProfile, "Cache"), { recursive: true });
|
||||
await writeFile(path.join(importProfile, "Cache", "data_0"), "junk");
|
||||
await mkdir(path.join(importProfile, "Local Storage", "leveldb"), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
path.join(importProfile, "Local Storage", "leveldb", "000003.log"),
|
||||
"site-data",
|
||||
);
|
||||
writeChromiumHistory(path.join(importProfile, "History"), [
|
||||
"https://example.com/",
|
||||
"https://example.org/",
|
||||
]);
|
||||
writeChromiumCookies(path.join(importProfile, "Cookies"), [
|
||||
{ host: "example.com", name: "sid", value: "session-token" },
|
||||
{ host: "example.org", name: "pref", value: "dark" },
|
||||
// Sealed with a key this machine does not have, and stored the way
|
||||
// Chromium's own v23->v24 migration stores it (TEXT in a BLOB column).
|
||||
// It must be reported as unrecoverable, never silently blanked and
|
||||
// counted as migrated.
|
||||
{
|
||||
host: "sealed.example",
|
||||
name: "sid",
|
||||
encryptedValueText: "v10\u0001\u0002\u0003unopenable-ciphertext",
|
||||
},
|
||||
]);
|
||||
|
||||
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: [
|
||||
{
|
||||
name: "Imported Proxy",
|
||||
type: "http",
|
||||
host: "127.0.0.1",
|
||||
port: 8081,
|
||||
source_path: scanned[0].path,
|
||||
browser_type: scanned[0].browser,
|
||||
new_profile_name: "Imported Profile",
|
||||
proxy_id: null,
|
||||
vpn_id: null,
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
assert.equal(importResult.imported_count, 1);
|
||||
groupId: null,
|
||||
duplicateStrategy: "rename",
|
||||
// A stored fingerprint, as elsewhere in this suite: generating a real
|
||||
// one shells out to the Wayfern binary, which no CRUD suite installs.
|
||||
wayfernConfig: { fingerprint: "{}" },
|
||||
});
|
||||
assert.equal(
|
||||
importBatch.imported_count,
|
||||
1,
|
||||
`import must succeed: ${JSON.stringify(importBatch.results)}`,
|
||||
);
|
||||
|
||||
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 imported = importBatch.results[0];
|
||||
// The assertion whose absence let the layout bug ship: an import that
|
||||
// carries nothing used to be indistinguishable from a successful one.
|
||||
assert.ok(
|
||||
imported.report,
|
||||
"an imported profile must report what it carried",
|
||||
);
|
||||
assert.equal(imported.report.cookies_migrated, 2);
|
||||
assert.equal(
|
||||
imported.report.cookies_unrecoverable,
|
||||
1,
|
||||
"a cookie no key can open must be counted, not silently emptied",
|
||||
);
|
||||
assert.equal(imported.report.history_entries, 2);
|
||||
assert.equal(imported.report.extensions_migrated, 1);
|
||||
assert.ok(imported.report.local_storage_origins > 0);
|
||||
|
||||
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",
|
||||
]);
|
||||
const importedDir = path.join(
|
||||
app.dataRoot,
|
||||
"data",
|
||||
"profiles",
|
||||
imported.profile_id,
|
||||
"profile",
|
||||
);
|
||||
// Chromium reads <user-data-dir>/Default/, so anything at the root is
|
||||
// invisible to the browser no matter how faithfully it was copied.
|
||||
assert.ok(
|
||||
existsSync(path.join(importedDir, "Default", "Preferences")),
|
||||
"profile content must land under Default/",
|
||||
);
|
||||
assert.ok(
|
||||
!existsSync(path.join(importedDir, "Preferences")),
|
||||
"nothing profile-scoped may sit at the user-data-dir root",
|
||||
);
|
||||
assert.ok(
|
||||
existsSync(path.join(importedDir, "os_crypt_key")),
|
||||
"Wayfern reads its key from the user-data-dir root",
|
||||
);
|
||||
assert.ok(
|
||||
!existsSync(path.join(importedDir, "Default", "Cache")),
|
||||
"caches are pure waste and must not be copied",
|
||||
);
|
||||
assert.ok(
|
||||
existsSync(
|
||||
path.join(
|
||||
importedDir,
|
||||
"Default",
|
||||
"Local Storage",
|
||||
"leveldb",
|
||||
"000003.log",
|
||||
),
|
||||
),
|
||||
"site data must survive",
|
||||
);
|
||||
|
||||
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 importedCookies = path.join(
|
||||
importedDir,
|
||||
"Default",
|
||||
process.platform === "win32"
|
||||
? path.join("Network", "Cookies")
|
||||
: "Cookies",
|
||||
);
|
||||
assert.ok(
|
||||
existsSync(importedCookies),
|
||||
"cookies must sit where this platform's Chromium reads them",
|
||||
);
|
||||
// Chromium drops any row where both value and encrypted_value are set, so
|
||||
// a "migrated" cookie that kept its plaintext would never load.
|
||||
const cookieDb = new DatabaseSync(importedCookies, { readOnly: true });
|
||||
const rows = cookieDb
|
||||
.prepare(
|
||||
"SELECT host_key, value, length(encrypted_value) AS enc FROM cookies ORDER BY host_key",
|
||||
)
|
||||
.all();
|
||||
cookieDb.close();
|
||||
assert.equal(
|
||||
rows.length,
|
||||
2,
|
||||
"the unrecoverable row is dropped, not kept empty",
|
||||
);
|
||||
for (const row of rows) {
|
||||
assert.equal(row.value, "", `${row.host_key} kept a plaintext value`);
|
||||
assert.ok(row.enc > 0, `${row.host_key} was not re-encrypted`);
|
||||
}
|
||||
|
||||
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);
|
||||
const securePrefs = JSON.parse(
|
||||
await readFile(
|
||||
path.join(importedDir, "Default", "Secure Preferences"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
assert.equal(
|
||||
securePrefs.protection,
|
||||
undefined,
|
||||
"MACs from another machine can never validate and must be stripped",
|
||||
);
|
||||
assert.ok(
|
||||
securePrefs.extensions.settings.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa,
|
||||
"the user's own extension must survive",
|
||||
);
|
||||
assert.equal(
|
||||
securePrefs.extensions.settings.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb,
|
||||
undefined,
|
||||
"a component extension pointing into the source browser must be dropped",
|
||||
);
|
||||
|
||||
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 });
|
||||
}
|
||||
});
|
||||
const prefs = JSON.parse(
|
||||
await readFile(
|
||||
path.join(importedDir, "Default", "Preferences"),
|
||||
"utf8",
|
||||
),
|
||||
);
|
||||
assert.equal(prefs.profile.exit_type, "Normal");
|
||||
assert.equal(prefs.download.default_directory, undefined);
|
||||
assert.equal(prefs.profile.name, "Imported fixture");
|
||||
|
||||
// A Gecko profile must say why it cannot be imported instead of silently
|
||||
// producing an empty one.
|
||||
const firefoxRoot = path.join(app.root, "firefox-profile-fixture");
|
||||
await mkdir(firefoxRoot, { recursive: true });
|
||||
await writeFile(path.join(firefoxRoot, "prefs.js"), "// prefs");
|
||||
await writeFile(path.join(firefoxRoot, "places.sqlite"), "");
|
||||
const geckoBatch = await app.invoke("import_browser_profiles", {
|
||||
items: [
|
||||
{
|
||||
source_path: firefoxRoot,
|
||||
browser_type: "firefox",
|
||||
new_profile_name: "Gecko Profile",
|
||||
proxy_id: null,
|
||||
vpn_id: null,
|
||||
},
|
||||
],
|
||||
groupId: null,
|
||||
duplicateStrategy: "rename",
|
||||
wayfernConfig: { fingerprint: "{}" },
|
||||
});
|
||||
assert.equal(geckoBatch.failed_count, 1);
|
||||
assert.match(
|
||||
geckoBatch.results[0].error,
|
||||
/IMPORT_SOURCE_NOT_CHROMIUM/,
|
||||
"a Firefox folder must be rejected by name, not imported empty",
|
||||
);
|
||||
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, imported.profile_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 });
|
||||
}
|
||||
},
|
||||
{ seedDownloadedBrowser: true },
|
||||
);
|
||||
});
|
||||
|
||||
test("extensions, extension groups, VPN storage, DNS rules, and event-backed assignments", async () => {
|
||||
|
||||
@@ -316,6 +316,13 @@ test("MCP Streamable HTTP initialization, auth, discovery, calls, and isolated a
|
||||
"update_proxy",
|
||||
"get_page_content",
|
||||
"get_interactive_elements",
|
||||
// The remote loop has to be complete from MCP alone: start a session,
|
||||
// watch it become usable, drive it with the interaction tools above, stop
|
||||
// it. Any one of these missing leaves an agent able to lease a host it
|
||||
// cannot use, or unable to lease one at all.
|
||||
"run_profile_remote",
|
||||
"get_remote_session",
|
||||
"stop_remote_session",
|
||||
]) {
|
||||
assert.ok(names.includes(name), `MCP is missing ${name}`);
|
||||
}
|
||||
@@ -671,6 +678,15 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
|
||||
}),
|
||||
notSignedIn,
|
||||
);
|
||||
// The local-launch gate. Nothing has run remotely in this session, so it
|
||||
// is empty — but it must answer, because a UI that cannot read it shows
|
||||
// an enabled Run button over a profile the backend will refuse.
|
||||
const handoff = await app.invoke("get_remote_handoff_states");
|
||||
assert.ok(
|
||||
handoff && typeof handoff === "object" && !Array.isArray(handoff),
|
||||
"the handoff gate must answer with a profile-keyed object",
|
||||
);
|
||||
assert.equal(Object.keys(handoff).length, 0);
|
||||
|
||||
// The transition stream is what the desktop uses instead of polling, so
|
||||
// its subscriber has to start, report itself, and stop on demand. Both
|
||||
@@ -702,6 +718,33 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
|
||||
}),
|
||||
notSignedIn,
|
||||
);
|
||||
// Saved site lists are cloud-backed like the schedules above, so they
|
||||
// must refuse the same way rather than appearing to work offline.
|
||||
assert.match(
|
||||
await app.invokeError("get_cookie_bot_user_templates", {}),
|
||||
notSignedIn,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("create_cookie_bot_user_template", {
|
||||
name: "e2e list",
|
||||
sites: ["example.com"],
|
||||
}),
|
||||
notSignedIn,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("update_cookie_bot_user_template", {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
name: "renamed",
|
||||
sites: null,
|
||||
}),
|
||||
notSignedIn,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("delete_cookie_bot_user_template", {
|
||||
id: "00000000-0000-0000-0000-000000000000",
|
||||
}),
|
||||
notSignedIn,
|
||||
);
|
||||
assert.match(
|
||||
await app.invokeError("check_cookie_bot_conflicts", {
|
||||
profileId: missingProfileId,
|
||||
|
||||
@@ -22,6 +22,30 @@ test("fresh app renders, completes onboarding, persists settings, and never touc
|
||||
true,
|
||||
);
|
||||
|
||||
// Where the app draws its own titlebar it also owns the window controls,
|
||||
// so it needs the desktop's button layout to know which side they go on.
|
||||
const decorations = await app.invoke("get_window_decoration_layout");
|
||||
assert.equal(typeof decorations?.client_side, "boolean");
|
||||
if (decorations.client_side) {
|
||||
// Only reported where decorations were actually dropped, which is
|
||||
// every Linux session except KDE on Wayland.
|
||||
assert.equal(process.platform, "linux");
|
||||
// `layout` may be null when GtkSettings is unavailable; the frontend
|
||||
// falls back to the default arrangement rather than drawing nothing,
|
||||
// so asserting a string here would be stricter than the contract.
|
||||
if (decorations.layout !== null) {
|
||||
assert.equal(typeof decorations.layout, "string");
|
||||
assert.match(
|
||||
decorations.layout,
|
||||
/close|minimize|maximize/,
|
||||
`layout must name a drawable control, got: ${decorations.layout}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// The platform still draws a titlebar; the app must not draw a second.
|
||||
assert.equal(decorations.layout, null);
|
||||
}
|
||||
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...initial,
|
||||
@@ -113,8 +137,12 @@ test("keyboard command palette and major navigation surfaces are operable throug
|
||||
assert.match(body, /Settings/i);
|
||||
|
||||
// Exercise native WebDriver element marshalling and click, not just script execution.
|
||||
// Scoped to the open dialog on purpose: on Linux the app draws its own
|
||||
// titlebar, whose "Close window" control appears earlier in the DOM, and
|
||||
// clicking that would exercise the window lifecycle instead of the palette.
|
||||
const close = await app.execute(
|
||||
`return [...document.querySelectorAll("button")].find(
|
||||
`const dialog = document.querySelector("[role='dialog']") ?? document;
|
||||
return [...dialog.querySelectorAll("button")].find(
|
||||
(button) => /close/i.test(button.getAttribute("aria-label") || button.textContent || "")
|
||||
) ?? null;`,
|
||||
);
|
||||
|
||||
+17
-1
@@ -474,7 +474,7 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
|
||||
await app.clickSelector('[aria-label="New proxy"]');
|
||||
await app.waitForText("Add Proxy");
|
||||
await app.fillSelector("#proxy-name", "E2E VLESS");
|
||||
await chooseSelectOption(app, "#proxy-type", "VLESS · Vision · REALITY");
|
||||
await chooseSelectOption(app, "#proxy-type", "VLESS");
|
||||
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
@@ -512,6 +512,22 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
|
||||
true,
|
||||
);
|
||||
|
||||
// A well-formed URI for a setup Donut cannot use must say WHICH part is
|
||||
// unsupported, rather than implying the user mistyped it.
|
||||
await app.fillSelector(
|
||||
"#proxy-vless-uri",
|
||||
`${uri.replace("type=tcp", "type=ws")}&path=%2Fray`,
|
||||
);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return /only|TCP|transport/i.test(
|
||||
document.querySelector("#proxy-vless-uri-help")?.textContent || ""
|
||||
);`,
|
||||
),
|
||||
{ description: "transport-specific unsupported message" },
|
||||
);
|
||||
|
||||
await app.fillSelector("#proxy-vless-uri", uri);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.28.2";
|
||||
releaseVersion = "0.29.1";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_amd64.AppImage";
|
||||
hash = "sha256-+CqHiPMg4oczNiPg+MC6jvp0CUcK4kb5yeyk+QDbAWY=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_amd64.AppImage";
|
||||
hash = "sha256-8EI1aUe0nuW1JsYoRD+PhIDtGbZ1SiNqKBAhFRE/K1w=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.28.2/Donut_0.28.2_aarch64.AppImage";
|
||||
hash = "sha256-HodokW2ySIpdpW7Hyqpwsm8whQ0hHldlSg11Sl1UW3k=";
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.29.1/Donut_0.29.1_aarch64.AppImage";
|
||||
hash = "sha256-MLRZFU1y5dbgPhD7okCHYbC8pGrG79GT70kdD3yvqKk=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
|
||||
+4
-3
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.28.2",
|
||||
"version": "0.29.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"predev": "pnpm licenses:generate",
|
||||
@@ -10,8 +10,9 @@
|
||||
"prebuild": "pnpm licenses:generate",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"test": "pnpm test:themes && pnpm test:cookie-bot-limits && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
|
||||
"test": "pnpm test:themes && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
|
||||
"test:themes": "node --test src/lib/themes.test.mjs",
|
||||
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
|
||||
"test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs",
|
||||
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
|
||||
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
|
||||
@@ -110,7 +111,7 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~6.0.3"
|
||||
},
|
||||
"packageManager": "pnpm@11.10.0",
|
||||
"packageManager": "pnpm@11.20.0",
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||
"biome check --fix"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
diff --git a/dist/commonjs/index.d.ts b/dist/commonjs/index.d.ts
|
||||
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a7d061c98 100644
|
||||
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..7a19917a209b84b30938957ea67d4ad60dd748c5 100644
|
||||
--- a/dist/commonjs/index.d.ts
|
||||
+++ b/dist/commonjs/index.d.ts
|
||||
@@ -5,4 +5,5 @@ export type BraceExpansionOptions = {
|
||||
@@ -8,18 +8,20 @@ index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a
|
||||
export declare function expand(str: string, options?: BraceExpansionOptions): string[];
|
||||
+export default expand;
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
\ No newline at end of file
|
||||
diff --git a/dist/commonjs/index.js b/dist/commonjs/index.js
|
||||
index be9df86be09c7655787a65c55ae6da01858894c3..071ad97532f30155cb56d7f2662fa99122ca628a 100644
|
||||
index 869a6bee23807b9f01c18c99ab8e952b4b242f97..cd8fa65b1a1521aa0b27b3e661797763b8365138 100644
|
||||
--- a/dist/commonjs/index.js
|
||||
+++ b/dist/commonjs/index.js
|
||||
@@ -260,4 +260,5 @@ function expand_(str, max, maxLength, isTop) {
|
||||
@@ -286,4 +286,5 @@ function expand_(str, max, maxLength, isTop) {
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
+module.exports = Object.assign(expand, exports);
|
||||
//# sourceMappingURL=index.js.map
|
||||
\ No newline at end of file
|
||||
diff --git a/dist/esm/index.d.ts b/dist/esm/index.d.ts
|
||||
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a7d061c98 100644
|
||||
index f3e2de9d87e1ce462517e49f35733bed8bdf85af..7a19917a209b84b30938957ea67d4ad60dd748c5 100644
|
||||
--- a/dist/esm/index.d.ts
|
||||
+++ b/dist/esm/index.d.ts
|
||||
@@ -5,4 +5,5 @@ export type BraceExpansionOptions = {
|
||||
@@ -28,11 +30,12 @@ index f3e2de9d87e1ce462517e49f35733bed8bdf85af..6c84d87835182d0670981dc15f488c2a
|
||||
export declare function expand(str: string, options?: BraceExpansionOptions): string[];
|
||||
+export default expand;
|
||||
//# sourceMappingURL=index.d.ts.map
|
||||
\ No newline at end of file
|
||||
diff --git a/dist/esm/index.js b/dist/esm/index.js
|
||||
index 6dc0392fc0feedb811e63d70a30be2736af17a3a..81ea182fa5dbc3c60fa4cec4cb549fa256a903e4 100644
|
||||
index fd68f57029207ac1bcafe7fb1c14ad5305b3ffa4..f3ef09ac8ad02d3fde8150e7f64f40ac874a47e3 100644
|
||||
--- a/dist/esm/index.js
|
||||
+++ b/dist/esm/index.js
|
||||
@@ -256,4 +256,5 @@ function expand_(str, max, maxLength, isTop) {
|
||||
@@ -282,4 +282,5 @@ function expand_(str, max, maxLength, isTop) {
|
||||
}
|
||||
return acc;
|
||||
}
|
||||
Generated
+29
-28
@@ -9,21 +9,22 @@ overrides:
|
||||
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
||||
postcss@<8.5.18: '>=8.5.18'
|
||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||
fast-uri@<3.1.2: '>=3.1.2 <4'
|
||||
fast-uri@<3.1.5: '>=3.1.5 <4'
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||
js-cookie@<3.0.7: '>=3.0.7'
|
||||
nanoid@<3.3.17: '>=3.3.17 <4'
|
||||
fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
|
||||
multer@>=2.0.0 <2.2.0: '>=2.2.0'
|
||||
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
|
||||
js-yaml@<3.15.0: '>=3.15.0 <4'
|
||||
js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5'
|
||||
js-yaml@<3.15.1: '>=3.15.1 <4'
|
||||
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
|
||||
'@babel/core@<7.29.6': '>=7.29.6 <8'
|
||||
brace-expansion@<5.0.8: 5.0.8
|
||||
brace-expansion@<5.0.9: 5.0.9
|
||||
sharp@<0.35.0: '>=0.35.0 <0.36'
|
||||
|
||||
patchedDependencies:
|
||||
brace-expansion@5.0.8: 6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e
|
||||
brace-expansion@5.0.9: bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208
|
||||
|
||||
importers:
|
||||
|
||||
@@ -2852,8 +2853,8 @@ packages:
|
||||
bowser@2.14.1:
|
||||
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||
|
||||
brace-expansion@5.0.8:
|
||||
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
browserslist@4.28.4:
|
||||
@@ -3340,8 +3341,8 @@ packages:
|
||||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
fast-uri@3.1.4:
|
||||
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
|
||||
fast-uri@3.1.5:
|
||||
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
|
||||
|
||||
fb-watchman@2.0.2:
|
||||
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
|
||||
@@ -3773,12 +3774,12 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@3.15.0:
|
||||
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
|
||||
js-yaml@3.15.1:
|
||||
resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@4.3.0:
|
||||
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
||||
js-yaml@4.3.1:
|
||||
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
|
||||
hasBin: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
@@ -4093,8 +4094,8 @@ packages:
|
||||
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
||||
nanoid@3.3.16:
|
||||
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
|
||||
nanoid@3.3.17:
|
||||
resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
@@ -6040,7 +6041,7 @@ snapshots:
|
||||
camelcase: 5.3.1
|
||||
find-up: 4.1.0
|
||||
get-package-type: 0.1.0
|
||||
js-yaml: 3.15.0
|
||||
js-yaml: 3.15.1
|
||||
resolve-from: 5.0.0
|
||||
|
||||
'@istanbuljs/schema@0.1.6': {}
|
||||
@@ -7857,14 +7858,14 @@ snapshots:
|
||||
ajv@8.18.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.4
|
||||
fast-uri: 3.1.5
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ajv@8.20.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.4
|
||||
fast-uri: 3.1.5
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
@@ -8038,7 +8039,7 @@ snapshots:
|
||||
|
||||
bowser@2.14.1: {}
|
||||
|
||||
brace-expansion@5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e):
|
||||
brace-expansion@5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208):
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -8232,7 +8233,7 @@ snapshots:
|
||||
cosmiconfig@8.3.6(typescript@5.9.3):
|
||||
dependencies:
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.3.0
|
||||
js-yaml: 4.3.1
|
||||
parse-json: 5.2.0
|
||||
path-type: 4.0.0
|
||||
optionalDependencies:
|
||||
@@ -8480,7 +8481,7 @@ snapshots:
|
||||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fast-uri@3.1.4: {}
|
||||
fast-uri@3.1.5: {}
|
||||
|
||||
fb-watchman@2.0.2:
|
||||
dependencies:
|
||||
@@ -9102,12 +9103,12 @@ snapshots:
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@3.15.0:
|
||||
js-yaml@3.15.1:
|
||||
dependencies:
|
||||
argparse: 1.0.10
|
||||
esprima: 4.0.1
|
||||
|
||||
js-yaml@4.3.0:
|
||||
js-yaml@4.3.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
@@ -9332,15 +9333,15 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
|
||||
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
|
||||
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
|
||||
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -9371,7 +9372,7 @@ snapshots:
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
|
||||
nanoid@3.3.16: {}
|
||||
nanoid@3.3.17: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
@@ -9535,7 +9536,7 @@ snapshots:
|
||||
|
||||
postcss@8.5.23:
|
||||
dependencies:
|
||||
nanoid: 3.3.16
|
||||
nanoid: 3.3.17
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
|
||||
+6
-5
@@ -24,17 +24,18 @@ overrides:
|
||||
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
||||
postcss@<8.5.18: '>=8.5.18'
|
||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||
fast-uri@<3.1.2: '>=3.1.2 <4'
|
||||
fast-uri@<3.1.5: '>=3.1.5 <4'
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||
js-cookie@<3.0.7: '>=3.0.7'
|
||||
nanoid@<3.3.17: '>=3.3.17 <4'
|
||||
fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
|
||||
multer@>=2.0.0 <2.2.0: '>=2.2.0'
|
||||
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
|
||||
js-yaml@<3.15.0: '>=3.15.0 <4'
|
||||
js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5'
|
||||
js-yaml@<3.15.1: '>=3.15.1 <4'
|
||||
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
|
||||
'@babel/core@<7.29.6': '>=7.29.6 <8'
|
||||
brace-expansion@<5.0.8: 5.0.8
|
||||
brace-expansion@<5.0.9: 5.0.9
|
||||
sharp@<0.35.0: '>=0.35.0 <0.36'
|
||||
|
||||
allowBuilds:
|
||||
@@ -97,4 +98,4 @@ minimumReleaseAgeExclude:
|
||||
- '@aws-sdk/token-providers@3.1081.0'
|
||||
|
||||
patchedDependencies:
|
||||
brace-expansion@5.0.8: patches/brace-expansion@5.0.8.patch
|
||||
brace-expansion@5.0.9: patches/brace-expansion@5.0.9.patch
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# cargo-audit configuration.
|
||||
#
|
||||
# `cargo audit` reads Cargo.lock, which records optional dependencies even when
|
||||
# no enabled feature pulls them into the build. An advisory against such a
|
||||
# package fails CI while the vulnerable code is never compiled into the binary.
|
||||
# Entries here are for exactly that case and must each carry the evidence.
|
||||
#
|
||||
# Before adding an ignore, prove the crate is genuinely not built:
|
||||
# cd src-tauri
|
||||
# cargo tree -i <crate> --target all # must print "nothing to print"
|
||||
# cargo tree --target all | grep <crate> # must find nothing
|
||||
# If either finds it, the crate IS in the build and the advisory must be fixed,
|
||||
# not ignored.
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# RUSTSEC-2026-0235 — rkyv: insufficient archive validation can cause
|
||||
# out-of-bounds reads in archives containing Rc/Arc. Fixed in rkyv >= 0.8.17.
|
||||
#
|
||||
# Not reachable here. rkyv is an OPTIONAL dependency of rust_decimal, which
|
||||
# arrives via tauri-plugin-log -> byte-unit -> rust_decimal. No enabled
|
||||
# feature activates it, so it is present in Cargo.lock but absent from the
|
||||
# build graph. Verified with the two commands above (both find nothing) and
|
||||
# by there being no rkyv artifact in target/.
|
||||
#
|
||||
# There is no upgrade path: rust_decimal 1.42.1 is the newest release and
|
||||
# still pins rkyv 0.7.x, so `cargo update` cannot reach 0.8.17.
|
||||
#
|
||||
# REMOVE THIS as soon as either becomes true:
|
||||
# - rust_decimal ships a release depending on rkyv >= 0.8.17, or
|
||||
# - `cargo tree -i rkyv --target all` starts printing a path, which would
|
||||
# mean the crate is now genuinely compiled and the advisory applies.
|
||||
"RUSTSEC-2026-0235",
|
||||
]
|
||||
Generated
+79
-3
@@ -684,6 +684,15 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.3.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
|
||||
dependencies = [
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-padding"
|
||||
version = "0.4.2"
|
||||
@@ -971,6 +980,15 @@ dependencies = [
|
||||
"toml 0.9.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6"
|
||||
dependencies = [
|
||||
"cipher 0.4.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cbc"
|
||||
version = "0.2.1"
|
||||
@@ -1797,7 +1815,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.28.2"
|
||||
version = "0.29.2"
|
||||
dependencies = [
|
||||
"aes 0.9.1",
|
||||
"aes-gcm 0.11.0",
|
||||
@@ -1809,7 +1827,7 @@ dependencies = [
|
||||
"blake3",
|
||||
"boringtun",
|
||||
"bzip2",
|
||||
"cbc",
|
||||
"cbc 0.2.1",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"clap",
|
||||
@@ -1821,6 +1839,7 @@ dependencies = [
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"globset",
|
||||
"gtk",
|
||||
"http-body-util",
|
||||
"hyper",
|
||||
"hyper-util",
|
||||
@@ -1843,6 +1862,8 @@ dependencies = [
|
||||
"resvg",
|
||||
"ring",
|
||||
"rusqlite",
|
||||
"secret-service",
|
||||
"security-framework",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"serde_yaml",
|
||||
@@ -3322,6 +3343,7 @@ version = "0.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
|
||||
dependencies = [
|
||||
"block-padding 0.3.3",
|
||||
"generic-array",
|
||||
]
|
||||
|
||||
@@ -3331,7 +3353,7 @@ version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
|
||||
dependencies = [
|
||||
"block-padding",
|
||||
"block-padding 0.4.2",
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
@@ -4010,6 +4032,20 @@ dependencies = [
|
||||
"winapi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
@@ -4020,6 +4056,15 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -4046,6 +4091,16 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
@@ -5612,6 +5667,25 @@ dependencies = [
|
||||
"syn 2.0.118",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "secret-service"
|
||||
version = "5.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9a62d7f86047af0077255a29494136b9aaaf697c76ff70b8e49cded4e2623c14"
|
||||
dependencies = [
|
||||
"aes 0.8.4",
|
||||
"cbc 0.1.2",
|
||||
"futures-util",
|
||||
"generic-array",
|
||||
"getrandom 0.2.17",
|
||||
"hkdf",
|
||||
"num",
|
||||
"once_cell",
|
||||
"serde",
|
||||
"sha2 0.10.9",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
@@ -7035,6 +7109,7 @@ dependencies = [
|
||||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -8807,6 +8882,7 @@ dependencies = [
|
||||
"rustix",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
|
||||
+14
-1
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.28.2"
|
||||
version = "0.29.2"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -116,8 +116,18 @@ sys-locale = "0.3"
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.31", features = ["signal", "process"] }
|
||||
|
||||
# Reading the desktop's titlebar button layout for the in-app window controls.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
gtk = "0.18"
|
||||
# Reading the source browser's "<Brand> Safe Storage" secret during profile
|
||||
# import, so its cookies and passwords can be re-encrypted for Wayfern.
|
||||
secret-service = { version = "5", default-features = false, features = ["rt-tokio-crypto-rust"] }
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation = "0.10"
|
||||
# Reading the source browser's "<Brand> Safe Storage" Keychain item during
|
||||
# profile import, so its cookies and passwords can be re-encrypted for Wayfern.
|
||||
security-framework = "3"
|
||||
objc2 = "0.6.4"
|
||||
objc2-app-kit = { version = "0.3.2", features = ["NSWindow", "NSApplication", "NSRunningApplication"] }
|
||||
|
||||
@@ -134,6 +144,9 @@ windows = { version = "0.62", features = [
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Registry",
|
||||
"Win32_UI_Shell",
|
||||
# CryptUnprotectData, for unwrapping the source browser's os_crypt key from
|
||||
# `Local State` during profile import.
|
||||
"Win32_Security_Cryptography",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
"core:event:allow-emit-to",
|
||||
"core:event:allow-unlisten",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-start-resize-dragging",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-minimize",
|
||||
|
||||
+66
-22
@@ -60,8 +60,12 @@ export function requestedTarget() {
|
||||
return match[1].trim();
|
||||
}
|
||||
|
||||
function sha256(path) {
|
||||
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
||||
function sha256(bytes) {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function sha256File(path) {
|
||||
return sha256(readFileSync(path));
|
||||
}
|
||||
|
||||
export function xrayBinaryName(target) {
|
||||
@@ -121,11 +125,60 @@ function extractArchive(archive, destinationDir, windowsTarget) {
|
||||
};
|
||||
}
|
||||
|
||||
/// Attempts for the archive download. A release asset fetch is a network call
|
||||
/// on every CI job, and a single transport error ("fetch failed") has taken
|
||||
/// whole builds down. Retrying is safe because the checksum below is verified
|
||||
/// on every attempt, so a truncated or substituted archive still cannot pass.
|
||||
const DOWNLOAD_ATTEMPTS = 3;
|
||||
|
||||
export async function downloadVerifiedArchive(url, archive, expectedSha256) {
|
||||
let lastError;
|
||||
|
||||
for (let attempt = 1; attempt <= DOWNLOAD_ATTEMPTS; attempt += 1) {
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to download Xray-core (${response.status} ${response.statusText})`,
|
||||
);
|
||||
}
|
||||
|
||||
// The response body is verified in memory and only then written out, so
|
||||
// bytes that fail the pinned digest never reach the file system at all.
|
||||
// Writing first and checking afterwards left an unverified archive on
|
||||
// disk for the rest of the attempt, and any later reader of that path
|
||||
// would have been trusting a plain network download.
|
||||
const payload = Buffer.from(await response.arrayBuffer());
|
||||
const actual = sha256(payload);
|
||||
if (actual !== expectedSha256) {
|
||||
throw new Error(
|
||||
`Xray-core checksum mismatch: expected ${expectedSha256}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
writeFileSync(archive, payload);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < DOWNLOAD_ATTEMPTS) {
|
||||
console.warn(
|
||||
`Xray-core download attempt ${attempt} failed (${error.message}); retrying`,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, attempt * 2000));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
export async function downloadXray(target = requestedTarget()) {
|
||||
const asset = XRAY_ASSETS[target];
|
||||
if (!asset) {
|
||||
// `target` comes from --target/$TARGET, and it decides the file this writes
|
||||
// into src-tauri/binaries. Only an own key of the pinned table is a target;
|
||||
// a plain lookup also answers for inherited names like `constructor`.
|
||||
if (!Object.hasOwn(XRAY_ASSETS, target)) {
|
||||
throw new Error(`Xray-core is not packaged for Rust target '${target}'`);
|
||||
}
|
||||
const asset = XRAY_ASSETS[target];
|
||||
|
||||
const windowsTarget = target.includes("windows");
|
||||
const destinationDir = join(MANIFEST_DIR, "binaries");
|
||||
@@ -143,8 +196,8 @@ export async function downloadXray(target = requestedTarget()) {
|
||||
if (
|
||||
source.version === XRAY_VERSION &&
|
||||
source.archiveSha256 === asset.sha256 &&
|
||||
source.binarySha256 === sha256(destination) &&
|
||||
source.licenseSha256 === sha256(licenseDestination)
|
||||
source.binarySha256 === sha256File(destination) &&
|
||||
source.licenseSha256 === sha256File(licenseDestination)
|
||||
) {
|
||||
return destination;
|
||||
}
|
||||
@@ -157,20 +210,11 @@ export async function downloadXray(target = requestedTarget()) {
|
||||
const scratch = mkdtempSync(join(tmpdir(), "donut-xray-"));
|
||||
try {
|
||||
const archive = join(scratch, basename(asset.name));
|
||||
const response = await fetch(xrayDownloadUrl(asset.name));
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to download Xray-core (${response.status} ${response.statusText})`,
|
||||
);
|
||||
}
|
||||
writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
|
||||
|
||||
const actual = sha256(archive);
|
||||
if (actual !== asset.sha256) {
|
||||
throw new Error(
|
||||
`Xray-core checksum mismatch: expected ${asset.sha256}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
await downloadVerifiedArchive(
|
||||
xrayDownloadUrl(asset.name),
|
||||
archive,
|
||||
asset.sha256,
|
||||
);
|
||||
|
||||
const extracted = extractArchive(archive, scratch, windowsTarget);
|
||||
if (!existsSync(extracted.binary) || !existsSync(extracted.license)) {
|
||||
@@ -189,8 +233,8 @@ export async function downloadXray(target = requestedTarget()) {
|
||||
{
|
||||
version: XRAY_VERSION,
|
||||
archiveSha256: asset.sha256,
|
||||
binarySha256: sha256(destination),
|
||||
licenseSha256: sha256(licenseDestination),
|
||||
binarySha256: sha256File(destination),
|
||||
licenseSha256: sha256File(licenseDestination),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createHash } from "node:crypto";
|
||||
import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import test from "node:test";
|
||||
import {
|
||||
downloadVerifiedArchive,
|
||||
downloadXray,
|
||||
windowsExtractionInvocation,
|
||||
XRAY_ASSETS,
|
||||
@@ -122,3 +127,58 @@ test("rejects an unsupported target before downloading", async () => {
|
||||
/not packaged for Rust target/,
|
||||
);
|
||||
});
|
||||
|
||||
// `constructor`, `__proto__` and friends answer a plain `XRAY_ASSETS[target]`
|
||||
// lookup, and `target` picks the path this script writes into src-tauri.
|
||||
test("rejects inherited object keys as targets", async () => {
|
||||
for (const target of ["__proto__", "constructor", "toString"]) {
|
||||
await assert.rejects(
|
||||
downloadXray(target),
|
||||
/not packaged for Rust target/,
|
||||
target,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
async function withStubbedFetch(body, run) {
|
||||
const scratch = mkdtempSync(join(tmpdir(), "donut-xray-test-"));
|
||||
const archive = join(scratch, "Xray-linux-64.zip");
|
||||
const realFetch = globalThis.fetch;
|
||||
globalThis.fetch = async () => new Response(body);
|
||||
try {
|
||||
await run(archive);
|
||||
} finally {
|
||||
globalThis.fetch = realFetch;
|
||||
rmSync(scratch, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("writes the archive once the pinned digest matches", async () => {
|
||||
const body = Buffer.from("xray archive bytes");
|
||||
const digest = createHash("sha256").update(body).digest("hex");
|
||||
|
||||
await withStubbedFetch(body, async (archive) => {
|
||||
await downloadVerifiedArchive(
|
||||
"https://example.invalid/x.zip",
|
||||
archive,
|
||||
digest,
|
||||
);
|
||||
assert.deepEqual(readFileSync(archive), body);
|
||||
});
|
||||
});
|
||||
|
||||
// The bytes are hashed in memory and only then written, so a substituted or
|
||||
// truncated response never lands on disk for a later step to pick up.
|
||||
test("leaves nothing on disk when the payload fails its checksum", async () => {
|
||||
await withStubbedFetch(Buffer.from("tampered"), async (archive) => {
|
||||
await assert.rejects(
|
||||
downloadVerifiedArchive(
|
||||
"https://example.invalid/x.zip",
|
||||
archive,
|
||||
"0".repeat(64),
|
||||
),
|
||||
/checksum mismatch/,
|
||||
);
|
||||
assert.equal(existsSync(archive), false);
|
||||
});
|
||||
});
|
||||
|
||||
+377
-45
@@ -5,7 +5,10 @@ use crate::profile::manager::ProfileManager;
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::tag_manager::TAG_MANAGER;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
extract::{
|
||||
ws::{Message as WsMessage, WebSocket, WebSocketUpgrade},
|
||||
Path, Query, State,
|
||||
},
|
||||
http::{header, HeaderMap, Method, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Json, Response},
|
||||
@@ -509,6 +512,7 @@ struct ImportProxiesResponse {
|
||||
run_profile,
|
||||
run_profile_remote,
|
||||
stop_remote_session,
|
||||
remote_session_cdp,
|
||||
list_remote_sessions_api,
|
||||
get_remote_session_api,
|
||||
get_remote_hours,
|
||||
@@ -623,6 +627,7 @@ struct ImportProxiesResponse {
|
||||
crate::profile_importer::DuplicateStrategy,
|
||||
crate::profile_importer::ProfileImportItemResult,
|
||||
crate::profile_importer::ProfileImportBatchResult,
|
||||
crate::profile_import::report::ProfileImportReport,
|
||||
)),
|
||||
tags(
|
||||
(name = "profiles", description = "Profile management endpoints"),
|
||||
@@ -794,6 +799,7 @@ fn build_v1_router() -> Router<ApiServerState> {
|
||||
// `/v1/remote-sessions/{id}`, and registering them separately would have
|
||||
// the second overwrite the first.
|
||||
.routes(routes!(get_remote_session_api, stop_remote_session))
|
||||
.routes(routes!(remote_session_cdp))
|
||||
.routes(routes!(list_remote_sessions_api))
|
||||
.routes(routes!(get_remote_hours))
|
||||
.routes(routes!(set_profile_cloud_sync))
|
||||
@@ -1061,6 +1067,20 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
|
||||
/// bare status code. Matching is on message content because the managers
|
||||
/// return plain strings (some are the JSON `{"code": ...}` strings shared
|
||||
/// with the Tauri commands).
|
||||
/// Codes meaning "this profile is held by someone else right now".
|
||||
///
|
||||
/// Kept as one list so the REST layer, which has no other way to tell a refusal
|
||||
/// apart from a validation failure, cannot drift from the guards that produce
|
||||
/// them. `PROFILE_REMOTE_SYNC_PENDING` in particular is temporary by nature: the
|
||||
/// pull that clears it is already running.
|
||||
const LAUNCH_CONFLICT_CODES: [&str; 5] = [
|
||||
"PROFILE_RUNNING",
|
||||
"PROFILE_RUNNING_REMOTELY",
|
||||
"PROFILE_REMOTE_SYNC_PENDING",
|
||||
"PROFILE_LOCKED_BY_MEMBER",
|
||||
"PROFILE_LOCKED_ELSEWHERE",
|
||||
];
|
||||
|
||||
fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
|
||||
let msg = err.to_string();
|
||||
|
||||
@@ -1069,8 +1089,19 @@ fn manager_error_response(err: impl std::fmt::Display) -> (StatusCode, String) {
|
||||
if let Some(code) = value.get("code").and_then(|c| c.as_str()) {
|
||||
let status = if code.ends_with("_NOT_FOUND") {
|
||||
StatusCode::NOT_FOUND
|
||||
} else if LAUNCH_CONFLICT_CODES.contains(&code) {
|
||||
// Someone or something else holds this profile: another team member, a
|
||||
// browser already open, or a remote session whose work has not been
|
||||
// pulled back yet. All of them are "try again later", not "your request
|
||||
// was malformed", and 400 would tell an automation client to give up.
|
||||
StatusCode::CONFLICT
|
||||
} else if code == "INTERNAL_ERROR" {
|
||||
StatusCode::INTERNAL_SERVER_ERROR
|
||||
} else if code == "PROFILE_LOCK_UNAVAILABLE" {
|
||||
// The lock service could not be reached. The launch is refused because
|
||||
// it cannot be proven safe, which is an upstream failure, not the
|
||||
// caller's fault.
|
||||
StatusCode::SERVICE_UNAVAILABLE
|
||||
} else if code.ends_with("_REQUIRES_PRO") || code.ends_with("_PAYMENT_REQUIRED") {
|
||||
// Paid-feature gates (FINGERPRINT_REQUIRES_PRO, PROXY_PAYMENT_REQUIRED).
|
||||
// Mapping them here lets the gate live in the shared manager instead of
|
||||
@@ -2295,8 +2326,9 @@ async fn delete_extension_group_api(
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 409, description = "Profile is locked by another team member"),
|
||||
(status = 409, description = "Profile is locked by another team member, running on the remote fleet, or waiting for a finished remote session to be pulled back"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 503, description = "The profile lock service could not be reached"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -2308,12 +2340,12 @@ async fn run_profile(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<RunProfileRequest>,
|
||||
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
||||
) -> Result<Json<RunProfileResponse>, (StatusCode, String)> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
||||
}
|
||||
|
||||
let headless = request.headless.unwrap_or(false);
|
||||
@@ -2322,29 +2354,34 @@ async fn run_profile(
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == id)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
||||
|
||||
if profile.is_cross_os() {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
return Err((
|
||||
StatusCode::BAD_REQUEST,
|
||||
"cannot launch a cross-OS profile locally; use /run-remote".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
// Team lock check
|
||||
// Team lock check. Routed through the shared mapper so a profile held by the
|
||||
// user's OWN remote session is a 409 that says so, rather than a bare status
|
||||
// with no body, which is what an automation client had to guess from.
|
||||
crate::team_lock::acquire_team_lock_if_needed(profile)
|
||||
.await
|
||||
.map_err(|_| StatusCode::CONFLICT)?;
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
let remote_debugging_port = {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(manager_error_response)?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||
.map_err(manager_error_response)?
|
||||
.port();
|
||||
drop(listener);
|
||||
port
|
||||
@@ -2352,23 +2389,20 @@ async fn run_profile(
|
||||
|
||||
// Use the same launch path as the main app, but force a fresh instance with
|
||||
// remote debugging enabled so the returned port is the one the browser binds.
|
||||
match crate::browser_runner::launch_browser_profile_impl(
|
||||
let updated_profile = crate::browser_runner::launch_browser_profile_impl(
|
||||
state.app_handle.clone(),
|
||||
profile.clone(),
|
||||
url,
|
||||
Some(remote_debugging_port),
|
||||
headless,
|
||||
true,
|
||||
crate::browser_runner::LaunchOptions::automation(Some(remote_debugging_port), headless),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated_profile) => Ok(Json(RunProfileResponse {
|
||||
profile_id: updated_profile.id.to_string(),
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
Ok(Json(RunProfileResponse {
|
||||
profile_id: updated_profile.id.to_string(),
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
}))
|
||||
}
|
||||
|
||||
// API Handler - Launch this profile on a REMOTE VM of its own operating system
|
||||
@@ -2734,6 +2768,157 @@ fn status_for_code(code: &str) -> StatusCode {
|
||||
}
|
||||
}
|
||||
|
||||
// API Handler - Attach a CDP client (Playwright, Puppeteer, chrome-remote-interface)
|
||||
// to a remote session.
|
||||
//
|
||||
// This is what makes `run-remote` usable. Without it the endpoint hands back a
|
||||
// session id that nothing outside this app can do anything with: the fleet's
|
||||
// relay only accepts the user's Donut cloud credential, an automation client
|
||||
// does not have one, and it must not be given one — an API token is scoped to
|
||||
// "drive my browsers", not "act as my account".
|
||||
//
|
||||
// So the socket is opened here with the credential this process already holds
|
||||
// and the frames are pumped verbatim in both directions. The caller presents
|
||||
// the ordinary API bearer token and gets a browser-level CDP endpoint at
|
||||
// `ws://127.0.0.1:<api port>/v1/remote-sessions/{id}/cdp`:
|
||||
//
|
||||
// const browser = await chromium.connectOverCDP({
|
||||
// endpointURL: `ws://127.0.0.1:10108/v1/remote-sessions/${id}/cdp`,
|
||||
// headers: { Authorization: `Bearer ${API_TOKEN}` },
|
||||
// });
|
||||
//
|
||||
// Nothing is attached to a page first, deliberately: Playwright drives
|
||||
// `Target.setAutoAttach` and builds its own session map, and a socket already
|
||||
// bound to one page would hide every other target from it.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/remote-sessions/{id}/cdp",
|
||||
params(
|
||||
("id" = String, Path, description = "Remote session ID from run-remote")
|
||||
),
|
||||
responses(
|
||||
(status = 101, description = "Switching Protocols; a browser-level CDP WebSocket follows"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "No such remote session, or it is not attachable yet"),
|
||||
(status = 502, description = "The relay could not be reached"),
|
||||
(status = 426, description = "Not a WebSocket upgrade request")
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "remote-sessions"
|
||||
)]
|
||||
async fn remote_session_cdp(
|
||||
Path(id): Path<String>,
|
||||
upgrade: WebSocketUpgrade,
|
||||
) -> Result<Response, (StatusCode, String)> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
||||
}
|
||||
|
||||
// Dialled BEFORE the upgrade is accepted, so a session that is not attachable
|
||||
// is an HTTP status the client can read. Accepting the upgrade first would
|
||||
// turn every such failure into a socket that opens and immediately closes,
|
||||
// which is what a CDP client reports as "browser closed unexpectedly".
|
||||
let upstream = crate::cdp_target::open_relay_socket(&id)
|
||||
.await
|
||||
.map_err(cdp_error_response)?;
|
||||
|
||||
Ok(
|
||||
upgrade
|
||||
.max_message_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES)
|
||||
.max_frame_size(crate::cdp_target::MAX_RELAY_MESSAGE_BYTES)
|
||||
.on_upgrade(move |client| pump_cdp(id, client, upstream)),
|
||||
)
|
||||
}
|
||||
|
||||
fn cdp_error_response(err: crate::cdp_target::CdpError) -> (StatusCode, String) {
|
||||
use crate::cdp_target::CdpError;
|
||||
let status = match err {
|
||||
CdpError::Unauthorized(_) => StatusCode::UNAUTHORIZED,
|
||||
// "Not drivable" covers a session that is still provisioning and one that
|
||||
// is not the caller's. Both are 404 to a CDP client: there is no browser at
|
||||
// this address right now.
|
||||
CdpError::NotDrivable(_) => StatusCode::NOT_FOUND,
|
||||
CdpError::Unreachable(_) => StatusCode::BAD_GATEWAY,
|
||||
CdpError::Transport(_) | CdpError::Protocol(_) => StatusCode::BAD_GATEWAY,
|
||||
};
|
||||
(status, err.to_string())
|
||||
}
|
||||
|
||||
/// Copy CDP frames between the local client and the fleet relay until either
|
||||
/// side hangs up.
|
||||
///
|
||||
/// Verbatim in both directions. This proxy deliberately understands nothing
|
||||
/// about CDP: a client that speaks a newer protocol, or a target type this
|
||||
/// build has never heard of, must keep working without a Donut release.
|
||||
async fn pump_cdp(session_id: String, client: WebSocket, upstream: crate::cdp_target::RelaySocket) {
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message as RelayMessage;
|
||||
|
||||
let (mut client_tx, mut client_rx) = client.split();
|
||||
let (mut relay_tx, mut relay_rx) = upstream.split();
|
||||
|
||||
let to_relay = async {
|
||||
while let Some(Ok(message)) = client_rx.next().await {
|
||||
let forwarded = match message {
|
||||
WsMessage::Text(text) => RelayMessage::Text(text.as_str().into()),
|
||||
WsMessage::Binary(bytes) => RelayMessage::Binary(bytes),
|
||||
WsMessage::Ping(bytes) => RelayMessage::Ping(bytes),
|
||||
WsMessage::Pong(bytes) => RelayMessage::Pong(bytes),
|
||||
WsMessage::Close(_) => break,
|
||||
};
|
||||
if relay_tx.send(forwarded).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = relay_tx.close().await;
|
||||
};
|
||||
|
||||
let to_client = async {
|
||||
while let Some(Ok(message)) = relay_rx.next().await {
|
||||
let forwarded = match message {
|
||||
RelayMessage::Text(text) => WsMessage::Text(text.as_str().into()),
|
||||
RelayMessage::Binary(bytes) => WsMessage::Binary(bytes),
|
||||
RelayMessage::Ping(bytes) => WsMessage::Ping(bytes),
|
||||
RelayMessage::Pong(bytes) => WsMessage::Pong(bytes),
|
||||
// A relay close carries the only diagnosis the server gives (1008 is a
|
||||
// rejected credential, 1013 is "not up yet"), so it is passed through
|
||||
// rather than swallowed into a bare disconnect.
|
||||
RelayMessage::Close(frame) => {
|
||||
let _ = client_tx
|
||||
.send(WsMessage::Close(frame.map(|f| {
|
||||
axum::extract::ws::CloseFrame {
|
||||
code: u16::from(f.code),
|
||||
reason: f.reason.as_str().into(),
|
||||
}
|
||||
})))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
RelayMessage::Frame(_) => continue,
|
||||
};
|
||||
if client_tx.send(forwarded).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let _ = client_tx.close().await;
|
||||
};
|
||||
|
||||
// Either direction ending means the conversation is over. Waiting for both
|
||||
// would hold a relay socket open — and one of the session's four allowed
|
||||
// attachments with it — after the client had gone.
|
||||
tokio::select! {
|
||||
() = to_relay => {}
|
||||
() = to_client => {}
|
||||
}
|
||||
log::info!("CDP proxy for remote session {session_id} closed");
|
||||
}
|
||||
|
||||
// API Handler - Every remote session this account currently owns
|
||||
#[utoipa::path(
|
||||
get,
|
||||
@@ -2849,7 +3034,7 @@ fn cookie_bot_eligible_profile(
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
||||
|
||||
crate::cookie_bot::bot_precondition(&profile)
|
||||
crate::cookie_bot::bot_precondition(&profile, &crate::cookie_bot::exit_reachability(&profile))
|
||||
.map_err(|reason| (StatusCode::BAD_REQUEST, reason))?;
|
||||
Ok(profile)
|
||||
}
|
||||
@@ -3131,10 +3316,7 @@ async fn list_cookie_bot_runs(
|
||||
async fn start_cookie_bot_run(
|
||||
Json(request): Json<StartCookieBotRunRequest>,
|
||||
) -> Result<(StatusCode, Json<crate::cookie_bot::CookieBotRunStarted>), (StatusCode, String)> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
if !crate::cloud_auth::CLOUD_AUTH.can_use_cookie_bot().await {
|
||||
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
||||
}
|
||||
|
||||
@@ -3240,6 +3422,11 @@ async fn get_cookie_bot_usage(
|
||||
}
|
||||
|
||||
// API Handler - Open URL in existing browser
|
||||
//
|
||||
// Works against a profile running here OR one running on the leased fleet: a
|
||||
// remote session is navigated over the same CDP path the automation tools use,
|
||||
// so a caller does not have to know where the browser is. The cross-OS refusal
|
||||
// therefore only applies to a profile that would have to be launched locally.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/{id}/open-url",
|
||||
@@ -3248,12 +3435,14 @@ async fn get_cookie_bot_usage(
|
||||
),
|
||||
request_body = OpenUrlRequest,
|
||||
responses(
|
||||
(status = 200, description = "URL opened successfully"),
|
||||
(status = 400, description = "Cannot open URL with a cross-OS profile"),
|
||||
(status = 200, description = "URL opened successfully, locally or on the profile's remote session"),
|
||||
(status = 400, description = "Cannot open URL with a cross-OS profile that is not running remotely"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan with browser automation required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 409, description = "Profile is locked by another team member, or waiting for a finished remote session to be pulled back"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 503, description = "The profile lock service could not be reached"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -3276,7 +3465,12 @@ async fn open_url_in_profile(
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
|
||||
browser_runner
|
||||
.open_url_with_profile(state.app_handle.clone(), id, request.url)
|
||||
.open_url_with_profile(
|
||||
state.app_handle.clone(),
|
||||
id,
|
||||
request.url,
|
||||
crate::launch_gate::FingerprintGate::Advisory,
|
||||
)
|
||||
.await
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
@@ -3284,6 +3478,12 @@ async fn open_url_in_profile(
|
||||
}
|
||||
|
||||
// API Handler - Kill browser process
|
||||
//
|
||||
// Stops the browser wherever it is. A profile open on the leased fleet is ended
|
||||
// through the backend, which is what makes this endpoint mean "stop this
|
||||
// profile" rather than "stop this profile if it happens to be on this machine" —
|
||||
// the latter reported success, killed nothing, and left the session billing to
|
||||
// its two-hour cap.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/profiles/{id}/kill",
|
||||
@@ -3291,11 +3491,12 @@ async fn open_url_in_profile(
|
||||
("id" = String, Path, description = "Profile ID")
|
||||
),
|
||||
responses(
|
||||
(status = 204, description = "Browser process killed successfully"),
|
||||
(status = 204, description = "Browser stopped, locally or on the profile's remote session"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 402, description = "Active paid plan required"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 429, description = "Automation request rate limit exceeded"),
|
||||
(status = 503, description = "The fleet could not be reached; the remote browser is still running"),
|
||||
(status = 500, description = "Internal server error")
|
||||
),
|
||||
security(
|
||||
@@ -3306,31 +3507,41 @@ async fn open_url_in_profile(
|
||||
async fn kill_profile(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
) -> Result<StatusCode, (StatusCode, String)> {
|
||||
// Programmatically launching and stopping profiles is a paid feature; the
|
||||
// run/open-url handlers gate the same way.
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.can_use_browser_automation()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
return Err((StatusCode::PAYMENT_REQUIRED, String::new()));
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(manager_error_response)?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == id)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
.ok_or((StatusCode::NOT_FOUND, "profile not found".to_string()))?;
|
||||
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
browser_runner
|
||||
.kill_browser_process(state.app_handle.clone(), profile)
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
.map_err(|e| {
|
||||
let message = e.to_string();
|
||||
// The backend refuses to retire a session it could not stop on the fleet.
|
||||
// Reporting that as a 500 invites a retry loop against a browser that is
|
||||
// still running; 503 says "it is still up, try again".
|
||||
if message.contains("REMOTE_") {
|
||||
(StatusCode::SERVICE_UNAVAILABLE, message)
|
||||
} else {
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, message)
|
||||
}
|
||||
})?;
|
||||
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
|
||||
@@ -3416,9 +3627,7 @@ async fn batch_run_profiles(
|
||||
state.app_handle.clone(),
|
||||
profile.clone(),
|
||||
request.url.clone(),
|
||||
Some(port),
|
||||
headless,
|
||||
true,
|
||||
crate::browser_runner::LaunchOptions::automation(Some(port), headless),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -4023,14 +4232,22 @@ mod tests {
|
||||
let mut local_only = profile_with(SyncMode::Disabled, Some("macos"));
|
||||
local_only.proxy_id = Some("proxy-1".to_string());
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&local_only).is_err(),
|
||||
crate::cookie_bot::bot_precondition(
|
||||
&local_only,
|
||||
&crate::remote_exit::ExitReachability::Remote
|
||||
)
|
||||
.is_err(),
|
||||
"a profile with no cloud copy has nothing for a host to open"
|
||||
);
|
||||
|
||||
let mut encrypted = profile_with(SyncMode::Encrypted, Some("macos"));
|
||||
encrypted.proxy_id = Some("proxy-1".to_string());
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&encrypted).is_err(),
|
||||
crate::cookie_bot::bot_precondition(
|
||||
&encrypted,
|
||||
&crate::remote_exit::ExitReachability::Remote
|
||||
)
|
||||
.is_err(),
|
||||
"a host cannot decrypt a profile whose key never leaves this machine"
|
||||
);
|
||||
|
||||
@@ -4038,13 +4255,21 @@ mod tests {
|
||||
datacenter_egress.proxy_id = None;
|
||||
datacenter_egress.vpn_id = None;
|
||||
assert!(
|
||||
crate::cookie_bot::bot_precondition(&datacenter_egress).is_err(),
|
||||
crate::cookie_bot::bot_precondition(
|
||||
&datacenter_egress,
|
||||
&crate::remote_exit::ExitReachability::None
|
||||
)
|
||||
.is_err(),
|
||||
"hours of traffic from a hosting ASN damages the identity being warmed"
|
||||
);
|
||||
|
||||
let mut eligible = profile_with(SyncMode::Regular, Some("macos"));
|
||||
eligible.proxy_id = Some("proxy-1".to_string());
|
||||
assert!(crate::cookie_bot::bot_precondition(&eligible).is_ok());
|
||||
assert!(crate::cookie_bot::bot_precondition(
|
||||
&eligible,
|
||||
&crate::remote_exit::ExitReachability::Remote
|
||||
)
|
||||
.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -4221,13 +4446,29 @@ mod tests {
|
||||
}
|
||||
|
||||
let import_item = schema_required(&spec, "ImportProfileItem");
|
||||
for field in ["proxy_id", "vpn_id", "browser_type"] {
|
||||
for field in ["proxy_id", "vpn_id", "browser_type", "allow_running"] {
|
||||
assert!(
|
||||
!import_item.iter().any(|f| f == field),
|
||||
"{field} must be optional on import items, required list: {import_item:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// The per-item report only exists for items that actually imported.
|
||||
let import_result = schema_required(&spec, "ProfileImportItemResult");
|
||||
assert!(
|
||||
!import_result.iter().any(|f| f == "report"),
|
||||
"report must be optional on import results, required list: {import_result:?}"
|
||||
);
|
||||
|
||||
// `ProfileImportItemResult` references it, so a missing registration would
|
||||
// leave a dangling $ref in the served spec.
|
||||
assert!(
|
||||
spec
|
||||
.pointer("/components/schemas/ProfileImportReport")
|
||||
.is_some(),
|
||||
"ProfileImportReport must be registered in ApiDoc components"
|
||||
);
|
||||
|
||||
// A remote launch with no URL just opens the browser; forcing generated
|
||||
// clients to send one would make the common case the awkward one.
|
||||
let run_remote = schema_required(&spec, "RunRemoteRequest");
|
||||
@@ -4325,12 +4566,103 @@ mod tests {
|
||||
assert!(parsed.group_id.is_none());
|
||||
assert!(parsed.duplicate_strategy.is_none());
|
||||
assert_eq!(parsed.items[0].browser_type, "chromium");
|
||||
assert_eq!(parsed.items[0].allow_running, None);
|
||||
}
|
||||
|
||||
// The served /openapi.json comes from the hand-maintained ApiDoc `paths(...)`
|
||||
// list, not from the router — endpoints registered on the router but missing
|
||||
// from ApiDoc silently disappear from the spec. Lock in the ones that were
|
||||
// once dropped, and that removed endpoints stay gone.
|
||||
#[test]
|
||||
fn a_profile_held_elsewhere_is_a_conflict_not_a_bad_request() {
|
||||
// These four refusals all mean "come back in a moment". Answering 400 tells
|
||||
// an automation client its request was malformed and to stop retrying, and
|
||||
// that is what every one of them did before they had codes at all.
|
||||
for code in [
|
||||
"PROFILE_RUNNING_REMOTELY",
|
||||
"PROFILE_REMOTE_SYNC_PENDING",
|
||||
"PROFILE_LOCKED_BY_MEMBER",
|
||||
"PROFILE_LOCKED_ELSEWHERE",
|
||||
] {
|
||||
let (status, body) = manager_error_response(serde_json::json!({ "code": code }).to_string());
|
||||
assert_eq!(status, StatusCode::CONFLICT, "{code} must be a 409");
|
||||
assert!(body.contains(code), "{code} must reach the caller");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreachable_lock_service_is_not_the_callers_fault() {
|
||||
let (status, _) =
|
||||
manager_error_response(serde_json::json!({ "code": "PROFILE_LOCK_UNAVAILABLE" }).to_string());
|
||||
assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_remote_session_exposes_a_cdp_endpoint_an_external_client_can_attach_to() {
|
||||
// Without this route `run-remote` hands back a session id that nothing
|
||||
// outside the app can use: the fleet relay accepts only the user's cloud
|
||||
// credential, which an API consumer does not have and must not be given.
|
||||
// A Playwright user reads the spec to find this, so it has to be in it.
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
let operation = &spec["paths"]["/v1/remote-sessions/{id}/cdp"]["get"];
|
||||
assert!(
|
||||
operation.is_object(),
|
||||
"the CDP attach endpoint must be in the served spec"
|
||||
);
|
||||
assert!(
|
||||
operation["responses"].get("101").is_some(),
|
||||
"a WebSocket endpoint must document its upgrade"
|
||||
);
|
||||
assert_eq!(operation["tags"][0], "remote-sessions");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_cdp_attach_failure_is_not_reported_as_a_broken_relay() {
|
||||
// A CDP client retries a 502 and gives up on a 404. Reporting "this session
|
||||
// is not up yet" as a gateway failure sends it into a loop against a
|
||||
// session that is doing exactly what it should.
|
||||
use crate::cdp_target::CdpError;
|
||||
assert_eq!(
|
||||
cdp_error_response(CdpError::NotDrivable("provisioning".into())).0,
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
assert_eq!(
|
||||
cdp_error_response(CdpError::Unauthorized("no token".into())).0,
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
assert_eq!(
|
||||
cdp_error_response(CdpError::Unreachable("dns".into())).0,
|
||||
StatusCode::BAD_GATEWAY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_kill_route_documents_that_it_can_fail_to_stop_a_remote_browser() {
|
||||
// The backend refuses to retire a session it could not stop on the fleet, so
|
||||
// stopping can genuinely fail with the browser still running. A spec that
|
||||
// only lists 204 tells a client that never happens.
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
let responses = &spec["paths"]["/v1/profiles/{id}/kill"]["post"]["responses"];
|
||||
assert!(
|
||||
responses.get("503").is_some(),
|
||||
"kill must document that the fleet may be unreachable"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_local_launch_routes_document_their_conflict() {
|
||||
// A profile waiting on a finished remote session refuses a local launch.
|
||||
// Undocumented, that reaches an integrator as an unexplained 409.
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
for path in ["/v1/profiles/{id}/run", "/v1/profiles/{id}/open-url"] {
|
||||
let responses = &spec["paths"][path]["post"]["responses"];
|
||||
assert!(
|
||||
responses.get("409").is_some(),
|
||||
"{path} must document its conflict"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openapi_spec_covers_registered_routes() {
|
||||
let spec = serde_json::to_value(ApiDoc::openapi()).expect("spec serializes");
|
||||
|
||||
@@ -231,7 +231,7 @@ mod windows {
|
||||
pub fn is_wayfern_version_downloaded(install_dir: &Path) -> bool {
|
||||
if wayfern_executable_candidates(install_dir)
|
||||
.iter()
|
||||
.any(|exe_path| exe_path.exists() && exe_path.is_file())
|
||||
.any(|exe_path| exe_path.exists() && exe_path.is_file() && has_sibling_dll(exe_path))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -239,7 +239,8 @@ mod windows {
|
||||
// Check for any .exe file that looks like the browser
|
||||
if let Ok(entries) = std::fs::read_dir(install_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if is_wayfern_exe(&entry.path()) {
|
||||
let path = entry.path();
|
||||
if is_wayfern_exe(&path) && has_sibling_dll(&path) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -380,6 +381,33 @@ impl BrowserFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the directory holding `exe_path` also contains at least one `.dll`.
|
||||
///
|
||||
/// A Chromium build on Windows cannot start without its sibling libraries
|
||||
/// (`chrome.dll` and friends) and its `.manifest`; a lone `.exe` is a gutted
|
||||
/// install, and launching it fails inside the Windows loader with os error
|
||||
/// 14001 (`ERROR_SXS_CANT_GEN_ACTCTX`, "side-by-side configuration is
|
||||
/// incorrect"). Treating such a directory as downloaded is what made that state
|
||||
/// permanent: the registry rescan re-added it as a healthy install, so no
|
||||
/// re-download was ever offered. The check is scoped to the executable's own
|
||||
/// directory because the payload may sit at the version root or in a `bin/`,
|
||||
/// `wayfern/`, `wayfern-win/` or `chrome-win/` subdirectory.
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn has_sibling_dll(exe_path: &Path) -> bool {
|
||||
let Some(dir) = exe_path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
||||
return false;
|
||||
};
|
||||
entries.flatten().any(|entry| {
|
||||
entry
|
||||
.path()
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("dll"))
|
||||
})
|
||||
}
|
||||
|
||||
/// Check if a file is a valid PE executable by reading its magic bytes (MZ).
|
||||
/// Returns false for archive files (.zip starts with PK, etc.) that were
|
||||
/// incorrectly named with a .exe extension.
|
||||
@@ -575,6 +603,58 @@ mod tests {
|
||||
assert!(exe.ends_with(std::path::Path::new("wayfern-win").join("wayfern.exe")));
|
||||
}
|
||||
|
||||
/// A gutted Windows install (the `.exe` survived a cleanup pass that deleted
|
||||
/// every `.dll` and the `.manifest`) must not read as downloaded, otherwise it
|
||||
/// is re-registered as healthy and launching it fails with os error 14001.
|
||||
/// Runs on every platform because the predicate is platform-independent.
|
||||
#[test]
|
||||
fn test_lone_exe_is_not_a_valid_windows_install() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let install_dir = temp.path();
|
||||
|
||||
let exe = install_dir.join("chrome.exe");
|
||||
std::fs::File::create(&exe).unwrap();
|
||||
assert!(
|
||||
!has_sibling_dll(&exe),
|
||||
"an .exe with no sibling .dll is a gutted install"
|
||||
);
|
||||
|
||||
std::fs::File::create(install_dir.join("chrome.dll")).unwrap();
|
||||
assert!(
|
||||
has_sibling_dll(&exe),
|
||||
"an .exe next to its libraries is a complete install"
|
||||
);
|
||||
}
|
||||
|
||||
/// The DLL check is scoped to the executable's own directory, so the nested
|
||||
/// `chrome-win/` and `wayfern-win/` layouts are not falsely rejected because
|
||||
/// the version root happens to hold no libraries.
|
||||
#[test]
|
||||
fn test_sibling_dll_check_is_scoped_to_the_executable_directory() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let install_dir = temp.path();
|
||||
|
||||
let subdir = install_dir.join("chrome-win");
|
||||
std::fs::create_dir_all(&subdir).unwrap();
|
||||
let exe = subdir.join("chrome.exe");
|
||||
std::fs::File::create(&exe).unwrap();
|
||||
std::fs::File::create(subdir.join("CHROME.DLL")).unwrap();
|
||||
|
||||
assert!(
|
||||
has_sibling_dll(&exe),
|
||||
"libraries beside the executable count regardless of case or nesting"
|
||||
);
|
||||
|
||||
let root_exe = install_dir.join("chrome.exe");
|
||||
std::fs::File::create(&root_exe).unwrap();
|
||||
assert!(
|
||||
!has_sibling_dll(&root_exe),
|
||||
"libraries in a sibling subdirectory must not validate a bare root .exe"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proxy_settings_serialization() {
|
||||
let proxy = ProxySettings {
|
||||
|
||||
+387
-64
@@ -15,6 +15,13 @@ static PROFILE_LAUNCH_LOCKS: LazyLock<
|
||||
tokio::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>,
|
||||
> = LazyLock::new(|| tokio::sync::Mutex::new(HashMap::new()));
|
||||
|
||||
/// How long a remote navigation waits for the page to settle.
|
||||
///
|
||||
/// A relayed round trip crosses two networks and the page load itself happens
|
||||
/// on hardware in another country, so this is deliberately the same budget the
|
||||
/// automation tools give a navigation rather than a loopback-sized one.
|
||||
const REMOTE_NAVIGATE_TIMEOUT_SECS: u64 = 30;
|
||||
|
||||
async fn lock_profile_launch(profile_id: &str) -> tokio::sync::OwnedMutexGuard<()> {
|
||||
let lock = {
|
||||
let mut locks = PROFILE_LAUNCH_LOCKS.lock().await;
|
||||
@@ -165,12 +172,15 @@ impl BrowserRunner {
|
||||
});
|
||||
}
|
||||
|
||||
/// Resolve the upstream a launch will use.
|
||||
///
|
||||
/// Deliberately does NOT fire the launch hook: that moved below the gate, so
|
||||
/// a launch the user blocks and then retries calls the user's webhook once
|
||||
/// rather than once per attempt.
|
||||
async fn resolve_launch_proxy(
|
||||
&self,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
Self::fire_launch_hook(profile);
|
||||
|
||||
self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await
|
||||
@@ -203,9 +213,9 @@ impl BrowserRunner {
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
url: Option<String>,
|
||||
_local_proxy_settings: Option<&ProxySettings>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
gate: &crate::launch_gate::FingerprintGate,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Handle Wayfern profiles using WayfernManager
|
||||
if profile.browser == "wayfern" {
|
||||
@@ -270,12 +280,61 @@ impl BrowserRunner {
|
||||
upstream_proxy = Some(worker.local_proxy_settings());
|
||||
}
|
||||
|
||||
/// Stops a VPN worker this launch started, if the launch then fails.
|
||||
///
|
||||
/// `created` is the whole point: `start_vpn_worker` reuses a live worker
|
||||
/// for the same VPN, so an unconditional stop would sever the tunnel a
|
||||
/// *different* profile is browsing through the moment this one is
|
||||
/// cancelled. The in-use check is a second belt for a worker adopted by a
|
||||
/// browser that started between the two points.
|
||||
struct VpnLaunchGuard {
|
||||
worker_id: Option<String>,
|
||||
vpn_id: String,
|
||||
created: bool,
|
||||
profile_name: String,
|
||||
}
|
||||
impl Drop for VpnLaunchGuard {
|
||||
fn drop(&mut self) {
|
||||
let Some(worker_id) = self.worker_id.take() else {
|
||||
return;
|
||||
};
|
||||
if !self.created {
|
||||
return;
|
||||
}
|
||||
log::warn!(
|
||||
"Launch failed after VPN worker start for profile {}; stopping worker",
|
||||
self.profile_name
|
||||
);
|
||||
let vpn_id = self.vpn_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Serialize against worker startup for the whole check-then-stop.
|
||||
// Without it another launch can adopt this worker between the
|
||||
// in-use check and the kill, and lose its tunnel a moment later.
|
||||
let _adopt_guard = crate::vpn_worker_runner::lock_vpn_starts().await;
|
||||
if crate::vpn_worker_runner::vpn_id_in_use_by_running_browser(&vpn_id) {
|
||||
log::info!("VPN {vpn_id} is still in use by a running browser; leaving it up");
|
||||
return;
|
||||
}
|
||||
if let Err(error) = crate::vpn_worker_runner::stop_vpn_worker(&worker_id).await {
|
||||
log::warn!("Failed to stop VPN worker after failed launch: {error}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
let mut vpn_launch_guard: Option<VpnLaunchGuard> = None;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
if let Some(ref vpn_id) = profile.vpn_id {
|
||||
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
|
||||
Ok(vpn_worker) => {
|
||||
if let Some(port) = vpn_worker.local_port {
|
||||
match crate::vpn_worker_runner::start_vpn_worker_tracked(vpn_id).await {
|
||||
Ok(started) => {
|
||||
vpn_launch_guard = Some(VpnLaunchGuard {
|
||||
worker_id: Some(started.config.id.clone()),
|
||||
vpn_id: vpn_id.clone(),
|
||||
created: started.created,
|
||||
profile_name: profile.name.clone(),
|
||||
});
|
||||
if let Some(port) = started.config.local_port {
|
||||
upstream_proxy = Some(ProxySettings {
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
@@ -288,12 +347,33 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to start VPN worker: {e}").into());
|
||||
return Err(crate::backend_error_with_detail("VPN_WORKER_START_FAILED", e).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The gate sits exactly here on purpose. By this line the upstream is
|
||||
// fully normalized across all three transports — VLESS and VPN are
|
||||
// authenticated loopback workers, a stored proxy is its resolved
|
||||
// settings — so one probe covers every profile. And it is still ahead of
|
||||
// the local proxy worker, the decrypted profile copy, the extension
|
||||
// unpack, and the browser process, so a blocked launch has nothing to
|
||||
// undo beyond the two workers whose guards are already armed above.
|
||||
//
|
||||
// Run concurrently with the blocklist compile so the added wall clock is
|
||||
// max(), not sum().
|
||||
let (blocklist, gate_result) = tokio::join!(
|
||||
Self::resolve_blocklist_file(profile),
|
||||
crate::launch_gate::enforce_fingerprint_gate(profile, upstream_proxy.as_ref(), gate),
|
||||
);
|
||||
gate_result.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
let (blocklist_file, dns_allowlist_mode) = blocklist?;
|
||||
|
||||
// Past the gate: this launch is really happening, so tell the user's
|
||||
// webhook exactly once.
|
||||
Self::fire_launch_hook(profile);
|
||||
|
||||
log::info!(
|
||||
"Starting local proxy for Wayfern profile: {} (upstream: {})",
|
||||
profile.name,
|
||||
@@ -306,7 +386,6 @@ impl BrowserRunner {
|
||||
// Start the proxy and get local proxy settings
|
||||
// If proxy startup fails, DO NOT launch Wayfern - it requires local proxy
|
||||
let profile_id_str = profile.id.to_string();
|
||||
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.
|
||||
@@ -461,6 +540,25 @@ impl BrowserRunner {
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir);
|
||||
|
||||
// Profiles imported by builds before the layout fix have their content at
|
||||
// the user-data-dir root instead of under `Default/`, so the browser has
|
||||
// never seen a byte of it. Move it into place now, while the profile is
|
||||
// provably not running. Secrets stay unreadable — the source key was
|
||||
// never captured and cannot be recovered after the fact — but history,
|
||||
// bookmarks, extensions and site data come back.
|
||||
match crate::profile_import::repair_legacy_layout(&profile_data_path) {
|
||||
Ok(true) => log::info!(
|
||||
"Repaired legacy import layout for profile: {}",
|
||||
updated_profile.name
|
||||
),
|
||||
Ok(false) => {}
|
||||
Err(e) => log::warn!(
|
||||
"Could not repair legacy import layout for {}: {e}",
|
||||
updated_profile.name
|
||||
),
|
||||
}
|
||||
|
||||
let profile_path_str = profile_data_path.to_string_lossy().to_string();
|
||||
|
||||
// Install extensions if an extension group is assigned
|
||||
@@ -546,11 +644,14 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// The browser and both detached routing workers now share one verified
|
||||
// The browser and every detached routing worker now share one verified
|
||||
// process identity, so later profile-persistence failures must not tear
|
||||
// down a live route.
|
||||
proxy_launch_guard.armed = false;
|
||||
xray_launch_guard.worker_id = None;
|
||||
if let Some(guard) = vpn_launch_guard.as_mut() {
|
||||
guard.worker_id = None;
|
||||
}
|
||||
|
||||
// Wayfern.setFingerprint echoes back the fingerprint the browser actually
|
||||
// applied, which may be UPGRADED from the stored one (e.g. when the
|
||||
@@ -687,6 +788,7 @@ impl BrowserRunner {
|
||||
url: Option<String>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
gate: &crate::launch_gate::FingerprintGate,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Wayfern starts (and PID-reconciles) its own local proxy
|
||||
// inside `launch_browser_internal`, so we hand it None here rather than
|
||||
@@ -696,9 +798,9 @@ impl BrowserRunner {
|
||||
app_handle,
|
||||
profile,
|
||||
url,
|
||||
None,
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
gate,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -709,6 +811,7 @@ impl BrowserRunner {
|
||||
profile: &BrowserProfile,
|
||||
url: Option<String>,
|
||||
internal_proxy_settings: Option<&ProxySettings>,
|
||||
gate: &crate::launch_gate::FingerprintGate,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
log::info!(
|
||||
"launch_or_open_url called for profile: {} (ID: {})",
|
||||
@@ -789,14 +892,7 @@ impl BrowserRunner {
|
||||
} else {
|
||||
log::info!("Launching new browser instance - browser not running");
|
||||
self
|
||||
.launch_browser_internal(
|
||||
app_handle.clone(),
|
||||
&final_profile,
|
||||
url,
|
||||
internal_proxy_settings,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.launch_browser_internal(app_handle.clone(), &final_profile, url, None, false, gate)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -829,11 +925,64 @@ impl BrowserRunner {
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
|
||||
|
||||
// "Stop this profile" has to mean the browser that is actually running, and
|
||||
// for a profile on the leased fleet that browser is not on this machine.
|
||||
// Without this, stopping reported success, killed nothing, and left the
|
||||
// session running to its two-hour cap — billing the user for every minute
|
||||
// and holding their profile lock the whole time.
|
||||
if self.stop_remote_session_for(&app_handle, profile).await? {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
self
|
||||
.kill_browser_process_unlocked(app_handle, profile)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Stop this profile's fleet session, if it has one. Returns whether it did.
|
||||
///
|
||||
/// Guarded on there being no local process so a locally running profile never
|
||||
/// pays for the lookup, exactly as the open-URL path is: the profile lock
|
||||
/// makes a local and a remote browser mutually exclusive.
|
||||
async fn stop_remote_session_for(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
if profile.process_id.is_some() {
|
||||
return Ok(false);
|
||||
}
|
||||
let profile_id = profile.id.to_string();
|
||||
let Some(session_id) = crate::remote_handoff::running_session_for_profile(&profile_id) else {
|
||||
return Ok(false);
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Stopping remote session {session_id} for profile {} ({profile_id})",
|
||||
profile.name
|
||||
);
|
||||
crate::remote_session::end_remote_session(&session_id)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
// Surfaced rather than swallowed. The backend refuses to retire a
|
||||
// session it could not stop on the fleet, so a failure here means the
|
||||
// browser is STILL RUNNING; reporting success would tell the user their
|
||||
// profile is free when a host is still writing to it.
|
||||
log::warn!("Failed to stop remote session {session_id}: {e}");
|
||||
e.to_error_json().into()
|
||||
})?;
|
||||
|
||||
// The session is down and its work is in cloud storage. This is what puts
|
||||
// the profile into "pending sync" and starts the pull, so the user is not
|
||||
// handed back a profile directory that predates the session they just ran.
|
||||
//
|
||||
// The session's own profile lock is released by the backend when it retires
|
||||
// the row; nothing is released from here, because this client never held it.
|
||||
crate::remote_session::note_session_stopped(app_handle, &session_id);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
async fn kill_browser_process_unlocked(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1210,6 +1359,7 @@ impl BrowserRunner {
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
url: String,
|
||||
gate: crate::launch_gate::FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
// Get the profile by name
|
||||
let profiles = self
|
||||
@@ -1222,6 +1372,29 @@ impl BrowserRunner {
|
||||
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
|
||||
let _profile_launch_guard = lock_profile_launch(&profile.id.to_string()).await;
|
||||
|
||||
// A profile already open on the leased fleet is driven, not launched. This
|
||||
// sits above the cross-OS guard on purpose: a Windows profile cannot run on
|
||||
// this Mac, which is the whole reason it is running remotely, and refusing
|
||||
// to point it at a URL for that reason would make the remote session
|
||||
// unusable from the one endpoint that exists to use it.
|
||||
//
|
||||
// Guarded on there being no local process, so a profile running here never
|
||||
// pays for the lookup: a local launch records a pid, and the profile lock
|
||||
// keeps a local and a remote session mutually exclusive.
|
||||
if profile.process_id.is_none() {
|
||||
if let Ok(target) = crate::cdp_target::resolve(&profile).await {
|
||||
if target.is_remote() {
|
||||
log::info!("Opening URL through {}", target.describe());
|
||||
return crate::cdp_target::navigate(&target, &url, REMOTE_NAVIGATE_TIMEOUT_SECS)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::warn!("Failed to open a URL on the remote browser: {e}");
|
||||
format!("Failed to open URL with profile: {e}")
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if profile.is_cross_os() {
|
||||
return Err(format!(
|
||||
"Cannot open URL with profile '{}': this profile was created on {} and cannot be used on a different operating system",
|
||||
@@ -1230,19 +1403,36 @@ impl BrowserRunner {
|
||||
));
|
||||
}
|
||||
|
||||
// Past this point a local browser is about to be launched, and until now
|
||||
// this was the ONE launch path that took neither the profile lock nor any
|
||||
// notice of the fleet. A remote session whose state could not be read (a
|
||||
// dropped event stream plus an unreachable backend) fell straight through
|
||||
// to a local launch on a profile a host was writing to.
|
||||
crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?;
|
||||
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
|
||||
log::info!("Opening URL with selected profile");
|
||||
|
||||
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
|
||||
self
|
||||
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
|
||||
if let Err(e) = self
|
||||
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None, &gate)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
log::info!(
|
||||
"Failed to open URL with selected profile: {}",
|
||||
crate::log_redaction::text(&e.to_string())
|
||||
);
|
||||
format!("Failed to open URL with profile: {e}")
|
||||
})?;
|
||||
{
|
||||
log::info!(
|
||||
"Failed to open URL with selected profile: {}",
|
||||
crate::log_redaction::text(&e.to_string())
|
||||
);
|
||||
// This path takes the team lock too, and a blocked launch never records a
|
||||
// process_id for the status sweep to release it from.
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
// Pass structured errors through untouched: the gate's block carries the
|
||||
// mismatch detail the dialog renders, and wrapping it in English would
|
||||
// reach the user as raw JSON.
|
||||
return Err(crate::wrap_backend_error(
|
||||
e,
|
||||
"Failed to open URL with profile",
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("Successfully opened URL with selected profile");
|
||||
Ok(())
|
||||
@@ -1254,18 +1444,115 @@ pub async fn launch_browser_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: BrowserProfile,
|
||||
url: Option<String>,
|
||||
consent_token: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
launch_browser_profile_impl(app_handle, profile, url, None, false, false).await
|
||||
let options = LaunchOptions {
|
||||
gate: match consent_token {
|
||||
Some(token) => crate::launch_gate::FingerprintGate::Consented(token),
|
||||
None => crate::launch_gate::FingerprintGate::Enforce,
|
||||
},
|
||||
..Default::default()
|
||||
};
|
||||
launch_browser_profile_impl(app_handle, profile, url, options).await
|
||||
}
|
||||
|
||||
/// How one launch should behave.
|
||||
///
|
||||
/// A struct rather than four trailing positional arguments: `headless` and
|
||||
/// `force_new` are already passed adjacently as bare booleans, so a fifth would
|
||||
/// compile everywhere while silently inverting behavior wherever the order was
|
||||
/// got wrong.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct LaunchOptions {
|
||||
pub remote_debugging_port: Option<u16>,
|
||||
pub headless: bool,
|
||||
pub force_new: bool,
|
||||
pub gate: crate::launch_gate::FingerprintGate,
|
||||
}
|
||||
|
||||
impl LaunchOptions {
|
||||
/// Automation defaults: report, never block, never probe. A headless client
|
||||
/// has no dialog to answer and cannot regenerate its fingerprint mid-run, so
|
||||
/// a hard failure would turn a warning into an outage for a whole fleet.
|
||||
pub fn automation(remote_debugging_port: Option<u16>, headless: bool) -> Self {
|
||||
Self {
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
force_new: true,
|
||||
gate: crate::launch_gate::FingerprintGate::Advisory,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the team lock a launch attempt took before it failed.
|
||||
///
|
||||
/// Until the gate existed, failing here was rare enough that leaking was merely
|
||||
/// untidy. Cancelling a blocked launch is now an ordinary outcome, and the lock
|
||||
/// renews itself on a 30s heartbeat while only ever being released via a stored
|
||||
/// `process_id` — which a launch that never spawned does not have. So a leak
|
||||
/// leaves the profile reading as locked to the whole team until the app quits.
|
||||
///
|
||||
/// `acquired` is threaded from `acquire_team_lock_if_needed` so this releases
|
||||
/// only what this call took, never a lock a REST handler up the stack owns.
|
||||
///
|
||||
/// Several of these error paths are reachable while a browser for the profile
|
||||
/// is genuinely still running — `PROFILE_RUNNING`, or a failure to open a URL
|
||||
/// in an existing window. That browser owns the lock and the running mark, so
|
||||
/// releasing either would strand it: the team would see the profile as free
|
||||
/// while someone is typing in it, and `mark_profile_stopped` would queue a sync
|
||||
/// of a profile directory being written to. Hence the liveness check.
|
||||
async fn unwind_launch(profile: &BrowserProfile, acquired_team_lock: bool) {
|
||||
if browser_is_running_for(&profile.id.to_string()) {
|
||||
log::debug!(
|
||||
"Not unwinding launch state for {}: a browser is still running for it",
|
||||
profile.name
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if acquired_team_lock {
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
}
|
||||
// Otherwise this mark sticks for the rest of the session and silently defers
|
||||
// every sync of the profile. Safe here precisely because nothing is running.
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
scheduler
|
||||
.mark_profile_stopped(&profile.id.to_string())
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a live browser process is recorded for this profile right now.
|
||||
/// Re-read from disk: the caller's copy predates the launch attempt.
|
||||
fn browser_is_running_for(profile_id: &str) -> bool {
|
||||
BrowserRunner::instance()
|
||||
.profile_manager
|
||||
.list_profiles()
|
||||
.ok()
|
||||
.and_then(|profiles| {
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.map(|p| {
|
||||
p.process_id
|
||||
.is_some_and(crate::proxy_storage::is_process_running)
|
||||
})
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn launch_browser_profile_impl(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: BrowserProfile,
|
||||
url: Option<String>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
force_new: bool,
|
||||
options: LaunchOptions,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let LaunchOptions {
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
force_new,
|
||||
gate,
|
||||
} = options;
|
||||
log::info!(
|
||||
"Launch request received for profile: {} (ID: {})",
|
||||
profile.name,
|
||||
@@ -1281,8 +1568,14 @@ pub async fn launch_browser_profile_impl(
|
||||
));
|
||||
}
|
||||
|
||||
// Refuse a launch that would run over work a remote session has not handed
|
||||
// back yet. Checked before the profile lock because it answers without a
|
||||
// round trip and because it stays true after the session's lock is released:
|
||||
// the lock protects the browser, this protects the bytes it wrote.
|
||||
crate::remote_handoff::ensure_local_launch_allowed(&profile.id.to_string())?;
|
||||
|
||||
// Team lock check: if profile is sync-enabled and user is on a team, acquire lock
|
||||
crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
let acquired_team_lock = crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
|
||||
// Notify sync scheduler that profile is now running and queue sync for when it stops
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
@@ -1306,6 +1599,7 @@ pub async fn launch_browser_profile_impl(
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap_or_else(|| profile.clone()),
|
||||
Err(e) => {
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -1322,15 +1616,24 @@ pub async fn launch_browser_profile_impl(
|
||||
profile_for_launch.id
|
||||
);
|
||||
|
||||
if force_new
|
||||
&& browser_runner
|
||||
if force_new {
|
||||
let already_running = match browser_runner
|
||||
.check_browser_status(app_handle.clone(), &profile_for_launch)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
crate::wrap_backend_error(error, "Failed to check browser status before launch")
|
||||
})?
|
||||
{
|
||||
return Err(crate::backend_error("PROFILE_RUNNING"));
|
||||
{
|
||||
Ok(running) => running,
|
||||
Err(error) => {
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
return Err(crate::wrap_backend_error(
|
||||
error,
|
||||
"Failed to check browser status before launch",
|
||||
));
|
||||
}
|
||||
};
|
||||
if already_running {
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
return Err(crate::backend_error("PROFILE_RUNNING"));
|
||||
}
|
||||
}
|
||||
|
||||
// Launch browser or open URL in existing instance. Wayfern starts its
|
||||
@@ -1348,39 +1651,54 @@ pub async fn launch_browser_profile_impl(
|
||||
url,
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
&gate,
|
||||
)
|
||||
.await
|
||||
} else {
|
||||
browser_runner
|
||||
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None)
|
||||
.launch_or_open_url(app_handle.clone(), &profile_for_launch, url, None, &gate)
|
||||
.await
|
||||
};
|
||||
let updated_profile = launch_result.map_err(|e| {
|
||||
log::info!("Browser launch failed for profile: {}, error: {}", profile_for_launch.name, e);
|
||||
let updated_profile = match launch_result {
|
||||
Ok(updated) => updated,
|
||||
Err(e) => {
|
||||
log::info!(
|
||||
"Browser launch failed for profile: {}, error: {}",
|
||||
profile_for_launch.name,
|
||||
e
|
||||
);
|
||||
|
||||
// Emit a failure event to clear loading states in the frontend
|
||||
#[derive(serde::Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
let payload = RunningChangedPayload {
|
||||
id: profile_for_launch.id.to_string(),
|
||||
is_running: false,
|
||||
};
|
||||
|
||||
if let Err(e) = events::emit("profile-running-changed", &payload) {
|
||||
log::warn!("Warning: Failed to emit profile running changed event: {e}");
|
||||
}
|
||||
|
||||
// Check if this is an architecture compatibility issue
|
||||
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
|
||||
if io_error.kind() == std::io::ErrorKind::Other && io_error.to_string().contains("Exec format error") {
|
||||
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);
|
||||
// Emit a failure event to clear loading states in the frontend
|
||||
#[derive(serde::Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
let payload = RunningChangedPayload {
|
||||
id: profile_for_launch.id.to_string(),
|
||||
is_running: false,
|
||||
};
|
||||
|
||||
if let Err(e) = events::emit("profile-running-changed", &payload) {
|
||||
log::warn!("Warning: Failed to emit profile running changed event: {e}");
|
||||
}
|
||||
|
||||
unwind_launch(&profile, acquired_team_lock).await;
|
||||
|
||||
// Check if this is an architecture compatibility issue
|
||||
if let Some(io_error) = e.downcast_ref::<std::io::Error>() {
|
||||
if io_error.kind() == std::io::ErrorKind::Other
|
||||
&& io_error.to_string().contains("Exec format error")
|
||||
{
|
||||
return Err(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 Err(crate::wrap_backend_error(
|
||||
e,
|
||||
"Failed to launch browser or open URL",
|
||||
));
|
||||
}
|
||||
crate::wrap_backend_error(e, "Failed to launch browser or open URL")
|
||||
})?;
|
||||
};
|
||||
|
||||
log::info!(
|
||||
"Browser launch completed for profile: {} (ID: {})",
|
||||
@@ -1513,10 +1831,15 @@ pub async fn open_url_with_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
url: String,
|
||||
consent_token: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
let gate = match consent_token {
|
||||
Some(token) => crate::launch_gate::FingerprintGate::Consented(token),
|
||||
None => crate::launch_gate::FingerprintGate::Enforce,
|
||||
};
|
||||
browser_runner
|
||||
.open_url_with_profile(app_handle, profile_id, url)
|
||||
.open_url_with_profile(app_handle, profile_id, url, gate)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+51
-11
@@ -28,9 +28,9 @@ const DEFAULT_REQUESTS_PER_HOUR: i64 = 100;
|
||||
|
||||
/// Capability + limit set the account is entitled to, derived from its plan.
|
||||
/// Mirrors `apps/backend/src/plans/entitlements.ts`. Features are gated on these
|
||||
/// flags instead of a single "is paid?" boolean, so a plan like the future
|
||||
/// "starter" tier (cross-OS fingerprints + cloud backup, no automation) is just
|
||||
/// data here.
|
||||
/// flags instead of a single "is paid?" boolean, so a plan like "solo" (cloud
|
||||
/// backup + nightly cookie bot, no automation, no fingerprint editing, no
|
||||
/// hands-on remote session) is just data here.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Entitlements {
|
||||
#[serde(default)]
|
||||
@@ -49,6 +49,12 @@ pub struct Entitlements {
|
||||
/// together.
|
||||
#[serde(rename = "cookieBot", default)]
|
||||
pub cookie_bot: bool,
|
||||
/// Whether the plan may open a HANDS-ON remote session. Distinct from
|
||||
/// `cookie_bot`: solo funds a nightly bot out of its remote hours but may not
|
||||
/// drive a remote browser itself, so anything that offers interactive remote
|
||||
/// control must read THIS rather than `remote_browser_hours > 0`.
|
||||
#[serde(rename = "remoteInteractive", default)]
|
||||
pub remote_interactive: bool,
|
||||
#[serde(rename = "profileLimit", default)]
|
||||
pub profile_limit: i64,
|
||||
#[serde(rename = "requestsPerHour", default)]
|
||||
@@ -77,16 +83,29 @@ fn derive_entitlements(
|
||||
cloud_backup: false,
|
||||
team_collaboration: false,
|
||||
cookie_bot: false,
|
||||
remote_interactive: false,
|
||||
profile_limit: 0,
|
||||
requests_per_hour: 0,
|
||||
remote_browser_hours: 0,
|
||||
};
|
||||
}
|
||||
// pro and any unrecognized paid plan -> pro-level (never team).
|
||||
let (browser_automation, cross_os_fingerprints, cloud_backup, team_collaboration) = match plan {
|
||||
"starter" => (false, true, true, false),
|
||||
"team" | "enterprise" => (true, true, true, true),
|
||||
_ => (true, true, true, false),
|
||||
// Tuple order: (browser_automation, cross_os_fingerprints, cloud_backup,
|
||||
// team_collaboration, cookie_bot, remote_interactive).
|
||||
//
|
||||
// pro and any unrecognized paid plan -> pro-level (never team). Solo is the
|
||||
// one row where cookie_bot and browser_automation disagree, which is why
|
||||
// cookie_bot can no longer be derived from browser_automation below.
|
||||
let (
|
||||
browser_automation,
|
||||
cross_os_fingerprints,
|
||||
cloud_backup,
|
||||
team_collaboration,
|
||||
cookie_bot,
|
||||
remote_interactive,
|
||||
) = match plan {
|
||||
"solo" => (false, false, true, false, true, false),
|
||||
"team" | "enterprise" => (true, true, true, true, true, true),
|
||||
_ => (true, true, true, false, true, true),
|
||||
};
|
||||
Entitlements {
|
||||
active,
|
||||
@@ -94,9 +113,8 @@ fn derive_entitlements(
|
||||
cross_os_fingerprints,
|
||||
cloud_backup,
|
||||
team_collaboration,
|
||||
// A bot run IS remote automation on leased hardware, so the two capabilities
|
||||
// never diverge: a plan that cannot drive a browser cannot warm one either.
|
||||
cookie_bot: browser_automation,
|
||||
cookie_bot,
|
||||
remote_interactive,
|
||||
profile_limit,
|
||||
requests_per_hour: if browser_automation {
|
||||
DEFAULT_REQUESTS_PER_HOUR
|
||||
@@ -155,6 +173,13 @@ impl CloudUser {
|
||||
/// locally from the plan fields (keeps older cached state / backends working).
|
||||
pub fn entitlements(&self) -> Entitlements {
|
||||
if let Some(e) = &self.entitlements {
|
||||
// Returned verbatim, INCLUDING the `#[serde(default)]` false that a
|
||||
// backend older than this release leaves on `cookie_bot` /
|
||||
// `remote_interactive`. Repairing it here is impossible anyway — serde's
|
||||
// default erases the difference between "sent false" and "not sent" — and
|
||||
// it is not this layer's job: nothing in Rust gates on either flag, and
|
||||
// `getEntitlements()` in `src/lib/entitlements.ts` fills both gaps at the
|
||||
// single point every UI consumer already goes through.
|
||||
return e.clone();
|
||||
}
|
||||
derive_entitlements(
|
||||
@@ -794,6 +819,21 @@ impl CloudAuthManager {
|
||||
}
|
||||
|
||||
/// Launch/drive profiles programmatically (local API + MCP automation).
|
||||
/// Whether this account may run the nightly Cookie Bot.
|
||||
///
|
||||
/// NOT `can_use_browser_automation`. Solo is exactly the plan where the two
|
||||
/// disagree — it pays for a nightly bot and has `browser_automation: false` —
|
||||
/// so gating the bot on automation refused a Solo customer the one feature
|
||||
/// their plan is sold on, and answered 402 while their scheduled runs kept
|
||||
/// working server-side.
|
||||
pub async fn can_use_cookie_bot(&self) -> bool {
|
||||
self
|
||||
.entitlements()
|
||||
.await
|
||||
.map(|e| e.cookie_bot)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
pub async fn can_use_browser_automation(&self) -> bool {
|
||||
#[cfg(feature = "e2e")]
|
||||
if crate::e2e_automation_enabled()
|
||||
|
||||
+642
-11
@@ -51,6 +51,20 @@ const REPORT_CODES: FailureCodes = FailureCodes {
|
||||
conflict: cloud_errors::UNAVAILABLE,
|
||||
};
|
||||
|
||||
/// Failure codes for the user-template routes.
|
||||
///
|
||||
/// Distinct from `SCHEDULE_CODES` on every axis that matters: a 404 here is a
|
||||
/// template that was deleted (possibly from another device), not an unenrolled
|
||||
/// profile, and a 409 is a name the user already used, not a teammate's
|
||||
/// enrolment. Sharing the schedule set would have told someone renaming a site
|
||||
/// list that a colleague already warms this profile.
|
||||
const TEMPLATE_CODES: FailureCodes = FailureCodes {
|
||||
bad_request: "COOKIE_BOT_INVALID_TEMPLATE_NAME",
|
||||
forbidden: "COOKIE_BOT_NOT_ENTITLED",
|
||||
not_found: "COOKIE_BOT_TEMPLATE_NOT_FOUND",
|
||||
conflict: "COOKIE_BOT_TEMPLATE_NAME_TAKEN",
|
||||
};
|
||||
|
||||
/// Every cookie-bot call fails as a code the frontend can translate.
|
||||
///
|
||||
/// There is no `Other(String)` carrying backend English: a raw message reaches
|
||||
@@ -91,6 +105,18 @@ impl From<BackendFailure> for CookieBotError {
|
||||
// One place for every request and response shape, so a backend contract change
|
||||
// is a single edit here rather than a hunt through call sites.
|
||||
|
||||
/// One time-of-day an enrolment fires, on a set of local weekdays.
|
||||
///
|
||||
/// Copy, and deliberately tiny: a calendar is a list of these, and the desktop
|
||||
/// rebuilds that list on every keystroke in the enrolment form.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotSlot {
|
||||
/// Bitmask of local weekdays, bit 0 = Monday. At least one bit set.
|
||||
pub days_mask: u8,
|
||||
/// Minutes past local midnight, in the schedule's timezone.
|
||||
pub run_at_minute: u16,
|
||||
}
|
||||
|
||||
/// A profile enrolled in the nightly bot.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotSchedule {
|
||||
@@ -98,13 +124,31 @@ pub struct CookieBotSchedule {
|
||||
pub profile_name: String,
|
||||
pub platform: String,
|
||||
pub enabled: bool,
|
||||
/// Minutes past local midnight the run is anchored to.
|
||||
/// Minutes past local midnight the FIRST slot is anchored to. The server
|
||||
/// mirrors `slots[0]` onto this pair on every write.
|
||||
pub run_at_minute: u16,
|
||||
/// Bitmask of local weekdays, bit 0 = Monday.
|
||||
/// The first slot's weekdays, bit 0 = Monday. See `run_at_minute`.
|
||||
pub days_mask: u8,
|
||||
/// Every time-of-day this enrolment fires.
|
||||
///
|
||||
/// `default` rather than required because a server older than multi-slot
|
||||
/// scheduling sends only the mirrored pair above, and a decode failure there
|
||||
/// would blank the whole Cookie Bot surface rather than show one time instead
|
||||
/// of several. Callers must therefore fall back to the pair when this is
|
||||
/// empty — never treat an empty list as "fires at no time".
|
||||
#[serde(default)]
|
||||
pub slots: Vec<CookieBotSlot>,
|
||||
pub timezone: String,
|
||||
/// Server-issued preset id. Opaque here — what it expands to is infra's.
|
||||
pub preset: String,
|
||||
/// The template the sites came from, or `None` for the user's own list.
|
||||
///
|
||||
/// A built-in id (`low-intent-purchaser`) means `sites` is EMPTY on purpose:
|
||||
/// its URLs are server-owned and never sent to a client. A `user:<uuid>` id
|
||||
/// is provenance only — those sites were copied onto the enrolment and are
|
||||
/// present below.
|
||||
#[serde(default)]
|
||||
pub template_id: Option<String>,
|
||||
pub max_minutes: u32,
|
||||
#[serde(default)]
|
||||
pub sites: Vec<String>,
|
||||
@@ -120,6 +164,11 @@ pub struct CookieBotSchedule {
|
||||
pub encrypted_sync: bool,
|
||||
#[serde(default)]
|
||||
pub has_proxy: bool,
|
||||
/// Whether that exit is one a leased fleet host could dial. Defaults to false
|
||||
/// on an older server that does not send it, which reads as "not reachable"
|
||||
/// and is the safe direction.
|
||||
#[serde(default)]
|
||||
pub proxy_remote_reachable: bool,
|
||||
#[serde(default)]
|
||||
pub touch_fingerprint: bool,
|
||||
#[serde(default)]
|
||||
@@ -162,8 +211,26 @@ pub struct CookieBotScheduleInput {
|
||||
pub enabled: bool,
|
||||
pub run_at_minute: u16,
|
||||
pub days_mask: u8,
|
||||
/// The whole calendar, when the caller has one.
|
||||
///
|
||||
/// `skip_serializing_if` is load-bearing rather than tidiness: the server
|
||||
/// reads an ABSENT `slots` as "one slot, from the pair above" and refuses a
|
||||
/// present-but-empty one, and `null` takes the refusing branch. Serialising
|
||||
/// `None` as null would 400 every write from a single-slot form.
|
||||
///
|
||||
/// The pair above is still sent, mirrored from `slots[0]`, so a server that
|
||||
/// predates multi-slot stores the first time rather than nothing.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub slots: Option<Vec<CookieBotSlot>>,
|
||||
pub timezone: String,
|
||||
pub preset: String,
|
||||
/// A browsing template instead of a typed site list.
|
||||
///
|
||||
/// Mutually exclusive with a non-empty `sites`: the server refuses a write
|
||||
/// carrying both, because merging a curated persona with the user's own list
|
||||
/// produces neither. A caller naming a template sends `sites: []`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub template_id: Option<String>,
|
||||
pub max_minutes: u32,
|
||||
pub sites: Vec<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@@ -191,6 +258,8 @@ pub struct CookieBotScheduleInput {
|
||||
#[serde(default)]
|
||||
pub has_proxy: bool,
|
||||
#[serde(default)]
|
||||
pub proxy_remote_reachable: bool,
|
||||
#[serde(default)]
|
||||
pub encrypted_sync: bool,
|
||||
#[serde(default)]
|
||||
pub touch_fingerprint: bool,
|
||||
@@ -331,6 +400,53 @@ pub struct CookieBotPreset {
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// A server-owned browsing template: a named answer to "what is this profile
|
||||
/// for", which the user picks INSTEAD of typing a site list.
|
||||
///
|
||||
/// Carries no URLs, and must not gain any. The pool a template draws from is
|
||||
/// server-side for the same reason a preset's browsing model is: a published
|
||||
/// list is one a retailer can filter, and each profile is given its own sample
|
||||
/// so the template never becomes a fleet-wide fingerprint.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotTemplate {
|
||||
pub id: String,
|
||||
/// How many sites this template browses. Not which.
|
||||
#[serde(default)]
|
||||
pub site_count: u32,
|
||||
/// Server-supplied English label and blurb, present only so a template added
|
||||
/// after this build still renders. The UI prefers its own `t()` key for an id
|
||||
/// it recognises.
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// The bounds the schedule routes actually enforce, as this build reads them.
|
||||
///
|
||||
/// Every field is optional because a server that predates `limits` sends none
|
||||
/// of them, and a client that read a missing bound as `0` would refuse every
|
||||
/// value the form can produce. Only the bounds the desktop acts on are decoded
|
||||
/// — serde drops the rest, and this struct is what the GUI ultimately receives,
|
||||
/// so adding a field here is what makes one reachable from TypeScript.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotLimits {
|
||||
#[serde(default)]
|
||||
pub min_minutes: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub max_minutes: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub min_sites: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub max_sites: Option<u32>,
|
||||
/// Most entries a calendar may carry.
|
||||
#[serde(default)]
|
||||
pub max_slots: Option<u32>,
|
||||
/// Longest name a saved site list may be given.
|
||||
#[serde(default)]
|
||||
pub max_template_name_length: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotPresetList {
|
||||
#[serde(default)]
|
||||
@@ -339,6 +455,33 @@ pub struct CookieBotPresetList {
|
||||
/// preference.
|
||||
#[serde(default)]
|
||||
pub default_preset: Option<String>,
|
||||
/// The curated templates on offer. Served beside the presets so a template
|
||||
/// added server-side appears without a desktop release.
|
||||
#[serde(default)]
|
||||
pub templates: Vec<CookieBotTemplate>,
|
||||
/// The server's own bounds, when it publishes them. The desktop mirrors a
|
||||
/// copy for offline form validation; these win where they disagree.
|
||||
#[serde(default)]
|
||||
pub limits: Option<CookieBotLimits>,
|
||||
}
|
||||
|
||||
/// One of the caller's OWN saved site lists.
|
||||
///
|
||||
/// Carries its URLs, unlike {@link CookieBotTemplate} — they are the user's own
|
||||
/// and there is nothing to withhold. Applying one copies the sites onto the
|
||||
/// enrolment, so a list edited later does not silently change what an existing
|
||||
/// enrolment browses until it is saved again.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct CookieBotUserTemplate {
|
||||
/// Already carries the `user:` prefix: this id's job is to be pasted into a
|
||||
/// schedule's `template_id`, and assembling that convention on the client is
|
||||
/// how the two kinds of template get confused.
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
#[serde(default)]
|
||||
pub sites: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
@@ -465,7 +608,10 @@ pub struct CookieBotUsage {
|
||||
/// the client cannot see — but a profile that can never qualify should never
|
||||
/// reach a confirm dialog, an hour of quota or a leased host. Returns the
|
||||
/// `{"code":…}` string a Tauri command surfaces directly.
|
||||
pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
|
||||
pub fn bot_precondition(
|
||||
profile: &BrowserProfile,
|
||||
exit: &crate::remote_exit::ExitReachability,
|
||||
) -> Result<(), String> {
|
||||
if !profile.is_sync_enabled() {
|
||||
// The host materialises the profile by pulling it from donut-sync. A
|
||||
// local-only profile has nothing there, so there is no path to a run.
|
||||
@@ -491,6 +637,21 @@ pub fn bot_precondition(profile: &BrowserProfile) -> Result<(), String> {
|
||||
// than not warming it at all.
|
||||
return Err(error("COOKIE_BOT_REQUIRES_EXIT_NODE", &[]));
|
||||
}
|
||||
// ...and the exit has to be one the leased host can reach. The profile and its
|
||||
// proxy record are pulled onto the fleet with no address rewriting, so
|
||||
// 127.0.0.1 arrives meaning THAT host's loopback — an ordinary mistake (an SSH
|
||||
// tunnel, a local MITM proxy, a locally-run SOCKS client), and by the time the
|
||||
// run fails an hour has been leased and billed.
|
||||
//
|
||||
// Taken as an ARGUMENT rather than resolved here, for the same reason
|
||||
// `ProfileState` is required rather than defaulted: resolving it needs the
|
||||
// proxy and VPN stores, and a function that reaches into those globals is one
|
||||
// no test can set up and every caller silently depends on. `exit_reachability`
|
||||
// is the one place that resolution happens; this stays a pure predicate over
|
||||
// facts it is handed.
|
||||
if !exit.is_remote() {
|
||||
return Err(error("COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE", &[]));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -511,6 +672,12 @@ pub fn profile_state(profile: &BrowserProfile) -> ProfileState {
|
||||
// A VPN is an exit node just as much as a proxy is; the server only asks
|
||||
// whether the traffic leaves through something the user brought.
|
||||
has_proxy: profile.proxy_id.is_some() || profile.vpn_id.is_some(),
|
||||
// ...and, separately, whether anyone OTHER than this machine could use it.
|
||||
// `has_proxy` answers "did the user bring an exit"; this answers "is that
|
||||
// exit an address a leased host can dial". They disagree for every local
|
||||
// proxy, which is the case that used to be accepted and then fail on the
|
||||
// fleet. See `remote_exit`.
|
||||
proxy_remote_reachable: exit_reachability(profile).is_remote(),
|
||||
// Always false: this data model has no mobile/touch profile. `resolved_os`
|
||||
// yields only windows, macos or linux, and `bot_precondition` already
|
||||
// refuses everything but the first two. Reported rather than omitted so the
|
||||
@@ -533,16 +700,69 @@ pub struct ProfileState {
|
||||
pub sync_enabled: bool,
|
||||
pub encrypted_sync: bool,
|
||||
pub has_proxy: bool,
|
||||
/// Whether that exit is an address a leased fleet host can dial.
|
||||
pub proxy_remote_reachable: bool,
|
||||
pub touch_fingerprint: bool,
|
||||
pub sticky_exit: bool,
|
||||
}
|
||||
|
||||
/// Whether this profile's exit could be used from a host that is not this one.
|
||||
///
|
||||
/// Resolves the profile's proxy or VPN out of local storage — the server cannot
|
||||
/// do this, because it never sees a proxy record until sync has uploaded one and
|
||||
/// even then would have to re-derive what the browser will actually dial.
|
||||
///
|
||||
/// A profile carrying BOTH a proxy and a VPN is judged on the proxy: that is
|
||||
/// what the browser is pointed at, and it is the address the fleet has to reach.
|
||||
pub fn exit_reachability(profile: &BrowserProfile) -> crate::remote_exit::ExitReachability {
|
||||
use crate::remote_exit::{classify_proxy, classify_wireguard_endpoint, ExitReachability};
|
||||
|
||||
if let Some(proxy_id) = profile.proxy_id.as_deref() {
|
||||
let stored = crate::proxy_manager::PROXY_MANAGER
|
||||
.get_stored_proxies()
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.id == proxy_id);
|
||||
return match stored {
|
||||
Some(proxy) => classify_proxy(&proxy.proxy_settings),
|
||||
// Referenced but missing. Fail closed: a dangling id is not evidence of a
|
||||
// reachable exit, and the launch would fail anyway.
|
||||
None => ExitReachability::Unknown {
|
||||
reason: "the profile references a proxy that no longer exists".to_string(),
|
||||
source: "proxy",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if let Some(vpn_id) = profile.vpn_id.as_deref() {
|
||||
let config = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|storage| storage.load_config(vpn_id).ok());
|
||||
return match config {
|
||||
Some(config) => match crate::vpn::parse_wireguard_config(&config.config_data) {
|
||||
Ok(parsed) => classify_wireguard_endpoint(&parsed.peer_endpoint),
|
||||
Err(error) => ExitReachability::Unknown {
|
||||
reason: format!("VPN config could not be parsed ({error})"),
|
||||
source: "VPN",
|
||||
},
|
||||
},
|
||||
None => ExitReachability::Unknown {
|
||||
reason: "the profile references a VPN config that no longer exists".to_string(),
|
||||
source: "VPN",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
ExitReachability::None
|
||||
}
|
||||
|
||||
impl CookieBotScheduleInput {
|
||||
/// Stamp the profile facts onto an input built from user-chosen values.
|
||||
pub fn with_profile_state(mut self, state: ProfileState) -> Self {
|
||||
self.sync_enabled = state.sync_enabled;
|
||||
self.encrypted_sync = state.encrypted_sync;
|
||||
self.has_proxy = state.has_proxy;
|
||||
self.proxy_remote_reachable = state.proxy_remote_reachable;
|
||||
self.touch_fingerprint = state.touch_fingerprint;
|
||||
self.sticky_exit = state.sticky_exit;
|
||||
self
|
||||
@@ -663,6 +883,10 @@ pub async fn update_profile_state(
|
||||
body.insert("sync_enabled".to_string(), state.sync_enabled.into());
|
||||
body.insert("encrypted_sync".to_string(), state.encrypted_sync.into());
|
||||
body.insert("has_proxy".to_string(), state.has_proxy.into());
|
||||
body.insert(
|
||||
"proxy_remote_reachable".to_string(),
|
||||
state.proxy_remote_reachable.into(),
|
||||
);
|
||||
body.insert(
|
||||
"touch_fingerprint".to_string(),
|
||||
state.touch_fingerprint.into(),
|
||||
@@ -855,6 +1079,171 @@ pub async fn list_presets() -> Result<CookieBotPresetList, CookieBotError> {
|
||||
.await
|
||||
}
|
||||
|
||||
// --- User-defined templates -------------------------------------------------
|
||||
//
|
||||
// The caller's own saved site lists. Unlike every other route in this file
|
||||
// these are addressed by an id the SERVER minted and the client echoes back,
|
||||
// so each one percent-encodes it: the id is spelled `user:<uuid>`, and a bare
|
||||
// colon in a path segment is a spelling the router is free to read differently.
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserTemplateListEnvelope {
|
||||
#[serde(default)]
|
||||
templates: Vec<CookieBotUserTemplate>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserTemplateEnvelope {
|
||||
template: CookieBotUserTemplate,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UserTemplateDeleted {
|
||||
#[serde(default)]
|
||||
deleted: bool,
|
||||
}
|
||||
|
||||
/// Every site list this user has saved, most recently edited first.
|
||||
pub async fn list_user_templates() -> Result<Vec<CookieBotUserTemplate>, CookieBotError> {
|
||||
let envelope: UserTemplateListEnvelope = request(
|
||||
reqwest::Method::GET,
|
||||
format!("{}/user-templates", base()),
|
||||
Vec::new(),
|
||||
None,
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(envelope.templates)
|
||||
}
|
||||
|
||||
/// Save a new one.
|
||||
pub async fn create_user_template(
|
||||
name: &str,
|
||||
sites: &[String],
|
||||
) -> Result<CookieBotUserTemplate, CookieBotError> {
|
||||
let body = serde_json::json!({ "name": name, "sites": sites });
|
||||
let envelope: UserTemplateEnvelope = request(
|
||||
reqwest::Method::POST,
|
||||
format!("{}/user-templates", base()),
|
||||
Vec::new(),
|
||||
Some(body),
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(envelope.template)
|
||||
}
|
||||
|
||||
/// Rename one, replace its sites, or both.
|
||||
///
|
||||
/// A PATCH with only the fields that changed, because the two are independent:
|
||||
/// a rename that had to carry the whole site list is a rename that silently
|
||||
/// reverts an edit made to it from another device in the meantime. Sending an
|
||||
/// omitted field as `null` would defeat that, so each is skipped when absent.
|
||||
pub async fn update_user_template(
|
||||
id: &str,
|
||||
name: Option<&str>,
|
||||
sites: Option<&[String]>,
|
||||
) -> Result<CookieBotUserTemplate, CookieBotError> {
|
||||
let mut body = serde_json::Map::new();
|
||||
if let Some(name) = name {
|
||||
body.insert(
|
||||
"name".to_string(),
|
||||
serde_json::Value::String(name.to_string()),
|
||||
);
|
||||
}
|
||||
if let Some(sites) = sites {
|
||||
body.insert("sites".to_string(), serde_json::json!(sites));
|
||||
}
|
||||
|
||||
let envelope: UserTemplateEnvelope = request(
|
||||
reqwest::Method::PATCH,
|
||||
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
|
||||
Vec::new(),
|
||||
Some(serde_json::Value::Object(body)),
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(envelope.template)
|
||||
}
|
||||
|
||||
/// Delete one. Enrolments that used it keep the sites they copied, so this is
|
||||
/// never a way to stop a profile being warmed tonight.
|
||||
///
|
||||
/// Safe to repeat: deleting a list that is already gone answers `false` rather
|
||||
/// than 404, which is what makes a retry after a dropped response harmless.
|
||||
pub async fn delete_user_template(id: &str) -> Result<bool, CookieBotError> {
|
||||
let deleted: UserTemplateDeleted = request(
|
||||
reqwest::Method::DELETE,
|
||||
format!("{}/user-templates/{}", base(), urlencoding::encode(id)),
|
||||
Vec::new(),
|
||||
None,
|
||||
TEMPLATE_CODES,
|
||||
)
|
||||
.await?;
|
||||
Ok(deleted.deleted)
|
||||
}
|
||||
|
||||
// --- Tauri commands ---------------------------------------------------------
|
||||
//
|
||||
// The user-template commands live here rather than in `lib.rs` beside the
|
||||
// schedule ones because they carry no local precondition: nothing about a saved
|
||||
// site list depends on a profile this machine holds, so there is no profile to
|
||||
// look up and no `bot_precondition` to apply. They must still be registered in
|
||||
// `lib.rs`'s `invoke_handler` to be reachable.
|
||||
|
||||
/// Log a refusal and hand the frontend the envelope it translates.
|
||||
///
|
||||
/// The raw HTTP text never reaches the user: an untranslated backend sentence
|
||||
/// in a Japanese UI is the failure the `{"code":…}` convention exists to stop.
|
||||
fn command_error(context: &str, err: CookieBotError) -> String {
|
||||
log::warn!(
|
||||
"Cookie bot {context} failed: {} (HTTP {})",
|
||||
err.code(),
|
||||
err.status()
|
||||
);
|
||||
err.to_error_json()
|
||||
}
|
||||
|
||||
/// Every site list this user has saved.
|
||||
#[tauri::command]
|
||||
pub async fn get_cookie_bot_user_templates() -> Result<Vec<CookieBotUserTemplate>, String> {
|
||||
list_user_templates()
|
||||
.await
|
||||
.map_err(|e| command_error("template list", e))
|
||||
}
|
||||
|
||||
/// Save the current site list under a name.
|
||||
#[tauri::command]
|
||||
pub async fn create_cookie_bot_user_template(
|
||||
name: String,
|
||||
sites: Vec<String>,
|
||||
) -> Result<CookieBotUserTemplate, String> {
|
||||
create_user_template(&name, &sites)
|
||||
.await
|
||||
.map_err(|e| command_error("template create", e))
|
||||
}
|
||||
|
||||
/// Rename a saved list, replace its sites, or both. Omitted fields are left
|
||||
/// exactly as they are.
|
||||
#[tauri::command]
|
||||
pub async fn update_cookie_bot_user_template(
|
||||
id: String,
|
||||
name: Option<String>,
|
||||
sites: Option<Vec<String>>,
|
||||
) -> Result<CookieBotUserTemplate, String> {
|
||||
update_user_template(&id, name.as_deref(), sites.as_deref())
|
||||
.await
|
||||
.map_err(|e| command_error("template update", e))
|
||||
}
|
||||
|
||||
/// Delete a saved list. `false` means there was nothing left to delete.
|
||||
#[tauri::command]
|
||||
pub async fn delete_cookie_bot_user_template(id: String) -> Result<bool, String> {
|
||||
delete_user_template(&id)
|
||||
.await
|
||||
.map_err(|e| command_error("template delete", e))
|
||||
}
|
||||
|
||||
/// Per-member and per-profile spend for a calendar month (`YYYY-MM`).
|
||||
pub async fn team_usage(period: Option<&str>) -> Result<CookieBotUsage, CookieBotError> {
|
||||
let query = period
|
||||
@@ -976,6 +1365,7 @@ async fn request<T: DeserializeOwned>(
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::profile::types::SyncMode;
|
||||
use crate::remote_exit::ExitReachability;
|
||||
|
||||
fn eligible_profile() -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
@@ -1045,7 +1435,8 @@ mod tests {
|
||||
// that emptiness over the user's real profile.
|
||||
let mut profile = eligible_profile();
|
||||
profile.sync_mode = SyncMode::Disabled;
|
||||
let err = bot_precondition(&profile).expect_err("a local-only profile must be refused");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("a local-only profile must be refused");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_CLOUD_SYNC");
|
||||
}
|
||||
|
||||
@@ -1055,7 +1446,8 @@ mod tests {
|
||||
// one code cannot carry two different instructions.
|
||||
let mut profile = eligible_profile();
|
||||
profile.sync_mode = SyncMode::Encrypted;
|
||||
let err = bot_precondition(&profile).expect_err("encrypted sync must be refused");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("encrypted sync must be refused");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_ENCRYPTED_SYNC_UNSUPPORTED");
|
||||
}
|
||||
|
||||
@@ -1063,7 +1455,8 @@ mod tests {
|
||||
fn linux_is_refused_at_enrolment_rather_than_at_two_in_the_morning() {
|
||||
let mut profile = eligible_profile();
|
||||
profile.host_os = Some("linux".to_string());
|
||||
let err = bot_precondition(&profile).expect_err("linux has no host to lease");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("linux has no host to lease");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&err).expect("valid envelope");
|
||||
assert_eq!(parsed["code"], "COOKIE_BOT_UNSUPPORTED_PLATFORM");
|
||||
assert_eq!(
|
||||
@@ -1076,7 +1469,8 @@ mod tests {
|
||||
fn a_profile_with_no_recorded_os_cannot_be_scheduled_onto_a_host() {
|
||||
let mut profile = eligible_profile();
|
||||
profile.host_os = None;
|
||||
let err = bot_precondition(&profile).expect_err("no OS means no matching host");
|
||||
let err = bot_precondition(&profile, &ExitReachability::Remote)
|
||||
.expect_err("no OS means no matching host");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_UNKNOWN_PLATFORM");
|
||||
}
|
||||
|
||||
@@ -1087,7 +1481,8 @@ mod tests {
|
||||
let mut profile = eligible_profile();
|
||||
profile.proxy_id = None;
|
||||
profile.vpn_id = None;
|
||||
let err = bot_precondition(&profile).expect_err("datacenter egress must be refused");
|
||||
let err = bot_precondition(&profile, &ExitReachability::None)
|
||||
.expect_err("datacenter egress must be refused");
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_EXIT_NODE");
|
||||
}
|
||||
|
||||
@@ -1096,21 +1491,60 @@ mod tests {
|
||||
let mut profile = eligible_profile();
|
||||
profile.proxy_id = None;
|
||||
profile.vpn_id = Some("vpn-1".to_string());
|
||||
assert!(bot_precondition(&profile).is_ok());
|
||||
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_windows_profile_with_sync_and_a_proxy_qualifies() {
|
||||
let mut profile = eligible_profile();
|
||||
profile.host_os = Some("windows".to_string());
|
||||
assert!(bot_precondition(&profile).is_ok());
|
||||
assert!(bot_precondition(&profile, &ExitReachability::Remote).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_only_this_machine_can_reach_is_refused() {
|
||||
// The gap `has_proxy` alone could never see, and — before the verdict became
|
||||
// an argument — a case no unit test could construct, because resolving it
|
||||
// reached into the global proxy store. The profile is otherwise perfect.
|
||||
let profile = eligible_profile();
|
||||
|
||||
let err = bot_precondition(
|
||||
&profile,
|
||||
&ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".to_string(),
|
||||
source: "proxy",
|
||||
},
|
||||
)
|
||||
.expect_err("a loopback exit cannot be dialled from a leased host");
|
||||
|
||||
// Its own code: "attach a proxy" is unactionable advice for someone whose
|
||||
// proxy is plainly attached.
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_we_could_not_read_is_refused_too() {
|
||||
// Fails closed. Refusing a working setup costs one support question;
|
||||
// accepting a broken one burns a leased hour and damages an identity.
|
||||
let err = bot_precondition(
|
||||
&eligible_profile(),
|
||||
&ExitReachability::Unknown {
|
||||
reason: "VPN config could not be parsed".to_string(),
|
||||
source: "VPN",
|
||||
},
|
||||
)
|
||||
.expect_err("an unreadable exit must not be assumed reachable");
|
||||
|
||||
assert_eq!(code_of(&err), "COOKIE_BOT_REQUIRES_REMOTE_EXIT_NODE");
|
||||
}
|
||||
|
||||
/// A verbatim `CookieBotScheduleView`, field for field, as `toScheduleView`
|
||||
/// in donutbrowser-infra's `cookie-bot.service.ts` builds it.
|
||||
const SERVER_SCHEDULE_VIEW: &str = r#"{
|
||||
"profile_id":"p1","profile_name":"Yu","platform":"macos","enabled":true,
|
||||
"run_at_minute":120,"days_mask":127,"timezone":"Europe/Berlin",
|
||||
"run_at_minute":120,"days_mask":127,
|
||||
"slots":[{"days_mask":127,"run_at_minute":120},{"days_mask":31,"run_at_minute":690}],
|
||||
"timezone":"Europe/Berlin","template_id":null,
|
||||
"preset":"balanced","max_minutes":45,"sites":["https://example.com"],
|
||||
"jitter_seconds":900,"sync_enabled":true,"encrypted_sync":false,
|
||||
"has_proxy":true,"touch_fingerprint":false,"sticky_exit":false,
|
||||
@@ -1142,6 +1576,132 @@ mod tests {
|
||||
assert!(schedule.blocked_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_schedule_carries_its_whole_calendar_not_just_the_first_time() {
|
||||
// The mirrored pair is `slots[0]`, so a client that read only the pair
|
||||
// would show "every night at 02:00" for an enrolment that also runs at
|
||||
// 11:30 on weeknights — fewer runs than the user booked, silently.
|
||||
let schedule: CookieBotSchedule =
|
||||
serde_json::from_str(SERVER_SCHEDULE_VIEW).expect("a multi-slot schedule must deserialize");
|
||||
|
||||
assert_eq!(schedule.slots.len(), 2);
|
||||
assert_eq!(schedule.slots[0].run_at_minute, schedule.run_at_minute);
|
||||
assert_eq!(schedule.slots[0].days_mask, schedule.days_mask);
|
||||
assert_eq!(schedule.slots[1].run_at_minute, 690);
|
||||
assert_eq!(schedule.slots[1].days_mask, 31);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_server_that_predates_multi_slot_still_decodes_with_no_slots() {
|
||||
// `slots` absent is a deployment that has not rolled forward, not a broken
|
||||
// enrolment. Requiring it would blank the whole Cookie Bot surface against
|
||||
// an older backend rather than show the one time it does know about.
|
||||
let schedule: CookieBotSchedule = serde_json::from_str(
|
||||
r#"{"profile_id":"p1","profile_name":"Yu","platform":"windows","enabled":true,
|
||||
"run_at_minute":120,"days_mask":31,"timezone":"UTC","preset":"light",
|
||||
"max_minutes":10}"#,
|
||||
)
|
||||
.expect("a pre-multi-slot schedule must deserialize");
|
||||
|
||||
assert!(schedule.slots.is_empty());
|
||||
assert!(schedule.template_id.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_templated_enrolment_reports_its_template_and_no_sites() {
|
||||
// A built-in template's URLs are server-owned. An empty `sites` here is the
|
||||
// contract working, not a schedule with nothing to browse — anything that
|
||||
// reads it as "no sites" would show a healthy enrolment as broken.
|
||||
let schedule: CookieBotSchedule = serde_json::from_str(
|
||||
&SERVER_SCHEDULE_VIEW
|
||||
.replace(
|
||||
"\"template_id\":null",
|
||||
"\"template_id\":\"low-intent-purchaser\"",
|
||||
)
|
||||
.replace("\"sites\":[\"https://example.com\"]", "\"sites\":[]"),
|
||||
)
|
||||
.expect("a templated schedule must deserialize");
|
||||
|
||||
assert_eq!(
|
||||
schedule.template_id.as_deref(),
|
||||
Some("low-intent-purchaser")
|
||||
);
|
||||
assert!(schedule.sites.is_empty());
|
||||
assert!(schedule.blocked_by.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_calendar_is_sent_as_slots_and_omitted_entirely_when_there_is_none() {
|
||||
// The server reads an ABSENT `slots` as "one slot, from the legacy pair"
|
||||
// and REFUSES a null or empty one. Serialising `None` as null would 400
|
||||
// every write from a form with a single time on it.
|
||||
let one_slot = CookieBotScheduleInput {
|
||||
profile_name: "Yu".to_string(),
|
||||
platform: "macos".to_string(),
|
||||
enabled: true,
|
||||
run_at_minute: 120,
|
||||
days_mask: 127,
|
||||
timezone: "Europe/Berlin".to_string(),
|
||||
preset: "balanced".to_string(),
|
||||
max_minutes: 45,
|
||||
sites: vec!["https://example.com".to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = serde_json::to_value(&one_slot).expect("input must serialize");
|
||||
assert!(
|
||||
encoded.get("slots").is_none(),
|
||||
"an absent calendar must be absent on the wire, not null"
|
||||
);
|
||||
assert!(encoded.get("template_id").is_none());
|
||||
|
||||
let many = CookieBotScheduleInput {
|
||||
slots: Some(vec![
|
||||
CookieBotSlot {
|
||||
days_mask: 127,
|
||||
run_at_minute: 120,
|
||||
},
|
||||
CookieBotSlot {
|
||||
days_mask: 31,
|
||||
run_at_minute: 690,
|
||||
},
|
||||
]),
|
||||
..one_slot
|
||||
};
|
||||
let encoded = serde_json::to_value(&many).expect("input must serialize");
|
||||
let slots = encoded["slots"].as_array().expect("slots must be a list");
|
||||
assert_eq!(slots.len(), 2);
|
||||
// Mirrored, because a server that predates multi-slot ignores `slots` and
|
||||
// stores this pair. Dropping it would leave that server with no time at all.
|
||||
assert_eq!(encoded["run_at_minute"], 120);
|
||||
assert_eq!(encoded["days_mask"], 127);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_templated_write_names_the_template_and_sends_no_sites() {
|
||||
// The server refuses a body carrying both: a curated persona merged with
|
||||
// the user's own list is neither.
|
||||
let input = CookieBotScheduleInput {
|
||||
profile_name: "Yu".to_string(),
|
||||
platform: "macos".to_string(),
|
||||
enabled: true,
|
||||
run_at_minute: 120,
|
||||
days_mask: 127,
|
||||
timezone: "UTC".to_string(),
|
||||
preset: "balanced".to_string(),
|
||||
max_minutes: 45,
|
||||
sites: Vec::new(),
|
||||
template_id: Some("low-intent-purchaser".to_string()),
|
||||
..Default::default()
|
||||
};
|
||||
let encoded = serde_json::to_value(&input).expect("input must serialize");
|
||||
assert_eq!(encoded["template_id"], "low-intent-purchaser");
|
||||
assert_eq!(
|
||||
encoded["sites"].as_array().map(Vec::len),
|
||||
Some(0),
|
||||
"sites must still be sent, and must be empty, beside a template"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_broken_enrolment_carries_the_reason_it_cannot_run() {
|
||||
// The whole point of `blocked_by`: a profile whose proxy was detached in
|
||||
@@ -1419,5 +1979,76 @@ mod tests {
|
||||
assert_eq!(presets.presets[0].id, "balanced");
|
||||
assert_eq!(presets.presets[0].typical_minutes, Some(35));
|
||||
assert_eq!(presets.default_preset.as_deref(), Some("balanced"));
|
||||
// An older deployment sends neither of these, and the dialog has to render
|
||||
// against it: no templates simply means the picker offers the user's own
|
||||
// list, and no limits means the mirrored bounds apply.
|
||||
assert!(presets.templates.is_empty());
|
||||
assert!(presets.limits.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_crosses_the_wire_as_a_count_and_never_as_urls() {
|
||||
// The pool is server-owned for the same reason a preset's browsing model
|
||||
// is. If this type ever gained a `sites` field the curation would be
|
||||
// published, and a published list is one a retailer can filter.
|
||||
let presets: CookieBotPresetList = serde_json::from_str(
|
||||
r#"{"presets":[],"default_preset":"balanced",
|
||||
"templates":[{"id":"low-intent-purchaser","site_count":32,
|
||||
"name":"Low-Intent Purchaser","description":"Price-sensitive browsing."}],
|
||||
"limits":{"min_minutes":5,"max_minutes":120,"min_sites":1,"max_sites":40,
|
||||
"max_site_length":2048,"max_jitter_seconds":3600,"max_slots":14,
|
||||
"max_template_name_length":80}}"#,
|
||||
)
|
||||
.expect("the preset list must carry templates and limits");
|
||||
|
||||
assert_eq!(presets.templates[0].id, "low-intent-purchaser");
|
||||
assert_eq!(presets.templates[0].site_count, 32);
|
||||
let limits = presets.limits.expect("limits must decode");
|
||||
assert_eq!(limits.max_slots, Some(14));
|
||||
assert_eq!(limits.max_template_name_length, Some(80));
|
||||
assert_eq!(limits.max_sites, Some(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_saved_list_arrives_with_the_prefix_a_schedule_write_needs() {
|
||||
// The id is what `template_id` takes verbatim. Handing the client a bare
|
||||
// uuid and expecting it to prepend `user:` is how a saved list gets looked
|
||||
// up against the built-in catalogue instead — which answers "no sites" and
|
||||
// silently unschedules the profile.
|
||||
let envelope: UserTemplateListEnvelope = serde_json::from_str(
|
||||
r#"{"templates":[{"id":"user:1c9a…","name":"My shops",
|
||||
"sites":["https://example.com"],"updated_at":"2026-08-05T10:00:00.000Z"}]}"#,
|
||||
)
|
||||
.expect("the user template list must deserialize");
|
||||
|
||||
let template = &envelope.templates[0];
|
||||
assert!(template.id.starts_with("user:"));
|
||||
assert_eq!(template.name, "My shops");
|
||||
assert_eq!(template.sites.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn deleting_a_saved_list_that_is_already_gone_is_not_a_failure() {
|
||||
// The route never 404s, so a delete retried after a dropped response has to
|
||||
// read as "nothing left to do" rather than as an error the user must act on.
|
||||
let deleted: UserTemplateDeleted =
|
||||
serde_json::from_str(r#"{"deleted":false,"id":"user:gone"}"#)
|
||||
.expect("a no-op delete must deserialize");
|
||||
assert!(!deleted.deleted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_template_404_is_a_missing_list_and_not_an_unenrolled_profile() {
|
||||
// Sharing SCHEDULE_CODES here would tell someone renaming a site list that
|
||||
// their profile is not enrolled, and a name collision that a teammate
|
||||
// already warms the profile.
|
||||
assert_eq!(
|
||||
cloud_errors::classify_message("(404) Not Found", TEMPLATE_CODES).code,
|
||||
"COOKIE_BOT_TEMPLATE_NOT_FOUND"
|
||||
);
|
||||
assert_eq!(
|
||||
cloud_errors::classify_message("(409) Conflict", TEMPLATE_CODES).code,
|
||||
"COOKIE_BOT_TEMPLATE_NAME_TAKEN"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,8 +18,13 @@ impl DefaultBrowser {
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::is_default_browser();
|
||||
|
||||
// Linux answers this by running `xdg-mime`, a shell script that forks
|
||||
// further. That is blocking work with no upper bound, and this command
|
||||
// runs on the same async runtime as every other command, the REST API and
|
||||
// the sync scheduler — so doing it inline occupies a worker for as long as
|
||||
// the desktop takes to answer. The Settings page polls this on a timer.
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::is_default_browser();
|
||||
return blocking(linux::is_default_browser).await;
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
Err("Unsupported platform".to_string())
|
||||
@@ -32,14 +37,27 @@ impl DefaultBrowser {
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::set_as_default_browser();
|
||||
|
||||
// Same reasoning, and this one additionally sleeps 500ms before verifying.
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::set_as_default_browser();
|
||||
return blocking(linux::set_as_default_browser).await;
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
Err("Unsupported platform".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run blocking work off the async runtime's worker threads.
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn blocking<T, F>(work: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce() -> Result<T, String> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
tokio::task::spawn_blocking(work)
|
||||
.await
|
||||
.map_err(|e| format!("Default browser check did not run: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use core_foundation::base::OSStatus;
|
||||
|
||||
+153
-34
@@ -59,25 +59,48 @@ impl BlocklistLevel {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(&self) -> Option<&'static str> {
|
||||
/// Where this tier's `domains/*.txt` list is fetched from.
|
||||
///
|
||||
/// `raw.githubusercontent.com` only, deliberately. This used to be a jsDelivr
|
||||
/// URL against `hagezi/dns-blocklists`, and it broke every blocklisted launch:
|
||||
/// that repo grew past jsDelivr's 150 MB package-resolution limit, so
|
||||
/// `@latest` began answering `403 Package size exceeded the configured limit
|
||||
/// of 150 MB` for every tier. Nothing was wrong locally and nothing a user
|
||||
/// could do would fix it — a third party's repo got too big and a CDN's
|
||||
/// package resolver gave up.
|
||||
///
|
||||
/// raw.githubusercontent.com serves the file straight from the ref and
|
||||
/// resolves no package at all, so it cannot fail that way. The
|
||||
/// `domains/*.txt` format now lives in `hagezi/dns-blocklists-legacy`.
|
||||
///
|
||||
/// Returned as a slice so the fetch path can try several sources if one is
|
||||
/// ever added; today there is exactly one on purpose.
|
||||
pub fn urls(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::None | Self::Custom => None,
|
||||
Self::None | Self::Custom => &[],
|
||||
Self::Light => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
|
||||
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/light.txt"]
|
||||
}
|
||||
Self::Normal => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/multi.txt")
|
||||
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/multi.txt"]
|
||||
}
|
||||
Self::Pro => Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.txt"),
|
||||
Self::ProPlus => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.plus.txt")
|
||||
}
|
||||
Self::Ultimate => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/ultimate.txt")
|
||||
Self::Pro => {
|
||||
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/pro.txt"]
|
||||
}
|
||||
Self::ProPlus => &[
|
||||
"https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/pro.plus.txt",
|
||||
],
|
||||
Self::Ultimate => &[
|
||||
"https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/ultimate.txt",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The preferred source, for callers that only need to name one.
|
||||
pub fn url(&self) -> Option<&'static str> {
|
||||
self.urls().first().copied()
|
||||
}
|
||||
|
||||
pub fn filename(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
@@ -295,49 +318,85 @@ impl BlocklistManager {
|
||||
}
|
||||
|
||||
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
|
||||
let production_url = level
|
||||
.url()
|
||||
.ok_or_else(|| format!("No URL for level {:?}", level))?;
|
||||
let production_urls: Vec<String> = level.urls().iter().map(|u| (*u).to_string()).collect();
|
||||
if production_urls.is_empty() {
|
||||
return Err(format!("No URL for level {:?}", level));
|
||||
}
|
||||
#[cfg(feature = "e2e")]
|
||||
let url = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
|
||||
let urls = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
|
||||
.ok()
|
||||
.filter(|base| !base.is_empty())
|
||||
.map(|base| {
|
||||
format!(
|
||||
vec![format!(
|
||||
"{}/{}",
|
||||
base.trim_end_matches('/'),
|
||||
level.filename().unwrap_or("blocklist.txt")
|
||||
)
|
||||
)]
|
||||
})
|
||||
.unwrap_or_else(|| production_url.to_string());
|
||||
.unwrap_or(production_urls);
|
||||
#[cfg(not(feature = "e2e"))]
|
||||
let url = production_url.to_string();
|
||||
let urls = production_urls;
|
||||
let path =
|
||||
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
|
||||
|
||||
let cache_dir = Self::cache_dir();
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
|
||||
|
||||
log::info!(
|
||||
"[dns-blocklist] Fetching {} from {}",
|
||||
level.display_name(),
|
||||
url
|
||||
);
|
||||
// Try each source in turn. A tier is only a failure once EVERY source has
|
||||
// refused it: the outage this replaced was one CDN answering 403 for a
|
||||
// reason that had nothing to do with the user, and falling back would have
|
||||
// made it invisible.
|
||||
let mut body: Option<String> = None;
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
let response = HTTP_CLIENT
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
|
||||
for url in &urls {
|
||||
log::info!(
|
||||
"[dns-blocklist] Fetching {} from {}",
|
||||
level.display_name(),
|
||||
url
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {} when fetching {}", response.status(), url));
|
||||
let response = match HTTP_CLIENT.get(url).send().await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
failures.push(format!("{url}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
failures.push(format!("{url}: HTTP {}", response.status()));
|
||||
continue;
|
||||
}
|
||||
|
||||
match response.text().await {
|
||||
Ok(text) => {
|
||||
if failures.is_empty() {
|
||||
log::info!("[dns-blocklist] {} fetched", level.display_name());
|
||||
} else {
|
||||
// Worth saying out loud: the primary source is down and somebody
|
||||
// should know before the backup goes too.
|
||||
log::warn!(
|
||||
"[dns-blocklist] {} came from a fallback source after {} failure(s): {}",
|
||||
level.display_name(),
|
||||
failures.len(),
|
||||
failures.join("; ")
|
||||
);
|
||||
}
|
||||
body = Some(text);
|
||||
break;
|
||||
}
|
||||
Err(e) => failures.push(format!("{url}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
||||
let Some(body) = body else {
|
||||
return Err(format!(
|
||||
"Failed to fetch blocklist {} from any source ({})",
|
||||
level.display_name(),
|
||||
failures.join("; ")
|
||||
));
|
||||
};
|
||||
|
||||
// Write atomically: write to temp file, then rename
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
@@ -796,6 +855,66 @@ mod tests {
|
||||
assert!(BlocklistLevel::None.filename().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_tier_is_served_only_from_raw_githubusercontent() {
|
||||
// jsDelivr is deliberately not a source. It resolves a whole package to
|
||||
// serve one file, so when `hagezi/dns-blocklists` grew past its 150 MB
|
||||
// limit every tier began answering 403 — an outage nothing local could fix.
|
||||
// raw.githubusercontent.com serves the file straight from the ref and
|
||||
// resolves no package, so it cannot fail that way.
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
let urls = level.urls();
|
||||
assert_eq!(
|
||||
urls.len(),
|
||||
1,
|
||||
"{} should have exactly one source: {urls:?}",
|
||||
level.as_str()
|
||||
);
|
||||
for url in urls {
|
||||
assert!(
|
||||
url.starts_with("https://raw.githubusercontent.com/"),
|
||||
"{} must be served from raw.githubusercontent.com: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
assert!(
|
||||
!url.contains("jsdelivr"),
|
||||
"{} must not reintroduce jsDelivr: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tier_points_at_the_oversized_upstream_repo() {
|
||||
// The `domains/*.txt` format moved to `-legacy`, which is small enough for
|
||||
// jsDelivr to resolve. Pointing any tier back at the original repo
|
||||
// reintroduces the 403.
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
for url in level.urls() {
|
||||
assert!(
|
||||
!url.contains("/hagezi/dns-blocklists@") && !url.contains("/hagezi/dns-blocklists/"),
|
||||
"{} still points at the oversized repo: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
assert!(
|
||||
url.contains("dns-blocklists-legacy"),
|
||||
"{} should read the legacy list repo: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
assert!(
|
||||
url.ends_with(
|
||||
level
|
||||
.filename()
|
||||
.expect("downloadable tiers have a filename")
|
||||
),
|
||||
"{} source must serve its own tier file: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_status_returns_all_levels() {
|
||||
let statuses = BlocklistManager::get_cache_status();
|
||||
|
||||
@@ -26,6 +26,29 @@ pub struct DownloadedBrowsersRegistry {
|
||||
geoip_downloader: &'static GeoIPDownloader,
|
||||
}
|
||||
|
||||
/// Filename suffixes that identify a *downloaded artifact* — the container we
|
||||
/// fetched from the network — rather than a file belonging to the extracted
|
||||
/// install. Cleanup preserves these so a manually placed archive survives.
|
||||
///
|
||||
/// `.exe` and `.AppImage` are deliberately absent even though both can be
|
||||
/// downloaded. On Windows the extracted Wayfern payload is flat at the version
|
||||
/// root (`extraction::ensure_correct_directory_structure` returns early rather
|
||||
/// than nesting it), so preserving `.exe` kept `chrome.exe` while deleting every
|
||||
/// sibling `.dll`, the `.manifest`, `.pak` and `locales/` — a gutted install
|
||||
/// that then failed to launch with os error 14001. On Linux the `.AppImage`
|
||||
/// *is* the extracted payload. Cleanup must never leave behind something that
|
||||
/// still reads as an installed browser; the archive is deleted right after a
|
||||
/// successful download anyway, so nothing of value is lost.
|
||||
const DOWNLOAD_ARTIFACT_SUFFIXES: [&str; 7] =
|
||||
["zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "pkg", "msi"];
|
||||
|
||||
fn is_download_artifact(file_name: &str) -> bool {
|
||||
let lowered = file_name.to_lowercase();
|
||||
DOWNLOAD_ARTIFACT_SUFFIXES
|
||||
.iter()
|
||||
.any(|suffix| lowered.ends_with(suffix))
|
||||
}
|
||||
|
||||
impl DownloadedBrowsersRegistry {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
@@ -174,15 +197,19 @@ impl DownloadedBrowsersRegistry {
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Never delete files out from under a live download or extraction. Both the
|
||||
// detached task that runs the moment a download completes and the periodic
|
||||
// maintenance task land here, and a freshly downloaded version is referenced
|
||||
// by no persisted profile while profile creation is still in flight.
|
||||
if crate::downloader::is_downloading(browser, version) {
|
||||
log::info!("Skipping cleanup of {browser} {version}: a download is in progress");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(info) = self.remove_browser(browser, version) {
|
||||
// Clean up extracted binaries but preserve downloaded archives
|
||||
if info.file_path.exists() {
|
||||
if info.file_path.is_dir() {
|
||||
// Allowed archive extensions to preserve
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
|
||||
for entry in fs::read_dir(&info.file_path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
@@ -192,16 +219,11 @@ impl DownloadedBrowsersRegistry {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For files, preserve if they look like downloaded archives/installers
|
||||
// For files, preserve only genuine downloaded archives/installers
|
||||
let keep = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|name| {
|
||||
// Match suffixes (handles multi-part extensions like .tar.xz)
|
||||
archive_exts
|
||||
.iter()
|
||||
.any(|ext| name.to_lowercase().ends_with(&ext.to_lowercase()))
|
||||
})
|
||||
.map(is_download_artifact)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !keep {
|
||||
@@ -215,13 +237,7 @@ impl DownloadedBrowsersRegistry {
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
let is_archive = archive_exts
|
||||
.iter()
|
||||
.any(|ext| file_name.to_lowercase().ends_with(&ext.to_lowercase()));
|
||||
if !is_archive {
|
||||
if !is_download_artifact(file_name) {
|
||||
fs::remove_file(&info.file_path)?;
|
||||
}
|
||||
}
|
||||
@@ -1230,6 +1246,130 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The Windows payload is extracted flat at the version root, so preserving
|
||||
/// every `*.exe` used to leave `chrome.exe` behind while deleting the `.dll`
|
||||
/// files and the `.manifest` next to it. That gutted directory still passed
|
||||
/// the "is it downloaded?" check, was re-registered as healthy, and launching
|
||||
/// it failed in the Windows loader with os error 14001.
|
||||
#[test]
|
||||
fn test_cleanup_removes_the_browser_executable_not_just_its_libraries() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let version_dir = temp.path().join("wayfern").join("140.0");
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
|
||||
for name in [
|
||||
"chrome.exe",
|
||||
"wayfern.exe",
|
||||
"notification_helper.exe",
|
||||
"chrome.dll",
|
||||
"chrome_elf.dll",
|
||||
"chrome.exe.manifest",
|
||||
"resources.pak",
|
||||
] {
|
||||
std::fs::File::create(version_dir.join(name)).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(version_dir.join("locales")).unwrap();
|
||||
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "wayfern".to_string(),
|
||||
version: "140.0".to_string(),
|
||||
file_path: version_dir.clone(),
|
||||
});
|
||||
|
||||
registry
|
||||
.cleanup_failed_download("wayfern", "140.0")
|
||||
.expect("cleanup should succeed");
|
||||
|
||||
let leftovers: Vec<String> = std::fs::read_dir(&version_dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"cleanup must not leave a half-deleted install behind, found: {leftovers:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The preserve rule still exists for its actual purpose: a downloaded
|
||||
/// archive (including one placed there by hand) survives the cleanup.
|
||||
#[test]
|
||||
fn test_cleanup_preserves_a_downloaded_archive() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let version_dir = temp.path().join("wayfern").join("141.0");
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
|
||||
std::fs::File::create(version_dir.join("wayfern-win64.zip")).unwrap();
|
||||
std::fs::File::create(version_dir.join("wayfern-mac.tar.xz")).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.exe")).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.dll")).unwrap();
|
||||
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "wayfern".to_string(),
|
||||
version: "141.0".to_string(),
|
||||
file_path: version_dir.clone(),
|
||||
});
|
||||
|
||||
registry
|
||||
.cleanup_failed_download("wayfern", "141.0")
|
||||
.expect("cleanup should succeed");
|
||||
|
||||
assert!(
|
||||
version_dir.join("wayfern-win64.zip").exists(),
|
||||
"a downloaded archive must be preserved"
|
||||
);
|
||||
assert!(
|
||||
version_dir.join("wayfern-mac.tar.xz").exists(),
|
||||
"multi-part archive extensions must still be recognised"
|
||||
);
|
||||
assert!(
|
||||
!version_dir.join("chrome.exe").exists(),
|
||||
"the extracted executable must be removed"
|
||||
);
|
||||
assert!(
|
||||
!version_dir.join("chrome.dll").exists(),
|
||||
"the extracted libraries must be removed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cleanup runs on a detached task the moment a download completes and again
|
||||
/// on a periodic timer, either of which can land while an install is still
|
||||
/// being written. It must stand down instead of deleting live files.
|
||||
#[test]
|
||||
fn test_cleanup_stands_down_while_a_download_is_in_progress() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let version_dir = temp.path().join("wayfern").join("142.0");
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.exe")).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.dll")).unwrap();
|
||||
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "wayfern".to_string(),
|
||||
version: "142.0".to_string(),
|
||||
file_path: version_dir.clone(),
|
||||
});
|
||||
|
||||
crate::downloader::mark_downloading_for_test("wayfern", "142.0");
|
||||
let result = registry.cleanup_failed_download("wayfern", "142.0");
|
||||
crate::downloader::clear_download_state_for_browser("wayfern");
|
||||
result.expect("cleanup should succeed");
|
||||
|
||||
assert!(
|
||||
version_dir.join("chrome.exe").exists() && version_dir.join("chrome.dll").exists(),
|
||||
"an in-flight download must not be deleted out from under itself"
|
||||
);
|
||||
assert!(
|
||||
registry.is_browser_registered("wayfern", "142.0"),
|
||||
"the registry entry must survive too, the version is still being installed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_browser_registered_vs_downloaded() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
@@ -879,6 +879,17 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
|
||||
downloading.contains(&download_key)
|
||||
}
|
||||
|
||||
/// Test-only: mark a browser-version pair as in flight so guards that consult
|
||||
/// `is_downloading` can be exercised without running a real download. Clear it
|
||||
/// again with `clear_download_state_for_browser`.
|
||||
#[cfg(test)]
|
||||
pub fn mark_downloading_for_test(browser: &str, version: &str) {
|
||||
DOWNLOADING_BROWSERS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(format!("{browser}-{version}"));
|
||||
}
|
||||
|
||||
/// Clear all in-progress download bookkeeping for a browser.
|
||||
///
|
||||
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
|
||||
|
||||
@@ -86,6 +86,62 @@ fn find_zip_start(data: &[u8]) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Read and parse an extension archive's `manifest.json`. Handles the CRX3
|
||||
/// header by seeking to the embedded ZIP. Shared with
|
||||
/// `vpn_extension_detect`, which classifies from the raw manifest rather than
|
||||
/// from the metadata subset persisted on `Extension`.
|
||||
pub(crate) fn read_manifest_from_archive(
|
||||
file_data: &[u8],
|
||||
file_type: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut contents = String::new();
|
||||
{
|
||||
let mut file = archive.by_name("manifest.json").ok()?;
|
||||
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
|
||||
}
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
/// Resolve a `__MSG_key__` placeholder against the archive's default locale
|
||||
/// messages. Chromium extensions routinely localize `name`/`description`, and
|
||||
/// showing the raw placeholder in a warning dialog reads as a bug.
|
||||
pub(crate) fn resolve_archive_i18n(
|
||||
file_data: &[u8],
|
||||
file_type: &str,
|
||||
manifest: &serde_json::Value,
|
||||
value: &str,
|
||||
) -> Option<String> {
|
||||
let key = crate::vpn_extension_detect::message_placeholder_key(value)?;
|
||||
let default_locale = manifest.get("default_locale")?.as_str()?;
|
||||
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut contents = String::new();
|
||||
{
|
||||
let mut file = archive
|
||||
.by_name(&format!("_locales/{default_locale}/messages.json"))
|
||||
.ok()?;
|
||||
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
|
||||
}
|
||||
let messages: serde_json::Value = serde_json::from_str(&contents).ok()?;
|
||||
crate::vpn_extension_detect::lookup_message(&messages, &key)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn extract_manifest_metadata(
|
||||
file_data: &[u8],
|
||||
@@ -97,39 +153,11 @@ fn extract_manifest_metadata(
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
|
||||
let mut archive = match zip::ZipArchive::new(cursor) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") {
|
||||
let mut contents = String::new();
|
||||
if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() {
|
||||
Some(contents)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let manifest_content = match manifest_content {
|
||||
Some(c) => c,
|
||||
let manifest = match read_manifest_from_archive(file_data, file_type) {
|
||||
Some(v) => v,
|
||||
None => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let name = manifest
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! 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.
|
||||
//! Measures a proxy's exit node and compares it to a profile's fingerprint.
|
||||
//!
|
||||
//! Resolve the exit IP through the upstream, geolocate it with the bundled
|
||||
//! MaxMind database (the same source the fingerprint generator uses), then
|
||||
//! compare its timezone and country against the 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.
|
||||
//!
|
||||
//! This module only measures. Deciding what a mismatch *means* for a launch —
|
||||
//! block, warn, or ignore — belongs to `launch_gate`, which calls
|
||||
//! `probe_and_check_consistency` before the browser is spawned. Launches never
|
||||
//! rewrite the fingerprint silently, so a real mismatch always surfaces.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -19,20 +23,68 @@ use crate::proxy_manager::PROXY_MANAGER;
|
||||
/// on every launch is wasteful.
|
||||
const EXIT_CACHE_TTL_SECS: u64 = 30 * 60;
|
||||
|
||||
/// Ceiling on a single exit probe. `fetch_public_ip` races six endpoints with
|
||||
/// a 10s timeout each, which is fine for a background check but far longer
|
||||
/// than a user will wait staring at a launch that has not started yet.
|
||||
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedExit {
|
||||
fetched_at: u64,
|
||||
/// The proxy URL this exit was measured through. Editing a stored proxy keeps
|
||||
/// The endpoint 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,
|
||||
///
|
||||
/// Never a loopback URL: the Xray/VPN workers a launch spins up get a fresh
|
||||
/// random port and credentials each time, so keying on those would miss on
|
||||
/// every relaunch and re-probe forever.
|
||||
identity: String,
|
||||
timezone: Option<String>,
|
||||
country_code: Option<String>,
|
||||
ip: Option<String>,
|
||||
}
|
||||
|
||||
/// Identity of the exit a profile routes through, stable across worker
|
||||
/// restarts.
|
||||
///
|
||||
/// `scope` keys the cache; `identity` detects that the endpoint behind that
|
||||
/// key changed. Cloud-derived proxies inject a per-profile sticky-session id,
|
||||
/// so two profiles sharing one stored proxy correctly get different identities
|
||||
/// and never inherit each other's verdict.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExitCacheKey {
|
||||
pub scope: String,
|
||||
pub identity: String,
|
||||
}
|
||||
|
||||
/// Resolve the cache identity from the profile's *stored* configuration.
|
||||
///
|
||||
/// Deliberately not derived from the normalized upstream the launcher passes
|
||||
/// to the probe: for VLESS and VPN that upstream is a loopback worker whose
|
||||
/// port and credentials are regenerated per launch.
|
||||
pub fn exit_cache_key(profile: &BrowserProfile) -> Option<ExitCacheKey> {
|
||||
if let Some(proxy_id) = &profile.proxy_id {
|
||||
let settings = PROXY_MANAGER
|
||||
.resolve_proxy_for_profile(proxy_id, &profile.id.to_string())
|
||||
.or_else(|| PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))?;
|
||||
// build_proxy_url returns the VLESS URI verbatim for vless proxies, so one
|
||||
// call covers every transport.
|
||||
return Some(ExitCacheKey {
|
||||
scope: format!("proxy:{proxy_id}"),
|
||||
identity: crate::proxy_manager::ProxyManager::build_proxy_url(&settings),
|
||||
});
|
||||
}
|
||||
if let Some(vpn_id) = &profile.vpn_id {
|
||||
return Some(ExitCacheKey {
|
||||
scope: format!("vpn:{vpn_id}"),
|
||||
identity: vpn_id.clone(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
@@ -54,7 +106,7 @@ pub struct ConsistencyResult {
|
||||
}
|
||||
|
||||
impl ConsistencyResult {
|
||||
fn skip() -> Self {
|
||||
pub fn skip() -> Self {
|
||||
Self {
|
||||
consistent: true,
|
||||
checked: false,
|
||||
@@ -68,18 +120,15 @@ impl ConsistencyResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL for handing this proxy to reqwest. VLESS is reached through the
|
||||
/// authenticated loopback Xray-core worker already serving the profile.
|
||||
fn proxy_url(settings: &crate::browser::ProxySettings, profile_id: Option<&str>) -> Option<String> {
|
||||
/// Whether this upstream can carry a probe request at all.
|
||||
///
|
||||
/// Shadowsocks and anything else reqwest cannot dial directly is skipped
|
||||
/// rather than guessed at.
|
||||
fn probe_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),
|
||||
crate::proxy_manager::ProxyManager::build_probe_proxy_url(settings),
|
||||
),
|
||||
"vless" => profile_id
|
||||
.and_then(crate::xray_worker_storage::find_xray_worker_by_profile_id)
|
||||
.map(|worker| {
|
||||
crate::proxy_manager::ProxyManager::build_proxy_url(&worker.local_proxy_settings())
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -119,120 +168,174 @@ fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<Strin
|
||||
(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(
|
||||
/// A mutex whose poison is not fatal.
|
||||
///
|
||||
/// A panic anywhere under this lock used to brick the check process-wide.
|
||||
/// That was tolerable when a failed check only skipped a warning; now a launch
|
||||
/// consults it, so a poisoned lock must degrade rather than propagate.
|
||||
fn exit_cache() -> std::sync::MutexGuard<'static, HashMap<String, CachedExit>> {
|
||||
EXIT_CACHE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Compare a measured exit against a profile's fingerprint. Pure — no I/O.
|
||||
pub fn compare_exit_to_fingerprint(
|
||||
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 profile_id = profile.id.to_string();
|
||||
let Some(url) = proxy_url(&settings, Some(&profile_id)) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let cache_identity = if settings.proxy_type.eq_ignore_ascii_case("vless") {
|
||||
settings.vless_uri.clone().unwrap_or_else(|| url.clone())
|
||||
} else {
|
||||
url.clone()
|
||||
};
|
||||
|
||||
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 == cache_identity && 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: cache_identity,
|
||||
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());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exit_timezone: Option<String>,
|
||||
exit_country_code: Option<String>,
|
||||
exit_ip: Option<String>,
|
||||
) -> ConsistencyResult {
|
||||
let (fp_tz, fp_lang) = fingerprint_locale(profile);
|
||||
let mut mismatches = Vec::new();
|
||||
|
||||
if let (Some(exit), Some(fp)) = (&exit_tz, &fp_tz) {
|
||||
if let (Some(exit), Some(fp)) = (&exit_timezone, &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 let (Some(cc), Some(lang)) = (&exit_country_code, &fp_lang) {
|
||||
if language_matches_country(cc, lang) == Some(false) {
|
||||
mismatches.push("language".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ConsistencyResult {
|
||||
ConsistencyResult {
|
||||
consistent: mismatches.is_empty(),
|
||||
checked: true,
|
||||
exit_ip,
|
||||
exit_country_code: exit_cc,
|
||||
exit_timezone: exit_tz,
|
||||
exit_country_code,
|
||||
exit_timezone,
|
||||
fingerprint_timezone: fp_tz,
|
||||
fingerprint_language: fp_lang,
|
||||
mismatches,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_profile_fingerprint_consistency(
|
||||
profile_id: String,
|
||||
/// Look up a still-valid cached exit for this profile.
|
||||
fn cached_exit(key: &ExitCacheKey) -> Option<CachedExit> {
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
exit_cache()
|
||||
.get(&key.scope)
|
||||
.filter(|c| {
|
||||
c.identity == key.identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Cache-only check. Never performs I/O, so it is safe to call before a launch
|
||||
/// and for every profile in a bulk run. Returns an unchecked result on a miss.
|
||||
pub fn check_profile_consistency_cached(profile: &BrowserProfile) -> ConsistencyResult {
|
||||
let Some(key) = exit_cache_key(profile) else {
|
||||
return ConsistencyResult::skip();
|
||||
};
|
||||
let Some(cached) = cached_exit(&key) else {
|
||||
return ConsistencyResult::skip();
|
||||
};
|
||||
compare_exit_to_fingerprint(profile, cached.timezone, cached.country_code, cached.ip)
|
||||
}
|
||||
|
||||
/// Drop any cached exit for this profile, so the next check re-measures.
|
||||
pub fn invalidate_exit_cache(profile: &BrowserProfile) {
|
||||
if let Some(key) = exit_cache_key(profile) {
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the exit through an already-normalized upstream and compare it to
|
||||
/// the fingerprint.
|
||||
///
|
||||
/// `upstream` is what the launcher will actually hand the browser — a loopback
|
||||
/// worker for VLESS and VPN, the resolved endpoint for a stored proxy — so one
|
||||
/// code path covers every transport. `None` means a genuine direct connection,
|
||||
/// which has nothing to disagree with.
|
||||
pub async fn probe_and_check_consistency(
|
||||
profile: &BrowserProfile,
|
||||
upstream: Option<&crate::browser::ProxySettings>,
|
||||
key: &ExitCacheKey,
|
||||
) -> 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
|
||||
if let Some(cached) = cached_exit(key) {
|
||||
return Ok(compare_exit_to_fingerprint(
|
||||
profile,
|
||||
cached.timezone,
|
||||
cached.country_code,
|
||||
cached.ip,
|
||||
));
|
||||
}
|
||||
|
||||
let Some(settings) = upstream else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let Some(url) = probe_url(settings) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
|
||||
// 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.
|
||||
//
|
||||
// Bounded independently of fetch_public_ip's own per-request timeout: that
|
||||
// one races six endpoints and can add up to far longer than a user will wait
|
||||
// in front of a launch.
|
||||
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(Some(&url)))
|
||||
.await
|
||||
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
|
||||
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
|
||||
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => {
|
||||
let tz = Some(geo.timezone);
|
||||
let cc = geo.locale.region.clone();
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs(),
|
||||
identity: key.identity.clone(),
|
||||
timezone: tz.clone(),
|
||||
country_code: cc.clone(),
|
||||
ip: Some(exit_ip.clone()),
|
||||
},
|
||||
);
|
||||
Ok(compare_exit_to_fingerprint(profile, tz, cc, Some(exit_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}");
|
||||
Ok(ConsistencyResult::skip())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the exit this machine reaches without any proxy, and compare it to
|
||||
/// the fingerprint.
|
||||
///
|
||||
/// Used when a profile declares a route that did not materialize: the browser
|
||||
/// is about to connect directly, so the direct exit is the one that matters.
|
||||
/// Deliberately NOT cached — a direct exit belongs to this machine's network,
|
||||
/// not to any stored proxy, and it changes without a config edit.
|
||||
pub async fn probe_direct_and_check(profile: &BrowserProfile) -> Result<ConsistencyResult, String> {
|
||||
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(None))
|
||||
.await
|
||||
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
|
||||
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
|
||||
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => Ok(compare_exit_to_fingerprint(
|
||||
profile,
|
||||
Some(geo.timezone),
|
||||
geo.locale.region.clone(),
|
||||
Some(exit_ip),
|
||||
)),
|
||||
Err(e) => {
|
||||
log::debug!("Consistency check: could not geolocate direct exit IP: {e}");
|
||||
Ok(ConsistencyResult::skip())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite a profile's stored fingerprint so its geolocation (timezone,
|
||||
@@ -280,6 +383,10 @@ pub async fn match_profile_fingerprint_to_exit(
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
// The stored verdict was computed against the fingerprint we just rewrote.
|
||||
// Leaving it would re-block the very launch this fix exists to unblock.
|
||||
invalidate_exit_cache(&profile);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -344,55 +451,218 @@ mod tests {
|
||||
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(),
|
||||
fn settings(
|
||||
proxy_type: &str,
|
||||
user: Option<&str>,
|
||||
pass: Option<&str>,
|
||||
) -> crate::browser::ProxySettings {
|
||||
crate::browser::ProxySettings {
|
||||
proxy_type: proxy_type.into(),
|
||||
host: "gw.provider.io".into(),
|
||||
port: 8080,
|
||||
username: Some("u".into()),
|
||||
password: Some("p".into()),
|
||||
username: user.map(str::to_string),
|
||||
password: pass.map(str::to_string),
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(proxy_url(&http, None).as_deref(), Some("http://u:p@h:8080"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_url_percent_encodes_credentials_and_skips_shadowsocks() {
|
||||
assert_eq!(
|
||||
probe_url(&settings("http", Some("u"), Some("p"))).as_deref(),
|
||||
Some("http://u:p@gw.provider.io: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()),
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(
|
||||
proxy_url(&reserved, None).as_deref(),
|
||||
probe_url(&settings("http", Some("user"), Some("ab/cd@ef"))).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,
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(
|
||||
proxy_url(&user_only, None).as_deref(),
|
||||
Some("socks5://justuser@h:1080")
|
||||
probe_url(&settings("socks4", Some("justuser"), None)).as_deref(),
|
||||
Some("socks4://justuser@gw.provider.io:8080")
|
||||
);
|
||||
|
||||
let ss = crate::browser::ProxySettings {
|
||||
proxy_type: "ss".into(),
|
||||
host: "h".into(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
// Shadowsocks cannot carry a reqwest probe, so it is skipped rather than
|
||||
// guessed at.
|
||||
assert_eq!(probe_url(&settings("ss", None, None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_url_uses_socks5h_so_dns_resolves_at_the_exit() {
|
||||
let url = probe_url(&settings("socks5", Some("u"), Some("p"))).unwrap();
|
||||
assert!(
|
||||
url.starts_with("socks5h://"),
|
||||
"probe must not resolve the echo host locally, got {url}"
|
||||
);
|
||||
// The browser-facing builder is deliberately left alone.
|
||||
assert!(
|
||||
crate::proxy_manager::ProxyManager::build_proxy_url(&settings(
|
||||
"socks5",
|
||||
Some("u"),
|
||||
Some("p")
|
||||
))
|
||||
.starts_with("socks5://")
|
||||
);
|
||||
}
|
||||
|
||||
fn profile_with_fingerprint(timezone: &str, language: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "p".into(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(proxy_url(&ss, None), None);
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(
|
||||
serde_json::json!({ "timezone": timezone, "language": language }).to_string(),
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_flags_a_timezone_mismatch() {
|
||||
let profile = profile_with_fingerprint("America/New_York", "en-US");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
Some("1.2.3.4".into()),
|
||||
);
|
||||
assert!(result.checked);
|
||||
assert!(!result.consistent);
|
||||
assert!(result.mismatches.contains(&"timezone".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_accepts_a_matching_exit() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
Some("1.2.3.4".into()),
|
||||
);
|
||||
assert!(result.consistent, "{:?}", result.mismatches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_is_case_insensitive_on_timezone() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("europe/berlin".into()),
|
||||
Some("DE".into()),
|
||||
None,
|
||||
);
|
||||
assert!(result.consistent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_skips_dimensions_the_fingerprint_does_not_declare() {
|
||||
// A profile with no fingerprint has nothing to contradict; it must not be
|
||||
// reported as a mismatch and so must never block a launch.
|
||||
let profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
None,
|
||||
);
|
||||
assert!(result.consistent);
|
||||
assert!(result.mismatches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_check_reports_unchecked_without_a_proxy_or_vpn() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = check_profile_consistency_cached(&profile);
|
||||
assert!(!result.checked);
|
||||
assert!(result.consistent, "an unchecked profile must never block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_key_is_absent_without_a_proxy_or_vpn() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
assert_eq!(exit_cache_key(&profile), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_key_scopes_a_vpn_profile_by_vpn_id() {
|
||||
let mut profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
profile.vpn_id = Some("vpn-abc".into());
|
||||
let key = exit_cache_key(&profile).expect("vpn profiles must be cacheable");
|
||||
assert_eq!(key.scope, "vpn:vpn-abc");
|
||||
assert_eq!(key.identity, "vpn-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_entry_is_ignored_once_the_endpoint_identity_changes() {
|
||||
let key = ExitCacheKey {
|
||||
scope: "proxy:test-identity-change".into(),
|
||||
identity: "http://old@host:1".into(),
|
||||
};
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs(),
|
||||
identity: key.identity.clone(),
|
||||
timezone: Some("Europe/Berlin".into()),
|
||||
country_code: Some("DE".into()),
|
||||
ip: Some("1.2.3.4".into()),
|
||||
},
|
||||
);
|
||||
assert!(cached_exit(&key).is_some());
|
||||
|
||||
// Editing a stored proxy keeps its id but changes the endpoint; the old
|
||||
// measurement must not be reused for the new one.
|
||||
let rotated = ExitCacheKey {
|
||||
identity: "http://new@host:2".into(),
|
||||
..key.clone()
|
||||
};
|
||||
assert!(cached_exit(&rotated).is_none());
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_entry_expires_after_the_ttl() {
|
||||
let key: ExitCacheKey = ExitCacheKey {
|
||||
scope: "proxy:test-ttl".into(),
|
||||
identity: "http://host:1".into(),
|
||||
};
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs() - EXIT_CACHE_TTL_SECS - 1,
|
||||
identity: key.identity.clone(),
|
||||
timezone: Some("Europe/Berlin".into()),
|
||||
country_code: Some("DE".into()),
|
||||
ip: None,
|
||||
},
|
||||
);
|
||||
assert!(cached_exit(&key).is_none());
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_survives_a_poisoned_lock() {
|
||||
// A panic under this lock must degrade the check, not brick every
|
||||
// subsequent launch that consults it.
|
||||
let _ = std::thread::spawn(|| {
|
||||
let _guard = EXIT_CACHE.lock().unwrap();
|
||||
panic!("poison the cache");
|
||||
})
|
||||
.join();
|
||||
assert!(EXIT_CACHE.is_poisoned());
|
||||
exit_cache().remove("nonexistent-scope");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
//! The pre-spawn launch gate.
|
||||
//!
|
||||
//! Two findings can stop a launch being what the user expects:
|
||||
//!
|
||||
//! * a **VPN/proxy extension** in the profile, which can override the proxy
|
||||
//! Donut configured and silently move the browser's exit away from the one
|
||||
//! the fingerprint was generated for — a warning, since Donut cannot tell
|
||||
//! from outside whether it is actually routing anything;
|
||||
//! * a measured **exit/fingerprint mismatch**, which is a hard block: the
|
||||
//! browser does not start until the user explicitly proceeds.
|
||||
//!
|
||||
//! The enforcing half runs inside `browser_runner::launch_browser_internal`,
|
||||
//! after the upstream has been normalized (so VLESS and VPN profiles are
|
||||
//! reachable at all) and before the local proxy starts or the browser spawns.
|
||||
//! `get_profile_pre_launch_checks` is the cheap, local-only half the UI calls
|
||||
//! first, so a profile whose exit is already known blocks without starting a
|
||||
//! single worker.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::fingerprint_consistency::{self, ConsistencyResult};
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::vpn_extension_detect::{self, DetectedVpnExtension};
|
||||
|
||||
/// How long a "launch anyway" decision stays redeemable. Long enough to read
|
||||
/// the dialog, short enough that a token cannot sit around across a session.
|
||||
const CONSENT_TTL_SECS: u64 = 10 * 60;
|
||||
|
||||
/// What the gate is allowed to do on this launch.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum FingerprintGate {
|
||||
/// Block on a measured mismatch. The default, and what the GUI uses.
|
||||
#[default]
|
||||
Enforce,
|
||||
/// Measure only from cache and report; never block, never probe the network.
|
||||
/// Automation runs here: a headless client has no dialog to answer and
|
||||
/// cannot regenerate its fingerprint mid-run, so a hard failure would turn a
|
||||
/// warning into an outage for a whole fleet.
|
||||
Advisory,
|
||||
/// The user already said "launch anyway" and handed back a token.
|
||||
Consented(String),
|
||||
}
|
||||
|
||||
struct PendingConsent {
|
||||
profile_id: String,
|
||||
fingerprint_hash: String,
|
||||
exit_identity: String,
|
||||
issued_at: u64,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CONSENTS: Mutex<HashMap<String, PendingConsent>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn consents() -> std::sync::MutexGuard<'static, HashMap<String, PendingConsent>> {
|
||||
CONSENTS.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn random_token() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
let mut bytes = [0u8; 16];
|
||||
rng.fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Issue a single-use token authorizing one launch of this exact
|
||||
/// (profile, fingerprint, exit) combination.
|
||||
///
|
||||
/// A plain `bypass: bool` cannot express this: a "proceed" the user granted
|
||||
/// while looking at proxy A would silently authorize a launch through proxy B
|
||||
/// if they changed it before the retry landed.
|
||||
pub fn mint_consent(profile: &BrowserProfile, exit_identity: &str) -> String {
|
||||
let token = random_token();
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
let mut store = consents();
|
||||
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
|
||||
store.insert(
|
||||
token.clone(),
|
||||
PendingConsent {
|
||||
profile_id: profile.id.to_string(),
|
||||
fingerprint_hash: crate::launch_gate_prefs::fingerprint_hash(profile),
|
||||
exit_identity: exit_identity.to_string(),
|
||||
issued_at: now,
|
||||
},
|
||||
);
|
||||
token
|
||||
}
|
||||
|
||||
/// Redeem a consent token. Single use — a redeemed token is removed whether or
|
||||
/// not it validated, so a leaked token cannot be replayed.
|
||||
pub fn redeem_consent(
|
||||
token: &str,
|
||||
profile: &BrowserProfile,
|
||||
exit_identity: &str,
|
||||
) -> Result<(), String> {
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
let pending = {
|
||||
let mut store = consents();
|
||||
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
|
||||
store.remove(token)
|
||||
};
|
||||
|
||||
let Some(pending) = pending else {
|
||||
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
|
||||
};
|
||||
if pending.profile_id != profile.id.to_string()
|
||||
|| pending.fingerprint_hash != crate::launch_gate_prefs::fingerprint_hash(profile)
|
||||
|| pending.exit_identity != exit_identity
|
||||
{
|
||||
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mismatch_error(result: &ConsistencyResult, token: &str) -> String {
|
||||
serde_json::json!({
|
||||
"code": "FINGERPRINT_EXIT_MISMATCH",
|
||||
"params": {
|
||||
"token": token,
|
||||
"exitIp": result.exit_ip.clone().unwrap_or_default(),
|
||||
"exitCountry": result.exit_country_code.clone().unwrap_or_default(),
|
||||
"exitTimezone": result.exit_timezone.clone().unwrap_or_default(),
|
||||
"fingerprintTimezone": result.fingerprint_timezone.clone().unwrap_or_default(),
|
||||
"fingerprintLanguage": result.fingerprint_language.clone().unwrap_or_default(),
|
||||
"mismatches": result.mismatches.join(","),
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn gate_disabled() -> bool {
|
||||
crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|s| s.fingerprint_gate_disabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn extension_warning_disabled() -> bool {
|
||||
crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|s| s.vpn_extension_warning_disabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Identity used for consent and acknowledgement when the browser will connect
|
||||
/// directly. Distinct from any proxy identity, so accepting a direct-exit
|
||||
/// mismatch never disarms the gate for a proxied one.
|
||||
const DIRECT_EXIT_IDENTITY: &str = "direct";
|
||||
|
||||
/// Gate a launch that will connect directly despite the profile declaring a
|
||||
/// route. Measures the exit the browser will really use.
|
||||
async fn enforce_direct_exit(
|
||||
profile: &BrowserProfile,
|
||||
gate: &FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
if gate_disabled() {
|
||||
return Ok(());
|
||||
}
|
||||
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, DIRECT_EXIT_IDENTITY) {
|
||||
return Ok(());
|
||||
}
|
||||
if let FingerprintGate::Consented(token) = gate {
|
||||
return redeem_consent(token, profile, DIRECT_EXIT_IDENTITY);
|
||||
}
|
||||
// Automation never probes; without a cache to consult there is nothing to say.
|
||||
if matches!(gate, FingerprintGate::Advisory) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = match fingerprint_consistency::probe_direct_and_check(profile).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Fingerprint gate: direct exit probe failed for profile {}, allowing launch: {e}",
|
||||
profile.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if !result.checked || result.consistent {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = mint_consent(profile, DIRECT_EXIT_IDENTITY);
|
||||
Err(mismatch_error(&result, &token))
|
||||
}
|
||||
|
||||
/// The enforcing gate. Called from the launch pipeline once the upstream is
|
||||
/// normalized and before anything expensive or user-visible happens.
|
||||
///
|
||||
/// Fails **open** on every degradation — probe failure, timeout, missing geo
|
||||
/// database, private exit IP. The gate blocks only on a positively measured
|
||||
/// mismatch; a flaky IP-echo endpoint must never make profiles unlaunchable.
|
||||
pub async fn enforce_fingerprint_gate(
|
||||
profile: &BrowserProfile,
|
||||
upstream: Option<&crate::browser::ProxySettings>,
|
||||
gate: &FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
// A profile that declares no route is genuinely direct: the browser's exit is
|
||||
// this machine, which is what an un-proxied fingerprint should describe.
|
||||
//
|
||||
// But a profile that DOES declare one and still arrives here with no upstream
|
||||
// is about to go direct anyway — a deleted or unresolvable proxy resolves to
|
||||
// `None` and the launch continues. That is the exact leak this gate exists to
|
||||
// stop, so it must be measured, not waved through.
|
||||
let declares_route = profile.proxy_id.is_some() || profile.vpn_id.is_some();
|
||||
if upstream.is_none() && !declares_route {
|
||||
return Ok(());
|
||||
}
|
||||
if gate_disabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Decide *once*, before any consent handling, whether this launch is going
|
||||
// out directly. Both a route that no longer resolves (deleted proxy) and one
|
||||
// that produced no usable upstream (a VPN worker with no local port) end up
|
||||
// connecting directly, and both must mint and redeem consent under the same
|
||||
// identity — splitting that decision across the function meant the first
|
||||
// attempt minted under "direct" while the retry redeemed against the proxy
|
||||
// identity, so "Launch anyway" could never succeed.
|
||||
let key = fingerprint_consistency::exit_cache_key(profile);
|
||||
if key.is_none() || upstream.is_none() {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} declares a proxy/VPN that yielded no usable upstream; \
|
||||
measuring the direct exit it will actually use",
|
||||
profile.name
|
||||
);
|
||||
return enforce_direct_exit(profile, gate).await;
|
||||
}
|
||||
let key = key.expect("checked above");
|
||||
|
||||
// Ack first: a persisted acknowledgement already permits this launch, so a
|
||||
// stale token must not turn it into a hard failure.
|
||||
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, &key.identity) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let FingerprintGate::Consented(token) = gate {
|
||||
redeem_consent(token, profile, &key.identity)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = if matches!(gate, FingerprintGate::Advisory) {
|
||||
// Automation: answer from a warm cache or say nothing. Probing here would
|
||||
// add seconds to every profile in a batch run.
|
||||
fingerprint_consistency::check_profile_consistency_cached(profile)
|
||||
} else {
|
||||
match fingerprint_consistency::probe_and_check_consistency(profile, upstream, &key).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Fingerprint gate: exit probe failed for profile {}, allowing launch: {e}",
|
||||
profile.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !result.checked || result.consistent {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Automation is the only caller allowed past a measured mismatch, because it
|
||||
// has no dialog to answer. A proxy-capable extension in the profile does NOT
|
||||
// earn the same pass: it makes the measurement less trustworthy, and a route
|
||||
// that might be worse than measured is a reason for more scrutiny, not less.
|
||||
// Waiving the block on it also meant any download manager holding Chromium's
|
||||
// `proxy` permission silently disarmed the gate for good.
|
||||
if matches!(gate, FingerprintGate::Advisory) {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} launching with a known exit mismatch ({})",
|
||||
profile.name,
|
||||
result.mismatches.join(", ")
|
||||
);
|
||||
if let Err(e) = crate::events::emit("fingerprint-consistency-warning", &result) {
|
||||
log::warn!("Failed to emit fingerprint consistency warning: {e}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = mint_consent(profile, &key.identity);
|
||||
Err(mismatch_error(&result, &token))
|
||||
}
|
||||
|
||||
/// Everything the UI needs to decide whether to stop a launch, answered
|
||||
/// without touching the network or starting any worker.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PreLaunchChecks {
|
||||
pub vpn_extensions: Vec<DetectedVpnExtension>,
|
||||
pub scan_state: String,
|
||||
/// Cache-only; `checked` is false when the exit has not been measured yet.
|
||||
pub consistency: ConsistencyResult,
|
||||
/// True when the enforcing gate will still probe during the launch, so the
|
||||
/// UI can say the check is not finished rather than implying it passed.
|
||||
pub exit_probe_pending: bool,
|
||||
/// An extension holding the `proxy` permission is present, so any exit
|
||||
/// measurement describes a route the browser may not take. Informational
|
||||
/// only — it never relaxes the block.
|
||||
pub exit_measurement_unreliable: bool,
|
||||
/// Present only when a cached mismatch is already blocking, so "launch
|
||||
/// anyway" can proceed without a second round trip.
|
||||
pub consent_token: Option<String>,
|
||||
}
|
||||
|
||||
fn load_profile(profile_id: &str) -> Result<BrowserProfile, String> {
|
||||
crate::profile::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaunchChecks, String> {
|
||||
let profile = load_profile(&profile_id)?;
|
||||
|
||||
// The setting suppresses the extension report entirely, which is safe
|
||||
// precisely because nothing enforcing depends on it: the scan feeds the
|
||||
// dialog's warning and the "measurement may be unreliable" note, never the
|
||||
// decision to block.
|
||||
let scan = if extension_warning_disabled() {
|
||||
vpn_extension_detect::ExtensionScan {
|
||||
extensions: Vec::new(),
|
||||
scan_state: "scanned".to_string(),
|
||||
}
|
||||
} else {
|
||||
vpn_extension_detect::scan_profile(&profile)
|
||||
};
|
||||
|
||||
// Drop anything the user has already acknowledged for this profile, so the
|
||||
// dialog only ever opens for something new.
|
||||
let vpn_extensions: Vec<DetectedVpnExtension> = scan
|
||||
.extensions
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
!crate::launch_gate_prefs::extensions_acked(&profile_id, std::slice::from_ref(&e.key))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let exit_measurement_unreliable = vpn_extension_detect::has_proxy_control(&scan);
|
||||
|
||||
let disabled = gate_disabled();
|
||||
let key = fingerprint_consistency::exit_cache_key(&profile);
|
||||
|
||||
let consistency = if disabled {
|
||||
ConsistencyResult::skip()
|
||||
} else {
|
||||
fingerprint_consistency::check_profile_consistency_cached(&profile)
|
||||
};
|
||||
|
||||
let already_acked = key
|
||||
.as_ref()
|
||||
.is_some_and(|k| crate::launch_gate_prefs::fingerprint_ack_matches(&profile, &k.identity));
|
||||
|
||||
let blocking = consistency.checked && !consistency.consistent && !already_acked;
|
||||
let consent_token = match (&key, blocking) {
|
||||
(Some(k), true) => Some(mint_consent(&profile, &k.identity)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(PreLaunchChecks {
|
||||
vpn_extensions,
|
||||
scan_state: scan.scan_state,
|
||||
consistency: if blocking {
|
||||
consistency
|
||||
} else {
|
||||
ConsistencyResult::skip()
|
||||
},
|
||||
exit_probe_pending: !disabled && !already_acked && key.is_some() && !blocking,
|
||||
exit_measurement_unreliable,
|
||||
consent_token,
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist "don't ask me again" choices from the gate dialog.
|
||||
#[tauri::command]
|
||||
pub async fn ack_launch_gate(
|
||||
profile_id: String,
|
||||
ack_fingerprint: bool,
|
||||
ack_extension_keys: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let profile = load_profile(&profile_id)?;
|
||||
|
||||
if ack_fingerprint {
|
||||
// Must match the identity the block was issued against. A profile whose
|
||||
// route did not resolve is gated on the direct exit and has no cache key,
|
||||
// so falling back here is what makes "don't block again" stick for it.
|
||||
let identity = fingerprint_consistency::exit_cache_key(&profile)
|
||||
.map(|key| key.identity)
|
||||
.unwrap_or_else(|| DIRECT_EXIT_IDENTITY.to_string());
|
||||
crate::launch_gate_prefs::ack_fingerprint(&profile, &identity);
|
||||
}
|
||||
crate::launch_gate_prefs::ack_extensions(&profile_id, &ack_extension_keys);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile_with(fingerprint: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(fingerprint.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_authorizes_exactly_one_launch() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_ok());
|
||||
// Replaying it must fail, so a leaked token cannot re-authorize.
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_for_a_different_profile() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let other = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &other, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_after_the_fingerprint_changes() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
|
||||
let mut regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
regenerated.id = profile.id;
|
||||
assert!(redeem_consent(&token, ®enerated, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_after_the_exit_changes() {
|
||||
// The reason a bare `bypass: bool` is not enough: consent granted for one
|
||||
// proxy must not authorize a launch through another.
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &profile, "http://other:2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_token_is_rejected() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let err = redeem_consent("deadbeef", &profile, "http://gw:1").unwrap_err();
|
||||
assert!(err.contains("LAUNCH_CONSENT_EXPIRED"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_tokens_are_swept_and_rejected() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
// Back-date it past the TTL.
|
||||
{
|
||||
let mut store = consents();
|
||||
if let Some(pending) = store.get_mut(&token) {
|
||||
pending.issued_at = crate::proxy_manager::now_secs() - CONSENT_TTL_SECS - 1;
|
||||
}
|
||||
}
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_error_carries_the_details_the_dialog_renders() {
|
||||
let result = ConsistencyResult {
|
||||
consistent: false,
|
||||
checked: true,
|
||||
exit_ip: Some("1.2.3.4".into()),
|
||||
exit_country_code: Some("DE".into()),
|
||||
exit_timezone: Some("Europe/Berlin".into()),
|
||||
fingerprint_timezone: Some("America/New_York".into()),
|
||||
fingerprint_language: Some("en-US".into()),
|
||||
mismatches: vec!["timezone".into(), "language".into()],
|
||||
};
|
||||
let encoded = mismatch_error(&result, "tok");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&encoded).unwrap();
|
||||
assert_eq!(parsed["code"], "FINGERPRINT_EXIT_MISMATCH");
|
||||
assert_eq!(parsed["params"]["token"], "tok");
|
||||
assert_eq!(parsed["params"]["exitTimezone"], "Europe/Berlin");
|
||||
assert_eq!(parsed["params"]["fingerprintTimezone"], "America/New_York");
|
||||
// params values must be strings for the frontend's interpolation.
|
||||
assert_eq!(parsed["params"]["mismatches"], "timezone,language");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_allows_a_direct_connection_without_measuring() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert!(
|
||||
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_allows_a_profile_with_no_proxy_or_vpn() {
|
||||
// A profile that declares no route has no upstream either — that pairing is
|
||||
// the only one the launcher can actually produce. It must return without
|
||||
// measuring anything, so this stays a pure unit test with no network.
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert!(
|
||||
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consent_for_a_direct_launch_is_redeemable_by_the_gate() {
|
||||
// Regression: a route that yields no usable upstream is gated on the direct
|
||||
// exit, so consent is minted under DIRECT_EXIT_IDENTITY. If the gate then
|
||||
// redeemed against the proxy/VPN identity instead, "Launch anyway" would
|
||||
// fail forever and the profile could never be started.
|
||||
let mut profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
profile.vpn_id = Some("vpn-with-no-port".into());
|
||||
|
||||
let token = mint_consent(&profile, DIRECT_EXIT_IDENTITY);
|
||||
// No upstream: the launcher could not bring the route up.
|
||||
let result = enforce_fingerprint_gate(&profile, None, &FingerprintGate::Consented(token)).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"consent minted for the direct exit must be redeemable, got {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Persisted "I know, launch it anyway" acknowledgements for the launch gate.
|
||||
//!
|
||||
//! Deliberately NOT synced. An acknowledgement is a statement about this
|
||||
//! machine's operator ("I understand this profile's exit disagrees with its
|
||||
//! fingerprint"), not a property of the profile. Syncing it would let one
|
||||
//! teammate disarm another's gate, and writing it into profile metadata would
|
||||
//! bump `updated_at` and make a local dismissal look like a remote edit.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FingerprintAck {
|
||||
/// Hash of the fingerprint that was acknowledged.
|
||||
pub fingerprint_hash: String,
|
||||
/// Exit endpoint identity it was acknowledged against.
|
||||
pub exit_identity: String,
|
||||
pub acked_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LaunchGatePrefs {
|
||||
#[serde(default)]
|
||||
pub fingerprint_acks: HashMap<String, FingerprintAck>,
|
||||
/// Profile id -> acknowledged extension keys.
|
||||
#[serde(default)]
|
||||
pub vpn_extension_acks: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Serializes read-modify-write so two concurrent acknowledgements in a bulk
|
||||
/// run cannot clobber each other.
|
||||
static ref PREFS_LOCK: Mutex<()> = Mutex::new(());
|
||||
}
|
||||
|
||||
fn prefs_file() -> PathBuf {
|
||||
crate::app_dirs::data_subdir().join("launch_gate_prefs.json")
|
||||
}
|
||||
|
||||
pub fn load() -> LaunchGatePrefs {
|
||||
let Ok(content) = std::fs::read_to_string(prefs_file()) else {
|
||||
return LaunchGatePrefs::default();
|
||||
};
|
||||
serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
log::warn!("Failed to parse launch gate prefs, ignoring them: {e}");
|
||||
LaunchGatePrefs::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn save(prefs: &LaunchGatePrefs) {
|
||||
let path = prefs_file();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("Failed to create launch gate prefs dir: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_string_pretty(prefs) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&path, json) {
|
||||
log::warn!("Failed to write launch gate prefs: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Failed to serialize launch gate prefs: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(mutate: impl FnOnce(&mut LaunchGatePrefs)) {
|
||||
let _guard = PREFS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut prefs = load();
|
||||
mutate(&mut prefs);
|
||||
save(&prefs);
|
||||
}
|
||||
|
||||
/// Stable digest of a profile's stored fingerprint, so an acknowledgement stops
|
||||
/// applying the moment the fingerprint is regenerated or matched to a new exit.
|
||||
pub fn fingerprint_hash(profile: &BrowserProfile) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let fingerprint = profile
|
||||
.wayfern_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.fingerprint.as_deref())
|
||||
.unwrap_or("");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(fingerprint.as_bytes());
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record that the user accepted this exact (fingerprint, exit) mismatch.
|
||||
pub fn ack_fingerprint(profile: &BrowserProfile, exit_identity: &str) {
|
||||
let ack = FingerprintAck {
|
||||
fingerprint_hash: fingerprint_hash(profile),
|
||||
exit_identity: exit_identity.to_string(),
|
||||
acked_at: crate::proxy_manager::now_secs(),
|
||||
};
|
||||
let profile_id = profile.id.to_string();
|
||||
update(|prefs| {
|
||||
prefs.fingerprint_acks.insert(profile_id, ack);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the user already accepted the mismatch this profile currently has.
|
||||
///
|
||||
/// Bound to both the fingerprint and the exit endpoint on purpose: the old
|
||||
/// per-profile "don't warn again" flag never expired, so one dismissal left a
|
||||
/// profile unprotected forever, including after its proxy was swapped for one
|
||||
/// in a different country.
|
||||
pub fn fingerprint_ack_matches(profile: &BrowserProfile, exit_identity: &str) -> bool {
|
||||
let prefs = load();
|
||||
prefs
|
||||
.fingerprint_acks
|
||||
.get(&profile.id.to_string())
|
||||
.is_some_and(|ack| {
|
||||
ack.fingerprint_hash == fingerprint_hash(profile) && ack.exit_identity == exit_identity
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ack_extensions(profile_id: &str, keys: &[String]) {
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
let profile_id = profile_id.to_string();
|
||||
let keys = keys.to_vec();
|
||||
update(|prefs| {
|
||||
let entry = prefs.vpn_extension_acks.entry(profile_id).or_default();
|
||||
for key in keys {
|
||||
if !entry.contains(&key) {
|
||||
entry.push(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// True when every one of these extensions has already been acknowledged for
|
||||
/// this profile. Installing a *different* VPN extension later re-warns, because
|
||||
/// its key is not in the acknowledged set.
|
||||
pub fn extensions_acked(profile_id: &str, keys: &[String]) -> bool {
|
||||
if keys.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let prefs = load();
|
||||
let Some(acked) = prefs.vpn_extension_acks.get(profile_id) else {
|
||||
return false;
|
||||
};
|
||||
keys.iter().all(|k| acked.contains(k))
|
||||
}
|
||||
|
||||
/// Drop everything remembered for a profile, for use when it is deleted.
|
||||
pub fn forget_profile(profile_id: &str) {
|
||||
let profile_id = profile_id.to_string();
|
||||
update(|prefs| {
|
||||
prefs.fingerprint_acks.remove(&profile_id);
|
||||
prefs.vpn_extension_acks.remove(&profile_id);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile_with(fingerprint: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(fingerprint.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_hash_changes_with_the_fingerprint() {
|
||||
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let b = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
assert_ne!(fingerprint_hash(&a), fingerprint_hash(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_hash_is_stable_for_the_same_fingerprint() {
|
||||
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let b = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert_eq!(fingerprint_hash(&a), fingerprint_hash(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_without_a_fingerprint_still_hashes() {
|
||||
let profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!fingerprint_hash(&profile).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acks_round_trip_and_rearm_on_change() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
|
||||
ack_fingerprint(&profile, "http://gw:1");
|
||||
assert!(fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
|
||||
// Swapping the proxy re-arms the gate: the mismatch the user accepted is
|
||||
// not the mismatch they now have.
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://other:2"));
|
||||
|
||||
// Regenerating the fingerprint re-arms it too.
|
||||
let regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
let mut same_id = regenerated.clone();
|
||||
same_id.id = profile.id;
|
||||
assert!(!fingerprint_ack_matches(&same_id, "http://gw:1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_acks_are_per_key() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile_id = uuid::Uuid::new_v4().to_string();
|
||||
let nord = vec!["crx:aaaa".to_string()];
|
||||
let other = vec!["crx:bbbb".to_string()];
|
||||
|
||||
assert!(!extensions_acked(&profile_id, &nord));
|
||||
ack_extensions(&profile_id, &nord);
|
||||
assert!(extensions_acked(&profile_id, &nord));
|
||||
|
||||
// A different extension installed later must warn again.
|
||||
assert!(!extensions_acked(&profile_id, &other));
|
||||
assert!(!extensions_acked(
|
||||
&profile_id,
|
||||
&[nord[0].clone(), other[0].clone()]
|
||||
));
|
||||
|
||||
// Nothing to acknowledge is trivially acknowledged, so an empty scan never
|
||||
// opens the dialog.
|
||||
assert!(extensions_acked(&profile_id, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_profile_clears_both_kinds_of_ack() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let profile_id = profile.id.to_string();
|
||||
ack_fingerprint(&profile, "http://gw:1");
|
||||
ack_extensions(&profile_id, &["crx:aaaa".to_string()]);
|
||||
|
||||
forget_profile(&profile_id);
|
||||
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
assert!(!extensions_acked(&profile_id, &["crx:aaaa".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_prefs_file_is_ignored_rather_than_fatal() {
|
||||
let dir = tempfile::tempdir().expect("tempdir").keep();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(dir.clone());
|
||||
std::fs::create_dir_all(dir.join("data")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("data").join("launch_gate_prefs.json"),
|
||||
"{ not json",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Must not panic, and must fail closed (nothing acknowledged).
|
||||
let prefs = load();
|
||||
assert!(prefs.fingerprint_acks.is_empty());
|
||||
}
|
||||
}
|
||||
+128
-6
@@ -23,6 +23,16 @@ pub(crate) fn backend_error_with_detail(code: &str, detail: impl std::fmt::Displ
|
||||
serde_json::json!({ "code": code, "params": { "detail": detail.to_string() } }).to_string()
|
||||
}
|
||||
|
||||
/// A VLESS URI Donut cannot use, carrying which part is unsupported so the UI
|
||||
/// can say so instead of implying a typo.
|
||||
pub(crate) fn vless_config_error(error: &crate::xray::XrayError) -> String {
|
||||
serde_json::json!({
|
||||
"code": "VLESS_CONFIG_INVALID",
|
||||
"params": { "reason": error.reason_code(), "detail": error.to_string() }
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn e2e_automation_enabled() -> bool {
|
||||
#[cfg(feature = "e2e")]
|
||||
{
|
||||
@@ -51,6 +61,7 @@ mod automation_rate_limiter;
|
||||
mod browser;
|
||||
mod browser_runner;
|
||||
mod browser_version_manager;
|
||||
mod cdp_target;
|
||||
mod default_browser;
|
||||
pub mod dns_blocklist;
|
||||
mod downloaded_browsers_registry;
|
||||
@@ -64,14 +75,19 @@ mod geolocation;
|
||||
mod group_manager;
|
||||
mod human_typing;
|
||||
mod ip_utils;
|
||||
mod launch_gate;
|
||||
mod launch_gate_prefs;
|
||||
mod log_redaction;
|
||||
mod platform_browser;
|
||||
mod profile;
|
||||
mod profile_import;
|
||||
mod profile_importer;
|
||||
mod proxy_manager;
|
||||
pub mod proxy_runner;
|
||||
pub mod proxy_server;
|
||||
pub mod proxy_storage;
|
||||
mod remote_exit;
|
||||
mod remote_handoff;
|
||||
mod remote_session;
|
||||
mod settings_manager;
|
||||
pub mod socks5_local;
|
||||
@@ -80,6 +96,7 @@ mod synchronizer;
|
||||
pub mod traffic_stats;
|
||||
mod wayfern_manager;
|
||||
mod wayfern_terms;
|
||||
mod window_decorations;
|
||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||
pub mod cloud_auth;
|
||||
mod cloud_errors;
|
||||
@@ -93,6 +110,7 @@ mod tag_manager;
|
||||
mod team_lock;
|
||||
mod version_updater;
|
||||
pub mod vpn;
|
||||
mod vpn_extension_detect;
|
||||
pub mod vpn_worker_runner;
|
||||
pub mod vpn_worker_storage;
|
||||
pub mod xray;
|
||||
@@ -312,6 +330,16 @@ async fn create_stored_proxy(
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a VLESS URI without touching the network, so the proxy form can
|
||||
/// tell the user their setup is unsupported while they are still editing it
|
||||
/// rather than only after they try to save or launch.
|
||||
#[tauri::command]
|
||||
fn validate_vless_uri(uri: String) -> Result<(), String> {
|
||||
crate::xray::parse_vless_uri(uri.trim())
|
||||
.map(|_| ())
|
||||
.map_err(|error| vless_config_error(&error))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>, String> {
|
||||
Ok(crate::proxy_manager::PROXY_MANAGER.get_stored_proxies())
|
||||
@@ -1333,11 +1361,27 @@ async fn get_remote_session(
|
||||
/// so a handful of short launches bills an allowance meant for a hundred.
|
||||
#[tauri::command]
|
||||
async fn stop_remote_session(
|
||||
app_handle: tauri::AppHandle,
|
||||
session_id: String,
|
||||
) -> Result<remote_session::EndRemoteSessionOutcome, String> {
|
||||
remote_session::end_remote_session(&session_id)
|
||||
let outcome = remote_session::end_remote_session(&session_id)
|
||||
.await
|
||||
.map_err(|e| remote_session_error("stop", e))
|
||||
.map_err(|e| remote_session_error("stop", e))?;
|
||||
// The stream normally reports the close, but a stop must not depend on a
|
||||
// socket being up: without this the session's work would sit in cloud storage
|
||||
// with nothing to pull it, and the profile would look ready to open locally
|
||||
// while its local copy still predated the session.
|
||||
remote_session::note_session_stopped(&app_handle, &session_id);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// Which profiles cannot be launched locally right now, and why.
|
||||
///
|
||||
/// Backed by the same store the launch gate reads, so the button the UI disables
|
||||
/// and the refusal the backend would produce can never disagree.
|
||||
#[tauri::command]
|
||||
fn get_remote_handoff_states() -> std::collections::HashMap<String, remote_handoff::HandoffState> {
|
||||
remote_handoff::states()
|
||||
}
|
||||
|
||||
/// Subscribe to session transitions. Idempotent.
|
||||
@@ -1426,7 +1470,7 @@ async fn save_cookie_bot_schedule(
|
||||
// Refused here rather than at 02:00: a profile that can never be warmed
|
||||
// should never reach a schedule row, an hour of quota or a leased host.
|
||||
let profile = cookie_bot_profile(&profile_id)?;
|
||||
cookie_bot::bot_precondition(&profile)?;
|
||||
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
|
||||
// The frontend sends the user's choices; the profile facts the server refuses
|
||||
// a run on are stamped here, from the profile itself, so a caller cannot
|
||||
// assert them.
|
||||
@@ -1486,7 +1530,8 @@ async fn run_cookie_bot_now(
|
||||
profile_id: String,
|
||||
max_minutes: Option<u32>,
|
||||
) -> Result<cookie_bot::CookieBotRunStarted, String> {
|
||||
cookie_bot::bot_precondition(&cookie_bot_profile(&profile_id)?)?;
|
||||
let profile = cookie_bot_profile(&profile_id)?;
|
||||
cookie_bot::bot_precondition(&profile, &cookie_bot::exit_reachability(&profile))?;
|
||||
cookie_bot::run_now(&profile_id, max_minutes)
|
||||
.await
|
||||
.map_err(|e| cookie_bot_error("run start", e))
|
||||
@@ -1748,7 +1793,12 @@ pub fn run_with_builder(
|
||||
.with_state_flags(
|
||||
tauri_plugin_window_state::StateFlags::all()
|
||||
& !tauri_plugin_window_state::StateFlags::VISIBLE
|
||||
& !tauri_plugin_window_state::StateFlags::FULLSCREEN,
|
||||
& !tauri_plugin_window_state::StateFlags::FULLSCREEN
|
||||
// Whether the window is decorated is decided per-session by
|
||||
// `window_decorations::use_client_side_decorations()`, not by what
|
||||
// a previous run saved. Restoring it would put a real titlebar back
|
||||
// on top of the one the app draws — or strip both.
|
||||
& !tauri_plugin_window_state::StateFlags::DECORATIONS,
|
||||
)
|
||||
.build(),
|
||||
);
|
||||
@@ -1788,9 +1838,21 @@ pub fn run_with_builder(
|
||||
None => win_builder,
|
||||
};
|
||||
|
||||
// The app draws its own titlebar. macOS keeps the native one and makes
|
||||
// it transparent (below); Windows and Linux drop decorations entirely and
|
||||
// render their own controls.
|
||||
#[cfg(target_os = "windows")]
|
||||
let win_builder = win_builder.decorations(false);
|
||||
|
||||
// Linux opts out on the one configuration where dropping decorations can
|
||||
// make things worse rather than better — see `use_client_side_decorations`.
|
||||
#[cfg(target_os = "linux")]
|
||||
let win_builder = if window_decorations::use_client_side_decorations() {
|
||||
win_builder.decorations(false)
|
||||
} else {
|
||||
win_builder
|
||||
};
|
||||
|
||||
#[allow(unused_variables)]
|
||||
let window = win_builder.build().unwrap();
|
||||
|
||||
@@ -1823,6 +1885,44 @@ pub fn run_with_builder(
|
||||
});
|
||||
}
|
||||
|
||||
// Publish the desktop's titlebar button layout to the frontend. Runs
|
||||
// here because `setup` is the GTK main thread, which `gtk::Settings`
|
||||
// requires.
|
||||
//
|
||||
// The decorated state is logged alongside it: "my window has no titlebar"
|
||||
// and "my window has two titlebars" are both reports that hinge on this
|
||||
// one boolean, and it is otherwise invisible after the fact.
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
log::info!(
|
||||
"Linux window decorations: server-side = {:?}",
|
||||
window.is_decorated()
|
||||
);
|
||||
|
||||
// tao makes the window visible before it clears the decorations, so it
|
||||
// is realized while still framed and the frame extents come out of the
|
||||
// size we asked for (a requested 880x500 arrives noticeably smaller).
|
||||
//
|
||||
// Only correct that on a first run. Once window-state has geometry
|
||||
// saved, that geometry is the user's and has already been restored —
|
||||
// re-applying the default here would move and resize their window on
|
||||
// every launch, and the plugin would then persist the reset.
|
||||
let has_saved_geometry = app
|
||||
.path()
|
||||
.app_config_dir()
|
||||
.map(|dir| dir.join(".window-state.json").exists())
|
||||
.unwrap_or(false);
|
||||
if window_decorations::use_client_side_decorations() && !has_saved_geometry {
|
||||
if let Err(e) = window.set_size(tauri::LogicalSize::new(880.0, 500.0)) {
|
||||
log::warn!("Failed to re-apply the window size after dropping decorations: {e}");
|
||||
}
|
||||
if let Err(e) = window.center() {
|
||||
log::warn!("Failed to re-center the window after dropping decorations: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
window_decorations::init(app.handle());
|
||||
|
||||
// Set transparent titlebar for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
@@ -2535,6 +2635,12 @@ pub fn run_with_builder(
|
||||
// and would only be refused on a loop; the frontend starts it again
|
||||
// through `start_remote_session_events` once the user signs in.
|
||||
remote_session::start_session_events(app_handle_cloud.clone());
|
||||
|
||||
// A session that finished while this machine was shut, or whose pull
|
||||
// ran out of retries offline, leaves a profile blocked from launching
|
||||
// with its work still in cloud storage. Signing in is the first moment
|
||||
// that pull can succeed, so it is where it is retried.
|
||||
remote_handoff::resume_pending_pulls(&app_handle_cloud);
|
||||
}
|
||||
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
|
||||
});
|
||||
@@ -2649,8 +2755,11 @@ pub fn run_with_builder(
|
||||
clear_all_traffic_stats,
|
||||
clear_profile_traffic_stats,
|
||||
get_traffic_stats_for_period,
|
||||
fingerprint_consistency::check_profile_fingerprint_consistency,
|
||||
fingerprint_consistency::match_profile_fingerprint_to_exit,
|
||||
launch_gate::get_profile_pre_launch_checks,
|
||||
launch_gate::ack_launch_gate,
|
||||
window_decorations::get_window_decoration_layout,
|
||||
validate_vless_uri,
|
||||
get_sync_settings,
|
||||
save_sync_settings,
|
||||
set_profile_sync_mode,
|
||||
@@ -2731,6 +2840,7 @@ pub fn run_with_builder(
|
||||
list_remote_sessions,
|
||||
get_remote_session,
|
||||
stop_remote_session,
|
||||
get_remote_handoff_states,
|
||||
start_remote_session_events,
|
||||
stop_remote_session_events,
|
||||
get_remote_session_events_status,
|
||||
@@ -2746,6 +2856,14 @@ pub fn run_with_builder(
|
||||
get_cookie_bot_presets,
|
||||
get_remote_hours_quota,
|
||||
get_cookie_bot_usage,
|
||||
// Defined in `cookie_bot.rs` rather than here because they carry no local
|
||||
// precondition — there is no profile to look up and no `bot_precondition`
|
||||
// to apply. Unregistered they are unreachable, and the saved-list tab
|
||||
// fails at runtime with "command not found" rather than at build time.
|
||||
cookie_bot::get_cookie_bot_user_templates,
|
||||
cookie_bot::create_cookie_bot_user_template,
|
||||
cookie_bot::update_cookie_bot_user_template,
|
||||
cookie_bot::delete_cookie_bot_user_template,
|
||||
// Profile password commands
|
||||
set_profile_password,
|
||||
change_profile_password,
|
||||
@@ -2809,6 +2927,10 @@ mod tests {
|
||||
crate::remote_session::EVENT_SESSION_STATE,
|
||||
crate::remote_session::EVENT_SESSION_SNAPSHOT,
|
||||
crate::remote_session::EVENT_STREAM_STATUS,
|
||||
// The launch gate is emitted from the same place for the same reason: a
|
||||
// Run button that does not hear about it stays enabled over a profile the
|
||||
// backend will refuse, or over unsynced work it must not open.
|
||||
crate::remote_handoff::EVENT_REMOTE_HANDOFF,
|
||||
] {
|
||||
assert!(
|
||||
client.contains(&format!("\"{event}\"")),
|
||||
|
||||
+316
-428
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ fn is_kept(name: &str) -> bool {
|
||||
/// 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 {
|
||||
pub(crate) fn is_profile_dir_name(name: &str) -> bool {
|
||||
matches!(name, "Default" | "Guest Profile" | "System Profile")
|
||||
|| name
|
||||
.strip_prefix("Profile ")
|
||||
|
||||
@@ -479,6 +479,10 @@ impl ProfileManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Launch-gate acknowledgements are keyed by profile id and are not synced,
|
||||
// so nothing else would ever clean them up.
|
||||
crate::launch_gate_prefs::forget_profile(profile_id);
|
||||
|
||||
// Remember sync mode before deleting local files
|
||||
let was_sync_enabled = profile.is_sync_enabled();
|
||||
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
//! Copying a source profile into the new one.
|
||||
//!
|
||||
//! Two things a plain recursive copy gets wrong, both of which produce a
|
||||
//! profile that looks imported and is not:
|
||||
//!
|
||||
//! - **Torn databases.** Users import from a browser they are still using. A
|
||||
//! naive walk copies `Cookies` and `Cookies-wal` at different instants, and
|
||||
//! Chromium's `sql::Database` razes the result on open. `VACUUM INTO` takes a
|
||||
//! transactionally consistent snapshot instead, WAL content included, even
|
||||
//! while the source holds the file.
|
||||
//! - **Multi-GB of caches.** `Cache/`, `Code Cache/`, `GPUCache/` and friends
|
||||
//! carry no user state and dominate both copy time and disk use.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
/// Directories that never carry user state. Matched on the path relative to the
|
||||
/// profile root, so `Service Worker/CacheStorage` is dropped while
|
||||
/// `Service Worker/Database` survives.
|
||||
const SKIP_DIRS: &[&str] = &[
|
||||
"Cache",
|
||||
"Code Cache",
|
||||
"GPUCache",
|
||||
"GrShaderCache",
|
||||
"ShaderCache",
|
||||
"DawnCache",
|
||||
"DawnGraphiteCache",
|
||||
"DawnWebGPUCache",
|
||||
"GraphiteDawnCache",
|
||||
"GPUPersistentCache",
|
||||
"Service Worker/CacheStorage",
|
||||
"Service Worker/ScriptCache",
|
||||
"blob_storage",
|
||||
"Crashpad",
|
||||
"Crash Reports",
|
||||
"BrowserMetrics",
|
||||
"optimization_guide_model_store",
|
||||
"optimization_guide_hint_cache_store",
|
||||
"Safe Browsing",
|
||||
"Safe Browsing Network",
|
||||
"component_crx_cache",
|
||||
"extensions_crx_cache",
|
||||
"Download Service",
|
||||
"Site Characteristics Database",
|
||||
"shared_proto_db",
|
||||
"segmentation_platform",
|
||||
"Sync App Settings",
|
||||
// SNSS command logs replay the source machine's windows and can embed
|
||||
// absolute local paths in PageState blobs.
|
||||
"Sessions",
|
||||
"Session Storage",
|
||||
];
|
||||
|
||||
/// Exact file names that are per-machine, per-run, or regenerated.
|
||||
const SKIP_FILES: &[&str] = &[
|
||||
"LOCK",
|
||||
"LOG",
|
||||
"LOG.old",
|
||||
"SingletonLock",
|
||||
"SingletonCookie",
|
||||
"SingletonSocket",
|
||||
"RunningChromeVersion",
|
||||
"Last Version",
|
||||
"first_party_sets.db",
|
||||
".DS_Store",
|
||||
"Thumbs.db",
|
||||
// The account-bound part of `Sync Data/`. The rest of that directory is the
|
||||
// local DataTypeStore — Reading List, Saved Tab Groups and friends, which
|
||||
// exist for users who never signed in — so the folder itself is carried.
|
||||
"Nigori.bin",
|
||||
// Signed-in ephemeral twins of the real stores. They are wiped on sign-out,
|
||||
// and the imported profile will not be signed in.
|
||||
"Login Data For Account",
|
||||
"Login Data For Account-journal",
|
||||
"Account Web Data",
|
||||
"Account Web Data-journal",
|
||||
];
|
||||
|
||||
/// Suffixes that belong to a database we snapshot separately, or to scratch
|
||||
/// state. Copying a `-wal` next to a vacuumed main file actively corrupts it.
|
||||
const SKIP_SUFFIXES: &[&str] = &["-journal", "-wal", "-shm", ".tmp", ".old", ".bak.tmp"];
|
||||
|
||||
/// SQLite stores worth a consistent snapshot. Anything not listed is copied
|
||||
/// byte-for-byte, which is correct for JSON, LevelDB and unpacked CRXs.
|
||||
const SQLITE_FILES: &[&str] = &[
|
||||
"Cookies",
|
||||
"History",
|
||||
"Favicons",
|
||||
"Top Sites",
|
||||
"Shortcuts",
|
||||
"Login Data",
|
||||
"Web Data",
|
||||
"Affiliation Database",
|
||||
"Network Action Predictor",
|
||||
"DIPS",
|
||||
"Trust Tokens",
|
||||
"BudgetDatabase",
|
||||
"AutofillStrikeDatabase",
|
||||
"Reporting and NEL",
|
||||
"SCT Auditing Pending Reports",
|
||||
"Device Bound Sessions",
|
||||
"MediaDeviceSalts",
|
||||
"PreferredApps",
|
||||
"heavy_ad_intervention_opt_out.db",
|
||||
"SharedStorage",
|
||||
"BrowsingTopicsSiteData",
|
||||
"ClientCertificates",
|
||||
"PersistentOriginTrials",
|
||||
"Web Applications",
|
||||
];
|
||||
|
||||
pub struct CopyOutcome {
|
||||
pub bytes_copied: u64,
|
||||
/// Names of stores that could not be snapshotted and were skipped rather
|
||||
/// than copied in a corrupt state.
|
||||
pub unreadable_stores: Vec<String>,
|
||||
}
|
||||
|
||||
fn is_skipped_dir(relative: &Path) -> bool {
|
||||
let normalized = relative.to_string_lossy().replace('\\', "/");
|
||||
SKIP_DIRS.iter().any(|skip| {
|
||||
normalized == *skip
|
||||
|| normalized.ends_with(&format!("/{skip}"))
|
||||
// `BrowserMetrics-spare.pma` and friends.
|
||||
|| normalized.starts_with(&format!("{skip}-"))
|
||||
})
|
||||
}
|
||||
|
||||
fn is_skipped_file(name: &str) -> bool {
|
||||
SKIP_FILES.contains(&name)
|
||||
|| SKIP_SUFFIXES.iter().any(|suffix| name.ends_with(suffix))
|
||||
|| name.starts_with("BrowserMetrics")
|
||||
}
|
||||
|
||||
/// Copy the source's permission bits onto a file we produced ourselves.
|
||||
///
|
||||
/// `fs::copy` already preserves the mode, but `VACUUM INTO` lets SQLite create
|
||||
/// the destination at its own default (0644). Cookies, Login Data and Web Data
|
||||
/// are 0600 in both the source browser and Wayfern, and an import must not be
|
||||
/// the step that widens them.
|
||||
#[cfg(unix)]
|
||||
fn mirror_mode(source: &Path, dest: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
if let Ok(metadata) = fs::metadata(source) {
|
||||
let mode = metadata.permissions().mode() & 0o777;
|
||||
let _ = fs::set_permissions(dest, fs::Permissions::from_mode(mode));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn mirror_mode(_source: &Path, _dest: &Path) {}
|
||||
|
||||
/// Create a directory owner-only, matching what Chromium gives a profile.
|
||||
fn create_private_dir(path: &Path) -> std::io::Result<()> {
|
||||
fs::create_dir_all(path)?;
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = fs::set_permissions(path, fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Take a consistent snapshot of a SQLite database.
|
||||
///
|
||||
/// Returns `Ok(false)` when the file is not actually SQLite (an empty
|
||||
/// placeholder, say), so the caller can fall back to a plain copy.
|
||||
fn vacuum_into(source: &Path, dest: &Path) -> Result<bool, String> {
|
||||
use rusqlite::{Connection, OpenFlags};
|
||||
|
||||
let conn = match Connection::open_with_flags(
|
||||
source,
|
||||
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_URI,
|
||||
) {
|
||||
Ok(conn) => conn,
|
||||
Err(e) => return Err(format!("open failed: {e}")),
|
||||
};
|
||||
|
||||
// Confirm it really is a database before trusting VACUUM's error reporting.
|
||||
if conn
|
||||
.query_row("SELECT count(*) FROM sqlite_master", [], |r| {
|
||||
r.get::<_, i64>(0)
|
||||
})
|
||||
.is_err()
|
||||
{
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
if dest.exists() {
|
||||
fs::remove_file(dest).map_err(|e| format!("could not replace destination: {e}"))?;
|
||||
}
|
||||
|
||||
// `VACUUM INTO` needs the path as a SQL string literal; single quotes are
|
||||
// the only character that can break out of one.
|
||||
let target = dest.to_string_lossy().replace('\'', "''");
|
||||
conn
|
||||
.execute_batch(&format!("VACUUM INTO '{target}'"))
|
||||
.map_err(|e| format!("VACUUM INTO failed: {e}"))?;
|
||||
mirror_mode(source, dest);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// Copy `source` (a Chromium profile directory) into `dest`, skipping caches
|
||||
/// and snapshotting databases.
|
||||
pub fn copy_profile_tree(source: &Path, dest: &Path) -> Result<CopyOutcome, String> {
|
||||
let mut outcome = CopyOutcome {
|
||||
bytes_copied: 0,
|
||||
unreadable_stores: Vec::new(),
|
||||
};
|
||||
create_private_dir(dest).map_err(|e| format!("Failed to create {}: {e}", dest.display()))?;
|
||||
copy_dir(source, dest, Path::new(""), &mut outcome)?;
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn copy_dir(
|
||||
source: &Path,
|
||||
dest: &Path,
|
||||
relative: &Path,
|
||||
outcome: &mut CopyOutcome,
|
||||
) -> Result<(), String> {
|
||||
let entries =
|
||||
fs::read_dir(source).map_err(|e| format!("Failed to read {}: {e}", source.display()))?;
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name) = name.to_str() else { continue };
|
||||
let child_relative = relative.join(name);
|
||||
let source_path = entry.path();
|
||||
let dest_path = dest.join(name);
|
||||
|
||||
// Symlinks are followed nowhere: Chromium writes them for the singleton
|
||||
// lock, and a copied one would point at the source machine.
|
||||
let metadata = match fs::symlink_metadata(&source_path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if metadata.file_type().is_symlink() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if metadata.is_dir() {
|
||||
if is_skipped_dir(&child_relative) {
|
||||
continue;
|
||||
}
|
||||
create_private_dir(&dest_path)
|
||||
.map_err(|e| format!("Failed to create {}: {e}", dest_path.display()))?;
|
||||
copy_dir(&source_path, &dest_path, &child_relative, outcome)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if is_skipped_file(name) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if SQLITE_FILES.contains(&name) {
|
||||
match vacuum_into(&source_path, &dest_path) {
|
||||
Ok(true) => {
|
||||
outcome.bytes_copied += fs::metadata(&dest_path).map(|m| m.len()).unwrap_or(0);
|
||||
continue;
|
||||
}
|
||||
Ok(false) => {
|
||||
// Not a database after all; fall through to a byte copy.
|
||||
}
|
||||
Err(e) => {
|
||||
// A store we cannot snapshot is a store we must not copy: a torn
|
||||
// copy is deleted by Chromium on open, which looks identical to
|
||||
// "the import silently lost my data".
|
||||
log::warn!("Skipping unreadable store {}: {e}", source_path.display());
|
||||
outcome.unreadable_stores.push(name.to_string());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match fs::copy(&source_path, &dest_path) {
|
||||
Ok(bytes) => outcome.bytes_copied += bytes,
|
||||
Err(e) => log::warn!("Failed to copy {}: {e}", source_path.display()),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Every `Default/`-level store that holds real user data, for reporting.
|
||||
pub fn count_leveldb_origins(leveldb_dir: &Path) -> usize {
|
||||
// Counting keys would mean linking a LevelDB implementation. The number of
|
||||
// `.ldb`/`.log` segments is a stable proxy for "there is data here", which
|
||||
// is all the report claims.
|
||||
let Ok(entries) = fs::read_dir(leveldb_dir) else {
|
||||
return 0;
|
||||
};
|
||||
entries
|
||||
.flatten()
|
||||
.filter(|e| {
|
||||
e.file_name()
|
||||
.to_str()
|
||||
.is_some_and(|n| n.ends_with(".ldb") || n.ends_with(".log"))
|
||||
})
|
||||
.count()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use rusqlite::Connection;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn touch(path: &Path, contents: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caches_are_not_copied() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
touch(&source.join("Cache").join("data_0"), &[0u8; 4096]);
|
||||
touch(
|
||||
&source.join("Code Cache").join("js").join("x"),
|
||||
&[0u8; 4096],
|
||||
);
|
||||
touch(
|
||||
&source.join("Service Worker").join("CacheStorage").join("y"),
|
||||
&[0u8; 4096],
|
||||
);
|
||||
touch(
|
||||
&source
|
||||
.join("Service Worker")
|
||||
.join("Database")
|
||||
.join("CURRENT"),
|
||||
b"MANIFEST-000001\n",
|
||||
);
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
assert!(dest.join("Preferences").exists());
|
||||
assert!(!dest.join("Cache").exists());
|
||||
assert!(!dest.join("Code Cache").exists());
|
||||
assert!(!dest.join("Service Worker").join("CacheStorage").exists());
|
||||
assert!(
|
||||
dest.join("Service Worker").join("Database").exists(),
|
||||
"the Service Worker registry is real data and must survive"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_and_journal_files_are_not_copied() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
touch(
|
||||
&source.join("Local Storage").join("leveldb").join("LOCK"),
|
||||
b"",
|
||||
);
|
||||
touch(
|
||||
&source.join("Local Storage").join("leveldb").join("CURRENT"),
|
||||
b"MANIFEST-000001\n",
|
||||
);
|
||||
touch(&source.join("History-journal"), b"junk");
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
assert!(!dest
|
||||
.join("Local Storage")
|
||||
.join("leveldb")
|
||||
.join("LOCK")
|
||||
.exists());
|
||||
assert!(dest
|
||||
.join("Local Storage")
|
||||
.join("leveldb")
|
||||
.join("CURRENT")
|
||||
.exists());
|
||||
assert!(!dest.join("History-journal").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sqlite_stores_are_snapshotted_and_stay_queryable() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
|
||||
let db = source.join("History");
|
||||
let conn = Connection::open(&db).unwrap();
|
||||
conn
|
||||
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT); INSERT INTO urls(url) VALUES('https://example.com');")
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
let copied = Connection::open(dest.join("History")).unwrap();
|
||||
let count: i64 = copied
|
||||
.query_row("SELECT count(*) FROM urls", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snapshot_captures_uncheckpointed_wal_content() {
|
||||
// The whole reason for VACUUM INTO: a running browser leaves recent writes
|
||||
// in the WAL, and a plain file copy loses them.
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
|
||||
let db = source.join("History");
|
||||
let conn = Connection::open(&db).unwrap();
|
||||
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
||||
conn
|
||||
.execute_batch("CREATE TABLE urls(id INTEGER PRIMARY KEY, url TEXT);")
|
||||
.unwrap();
|
||||
conn
|
||||
.execute("INSERT INTO urls(url) VALUES('https://in-wal.example')", [])
|
||||
.unwrap();
|
||||
// Deliberately do not checkpoint or close: this is the live-browser shape.
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
drop(conn);
|
||||
|
||||
let copied = Connection::open(dest.join("History")).unwrap();
|
||||
let url: String = copied
|
||||
.query_row("SELECT url FROM urls", [], |r| r.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(url, "https://in-wal.example");
|
||||
assert!(
|
||||
!dest.join("History-wal").exists(),
|
||||
"a stale -wal beside a vacuumed file corrupts it"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn symlinks_are_never_followed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
let outside = dir.path().join("outside.txt");
|
||||
touch(&outside, b"secret");
|
||||
|
||||
#[cfg(unix)]
|
||||
std::os::unix::fs::symlink(&outside, source.join("SingletonLock")).unwrap();
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
assert!(!dest.join("SingletonLock").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn account_scoped_stores_are_dropped() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
touch(&source.join("Login Data For Account"), b"x");
|
||||
touch(&source.join("Sync Data").join("Nigori.bin"), b"x");
|
||||
touch(
|
||||
&source.join("Sync Data").join("LevelDB").join("CURRENT"),
|
||||
b"x",
|
||||
);
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
assert!(!dest.join("Login Data For Account").exists());
|
||||
assert!(
|
||||
!dest.join("Sync Data").join("Nigori.bin").exists(),
|
||||
"the Nigori keyset is bound to a Google account"
|
||||
);
|
||||
assert!(
|
||||
dest
|
||||
.join("Sync Data")
|
||||
.join("LevelDB")
|
||||
.join("CURRENT")
|
||||
.exists(),
|
||||
"the rest of Sync Data is local state such as the reading list"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(unix)]
|
||||
fn copied_databases_keep_the_browsers_private_permissions() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
fs::create_dir_all(&source).unwrap();
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
|
||||
let db = source.join("Cookies");
|
||||
let conn = rusqlite::Connection::open(&db).unwrap();
|
||||
conn
|
||||
.execute_batch("CREATE TABLE cookies(x INTEGER);")
|
||||
.unwrap();
|
||||
drop(conn);
|
||||
fs::set_permissions(&db, fs::Permissions::from_mode(0o600)).unwrap();
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
// VACUUM INTO would otherwise create the snapshot at SQLite's default 0644.
|
||||
let mode = fs::metadata(dest.join("Cookies"))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(
|
||||
mode & 0o777,
|
||||
0o600,
|
||||
"an import must not widen a cookie store"
|
||||
);
|
||||
let dir_mode = fs::metadata(&dest).unwrap().permissions().mode();
|
||||
assert_eq!(dir_mode & 0o777, 0o700);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_store_is_reported_not_copied_corrupt() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
// A file that opens as SQLite but is structurally broken.
|
||||
touch(
|
||||
&source.join("Cookies"),
|
||||
b"SQLite format 3\0garbage-not-a-db",
|
||||
);
|
||||
|
||||
let outcome = copy_profile_tree(&source, &dest).unwrap();
|
||||
|
||||
assert!(
|
||||
!dest.join("Cookies").exists() || outcome.unreadable_stores.is_empty(),
|
||||
"a store is either snapshotted cleanly or skipped and reported"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_sqlite_file_with_a_store_name_still_copies() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("src");
|
||||
let dest = dir.path().join("dst");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
touch(&source.join("Top Sites"), b"");
|
||||
|
||||
copy_profile_tree(&source, &dest).unwrap();
|
||||
assert!(dest.join("Top Sites").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
//! Recovering the *source* browser's os_crypt key.
|
||||
//!
|
||||
//! Every Chromium-family browser seals cookies, passwords and payment data with
|
||||
//! a key held outside the profile: the macOS Keychain, a DPAPI blob in
|
||||
//! `Local State`, or the Freedesktop secret service. Import has to open that
|
||||
//! lock before it can re-seal anything with Wayfern's portable key
|
||||
//! ([`super::os_crypt::TargetKey`]).
|
||||
//!
|
||||
//! Failure here is never fatal. A declined Keychain prompt or a locked keyring
|
||||
//! degrades to "everything except the secrets came across", recorded as a
|
||||
//! warning, because a partial profile is worth far more than a failed import.
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use super::os_crypt::CryptoKey;
|
||||
use super::os_crypt::SourceKeyring;
|
||||
#[cfg(target_os = "macos")]
|
||||
use super::os_crypt::MAC_ITERATIONS;
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
use super::os_crypt::{derive_key, CryptoKey};
|
||||
#[cfg(target_os = "linux")]
|
||||
use super::os_crypt::{POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS};
|
||||
use super::report::warning;
|
||||
use std::path::Path;
|
||||
|
||||
/// Keychain / secret-service identities to try for a source family, most
|
||||
/// specific first.
|
||||
///
|
||||
/// Trying several is safe and costs nothing: a lookup for a service that does
|
||||
/// not exist fails without prompting, so at most one dialog appears — the one
|
||||
/// for the item that is actually there. That is what lets a single `chromium`
|
||||
/// family key cover both Google Chrome and vanilla Chromium, which share a
|
||||
/// detection entry but not a Keychain item.
|
||||
// Consulted by the Keychain and secret-service lookups. Windows resolves the
|
||||
// key through DPAPI against the profile's own Local State, so it never needs
|
||||
// to guess a brand.
|
||||
#[allow(dead_code)]
|
||||
fn brand_candidates(family: &str, source_path: &Path) -> Vec<&'static str> {
|
||||
let path = source_path.to_string_lossy();
|
||||
let mut brands: Vec<&'static str> = match family {
|
||||
"chrome-beta" => vec!["Chrome Beta", "Chrome"],
|
||||
"chrome-dev" => vec!["Chrome Dev", "Chrome"],
|
||||
"chrome-canary" => vec!["Chrome Canary", "Chrome"],
|
||||
"brave" => vec!["Brave", "Brave Browser"],
|
||||
"brave-beta" => vec!["Brave Beta", "Brave Browser", "Brave"],
|
||||
"brave-nightly" => vec!["Brave Nightly", "Brave Browser", "Brave"],
|
||||
"edge" => vec!["Microsoft Edge", "Chromium"],
|
||||
"edge-beta" => vec!["Microsoft Edge Beta", "Microsoft Edge"],
|
||||
"edge-dev" => vec!["Microsoft Edge Dev", "Microsoft Edge"],
|
||||
"vivaldi" => vec!["Vivaldi", "Chromium"],
|
||||
"opera" => vec!["Opera", "Chromium"],
|
||||
"opera-gx" => vec!["Opera GX", "Opera", "Chromium"],
|
||||
"arc" => vec!["Arc", "Chromium"],
|
||||
"yandex" => vec!["Yandex", "Yandex Browser", "Chromium"],
|
||||
// "chromium" covers both Google Chrome and upstream Chromium; the install
|
||||
// path is the only thing that tells them apart.
|
||||
_ => vec!["Chrome", "Chromium"],
|
||||
};
|
||||
|
||||
if (family.is_empty() || family == "chromium")
|
||||
&& path.contains("Chromium")
|
||||
&& !path.contains("Google")
|
||||
{
|
||||
brands = vec!["Chromium", "Chrome"];
|
||||
}
|
||||
|
||||
brands
|
||||
}
|
||||
|
||||
/// Recover whatever key material the source browser used.
|
||||
///
|
||||
/// `source_user_data_dir` is the directory holding `Local State` (the parent of
|
||||
/// the profile directory), which is where Windows keeps its wrapped key. It is
|
||||
/// `None` when the user pointed at a bare profile folder with no parent we can
|
||||
/// trust.
|
||||
pub fn recover_source_keys(
|
||||
family: &str,
|
||||
source_path: &Path,
|
||||
source_user_data_dir: Option<&Path>,
|
||||
report: &mut super::report::ProfileImportReport,
|
||||
) -> SourceKeyring {
|
||||
let mut keyring = SourceKeyring::default();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = source_user_data_dir;
|
||||
for brand in brand_candidates(family, source_path) {
|
||||
match macos_keychain_password(brand) {
|
||||
Ok(Some(password)) => {
|
||||
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(&password, MAC_ITERATIONS)));
|
||||
log::info!("Recovered os_crypt password for '{brand} Safe Storage'");
|
||||
break;
|
||||
}
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
log::warn!("Keychain lookup for '{brand} Safe Storage' failed: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let _ = source_path;
|
||||
if let Some(dir) = source_user_data_dir {
|
||||
match windows_local_state_key(dir) {
|
||||
Ok(Some(key)) => keyring.v10 = Some(CryptoKey::Aes256Gcm(key)),
|
||||
Ok(None) => {}
|
||||
Err(e) => log::warn!("DPAPI key recovery failed: {e}"),
|
||||
}
|
||||
if windows_has_app_bound_key(dir) {
|
||||
// Recorded up front: the cookie store will be full of `v20` records
|
||||
// and the user deserves to know why before they see the count.
|
||||
report.warn(warning::APP_BOUND_ENCRYPTED);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = source_user_data_dir;
|
||||
// A profile can hold both tags at once, so populate both slots rather than
|
||||
// choosing one. v10 is always available: it is a hardcoded password.
|
||||
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(
|
||||
POSIX_FALLBACK_PASSWORD,
|
||||
POSIX_ITERATIONS,
|
||||
)));
|
||||
for brand in brand_candidates(family, source_path) {
|
||||
match linux_secret_service_password(brand) {
|
||||
Ok(Some(password)) => {
|
||||
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(
|
||||
&password,
|
||||
POSIX_ITERATIONS,
|
||||
)));
|
||||
log::info!("Recovered os_crypt secret for '{brand} Safe Storage'");
|
||||
break;
|
||||
}
|
||||
Ok(None) => continue,
|
||||
Err(e) => {
|
||||
log::warn!("Secret service lookup for '{brand} Safe Storage' failed: {e}");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if keyring.is_empty() {
|
||||
report.warn(warning::SECRETS_NOT_MIGRATED);
|
||||
}
|
||||
|
||||
// Silence unused-parameter warnings on platforms that do not use every arg.
|
||||
let _ = (family, source_path, source_user_data_dir);
|
||||
keyring
|
||||
}
|
||||
|
||||
/// How long to wait on a keyring before giving up.
|
||||
///
|
||||
/// Both backends can put a dialog in front of the user — macOS asks whether
|
||||
/// Donut may read another app's Keychain item, and an unlocked-on-demand
|
||||
/// keyring prompts on Linux. That is fine interactively, but an import driven
|
||||
/// over REST or MCP would otherwise wedge forever with nobody at the screen.
|
||||
/// Long enough for a person to notice and click; short enough that automation
|
||||
/// recovers into "secrets not migrated", which is merely a partial import.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
const KEYRING_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60);
|
||||
|
||||
/// Run a keyring lookup on its own OS thread, bounded by [`KEYRING_TIMEOUT`].
|
||||
///
|
||||
/// Off-thread rather than inline for two reasons: import already runs inside
|
||||
/// `spawn_blocking`, and zbus's blocking API drives a private tokio runtime, so
|
||||
/// keeping it off a runtime-owned thread sidesteps any nested-runtime question;
|
||||
/// and it turns a panic or a stuck IPC call into a recoverable warning instead
|
||||
/// of a failed import.
|
||||
#[cfg(any(target_os = "macos", target_os = "linux"))]
|
||||
fn run_keyring_lookup<F>(what: &str, lookup: F) -> Result<Option<Vec<u8>>, String>
|
||||
where
|
||||
F: FnOnce() -> Result<Option<Vec<u8>>, String> + Send + std::panic::UnwindSafe + 'static,
|
||||
{
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
std::thread::spawn(move || {
|
||||
let result =
|
||||
std::panic::catch_unwind(lookup).unwrap_or_else(|_| Err("lookup panicked".to_string()));
|
||||
let _ = tx.send(result);
|
||||
});
|
||||
|
||||
match rx.recv_timeout(KEYRING_TIMEOUT) {
|
||||
Ok(result) => result,
|
||||
Err(_) => Err(format!("{what} did not respond")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_keychain_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
let brand = brand.to_string();
|
||||
run_keyring_lookup("keychain", move || macos_keychain_lookup(&brand))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn macos_keychain_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
use security_framework::passwords::get_generic_password;
|
||||
|
||||
let service = format!("{brand} Safe Storage");
|
||||
match get_generic_password(&service, brand) {
|
||||
Ok(password) => Ok(Some(password)),
|
||||
Err(e) => {
|
||||
// errSecItemNotFound: this brand simply is not installed. Anything else
|
||||
// (notably errSecAuthFailed / errSecUserCanceled when the user declines
|
||||
// the access dialog) is a real failure worth surfacing.
|
||||
if e.code() == -25300 {
|
||||
Ok(None)
|
||||
} else {
|
||||
Err(e.to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn read_local_state_os_crypt(dir: &Path) -> Option<serde_json::Value> {
|
||||
let raw = std::fs::read_to_string(dir.join("Local State")).ok()?;
|
||||
let parsed: serde_json::Value = serde_json::from_str(&raw).ok()?;
|
||||
parsed.get("os_crypt").cloned()
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_has_app_bound_key(dir: &Path) -> bool {
|
||||
read_local_state_os_crypt(dir)
|
||||
.and_then(|v| {
|
||||
v.get("app_bound_encrypted_key")
|
||||
.and_then(|k| k.as_str().map(str::to_string))
|
||||
})
|
||||
.is_some_and(|k| !k.is_empty())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn windows_local_state_key(dir: &Path) -> Result<Option<[u8; 32]>, String> {
|
||||
use base64::Engine;
|
||||
|
||||
let Some(os_crypt) = read_local_state_os_crypt(dir) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(encoded) = os_crypt.get("encrypted_key").and_then(|k| k.as_str()) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let decoded = base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|e| format!("encrypted_key is not valid base64: {e}"))?;
|
||||
|
||||
// The blob is "DPAPI" || CryptProtectData(key).
|
||||
const DPAPI_PREFIX: &[u8] = b"DPAPI";
|
||||
if !decoded.starts_with(DPAPI_PREFIX) {
|
||||
return Err("encrypted_key is missing the DPAPI header".to_string());
|
||||
}
|
||||
|
||||
let unwrapped = dpapi_unprotect(&decoded[DPAPI_PREFIX.len()..])?;
|
||||
let key: [u8; 32] = unwrapped
|
||||
.as_slice()
|
||||
.try_into()
|
||||
.map_err(|_| format!("expected a 32-byte AES key, got {} bytes", unwrapped.len()))?;
|
||||
Ok(Some(key))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn dpapi_unprotect(ciphertext: &[u8]) -> Result<Vec<u8>, String> {
|
||||
use windows::Win32::Foundation::LocalFree;
|
||||
use windows::Win32::Security::Cryptography::{CryptUnprotectData, CRYPT_INTEGER_BLOB};
|
||||
|
||||
// `pdatain` is `*const CRYPT_INTEGER_BLOB`: DPAPI only reads the input blob,
|
||||
// so a shared reference is what the signature wants.
|
||||
let input = CRYPT_INTEGER_BLOB {
|
||||
cbData: ciphertext.len() as u32,
|
||||
pbData: ciphertext.as_ptr() as *mut u8,
|
||||
};
|
||||
let mut output = CRYPT_INTEGER_BLOB::default();
|
||||
|
||||
// SAFETY: `input` points at a live slice for the duration of the call, and
|
||||
// `output` is freed via LocalFree exactly once below, as the API requires.
|
||||
unsafe {
|
||||
CryptUnprotectData(&input, None, None, None, None, 0, &mut output)
|
||||
.map_err(|e| format!("CryptUnprotectData failed: {e}"))?;
|
||||
|
||||
let plaintext = std::slice::from_raw_parts(output.pbData, output.cbData as usize).to_vec();
|
||||
let _ = LocalFree(Some(windows::Win32::Foundation::HLOCAL(
|
||||
output.pbData as *mut core::ffi::c_void,
|
||||
)));
|
||||
Ok(plaintext)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_secret_service_password(brand: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
let brand = brand.to_string();
|
||||
run_keyring_lookup("secret service", move || {
|
||||
linux_secret_service_lookup(&brand)
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn linux_secret_service_lookup(brand: &str) -> Result<Option<Vec<u8>>, String> {
|
||||
use secret_service::blocking::SecretService;
|
||||
use secret_service::EncryptionType;
|
||||
use std::collections::HashMap;
|
||||
|
||||
let service =
|
||||
SecretService::connect(EncryptionType::Dh).map_err(|e| format!("no secret service: {e}"))?;
|
||||
let collection = service
|
||||
.get_default_collection()
|
||||
.map_err(|e| format!("no default collection: {e}"))?;
|
||||
if collection.is_locked().unwrap_or(true) {
|
||||
collection
|
||||
.unlock()
|
||||
.map_err(|e| format!("keyring is locked: {e}"))?;
|
||||
}
|
||||
|
||||
// Match on the item's LABEL, not on its `application` attribute.
|
||||
//
|
||||
// `freedesktop_secret_key_provider.cc` stores two attributes —
|
||||
// `application: kAppName` and `xdg:schema` — and sets the label to
|
||||
// `kKeyName`, which is always "<Brand> Safe Storage". `kAppName` is a
|
||||
// per-fork branding string ("chrome", "chromium", …) that we cannot derive
|
||||
// from a display name: lowercasing "Microsoft Edge" gives "microsoft edge",
|
||||
// which matches nothing, and the search would silently return zero items.
|
||||
// The label is the one identifier that is the same across every fork and is
|
||||
// exactly the string we already build for the macOS Keychain.
|
||||
let label = format!("{brand} Safe Storage");
|
||||
|
||||
// The schema attribute narrows the scan to os_crypt secrets; it is shared by
|
||||
// every Chromium fork, so it costs nothing in portability.
|
||||
let mut attributes = HashMap::new();
|
||||
attributes.insert("xdg:schema", "chrome_libsecret_os_crypt_password_v2");
|
||||
let mut items = collection
|
||||
.search_items(attributes)
|
||||
.map_err(|e| format!("search failed: {e}"))?;
|
||||
if items.is_empty() {
|
||||
// Older Chromium releases used a v1 schema, and some forks omit it.
|
||||
items = collection
|
||||
.get_all_items()
|
||||
.map_err(|e| format!("could not list items: {e}"))?;
|
||||
}
|
||||
|
||||
for item in &items {
|
||||
if item.get_label().is_ok_and(|found| found == label) {
|
||||
return item
|
||||
.get_secret()
|
||||
.map(Some)
|
||||
.map_err(|e| format!("could not read secret: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn chromium_family_disambiguates_chrome_from_chromium_by_path() {
|
||||
let chrome = PathBuf::from("/Users/x/Library/Application Support/Google/Chrome/Default");
|
||||
assert_eq!(brand_candidates("chromium", &chrome)[0], "Chrome");
|
||||
|
||||
let chromium = PathBuf::from("/Users/x/Library/Application Support/Chromium/Default");
|
||||
assert_eq!(brand_candidates("chromium", &chromium)[0], "Chromium");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_brand_falls_back_to_a_second_candidate() {
|
||||
// A single candidate means one wrong guess loses the secrets entirely, so
|
||||
// each family must offer a fallback identity.
|
||||
for family in [
|
||||
"chrome-beta",
|
||||
"chrome-dev",
|
||||
"chrome-canary",
|
||||
"brave",
|
||||
"edge",
|
||||
"vivaldi",
|
||||
"opera",
|
||||
"opera-gx",
|
||||
"arc",
|
||||
"yandex",
|
||||
"chromium",
|
||||
] {
|
||||
let candidates = brand_candidates(family, Path::new("/tmp/profile"));
|
||||
assert!(
|
||||
candidates.len() >= 2,
|
||||
"{family} needs a fallback brand candidate"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_family_still_yields_candidates() {
|
||||
let candidates = brand_candidates("something-new", Path::new("/tmp/profile"));
|
||||
assert!(!candidates.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,380 @@
|
||||
//! Working out what the user pointed at, and where its files have to land.
|
||||
//!
|
||||
//! Two layout facts drive everything here:
|
||||
//!
|
||||
//! 1. Donut launches with `--user-data-dir` and no `--profile-directory`, so
|
||||
//! Chromium reads `<user-data-dir>/Default/` (`chrome_constants.cc`
|
||||
//! `kInitialProfile`). A source *profile* directory therefore has to be
|
||||
//! copied one level down, not onto the root.
|
||||
//! 2. Network state (`Cookies`, `TransportSecurity`, …) lives in
|
||||
//! `Default/Network/` on Windows and in `Default/` everywhere else. That
|
||||
//! split is not cosmetic: `kTriggerNetworkDataMigration` is enabled by
|
||||
//! default only on Windows, and on the other platforms Chromium actively
|
||||
//! redirects reads back to `Default/`. A profile exported from Windows is
|
||||
//! invisible on macOS until its files are moved up, and vice versa.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Files Chromium keeps under `Default/Network/` on Windows and directly under
|
||||
/// `Default/` on macOS and Linux.
|
||||
pub const NETWORK_DATA_FILES: &[&str] = &[
|
||||
"Cookies",
|
||||
"Cookies-journal",
|
||||
"Network Persistent State",
|
||||
"Reporting and NEL",
|
||||
"SCT Auditing Pending Reports",
|
||||
"Trust Tokens",
|
||||
"Trust Tokens-journal",
|
||||
"TransportSecurity",
|
||||
"Device Bound Sessions",
|
||||
"Device Bound Sessions-journal",
|
||||
];
|
||||
|
||||
/// What the user handed us.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SourceKind {
|
||||
/// A profile directory (holds `Preferences`): `.../Chrome/Default`.
|
||||
ProfileDir,
|
||||
/// A user-data directory whose profile lives at its root — Opera's layout.
|
||||
RootProfileUserDataDir,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SourceShape {
|
||||
pub kind: SourceKind,
|
||||
/// The directory holding `Preferences` — the content that becomes `Default/`.
|
||||
pub profile_dir: PathBuf,
|
||||
/// The directory holding `Local State`, when there is one. Windows keeps the
|
||||
/// DPAPI-wrapped os_crypt key there, so losing it loses every secret.
|
||||
pub user_data_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// Why a directory cannot be imported.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub enum RejectReason {
|
||||
/// Recognisably a Gecko profile. Worth naming explicitly: silently returning
|
||||
/// "nothing found" for a Firefox folder is what made import feel broken.
|
||||
Firefox,
|
||||
/// Not a browser profile we recognise at all.
|
||||
NotChromium,
|
||||
}
|
||||
|
||||
/// Markers that identify a real Chromium profile directory. `Preferences` is
|
||||
/// the usual one, but a profile whose prefs were wiped still has data worth
|
||||
/// carrying, so any of these counts.
|
||||
const CHROMIUM_PROFILE_MARKERS: &[&str] = &[
|
||||
"Preferences",
|
||||
"Secure Preferences",
|
||||
"History",
|
||||
"Cookies",
|
||||
"Bookmarks",
|
||||
"Web Data",
|
||||
"Login Data",
|
||||
];
|
||||
|
||||
fn looks_like_chromium_profile(dir: &Path) -> bool {
|
||||
CHROMIUM_PROFILE_MARKERS
|
||||
.iter()
|
||||
.any(|marker| dir.join(marker).exists())
|
||||
// Windows-layout profiles keep Cookies one level down.
|
||||
|| dir.join("Network").join("Cookies").exists()
|
||||
}
|
||||
|
||||
fn looks_like_firefox_profile(dir: &Path) -> bool {
|
||||
// Any one of these alone can appear elsewhere; together they are conclusive.
|
||||
let markers = ["prefs.js", "places.sqlite", "cookies.sqlite", "key4.db"];
|
||||
markers.iter().filter(|m| dir.join(m).exists()).count() >= 2
|
||||
}
|
||||
|
||||
/// Classify an import source, or explain why it cannot be one.
|
||||
pub fn classify(source: &Path) -> Result<SourceShape, RejectReason> {
|
||||
if looks_like_firefox_profile(source) {
|
||||
return Err(RejectReason::Firefox);
|
||||
}
|
||||
if !looks_like_chromium_profile(source) {
|
||||
return Err(RejectReason::NotChromium);
|
||||
}
|
||||
|
||||
// A directory that holds both profile markers and `Local State` is Opera's
|
||||
// root-profile layout: the user-data dir and the profile are the same place.
|
||||
let kind = if source.join("Local State").exists() {
|
||||
SourceKind::RootProfileUserDataDir
|
||||
} else {
|
||||
SourceKind::ProfileDir
|
||||
};
|
||||
|
||||
let user_data_dir = match kind {
|
||||
SourceKind::RootProfileUserDataDir => Some(source.to_path_buf()),
|
||||
// For `.../Chrome/Default`, `Local State` is in `.../Chrome`. Only accept
|
||||
// the parent if it really holds one, so a profile copied to a random
|
||||
// folder does not make us read a stranger's `Local State`.
|
||||
SourceKind::ProfileDir => source.parent().and_then(|parent| {
|
||||
if parent.join("Local State").exists() {
|
||||
return Some(parent.to_path_buf());
|
||||
}
|
||||
// Opera keeps its extra profiles at `<user-data-dir>/_side_profiles/<id>`
|
||||
// but still launches them against the same user-data dir, so the
|
||||
// DPAPI-wrapped os_crypt key sits one further level up. Without this,
|
||||
// every Opera side profile imports on Windows with no secrets at all.
|
||||
if parent.file_name() == Some(std::ffi::OsStr::new("_side_profiles")) {
|
||||
return parent
|
||||
.parent()
|
||||
.filter(|root| root.join("Local State").exists())
|
||||
.map(Path::to_path_buf);
|
||||
}
|
||||
None
|
||||
}),
|
||||
};
|
||||
|
||||
Ok(SourceShape {
|
||||
kind,
|
||||
profile_dir: source.to_path_buf(),
|
||||
user_data_dir,
|
||||
})
|
||||
}
|
||||
|
||||
/// Move network data into the position the *host* Chromium build reads from.
|
||||
///
|
||||
/// Host, not source: the files were written by whatever browser produced them,
|
||||
/// but they will be read by Wayfern running here. Getting this backwards is a
|
||||
/// silent, total cookie loss on any cross-platform import.
|
||||
pub fn normalize_network_dir(default_dir: &Path) -> std::io::Result<()> {
|
||||
let network_dir = default_dir.join("Network");
|
||||
|
||||
let (from, to) = if cfg!(target_os = "windows") {
|
||||
(default_dir.to_path_buf(), network_dir.clone())
|
||||
} else {
|
||||
(network_dir.clone(), default_dir.to_path_buf())
|
||||
};
|
||||
|
||||
if !from.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
for name in NETWORK_DATA_FILES {
|
||||
let src = from.join(name);
|
||||
if !src.is_file() {
|
||||
continue;
|
||||
}
|
||||
std::fs::create_dir_all(&to)?;
|
||||
let dest = to.join(name);
|
||||
if dest.exists() {
|
||||
// Both positions hold the file. The one in the source position is the
|
||||
// stale duplicate: on Windows, Chromium's migration would copy it over
|
||||
// the newer file ("overwrite the new file with the old file even if it
|
||||
// exists already", network_sandbox.cc), so it has to go.
|
||||
std::fs::remove_file(&src)?;
|
||||
continue;
|
||||
}
|
||||
std::fs::rename(&src, &dest).or_else(|_| {
|
||||
// Rename across devices can fail even within one tree on some setups.
|
||||
std::fs::copy(&src, &dest).and_then(|_| std::fs::remove_file(&src))?;
|
||||
Ok::<(), std::io::Error>(())
|
||||
})?;
|
||||
}
|
||||
|
||||
if !cfg!(target_os = "windows") {
|
||||
// Chromium's migration checkpoint, and the reason an otherwise-correct
|
||||
// move is not enough. `network_sandbox.cc:478` treats the presence of
|
||||
// `NetworkDataMigrated` as proof the migration already ran, keeps the (now
|
||||
// empty) `Network/` as the data directory, and then `CleanUpOldData` at
|
||||
// `:536-540` DELETES the files we just moved up into `Default/`. A profile
|
||||
// exported from Windows would lose every cookie on first launch.
|
||||
let _ = std::fs::remove_file(network_dir.join("NetworkDataMigrated"));
|
||||
|
||||
// Leave no empty `Network/` behind: harmless, but it makes a profile look
|
||||
// like it still holds network state.
|
||||
if network_dir.is_dir() && std::fs::read_dir(&network_dir)?.next().is_none() {
|
||||
let _ = std::fs::remove_dir(&network_dir);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Where the cookie store ends up for the host platform.
|
||||
pub fn host_cookie_path(default_dir: &Path) -> PathBuf {
|
||||
if cfg!(target_os = "windows") {
|
||||
default_dir.join("Network").join("Cookies")
|
||||
} else {
|
||||
default_dir.join("Cookies")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn touch(path: &Path) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, b"x").unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_profile_dir_is_classified_without_a_user_data_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("Default");
|
||||
touch(&profile.join("Preferences"));
|
||||
|
||||
let shape = classify(&profile).expect("should classify");
|
||||
assert_eq!(shape.kind, SourceKind::ProfileDir);
|
||||
assert_eq!(shape.user_data_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profile_dir_finds_local_state_in_its_parent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("Default");
|
||||
touch(&profile.join("Preferences"));
|
||||
touch(&dir.path().join("Local State"));
|
||||
|
||||
let shape = classify(&profile).expect("should classify");
|
||||
// Windows keeps the wrapped os_crypt key here; missing it means no secrets.
|
||||
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opera_root_layout_is_its_own_user_data_dir() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(&dir.path().join("Preferences"));
|
||||
touch(&dir.path().join("Local State"));
|
||||
|
||||
let shape = classify(dir.path()).expect("should classify");
|
||||
assert_eq!(shape.kind, SourceKind::RootProfileUserDataDir);
|
||||
assert_eq!(shape.user_data_dir.as_deref(), Some(dir.path()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn firefox_profile_is_rejected_by_name() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(&dir.path().join("prefs.js"));
|
||||
touch(&dir.path().join("places.sqlite"));
|
||||
assert_eq!(classify(dir.path()), Err(RejectReason::Firefox));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_directory_is_rejected() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
assert_eq!(classify(dir.path()), Err(RejectReason::NotChromium));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_layout_profile_is_recognised_without_root_markers() {
|
||||
// A profile whose only surviving data is Windows-layout cookies.
|
||||
let dir = TempDir::new().unwrap();
|
||||
touch(&dir.path().join("Network").join("Cookies"));
|
||||
assert!(classify(dir.path()).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opera_side_profile_finds_local_state_two_levels_up() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("_side_profiles").join("gaming");
|
||||
touch(&profile.join("Preferences"));
|
||||
touch(&dir.path().join("Local State"));
|
||||
|
||||
let shape = classify(&profile).expect("should classify");
|
||||
assert_eq!(
|
||||
shape.user_data_dir.as_deref(),
|
||||
Some(dir.path()),
|
||||
"Windows keeps the os_crypt key in the root Local State, not beside the profile"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_in_an_unrelated_folder_does_not_adopt_a_strangers_local_state() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("_side_profiles").join("gaming");
|
||||
touch(&profile.join("Preferences"));
|
||||
// No Local State anywhere above it.
|
||||
let shape = classify(&profile).expect("should classify");
|
||||
assert_eq!(shape.user_data_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migration_checkpoint_is_removed_so_chromium_does_not_delete_the_moved_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
touch(&default_dir.join("Network").join("Cookies"));
|
||||
touch(&default_dir.join("Network").join("NetworkDataMigrated"));
|
||||
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
|
||||
assert!(host_cookie_path(&default_dir).is_file());
|
||||
if !cfg!(target_os = "windows") {
|
||||
assert!(
|
||||
!default_dir
|
||||
.join("Network")
|
||||
.join("NetworkDataMigrated")
|
||||
.exists(),
|
||||
"the checkpoint makes Chromium delete the files we just moved up"
|
||||
);
|
||||
assert!(!default_dir.join("Network").exists());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn network_files_are_moved_into_the_host_position() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
|
||||
// Seed the file in the position the host does NOT read from.
|
||||
if cfg!(target_os = "windows") {
|
||||
touch(&default_dir.join("Cookies"));
|
||||
} else {
|
||||
touch(&default_dir.join("Network").join("Cookies"));
|
||||
}
|
||||
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
|
||||
assert!(
|
||||
host_cookie_path(&default_dir).is_file(),
|
||||
"cookies must end up where this platform's Chromium reads them"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_duplicate_in_the_source_position_is_removed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
touch(&default_dir.join("Cookies"));
|
||||
touch(&default_dir.join("Network").join("Cookies"));
|
||||
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
|
||||
assert!(host_cookie_path(&default_dir).is_file());
|
||||
let stale = if cfg!(target_os = "windows") {
|
||||
default_dir.join("Cookies")
|
||||
} else {
|
||||
default_dir.join("Network").join("Cookies")
|
||||
};
|
||||
assert!(
|
||||
!stale.exists(),
|
||||
"the duplicate would be copied over the live file by Chromium's migration"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
touch(&default_dir.join("Network").join("Cookies"));
|
||||
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
|
||||
assert!(host_cookie_path(&default_dir).is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_on_a_profile_with_no_network_data_is_a_no_op() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let default_dir = dir.path().join("Default");
|
||||
std::fs::create_dir_all(&default_dir).unwrap();
|
||||
normalize_network_dir(&default_dir).unwrap();
|
||||
assert!(!host_cookie_path(&default_dir).exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
//! Turning someone else's browser profile into one Wayfern will actually load.
|
||||
//!
|
||||
//! The old importer copied a source profile directory verbatim onto the new
|
||||
//! profile's `--user-data-dir`. Chromium reads `<user-data-dir>/Default/`, so
|
||||
//! every imported file sat one level above where the browser looked and the
|
||||
//! profile came up empty — and even in the right place the secrets would not
|
||||
//! have opened, because they are sealed with a key held in the source
|
||||
//! machine's Keychain / DPAPI / secret service that Wayfern never consults.
|
||||
//!
|
||||
//! This module does the whole job: classify the source, recover its key, copy
|
||||
//! with consistent database snapshots, put the files where Chromium reads them,
|
||||
//! re-seal every secret with Wayfern's portable key, and report exactly what
|
||||
//! came across.
|
||||
|
||||
pub mod copy;
|
||||
pub mod keyring;
|
||||
pub mod layout;
|
||||
pub mod os_crypt;
|
||||
pub mod report;
|
||||
pub mod rewrite;
|
||||
|
||||
use layout::RejectReason;
|
||||
use report::{warning, ProfileImportReport};
|
||||
use std::path::Path;
|
||||
|
||||
/// The profile subdirectory Chromium reads when no `--profile-directory` is
|
||||
/// passed (`chrome_constants.cc` `kInitialProfile`). Donut never passes one.
|
||||
pub const INITIAL_PROFILE_DIR: &str = "Default";
|
||||
|
||||
/// Import `source` into `dest_user_data_dir`, which becomes the new profile's
|
||||
/// `--user-data-dir`.
|
||||
///
|
||||
/// Never fails because part of the data could not be carried: partial results
|
||||
/// plus an honest report beat an all-or-nothing import that leaves the user
|
||||
/// with nothing and no explanation. It fails only when the source is not
|
||||
/// importable at all, or when the target key cannot be established — without
|
||||
/// that key, anything written would be unreadable forever.
|
||||
pub fn import_into(
|
||||
source: &Path,
|
||||
dest_user_data_dir: &Path,
|
||||
source_family: &str,
|
||||
allow_running: bool,
|
||||
) -> Result<ProfileImportReport, String> {
|
||||
let shape = layout::classify(source).map_err(|reason| match reason {
|
||||
RejectReason::Firefox => serde_json::json!({
|
||||
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
|
||||
"params": { "family": "Firefox" }
|
||||
})
|
||||
.to_string(),
|
||||
RejectReason::NotChromium => serde_json::json!({
|
||||
"code": "IMPORT_SOURCE_NOT_CHROMIUM",
|
||||
"params": { "family": "" }
|
||||
})
|
||||
.to_string(),
|
||||
})?;
|
||||
|
||||
let mut report = ProfileImportReport::default();
|
||||
|
||||
if let Some(running) = running_source_browser(&shape) {
|
||||
if !allow_running {
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "IMPORT_SOURCE_BROWSER_RUNNING",
|
||||
"params": { "browser": running }
|
||||
})
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
// Databases are snapshotted transactionally, but LevelDB site data is
|
||||
// copied as files and can be mid-write.
|
||||
report.warn(warning::SOURCE_BROWSER_RUNNING);
|
||||
}
|
||||
|
||||
// Mint the target key first. Everything after this point is written to be
|
||||
// readable with it, and a profile whose key could not be persisted would
|
||||
// lose every secret the first time the browser exits.
|
||||
let target = os_crypt::TargetKey::ensure(dest_user_data_dir)?;
|
||||
|
||||
// Recover the source key before the copy: on macOS this may prompt, and
|
||||
// asking before a multi-GB copy respects the user's time.
|
||||
let source_keys = keyring::recover_source_keys(
|
||||
source_family,
|
||||
&shape.profile_dir,
|
||||
shape.user_data_dir.as_deref(),
|
||||
&mut report,
|
||||
);
|
||||
|
||||
let default_dir = dest_user_data_dir.join(INITIAL_PROFILE_DIR);
|
||||
let outcome = copy::copy_profile_tree(&shape.profile_dir, &default_dir)?;
|
||||
report.bytes_copied = outcome.bytes_copied;
|
||||
if !outcome.unreadable_stores.is_empty() {
|
||||
report.warn(warning::STORE_UNREADABLE);
|
||||
}
|
||||
|
||||
layout::normalize_network_dir(&default_dir)
|
||||
.map_err(|e| format!("Failed to place network data: {e}"))?;
|
||||
|
||||
rewrite::finalize_profile(&default_dir, &source_keys, &target, &mut report);
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Is the browser that owns this profile currently running?
|
||||
///
|
||||
/// Matched on the profile path in the process command line rather than on the
|
||||
/// executable name: the user may well have Chrome open on a *different*
|
||||
/// profile, which is no reason to block the import.
|
||||
fn running_source_browser(shape: &layout::SourceShape) -> Option<String> {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
|
||||
let needle = shape
|
||||
.user_data_dir
|
||||
.as_deref()
|
||||
.unwrap_or(&shape.profile_dir)
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
if needle.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
for process in system.processes().values() {
|
||||
let name = process.name().to_string_lossy().to_lowercase();
|
||||
let looks_like_a_browser = name.contains("chrome")
|
||||
|| name.contains("chromium")
|
||||
|| name.contains("brave")
|
||||
|| name.contains("edge")
|
||||
|| name.contains("vivaldi")
|
||||
|| name.contains("opera")
|
||||
|| name.contains("arc")
|
||||
|| name.contains("yandex");
|
||||
if !looks_like_a_browser {
|
||||
continue;
|
||||
}
|
||||
// Donut's own browser is Wayfern; never report it as the source.
|
||||
if name.contains("wayfern") {
|
||||
continue;
|
||||
}
|
||||
if process
|
||||
.cmd()
|
||||
.iter()
|
||||
.any(|arg| arg.to_string_lossy().contains(&needle))
|
||||
{
|
||||
return Some(process.name().to_string_lossy().to_string());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Move a profile that an earlier build imported into the broken root layout
|
||||
/// down into `Default/`, where the browser reads it.
|
||||
///
|
||||
/// Without this, everything those users imported stays stranded: their real
|
||||
/// data sits at `profile/Cookies` while Wayfern reads and writes
|
||||
/// `profile/Default/Cookies`. Their secrets remain unreadable — the source key
|
||||
/// was never captured and cannot be recovered after the fact — but history,
|
||||
/// bookmarks, extensions and site data become visible again.
|
||||
///
|
||||
/// Returns `Ok(true)` when a repair was performed.
|
||||
pub fn repair_legacy_layout(user_data_dir: &Path) -> Result<bool, String> {
|
||||
let default_dir = user_data_dir.join(INITIAL_PROFILE_DIR);
|
||||
|
||||
// The broken shape is exactly: profile markers at the root, and no `Default/`
|
||||
// for the browser to have used instead.
|
||||
let has_root_profile = user_data_dir.join("Preferences").exists()
|
||||
|| user_data_dir.join("History").exists()
|
||||
|| user_data_dir.join("Cookies").exists();
|
||||
if !has_root_profile || default_dir.exists() {
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Root-level files that belong to the user-data dir, not to the profile.
|
||||
const ROOT_LEVEL: &[&str] = &[
|
||||
"Local State",
|
||||
"os_crypt_key",
|
||||
"First Run",
|
||||
"Last Version",
|
||||
"Variations",
|
||||
"ChromeFeatureState",
|
||||
"RunningChromeVersion",
|
||||
"SingletonLock",
|
||||
"SingletonCookie",
|
||||
"SingletonSocket",
|
||||
"user.js",
|
||||
"metadata.json",
|
||||
".donut-sync",
|
||||
];
|
||||
|
||||
let staging = user_data_dir.join(".donut-import-repair");
|
||||
if staging.exists() {
|
||||
std::fs::remove_dir_all(&staging).map_err(|e| format!("Failed to clear staging: {e}"))?;
|
||||
}
|
||||
std::fs::create_dir_all(&staging).map_err(|e| format!("Failed to create staging: {e}"))?;
|
||||
|
||||
let entries =
|
||||
std::fs::read_dir(user_data_dir).map_err(|e| format!("Failed to read profile: {e}"))?;
|
||||
for entry in entries.flatten() {
|
||||
let name = entry.file_name();
|
||||
let Some(name_str) = name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if ROOT_LEVEL.contains(&name_str) || name_str == ".donut-import-repair" {
|
||||
continue;
|
||||
}
|
||||
std::fs::rename(entry.path(), staging.join(name_str))
|
||||
.map_err(|e| format!("Failed to relocate {name_str}: {e}"))?;
|
||||
}
|
||||
|
||||
std::fs::rename(&staging, &default_dir)
|
||||
.map_err(|e| format!("Failed to install {INITIAL_PROFILE_DIR}: {e}"))?;
|
||||
|
||||
// Now that the files are in the right place, put the network data where this
|
||||
// platform reads it too.
|
||||
let _ = layout::normalize_network_dir(&default_dir);
|
||||
// And make sure the profile has a key, so the browser does not mint one
|
||||
// mid-session and lose whatever it writes.
|
||||
let _ = os_crypt::TargetKey::ensure(user_data_dir);
|
||||
|
||||
log::info!(
|
||||
"Repaired legacy import layout at {} (moved profile content into {INITIAL_PROFILE_DIR}/)",
|
||||
user_data_dir.display()
|
||||
);
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn touch(path: &Path, contents: &[u8]) {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).unwrap();
|
||||
}
|
||||
std::fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_places_everything_under_default() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("Chrome").join("Default");
|
||||
let dest = dir.path().join("profile");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
touch(&source.join("Bookmarks"), b"{\"roots\":{}}");
|
||||
|
||||
let report = import_into(&source, &dest, "chromium", true).expect("import");
|
||||
|
||||
assert!(
|
||||
dest.join("Default").join("Preferences").exists(),
|
||||
"Chromium reads Default/, not the user-data-dir root"
|
||||
);
|
||||
assert!(
|
||||
!dest.join("Preferences").exists(),
|
||||
"nothing profile-scoped belongs at the root"
|
||||
);
|
||||
assert!(dest.join(os_crypt::KEY_FILE_NAME).exists());
|
||||
assert!(report.bytes_copied > 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_a_firefox_profile_by_name() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("xyz.default-release");
|
||||
let dest = dir.path().join("profile");
|
||||
touch(&source.join("prefs.js"), b"");
|
||||
touch(&source.join("places.sqlite"), b"");
|
||||
|
||||
let err = import_into(&source, &dest, "firefox", true).expect_err("must reject");
|
||||
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
|
||||
assert!(
|
||||
err.contains("Firefox"),
|
||||
"the user needs to be told why, not just that it failed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_rejects_an_arbitrary_folder() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("holiday-photos");
|
||||
let dest = dir.path().join("profile");
|
||||
touch(&source.join("IMG_0001.jpg"), b"not a profile");
|
||||
|
||||
let err = import_into(&source, &dest, "chromium", true).expect_err("must reject");
|
||||
assert!(err.contains("IMPORT_SOURCE_NOT_CHROMIUM"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_is_rerunnable_over_the_same_destination() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let source = dir.path().join("Default");
|
||||
let dest = dir.path().join("profile");
|
||||
touch(&source.join("Preferences"), b"{}");
|
||||
|
||||
import_into(&source, &dest, "chromium", true).expect("first");
|
||||
let key = std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap();
|
||||
import_into(&source, &dest, "chromium", true).expect("second");
|
||||
assert_eq!(
|
||||
std::fs::read(dest.join(os_crypt::KEY_FILE_NAME)).unwrap(),
|
||||
key,
|
||||
"re-running must not orphan what the first run encrypted"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_layout_is_repaired_into_default() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("profile");
|
||||
// Exactly what the old importer produced.
|
||||
touch(&profile.join("Preferences"), b"{}");
|
||||
touch(&profile.join("History"), b"");
|
||||
touch(
|
||||
&profile
|
||||
.join("Local Storage")
|
||||
.join("leveldb")
|
||||
.join("CURRENT"),
|
||||
b"",
|
||||
);
|
||||
touch(&profile.join("Local State"), b"{}");
|
||||
|
||||
assert!(repair_legacy_layout(&profile).unwrap());
|
||||
|
||||
assert!(profile.join("Default").join("Preferences").exists());
|
||||
assert!(profile.join("Default").join("History").exists());
|
||||
assert!(profile
|
||||
.join("Default")
|
||||
.join("Local Storage")
|
||||
.join("leveldb")
|
||||
.join("CURRENT")
|
||||
.exists());
|
||||
assert!(
|
||||
profile.join("Local State").exists(),
|
||||
"Local State belongs to the user-data dir, not the profile"
|
||||
);
|
||||
assert!(profile.join(os_crypt::KEY_FILE_NAME).exists());
|
||||
assert!(!profile.join(".donut-import-repair").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_leaves_a_healthy_profile_alone() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("profile");
|
||||
touch(&profile.join("Default").join("Preferences"), b"{}");
|
||||
touch(&profile.join("Local State"), b"{}");
|
||||
|
||||
assert!(!repair_legacy_layout(&profile).unwrap());
|
||||
assert!(profile.join("Default").join("Preferences").exists());
|
||||
assert!(!profile.join("Default").join("Default").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_is_a_no_op_on_an_empty_profile() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("profile");
|
||||
std::fs::create_dir_all(&profile).unwrap();
|
||||
assert!(!repair_legacy_layout(&profile).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repair_is_idempotent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let profile = dir.path().join("profile");
|
||||
touch(&profile.join("Preferences"), b"{}");
|
||||
|
||||
assert!(repair_legacy_layout(&profile).unwrap());
|
||||
assert!(!repair_legacy_layout(&profile).unwrap());
|
||||
assert!(profile.join("Default").join("Preferences").exists());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,568 @@
|
||||
//! Key material for profile import.
|
||||
//!
|
||||
//! Wayfern deliberately does not use the OS keyring. Every `os_crypt_async`
|
||||
//! key provider is patched to read (or mint) `<user-data-dir>/os_crypt_key`
|
||||
//! instead, so a profile directory is self-contained and portable. See
|
||||
//! `wayfern/patches/extra/fingerprint/components-os_crypt-async-browser-*`.
|
||||
//!
|
||||
//! That portability is exactly why an imported Chrome profile carries nothing:
|
||||
//! its secrets are sealed with a key held in the macOS Keychain / Windows DPAPI
|
||||
//! / the Freedesktop secret service, and Wayfern never looks there. Import has
|
||||
//! to open the source's lock and re-seal everything with Wayfern's.
|
||||
//!
|
||||
//! The on-disk format is per-platform and NOT interchangeable, matching the
|
||||
//! provider that owns each tag in the patched Chromium 151 tree:
|
||||
//!
|
||||
//! | Host | `os_crypt_key` | Derivation | Cipher | Tag |
|
||||
//! |---------|---------------------|-------------------------------------|--------------|-------|
|
||||
//! | macOS | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1003) | AES-128-CBC | `v10` |
|
||||
//! | Linux | `base64(16 bytes)` | PBKDF2-HMAC-SHA1(saltysalt, 1) | AES-128-CBC | `v11` |
|
||||
//! | Windows | 32 raw bytes | none, the bytes are the key | AES-256-GCM | `v10` |
|
||||
//!
|
||||
//! Linux must write `v11`, not `v10`: `PosixKeyProvider` owns `v10` with the
|
||||
//! hardcoded "peanuts" password and `Encryptor::DecryptData` dispatches on the
|
||||
//! tag prefix, so a `v10` record on Linux would be decrypted with the wrong key
|
||||
//! forever.
|
||||
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockModeDecrypt, BlockModeEncrypt, KeyIvInit};
|
||||
use aes_gcm::aead::{Aead, KeyInit, Payload};
|
||||
use aes_gcm::{Aes256Gcm, Key, Nonce};
|
||||
// Windows stores the raw 32-byte key, so it neither encodes nor decodes
|
||||
// base64; only the mac/Linux password paths below need the trait in scope.
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
use base64::Engine;
|
||||
use rand::RngExt;
|
||||
use ring::pbkdf2;
|
||||
use std::num::NonZeroU32;
|
||||
use std::path::Path;
|
||||
|
||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
||||
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
|
||||
|
||||
/// Chromium's fixed PBKDF2 salt for every CBC-based os_crypt provider.
|
||||
// Only the CBC hosts derive a key; Windows uses the file's bytes directly.
|
||||
#[allow(dead_code)]
|
||||
pub const SALT: &[u8] = b"saltysalt";
|
||||
/// Chromium's fixed CBC IV: sixteen spaces.
|
||||
pub const CBC_IV: [u8; 16] = [b' '; 16];
|
||||
/// AES-256-GCM nonce length, prepended to the ciphertext by `Encryptor::Key::Encrypt`.
|
||||
const GCM_NONCE_LEN: usize = 12;
|
||||
/// The `os_crypt_key` name, at the root of the user-data dir.
|
||||
pub const KEY_FILE_NAME: &str = "os_crypt_key";
|
||||
|
||||
/// `PBKDF2-HMAC-SHA1(password = "", salt = "saltysalt", iterations = 1)`.
|
||||
///
|
||||
/// Chromium retries every failed AES-128-CBC decrypt with this key
|
||||
/// (`encryptor.cc`, crbug.com/40055416) because profiles created while the
|
||||
/// keyring was unavailable were sealed with an empty password. Import has to do
|
||||
/// the same or those records look corrupt.
|
||||
pub const EMPTY_PASSWORD_KEY: [u8; 16] = [
|
||||
0xd0, 0xd0, 0xec, 0x9c, 0x7d, 0x77, 0xd4, 0x3a, 0xc5, 0x41, 0x87, 0xfa, 0x48, 0x18, 0xd1, 0x7f,
|
||||
];
|
||||
|
||||
/// The password Chromium's `PosixKeyProvider` uses when no secret service is
|
||||
/// available (`--password-store=basic`). Records sealed with it carry `v10`.
|
||||
// Read on Linux and by the known-answer tests; unreferenced on other hosts.
|
||||
#[allow(dead_code)]
|
||||
pub const POSIX_FALLBACK_PASSWORD: &[u8] = b"peanuts";
|
||||
|
||||
/// PBKDF2 iteration counts, per the provider that owns each platform.
|
||||
// Each host only ever derives with its own count, but both are needed to read
|
||||
// a profile produced on the other one.
|
||||
#[allow(dead_code)]
|
||||
pub const MAC_ITERATIONS: u32 = 1003;
|
||||
#[allow(dead_code)]
|
||||
pub const POSIX_ITERATIONS: u32 = 1;
|
||||
|
||||
/// Derive a 16-byte AES-128 key the way every CBC os_crypt provider does.
|
||||
///
|
||||
/// `password` is the raw bytes, never trimmed: Chromium passes the exact
|
||||
/// `ReadFileToString` result to the KDF, so normalising here would silently
|
||||
/// produce a different key and every decrypt would fail.
|
||||
// Called from the mac and Linux branches only: `DPAPIKeyProvider` takes the
|
||||
// 32 bytes on disk as the AES-256 key with no derivation step at all.
|
||||
#[allow(dead_code)]
|
||||
pub fn derive_key(password: &[u8], iterations: u32) -> [u8; 16] {
|
||||
let mut key = [0u8; 16];
|
||||
// ring rather than the `pbkdf2` crate: sha1 0.11 (digest 0.11) and
|
||||
// pbkdf2 0.12 (digest 0.10) cannot coexist. ring is self-contained.
|
||||
pbkdf2::derive(
|
||||
pbkdf2::PBKDF2_HMAC_SHA1,
|
||||
NonZeroU32::new(iterations).expect("iterations must be non-zero"),
|
||||
SALT,
|
||||
password,
|
||||
&mut key,
|
||||
);
|
||||
key
|
||||
}
|
||||
|
||||
/// One os_crypt cipher, keyed. Which variant applies is decided by the tag the
|
||||
/// record carries, never by the host platform.
|
||||
#[derive(Clone)]
|
||||
pub enum CryptoKey {
|
||||
Aes128Cbc([u8; 16]),
|
||||
// Only Windows keys with GCM, but the variant has to exist everywhere so the
|
||||
// tag dispatch in `SourceKeyring` stays platform-independent.
|
||||
#[allow(dead_code)]
|
||||
Aes256Gcm([u8; 32]),
|
||||
}
|
||||
|
||||
impl CryptoKey {
|
||||
/// Decrypt a *tagless* ciphertext (the caller has already stripped the
|
||||
/// 3-byte version prefix).
|
||||
pub fn decrypt(&self, ciphertext: &[u8]) -> Option<Vec<u8>> {
|
||||
match self {
|
||||
Self::Aes128Cbc(key) => {
|
||||
if ciphertext.is_empty() {
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let mut buf = ciphertext.to_vec();
|
||||
Aes128CbcDec::new(key.into(), &CBC_IV.into())
|
||||
.decrypt_padded::<Pkcs7>(&mut buf)
|
||||
.ok()
|
||||
.map(<[u8]>::to_vec)
|
||||
}
|
||||
Self::Aes256Gcm(key) => {
|
||||
if ciphertext.len() < GCM_NONCE_LEN {
|
||||
return None;
|
||||
}
|
||||
let (nonce, body) = ciphertext.split_at(GCM_NONCE_LEN);
|
||||
let nonce: [u8; GCM_NONCE_LEN] = nonce.try_into().ok()?;
|
||||
Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
|
||||
.decrypt(
|
||||
&Nonce::from(nonce),
|
||||
Payload {
|
||||
msg: body,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.ok()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encrypt to a *tagless* ciphertext. The caller prepends the tag.
|
||||
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
|
||||
match self {
|
||||
Self::Aes128Cbc(key) => {
|
||||
let mut buf = vec![0u8; plaintext.len() + 16];
|
||||
buf[..plaintext.len()].copy_from_slice(plaintext);
|
||||
Aes128CbcEnc::new(key.into(), &CBC_IV.into())
|
||||
.encrypt_padded::<Pkcs7>(&mut buf, plaintext.len())
|
||||
.ok()
|
||||
.map(<[u8]>::to_vec)
|
||||
}
|
||||
Self::Aes256Gcm(key) => {
|
||||
let nonce: [u8; GCM_NONCE_LEN] = rand::rng().random();
|
||||
let sealed = Aes256Gcm::new(&Key::<Aes256Gcm>::from(*key))
|
||||
.encrypt(
|
||||
&Nonce::from(nonce),
|
||||
Payload {
|
||||
msg: plaintext,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.ok()?;
|
||||
// The nonce goes at the front, matching `Encryptor::Key::Encrypt`.
|
||||
let mut out = Vec::with_capacity(GCM_NONCE_LEN + sealed.len());
|
||||
out.extend_from_slice(&nonce);
|
||||
out.extend_from_slice(&sealed);
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wayfern's key for the profile being created.
|
||||
pub struct TargetKey {
|
||||
key: CryptoKey,
|
||||
tag: &'static [u8; 3],
|
||||
}
|
||||
|
||||
impl TargetKey {
|
||||
/// The tag the host platform's key provider claims.
|
||||
pub const fn host_tag() -> &'static [u8; 3] {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
b"v11"
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
b"v10"
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the key from the raw `os_crypt_key` file contents.
|
||||
///
|
||||
/// Returns `None` when the contents cannot key the host cipher — on Windows
|
||||
/// that means anything other than exactly 32 bytes, which is what
|
||||
/// `DPAPIKeyProvider` requires before it will adopt a portable key.
|
||||
fn from_file_contents(contents: &[u8]) -> Option<Self> {
|
||||
if contents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let bytes: [u8; 32] = contents.try_into().ok()?;
|
||||
Some(Self {
|
||||
key: CryptoKey::Aes256Gcm(bytes),
|
||||
tag: Self::host_tag(),
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Some(Self {
|
||||
key: CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)),
|
||||
tag: Self::host_tag(),
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Some(Self {
|
||||
key: CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)),
|
||||
tag: Self::host_tag(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Fresh key material in the host platform's `os_crypt_key` format.
|
||||
fn generate_file_contents() -> Vec<u8> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Windows stores the AES-256 key itself, so it must be 32 bytes.
|
||||
let key: [u8; 32] = rand::rng().random();
|
||||
key.to_vec()
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// mac/Linux store a *password* that is fed to PBKDF2. Wayfern mints
|
||||
// `base64(16 random bytes)`; match it so the file is indistinguishable
|
||||
// from one the browser wrote itself.
|
||||
let raw: [u8; 16] = rand::rng().random();
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.encode(raw)
|
||||
.into_bytes()
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the existing `os_crypt_key`, or mint and persist one.
|
||||
///
|
||||
/// Writing eagerly at import time — rather than letting the first launch do
|
||||
/// it — is deliberate. The mac and Linux patches have no `else` branch when
|
||||
/// the write fails, so the browser would run on an in-memory key that dies
|
||||
/// with the process and orphans everything it wrote. Failing here instead
|
||||
/// turns that silent data loss into a visible import error.
|
||||
pub fn ensure(user_data_dir: &Path) -> Result<Self, String> {
|
||||
let key_file = user_data_dir.join(KEY_FILE_NAME);
|
||||
|
||||
if let Ok(existing) = std::fs::read(&key_file) {
|
||||
if let Some(key) = Self::from_file_contents(&existing) {
|
||||
return Ok(key);
|
||||
}
|
||||
// Present but unusable (a Windows-format key on macOS, say, or a
|
||||
// truncated write). Replacing it is safe only because import always
|
||||
// re-encrypts into whatever key we end up with.
|
||||
log::warn!(
|
||||
"Replacing unusable {KEY_FILE_NAME} ({} bytes) at {}",
|
||||
existing.len(),
|
||||
key_file.display()
|
||||
);
|
||||
}
|
||||
|
||||
std::fs::create_dir_all(user_data_dir)
|
||||
.map_err(|e| format!("Failed to create profile directory: {e}"))?;
|
||||
let contents = Self::generate_file_contents();
|
||||
std::fs::write(&key_file, &contents)
|
||||
.map_err(|e| format!("Failed to write os_crypt_key: {e}"))?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&key_file, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
// Read back rather than trust the write: a key that did not land is the
|
||||
// one failure mode that silently destroys every secret we are about to
|
||||
// write with it.
|
||||
let written =
|
||||
std::fs::read(&key_file).map_err(|e| format!("Failed to verify os_crypt_key: {e}"))?;
|
||||
if written != contents {
|
||||
return Err("os_crypt_key verification failed after write".to_string());
|
||||
}
|
||||
|
||||
Self::from_file_contents(&contents).ok_or_else(|| "Failed to derive os_crypt_key".to_string())
|
||||
}
|
||||
|
||||
/// Seal a value the way Wayfern will expect to find it: `tag || ciphertext`.
|
||||
pub fn encrypt(&self, plaintext: &[u8]) -> Option<Vec<u8>> {
|
||||
let body = self.key.encrypt(plaintext)?;
|
||||
let mut out = Vec::with_capacity(3 + body.len());
|
||||
out.extend_from_slice(self.tag);
|
||||
out.extend_from_slice(&body);
|
||||
Some(out)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a decrypt attempt produced.
|
||||
pub enum Decrypted {
|
||||
/// Recovered plaintext.
|
||||
Value(Vec<u8>),
|
||||
/// Already plaintext — no recognised version tag.
|
||||
NotEncrypted,
|
||||
/// Correctly identified but not openable: no key for the tag (Windows
|
||||
/// App-Bound `v20`), or every candidate key failed.
|
||||
Unrecoverable,
|
||||
}
|
||||
|
||||
/// The source browser's keys, indexed by the tag the records carry.
|
||||
///
|
||||
/// Indexing by tag rather than by platform is not pedantry: a single Linux
|
||||
/// profile can legitimately hold both `v10` (peanuts) and `v11` (keyring)
|
||||
/// records, because the available secret service changes between sessions.
|
||||
#[derive(Default)]
|
||||
pub struct SourceKeyring {
|
||||
pub v10: Option<CryptoKey>,
|
||||
pub v11: Option<CryptoKey>,
|
||||
/// Seen at least one `v20` (Windows App-Bound) record, which no third party
|
||||
/// can open. Tracked so the import report can say so explicitly.
|
||||
pub saw_app_bound: std::cell::Cell<bool>,
|
||||
}
|
||||
|
||||
impl SourceKeyring {
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.v10.is_none() && self.v11.is_none()
|
||||
}
|
||||
|
||||
/// Open one stored value, dispatching on its version tag exactly as
|
||||
/// `Encryptor::DecryptData` does.
|
||||
pub fn decrypt(&self, stored: &[u8]) -> Decrypted {
|
||||
if stored.len() < 3 {
|
||||
return if stored.is_empty() {
|
||||
Decrypted::Value(Vec::new())
|
||||
} else {
|
||||
Decrypted::NotEncrypted
|
||||
};
|
||||
}
|
||||
|
||||
let (tag, body) = stored.split_at(3);
|
||||
let key = match tag {
|
||||
b"v10" => self.v10.as_ref(),
|
||||
b"v11" => self.v11.as_ref(),
|
||||
b"v20" => {
|
||||
// App-Bound Encryption. The key is wrapped by the SYSTEM-level Chrome
|
||||
// Elevation Service, which validates the calling binary. There is no
|
||||
// legitimate way for us to unwrap it.
|
||||
self.saw_app_bound.set(true);
|
||||
return Decrypted::Unrecoverable;
|
||||
}
|
||||
_ => return Decrypted::NotEncrypted,
|
||||
};
|
||||
|
||||
let Some(key) = key else {
|
||||
return Decrypted::Unrecoverable;
|
||||
};
|
||||
|
||||
if let Some(plaintext) = key.decrypt(body) {
|
||||
return Decrypted::Value(plaintext);
|
||||
}
|
||||
|
||||
// Chromium's own fallback for CBC records sealed with an empty password.
|
||||
if matches!(key, CryptoKey::Aes128Cbc(_)) {
|
||||
if let Some(plaintext) = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY).decrypt(body) {
|
||||
return Decrypted::Value(plaintext);
|
||||
}
|
||||
}
|
||||
|
||||
Decrypted::Unrecoverable
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[test]
|
||||
fn empty_password_key_matches_chromium_constant() {
|
||||
// Locks the constant against the value Chromium hardcodes in encryptor.cc.
|
||||
assert_eq!(derive_key(b"", POSIX_ITERATIONS), EMPTY_PASSWORD_KEY);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn peanuts_key_matches_known_vector() {
|
||||
// PBKDF2-HMAC-SHA1("peanuts", "saltysalt", 1, 16). Any drift here silently
|
||||
// breaks every Linux `--password-store=basic` import.
|
||||
assert_eq!(
|
||||
derive_key(POSIX_FALLBACK_PASSWORD, POSIX_ITERATIONS),
|
||||
[
|
||||
0xfd, 0x62, 0x1f, 0xe5, 0xa2, 0xb4, 0x02, 0x53, 0x9d, 0xfa, 0x14, 0x7c, 0xa9, 0x27, 0x27,
|
||||
0x78
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbc_round_trip() {
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
|
||||
let sealed = key.encrypt(b"session-token").expect("encrypt");
|
||||
assert_eq!(key.decrypt(&sealed).expect("decrypt"), b"session-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cbc_round_trip_empty_plaintext() {
|
||||
let key = CryptoKey::Aes128Cbc(derive_key(b"hunter2", MAC_ITERATIONS));
|
||||
let sealed = key.encrypt(b"").expect("encrypt");
|
||||
// PKCS7 always emits a full padding block, so this must not be empty.
|
||||
assert_eq!(sealed.len(), 16);
|
||||
assert!(key.decrypt(&sealed).expect("decrypt").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcm_round_trip_with_fresh_nonce_each_time() {
|
||||
let key = CryptoKey::Aes256Gcm([7u8; 32]);
|
||||
let a = key.encrypt(b"session-token").expect("encrypt");
|
||||
let b = key.encrypt(b"session-token").expect("encrypt");
|
||||
assert_ne!(a, b, "nonce must be random per call");
|
||||
assert_eq!(key.decrypt(&a).expect("decrypt"), b"session-token");
|
||||
assert_eq!(key.decrypt(&b).expect("decrypt"), b"session-token");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gcm_rejects_tampered_ciphertext() {
|
||||
let key = CryptoKey::Aes256Gcm([7u8; 32]);
|
||||
let mut sealed = key.encrypt(b"session-token").expect("encrypt");
|
||||
let last = sealed.len() - 1;
|
||||
sealed[last] ^= 0xff;
|
||||
assert!(key.decrypt(&sealed).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn target_key_is_stable_across_calls() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let first = TargetKey::ensure(dir.path()).expect("mint");
|
||||
let sealed = first.encrypt(b"value").expect("encrypt");
|
||||
|
||||
let second = TargetKey::ensure(dir.path()).expect("reuse");
|
||||
// Re-running import over the same directory must not orphan what the
|
||||
// previous run wrote.
|
||||
let key_file = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
let reloaded = TargetKey::from_file_contents(&key_file).expect("reload");
|
||||
assert_eq!(
|
||||
reloaded.encrypt(b"probe").map(|v| v[..3].to_vec()),
|
||||
second.encrypt(b"probe").map(|v| v[..3].to_vec())
|
||||
);
|
||||
|
||||
let mut keyring = SourceKeyring::default();
|
||||
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
install_host_key(&mut keyring, &contents);
|
||||
match keyring.decrypt(&sealed) {
|
||||
Decrypted::Value(v) => assert_eq!(v, b"value"),
|
||||
_ => panic!("target key must round-trip through the source keyring"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minted_key_matches_wayfern_file_format() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
TargetKey::ensure(dir.path()).expect("mint");
|
||||
let contents = std::fs::read(dir.path().join(KEY_FILE_NAME)).unwrap();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
assert_eq!(
|
||||
contents.len(),
|
||||
32,
|
||||
"DPAPIKeyProvider only adopts a 32-byte portable key"
|
||||
);
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
// Wayfern writes base64(16 random bytes) = 24 ASCII chars.
|
||||
assert_eq!(contents.len(), 24);
|
||||
let text = String::from_utf8(contents).expect("ascii");
|
||||
assert!(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(&text)
|
||||
.map(|b| b.len())
|
||||
== Ok(16),
|
||||
"expected base64 of 16 bytes, got {text}"
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mode = std::fs::metadata(dir.path().join(KEY_FILE_NAME))
|
||||
.unwrap()
|
||||
.permissions()
|
||||
.mode();
|
||||
assert_eq!(mode & 0o777, 0o600);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tag_is_treated_as_plaintext_not_as_loss() {
|
||||
let keyring = SourceKeyring::default();
|
||||
assert!(matches!(
|
||||
keyring.decrypt(b"plain cookie value"),
|
||||
Decrypted::NotEncrypted
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_bound_records_are_flagged_unrecoverable() {
|
||||
let keyring = SourceKeyring::default();
|
||||
let mut sealed = b"v20".to_vec();
|
||||
sealed.extend_from_slice(&[0u8; 40]);
|
||||
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
|
||||
assert!(
|
||||
keyring.saw_app_bound.get(),
|
||||
"v20 must be reported to the user, not silently dropped"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_key_for_known_tag_is_unrecoverable() {
|
||||
let keyring = SourceKeyring::default();
|
||||
let mut sealed = b"v10".to_vec();
|
||||
sealed.extend_from_slice(&[0u8; 32]);
|
||||
assert!(matches!(keyring.decrypt(&sealed), Decrypted::Unrecoverable));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_password_fallback_recovers_the_record() {
|
||||
// A record sealed with the empty-password key must still open when the
|
||||
// keyring holds a different primary key, mirroring Chromium.
|
||||
let sealed_body = CryptoKey::Aes128Cbc(EMPTY_PASSWORD_KEY)
|
||||
.encrypt(b"legacy")
|
||||
.unwrap();
|
||||
let mut stored = b"v10".to_vec();
|
||||
stored.extend_from_slice(&sealed_body);
|
||||
|
||||
let keyring = SourceKeyring {
|
||||
v10: Some(CryptoKey::Aes128Cbc(derive_key(b"a different key", 1003))),
|
||||
..Default::default()
|
||||
};
|
||||
match keyring.decrypt(&stored) {
|
||||
Decrypted::Value(v) => assert_eq!(v, b"legacy"),
|
||||
_ => panic!("empty-password fallback must be attempted"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Load the host-format key into a keyring under the host tag, for tests
|
||||
/// that need to verify what we wrote is what Wayfern will read.
|
||||
fn install_host_key(keyring: &mut SourceKeyring, contents: &[u8]) {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let bytes: [u8; 32] = contents.try_into().unwrap();
|
||||
keyring.v10 = Some(CryptoKey::Aes256Gcm(bytes));
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
keyring.v10 = Some(CryptoKey::Aes128Cbc(derive_key(contents, MAC_ITERATIONS)));
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
keyring.v11 = Some(CryptoKey::Aes128Cbc(derive_key(contents, POSIX_ITERATIONS)));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
//! What an import actually carried across.
|
||||
//!
|
||||
//! Import is best-effort by nature: a locked keychain, a Windows App-Bound
|
||||
//! cookie store or a schema too old for Chromium to migrate all mean some
|
||||
//! subset does not survive, and none of them should abort the whole operation.
|
||||
//! The report is how that stays honest — every skipped store is a counted
|
||||
//! warning rather than a silent zero.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Stable warning codes. The frontend maps these to
|
||||
/// `importProfile.warnings.*`, so they are part of the API contract: rename one
|
||||
/// and the user sees a missing translation.
|
||||
pub mod warning {
|
||||
/// The source browser's key could not be read, so cookies/passwords were
|
||||
/// left encrypted and are unreadable in the new profile.
|
||||
pub const SECRETS_NOT_MIGRATED: &str = "secretsNotMigrated";
|
||||
/// Windows App-Bound Encryption (Chrome 127+). Unrecoverable by design.
|
||||
pub const APP_BOUND_ENCRYPTED: &str = "appBoundEncrypted";
|
||||
/// A store's schema predates what Chromium will migrate; it would have been
|
||||
/// deleted on first launch, so it was skipped instead.
|
||||
pub const STORE_TOO_OLD: &str = "storeTooOld";
|
||||
/// A store's schema is newer than this Chromium can read.
|
||||
pub const STORE_TOO_NEW: &str = "storeTooNew";
|
||||
/// The source browser was running; databases were snapshotted but LevelDB
|
||||
/// site data may be incomplete.
|
||||
pub const SOURCE_BROWSER_RUNNING: &str = "sourceBrowserRunning";
|
||||
/// Tracked preferences lost their MACs and will reset to defaults.
|
||||
pub const SECURE_PREFERENCES_RESET: &str = "securePreferencesReset";
|
||||
/// At least one extension could not be carried.
|
||||
pub const EXTENSIONS_PARTIAL: &str = "extensionsPartial";
|
||||
/// A database was unreadable and was skipped rather than copied corrupt.
|
||||
pub const STORE_UNREADABLE: &str = "storeUnreadable";
|
||||
}
|
||||
|
||||
/// Per-profile outcome, returned alongside each item in a batch import.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, utoipa::ToSchema)]
|
||||
pub struct ProfileImportReport {
|
||||
/// Cookies whose value is readable in the new profile.
|
||||
pub cookies_migrated: usize,
|
||||
/// Cookies carried over as rows but whose value could not be recovered.
|
||||
pub cookies_unrecoverable: usize,
|
||||
pub passwords_migrated: usize,
|
||||
pub passwords_unrecoverable: usize,
|
||||
/// Saved cards / IBANs / autofill secrets re-encrypted.
|
||||
pub payment_methods_migrated: usize,
|
||||
pub payment_methods_unrecoverable: usize,
|
||||
pub extensions_migrated: usize,
|
||||
pub history_entries: usize,
|
||||
pub bookmarks: usize,
|
||||
/// Origins with Local Storage data.
|
||||
pub local_storage_origins: usize,
|
||||
pub bytes_copied: u64,
|
||||
/// Stable codes from [`warning`], deduplicated, in insertion order.
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProfileImportReport {
|
||||
pub fn warn(&mut self, code: &str) {
|
||||
if !self.warnings.iter().any(|w| w == code) {
|
||||
self.warnings.push(code.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// True when nothing readable came across. Used to decide whether the UI
|
||||
/// should present the import as a success or as a warning.
|
||||
pub fn is_empty_import(&self) -> bool {
|
||||
self.cookies_migrated == 0
|
||||
&& self.passwords_migrated == 0
|
||||
&& self.history_entries == 0
|
||||
&& self.bookmarks == 0
|
||||
&& self.local_storage_origins == 0
|
||||
&& self.extensions_migrated == 0
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn warnings_are_deduplicated_in_order() {
|
||||
let mut report = ProfileImportReport::default();
|
||||
report.warn(warning::STORE_TOO_OLD);
|
||||
report.warn(warning::SECRETS_NOT_MIGRATED);
|
||||
report.warn(warning::STORE_TOO_OLD);
|
||||
assert_eq!(
|
||||
report.warnings,
|
||||
vec![
|
||||
warning::STORE_TOO_OLD.to_string(),
|
||||
warning::SECRETS_NOT_MIGRATED.to_string()
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_import_detection_ignores_unrecoverable_counts() {
|
||||
let mut report = ProfileImportReport {
|
||||
cookies_unrecoverable: 500,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(
|
||||
report.is_empty_import(),
|
||||
"500 unreadable cookies is still nothing carried"
|
||||
);
|
||||
report.history_entries = 1;
|
||||
assert!(!report.is_empty_import());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -9,6 +9,7 @@ use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::events;
|
||||
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
|
||||
use crate::profile::ProfileManager;
|
||||
use crate::profile_import::report::ProfileImportReport;
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::wayfern_manager::WayfernConfig;
|
||||
|
||||
@@ -28,6 +29,9 @@ pub struct DetectedProfile {
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
pub struct ImportProfileItem {
|
||||
pub source_path: String,
|
||||
/// The source browser family (`chromium`, `brave`, `edge`, …). Load-bearing:
|
||||
/// it selects which Keychain / secret-service item holds the key that
|
||||
/// unlocks the source's cookies and passwords.
|
||||
#[serde(default = "default_import_browser_type")]
|
||||
pub browser_type: String,
|
||||
pub new_profile_name: String,
|
||||
@@ -35,6 +39,10 @@ pub struct ImportProfileItem {
|
||||
pub proxy_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub vpn_id: Option<String>,
|
||||
/// Import even though the source browser is running. Databases are still
|
||||
/// snapshotted consistently, but LevelDB site data may be mid-write.
|
||||
#[serde(default)]
|
||||
pub allow_running: Option<bool>,
|
||||
}
|
||||
|
||||
fn default_import_browser_type() -> String {
|
||||
@@ -61,6 +69,8 @@ pub struct ProfileImportItemResult {
|
||||
pub profile_id: Option<String>,
|
||||
/// Structured `{"code": …}` error string when status is "failed".
|
||||
pub error: Option<String>,
|
||||
/// What actually came across. Present when status is "imported".
|
||||
pub report: Option<ProfileImportReport>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, utoipa::ToSchema)]
|
||||
@@ -719,6 +729,7 @@ impl ProfileImporter {
|
||||
status: "failed".to_string(),
|
||||
profile_id: None,
|
||||
error: Some(serde_json::json!({ "code": "NAME_CANNOT_BE_EMPTY" }).to_string()),
|
||||
report: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -735,6 +746,7 @@ impl ProfileImporter {
|
||||
status: "skipped".to_string(),
|
||||
profile_id: None,
|
||||
error: None,
|
||||
report: None,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -757,10 +769,11 @@ impl ProfileImporter {
|
||||
item.vpn_id.clone(),
|
||||
group_id.clone(),
|
||||
wayfern_config.clone(),
|
||||
item.allow_running.unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(profile) => {
|
||||
Ok((profile, report)) => {
|
||||
imported_count += 1;
|
||||
completed += 1;
|
||||
emit_import_progress(total, completed, index, &final_name, "imported");
|
||||
@@ -771,6 +784,7 @@ impl ProfileImporter {
|
||||
status: "imported".to_string(),
|
||||
profile_id: Some(profile.id.to_string()),
|
||||
error: None,
|
||||
report: Some(report),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -785,6 +799,7 @@ impl ProfileImporter {
|
||||
status: "failed".to_string(),
|
||||
profile_id: None,
|
||||
error: Some(error_to_code_string(e)),
|
||||
report: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -809,7 +824,8 @@ impl ProfileImporter {
|
||||
vpn_id: Option<String>,
|
||||
group_id: Option<String>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
allow_running: bool,
|
||||
) -> Result<(BrowserProfile, ProfileImportReport), Box<dyn std::error::Error>> {
|
||||
let source_path = Path::new(source_path);
|
||||
if !source_path.exists() {
|
||||
return Err(
|
||||
@@ -847,39 +863,54 @@ impl ProfileImporter {
|
||||
create_dir_all(&new_profile_uuid_dir)?;
|
||||
create_dir_all(&new_profile_data_dir)?;
|
||||
|
||||
// Profile dirs can be multiple GB — keep the copy off the async runtime.
|
||||
let copy_source = source_path.to_path_buf();
|
||||
let copy_dest = new_profile_data_dir.clone();
|
||||
let copy_result = match tokio::task::spawn_blocking(move || {
|
||||
Self::copy_directory_recursive(©_source, ©_dest).map_err(|e| e.to_string())
|
||||
// Profile dirs can be multiple GB and the migration hits SQLite and the
|
||||
// OS keyring — keep all of it off the async runtime.
|
||||
let migrate_source = source_path.to_path_buf();
|
||||
let migrate_dest = new_profile_data_dir.clone();
|
||||
let source_family = browser_type.to_string();
|
||||
let migrate_result = match tokio::task::spawn_blocking(move || {
|
||||
crate::profile_import::import_into(
|
||||
&migrate_source,
|
||||
&migrate_dest,
|
||||
&source_family,
|
||||
allow_running,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
// The copy task died (panic, or runtime shutdown mid-import). Clean up
|
||||
// like every other error path here, or the half-copied — possibly
|
||||
// multi-GB — directory is orphaned with no metadata pointing at it, so
|
||||
// nothing ever reclaims it.
|
||||
// The task died (panic, or runtime shutdown mid-import). Clean up like
|
||||
// every other error path here, or the half-copied — possibly multi-GB
|
||||
// — directory is orphaned with no metadata pointing at it, so nothing
|
||||
// ever reclaims it.
|
||||
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
|
||||
return Err(
|
||||
serde_json::json!({
|
||||
"code": "INTERNAL_ERROR",
|
||||
"params": { "detail": format!("Profile copy task failed: {e}") },
|
||||
"params": { "detail": format!("Profile import task failed: {e}") },
|
||||
})
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
};
|
||||
if let Err(e) = copy_result {
|
||||
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
|
||||
return Err(
|
||||
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } })
|
||||
.to_string()
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let report = match migrate_result {
|
||||
Ok(report) => report,
|
||||
Err(e) => {
|
||||
let _ = fs::remove_dir_all(&new_profile_uuid_dir);
|
||||
// Structured codes (an unimportable source, a running browser) pass
|
||||
// through so the frontend can translate them; anything else is
|
||||
// internal.
|
||||
return Err(if e.starts_with('{') {
|
||||
e.into()
|
||||
} else {
|
||||
serde_json::json!({ "code": "INTERNAL_ERROR", "params": { "detail": e } })
|
||||
.to_string()
|
||||
.into()
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let version = match self.get_default_version_for_browser(mapped) {
|
||||
Ok(version) => version,
|
||||
@@ -1017,13 +1048,30 @@ impl ProfileImporter {
|
||||
|
||||
self.profile_manager.save_profile(&profile)?;
|
||||
|
||||
log::info!(
|
||||
"Successfully imported profile '{}' from '{}'",
|
||||
new_profile_name,
|
||||
source_path.display()
|
||||
);
|
||||
if report.is_empty_import() {
|
||||
// Not an error — an empty source profile imports legitimately — but it is
|
||||
// the exact symptom the old layout bug produced, so it is worth a loud
|
||||
// line in the log rather than a silent success.
|
||||
log::warn!(
|
||||
"Imported profile '{}' from '{}' carried no readable data (warnings: {:?})",
|
||||
new_profile_name,
|
||||
source_path.display(),
|
||||
report.warnings
|
||||
);
|
||||
} else {
|
||||
log::info!(
|
||||
"Imported profile '{}' from '{}': {} cookies, {} passwords, {} history entries ({} unrecoverable secrets, warnings: {:?})",
|
||||
new_profile_name,
|
||||
source_path.display(),
|
||||
report.cookies_migrated,
|
||||
report.passwords_migrated,
|
||||
report.history_entries,
|
||||
report.cookies_unrecoverable + report.passwords_unrecoverable,
|
||||
report.warnings
|
||||
);
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
Ok((profile, report))
|
||||
}
|
||||
|
||||
fn get_default_version_for_browser(
|
||||
|
||||
@@ -464,10 +464,10 @@ impl ProxyManager {
|
||||
.as_deref()
|
||||
.filter(|uri| !uri.is_empty())
|
||||
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
|
||||
let parsed = crate::xray::parse_vless_uri(uri)
|
||||
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
let parsed =
|
||||
crate::xray::parse_vless_uri(uri).map_err(|error| crate::vless_config_error(&error))?;
|
||||
let canonical_uri = crate::xray::export_vless_uri(&parsed.config, parsed.name.as_deref())
|
||||
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| crate::vless_config_error(&error))?;
|
||||
|
||||
proxy_settings.proxy_type = "vless".to_string();
|
||||
proxy_settings.host = parsed.config.address;
|
||||
@@ -1152,6 +1152,21 @@ impl ProxyManager {
|
||||
url
|
||||
}
|
||||
|
||||
/// Proxy URL for a diagnostic probe made by the app itself (reqwest), as
|
||||
/// opposed to `build_proxy_url`, which feeds the browser.
|
||||
///
|
||||
/// SOCKS5 becomes `socks5h://` so the probe endpoint's hostname resolves at
|
||||
/// the exit rather than on this machine. Resolving locally would leak the
|
||||
/// real DNS and, behind a split-horizon resolver, can reach a different host
|
||||
/// than the browser would.
|
||||
pub fn build_probe_proxy_url(proxy_settings: &ProxySettings) -> String {
|
||||
let url = Self::build_proxy_url(proxy_settings);
|
||||
if proxy_settings.proxy_type.eq_ignore_ascii_case("socks5") {
|
||||
return url.replacen("socks5://", "socks5h://", 1);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
// Check if a proxy is valid by routing through a temporary donut-proxy process.
|
||||
// This tests the exact same code path the browser uses.
|
||||
// Falls back to direct reqwest check if the proxy worker fails to start.
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Whether a profile's exit node can be reached from somewhere that is not this
|
||||
//! machine.
|
||||
//!
|
||||
//! Remote execution — an interactive remote session or a Cookie Bot night — runs
|
||||
//! the browser on a leased fleet host, but the PROFILE (and its proxy, and its
|
||||
//! VPN config) is pulled from the user's sync namespace. Nothing in that
|
||||
//! handover rewrites addresses, so a proxy recorded as `127.0.0.1:8080` arrives
|
||||
//! on the fleet host meaning *the fleet host's own loopback*.
|
||||
//!
|
||||
//! That is the whole bug this module exists to prevent. The server already
|
||||
//! refuses a profile with NO exit (`proxy_required`), because a night browsed
|
||||
//! from the fleet's datacenter address damages an identity rather than building
|
||||
//! it — but it was asking whether an exit was *configured*, never whether it was
|
||||
//! *reachable*. A local proxy satisfied the first question and failed the
|
||||
//! second, so the run was accepted, dispatched, and burned a leased host either
|
||||
//! erroring out or (worse) egressing direct from the datacenter: exactly the
|
||||
//! outcome `proxy_required` exists to stop, reached by the one route it did not
|
||||
//! check.
|
||||
//!
|
||||
//! Local proxies are not an exotic case. A local MITM proxy, an SSH tunnel, a
|
||||
//! locally-run SOCKS client and Donut's own VLESS support all present to the
|
||||
//! browser as `127.0.0.1:<port>`.
|
||||
//!
|
||||
//! This module is the single answer, shared by every caller, and it FAILS
|
||||
//! CLOSED: anything it cannot parse is reported as unreachable. Refusing a
|
||||
//! working setup costs the user one support question; accepting a broken one
|
||||
//! costs a burned hour and a damaged profile identity.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
/// Whether a leased fleet host could dial this profile's exit.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExitReachability {
|
||||
/// No proxy and no VPN. The caller's existing "no exit" refusal applies.
|
||||
None,
|
||||
/// An address a host elsewhere on the internet can reach.
|
||||
Remote,
|
||||
/// An address that only means anything on this machine or this LAN.
|
||||
LocalOnly {
|
||||
/// The offending host, for a message the user can act on.
|
||||
host: String,
|
||||
/// Which part of the config it came from: "proxy" or "VPN".
|
||||
source: &'static str,
|
||||
},
|
||||
/// Configured, but this code could not determine the host.
|
||||
///
|
||||
/// Treated as unreachable by [`ExitReachability::is_remote`] — see the
|
||||
/// fail-closed note in the module docs.
|
||||
Unknown {
|
||||
reason: String,
|
||||
source: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExitReachability {
|
||||
/// Whether remote execution may proceed.
|
||||
pub fn is_remote(&self) -> bool {
|
||||
matches!(self, ExitReachability::Remote)
|
||||
}
|
||||
|
||||
/// A one-line reason for a refusal, or None when there is nothing to refuse.
|
||||
pub fn refusal_detail(&self) -> Option<String> {
|
||||
match self {
|
||||
ExitReachability::Remote | ExitReachability::None => None,
|
||||
ExitReachability::LocalOnly { host, source } => Some(format!(
|
||||
"The {source} for this profile points at {host}, which only exists on this computer. \
|
||||
Remote runs happen on our hosts and cannot reach it."
|
||||
)),
|
||||
ExitReachability::Unknown { reason, source } => Some(format!(
|
||||
"The {source} for this profile could not be read ({reason}), so we cannot confirm a \
|
||||
remote host could use it."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a hostname or IP literal is reachable from another machine.
|
||||
///
|
||||
/// Rejects, in order: empty/whitespace, unparsable-as-either, and every IP
|
||||
/// range that is scoped to a machine or a private network. Hostnames that are
|
||||
/// not IP literals are accepted unless they use a name suffix that is
|
||||
/// definitionally local — a public DNS name cannot be validated here without a
|
||||
/// lookup, and doing a lookup would make this impure and slow on a hot path.
|
||||
pub fn host_is_remote_reachable(host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
if host.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
return ip_is_remote_reachable(ip);
|
||||
}
|
||||
|
||||
let lower = host.to_ascii_lowercase();
|
||||
|
||||
// `localhost` and anything under it resolve to loopback everywhere.
|
||||
if lower == "localhost" || lower.ends_with(".localhost") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Suffixes reserved for local/private name resolution (RFC 6762 mDNS, RFC
|
||||
// 8375, and the names router vendors hand out on a LAN). A fleet host
|
||||
// resolving one of these gets its own network's answer, not the user's.
|
||||
const LOCAL_SUFFIXES: [&str; 7] = [
|
||||
".local",
|
||||
".localdomain",
|
||||
".internal",
|
||||
".home",
|
||||
".home.arpa",
|
||||
".lan",
|
||||
".intranet",
|
||||
];
|
||||
if LOCAL_SUFFIXES.iter().any(|suffix| lower.ends_with(suffix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A bare single-label name ("my-proxy", "router") is only resolvable through
|
||||
// a local search domain, so it is no more use to a fleet host than `.local`.
|
||||
if !lower.contains('.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether an IP literal is routable from another machine.
|
||||
fn ip_is_remote_reachable(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => ipv4_is_remote_reachable(v4),
|
||||
IpAddr::V6(v6) => ipv6_is_remote_reachable(v6),
|
||||
}
|
||||
}
|
||||
|
||||
fn ipv4_is_remote_reachable(ip: Ipv4Addr) -> bool {
|
||||
// `is_private`/`is_loopback`/`is_link_local` are stable; the rest are not, so
|
||||
// the remaining ranges are spelled out rather than gated behind a nightly
|
||||
// feature.
|
||||
if ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
if ip.is_broadcast() || ip.is_multicast() || ip.is_documentation() {
|
||||
return false;
|
||||
}
|
||||
let [a, b, ..] = ip.octets();
|
||||
// 100.64.0.0/10 — carrier-grade NAT (RFC 6598). Reachable inside one
|
||||
// carrier's network and nowhere else.
|
||||
if a == 100 && (64..128).contains(&b) {
|
||||
return false;
|
||||
}
|
||||
// 0.0.0.0/8 "this network", and 240.0.0.0/4 reserved.
|
||||
if a == 0 || a >= 240 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn ipv6_is_remote_reachable(ip: Ipv6Addr) -> bool {
|
||||
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
|
||||
return false;
|
||||
}
|
||||
// An IPv4 address wearing an IPv6 hat is still that IPv4 address — classify
|
||||
// it as one, or `::ffff:127.0.0.1` walks straight through.
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return ipv4_is_remote_reachable(v4);
|
||||
}
|
||||
if let Some(v4) = ip.to_ipv4() {
|
||||
return ipv4_is_remote_reachable(v4);
|
||||
}
|
||||
let segments = ip.segments();
|
||||
// fc00::/7 unique-local, fe80::/10 link-local.
|
||||
if (segments[0] & 0xfe00) == 0xfc00 {
|
||||
return false;
|
||||
}
|
||||
if (segments[0] & 0xffc0) == 0xfe80 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Strip the decoration a host can arrive wrapped in: whitespace, `[...]`
|
||||
/// around an IPv6 literal, a trailing dot on an FQDN, and any `user@` or
|
||||
/// `:port` that came along from a URI.
|
||||
fn normalize_host(raw: &str) -> String {
|
||||
let mut host = raw.trim();
|
||||
if host.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// `user:pass@host` — take what follows the LAST '@', since a password may
|
||||
// itself contain one.
|
||||
if let Some(at) = host.rfind('@') {
|
||||
host = &host[at + 1..];
|
||||
}
|
||||
|
||||
// Bracketed IPv6, optionally with a port: `[::1]:1080`.
|
||||
if let Some(stripped) = host.strip_prefix('[') {
|
||||
if let Some(end) = stripped.find(']') {
|
||||
return stripped[..end].trim().to_string();
|
||||
}
|
||||
return stripped.trim().to_string();
|
||||
}
|
||||
|
||||
// `host:port`, but only when there is exactly one colon — more than one means
|
||||
// a bare IPv6 literal, whose colons are part of the address.
|
||||
if host.matches(':').count() == 1 {
|
||||
if let Some((left, _port)) = host.split_once(':') {
|
||||
host = left;
|
||||
}
|
||||
}
|
||||
|
||||
host.trim().trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// The host a VLESS URI actually dials.
|
||||
///
|
||||
/// Load-bearing because of an asymmetry that is easy to get backwards: a VLESS
|
||||
/// proxy presents to the browser as `127.0.0.1:<port>` — Donut runs a local xray
|
||||
/// worker and points the browser at it — but the address that decides whether
|
||||
/// anyone else could use this config is the SERVER inside the URI. The local
|
||||
/// port is an implementation detail of this machine; the URI is the exit.
|
||||
pub fn vless_uri_host(uri: &str) -> Option<String> {
|
||||
let rest = uri.trim().strip_prefix("vless://")?;
|
||||
// Cut the fragment (`#label`) and query (`?type=...`) before looking for the
|
||||
// authority — either may contain '@' or ':'.
|
||||
let rest = rest.split('#').next()?;
|
||||
let rest = rest.split('?').next()?;
|
||||
// `uuid@host:port/...`
|
||||
let authority = rest.split('/').next()?;
|
||||
let host_port = authority
|
||||
.rsplit_once('@')
|
||||
.map(|(_, h)| h)
|
||||
.unwrap_or(authority);
|
||||
let host = normalize_host(host_port);
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host)
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit host a stored proxy represents, as a remote host would have to dial
|
||||
/// it.
|
||||
pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result<String, String> {
|
||||
if settings.proxy_type.eq_ignore_ascii_case("vless") {
|
||||
let uri = settings
|
||||
.vless_uri
|
||||
.as_deref()
|
||||
.filter(|uri| !uri.trim().is_empty())
|
||||
.ok_or_else(|| "VLESS proxy has no server URI".to_string())?;
|
||||
return vless_uri_host(uri).ok_or_else(|| "VLESS server URI is malformed".to_string());
|
||||
}
|
||||
|
||||
let host = normalize_host(&settings.host);
|
||||
if host.is_empty() {
|
||||
return Err("proxy has no host".to_string());
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// Classify a stored proxy.
|
||||
pub fn classify_proxy(settings: &crate::browser::ProxySettings) -> ExitReachability {
|
||||
match proxy_exit_host(settings) {
|
||||
Err(reason) => ExitReachability::Unknown {
|
||||
reason,
|
||||
source: "proxy",
|
||||
},
|
||||
Ok(host) => {
|
||||
if host_is_remote_reachable(&host) {
|
||||
ExitReachability::Remote
|
||||
} else {
|
||||
ExitReachability::LocalOnly {
|
||||
host,
|
||||
source: "proxy",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a WireGuard peer endpoint (`host:port`).
|
||||
pub fn classify_wireguard_endpoint(peer_endpoint: &str) -> ExitReachability {
|
||||
let host = normalize_host(peer_endpoint);
|
||||
if host.is_empty() {
|
||||
return ExitReachability::Unknown {
|
||||
reason: "VPN config has no peer endpoint".to_string(),
|
||||
source: "VPN",
|
||||
};
|
||||
}
|
||||
if host_is_remote_reachable(&host) {
|
||||
ExitReachability::Remote
|
||||
} else {
|
||||
ExitReachability::LocalOnly {
|
||||
host,
|
||||
source: "VPN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::browser::ProxySettings;
|
||||
|
||||
fn proxy(proxy_type: &str, host: &str) -> ProxySettings {
|
||||
ProxySettings {
|
||||
proxy_type: proxy_type.to_string(),
|
||||
host: host.to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_in_every_spelling_is_local() {
|
||||
// The literal case the bug was reported for, plus the spellings that reach
|
||||
// the same place. `::ffff:127.0.0.1` is the one a naive IPv6 check misses.
|
||||
for host in [
|
||||
"127.0.0.1",
|
||||
"127.1.2.3",
|
||||
"localhost",
|
||||
"LOCALHOST",
|
||||
"foo.localhost",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"::ffff:127.0.0.1",
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_and_carrier_ranges_are_local() {
|
||||
for host in [
|
||||
"10.0.0.1",
|
||||
"192.168.1.1",
|
||||
"172.16.0.1",
|
||||
"172.31.255.254",
|
||||
"169.254.1.1", // link-local / APIPA
|
||||
"100.64.0.1", // CGNAT
|
||||
"100.127.255.1",
|
||||
"fd00::1", // unique-local
|
||||
"fe80::1", // link-local
|
||||
"240.0.0.1",
|
||||
"0.1.2.3",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_addresses_and_names_are_reachable() {
|
||||
for host in [
|
||||
"1.1.1.1",
|
||||
"8.8.8.8",
|
||||
"172.15.0.1", // just outside 172.16/12
|
||||
"172.32.0.1",
|
||||
"100.63.255.255", // just outside 100.64/10
|
||||
"100.128.0.1",
|
||||
"2606:4700:4700::1111",
|
||||
"proxy.example.com",
|
||||
"gate.smartproxy.net.",
|
||||
"residential.example.co.uk",
|
||||
] {
|
||||
assert!(
|
||||
host_is_remote_reachable(host),
|
||||
"{host} should be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_only_names_are_local() {
|
||||
// A fleet host resolving these gets ITS network's answer, not the user's —
|
||||
// which is worse than failing, because it may well succeed against
|
||||
// something unrelated.
|
||||
for host in [
|
||||
"my-proxy", // single label: needs a search domain
|
||||
"router.local",
|
||||
"nas.home.arpa",
|
||||
"proxy.lan",
|
||||
"box.internal",
|
||||
"server.localdomain",
|
||||
"gateway.intranet",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_port_and_credentials_are_stripped_before_classifying() {
|
||||
assert!(!host_is_remote_reachable("127.0.0.1:8080"));
|
||||
assert!(!host_is_remote_reachable("user:pass@127.0.0.1:8080"));
|
||||
assert!(!host_is_remote_reachable("[::1]:1080"));
|
||||
assert!(host_is_remote_reachable("user:p@ss@proxy.example.com:8080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vless_proxy_is_judged_by_its_server_not_its_local_port() {
|
||||
// THE asymmetry. Donut points the browser at a local xray worker, so the
|
||||
// browser-facing address of every VLESS proxy is 127.0.0.1 — but the stored
|
||||
// config names a real server, and that is what a fleet host would dial.
|
||||
// Classifying VLESS off `settings.host` would refuse every VLESS profile.
|
||||
let mut settings = proxy("vless", "127.0.0.1");
|
||||
settings.vless_uri =
|
||||
Some("vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?type=tcp#node".into());
|
||||
|
||||
assert_eq!(classify_proxy(&settings), ExitReachability::Remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vless_uri_pointing_at_loopback_is_still_local() {
|
||||
let mut settings = proxy("vless", "127.0.0.1");
|
||||
settings.vless_uri = Some("vless://uuid@127.0.0.1:443?type=tcp".into());
|
||||
|
||||
assert_eq!(
|
||||
classify_proxy(&settings),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".to_string(),
|
||||
source: "proxy",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vless_host_parsing_survives_query_and_fragment() {
|
||||
assert_eq!(
|
||||
vless_uri_host("vless://uuid@example.com:443?sni=a@b.com&x=1#my@label"),
|
||||
Some("example.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
vless_uri_host("vless://uuid@[2606:4700::1111]:443?type=ws"),
|
||||
Some("2606:4700::1111".to_string())
|
||||
);
|
||||
assert_eq!(vless_uri_host("not-a-vless-uri"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_config_fails_closed() {
|
||||
// Unknown must never be treated as usable: the point of the check is that
|
||||
// we could not confirm reachability, and guessing "yes" reintroduces the
|
||||
// exact failure it prevents.
|
||||
let mut settings = proxy("vless", "");
|
||||
settings.vless_uri = None;
|
||||
let verdict = classify_proxy(&settings);
|
||||
|
||||
assert!(matches!(verdict, ExitReachability::Unknown { .. }));
|
||||
assert!(!verdict.is_remote());
|
||||
assert!(verdict.refusal_detail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_proxies_are_classified_by_host() {
|
||||
assert_eq!(
|
||||
classify_proxy(&proxy("socks5", "gate.example.com")),
|
||||
ExitReachability::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
classify_proxy(&proxy("http", "192.168.0.10")),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "192.168.0.10".to_string(),
|
||||
source: "proxy",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_endpoints_are_classified_by_their_peer() {
|
||||
assert_eq!(
|
||||
classify_wireguard_endpoint("vpn.example.com:51820"),
|
||||
ExitReachability::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
classify_wireguard_endpoint("10.0.0.1:51820"),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "10.0.0.1".to_string(),
|
||||
source: "VPN",
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
classify_wireguard_endpoint(" "),
|
||||
ExitReachability::Unknown { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_remote_permits_a_run() {
|
||||
assert!(ExitReachability::Remote.is_remote());
|
||||
assert!(!ExitReachability::None.is_remote());
|
||||
assert!(!ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".into(),
|
||||
source: "proxy"
|
||||
}
|
||||
.is_remote());
|
||||
// `None` has no detail: the caller's existing "no exit at all" refusal is
|
||||
// the better message, and two refusals for one condition read as a bug.
|
||||
assert!(ExitReachability::None.refusal_detail().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
//! What a remote session owes this machine, and the gate that collects it.
|
||||
//!
|
||||
//! A profile that runs on the leased fleet is written by the host, not here.
|
||||
//! The host pushes it back to cloud storage when the session ends, and until
|
||||
//! this machine has pulled that push, the local profile directory is a stale
|
||||
//! copy of something that has moved on.
|
||||
//!
|
||||
//! Opening that stale copy is not a cosmetic problem, it is destructive. The
|
||||
//! local browser writes, every local mtime jumps past the host's push, and the
|
||||
//! next ordinary sync therefore reads local as the newer side: it uploads the
|
||||
//! pre-session files and puts everything the host wrote into
|
||||
//! `files_to_delete_remote`. A night of cookie warming is deleted with no error
|
||||
//! anywhere. Nothing in the manifest can prevent this, because by then the local
|
||||
//! clock genuinely IS later.
|
||||
//!
|
||||
//! So the gate is here instead, and it is deliberately a LOCAL, per-machine
|
||||
//! fact rather than a synced one. "This computer has not yet pulled" is true of
|
||||
//! one computer at a time; putting it in the profile's synced metadata would let
|
||||
//! a second device that had already pulled clear it for a first device that had
|
||||
//! not.
|
||||
//!
|
||||
//! Two states, and the difference matters to the user:
|
||||
//!
|
||||
//! - [`HandoffState::Running`]: a session is live on the fleet. The profile lock
|
||||
//! is held server-side, so a launch would be refused anyway; this makes the
|
||||
//! refusal instant and legible instead of a round trip and a raw string.
|
||||
//! - [`HandoffState::PendingSync`]: the session is over, the lock is released,
|
||||
//! and the work is sitting in cloud storage. This is the window that used to
|
||||
//! be wide open.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Emitted whenever the set of gated profiles changes.
|
||||
pub const EVENT_REMOTE_HANDOFF: &str = "remote-handoff-changed";
|
||||
|
||||
/// Attempts at pulling a finished session's work before giving up for now.
|
||||
///
|
||||
/// The entry survives a failure, so "giving up" only means this burst stops;
|
||||
/// the next stream event, app start or manual sync tries again. What the retries
|
||||
/// buy is the common case: the profile lock is released server-side a moment
|
||||
/// before this machine's cached copy of it expires, and a single attempt would
|
||||
/// hit `Skipped("profile is locked elsewhere")` and leave the user blocked for
|
||||
/// no reason.
|
||||
const PULL_ATTEMPTS: u32 = 5;
|
||||
|
||||
/// Delay before the second pull attempt. Doubles, capped by [`PULL_RETRY_MAX`].
|
||||
const PULL_RETRY_BASE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Ceiling on the pull backoff. Above the 30s profile-lock refresh, so a run of
|
||||
/// attempts is guaranteed to span at least one refresh of the lock cache.
|
||||
const PULL_RETRY_MAX: Duration = Duration::from_secs(45);
|
||||
|
||||
/// Where a profile stands with respect to the fleet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HandoffState {
|
||||
/// A session is live on the fleet right now.
|
||||
Running,
|
||||
/// A session has finished and its work has not been pulled down yet.
|
||||
PendingSync,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct HandoffEntry {
|
||||
session_id: String,
|
||||
state: HandoffState,
|
||||
/// When this entry last changed, unix seconds. Diagnostics only; the gate
|
||||
/// never expires on its own, because an entry that timed out would reopen
|
||||
/// exactly the window it exists to close.
|
||||
observed_at: u64,
|
||||
}
|
||||
|
||||
type Store = HashMap<String, HandoffEntry>;
|
||||
|
||||
static STORE: RwLock<Option<Store>> = RwLock::new(None);
|
||||
|
||||
fn store_path() -> std::path::PathBuf {
|
||||
crate::app_dirs::settings_dir().join("remote_handoff.json")
|
||||
}
|
||||
|
||||
fn load_from_disk() -> Store {
|
||||
let path = store_path();
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return Store::new();
|
||||
};
|
||||
match serde_json::from_slice::<Store>(&bytes) {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
// Losing the file means losing the gate, so say so loudly rather than
|
||||
// starting empty and quietly permitting a launch over pending work.
|
||||
log::error!(
|
||||
"Could not read {}: {e}. Profiles with unsynced remote work will not be gated until \
|
||||
the next session event.",
|
||||
path.display()
|
||||
);
|
||||
Store::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(store: &Store) {
|
||||
let path = store_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("Could not create {}: {e}", parent.display());
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_vec_pretty(store) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = crate::app_dirs::write_owner_only(&path, &bytes) {
|
||||
log::warn!("Could not write {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Could not encode the remote handoff store: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_store<T>(f: impl FnOnce(&mut Store) -> T) -> T {
|
||||
let mut guard = STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let store = guard.get_or_insert_with(load_from_disk);
|
||||
f(store)
|
||||
}
|
||||
|
||||
/// Apply a mutation, and persist plus announce it only if it changed anything.
|
||||
fn mutate(f: impl FnOnce(&mut Store) -> bool) {
|
||||
let changed = with_store(|store| {
|
||||
let changed = f(store);
|
||||
if changed {
|
||||
persist(store);
|
||||
}
|
||||
changed
|
||||
});
|
||||
if changed {
|
||||
announce();
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn announce() {
|
||||
let _ = crate::events::emit(EVENT_REMOTE_HANDOFF, states());
|
||||
}
|
||||
|
||||
/// Every gated profile, for the UI and for one-shot reads.
|
||||
pub fn states() -> HashMap<String, HandoffState> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.map(|(profile_id, entry)| (profile_id.clone(), entry.state))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Where this profile stands, if it is gated at all.
|
||||
pub fn state_for(profile_id: &str) -> Option<HandoffState> {
|
||||
with_store(|store| store.get(profile_id).map(|entry| entry.state))
|
||||
}
|
||||
|
||||
/// The session currently holding this profile on the fleet, if any.
|
||||
///
|
||||
/// Answers for a `provisioning` session too, which the drivable-session index
|
||||
/// deliberately does not. Stopping a session that has not finished coming up is
|
||||
/// the single most common thing a user does after starting one by mistake, and
|
||||
/// an index built for "where do I attach a CDP client" cannot serve it.
|
||||
pub fn running_session_for_profile(profile_id: &str) -> Option<String> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.get(profile_id)
|
||||
.filter(|entry| entry.state == HandoffState::Running)
|
||||
.map(|entry| entry.session_id.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Which profile a session belongs to, as this machine last recorded it.
|
||||
///
|
||||
/// The backend's stop reply carries a session id and a duration but no profile,
|
||||
/// and the caller that pressed stop needs to know whose work to pull. Reading it
|
||||
/// back from the gate avoids a second round trip for something already known.
|
||||
pub fn profile_for_session(session_id: &str) -> Option<String> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.find(|(_, entry)| entry.session_id == session_id)
|
||||
.map(|(profile_id, _)| profile_id.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Record that a session is live on the fleet for this profile.
|
||||
///
|
||||
/// Written to disk immediately, and this is the point of the whole store: if the
|
||||
/// app is closed while a session runs, nothing on restart would otherwise
|
||||
/// distinguish "this profile is fine" from "a host has been writing to this
|
||||
/// profile for the last hour".
|
||||
pub fn note_running(profile_id: &str, session_id: &str) {
|
||||
mutate(|store| {
|
||||
let entry = store.get(profile_id);
|
||||
if entry
|
||||
.is_some_and(|held| held.state == HandoffState::Running && held.session_id == session_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
store.insert(
|
||||
profile_id.to_string(),
|
||||
HandoffEntry {
|
||||
session_id: session_id.to_string(),
|
||||
state: HandoffState::Running,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Record that a session has finished and its work is waiting in cloud storage.
|
||||
///
|
||||
/// Returns whether this call is the one that moved the profile into
|
||||
/// `PendingSync`, so the caller starts exactly one pull for a transition that
|
||||
/// the stream may well deliver more than once.
|
||||
pub fn note_ended(profile_id: &str, session_id: &str) -> bool {
|
||||
let mut transitioned = false;
|
||||
mutate(|store| {
|
||||
// Only a session this machine was watching can hand work over to it.
|
||||
//
|
||||
// No entry means one of two things and both say "do nothing": the pull for
|
||||
// this session already completed and cleared the gate, or this machine
|
||||
// never held the profile. The backend's listing returns closed sessions
|
||||
// alongside live ones, so the snapshot on every reconnect replays each
|
||||
// finished session — treating those as fresh handoffs would gate a
|
||||
// perfectly current profile on every app start, and keep it blocked for as
|
||||
// long as the machine happened to be offline.
|
||||
let Some(entry) = store.get(profile_id) else {
|
||||
return false;
|
||||
};
|
||||
// A late `closed` for a session that has already been replaced by a newer
|
||||
// one must not mark the newer one's profile as finished.
|
||||
if entry.session_id != session_id {
|
||||
return false;
|
||||
}
|
||||
if entry.state == HandoffState::PendingSync {
|
||||
return false;
|
||||
}
|
||||
transitioned = true;
|
||||
store.insert(
|
||||
profile_id.to_string(),
|
||||
HandoffEntry {
|
||||
session_id: session_id.to_string(),
|
||||
state: HandoffState::PendingSync,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
true
|
||||
});
|
||||
transitioned
|
||||
}
|
||||
|
||||
/// Drop the gate. Called only after a pull has actually completed.
|
||||
pub fn clear(profile_id: &str) {
|
||||
mutate(|store| store.remove(profile_id).is_some());
|
||||
}
|
||||
|
||||
/// Bring stored `Running` entries back in line with what the backend reports.
|
||||
///
|
||||
/// The stream is how a transition normally arrives, and it cannot deliver one
|
||||
/// that happened while the app was shut. Any profile this machine last saw
|
||||
/// running, whose session the backend no longer reports as live, finished
|
||||
/// without being observed — and its work is sitting in cloud storage unpulled.
|
||||
/// Returns the profiles that just moved into `PendingSync`.
|
||||
pub fn reconcile(live_session_ids: &std::collections::HashSet<String>) -> Vec<String> {
|
||||
let mut ended = Vec::new();
|
||||
mutate(|store| {
|
||||
let stale: Vec<(String, String)> = store
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.state == HandoffState::Running)
|
||||
.filter(|(_, entry)| !live_session_ids.contains(&entry.session_id))
|
||||
.map(|(profile_id, entry)| (profile_id.clone(), entry.session_id.clone()))
|
||||
.collect();
|
||||
for (profile_id, session_id) in stale {
|
||||
log::info!(
|
||||
"Remote session {session_id} for profile {profile_id} ended while this machine was not \
|
||||
watching; its work is still in cloud storage"
|
||||
);
|
||||
store.insert(
|
||||
profile_id.clone(),
|
||||
HandoffEntry {
|
||||
session_id,
|
||||
state: HandoffState::PendingSync,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
ended.push(profile_id);
|
||||
}
|
||||
!ended.is_empty()
|
||||
});
|
||||
ended
|
||||
}
|
||||
|
||||
/// Refuse a local launch that would run over unsynced remote work.
|
||||
///
|
||||
/// Returns the `{"code":…}` string a Tauri command and the REST layer both
|
||||
/// surface. Every local launch path calls this: the two that did not are how a
|
||||
/// profile could be opened locally while a host was still writing to it.
|
||||
pub fn ensure_local_launch_allowed(profile_id: &str) -> Result<(), String> {
|
||||
match state_for(profile_id) {
|
||||
None => Ok(()),
|
||||
Some(HandoffState::Running) => Err(crate::backend_error("PROFILE_RUNNING_REMOTELY")),
|
||||
Some(HandoffState::PendingSync) => Err(crate::backend_error("PROFILE_REMOTE_SYNC_PENDING")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the pull for every profile still waiting on one.
|
||||
///
|
||||
/// A pull can fail for as long as the machine is offline, and its retries are
|
||||
/// bounded, so without this a profile could stay blocked from launching until
|
||||
/// the user found the manual sync button. Called whenever the app has a cloud
|
||||
/// session again, which is exactly when a previously impossible pull becomes
|
||||
/// possible.
|
||||
pub fn resume_pending_pulls(app_handle: &tauri::AppHandle) {
|
||||
let pending: Vec<String> = with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.state == HandoffState::PendingSync)
|
||||
.map(|(profile_id, _)| profile_id.clone())
|
||||
.collect()
|
||||
});
|
||||
for profile_id in pending {
|
||||
log::info!("Resuming the post-session pull for profile {profile_id}");
|
||||
schedule_pull(app_handle.clone(), profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull one profile's finished session down, then lift its gate.
|
||||
///
|
||||
/// Spawned rather than awaited by its callers: a stream frame and a stop button
|
||||
/// must not block on a transfer that can take minutes. The gate stays up for the
|
||||
/// whole attempt, so there is no window in which the user can open the stale
|
||||
/// copy while this is in flight.
|
||||
pub fn schedule_pull(app_handle: tauri::AppHandle, profile_id: String) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
for attempt in 0..PULL_ATTEMPTS {
|
||||
if state_for(&profile_id) != Some(HandoffState::PendingSync) {
|
||||
// A new session started, or another pull got there first.
|
||||
return;
|
||||
}
|
||||
if attempt > 0 {
|
||||
let delay = PULL_RETRY_BASE
|
||||
.saturating_mul(1u32 << (attempt - 1).min(16))
|
||||
.min(PULL_RETRY_MAX);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
|
||||
match crate::sync::pull_profile_after_remote_session(&app_handle, &profile_id).await {
|
||||
Ok(outcome) if outcome.is_completed() => {
|
||||
log::info!("Pulled remote session work for profile {profile_id}");
|
||||
clear(&profile_id);
|
||||
return;
|
||||
}
|
||||
Ok(crate::sync::ProfileSyncOutcome::Skipped(reason)) => {
|
||||
log::info!("Post-session pull for profile {profile_id} did nothing ({reason}); retrying");
|
||||
}
|
||||
Ok(_) => unreachable!("is_completed covers every completed outcome"),
|
||||
Err(e) => {
|
||||
log::warn!("Post-session pull for profile {profile_id} failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"Could not pull remote session work for profile {profile_id} yet; it stays blocked from \
|
||||
launching locally until the pull succeeds"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Serialises every test that can reach [`STORE`], wherever it lives.
|
||||
///
|
||||
/// `remote_session`'s tests drive session transitions through `note_running`
|
||||
/// and `note_ended`, so they mutate this module's global store too — with the
|
||||
/// same `p1`/`p2` fixture ids. Two mutexes meant the two groups could interleave
|
||||
/// and clobber each other, which showed up as an intermittent failure in the
|
||||
/// suite guarding a data-loss bug.
|
||||
#[cfg(test)]
|
||||
pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Take the store lock and start from an empty store. Callers must hold the
|
||||
/// returned guard for the whole test.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lock_for_test() -> std::sync::MutexGuard<'static, ()> {
|
||||
let lock = TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
|
||||
lock
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Point the store at a scratch directory and start it empty.
|
||||
///
|
||||
/// Everything returned must outlive the test body: dropping the guard
|
||||
/// restores the real data directory, and a test that let it drop early would
|
||||
/// write a gate file into the developer's own app data. `TEST_DATA_DIR` is
|
||||
/// thread-local but [`STORE`] is process-global, so [`lock_for_test`] is what
|
||||
/// keeps two tests from sharing one store while pointing at different
|
||||
/// directories.
|
||||
fn isolated() -> (
|
||||
tempfile::TempDir,
|
||||
crate::app_dirs::TestDirGuard,
|
||||
std::sync::MutexGuard<'static, ()>,
|
||||
) {
|
||||
let lock = lock_for_test();
|
||||
let dir = tempfile::TempDir::new().expect("a scratch directory");
|
||||
let guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
|
||||
// Re-taken after the data dir is redirected, so nothing loads from the
|
||||
// real one.
|
||||
*STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
|
||||
(dir, guard, lock)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_session_blocks_a_local_launch() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let err = ensure_local_launch_allowed("p1").expect_err("a live session must block a launch");
|
||||
assert!(err.contains("PROFILE_RUNNING_REMOTELY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_session_still_blocks_until_the_work_is_pulled() {
|
||||
// The whole point. The profile lock is released the moment the session
|
||||
// closes, so without this the user can open the stale copy and the next
|
||||
// sync deletes everything the host wrote.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(note_ended("p1", "s1"));
|
||||
let err = ensure_local_launch_allowed("p1").expect_err("pending work must block a launch");
|
||||
assert!(err.contains("PROFILE_REMOTE_SYNC_PENDING"));
|
||||
|
||||
clear("p1");
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ungated_profile_is_not_blocked() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(ensure_local_launch_allowed("p2").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_transition_is_reported_once_however_often_the_frame_arrives() {
|
||||
// The stream re-delivers a snapshot on every reconnect, and `closed` can
|
||||
// arrive alongside it. Starting a pull per frame would run several
|
||||
// concurrent transfers of the same profile.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(note_ended("p1", "s1"));
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_session_this_machine_never_watched_does_not_gate_anything() {
|
||||
// `listForUser` returns closed sessions next to live ones, so the snapshot
|
||||
// on every reconnect replays every session that ever finished. Treating
|
||||
// those as fresh handoffs would block the Run button on a perfectly current
|
||||
// profile at each app start, and block it indefinitely while offline.
|
||||
let _iso = isolated();
|
||||
assert!(!note_ended("p1", "s-finished-last-week"));
|
||||
assert_eq!(state_for("p1"), None);
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pulled_profile_is_not_re_gated_by_a_replayed_close() {
|
||||
// Same frame, one step later: the pull completed and cleared the gate. The
|
||||
// next reconnect must not put it back.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
clear("p1");
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_close_for_a_replaced_session_does_not_gate_the_new_one() {
|
||||
// Session s1 finished and was pulled; s2 is now live on the same profile. A
|
||||
// straggling `closed` for s1 must not declare s2's profile finished, or the
|
||||
// gate lifts while a host is still writing.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s2");
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_ended_while_the_app_was_shut_is_recovered() {
|
||||
// Nothing streams a transition to a process that is not running. Without
|
||||
// this the profile reads as still-running for ever and can never be
|
||||
// launched again, and its work is never pulled.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let live: HashSet<String> = HashSet::new();
|
||||
assert_eq!(reconcile(&live), vec!["p1".to_string()]);
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::PendingSync));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_leaves_a_session_that_is_genuinely_still_live() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let live: HashSet<String> = ["s1".to_string()].into_iter().collect();
|
||||
assert!(reconcile(&live).is_empty());
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_does_not_reopen_a_pending_profile() {
|
||||
// `PendingSync` is not a session state and no listing will ever contain it.
|
||||
// Re-deriving it from the snapshot would report the same handoff as new on
|
||||
// every reconnect and start a pull each time.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
let live: HashSet<String> = HashSet::new();
|
||||
assert!(reconcile(&live).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gate_survives_a_restart() {
|
||||
// Held on disk precisely because the dangerous window outlives the process:
|
||||
// an app killed mid-session comes back with no memory of it.
|
||||
let (_dir, _guard, _lock) = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
|
||||
*STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::PendingSync));
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,9 @@
|
||||
|
||||
use crate::cloud_errors::{self, FailureCodes};
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::remote_exit::ExitReachability;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
@@ -114,6 +116,54 @@ pub fn idempotency_key(profile_id: &str, attempt: &str) -> String {
|
||||
format!("run-remote:{profile_id}:{attempt}")
|
||||
}
|
||||
|
||||
/// Whether this profile's exit rules out running it on a leased host.
|
||||
///
|
||||
/// A session runs on a fleet host that pulls the profile — and its proxy record
|
||||
/// — out of the user's sync namespace, rewriting no addresses along the way. A
|
||||
/// proxy stored as `127.0.0.1:8080` therefore arrives meaning THAT host's
|
||||
/// loopback: the browser either cannot connect and the leased hour is burned on
|
||||
/// a session that never worked, or it falls through and the user's identity
|
||||
/// egresses from our datacenter. The Cookie Bot has refused this since
|
||||
/// `remote_exit` existed; interactive sessions take the same profile onto the
|
||||
/// same hosts and did not, so the same mistake cost a leased hour here.
|
||||
///
|
||||
/// A profile with NO exit at all is deliberately allowed through. The Cookie
|
||||
/// Bot refuses that separately because a night of unattended browsing from a
|
||||
/// hosting ASN damages an identity, but an interactive session is a person at a
|
||||
/// keyboard who chose to open this profile and can see where it comes out —
|
||||
/// and no rule has ever required an exit here. Refusing it would be a new
|
||||
/// product restriction wearing this bug's error code.
|
||||
///
|
||||
/// Split out from the launch because that is the only testable seam:
|
||||
/// `exit_reachability` reads this machine's proxy and VPN stores and the launch
|
||||
/// itself needs a fleet.
|
||||
fn local_exit_refusal(verdict: &ExitReachability) -> Option<RemoteSessionError> {
|
||||
match verdict {
|
||||
// An address anyone can dial, so the leased host can dial it too.
|
||||
ExitReachability::Remote => None,
|
||||
// See the second paragraph above: allowed on purpose, not overlooked.
|
||||
ExitReachability::None => None,
|
||||
// `LocalOnly`, plus `Unknown` — which `remote_exit` produces when it could
|
||||
// not read the config and which fails closed by design, because "we could
|
||||
// not confirm it" guessed as "yes" is the failure this whole check exists
|
||||
// to stop.
|
||||
unusable => {
|
||||
// The prose names the offending host, which belongs in the log where
|
||||
// support can read it. The toast gets the code so it stays translated.
|
||||
if let Some(detail) = unusable.refusal_detail() {
|
||||
log::warn!("Refusing an interactive remote session: {detail}");
|
||||
}
|
||||
// `Other` rather than a typed variant: the other three are each pinned to
|
||||
// a status and a meaning — "the fleet is busy", "already open somewhere",
|
||||
// "not on your plan" — and this refusal is none of them. The code in the
|
||||
// body is what every surface renders.
|
||||
Some(RemoteSessionError::Other(
|
||||
serde_json::json!({ "code": "REMOTE_REQUIRES_REMOTE_EXIT_NODE" }).to_string(),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask donutbrowser-infra to start a remote session for this profile.
|
||||
///
|
||||
/// Goes through `api_call_with_retry` so an expired access token is refreshed
|
||||
@@ -132,12 +182,20 @@ pub async fn start_remote_session(
|
||||
.to_string();
|
||||
let profile_id = profile.id.to_string();
|
||||
|
||||
// Checked here, before the request: the backend is told which profile to
|
||||
// start but never sees the proxy record, so it cannot derive this — and by
|
||||
// the time it could, an hour is already leased and billed. Resolving a proxy
|
||||
// id to an address is only possible on the machine that stores it.
|
||||
if let Some(refusal) = local_exit_refusal(&crate::cookie_bot::exit_reachability(profile)) {
|
||||
return Err(refusal);
|
||||
}
|
||||
|
||||
// One key for this user action: a retry inside api_call_with_retry must
|
||||
// de-duplicate rather than open a second browser on the same profile.
|
||||
let key = idempotency_key(&profile_id, &uuid::Uuid::new_v4().to_string());
|
||||
let endpoint = format!("{}/api/remote-sessions", crate::cloud_auth::CLOUD_API_URL);
|
||||
|
||||
crate::cloud_auth::CLOUD_AUTH
|
||||
let outcome = crate::cloud_auth::CLOUD_AUTH
|
||||
.api_call_with_retry(|token| {
|
||||
let endpoint = endpoint.clone();
|
||||
let body = StartRemoteRequest {
|
||||
@@ -169,7 +227,14 @@ pub async fn start_remote_session(
|
||||
}
|
||||
})
|
||||
.await
|
||||
.map_err(|e| classify_error_string(&e))
|
||||
.map_err(|e| classify_error_string(&e))?;
|
||||
|
||||
// Gate the profile here rather than waiting for the stream to say so. A host
|
||||
// starts pulling this profile the instant the backend accepts, and the first
|
||||
// transition can arrive seconds later or, on a machine whose stream is down,
|
||||
// not at all. Those seconds are enough for a user to press Run.
|
||||
note_session_started(&profile.id.to_string(), &outcome.session_id);
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
/// What the backend returns when a session is stopped.
|
||||
@@ -181,6 +246,30 @@ pub struct EndRemoteSessionOutcome {
|
||||
pub billed_seconds: u64,
|
||||
}
|
||||
|
||||
/// Gate a profile the moment a launch is accepted, and pull when one is stopped.
|
||||
///
|
||||
/// The event stream is the normal way this machine learns a session's state, but
|
||||
/// it is not the only way a session starts or ends and it is not guaranteed to
|
||||
/// be connected. Both of these are called directly by the launch and stop paths
|
||||
/// so the gate never depends on a socket being up: a launch whose first
|
||||
/// transition is missed would leave the profile openable locally while a host
|
||||
/// wrote to it, and a stop whose `closed` frame is missed would leave the
|
||||
/// session's work sitting in cloud storage with nothing to pull it.
|
||||
pub fn note_session_started(profile_id: &str, session_id: &str) {
|
||||
crate::remote_handoff::note_running(profile_id, session_id);
|
||||
}
|
||||
|
||||
pub fn note_session_stopped(app: &AppHandle, session_id: &str) {
|
||||
let Some(profile_id) = crate::remote_handoff::profile_for_session(session_id) else {
|
||||
// A session this machine never saw start. There is nothing recorded to
|
||||
// pull for, and inventing a profile id would gate the wrong profile.
|
||||
return;
|
||||
};
|
||||
if crate::remote_handoff::note_ended(&profile_id, session_id) {
|
||||
crate::remote_handoff::schedule_pull(app.clone(), profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Ask donutbrowser-infra to stop a remote session.
|
||||
///
|
||||
/// Without this the only thing that ends a session is the fleet's own two-hour
|
||||
@@ -343,6 +432,257 @@ async fn get_json<T: serde::de::DeserializeOwned>(
|
||||
.map_err(|e| classify_error_string(&e))
|
||||
}
|
||||
|
||||
// --- Driving a session ------------------------------------------------------
|
||||
|
||||
/// Where to attach a CDP client for one session.
|
||||
///
|
||||
/// The descriptor is deliberately OPAQUE and server-decided. The desktop knows
|
||||
/// nothing about the fleet — not its hostname, not its paths, not a credential
|
||||
/// it would accept — and switches only on `auth`. That is what lets the server
|
||||
/// move the endpoint, or hand out a different kind of credential, without a
|
||||
/// desktop release; a hard-coded URL in a shipped binary could not be moved at
|
||||
/// all.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct CdpEndpoint {
|
||||
#[serde(default)]
|
||||
pub session_id: String,
|
||||
pub ws_url: String,
|
||||
/// Wire protocol the endpoint speaks.
|
||||
#[serde(default)]
|
||||
pub protocol: String,
|
||||
/// How to authenticate: `bearer` means the same access token used for REST.
|
||||
#[serde(default)]
|
||||
pub auth: String,
|
||||
}
|
||||
|
||||
/// The only credential scheme this build can present.
|
||||
const AUTH_BEARER: &str = "bearer";
|
||||
|
||||
/// The only relay protocol this build speaks.
|
||||
const PROTOCOL_CDP_RELAY_1: &str = "cdp-relay/1";
|
||||
|
||||
/// Endpoints already resolved, keyed by session id.
|
||||
///
|
||||
/// A session's endpoint does not move while it lives, and every tool call would
|
||||
/// otherwise pay a cloud round trip before it could send its first byte.
|
||||
static CDP_ENDPOINTS: Mutex<Option<HashMap<String, CdpEndpoint>>> = Mutex::new(None);
|
||||
|
||||
/// Ask the backend where to attach for `session_id`.
|
||||
pub async fn cdp_endpoint(session_id: &str) -> Result<CdpEndpoint, RemoteSessionError> {
|
||||
if let Some(cached) = with_endpoints(|map| map.get(session_id).cloned()) {
|
||||
return Ok(cached);
|
||||
}
|
||||
|
||||
let endpoint = format!(
|
||||
"{}/api/remote-sessions/{}/cdp",
|
||||
crate::cloud_auth::CLOUD_API_URL,
|
||||
urlencoding::encode(session_id)
|
||||
);
|
||||
let mut resolved: CdpEndpoint = get_json(endpoint).await?;
|
||||
if resolved.session_id.is_empty() {
|
||||
resolved.session_id = session_id.to_string();
|
||||
}
|
||||
|
||||
if let Some(reason) = unsupported_descriptor(&resolved) {
|
||||
return Err(RemoteSessionError::Other(reason));
|
||||
}
|
||||
|
||||
with_endpoints(|map| map.insert(session_id.to_string(), resolved.clone()));
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
/// Why this build cannot use a descriptor, if it cannot.
|
||||
///
|
||||
/// A scheme or protocol this version does not implement has to fail loudly.
|
||||
/// Guessing at a credential scheme would send the user's access token somewhere
|
||||
/// it was never meant to go, and ignoring the fields would present the wrong
|
||||
/// credential on a wire expecting another — both of which read as "remote
|
||||
/// driving is broken" rather than "this app is out of date".
|
||||
///
|
||||
/// An empty field means the server stated nothing, which is how a descriptor
|
||||
/// that predates the field looks; the historic behaviour is then the answer.
|
||||
fn unsupported_descriptor(endpoint: &CdpEndpoint) -> Option<String> {
|
||||
if !endpoint.auth.is_empty() && endpoint.auth != AUTH_BEARER {
|
||||
return Some(format!(
|
||||
"this version cannot attach to a remote browser using {:?} authentication; update Donut Browser",
|
||||
endpoint.auth
|
||||
));
|
||||
}
|
||||
if !endpoint.protocol.is_empty() && endpoint.protocol != PROTOCOL_CDP_RELAY_1 {
|
||||
return Some(format!(
|
||||
"this version does not speak {:?}; update Donut Browser",
|
||||
endpoint.protocol
|
||||
));
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn with_endpoints<T>(f: impl FnOnce(&mut HashMap<String, CdpEndpoint>) -> T) -> T {
|
||||
let mut guard = CDP_ENDPOINTS
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
f(guard.get_or_insert_with(HashMap::new))
|
||||
}
|
||||
|
||||
fn forget_endpoint(session_id: &str) {
|
||||
with_endpoints(|map| map.remove(session_id));
|
||||
}
|
||||
|
||||
/// The access token a relay attach presents.
|
||||
///
|
||||
/// One place, so the credential a WebSocket carries is provably the same one
|
||||
/// every REST call already carries, and no second copy of the load-and-check
|
||||
/// logic can drift from it.
|
||||
pub fn access_token_for_cdp() -> Result<String, String> {
|
||||
crate::cloud_auth::CloudAuthManager::load_access_token()?
|
||||
.filter(|token| !token.is_empty())
|
||||
.ok_or_else(|| "not signed in to Donut cloud".to_string())
|
||||
}
|
||||
|
||||
/// Sessions that can be driven right now, keyed by the profile they hold.
|
||||
///
|
||||
/// Maintained from the event stream so deciding "is this profile running on the
|
||||
/// fleet?" costs a lock rather than a cloud round trip on every tool call.
|
||||
static LIVE_BY_PROFILE: Mutex<Option<HashMap<String, RemoteSessionState>>> = Mutex::new(None);
|
||||
|
||||
/// Whether the stream has delivered a snapshot and has not dropped since.
|
||||
///
|
||||
/// Without this the index cannot distinguish "no session for that profile" from
|
||||
/// "nothing has told us about any session yet", and the second answered as the
|
||||
/// first is exactly how a live remote profile reports itself as not running.
|
||||
static INDEX_AUTHORITATIVE: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
fn with_index<T>(f: impl FnOnce(&mut HashMap<String, RemoteSessionState>) -> T) -> T {
|
||||
let mut guard = LIVE_BY_PROFILE
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
f(guard.get_or_insert_with(HashMap::new))
|
||||
}
|
||||
|
||||
/// A session that is up AND attachable.
|
||||
///
|
||||
/// `provisioning` and `ready` are both "the browser is not there yet"; treating
|
||||
/// either as drivable is what makes a client attach into a connection that
|
||||
/// never establishes.
|
||||
pub fn is_drivable(session: &RemoteSessionState) -> bool {
|
||||
session.state == "live" && session.cdp_ready
|
||||
}
|
||||
|
||||
/// A session that will never write to the profile again.
|
||||
///
|
||||
/// Deliberately NOT the negation of [`is_drivable`]. A `provisioning` session
|
||||
/// has already taken the profile lock and its host is about to pull the profile
|
||||
/// down and launch a browser on it, so it owns the profile every bit as much as
|
||||
/// a `live` one does — it is simply not attachable yet. Treating "not drivable"
|
||||
/// as "finished" would lift the local launch gate during the one minute a host
|
||||
/// spends starting up, which is the window in which two writers do the most
|
||||
/// damage.
|
||||
pub fn is_terminal(session: &RemoteSessionState) -> bool {
|
||||
matches!(session.state.as_str(), "closed" | "error")
|
||||
}
|
||||
|
||||
/// Apply one session to the index, and to the local launch gate.
|
||||
///
|
||||
/// A session that stopped being drivable is removed, but only by the session
|
||||
/// that owns the slot: a late `closed` for a finished session must not evict
|
||||
/// the live one that replaced it.
|
||||
fn index_session(app: Option<&AppHandle>, session: &RemoteSessionState) {
|
||||
let Some(profile_id) = session.profile_id.clone() else {
|
||||
return;
|
||||
};
|
||||
|
||||
// The gate is maintained from the same frames as the index, because these are
|
||||
// the only frames that exist. It is deliberately keyed off `is_terminal`
|
||||
// rather than `is_drivable`: a provisioning host already owns the profile.
|
||||
if is_terminal(session) {
|
||||
if crate::remote_handoff::note_ended(&profile_id, &session.session_id) {
|
||||
if let Some(app) = app {
|
||||
crate::remote_handoff::schedule_pull(app.clone(), profile_id.clone());
|
||||
}
|
||||
}
|
||||
} else {
|
||||
crate::remote_handoff::note_running(&profile_id, &session.session_id);
|
||||
}
|
||||
|
||||
if is_drivable(session) {
|
||||
with_index(|map| map.insert(profile_id, session.clone()));
|
||||
return;
|
||||
}
|
||||
forget_endpoint(&session.session_id);
|
||||
with_index(|map| {
|
||||
let owns_slot = map
|
||||
.get(&profile_id)
|
||||
.is_some_and(|held| held.session_id == session.session_id);
|
||||
if owns_slot {
|
||||
map.remove(&profile_id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Replace the whole index from a full listing, and reconcile the launch gate.
|
||||
fn reindex(app: Option<&AppHandle>, sessions: &[RemoteSessionState]) {
|
||||
// Every session the backend still considers unfinished. A profile this
|
||||
// machine last saw running whose session is not in here finished while
|
||||
// nothing was watching — its work is in cloud storage and has not been pulled.
|
||||
let unfinished: std::collections::HashSet<String> = sessions
|
||||
.iter()
|
||||
.filter(|session| !is_terminal(session))
|
||||
.map(|session| session.session_id.clone())
|
||||
.collect();
|
||||
|
||||
for session in sessions {
|
||||
index_session(app, session);
|
||||
}
|
||||
for profile_id in crate::remote_handoff::reconcile(&unfinished) {
|
||||
if let Some(app) = app {
|
||||
crate::remote_handoff::schedule_pull(app.clone(), profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
let next: HashMap<String, RemoteSessionState> = sessions
|
||||
.iter()
|
||||
.filter(|session| is_drivable(session))
|
||||
.filter_map(|session| {
|
||||
session
|
||||
.profile_id
|
||||
.clone()
|
||||
.map(|profile_id| (profile_id, session.clone()))
|
||||
})
|
||||
.collect();
|
||||
let live: std::collections::HashSet<&str> = next
|
||||
.values()
|
||||
.map(|session| session.session_id.as_str())
|
||||
.collect();
|
||||
with_endpoints(|map| map.retain(|session_id, _| live.contains(session_id.as_str())));
|
||||
with_index(|map| *map = next);
|
||||
}
|
||||
|
||||
/// The drivable session holding `profile_id`, if there is one.
|
||||
///
|
||||
/// Consults the in-process index first. Only when the stream is not delivering
|
||||
/// transitions does it spend a cloud round trip, because in that state the
|
||||
/// index cannot be trusted to be complete and answering "not running" from it
|
||||
/// would hide a session the user is already paying for.
|
||||
pub async fn live_session_for_profile(profile_id: &str) -> Option<RemoteSessionState> {
|
||||
if let Some(session) = with_index(|map| map.get(profile_id).cloned()) {
|
||||
return Some(session);
|
||||
}
|
||||
if INDEX_AUTHORITATIVE.load(Ordering::SeqCst) {
|
||||
return None;
|
||||
}
|
||||
|
||||
match list_remote_sessions().await {
|
||||
Ok(sessions) => {
|
||||
reindex(None, &sessions);
|
||||
with_index(|map| map.get(profile_id).cloned())
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Could not refresh remote sessions while resolving a CDP target: {e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Live state, without polling -------------------------------------------
|
||||
|
||||
/// A session transition. Payload is the session as the backend sees it.
|
||||
@@ -550,6 +890,10 @@ pub fn start_session_events(app: AppHandle) {
|
||||
|
||||
/// Stop receiving. Safe to call when nothing is running.
|
||||
pub fn stop_session_events() {
|
||||
// Cleared unconditionally: unsubscribing is what sign-out does, and an index
|
||||
// left marked authoritative would keep answering from state nothing is
|
||||
// maintaining any more.
|
||||
INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst);
|
||||
if !STREAM_RUNNING.swap(false, Ordering::SeqCst) {
|
||||
return;
|
||||
}
|
||||
@@ -723,6 +1067,7 @@ fn dispatch_frame(app: &AppHandle, frame: &SseFrame) {
|
||||
let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) else {
|
||||
return;
|
||||
};
|
||||
apply_to_index(Some(app), target, &payload);
|
||||
|
||||
use tauri::Emitter;
|
||||
if let Err(e) = app.emit(target, payload) {
|
||||
@@ -730,7 +1075,47 @@ fn dispatch_frame(app: &AppHandle, frame: &SseFrame) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Keep the drivable-session index in step with what the stream just said.
|
||||
///
|
||||
/// The same frames that tell the frontend a session went live are the only
|
||||
/// thing that can tell the CDP resolver so without polling, and a resolver that
|
||||
/// polls would put a cloud round trip in front of every automation call.
|
||||
pub fn apply_to_index(app: Option<&AppHandle>, target: &str, payload: &serde_json::Value) {
|
||||
if target == EVENT_SESSION_SNAPSHOT {
|
||||
let Some(array) = payload.get("sessions").and_then(|v| v.as_array()) else {
|
||||
// Marking the index authoritative off a frame that carried no list would
|
||||
// answer "no session" for every profile until the next reconnect.
|
||||
log::warn!("Ignoring a remote-session snapshot that carried no session list");
|
||||
return;
|
||||
};
|
||||
let mut sessions = Vec::with_capacity(array.len());
|
||||
for value in array {
|
||||
match serde_json::from_value::<RemoteSessionState>(value.clone()) {
|
||||
Ok(session) => sessions.push(session),
|
||||
Err(e) => log::warn!("Skipping an undecodable session in the snapshot: {e}"),
|
||||
}
|
||||
}
|
||||
reindex(app, &sessions);
|
||||
INDEX_AUTHORITATIVE.store(true, Ordering::SeqCst);
|
||||
return;
|
||||
}
|
||||
|
||||
if target == EVENT_SESSION_STATE {
|
||||
match serde_json::from_value::<RemoteSessionState>(payload.clone()) {
|
||||
Ok(session) => index_session(app, &session),
|
||||
Err(e) => log::warn!("Ignoring an undecodable session transition: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn emit_stream_status(app: &AppHandle, connected: bool, reason: Option<&str>) {
|
||||
if !connected {
|
||||
// A dropped stream means transitions are being missed, so the index stops
|
||||
// being an answer and becomes a cache: a miss now costs one cloud read
|
||||
// rather than silently reporting a live session as absent.
|
||||
INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst);
|
||||
}
|
||||
|
||||
use tauri::Emitter;
|
||||
let payload = serde_json::json!({ "connected": connected, "reason": reason });
|
||||
if let Err(e) = app.emit(EVENT_STREAM_STATUS, payload) {
|
||||
@@ -852,6 +1237,50 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_local_only_exit_is_refused_before_a_host_is_leased() {
|
||||
// The profile and its proxy record are copied onto the fleet unrewritten,
|
||||
// so this loopback address would mean the FLEET's loopback. Accepting the
|
||||
// launch bills an hour for a session that cannot reach the user's exit.
|
||||
let refusal = local_exit_refusal(&ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".to_string(),
|
||||
source: "proxy",
|
||||
})
|
||||
.expect("a loopback proxy is unusable from a leased host");
|
||||
|
||||
assert_eq!(
|
||||
refusal.to_error_json(),
|
||||
r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_exit_that_could_not_be_read_is_refused_too() {
|
||||
// `Unknown` is "we could not confirm this works from elsewhere". Treating
|
||||
// that as a yes reintroduces exactly the burned hour above, so it fails
|
||||
// closed here as it does everywhere else `remote_exit` is consulted.
|
||||
let refusal = local_exit_refusal(&ExitReachability::Unknown {
|
||||
reason: "the profile references a proxy that no longer exists".to_string(),
|
||||
source: "proxy",
|
||||
})
|
||||
.expect("an unreadable exit is not evidence of a reachable one");
|
||||
|
||||
assert_eq!(
|
||||
refusal.to_error_json(),
|
||||
r#"{"code":"REMOTE_REQUIRES_REMOTE_EXIT_NODE"}"#
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_reachable_exit_and_no_exit_at_all_are_both_allowed_to_launch() {
|
||||
assert!(local_exit_refusal(&ExitReachability::Remote).is_none());
|
||||
// Deliberate, and the reason this gate is not simply `!is_remote()`: a
|
||||
// proxyless interactive session has always been permitted, and refusing it
|
||||
// with a code that says "your proxy is local" would be both a new product
|
||||
// rule and a sentence that does not describe the profile.
|
||||
assert!(local_exit_refusal(&ExitReachability::None).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_backend_supplied_code_survives_the_trip_through_the_typed_error() {
|
||||
// Once infra sends an envelope, its code must win over the status default
|
||||
@@ -1123,4 +1552,271 @@ mod tests {
|
||||
stop_session_events();
|
||||
assert!(!session_events_running());
|
||||
}
|
||||
|
||||
// --- The drivable-session index ------------------------------------------
|
||||
//
|
||||
// This index is what lets an automation call decide "is this profile running
|
||||
// on the fleet?" without a cloud round trip. Everything below drives it
|
||||
// through the SAME two steps production uses — decode the wire, route the
|
||||
// frame, apply it — because the whole class of bug this replaced came from a
|
||||
// test that agreed with the client and neither agreeing with the server.
|
||||
|
||||
/// The statics below are process-wide, and `cargo test` runs these threads in
|
||||
/// parallel. Without this every index test would be racing every other one.
|
||||
static INDEX_TESTS: Mutex<()> = Mutex::new(());
|
||||
|
||||
fn index_test<T>(body: impl FnOnce() -> T) -> T {
|
||||
let _guard = INDEX_TESTS
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
// Applying a session transition also drives `remote_handoff`: it mutates
|
||||
// that module's process-global store and persists the launch gate to the
|
||||
// data directory. Its lock keeps the two test groups from clobbering each
|
||||
// other's `p1`/`p2` fixtures, and the scratch directory keeps the gate file
|
||||
// out of the developer's own app data.
|
||||
let _handoff = crate::remote_handoff::lock_for_test();
|
||||
let dir = tempfile::TempDir::new().expect("a scratch directory");
|
||||
let _data_dir = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
|
||||
with_index(|map| map.clear());
|
||||
with_endpoints(|map| map.clear());
|
||||
INDEX_AUTHORITATIVE.store(false, Ordering::SeqCst);
|
||||
body()
|
||||
}
|
||||
|
||||
/// Decode, route and apply a literal wire capture, exactly as
|
||||
/// `dispatch_frame` does minus the emit to the frontend.
|
||||
fn feed(bytes: &[u8]) {
|
||||
let mut decoder = SseDecoder::new();
|
||||
for frame in decoder.push(bytes) {
|
||||
if let Some((target, payload)) = route_frame(frame.event.as_deref(), &frame.data) {
|
||||
apply_to_index(None, target, &payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn indexed(profile_id: &str) -> Option<RemoteSessionState> {
|
||||
with_index(|map| map.get(profile_id).cloned())
|
||||
}
|
||||
|
||||
fn transition(session_id: &str, profile_id: &str, state: &str, cdp_ready: bool) -> Vec<u8> {
|
||||
format!(
|
||||
"data: {{\"type\":\"state\",\"session\":{{\"session_id\":\"{session_id}\",\"profile_id\":\"{profile_id}\",\"state\":\"{state}\",\"cdp_ready\":{cdp_ready}}}}}\n\n"
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_becoming_drivable_is_indexed_by_the_profile_it_holds() {
|
||||
index_test(|| {
|
||||
feed(&transition("sess-1", "p1", "live", true));
|
||||
let held = indexed("p1").expect("a live session must be resolvable by profile");
|
||||
assert_eq!(held.session_id, "sess-1");
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_browser_that_is_up_but_not_attachable_is_not_offered_for_driving() {
|
||||
index_test(|| {
|
||||
// `ready` without CDP is a browser that exists and cannot be driven.
|
||||
// Offering it is what makes a client attach into a connection that never
|
||||
// establishes, and then blame the fleet for the timeout.
|
||||
feed(&transition("sess-1", "p1", "ready", false));
|
||||
assert!(indexed("p1").is_none());
|
||||
feed(&transition("sess-1", "p1", "provisioning", false));
|
||||
assert!(indexed("p1").is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_closes_frees_the_profile_and_forgets_its_endpoint() {
|
||||
index_test(|| {
|
||||
feed(&transition("sess-1", "p1", "live", true));
|
||||
with_endpoints(|map| {
|
||||
map.insert(
|
||||
"sess-1".to_string(),
|
||||
CdpEndpoint {
|
||||
session_id: "sess-1".to_string(),
|
||||
ws_url: "wss://example/cdp".to_string(),
|
||||
protocol: PROTOCOL_CDP_RELAY_1.to_string(),
|
||||
auth: AUTH_BEARER.to_string(),
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
feed(&transition("sess-1", "p1", "closed", false));
|
||||
assert!(indexed("p1").is_none());
|
||||
// A cached endpoint for a dead session would be handed to the next
|
||||
// attach, which would then fail against a relay that has nothing left to
|
||||
// relay to.
|
||||
assert!(with_endpoints(|map| map.get("sess-1").cloned()).is_none());
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_close_for_a_finished_session_does_not_evict_the_one_that_replaced_it() {
|
||||
index_test(|| {
|
||||
feed(&transition("sess-1", "p1", "live", true));
|
||||
feed(&transition("sess-1", "p1", "closed", false));
|
||||
feed(&transition("sess-2", "p1", "live", true));
|
||||
|
||||
// Out-of-order frames are normal: the reconciler polls the fleet while
|
||||
// the user is already starting the next session. A stale close arriving
|
||||
// after the new session went live must not make a working browser
|
||||
// unreachable.
|
||||
feed(&transition("sess-1", "p1", "closed", false));
|
||||
assert_eq!(
|
||||
indexed("p1").map(|s| s.session_id),
|
||||
Some("sess-2".to_string())
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_opening_snapshot_replaces_the_index_and_makes_it_authoritative() {
|
||||
index_test(|| {
|
||||
feed(&transition("stale", "p-gone", "live", true));
|
||||
feed(
|
||||
concat!(
|
||||
r#"data: {"type":"snapshot","at":"2026-08-03T00:00:00.000Z","sessions":["#,
|
||||
r#"{"session_id":"sess-1","profile_id":"p1","state":"live","cdp_ready":true},"#,
|
||||
r#"{"session_id":"sess-2","profile_id":"p2","state":"ready","cdp_ready":false}]}"#,
|
||||
"\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
indexed("p1").map(|s| s.session_id),
|
||||
Some("sess-1".to_string())
|
||||
);
|
||||
// Not attachable, so not in the index even though the snapshot listed it.
|
||||
assert!(indexed("p2").is_none());
|
||||
// A session the snapshot did not mention is gone, however live the index
|
||||
// last believed it to be.
|
||||
assert!(indexed("p-gone").is_none());
|
||||
assert!(INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_snapshot_carrying_no_session_list_does_not_blind_the_resolver() {
|
||||
index_test(|| {
|
||||
feed(&transition("sess-1", "p1", "live", true));
|
||||
// Trusting a malformed snapshot would answer "no session" for every
|
||||
// profile until the next reconnect, which is exactly the blindness the
|
||||
// index exists to remove.
|
||||
apply_to_index(None, EVENT_SESSION_SNAPSHOT, &serde_json::json!({}));
|
||||
assert_eq!(
|
||||
indexed("p1").map(|s| s.session_id),
|
||||
Some("sess-1".to_string())
|
||||
);
|
||||
assert!(!INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_undecodable_session_does_not_cost_the_whole_snapshot() {
|
||||
index_test(|| {
|
||||
feed(
|
||||
concat!(
|
||||
r#"data: {"type":"snapshot","sessions":[{"nonsense":true},"#,
|
||||
r#"{"session_id":"sess-1","profile_id":"p1","state":"live","cdp_ready":true}]}"#,
|
||||
"\n\n"
|
||||
)
|
||||
.as_bytes(),
|
||||
);
|
||||
assert_eq!(
|
||||
indexed("p1").map(|s| s.session_id),
|
||||
Some("sess-1".to_string())
|
||||
);
|
||||
assert!(INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsubscribing_stops_the_index_being_an_answer() {
|
||||
index_test(|| {
|
||||
INDEX_AUTHORITATIVE.store(true, Ordering::SeqCst);
|
||||
STREAM_RUNNING.store(false, Ordering::SeqCst);
|
||||
// Sign-out unsubscribes. An index still marked authoritative would keep
|
||||
// answering from state nothing is maintaining any more, so a session
|
||||
// started by the next account would report as absent.
|
||||
stop_session_events();
|
||||
assert!(!INDEX_AUTHORITATIVE.load(Ordering::SeqCst));
|
||||
});
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_with_no_profile_is_ignored_rather_than_indexed_under_nothing() {
|
||||
index_test(|| {
|
||||
feed(b"data: {\"type\":\"state\",\"session\":{\"session_id\":\"s1\",\"state\":\"live\",\"cdp_ready\":true}}\n\n");
|
||||
assert!(with_index(|map| map.is_empty()));
|
||||
});
|
||||
}
|
||||
|
||||
// --- The CDP endpoint descriptor -----------------------------------------
|
||||
|
||||
#[test]
|
||||
fn the_endpoint_descriptor_matches_what_the_backend_sends() {
|
||||
// Pinned against `GET /api/remote-sessions/:id/cdp` in donutbrowser-infra.
|
||||
// A field name that does not match makes every remote attach fail at the
|
||||
// decode step, and the desktop reports a live session as undrivable.
|
||||
let endpoint: CdpEndpoint = serde_json::from_str(
|
||||
r#"{"session_id":"sess-1",
|
||||
"ws_url":"wss://api.donutbrowser.com/api/remote-sessions/cdp?session_id=sess-1",
|
||||
"protocol":"cdp-relay/1","auth":"bearer"}"#,
|
||||
)
|
||||
.expect("the backend's CDP descriptor must deserialize");
|
||||
assert_eq!(endpoint.session_id, "sess-1");
|
||||
assert!(endpoint.ws_url.starts_with("wss://"));
|
||||
assert!(unsupported_descriptor(&endpoint).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_descriptor_this_build_cannot_honour_is_refused_rather_than_guessed_at() {
|
||||
// The descriptor is opaque and server-decided so the endpoint can move
|
||||
// without a desktop release. The other side of that bargain is that a
|
||||
// scheme this build does not implement must say so, not present the user's
|
||||
// access token on a wire that expected something else.
|
||||
let ticketed = CdpEndpoint {
|
||||
session_id: "sess-1".to_string(),
|
||||
ws_url: "wss://fleet.example/cdp".to_string(),
|
||||
protocol: PROTOCOL_CDP_RELAY_1.to_string(),
|
||||
auth: "ticket".to_string(),
|
||||
};
|
||||
assert!(unsupported_descriptor(&ticketed)
|
||||
.expect("an unknown auth scheme must be refused")
|
||||
.contains("update Donut Browser"));
|
||||
|
||||
let future_protocol = CdpEndpoint {
|
||||
auth: AUTH_BEARER.to_string(),
|
||||
protocol: "cdp-relay/2".to_string(),
|
||||
..ticketed
|
||||
};
|
||||
assert!(unsupported_descriptor(&future_protocol).is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_descriptor_that_states_nothing_is_treated_as_todays_behaviour() {
|
||||
// An older backend that predates the fields must keep working; the fields
|
||||
// are a forward-compatibility hook, not a required handshake.
|
||||
let bare: CdpEndpoint = serde_json::from_str(r#"{"ws_url":"wss://example/cdp"}"#)
|
||||
.expect("a descriptor with only a URL must deserialize");
|
||||
assert!(bare.session_id.is_empty());
|
||||
assert!(unsupported_descriptor(&bare).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_session_that_is_both_live_and_attachable_is_drivable() {
|
||||
let mut session: RemoteSessionState =
|
||||
serde_json::from_str(r#"{"session_id":"s1","state":"live","cdp_ready":true}"#).unwrap();
|
||||
assert!(is_drivable(&session));
|
||||
|
||||
session.cdp_ready = false;
|
||||
assert!(!is_drivable(&session));
|
||||
|
||||
session.cdp_ready = true;
|
||||
session.state = "ready".to_string();
|
||||
assert!(!is_drivable(&session));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,14 @@ pub struct AppSettings {
|
||||
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ko", "ru", or None for system default
|
||||
#[serde(default)]
|
||||
pub window_resize_warning_dismissed: bool,
|
||||
/// Stop blocking launches whose proxy exit disagrees with the fingerprint.
|
||||
/// Lives here rather than in localStorage because the Rust launch path is
|
||||
/// what enforces the block and cannot read the frontend's storage.
|
||||
#[serde(default)]
|
||||
pub fingerprint_gate_disabled: bool,
|
||||
/// Stop warning about VPN/proxy extensions found in a profile.
|
||||
#[serde(default)]
|
||||
pub vpn_extension_warning_disabled: bool,
|
||||
#[serde(default)]
|
||||
pub onboarding_completed: bool, // First-launch onboarding has been shown/handled (one-shot)
|
||||
#[serde(default)]
|
||||
@@ -96,6 +104,8 @@ impl Default for AppSettings {
|
||||
mcp_token: None,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
fingerprint_gate_disabled: false,
|
||||
vpn_extension_warning_disabled: false,
|
||||
onboarding_completed: false,
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
@@ -1190,6 +1200,8 @@ mod tests {
|
||||
mcp_token: None,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
fingerprint_gate_disabled: false,
|
||||
vpn_extension_warning_disabled: false,
|
||||
onboarding_completed: false,
|
||||
disable_auto_updates: false,
|
||||
keep_decrypted_profiles_in_ram: false,
|
||||
|
||||
+161
-19
@@ -1,6 +1,8 @@
|
||||
use super::client::SyncClient;
|
||||
use super::encryption;
|
||||
use super::manifest::{compute_diff, generate_manifest, get_cache_path, HashCache, SyncManifest};
|
||||
use super::manifest::{
|
||||
compute_diff_with_bias, generate_manifest, get_cache_path, DiffBias, HashCache, SyncManifest,
|
||||
};
|
||||
use super::types::*;
|
||||
use crate::events;
|
||||
use crate::profile::types::{BrowserProfile, SyncMode};
|
||||
@@ -20,6 +22,22 @@ use tokio::sync::{Mutex as TokioMutex, Semaphore};
|
||||
/// (last-write-wins) from a HEAD request without downloading the object body.
|
||||
const UPDATED_AT_META_KEY: &str = "updated-at";
|
||||
|
||||
/// What one profile reconcile actually did.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProfileSyncOutcome {
|
||||
/// The local directory and the remote copy now agree.
|
||||
Completed,
|
||||
/// Nothing was transferred, and the reason is not an error. A caller waiting
|
||||
/// on the remote copy has NOT got it and must try again.
|
||||
Skipped(&'static str),
|
||||
}
|
||||
|
||||
impl ProfileSyncOutcome {
|
||||
pub fn is_completed(&self) -> bool {
|
||||
matches!(self, Self::Completed)
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref SYNC_CANCEL_FLAGS: StdMutex<HashMap<String, Arc<AtomicBool>>> =
|
||||
StdMutex::new(HashMap::new());
|
||||
@@ -91,6 +109,37 @@ fn is_critical_file(path: &str) -> bool {
|
||||
.any(|pattern| path.contains(pattern))
|
||||
}
|
||||
|
||||
/// How many failed paths to name before collapsing the rest into a count.
|
||||
const MAX_LISTED_FAILURES: usize = 10;
|
||||
|
||||
/// Aggregate a batch of failed transfers into the message the user sees.
|
||||
///
|
||||
/// Whatever breaks a sync usually breaks every file the same way — one
|
||||
/// unreachable storage host, one rejected signature — so the per-file causes
|
||||
/// were dropped and only the paths survived into the message. That left users
|
||||
/// staring at a list of filenames with nothing to act on. Carry the first
|
||||
/// cause through, and stop pasting hundreds of paths into a toast.
|
||||
fn critical_failure_message(action: &str, failures: &[(String, String)]) -> String {
|
||||
let listed: Vec<&str> = failures
|
||||
.iter()
|
||||
.take(MAX_LISTED_FAILURES)
|
||||
.map(|(path, _)| path.as_str())
|
||||
.collect();
|
||||
let hidden = failures.len().saturating_sub(listed.len());
|
||||
let files = if hidden > 0 {
|
||||
format!("{} (and {} more)", listed.join(", "), hidden)
|
||||
} else {
|
||||
listed.join(", ")
|
||||
};
|
||||
|
||||
match failures.first() {
|
||||
Some((_, cause)) => format!(
|
||||
"Critical files failed to {action}: {files}. Cause: {cause}. Sync aborted to prevent data loss."
|
||||
),
|
||||
None => format!("Critical files failed to {action}: {files}. Sync aborted to prevent data loss."),
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate that a manifest-supplied relative file path is safe to join onto a
|
||||
/// profile directory before writing/deleting. The manifest is remote-controlled
|
||||
/// (a self-hosted or compromised sync server, a MITM on a plaintext Regular-mode
|
||||
@@ -313,7 +362,7 @@ impl SyncProgressTracker {
|
||||
/// Check if sync is configured (cloud or self-hosted)
|
||||
pub fn is_sync_configured() -> bool {
|
||||
// Cloud backup is a plan capability. Every paid plan (incl. the future
|
||||
// "starter" tier) grants it, but gating on the capability — not just "is paid"
|
||||
// "solo" tier) grants it, but gating on the capability — not just "is paid"
|
||||
// — keeps this correct if a plan without cloud backup is ever added.
|
||||
if crate::cloud_auth::CLOUD_AUTH.can_use_cloud_backup_sync() {
|
||||
return true;
|
||||
@@ -450,13 +499,35 @@ impl SyncEngine {
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
) -> SyncResult<()> {
|
||||
self
|
||||
.sync_profile_with_bias(app_handle, profile, DiffBias::Auto)
|
||||
.await
|
||||
.map(|_| ())
|
||||
}
|
||||
|
||||
/// Reconcile a profile, stating which side wins and whether anything happened.
|
||||
///
|
||||
/// The outcome matters to exactly one caller: the pull that follows a remote
|
||||
/// session. Every skip below returns `Ok(())` from `sync_profile`, so a caller
|
||||
/// that treated success as "the profile is now current" would clear the local
|
||||
/// launch gate without having downloaded a single byte — and the user would
|
||||
/// then open a stale profile over the session's work. `Skipped` says so.
|
||||
pub async fn sync_profile_with_bias(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
bias: DiffBias,
|
||||
) -> SyncResult<ProfileSyncOutcome> {
|
||||
if profile.is_cross_os() {
|
||||
log::info!(
|
||||
"Cross-OS profile: {} ({}) — syncing metadata only",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return self.sync_cross_os_metadata(app_handle, profile).await;
|
||||
self.sync_cross_os_metadata(app_handle, profile).await?;
|
||||
// The browser files are the thing a remote session changes, and a cross-OS
|
||||
// profile syncs none of them here, so this is not a completed pull.
|
||||
return Ok(ProfileSyncOutcome::Skipped("cross-OS profile"));
|
||||
}
|
||||
|
||||
// Skip team profiles for self-hosted sync
|
||||
@@ -466,7 +537,9 @@ impl SyncEngine {
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(ProfileSyncOutcome::Skipped(
|
||||
"team profile, self-hosted sync",
|
||||
));
|
||||
}
|
||||
|
||||
// Skip if profile is currently running locally
|
||||
@@ -476,20 +549,21 @@ impl SyncEngine {
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(ProfileSyncOutcome::Skipped("profile is running locally"));
|
||||
}
|
||||
|
||||
// Skip if profile is locked by another team member
|
||||
// Skip if profile is locked by another team member, or by one of this
|
||||
// user's own remote sessions.
|
||||
if crate::team_lock::TEAM_LOCK
|
||||
.is_locked_by_another(&profile.id.to_string())
|
||||
.await
|
||||
{
|
||||
log::info!(
|
||||
"Skipping sync for profile locked by another team member: {} ({})",
|
||||
"Skipping sync for profile locked by another holder: {} ({})",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return Ok(());
|
||||
return Ok(ProfileSyncOutcome::Skipped("profile is locked elsewhere"));
|
||||
}
|
||||
|
||||
let reconciled_profile = self.reconcile_profile_metadata(profile).await?;
|
||||
@@ -591,7 +665,7 @@ impl SyncEngine {
|
||||
.await?;
|
||||
|
||||
// Compute diff
|
||||
let diff = compute_diff(&local_manifest, remote_manifest.as_ref());
|
||||
let diff = compute_diff_with_bias(&local_manifest, remote_manifest.as_ref(), bias);
|
||||
|
||||
if diff.is_empty() {
|
||||
log::info!("Profile {} is already in sync", profile_id);
|
||||
@@ -603,7 +677,9 @@ impl SyncEngine {
|
||||
"status": "synced"
|
||||
}),
|
||||
);
|
||||
return Ok(());
|
||||
// Nothing to transfer IS a completed reconcile: the local copy already
|
||||
// matches what the host pushed, which is exactly what the caller waits for.
|
||||
return Ok(ProfileSyncOutcome::Completed);
|
||||
}
|
||||
|
||||
let upload_bytes: u64 = diff.files_to_upload.iter().map(|f| f.size).sum();
|
||||
@@ -769,7 +845,7 @@ impl SyncEngine {
|
||||
);
|
||||
|
||||
log::info!("Profile {} synced successfully", profile_id);
|
||||
Ok(())
|
||||
Ok(ProfileSyncOutcome::Completed)
|
||||
}
|
||||
|
||||
async fn download_manifest(
|
||||
@@ -1238,10 +1314,9 @@ impl SyncEngine {
|
||||
}
|
||||
|
||||
if !critical_failures.is_empty() {
|
||||
let file_list: Vec<&str> = critical_failures.iter().map(|(p, _)| p.as_str()).collect();
|
||||
return Err(SyncError::IoError(format!(
|
||||
"Critical files failed to upload: {}. Sync aborted to prevent data loss.",
|
||||
file_list.join(", ")
|
||||
return Err(SyncError::IoError(critical_failure_message(
|
||||
"upload",
|
||||
&critical_failures,
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -1514,10 +1589,9 @@ impl SyncEngine {
|
||||
}
|
||||
|
||||
if !critical_failures.is_empty() {
|
||||
let file_list: Vec<&str> = critical_failures.iter().map(|(p, _)| p.as_str()).collect();
|
||||
return Err(SyncError::IoError(format!(
|
||||
"Critical files failed to download: {}. Sync aborted to prevent data loss.",
|
||||
file_list.join(", ")
|
||||
return Err(SyncError::IoError(critical_failure_message(
|
||||
"download",
|
||||
&critical_failures,
|
||||
)));
|
||||
}
|
||||
|
||||
@@ -3546,6 +3620,40 @@ pub async fn trigger_sync_for_profile(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Pull a profile back down after a remote session wrote to it.
|
||||
///
|
||||
/// Not `trigger_sync_for_profile` with a different name. Two things differ, and
|
||||
/// both of them are the reason the session's work used to be destroyed:
|
||||
///
|
||||
/// - The diff is biased to the remote copy. The host has just written the
|
||||
/// authoritative profile; local mtimes may nonetheless be newer, and under the
|
||||
/// ordinary rule that uploads the stale copy and deletes the host's files.
|
||||
/// - The outcome is reported. Every skip inside `sync_profile` returns success,
|
||||
/// so the caller could otherwise mark the profile current without a byte
|
||||
/// having moved.
|
||||
pub async fn pull_profile_after_remote_session(
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
) -> Result<ProfileSyncOutcome, String> {
|
||||
let engine = SyncEngine::create_from_settings(app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create sync engine: {e}"))?;
|
||||
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profile = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
engine
|
||||
.sync_profile_with_bias(app_handle, &profile, DiffBias::PreferRemote)
|
||||
.await
|
||||
.map_err(|e| format!("Sync failed: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_proxy_sync_enabled(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -4168,6 +4276,40 @@ pub async fn rollover_encryption_for_all_entities(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_critical_failure_message_carries_the_cause() {
|
||||
// A self-hosted server that hands out unreachable presigned URLs fails
|
||||
// every file with the same connect error. Naming only the files told the
|
||||
// user nothing about why, which is what made this undiagnosable.
|
||||
let failures = vec![
|
||||
(
|
||||
"Default/Cookies".to_string(),
|
||||
"Failed to upload Default/Cookies after 3 retries: error sending request".to_string(),
|
||||
),
|
||||
("Local State".to_string(), "same".to_string()),
|
||||
];
|
||||
|
||||
let message = critical_failure_message("upload", &failures);
|
||||
assert!(message.contains("Default/Cookies"));
|
||||
assert!(message.contains("Local State"));
|
||||
assert!(message.contains("Cause: Failed to upload Default/Cookies"));
|
||||
assert!(message.contains("Sync aborted to prevent data loss."));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_critical_failure_message_collapses_long_lists() {
|
||||
let failures: Vec<(String, String)> = (0..25)
|
||||
.map(|i| (format!("file-{i}"), "connect error".to_string()))
|
||||
.collect();
|
||||
|
||||
let message = critical_failure_message("download", &failures);
|
||||
assert!(message.contains("file-0"));
|
||||
assert!(message.contains(&format!("file-{}", MAX_LISTED_FAILURES - 1)));
|
||||
assert!(!message.contains(&format!("file-{MAX_LISTED_FAILURES}")));
|
||||
assert!(message.contains("(and 15 more)"));
|
||||
assert!(message.contains("failed to download"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_safe_manifest_path() {
|
||||
// Legitimate profile-relative paths are accepted.
|
||||
|
||||
@@ -414,11 +414,41 @@ impl ManifestDiff {
|
||||
}
|
||||
|
||||
/// Compute what needs to be synced between local and remote
|
||||
/// Which side a sync should believe when both have moved.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DiffBias {
|
||||
/// Newest `updated_at` wins. What an ordinary background sync uses.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Remote wins regardless of timestamps.
|
||||
///
|
||||
/// Used for exactly one thing: the pull that follows a remote session. A
|
||||
/// leased host has just written the authoritative copy of this profile, and
|
||||
/// the local directory is whatever it was before the session started. If the
|
||||
/// user launched locally in between, local mtimes are NEWER than the host's
|
||||
/// push, so `Auto` would upload the stale copy and put every file the host
|
||||
/// wrote into `files_to_delete_remote` — the whole session's work destroyed,
|
||||
/// silently. There is no timestamp comparison that gets this right, because
|
||||
/// the local clock genuinely is later; only the caller knows that the remote
|
||||
/// copy is the one that matters.
|
||||
PreferRemote,
|
||||
}
|
||||
|
||||
pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> ManifestDiff {
|
||||
compute_diff_with_bias(local, remote, DiffBias::Auto)
|
||||
}
|
||||
|
||||
pub fn compute_diff_with_bias(
|
||||
local: &SyncManifest,
|
||||
remote: Option<&SyncManifest>,
|
||||
bias: DiffBias,
|
||||
) -> ManifestDiff {
|
||||
let mut diff = ManifestDiff::default();
|
||||
|
||||
let Some(remote) = remote else {
|
||||
// No remote manifest - upload everything
|
||||
// No remote manifest - upload everything. Even under PreferRemote: there is
|
||||
// no remote copy to prefer, and refusing to upload would leave the profile
|
||||
// with no cloud copy at all.
|
||||
diff.files_to_upload = local.files.clone();
|
||||
return diff;
|
||||
};
|
||||
@@ -446,11 +476,14 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani
|
||||
let local_updated = local.updated_at_datetime();
|
||||
let remote_updated = remote.updated_at_datetime();
|
||||
|
||||
let local_is_newer = match (local_updated, remote_updated) {
|
||||
(Some(l), Some(r)) => l > r,
|
||||
(Some(_), None) => true,
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => true, // Default to uploading
|
||||
let local_is_newer = match bias {
|
||||
DiffBias::PreferRemote => false,
|
||||
DiffBias::Auto => match (local_updated, remote_updated) {
|
||||
(Some(l), Some(r)) => l > r,
|
||||
(Some(_), None) => true,
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => true, // Default to uploading
|
||||
},
|
||||
};
|
||||
|
||||
if local_is_newer {
|
||||
@@ -674,6 +707,68 @@ mod tests {
|
||||
assert!(diff.files_to_delete_remote.is_empty());
|
||||
}
|
||||
|
||||
/// A manifest with one file, at a stated time.
|
||||
fn manifest_at(updated_at: &str, files: &[(&str, &str)]) -> SyncManifest {
|
||||
SyncManifest {
|
||||
version: 1,
|
||||
profile_id: "test".to_string(),
|
||||
generated_at: updated_at.to_string(),
|
||||
updated_at: updated_at.to_string(),
|
||||
exclude_globs: vec![],
|
||||
files: files
|
||||
.iter()
|
||||
.map(|(path, hash)| ManifestFileEntry {
|
||||
path: (*path).to_string(),
|
||||
size: 10,
|
||||
mtime: 1000,
|
||||
hash: (*hash).to_string(),
|
||||
})
|
||||
.collect(),
|
||||
encrypted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefer_remote_downloads_even_though_the_local_clock_is_later() {
|
||||
// The exact shape of the data-loss bug. A remote session finishes and the
|
||||
// host pushes the profile; the user then launches locally before the pull
|
||||
// lands, so every local mtime is newer than the host's write. Under Auto
|
||||
// that uploads the stale copy and deletes the session's own files.
|
||||
let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "before-session")]);
|
||||
let remote = manifest_at(
|
||||
"2026-01-01T00:00:00Z",
|
||||
&[("Cookies", "after-session"), ("History", "warmed")],
|
||||
);
|
||||
|
||||
let lossy = compute_diff_with_bias(&local, Some(&remote), DiffBias::Auto);
|
||||
assert_eq!(lossy.files_to_delete_remote, vec!["History".to_string()]);
|
||||
assert_eq!(lossy.files_to_upload.len(), 1);
|
||||
|
||||
let safe = compute_diff_with_bias(&local, Some(&remote), DiffBias::PreferRemote);
|
||||
assert!(
|
||||
safe.files_to_delete_remote.is_empty(),
|
||||
"a post-session pull must never delete what the host just wrote"
|
||||
);
|
||||
assert!(safe.files_to_upload.is_empty());
|
||||
let downloaded: Vec<&str> = safe
|
||||
.files_to_download
|
||||
.iter()
|
||||
.map(|f| f.path.as_str())
|
||||
.collect();
|
||||
assert_eq!(downloaded.len(), 2);
|
||||
assert!(downloaded.contains(&"Cookies"));
|
||||
assert!(downloaded.contains(&"History"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefer_remote_still_uploads_when_there_is_no_remote_copy() {
|
||||
// Nothing to prefer. Refusing to upload here would leave a profile with no
|
||||
// cloud copy because a session once ran against it.
|
||||
let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "only-local")]);
|
||||
let diff = compute_diff_with_bias(&local, None, DiffBias::PreferRemote);
|
||||
assert_eq!(diff.files_to_upload.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_diff_detect_changes() {
|
||||
let old_time = "2024-01-01T00:00:00Z";
|
||||
|
||||
@@ -15,12 +15,16 @@ pub use engine::{
|
||||
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
|
||||
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
|
||||
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
|
||||
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
|
||||
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
|
||||
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
|
||||
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
|
||||
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile,
|
||||
pull_profile_after_remote_session, request_profile_sync, rollover_encryption_for_all_entities,
|
||||
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
|
||||
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile,
|
||||
trigger_sync_for_profile, ProfileSyncOutcome, SyncEngine,
|
||||
};
|
||||
pub use manifest::{
|
||||
compute_diff, compute_diff_with_bias, generate_manifest, DiffBias, HashCache, ManifestDiff,
|
||||
SyncManifest,
|
||||
};
|
||||
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
|
||||
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
|
||||
pub use subscription::{SubscriptionManager, SyncWorkItem};
|
||||
pub use types::{SyncError, SyncResult};
|
||||
|
||||
@@ -168,7 +168,7 @@ impl SynchronizerManager {
|
||||
);
|
||||
|
||||
// Launch leader first so it gets focus
|
||||
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None)
|
||||
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch leader: {e}"))?;
|
||||
|
||||
@@ -179,7 +179,7 @@ impl SynchronizerManager {
|
||||
let ah = app_handle.clone();
|
||||
let fp = fp.clone();
|
||||
set.spawn(async move {
|
||||
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None)
|
||||
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None, None)
|
||||
.await
|
||||
.map_err(|e| (fp.name.clone(), e.to_string()))
|
||||
});
|
||||
|
||||
+102
-20
@@ -95,8 +95,8 @@ impl ProfileLockManager {
|
||||
|
||||
pub async fn acquire_lock(&self, profile_id: &str) -> Result<(), String> {
|
||||
let client = Client::new();
|
||||
let access_token =
|
||||
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
||||
let access_token = CloudAuthManager::load_access_token()?
|
||||
.ok_or_else(|| crate::backend_error("PROFILE_LOCK_UNAVAILABLE"))?;
|
||||
|
||||
let url = format!("{CLOUD_API_URL}/api/profile-locks/{profile_id}");
|
||||
let response = client
|
||||
@@ -104,24 +104,29 @@ impl ProfileLockManager {
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to acquire lock: {e}"))?;
|
||||
.map_err(|e| {
|
||||
log::warn!("Failed to acquire profile lock for {profile_id}: {e}");
|
||||
crate::backend_error("PROFILE_LOCK_UNAVAILABLE")
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Lock acquisition failed ({status}): {body}"));
|
||||
log::warn!("Profile lock acquisition for {profile_id} failed ({status}): {body}");
|
||||
return Err(crate::backend_error("PROFILE_LOCK_UNAVAILABLE"));
|
||||
}
|
||||
|
||||
let result: AcquireLockResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse lock response: {e}"))?;
|
||||
let result: AcquireLockResponse = response.json().await.map_err(|e| {
|
||||
log::warn!("Could not parse the profile lock response for {profile_id}: {e}");
|
||||
crate::backend_error("PROFILE_LOCK_UNAVAILABLE")
|
||||
})?;
|
||||
|
||||
if !result.success {
|
||||
let email = result
|
||||
.locked_by_email
|
||||
.unwrap_or_else(|| "another device".to_string());
|
||||
return Err(format!("Profile is in use by {email}"));
|
||||
return Err(lock_conflict_error(
|
||||
profile_id,
|
||||
result.locked_by.as_deref(),
|
||||
result.locked_by_email.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
// Update local cache
|
||||
@@ -274,15 +279,51 @@ impl ProfileLockManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Separator the backend puts between a user id and a non-desktop holder's
|
||||
/// sub-identity. Mirrors `HOLDER_SEPARATOR` in donutbrowser-infra's
|
||||
/// `profile-locks.service.ts`.
|
||||
///
|
||||
/// A remote VM session takes the lock under `<user id>:vm:<session id>` so it
|
||||
/// contends with this desktop instead of silently sharing its lock. That makes
|
||||
/// the holder string the one place a client can tell "a teammate has this open"
|
||||
/// apart from "this is my own profile, running on the fleet" — two refusals that
|
||||
/// need completely different words.
|
||||
const VM_HOLDER_SEPARATOR: &str = ":vm:";
|
||||
|
||||
/// The `{"code":…}` for a lock this caller could not take.
|
||||
fn lock_conflict_error(
|
||||
profile_id: &str,
|
||||
holder: Option<&str>,
|
||||
holder_email: Option<&str>,
|
||||
) -> String {
|
||||
if holder.is_some_and(|id| id.contains(VM_HOLDER_SEPARATOR)) {
|
||||
// The user's own remote session. Saying "in use by you@example.com" here,
|
||||
// which is what the raw backend message did, reads as a bug.
|
||||
log::info!("Profile {profile_id} is held by a remote session");
|
||||
return crate::backend_error("PROFILE_RUNNING_REMOTELY");
|
||||
}
|
||||
match holder_email {
|
||||
Some(email) if !email.is_empty() => serde_json::json!({
|
||||
"code": "PROFILE_LOCKED_BY_MEMBER",
|
||||
"params": { "email": email }
|
||||
})
|
||||
.to_string(),
|
||||
_ => crate::backend_error("PROFILE_LOCKED_ELSEWHERE"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire profile lock if profile is sync-enabled and user has a paid subscription.
|
||||
/// Returns whether a lock was actually taken, so a caller that unwinds a failed
|
||||
/// launch releases only what it acquired. Releasing unconditionally would drop
|
||||
/// a lock a REST handler up the stack still owns.
|
||||
pub async fn acquire_team_lock_if_needed(
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<bool, String> {
|
||||
if !profile.is_sync_enabled() {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Ensure lock manager is connected
|
||||
@@ -294,13 +335,18 @@ pub async fn acquire_team_lock_if_needed(
|
||||
.is_locked_by_another(&profile.id.to_string())
|
||||
.await
|
||||
{
|
||||
if let Some(lock) = PROFILE_LOCK.get_lock_status(&profile.id.to_string()).await {
|
||||
return Err(format!("Profile is in use by {}", lock.locked_by_email));
|
||||
}
|
||||
return Err("Profile is in use on another device".to_string());
|
||||
let held = PROFILE_LOCK.get_lock_status(&profile.id.to_string()).await;
|
||||
return Err(lock_conflict_error(
|
||||
&profile.id.to_string(),
|
||||
held.as_ref().map(|lock| lock.locked_by.as_str()),
|
||||
held.as_ref().map(|lock| lock.locked_by_email.as_str()),
|
||||
));
|
||||
}
|
||||
|
||||
PROFILE_LOCK.acquire_lock(&profile.id.to_string()).await
|
||||
PROFILE_LOCK
|
||||
.acquire_lock(&profile.id.to_string())
|
||||
.await
|
||||
.map(|()| true)
|
||||
}
|
||||
|
||||
/// Release profile lock if profile is sync-enabled and user has a paid subscription.
|
||||
@@ -328,3 +374,39 @@ pub async fn get_team_locks() -> Result<Vec<ProfileLockInfo>, String> {
|
||||
pub async fn get_team_lock_status(profile_id: String) -> Result<Option<ProfileLockInfo>, String> {
|
||||
Ok(PROFILE_LOCK.get_lock_status(&profile_id).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_users_own_remote_session_is_not_reported_as_a_teammate() {
|
||||
// The holder for a fleet session is `<user id>:vm:<session id>` and the row
|
||||
// carries the OWNER's email, so the previous message read "Profile is in use
|
||||
// by you@example.com" — the user's own address, about their own profile.
|
||||
let err = lock_conflict_error(
|
||||
"p1",
|
||||
Some("11111111-2222-3333-4444-555555555555:vm:run-remote:p1:abc"),
|
||||
Some("owner@example.com"),
|
||||
);
|
||||
assert_eq!(err, r#"{"code":"PROFILE_RUNNING_REMOTELY"}"#);
|
||||
assert!(!err.contains("owner@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_teammates_lock_names_them_through_a_translatable_code() {
|
||||
let err = lock_conflict_error("p1", Some("other-user-id"), Some("mate@example.com"));
|
||||
let json: serde_json::Value = serde_json::from_str(&err).expect("a code envelope");
|
||||
assert_eq!(json["code"], "PROFILE_LOCKED_BY_MEMBER");
|
||||
assert_eq!(json["params"]["email"], "mate@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lock_with_no_identifiable_holder_still_produces_a_code() {
|
||||
// Raw English here is what reaches a Russian user untranslated.
|
||||
for holder in [None, Some("")] {
|
||||
let err = lock_conflict_error("p1", holder, None);
|
||||
assert_eq!(err, r#"{"code":"PROFILE_LOCKED_ELSEWHERE"}"#);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
//! Enumerates extensions the user installed from inside the browser, by
|
||||
//! walking the Chromium profile directory on disk.
|
||||
//!
|
||||
//! Depends only on `std`, `serde_json`, the sibling `rules` module, and the
|
||||
//! shared profile-directory-name predicate, so the directory-layout handling —
|
||||
//! the riskiest part of detection — stays testable without app state.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::rules::{
|
||||
classify, lookup_message, manifest_str, message_placeholder_key, signal_labels,
|
||||
signals_from_manifest, version_dir_sort_key, vpn_keyword_hit, DetectedVpnExtension,
|
||||
};
|
||||
|
||||
/// Upper bound on extension directories walked per profile. A launch must not
|
||||
/// stall behind a pathological profile; whatever was found is still reported,
|
||||
/// flagged `partial`.
|
||||
const MAX_EXTENSION_DIRS: usize = 300;
|
||||
/// Manifests are a few KiB. Anything past this is not a manifest we can use.
|
||||
const MAX_MANIFEST_BYTES: u64 = 512 * 1024;
|
||||
/// Wall-clock ceiling for the whole scan. This runs on the launch path.
|
||||
const SCAN_DEADLINE: Duration = Duration::from_millis(750);
|
||||
/// Chromium preference files are larger than a manifest but still bounded; a
|
||||
/// pathological one must not be parsed while the user waits for a browser.
|
||||
const MAX_PREFERENCES_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// Chromium profile directories to search inside a user-data dir.
|
||||
///
|
||||
/// Two layouts are real here: Donut launches Wayfern with only
|
||||
/// `--user-data-dir`, so Chromium uses `Default/`; but an imported profile is
|
||||
/// copied in as the profile directory itself, putting `Extensions/` at the
|
||||
/// root. Checking only one layout misses every profile of the other kind.
|
||||
fn candidate_profile_dirs(user_data_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
if user_data_dir.join("Preferences").exists() || user_data_dir.join("Extensions").is_dir() {
|
||||
dirs.push(user_data_dir.to_path_buf());
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(user_data_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if crate::profile::clear_on_close::is_profile_dir_name(name)
|
||||
|| path.join("Preferences").exists()
|
||||
{
|
||||
dirs.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dirs.sort();
|
||||
dirs.dedup();
|
||||
dirs
|
||||
}
|
||||
|
||||
fn read_json_file(path: &Path, max_bytes: Option<u64>) -> Option<serde_json::Value> {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
if max_bytes.is_some_and(|cap| metadata.len() > cap) {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
|
||||
}
|
||||
|
||||
/// Chromium locale directory names are `[A-Za-z0-9_-]` (`en`, `en_GB`,
|
||||
/// `zh_CN`). Anything else in a manifest we did not write is untrusted input
|
||||
/// being joined into a filesystem path, so it is refused rather than sanitized
|
||||
/// — this runs on the launch path against extensions the user may have
|
||||
/// sideloaded.
|
||||
fn is_safe_locale_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 32
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
fn resolve_dir_i18n(
|
||||
version_dir: &Path,
|
||||
manifest: &serde_json::Value,
|
||||
value: &str,
|
||||
) -> Option<String> {
|
||||
let key = message_placeholder_key(value)?;
|
||||
let default_locale = manifest.get("default_locale")?.as_str()?;
|
||||
if !is_safe_locale_name(default_locale) {
|
||||
log::warn!("Ignoring extension with a suspicious default_locale: {default_locale:?}");
|
||||
return None;
|
||||
}
|
||||
let messages = read_json_file(
|
||||
&version_dir
|
||||
.join("_locales")
|
||||
.join(default_locale)
|
||||
.join("messages.json"),
|
||||
Some(MAX_MANIFEST_BYTES),
|
||||
)?;
|
||||
lookup_message(&messages, &key)
|
||||
}
|
||||
|
||||
/// What the profile's preference files say about installed extensions.
|
||||
///
|
||||
/// Read-only on purpose: `Secure Preferences` is MAC-protected, and rewriting
|
||||
/// it invalidates the signature, which disables every extension in the profile.
|
||||
#[derive(Default)]
|
||||
struct PreferenceExtensions {
|
||||
/// Explicitly disabled. A disabled extension cannot touch the proxy, so
|
||||
/// warning about it would be a false alarm.
|
||||
disabled: HashSet<String>,
|
||||
/// Unpacked ("Load unpacked" / developer mode) extensions, which live
|
||||
/// OUTSIDE `Extensions/` and are therefore invisible to the directory walk.
|
||||
/// Sideloading is exactly how someone gets a VPN extension in without the
|
||||
/// Web Store, so missing these would leave the obvious hole open.
|
||||
unpacked: Vec<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
fn preference_extensions(profile_dir: &Path) -> PreferenceExtensions {
|
||||
let mut out = PreferenceExtensions::default();
|
||||
for file in ["Secure Preferences", "Preferences"] {
|
||||
// Larger than a manifest, but still capped: this is parsed while the user
|
||||
// waits for a browser to start.
|
||||
let Some(prefs) = read_json_file(&profile_dir.join(file), Some(MAX_PREFERENCES_BYTES)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(settings) = prefs
|
||||
.get("extensions")
|
||||
.and_then(|e| e.get("settings"))
|
||||
.and_then(|s| s.as_object())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (id, entry) in settings {
|
||||
// Chromium's Extension::State: 0 = disabled.
|
||||
if entry.get("state").and_then(serde_json::Value::as_i64) == Some(0) {
|
||||
out.disabled.insert(id.clone());
|
||||
continue;
|
||||
}
|
||||
// A packed extension's `path` is relative to Extensions/; an unpacked
|
||||
// one records an absolute path elsewhere on disk.
|
||||
if let Some(path) = entry.get("path").and_then(|p| p.as_str()) {
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_absolute() {
|
||||
out.unpacked.push((id.clone(), path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Walk a user-data dir for VPN/proxy extensions.
|
||||
///
|
||||
/// Returns false when the walk was cut short by a cap or the deadline, so the
|
||||
/// caller can report the scan as incomplete rather than clean.
|
||||
pub(super) fn scan_browser_extensions(
|
||||
user_data_dir: &Path,
|
||||
out: &mut Vec<DetectedVpnExtension>,
|
||||
started: Instant,
|
||||
) -> bool {
|
||||
let mut walked = 0usize;
|
||||
|
||||
for profile_dir in candidate_profile_dirs(user_data_dir) {
|
||||
// Read preferences first: unpacked extensions live outside Extensions/, so
|
||||
// a profile that has only sideloaded ones has no Extensions/ dir at all and
|
||||
// must not be skipped before they are considered.
|
||||
if started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
let prefs = preference_extensions(&profile_dir);
|
||||
let disabled = &prefs.disabled;
|
||||
|
||||
for (crx_id, unpacked_dir) in &prefs.unpacked {
|
||||
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
walked += 1;
|
||||
if let Some(found) = detect_in_version_dir(crx_id, unpacked_dir) {
|
||||
out.push(found);
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(profile_dir.join("Extensions")) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
walked += 1;
|
||||
|
||||
let ext_dir = entry.path();
|
||||
if !ext_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(crx_id) = ext_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(str::to_string)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if disabled.contains(&crx_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Several versions can coexist on disk; Chromium runs the highest.
|
||||
let Ok(version_entries) = std::fs::read_dir(&ext_dir) else {
|
||||
continue;
|
||||
};
|
||||
let mut versions: Vec<PathBuf> = version_entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
versions.sort_by_key(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(version_dir_sort_key)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let Some(version_dir) = versions.last() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(found) = detect_in_version_dir(&crx_id, version_dir) {
|
||||
out.push(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Classify the extension whose unpacked files live in `version_dir`.
|
||||
/// Shared by the packed walk and the unpacked (developer-mode) entries.
|
||||
fn detect_in_version_dir(crx_id: &str, version_dir: &Path) -> Option<DetectedVpnExtension> {
|
||||
let manifest = read_json_file(&version_dir.join("manifest.json"), Some(MAX_MANIFEST_BYTES))?;
|
||||
|
||||
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| crx_id.to_string());
|
||||
let name = resolve_dir_i18n(version_dir, &manifest, &raw_name).unwrap_or_else(|| {
|
||||
if message_placeholder_key(&raw_name).is_some() {
|
||||
crx_id.to_string()
|
||||
} else {
|
||||
raw_name.clone()
|
||||
}
|
||||
});
|
||||
let description = manifest_str(&manifest, "description").and_then(|d| {
|
||||
resolve_dir_i18n(version_dir, &manifest, &d).or(if message_placeholder_key(&d).is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(d)
|
||||
})
|
||||
});
|
||||
|
||||
let signals = signals_from_manifest(&manifest);
|
||||
let keyword = vpn_keyword_hit(&name, description.as_deref());
|
||||
let confidence = classify(Some(crx_id), &signals, keyword)?;
|
||||
|
||||
Some(DetectedVpnExtension {
|
||||
key: format!("crx:{crx_id}"),
|
||||
name,
|
||||
version: manifest_str(&manifest, "version"),
|
||||
source: "browser".to_string(),
|
||||
confidence: confidence.to_string(),
|
||||
proxy_control: signals.proxy_permission,
|
||||
signals: signal_labels(Some(crx_id), &signals, keyword),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn write(path: &Path, contents: &str) {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
/// Build a Chromium `Preferences` blob for one extension.
|
||||
///
|
||||
/// Serialized rather than string-interpolated on purpose: a Windows path is
|
||||
/// `C:\Users\...`, and pasting it into a JSON string literal produces invalid
|
||||
/// escape sequences, so the file silently fails to parse and every assertion
|
||||
/// about it passes for the wrong reason.
|
||||
fn preferences_json(crx_id: &str, state: i64, path: &Path) -> String {
|
||||
serde_json::json!({
|
||||
"extensions": {
|
||||
"settings": {
|
||||
crx_id: { "state": state, "path": path.to_string_lossy() }
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
const VPN_MANIFEST: &str = r#"{"name":"Turbo VPN","version":"2.1.0","permissions":["proxy"]}"#;
|
||||
const CRX_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_accepts_the_default_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Default").join("Preferences"), "{}");
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.join("Default")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_accepts_the_imported_root_layout() {
|
||||
// profile_importer copies a Chromium profile dir in as the root, so
|
||||
// Preferences and Extensions/ sit at the top level.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Preferences"), "{}");
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.to_path_buf()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_finds_named_profile_dirs_without_preferences() {
|
||||
// Chromium writes Preferences lazily, so a populated Default/ can lack it.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("Profile 2").join("Extensions")).unwrap();
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.join("Profile 2")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_a_vpn_extension_in_the_default_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
|
||||
assert_eq!(out[0].name, "Turbo VPN");
|
||||
assert_eq!(out[0].confidence, "confirmed");
|
||||
assert_eq!(out[0].source, "browser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_a_vpn_extension_in_the_imported_root_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Preferences"), "{}");
|
||||
write(
|
||||
&root
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reads_the_highest_version_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let ext = root.join("Default").join("Extensions").join(CRX_ID);
|
||||
// Lexicographically "1.9.0_0" > "1.10.0_0"; numerically it is not.
|
||||
write(
|
||||
&ext.join("1.9.0_0").join("manifest.json"),
|
||||
r#"{"name":"Old","version":"1.9.0","permissions":["storage"]}"#,
|
||||
);
|
||||
write(
|
||||
&ext.join("1.10.0_0").join("manifest.json"),
|
||||
r#"{"name":"New VPN","version":"1.10.0","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].version.as_deref(), Some("1.10.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_skips_extensions_chromium_has_disabled() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let profile = root.join("Default");
|
||||
write(
|
||||
&profile
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
write(
|
||||
&profile.join("Secure Preferences"),
|
||||
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":0}}}}}}}}"#),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"a disabled extension cannot touch the proxy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_keeps_enabled_extensions_listed_in_preferences() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let profile = root.join("Default");
|
||||
write(
|
||||
&profile
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
write(
|
||||
&profile.join("Secure Preferences"),
|
||||
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":1}}}}}}}}"#),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_resolves_a_localized_extension_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let version_dir = root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0");
|
||||
write(
|
||||
&version_dir.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"en","permissions":["proxy"]}"#,
|
||||
);
|
||||
write(
|
||||
&version_dir
|
||||
.join("_locales")
|
||||
.join("en")
|
||||
.join("messages.json"),
|
||||
r#"{"appName":{"message":"Nord VPN"}}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out[0].name, "Nord VPN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_falls_back_to_the_crx_id_when_a_placeholder_cannot_be_resolved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out[0].name, CRX_ID, "never show a raw __MSG_ placeholder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reports_a_proxy_holding_download_manager_as_a_capability() {
|
||||
// End-to-end shape of the false positive that prompted the audit: the
|
||||
// extension must still be surfaced (it really can change the proxy) but
|
||||
// never as a VPN.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join("ngpampappnmepgilojfohadhhmbhlaek")
|
||||
.join("6.43.1_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"IDM Integration Module","version":"6.43.1","description":"Download files with Internet Download Manager","permissions":["downloads","storage","proxy","nativeMessaging"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].confidence, "capability");
|
||||
assert!(out[0].proxy_control);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_confirms_a_known_vpn_whose_name_gives_nothing_away() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join("nlbejmccbhkncgokjcmghpfloaajcffj")
|
||||
.join("10.0.0_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"Hotspot Shield","version":"10.0.0","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].confidence, "confirmed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_ignores_an_ordinary_extension() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("1.0.0_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"Dark Reader","version":"1.0.0","permissions":["storage","activeTab"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reports_incomplete_when_the_deadline_has_passed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
// A start time already past the deadline stands in for a slow disk.
|
||||
let expired = Instant::now() - SCAN_DEADLINE - Duration::from_millis(10);
|
||||
assert!(!scan_browser_extensions(root, &mut out, expired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_survives_a_malformed_manifest() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
"{ not json",
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_json_escapes_windows_style_paths() {
|
||||
// The Windows CI failure this guards: a raw `C:\Users\...` pasted into a
|
||||
// JSON string literal is invalid (`\U` is not an escape), so Preferences
|
||||
// failed to parse, `preference_extensions` returned nothing, and the
|
||||
// unpacked extension silently vanished — on Linux the same test passed
|
||||
// because POSIX paths contain no backslashes.
|
||||
let json = preferences_json(CRX_ID, 1, Path::new(r"C:\Users\runner\ext\my-vpn"));
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&json).expect("Preferences must be valid JSON on every platform");
|
||||
assert_eq!(
|
||||
parsed["extensions"]["settings"][CRX_ID]["path"],
|
||||
r"C:\Users\runner\ext\my-vpn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_an_unpacked_developer_mode_extension() {
|
||||
// Sideloading via "Load unpacked" is exactly how a VPN extension gets in
|
||||
// without the Web Store, and those files live outside Extensions/.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let unpacked = root.join("somewhere-else").join("my-vpn");
|
||||
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&preferences_json(CRX_ID, 1, &unpacked),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1, "unpacked extensions must not be invisible");
|
||||
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
|
||||
assert_eq!(out[0].confidence, "confirmed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_ignores_a_disabled_unpacked_extension() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let unpacked = root.join("somewhere-else").join("my-vpn");
|
||||
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&preferences_json(CRX_ID, 0, &unpacked),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_relative_paths_are_not_treated_as_unpacked() {
|
||||
// A packed extension records a path relative to Extensions/; following it
|
||||
// as if absolute would read the wrong place (or nothing).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&preferences_json(CRX_ID, 1, Path::new(&format!("{CRX_ID}/2.1.0_0"))),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversing_default_locale_is_refused() {
|
||||
// `default_locale` comes from a manifest we did not write. Joined naively
|
||||
// it reads any file the user can read, on the launch path.
|
||||
assert!(!is_safe_locale_name("../../../../etc"));
|
||||
assert!(!is_safe_locale_name("..\\..\\windows"));
|
||||
assert!(!is_safe_locale_name("/etc/passwd"));
|
||||
assert!(!is_safe_locale_name(""));
|
||||
assert!(is_safe_locale_name("en"));
|
||||
assert!(is_safe_locale_name("en_GB"));
|
||||
assert!(is_safe_locale_name("zh-CN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_localized_name_with_a_traversing_locale_is_not_resolved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let version_dir = root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0");
|
||||
write(
|
||||
&version_dir.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"../../../../etc","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
// Still detected (the proxy permission is what matters), but the name
|
||||
// falls back rather than the traversal being followed.
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].name, CRX_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_tolerates_a_missing_user_data_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(
|
||||
&tmp.path().join("nope"),
|
||||
&mut out,
|
||||
Instant::now()
|
||||
));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Detects VPN/proxy browser extensions present in a profile.
|
||||
//!
|
||||
//! An extension holding Chromium's `proxy` permission can override the proxy
|
||||
//! Donut passes on the command line, so the browser's real exit stops being the
|
||||
//! one Donut measured and generated the fingerprint against. That produces
|
||||
//! exactly the geo/timezone/language mismatch the fingerprint exists to avoid,
|
||||
//! except Donut cannot observe it from the outside — hence a launch-time
|
||||
//! warning rather than a measurement.
|
||||
//!
|
||||
//! That permission is a capability, not an identity. Chromium exposes no
|
||||
//! read-only variant of it, so a download manager replicating the browser's
|
||||
//! route for its own transfers declares exactly what a VPN hijacking it
|
||||
//! declares. The two are reported as different things — see `rules::classify`.
|
||||
//!
|
||||
//! Two sources, deliberately both: Donut-managed extensions live in the app's
|
||||
//! own store and are handed to Chromium via `--load-extension` from *outside*
|
||||
//! the profile directory, while extensions the user installed from the Web
|
||||
//! Store live *inside* it. Neither set appears in the other.
|
||||
|
||||
mod browser_scan;
|
||||
mod rules;
|
||||
|
||||
// `message_placeholder_key`/`lookup_message` are shared with
|
||||
// `extension_manager`, which resolves the same placeholders out of a zip.
|
||||
use rules::{classify, manifest_str, signal_labels, signals_from_manifest, vpn_keyword_hit};
|
||||
pub use rules::{lookup_message, message_placeholder_key, DetectedVpnExtension};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExtensionScan {
|
||||
pub extensions: Vec<DetectedVpnExtension>,
|
||||
/// `scanned` | `partial` | `encrypted` | `ephemeral` | `missing`.
|
||||
///
|
||||
/// Reported honestly so the dialog can say the scan was incomplete rather
|
||||
/// than implying a clean profile it never managed to read.
|
||||
pub scan_state: String,
|
||||
}
|
||||
|
||||
/// Donut-managed extensions, reached through the profile's extension group.
|
||||
///
|
||||
/// Read live from the stored archive rather than from the metadata cached on
|
||||
/// `Extension`, so replacing an extension's file cannot leave a stale verdict
|
||||
/// behind. N is the group size — typically a handful.
|
||||
fn scan_donut_extensions(profile: &BrowserProfile, out: &mut Vec<DetectedVpnExtension>) {
|
||||
let Some(group_id) = &profile.extension_group_id else {
|
||||
return;
|
||||
};
|
||||
let Ok(manager) = crate::extension_manager::EXTENSION_MANAGER.lock() else {
|
||||
log::warn!("VPN extension scan: extension manager lock poisoned, skipping managed extensions");
|
||||
return;
|
||||
};
|
||||
let Ok(group) = manager.get_group(group_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for ext_id in &group.extension_ids {
|
||||
let Ok(ext) = manager.get_extension(ext_id) else {
|
||||
continue;
|
||||
};
|
||||
let path = manager.get_file_dir_public(ext_id).join(&ext.file_name);
|
||||
let Ok(data) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(manifest) =
|
||||
crate::extension_manager::read_manifest_from_archive(&data, &ext.file_type)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| ext.name.clone());
|
||||
let name =
|
||||
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &raw_name)
|
||||
.unwrap_or_else(|| {
|
||||
// An unresolvable placeholder is not a name — fall back to the one
|
||||
// the extension carries in Donut.
|
||||
if message_placeholder_key(&raw_name).is_some() {
|
||||
ext.name.clone()
|
||||
} else {
|
||||
raw_name.clone()
|
||||
}
|
||||
});
|
||||
let description = manifest_str(&manifest, "description").and_then(|d| {
|
||||
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &d).or(
|
||||
if message_placeholder_key(&d).is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(d)
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
let signals = signals_from_manifest(&manifest);
|
||||
let keyword = vpn_keyword_hit(&name, description.as_deref());
|
||||
// A Donut-managed extension is stored under Donut's own uuid, not the Web
|
||||
// Store id the known-VPN list is keyed on, so it is classified on what its
|
||||
// manifest says about itself.
|
||||
let Some(confidence) = classify(None, &signals, keyword) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
out.push(DetectedVpnExtension {
|
||||
key: format!("donut:{ext_id}"),
|
||||
name,
|
||||
version: manifest_str(&manifest, "version").or_else(|| ext.version.clone()),
|
||||
source: "donut".to_string(),
|
||||
confidence: confidence.to_string(),
|
||||
proxy_control: signals.proxy_permission,
|
||||
signals: signal_labels(None, &signals, keyword),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a profile for VPN/proxy extensions from both sources.
|
||||
///
|
||||
/// Never fails: an unreadable profile reports whatever it could see plus a
|
||||
/// `scan_state` explaining why the picture is incomplete.
|
||||
pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
|
||||
let started = Instant::now();
|
||||
let mut extensions = Vec::new();
|
||||
|
||||
scan_donut_extensions(profile, &mut extensions);
|
||||
|
||||
let profiles_dir = crate::app_dirs::profiles_dir();
|
||||
let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
|
||||
// `get_effective_profile_path` only returns the decrypted RAM copy while the
|
||||
// profile is unlocked; locked, it falls back to the on-disk directory, which
|
||||
// exists but is ciphertext. Walking that finds nothing — so the state has to
|
||||
// be decided on whether we actually got a readable copy, not on the path
|
||||
// existing, or a locked profile reports as verified-clean.
|
||||
let has_plaintext_dir = !(profile.password_protected || profile.ephemeral)
|
||||
|| crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_some();
|
||||
|
||||
let scan_state = if !has_plaintext_dir {
|
||||
if profile.password_protected {
|
||||
"encrypted"
|
||||
} else {
|
||||
"ephemeral"
|
||||
}
|
||||
} else if !user_data_dir.is_dir() {
|
||||
// Never launched, so there is no profile directory to inspect yet.
|
||||
"missing"
|
||||
} else if browser_scan::scan_browser_extensions(&user_data_dir, &mut extensions, started) {
|
||||
"scanned"
|
||||
} else {
|
||||
"partial"
|
||||
};
|
||||
|
||||
// Collapse only exact duplicates of the same extension. `key` is the real
|
||||
// identity (`donut:<uuid>` / `crx:<id>`); name+version is not, and two
|
||||
// distinct extensions sharing a display name would silently fold into one,
|
||||
// hiding a real detection behind an unrelated namesake.
|
||||
let mut seen = HashSet::new();
|
||||
extensions.retain(|e| seen.insert(e.key.clone()));
|
||||
|
||||
ExtensionScan {
|
||||
extensions,
|
||||
scan_state: scan_state.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when at least one extension holds the `proxy` permission outright, so
|
||||
/// it can redirect the browser's traffic without asking for anything further.
|
||||
///
|
||||
/// Informational: it tells the user an exit measurement may describe a route
|
||||
/// the browser will not take. It deliberately does not relax the gate — a
|
||||
/// measurement that might be wrong is a reason for more scrutiny, not less,
|
||||
/// and this signal is true for every download manager on the machine.
|
||||
pub fn has_proxy_control(scan: &ExtensionScan) -> bool {
|
||||
scan.extensions.iter().any(|e| e.proxy_control)
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
//! Pure classification rules for VPN/proxy extension detection.
|
||||
//!
|
||||
//! Deliberately free of crate-internal dependencies (`std` + `serde_json`
|
||||
//! only): these rules are the heart of the feature and the part most worth
|
||||
//! testing in isolation, so nothing here may reach for app state, the
|
||||
//! filesystem, or the network. Enumerating the two extension sources and
|
||||
//! reading them off disk lives in the parent module.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Chrome Web Store ids of extensions whose whole purpose is routing the
|
||||
/// browser somewhere else. Sorted, so membership is a binary search.
|
||||
///
|
||||
/// This list is what lets a VPN with an unrevealing name — "Hotspot Shield"
|
||||
/// says nothing about what it does — be named as one instead of appearing as
|
||||
/// an anonymous holder of the proxy permission. Every id was verified by
|
||||
/// downloading the extension and reading its manifest; a wrong id is worse
|
||||
/// than a missing one, because a stale list only ever loses recall while a
|
||||
/// wrong one accuses the wrong extension.
|
||||
const KNOWN_VPN_EXTENSION_IDS: &[&str] = &[
|
||||
"adlpodnneegcnbophopdmhedicjbcgco", // Troywell VPN
|
||||
"ailoabdmgclmfmhdagmlohpjlbpffblp", // Surfshark
|
||||
"akcocjjpkmlniicdeemdceeajlmoabhg", // 1VPN
|
||||
"apbcbecdpjefgklcokinpapmmdekecah", // Ninja VPN
|
||||
"bihmplhobchoageeokmgbdihknkjbknd", // Touch VPN (delisted 2025, still installed in old profiles)
|
||||
"blapeiihifiknfmceddkceklnpopgclm", // Proxy Switcher Pro
|
||||
"bnlofglpdlboacepdieejiecfbfpmhlb", // Turbo VPN
|
||||
"dookpfaalaaappcdneeahomimbllocnb", // FoxyProxy Basic
|
||||
"eppiocemhmnlbhjplcgkofciiegomcon", // Urban VPN
|
||||
"fcfhplploccackoneaefokcmbjfbkenj", // 1clickVPN
|
||||
"fdcgdnkidjaadafnichfpabhfomcebme", // ZenMate (delisted 2025)
|
||||
"ffbkglfijbcbgblgflchnbphjdllaogb", // CyberGhost
|
||||
"fgddmllnllkalaagkghckoinaemmogpe", // ExpressVPN
|
||||
"fjoaledfpmneenckfbpdfhkmimnjocfa", // NordVPN
|
||||
"gcknhkkoolaabfmlnjonogaaifnjlfnp", // FoxyProxy
|
||||
"gdpehpfhegefkjelaifkdbppjbhilaom", // Proxy-Cheap Proxy Manager
|
||||
"gjakohbhfclfjmhhlenfdkldieofkpjl", // IPRoyal Proxy Manager
|
||||
"gjknjjomckknofjidppipffbpoekiipm", // Betternet
|
||||
"gkojfkhlekighikafcpjkiklfbnlmeio", // Hola VPN
|
||||
"hnmpcagpplmpfojmgmnngilcnanddlhb", // Windscribe
|
||||
"jaoafpkngncfpfggjefnekilbkcpjdgp", // uVPN
|
||||
"jedieiamjmoflcknjdjhpieklepfglin", // FastestVPN
|
||||
"jpadbaildllggkcgibilkeacpcodailn", // Planet VPN lite
|
||||
"jplgfhpmjnbigmhklmmbgecoobifkmpa", // Proton VPN
|
||||
"jplnlifepflhkbkgonidnobkakhmpnmh", // Private Internet Access
|
||||
"kgepmkaldicdcljckhamnhkigddnbcbd", // PACify Proxy Manager
|
||||
"kpiecbcckbofpmkkkdibbllpinceiihk", // DotVPN
|
||||
"majdfhpaihoncoakbjgbdhglocklcgno", // VeePN
|
||||
"nbcojefnccbanplpoffopkoepjmhgdgh", // Hoxx VPN
|
||||
"nlbejmccbhkncgokjcmghpfloaajcffj", // Hotspot Shield
|
||||
"ohjocgmpmlfahafbipehkhbaacoemojp", // hide.me Proxy
|
||||
"omdakjcmkglenbhjadbccaookpfjihpa", // TunnelBear
|
||||
"omghfjlpggmjjaagoclmmobgdodcjboh", // Browsec
|
||||
"onnfghpihccifgojkpnnncpagjcdbjod", // Proxy Switcher and Manager
|
||||
"oofgbpoabipfcfjapgnbbjjaenockbdp", // SetupVPN
|
||||
"padekgcemlokbadohgkifijomclgjgif", // Proxy SwitchyOmega
|
||||
"pphgdbgldlmicfdkhondlafkiomnelnk", // 1ClickVPN Proxy
|
||||
];
|
||||
|
||||
/// Terms specific enough to name a VPN wherever they appear, including in a
|
||||
/// 132-character manifest description.
|
||||
const STRONG_KEYWORDS: &[&str] = &["vpn", "wireguard", "shadowsocks", "openvpn"];
|
||||
|
||||
/// Terms that only mean "VPN" in a product's *name*. In a description they are
|
||||
/// ordinary English — "no proxy setup required", "carpal tunnel", "unblock
|
||||
/// right click" — and matching them there is where the noise comes from.
|
||||
const NAME_ONLY_KEYWORDS: &[&str] = &["proxy", "unblock"];
|
||||
|
||||
/// Matched as whole tokens rather than substrings, and in the name only. Too
|
||||
/// short to be safe inside other words ("tussocks", "tunnelling").
|
||||
const NAME_TOKEN_KEYWORDS: &[&str] = &["socks", "socks5", "tunnel"];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DetectedVpnExtension {
|
||||
/// Stable acknowledgement identity: `donut:<uuid>` or `crx:<32-char-id>`.
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub version: Option<String>,
|
||||
/// `"donut"` (managed by Donut) or `"browser"` (installed inside the profile).
|
||||
pub source: String,
|
||||
/// `"confirmed"` and `"likely"` are claims that this IS a VPN/proxy tool.
|
||||
/// `"capability"` claims only that it *could* change the proxy.
|
||||
pub confidence: String,
|
||||
/// Whether the manifest holds Chromium's `proxy` permission outright, so the
|
||||
/// extension can call `chrome.proxy.settings.set` without asking again.
|
||||
/// Separate from `confidence`: a download manager reading the browser's
|
||||
/// proxy declares the identical permission as a VPN hijacking it.
|
||||
pub proxy_control: bool,
|
||||
/// Why it matched, for the dialog's detail line.
|
||||
pub signals: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ManifestSignals {
|
||||
pub proxy_permission: bool,
|
||||
pub optional_proxy_permission: bool,
|
||||
pub declarative_net_request: bool,
|
||||
pub web_request_blocking: bool,
|
||||
pub broad_host_permissions: bool,
|
||||
}
|
||||
|
||||
fn string_list<'a>(manifest: &'a serde_json::Value, key: &str) -> Vec<&'a str> {
|
||||
manifest
|
||||
.get(key)
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_broad_host(pattern: &str) -> bool {
|
||||
matches!(pattern, "<all_urls>" | "*://*/*")
|
||||
}
|
||||
|
||||
pub fn signals_from_manifest(manifest: &serde_json::Value) -> ManifestSignals {
|
||||
let permissions = string_list(manifest, "permissions");
|
||||
let optional_permissions = string_list(manifest, "optional_permissions");
|
||||
let host_permissions = string_list(manifest, "host_permissions");
|
||||
let optional_host_permissions = string_list(manifest, "optional_host_permissions");
|
||||
|
||||
let has = |list: &[&str], name: &str| list.contains(&name);
|
||||
|
||||
// MV2 keeps host patterns inside `permissions`; MV3 splits them into
|
||||
// `host_permissions`. Look in both so one manifest version isn't silently
|
||||
// under-detected.
|
||||
let all_hosts: Vec<&str> = permissions
|
||||
.iter()
|
||||
.chain(host_permissions.iter())
|
||||
.chain(optional_host_permissions.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
let broad = all_hosts.iter().any(|p| is_broad_host(p))
|
||||
|| (all_hosts.contains(&"http://*/*") && all_hosts.contains(&"https://*/*"));
|
||||
|
||||
ManifestSignals {
|
||||
proxy_permission: has(&permissions, "proxy"),
|
||||
optional_proxy_permission: has(&optional_permissions, "proxy"),
|
||||
declarative_net_request: has(&permissions, "declarativeNetRequest")
|
||||
|| has(&permissions, "declarativeNetRequestWithHostAccess"),
|
||||
web_request_blocking: has(&permissions, "webRequest")
|
||||
&& has(&permissions, "webRequestBlocking"),
|
||||
broad_host_permissions: broad,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this is the id of an extension known to route browser traffic.
|
||||
pub fn is_known_vpn_extension(extension_id: &str) -> bool {
|
||||
KNOWN_VPN_EXTENSION_IDS.binary_search(&extension_id).is_ok()
|
||||
}
|
||||
|
||||
fn has_token(haystack: &str, tokens: &[&str]) -> bool {
|
||||
haystack
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.any(|token| tokens.contains(&token))
|
||||
}
|
||||
|
||||
/// Does the extension describe itself as a VPN or proxy tool?
|
||||
///
|
||||
/// The name is weighted far more heavily than the description, because that is
|
||||
/// where the evidence actually lives: a VPN vendor puts "VPN" in the name — it
|
||||
/// is how the store surfaces them — while a description is 132 characters of
|
||||
/// ordinary prose in which "proxy", "tunnel" and "unblock" are all innocent.
|
||||
/// Matching those three against descriptions is what flags carpal-tunnel
|
||||
/// reminders, right-click unblockers, and tools whose pitch is that they need
|
||||
/// *no* proxy setup.
|
||||
pub fn vpn_keyword_hit(name: &str, description: Option<&str>) -> bool {
|
||||
let name = name.to_lowercase();
|
||||
if STRONG_KEYWORDS.iter().any(|k| name.contains(k))
|
||||
|| NAME_ONLY_KEYWORDS.iter().any(|k| name.contains(k))
|
||||
|| has_token(&name, NAME_TOKEN_KEYWORDS)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
description
|
||||
.map(str::to_lowercase)
|
||||
.is_some_and(|d| STRONG_KEYWORDS.iter().any(|k| d.contains(k)))
|
||||
}
|
||||
|
||||
/// Classify an extension from its id, manifest signals and self-description.
|
||||
///
|
||||
/// Two different questions are answered here, and fusing them is what made an
|
||||
/// ordinary download manager get reported as a VPN. Chromium has no read-only
|
||||
/// variant of the `proxy` permission: `chrome.proxy.settings.get()` and
|
||||
/// `.set()` sit behind the same manifest string, so an extension replicating
|
||||
/// the browser's proxy for its own transfers declares exactly what a VPN
|
||||
/// hijacking it declares. The permission therefore proves a *capability* and
|
||||
/// nothing more; naming something a VPN needs separate evidence — a known id,
|
||||
/// or the extension saying so itself.
|
||||
///
|
||||
/// The request-blocking tier's keyword requirement is not optional either:
|
||||
/// `declarativeNetRequest` plus `<all_urls>` describes every content blocker in
|
||||
/// the ecosystem, so without it the warning fires on uBlock Origin — which
|
||||
/// would teach users to dismiss the dialog on sight, destroying the value of
|
||||
/// the mismatch block that shares it.
|
||||
///
|
||||
/// An `optional_permissions` entry the user has never granted is deliberately
|
||||
/// not a capability at all: the extension cannot call `chrome.proxy` until it
|
||||
/// asks and is allowed.
|
||||
pub fn classify(
|
||||
extension_id: Option<&str>,
|
||||
signals: &ManifestSignals,
|
||||
keyword: bool,
|
||||
) -> Option<&'static str> {
|
||||
if extension_id.is_some_and(is_known_vpn_extension) {
|
||||
return Some("confirmed");
|
||||
}
|
||||
if keyword {
|
||||
if signals.proxy_permission {
|
||||
return Some("confirmed");
|
||||
}
|
||||
if signals.optional_proxy_permission
|
||||
|| ((signals.declarative_net_request || signals.web_request_blocking)
|
||||
&& signals.broad_host_permissions)
|
||||
{
|
||||
return Some("likely");
|
||||
}
|
||||
}
|
||||
if signals.proxy_permission {
|
||||
return Some("capability");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn signal_labels(
|
||||
extension_id: Option<&str>,
|
||||
signals: &ManifestSignals,
|
||||
keyword: bool,
|
||||
) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if extension_id.is_some_and(is_known_vpn_extension) {
|
||||
out.push("knownVpnExtension".to_string());
|
||||
}
|
||||
if signals.proxy_permission {
|
||||
out.push("permissions:proxy".to_string());
|
||||
}
|
||||
if signals.optional_proxy_permission {
|
||||
out.push("optionalPermissions:proxy".to_string());
|
||||
}
|
||||
if signals.declarative_net_request {
|
||||
out.push("declarativeNetRequest".to_string());
|
||||
}
|
||||
if signals.web_request_blocking {
|
||||
out.push("webRequestBlocking".to_string());
|
||||
}
|
||||
if signals.broad_host_permissions {
|
||||
out.push("broadHostPermissions".to_string());
|
||||
}
|
||||
if keyword {
|
||||
out.push("keyword".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `__MSG_someKey__` -> `someKey`.
|
||||
pub fn message_placeholder_key(value: &str) -> Option<String> {
|
||||
value
|
||||
.strip_prefix("__MSG_")
|
||||
.and_then(|rest| rest.strip_suffix("__"))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Chromium's `messages.json` shape: `{ "key": { "message": "..." } }`, with
|
||||
/// keys compared case-insensitively.
|
||||
pub fn lookup_message(messages: &serde_json::Value, key: &str) -> Option<String> {
|
||||
let obj = messages.as_object()?;
|
||||
obj
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||
.and_then(|(_, v)| v.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn manifest_str(manifest: &serde_json::Value, key: &str) -> Option<String> {
|
||||
manifest
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Sort key for an extension version directory (`1.10.0_0`), compared
|
||||
/// numerically so `1.10.0` sorts above `1.9.0` where a lexicographic compare
|
||||
/// would put it below.
|
||||
pub fn version_dir_sort_key(name: &str) -> Vec<u64> {
|
||||
name
|
||||
.split(['.', '_'])
|
||||
.map(|part| part.parse::<u64>().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn signals_of(manifest: serde_json::Value) -> ManifestSignals {
|
||||
signals_from_manifest(&manifest)
|
||||
}
|
||||
|
||||
fn classify_named(
|
||||
manifest: serde_json::Value,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Option<&'static str> {
|
||||
let s = signals_of(manifest);
|
||||
classify(None, &s, vpn_keyword_hit(name, description))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_confirms_a_self_described_vpn_holding_the_proxy_permission() {
|
||||
let s = signals_of(json!({ "permissions": ["proxy", "storage"] }));
|
||||
assert!(s.proxy_permission);
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
|
||||
Some("confirmed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_confirms_proxy_permission_in_mv2() {
|
||||
// `proxy` is an API permission, so MV3's host_permissions split does not
|
||||
// move it — the same key works for both manifest versions.
|
||||
let s = signals_of(json!({
|
||||
"manifest_version": 2,
|
||||
"permissions": ["proxy", "<all_urls>", "webRequest"]
|
||||
}));
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Hoxx VPN Proxy", None)),
|
||||
Some("confirmed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_download_manager_is_reported_as_a_capability_never_as_a_vpn() {
|
||||
// The bug this whole split exists for. IDM Integration Module declares
|
||||
// `proxy` so the desktop binary can replicate the browser's route for a
|
||||
// handed-off download, and says nothing about VPNs anywhere. Verified
|
||||
// against the real published manifest.
|
||||
let verdict = classify_named(
|
||||
json!({
|
||||
"permissions": [
|
||||
"scripting", "tabs", "cookies", "contextMenus", "webNavigation",
|
||||
"webRequest", "declarativeNetRequest", "downloads", "downloads.shelf",
|
||||
"downloads.ui", "management", "storage", "proxy", "nativeMessaging"
|
||||
]
|
||||
}),
|
||||
"IDM Integration Module",
|
||||
Some("Download files with Internet Download Manager"),
|
||||
);
|
||||
assert_eq!(verdict, Some("capability"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_known_vpn_is_confirmed_from_its_id_alone() {
|
||||
// Hotspot Shield's name contains no keyword at all, so without the id list
|
||||
// the biggest VPN in the store would be indistinguishable from a download
|
||||
// manager.
|
||||
let s = signals_of(json!({ "permissions": ["proxy"] }));
|
||||
let id = "nlbejmccbhkncgokjcmghpfloaajcffj";
|
||||
assert_eq!(
|
||||
classify(Some(id), &s, vpn_keyword_hit("Hotspot Shield", None)),
|
||||
Some("confirmed")
|
||||
);
|
||||
assert!(signal_labels(Some(id), &s, false).contains(&"knownVpnExtension".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_known_vpn_id_list_is_sorted_and_well_formed() {
|
||||
// Membership is a binary search, so an unsorted entry is silently missed.
|
||||
assert!(KNOWN_VPN_EXTENSION_IDS.windows(2).all(|w| w[0] < w[1]));
|
||||
for id in KNOWN_VPN_EXTENSION_IDS {
|
||||
assert_eq!(id.len(), 32, "{id} is not a Chrome extension id");
|
||||
assert!(
|
||||
id.bytes().all(|b| (b'a'..=b'p').contains(&b)),
|
||||
"{id} is not a Chrome extension id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_ignores_content_blocker() {
|
||||
// The regression guard: a content blocker declares exactly these and is
|
||||
// not a VPN. Firing here would train users to dismiss the dialog.
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["<all_urls>"]
|
||||
}));
|
||||
assert!(s.declarative_net_request && s.broad_host_permissions);
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("uBlock Origin", None)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_likely_on_dnr_plus_keyword() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["<all_urls>"]
|
||||
}));
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Free VPN Proxy", None)),
|
||||
Some("likely")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_likely_on_optional_proxy_plus_keyword() {
|
||||
// Optional and ungranted is not a capability, so it only matters when the
|
||||
// extension also says what it is.
|
||||
let s = signals_of(json!({ "optional_permissions": ["proxy"] }));
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Some VPN", None)),
|
||||
Some("likely")
|
||||
);
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Request Interceptor", None)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_ignores_keyword_only() {
|
||||
// A name alone proves nothing; without a capability signal this is noise.
|
||||
let s = signals_of(json!({ "permissions": ["storage"] }));
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("VPN Deals Finder", None)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_requires_broad_hosts_for_the_blocking_tier() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["https://example.com/*"]
|
||||
}));
|
||||
assert_eq!(classify(None, &s, vpn_keyword_hit("Some VPN", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_hosts_detected_from_split_http_and_https() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["webRequest", "webRequestBlocking"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"]
|
||||
}));
|
||||
assert!(s.broad_host_permissions);
|
||||
assert!(s.web_request_blocking);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_hosts_detected_from_mv2_permissions_array() {
|
||||
// MV2 puts host patterns in `permissions`; the split-out key is absent.
|
||||
let s = signals_of(json!({
|
||||
"manifest_version": 2,
|
||||
"permissions": ["webRequest", "webRequestBlocking", "<all_urls>"]
|
||||
}));
|
||||
assert!(s.broad_host_permissions);
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
|
||||
Some("likely")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_matching_reads_the_name_broadly_and_the_description_narrowly() {
|
||||
assert!(vpn_keyword_hit("TouchVPN", None));
|
||||
assert!(vpn_keyword_hit("Unblock Sites", None));
|
||||
assert!(vpn_keyword_hit("Shadowsocks Client", None));
|
||||
// Whole-token terms must not match inside longer words. "socks" in a name
|
||||
// is the protocol often enough to keep; "tussocks" and "tunnelling" are
|
||||
// exactly why it cannot be a substring.
|
||||
assert!(vpn_keyword_hit("SOCKS5 Configurator", None));
|
||||
assert!(!vpn_keyword_hit("Tussocks Field Guide", None));
|
||||
assert!(!vpn_keyword_hit("Tunnelling Contractors CRM", None));
|
||||
|
||||
// A description says "VPN" only when it means one...
|
||||
assert!(vpn_keyword_hit(
|
||||
"Anything",
|
||||
Some("a free VPN for your browser")
|
||||
));
|
||||
// ...but these three are ordinary English and must not promote anything.
|
||||
assert!(!vpn_keyword_hit(
|
||||
"Requestly",
|
||||
Some("Modify HTTP requests, no proxy setup required")
|
||||
));
|
||||
assert!(!vpn_keyword_hit(
|
||||
"Stretch Reminder",
|
||||
Some("Avoid carpal tunnel syndrome while you work")
|
||||
));
|
||||
assert!(!vpn_keyword_hit(
|
||||
"Absolute Right Click",
|
||||
Some("Unblock right click and text selection on any site")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_placeholder_round_trip() {
|
||||
assert_eq!(
|
||||
message_placeholder_key("__MSG_appName__").as_deref(),
|
||||
Some("appName")
|
||||
);
|
||||
assert_eq!(message_placeholder_key("Plain Name"), None);
|
||||
let messages = json!({ "appName": { "message": "Nord VPN" } });
|
||||
assert_eq!(
|
||||
lookup_message(&messages, "appName").as_deref(),
|
||||
Some("Nord VPN")
|
||||
);
|
||||
// Chromium compares message keys case-insensitively.
|
||||
assert_eq!(
|
||||
lookup_message(&messages, "APPNAME").as_deref(),
|
||||
Some("Nord VPN")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_dirs_sort_numerically_not_lexicographically() {
|
||||
let mut dirs = ["1.9.0_0", "1.10.0_0", "1.2.0_0"];
|
||||
dirs.sort_by_key(|d| version_dir_sort_key(d));
|
||||
assert_eq!(dirs.last(), Some(&"1.10.0_0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_manifest_yields_no_signals() {
|
||||
// Arrays of non-strings, wrong types, and missing keys must not panic.
|
||||
let s = signals_of(json!({ "permissions": [1, 2, {"a": "b"}], "host_permissions": "nope" }));
|
||||
assert_eq!(s, ManifestSignals::default());
|
||||
assert_eq!(classify(None, &s, true), None);
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,50 @@ async fn wait_for_vpn_worker_ready(
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes worker startup for a given VPN, so two concurrent launches cannot
|
||||
/// both observe "no worker" and both believe they created it. Benign until a
|
||||
/// launch guard may stop one on failure; then double-ownership means a
|
||||
/// cancelled launch tears down a tunnel another profile is using. Mirrors
|
||||
/// `xray_worker_runner::XRAY_START_LOCK`.
|
||||
static VPN_START_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// A started VPN worker plus whether *this* call spawned it.
|
||||
pub struct VpnWorkerStart {
|
||||
pub config: VpnWorkerConfig,
|
||||
/// False when an already-running worker was adopted. Only the creator may
|
||||
/// stop it while unwinding a failed launch.
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// Whether any profile with a live browser process is routing through this VPN.
|
||||
///
|
||||
/// Extracted from the startup sweep so the launch guard and the sweep agree on
|
||||
/// what "in use" means instead of each carrying its own copy.
|
||||
pub fn vpn_id_in_use_by_running_browser(vpn_id: &str) -> bool {
|
||||
let Ok(profiles) = crate::profile::ProfileManager::instance().list_profiles() else {
|
||||
// Unable to tell — assume in use rather than tear down a live tunnel.
|
||||
return true;
|
||||
};
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|p| p.process_id.is_some_and(is_process_running))
|
||||
.any(|p| p.vpn_id.as_deref() == Some(vpn_id))
|
||||
}
|
||||
|
||||
/// Hold the start lock across an adopt-sensitive section (a launch guard
|
||||
/// deciding whether to stop a worker it created).
|
||||
pub async fn lock_vpn_starts() -> tokio::sync::MutexGuard<'static, ()> {
|
||||
VPN_START_LOCK.lock().await
|
||||
}
|
||||
|
||||
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
|
||||
start_vpn_worker_tracked(vpn_id).await.map(|s| s.config)
|
||||
}
|
||||
|
||||
pub async fn start_vpn_worker_tracked(
|
||||
vpn_id: &str,
|
||||
) -> Result<VpnWorkerStart, Box<dyn std::error::Error>> {
|
||||
let _start_guard = VPN_START_LOCK.lock().await;
|
||||
crate::proxy_runner::ensure_sidecar_version().await?;
|
||||
|
||||
for config in list_vpn_worker_configs() {
|
||||
@@ -117,10 +160,18 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
if let Some(pid) = existing.pid {
|
||||
if is_process_running(pid) {
|
||||
if vpn_worker_accepting_connections(&existing).await {
|
||||
return Ok(existing);
|
||||
return Ok(VpnWorkerStart {
|
||||
config: existing,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
return wait_for_vpn_worker_ready(&existing.id).await;
|
||||
return wait_for_vpn_worker_ready(&existing.id)
|
||||
.await
|
||||
.map(|config| VpnWorkerStart {
|
||||
config,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Worker config exists but process is dead, clean up
|
||||
@@ -263,7 +314,12 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
drop(child);
|
||||
}
|
||||
|
||||
wait_for_vpn_worker_ready(&id).await
|
||||
wait_for_vpn_worker_ready(&id)
|
||||
.await
|
||||
.map(|config| VpnWorkerStart {
|
||||
config,
|
||||
created: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -721,12 +721,10 @@ impl WayfernManager {
|
||||
};
|
||||
|
||||
if key_path.exists() {
|
||||
let key_text = std::fs::read_to_string(&key_path).unwrap_or_default();
|
||||
log::info!(
|
||||
"Pre-launch: os_crypt_key present ({} bytes, content: '{}')",
|
||||
key_text.len(),
|
||||
key_text.trim()
|
||||
);
|
||||
// Length only. The contents are the profile's encryption key, and this
|
||||
// log is the first thing a user attaches to a bug report.
|
||||
let key_len = std::fs::metadata(&key_path).map(|m| m.len()).unwrap_or(0);
|
||||
log::info!("Pre-launch: os_crypt_key present ({key_len} bytes)");
|
||||
} else {
|
||||
log::warn!("Pre-launch: os_crypt_key NOT FOUND");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Client-side window decorations on Linux.
|
||||
//!
|
||||
//! The app draws its own titlebar (as it already does on macOS and Windows), so
|
||||
//! the window is built without server-side decorations. That means the app also
|
||||
//! owns the window *controls*, and their side and order are a desktop-wide user
|
||||
//! preference that differs between environments — GNOME defaults to
|
||||
//! `:minimize,maximize,close` (all on the right), and a user who has moved them
|
||||
//! to the left expects every app to follow.
|
||||
//!
|
||||
//! `GtkSettings::gtk-decoration-layout` is the one place every desktop
|
||||
//! publishes that preference to GTK applications: GNOME mirrors
|
||||
//! `org.gnome.desktop.wm.preferences button-layout` into it, and on KDE Plasma
|
||||
//! `kde-gtk-config` mirrors KWin's decoration button configuration into it.
|
||||
//! Reading this property therefore gets both environments right without any
|
||||
//! desktop-specific branching.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Whether this window draws its own titlebar, and how.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WindowDecorations {
|
||||
/// True when the app owns the titlebar and must draw controls and resize
|
||||
/// edges. False means the platform still draws a real titlebar and the
|
||||
/// frontend must render nothing.
|
||||
pub client_side: bool,
|
||||
/// The desktop's button layout, e.g. `":minimize,maximize,close"`. Only
|
||||
/// meaningful when `client_side` is true.
|
||||
pub layout: Option<String>,
|
||||
}
|
||||
|
||||
/// Whether to drop server-side decorations on this Linux session.
|
||||
///
|
||||
/// Enabled everywhere except KDE Plasma on Wayland, and overridable with
|
||||
/// `DONUT_LINUX_CLIENT_DECORATIONS=1|0`.
|
||||
///
|
||||
/// The KDE/Wayland exclusion is deliberate and is about a failure mode, not a
|
||||
/// preference. GTK3 speaks no `xdg-decoration`; when a window is built
|
||||
/// undecorated, GTK does not mark it client-decorated, and on Wayland it
|
||||
/// therefore *announces server-side decorations* to the compositor. mutter
|
||||
/// ignores that (it never decorates Wayland toplevels), which is why GNOME
|
||||
/// works. KWin honors it, so Plasma would be free to draw a Breeze titlebar
|
||||
/// directly above the one the app draws — two titlebars, worse than the
|
||||
/// feature is good. Whether it actually does depends on the decoration mode
|
||||
/// KWin advertises, which could not be established from documentation and
|
||||
/// cannot be tested from here, so this stays off until somebody can run it.
|
||||
///
|
||||
/// KDE on X11 is *not* excluded: there the request travels as `_MOTIF_WM_HINTS`,
|
||||
/// which KWin has honored for as long as it has existed.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn use_client_side_decorations() -> bool {
|
||||
if let Ok(value) = std::env::var("DONUT_LINUX_CLIENT_DECORATIONS") {
|
||||
let forced = matches!(value.trim(), "1" | "true" | "yes");
|
||||
log::info!("Client-side decorations forced to {forced} by DONUT_LINUX_CLIENT_DECORATIONS");
|
||||
return forced;
|
||||
}
|
||||
|
||||
let env = |key: &str| std::env::var(key).unwrap_or_default().to_lowercase();
|
||||
|
||||
// GDK_BACKEND is a comma-separated preference list ("wayland,x11"), and GDK
|
||||
// takes the FIRST entry it can open. Testing for a substring would read
|
||||
// "wayland,x11" as X11 and hand a Plasma Wayland session the undecorated
|
||||
// path this guard exists to withhold.
|
||||
let backend = env("GDK_BACKEND");
|
||||
let preferred = backend
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty());
|
||||
let on_wayland = match preferred {
|
||||
Some("x11") => false,
|
||||
Some("wayland") => true,
|
||||
// Unset or something exotic: fall back to what the session advertises.
|
||||
_ => {
|
||||
!std::env::var("WAYLAND_DISPLAY")
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
&& env("XDG_SESSION_TYPE") != "x11"
|
||||
}
|
||||
};
|
||||
|
||||
let on_kde = env("XDG_CURRENT_DESKTOP").contains("kde")
|
||||
|| env("XDG_SESSION_DESKTOP").contains("plasma")
|
||||
|| env("DESKTOP_SESSION").contains("plasma")
|
||||
|| !std::env::var("KDE_FULL_SESSION")
|
||||
.unwrap_or_default()
|
||||
.is_empty();
|
||||
|
||||
if on_kde && on_wayland {
|
||||
log::info!(
|
||||
"Keeping server-side decorations: KWin on Wayland may draw its own titlebar over the \
|
||||
app's. Set DONUT_LINUX_CLIENT_DECORATIONS=1 to override."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod imp {
|
||||
use std::sync::Mutex;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref LAYOUT: Mutex<Option<String>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
fn store(layout: Option<String>) {
|
||||
if let Ok(mut slot) = LAYOUT.lock() {
|
||||
*slot = layout;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cached() -> Option<String> {
|
||||
LAYOUT.lock().ok().and_then(|slot| slot.clone())
|
||||
}
|
||||
|
||||
/// Read the layout and subscribe to changes.
|
||||
///
|
||||
/// MUST be called on the GTK main thread — `gtk::Settings::default()` panics
|
||||
/// elsewhere, and the notify subscription has to be attached from the thread
|
||||
/// owning the GTK main context. The app's `setup` hook already runs there.
|
||||
pub fn init<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
|
||||
use gtk::prelude::*;
|
||||
use tauri::Emitter;
|
||||
|
||||
let Some(settings) = gtk::Settings::default() else {
|
||||
log::warn!("No GTK settings available; using the default decoration layout");
|
||||
return;
|
||||
};
|
||||
|
||||
store(settings.gtk_decoration_layout().map(|v| v.to_string()));
|
||||
log::info!(
|
||||
"Window decoration layout: {}",
|
||||
cached().as_deref().unwrap_or("<unset>")
|
||||
);
|
||||
|
||||
// The user can rearrange titlebar buttons while the app is running, and
|
||||
// every other application follows immediately. Note this never fires where
|
||||
// the value comes only from gtk-3.0/settings.ini (a KDE X11 box with no
|
||||
// xsettings daemon) — there it is simply static until restart.
|
||||
let handle = app.clone();
|
||||
settings.connect_gtk_decoration_layout_notify(move |settings| {
|
||||
let layout = settings.gtk_decoration_layout().map(|v| v.to_string());
|
||||
log::info!(
|
||||
"Window decoration layout changed to: {}",
|
||||
layout.as_deref().unwrap_or("<unset>")
|
||||
);
|
||||
store(layout.clone());
|
||||
if let Err(e) = handle.emit("window-decoration-layout-changed", layout) {
|
||||
log::warn!("Failed to emit window decoration layout change: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod imp {
|
||||
pub fn init<R: tauri::Runtime>(_app: &tauri::AppHandle<R>) {}
|
||||
}
|
||||
|
||||
pub use imp::init;
|
||||
|
||||
/// How this window is decorated, and the desktop's button layout when the app
|
||||
/// owns the titlebar.
|
||||
#[tauri::command]
|
||||
pub fn get_window_decoration_layout() -> WindowDecorations {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let client_side = use_client_side_decorations();
|
||||
WindowDecorations {
|
||||
client_side,
|
||||
layout: if client_side { imp::cached() } else { None },
|
||||
}
|
||||
}
|
||||
// Every other platform keeps a real titlebar: macOS makes the native one
|
||||
// transparent, Windows draws its own controls on a fixed layout.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
WindowDecorations {
|
||||
client_side: false,
|
||||
layout: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,42 @@ pub enum XrayError {
|
||||
#[error("failed to serialize Xray client configuration")]
|
||||
Serialization,
|
||||
}
|
||||
|
||||
impl XrayError {
|
||||
/// A stable, translatable identifier for *why* a URI was rejected.
|
||||
///
|
||||
/// Donut supports one VLESS shape — REALITY + XTLS Vision over TCP — so most
|
||||
/// rejections are "your setup is a kind we do not support", not "you made a
|
||||
/// typo". The frontend turns these into a sentence naming the unsupported
|
||||
/// part; without them every rejection reads as a malformed URI and a user
|
||||
/// with a working WebSocket or plain-TLS server has no idea why it failed.
|
||||
pub fn reason_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UnsupportedScheme => "scheme",
|
||||
Self::UnsupportedValue { field, .. } | Self::InvalidField { field, .. } => match *field {
|
||||
"security" => "security",
|
||||
"flow" => "flow",
|
||||
"type" => "transport",
|
||||
"encryption" => "encryption",
|
||||
"headerType" => "headerType",
|
||||
"fp" => "fingerprint",
|
||||
// A malformed sni/public key is the same user-facing problem as a
|
||||
// missing one, so it earns the same specific help rather than the
|
||||
// generic "invalid URI".
|
||||
"sni" | "server_name" => "sni",
|
||||
"pbk" | "public_key" => "publicKey",
|
||||
_ => "malformed",
|
||||
},
|
||||
Self::MissingField(field) => match *field {
|
||||
"sni" => "sni",
|
||||
"pbk" => "publicKey",
|
||||
"security" => "security",
|
||||
"flow" => "flow",
|
||||
_ => "malformed",
|
||||
},
|
||||
Self::UnsupportedParameter(_) => "parameter",
|
||||
Self::DuplicateParameter(_) => "malformed",
|
||||
Self::InvalidUri | Self::Serialization => "malformed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-8
@@ -61,10 +61,10 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
|
||||
};
|
||||
let port = url.port().ok_or(XrayError::MissingField("port"))?;
|
||||
|
||||
let parameters = parse_parameters(&url)?;
|
||||
require_value(¶meters, "security", "reality")?;
|
||||
require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
|
||||
optional_value(¶meters, "encryption", "none")?;
|
||||
let (parameters, unsupported) = parse_parameters(&url)?;
|
||||
|
||||
// Transport first: it is the most common reason a real-world VLESS server is
|
||||
// unusable here, and it explains the stray parameters that come with it.
|
||||
match parameters.get("type").map(String::as_str) {
|
||||
None | Some("tcp" | "raw") => {}
|
||||
Some(_) => {
|
||||
@@ -74,8 +74,17 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
|
||||
});
|
||||
}
|
||||
}
|
||||
require_value(¶meters, "security", "reality")?;
|
||||
require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
|
||||
optional_value(¶meters, "encryption", "none")?;
|
||||
optional_value(¶meters, "headerType", "none")?;
|
||||
|
||||
// Only once the shape is known-good does an unrecognized parameter become
|
||||
// the most useful thing to report.
|
||||
if let Some(name) = unsupported.into_iter().next() {
|
||||
return Err(XrayError::UnsupportedParameter(name));
|
||||
}
|
||||
|
||||
let server_name = required_parameter(¶meters, "sni")?.to_string();
|
||||
let public_key = required_parameter(¶meters, "pbk")?.to_string();
|
||||
let short_id = parameters.get("sid").cloned().unwrap_or_default();
|
||||
@@ -162,15 +171,28 @@ pub fn export_vless_uri(config: &VlessRealityConfig, name: Option<&str>) -> Xray
|
||||
query.append_pair("type", "tcp");
|
||||
query.append_pair("headerType", "none");
|
||||
}
|
||||
url.set_fragment(name);
|
||||
// The parser percent-DECODES the fragment, so the exporter must encode it or
|
||||
// a name containing `%` (or `#`) comes back different every time the URI is
|
||||
// canonicalized — the name mutates a little more on each save.
|
||||
let encoded_name = name.map(|value| urlencoding::encode(value).into_owned());
|
||||
url.set_fragment(encoded_name.as_deref());
|
||||
Ok(url.into())
|
||||
}
|
||||
|
||||
fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
|
||||
/// Split the query into recognized parameters and the names of the rest.
|
||||
///
|
||||
/// Unrecognized names are returned rather than rejected on the spot so the
|
||||
/// caller can report the *shape* problem first. A WebSocket URI always carries
|
||||
/// `path` (and usually `host`), gRPC carries `serviceName` — naming those keys
|
||||
/// instead of the transport sends the user deleting parameters when the real
|
||||
/// answer is that Donut only speaks plain TCP.
|
||||
fn parse_parameters(url: &Url) -> XrayResult<(HashMap<String, String>, Vec<String>)> {
|
||||
let mut parameters = HashMap::new();
|
||||
let mut unsupported = Vec::new();
|
||||
for (name, value) in url.query_pairs() {
|
||||
if !SUPPORTED_PARAMETERS.contains(&name.as_ref()) {
|
||||
return Err(XrayError::UnsupportedParameter(name.into_owned()));
|
||||
unsupported.push(name.into_owned());
|
||||
continue;
|
||||
}
|
||||
if parameters
|
||||
.insert(name.to_string(), value.into_owned())
|
||||
@@ -179,7 +201,7 @@ fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
|
||||
return Err(XrayError::DuplicateParameter(name.into_owned()));
|
||||
}
|
||||
}
|
||||
Ok(parameters)
|
||||
Ok((parameters, unsupported))
|
||||
}
|
||||
|
||||
fn required_parameter<'a>(
|
||||
@@ -230,6 +252,109 @@ mod tests {
|
||||
|
||||
const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
|
||||
|
||||
/// Donut accepts exactly one VLESS shape, so most rejections mean "your
|
||||
/// server is a kind we do not support" rather than "you mistyped". These pin
|
||||
/// the reason each rejection reports, because the UI turns it into the one
|
||||
/// sentence that tells a user with a working WebSocket or plain-TLS server
|
||||
/// why Donut will not take it.
|
||||
#[test]
|
||||
fn unsupported_setups_report_which_part_is_unsupported() {
|
||||
let good = format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome"
|
||||
);
|
||||
assert!(parse_vless_uri(&good).is_ok(), "baseline URI must parse");
|
||||
|
||||
let reason = |uri: &str| parse_vless_uri(uri).unwrap_err().reason_code();
|
||||
|
||||
// Plain TLS instead of REALITY — the most common real-world setup.
|
||||
assert_eq!(
|
||||
reason(&good.replace("security=reality", "security=tls")),
|
||||
"security"
|
||||
);
|
||||
assert_eq!(
|
||||
reason(&good.replace("flow=xtls-rprx-vision", "flow=none")),
|
||||
"flow"
|
||||
);
|
||||
// WebSocket / gRPC transports.
|
||||
assert_eq!(reason(&good.replace("type=tcp", "type=ws")), "transport");
|
||||
assert_eq!(reason(&good.replace("type=tcp", "type=grpc")), "transport");
|
||||
assert_eq!(reason(&good.replace("&sni=a.com", "")), "sni");
|
||||
assert_eq!(
|
||||
reason(&good.replace("&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4", "")),
|
||||
"publicKey"
|
||||
);
|
||||
assert_eq!(reason(&good.replace("vless://", "vmess://")), "scheme");
|
||||
assert_eq!(reason("not a uri"), "malformed");
|
||||
}
|
||||
|
||||
/// The URIs users actually paste, not canonical-REALITY-with-one-field-changed.
|
||||
///
|
||||
/// A real WebSocket link carries `path` (and usually `host`); a gRPC link
|
||||
/// carries `serviceName`. Those keys are not in SUPPORTED_PARAMETERS, so
|
||||
/// before the shape was checked first they produced "unsupported option"
|
||||
/// and sent the user deleting query parameters instead of telling them
|
||||
/// Donut only speaks plain TCP.
|
||||
#[test]
|
||||
fn a_display_name_survives_an_export_parse_round_trip() {
|
||||
// Percent signs are legal in a fragment, so they used to pass through
|
||||
// unencoded and then get decoded on the way back in — "50% off" became
|
||||
// "50 off"-ish and drifted further on every canonicalizing save.
|
||||
for name in ["50% off", "a#b", "spaced name", "100%25", "üñî"] {
|
||||
let parsed = parse_vless_uri(&format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
|
||||
))
|
||||
.expect("baseline parses");
|
||||
|
||||
let exported = export_vless_uri(&parsed.config, Some(name)).expect("exports");
|
||||
let reparsed = parse_vless_uri(&exported).expect("re-parses");
|
||||
assert_eq!(
|
||||
reparsed.name.as_deref(),
|
||||
Some(name),
|
||||
"display name mutated across a round trip: {exported}"
|
||||
);
|
||||
|
||||
// And a second round trip must be a fixed point, not drift again.
|
||||
let exported_again =
|
||||
export_vless_uri(&reparsed.config, reparsed.name.as_deref()).expect("re-exports");
|
||||
assert_eq!(exported, exported_again);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_world_websocket_and_grpc_links_name_the_transport() {
|
||||
let ws = format!(
|
||||
"vless://{ID}@cdn.example.com:443?encryption=none&security=tls&type=ws\
|
||||
&path=%2Fray&host=cdn.example.com&sni=cdn.example.com#WS%20node"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&ws).unwrap_err().reason_code(),
|
||||
"transport",
|
||||
"a WebSocket link must be told its transport is unsupported"
|
||||
);
|
||||
|
||||
let grpc = format!(
|
||||
"vless://{ID}@grpc.example.com:443?encryption=none&security=reality&type=grpc\
|
||||
&serviceName=gun&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&grpc).unwrap_err().reason_code(),
|
||||
"transport"
|
||||
);
|
||||
|
||||
// A genuinely unknown option on an otherwise-supported URI still reports
|
||||
// as a parameter problem, which is the accurate answer there.
|
||||
let odd = format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&madeUpKey=1"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&odd).unwrap_err().reason_code(),
|
||||
"parameter"
|
||||
);
|
||||
}
|
||||
|
||||
fn public_key() -> String {
|
||||
URL_SAFE_NO_PAD.encode([7_u8; 32])
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ pub async fn start_xray_worker(
|
||||
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
|
||||
let _start_guard = XRAY_START_LOCK.lock().await;
|
||||
parse_vless_uri(vless_uri)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
crate::proxy_runner::ensure_sidecar_version().await?;
|
||||
ensure_xray_binary()?;
|
||||
let owner_pid = std::process::id();
|
||||
@@ -521,14 +521,14 @@ pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box<dyn std::erro
|
||||
save_xray_worker_config_to_path(&config, config_path)
|
||||
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
|
||||
let parsed = parse_vless_uri(&config.vless_uri)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
let runtime = XrayClientRuntime {
|
||||
listen_port: config.local_port,
|
||||
username: config.username.clone(),
|
||||
password: config.password.clone(),
|
||||
};
|
||||
let runtime_json = build_client_config_json(&parsed.config, &runtime)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
write_xray_runtime_config(&config.id, runtime_json.as_bytes())
|
||||
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
|
||||
let runtime_path = crate::xray_worker_storage::xray_runtime_config_path(&config.id);
|
||||
|
||||
@@ -182,6 +182,39 @@ fn worker_is_tombstoned(id: &str) -> bool {
|
||||
xray_worker_tombstone_path(id).exists()
|
||||
}
|
||||
|
||||
/// How long a tombstone has to outlive its worker.
|
||||
///
|
||||
/// It only has to survive long enough to beat a write already in flight from
|
||||
/// the process that owned that id. A day is many orders of magnitude more than
|
||||
/// that, and bounds a directory that otherwise gains a file per worker forever.
|
||||
const TOMBSTONE_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Drop tombstones old enough that nothing could still be racing them.
|
||||
fn prune_stale_tombstones() {
|
||||
let Ok(entries) = fs::read_dir(crate::proxy_storage::get_storage_dir()) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("stopped") {
|
||||
continue;
|
||||
}
|
||||
let aged_out = path
|
||||
.metadata()
|
||||
.and_then(|meta| meta.modified())
|
||||
.map(|modified| {
|
||||
modified
|
||||
.elapsed()
|
||||
.map(|age| age > TOMBSTONE_TTL)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if aged_out {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_xray_worker_log(id: &str) -> std::io::Result<std::fs::File> {
|
||||
ensure_private_storage_dir()?;
|
||||
if worker_is_tombstoned(id) {
|
||||
@@ -275,6 +308,8 @@ pub fn delete_xray_worker_config(id: &str) -> bool {
|
||||
}
|
||||
|
||||
pub fn list_xray_worker_configs() -> Vec<XrayWorkerConfig> {
|
||||
// Cheap, and this is the one call every sweep already makes.
|
||||
prune_stale_tombstones();
|
||||
let storage_dir = crate::proxy_storage::get_storage_dir();
|
||||
let Ok(entries) = fs::read_dir(storage_dir) else {
|
||||
return Vec::new();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.28.2",
|
||||
"version": "0.29.2",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
@@ -464,15 +464,10 @@ async fn test_local_proxy_direct() -> Result<(), Box<dyn std::error::Error + Sen
|
||||
);
|
||||
|
||||
// Verify proxy is listening
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
match TcpStream::connect(("127.0.0.1", local_port)).await {
|
||||
Ok(_) => {
|
||||
println!("Proxy is listening on port {local_port}");
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Proxy port {local_port} is not listening: {e}").into());
|
||||
}
|
||||
if !wait_for_port_open(local_port, Duration::from_secs(10)).await {
|
||||
return Err(format!("Proxy port {local_port} is not listening").into());
|
||||
}
|
||||
println!("Proxy is listening on port {local_port}");
|
||||
|
||||
// Test making an HTTP request through the proxy
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
@@ -524,11 +519,10 @@ async fn test_chained_local_proxies() -> Result<(), Box<dyn std::error::Error +
|
||||
println!("First proxy started on port {}", proxy1_port);
|
||||
|
||||
// Wait for first proxy to be ready
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
match TcpStream::connect(("127.0.0.1", proxy1_port)).await {
|
||||
Ok(_) => println!("First proxy is ready"),
|
||||
Err(e) => return Err(format!("First proxy not ready: {e}").into()),
|
||||
if !wait_for_port_open(proxy1_port, Duration::from_secs(10)).await {
|
||||
return Err("First proxy not ready".into());
|
||||
}
|
||||
println!("First proxy is ready");
|
||||
|
||||
// Start second proxy chained to first proxy
|
||||
let output2 = TestUtils::execute_command(
|
||||
@@ -565,11 +559,10 @@ async fn test_chained_local_proxies() -> Result<(), Box<dyn std::error::Error +
|
||||
);
|
||||
|
||||
// Wait for second proxy to be ready
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
match TcpStream::connect(("127.0.0.1", proxy2_port)).await {
|
||||
Ok(_) => println!("Second proxy is ready"),
|
||||
Err(e) => return Err(format!("Second proxy not ready: {e}").into()),
|
||||
if !wait_for_port_open(proxy2_port, Duration::from_secs(10)).await {
|
||||
return Err("Second proxy not ready".into());
|
||||
}
|
||||
println!("Second proxy is ready");
|
||||
|
||||
// Test making an HTTP request through the chained proxy
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", proxy2_port)).await?;
|
||||
@@ -669,16 +662,11 @@ async fn test_local_proxy_with_http_upstream(
|
||||
println!("Proxy started: id={}, port={}", proxy_id, local_port);
|
||||
|
||||
// Verify proxy is listening
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
match TcpStream::connect(("127.0.0.1", local_port)).await {
|
||||
Ok(_) => {
|
||||
println!("Proxy is listening on port {local_port}");
|
||||
}
|
||||
Err(e) => {
|
||||
upstream_handle.abort();
|
||||
return Err(format!("Proxy port {local_port} is not listening: {e}").into());
|
||||
}
|
||||
if !wait_for_port_open(local_port, Duration::from_secs(10)).await {
|
||||
upstream_handle.abort();
|
||||
return Err(format!("Proxy port {local_port} is not listening").into());
|
||||
}
|
||||
println!("Proxy is listening on port {local_port}");
|
||||
|
||||
// Cleanup
|
||||
tracker.cleanup_all().await;
|
||||
@@ -955,11 +943,10 @@ async fn test_proxy_stop() -> Result<(), Box<dyn std::error::Error + Send + Sync
|
||||
let local_port = config["localPort"].as_u64().unwrap() as u16;
|
||||
|
||||
// Verify proxy is running
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
match TcpStream::connect(("127.0.0.1", local_port)).await {
|
||||
Ok(_) => println!("Proxy is running"),
|
||||
Err(_) => return Err("Proxy is not running".into()),
|
||||
if !wait_for_port_open(local_port, Duration::from_secs(10)).await {
|
||||
return Err("Proxy is not running".into());
|
||||
}
|
||||
println!("Proxy is running");
|
||||
|
||||
// Stop the proxy
|
||||
let stop_output =
|
||||
@@ -969,14 +956,11 @@ async fn test_proxy_stop() -> Result<(), Box<dyn std::error::Error + Send + Sync
|
||||
return Err("Failed to stop proxy".into());
|
||||
}
|
||||
|
||||
// Wait a bit for the process to exit
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Verify proxy is stopped (connection should fail)
|
||||
match TcpStream::connect(("127.0.0.1", local_port)).await {
|
||||
Ok(_) => return Err("Proxy should be stopped but is still listening".into()),
|
||||
Err(_) => println!("Proxy successfully stopped"),
|
||||
if !wait_for_port_closed(local_port, Duration::from_secs(10)).await {
|
||||
return Err("Proxy should be stopped but is still listening".into());
|
||||
}
|
||||
println!("Proxy successfully stopped");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -1785,6 +1769,39 @@ impl Drop for StubBrowser {
|
||||
}
|
||||
}
|
||||
|
||||
/// Wait for a port to start accepting connections.
|
||||
///
|
||||
/// `proxy start` returns once the worker is spawned, not once it has bound its
|
||||
/// listener, so a fixed sleep is a bet on how fast the runner is. Poll instead.
|
||||
async fn wait_for_port_open(port: u16, timeout: Duration) -> bool {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
while std::time::Instant::now() < deadline {
|
||||
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
|
||||
return true;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Wait for a listening port to stop accepting connections.
|
||||
///
|
||||
/// `proxy stop` returns once the worker has been told to exit, not once it has
|
||||
/// actually gone, so the listener can outlive the command by however long the
|
||||
/// process takes to unwind. That gap is invisible on an idle laptop and lands
|
||||
/// squarely on a loaded CI runner, so poll to a deadline rather than sleeping a
|
||||
/// fixed amount and hoping. Returns false if it is still accepting at the end.
|
||||
async fn wait_for_port_closed(port: u16, timeout: Duration) -> bool {
|
||||
let deadline = std::time::Instant::now() + timeout;
|
||||
while std::time::Instant::now() < deadline {
|
||||
if TcpStream::connect(("127.0.0.1", port)).await.is_err() {
|
||||
return true;
|
||||
}
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Wait for a worker to remove its own config, which it does immediately before
|
||||
/// exiting. Returns false if it is still there when the deadline passes.
|
||||
async fn wait_for_worker_exit(proxy_id: &str, timeout: Duration) -> bool {
|
||||
|
||||
@@ -627,6 +627,37 @@ async fn cleanup_runtime() {
|
||||
test_harness::stop_vpn_servers().await;
|
||||
}
|
||||
|
||||
/// Request through the proxy until the tunnel behind it actually carries the
|
||||
/// traffic, or the deadline passes.
|
||||
///
|
||||
/// Returns the last response either way, so a genuine failure still asserts
|
||||
/// against the real body rather than a timeout message.
|
||||
async fn wait_for_tunnel(
|
||||
local_port: u16,
|
||||
url: &str,
|
||||
host_header: &str,
|
||||
timeout: Duration,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
|
||||
loop {
|
||||
let last = match raw_http_request_via_proxy(local_port, url, host_header).await {
|
||||
Ok(response) => {
|
||||
if response.contains("WG-TUNNEL-OK") {
|
||||
return Ok(response);
|
||||
}
|
||||
response
|
||||
}
|
||||
Err(e) => format!("request error: {e}"),
|
||||
};
|
||||
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
return Ok(last);
|
||||
}
|
||||
sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn wait_for_file(
|
||||
path: &std::path::Path,
|
||||
timeout: Duration,
|
||||
@@ -661,12 +692,22 @@ async fn run_proxy_feature_suite(
|
||||
let proxy =
|
||||
start_proxy_with_upstream(binary_path, &vpn_upstream, &[], None, Some(&profile_id)).await?;
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
let internal_url = format!("http://{}:8080/", server_tunnel_ip);
|
||||
let internal_host = format!("{}:8080", server_tunnel_ip);
|
||||
let http_response =
|
||||
raw_http_request_via_proxy(proxy.local_port, &internal_url, &internal_host).await?;
|
||||
|
||||
// The proxy answers as soon as it is listening, but the route behind it is
|
||||
// not ready until the WireGuard handshake completes and the in-tunnel server
|
||||
// accepts. A fixed sleep raced that on a loaded runner and came back
|
||||
// `502 Bad Gateway`, which is the tunnel not being up yet rather than
|
||||
// anything under test being wrong. Poll to a deadline instead, the same way
|
||||
// `wait_for_file` does below.
|
||||
let http_response = wait_for_tunnel(
|
||||
proxy.local_port,
|
||||
&internal_url,
|
||||
&internal_host,
|
||||
Duration::from_secs(20),
|
||||
)
|
||||
.await?;
|
||||
assert!(
|
||||
http_response.contains("WG-TUNNEL-OK"),
|
||||
"HTTP traffic through donut-proxy+VPN tunnel should succeed, got: {}",
|
||||
|
||||
+365
-44
@@ -13,11 +13,6 @@ import { CloneProfileDialog } from "@/components/clone-profile-dialog";
|
||||
import { CloseConfirmDialog } from "@/components/close-confirm-dialog";
|
||||
import { CommandPalette } from "@/components/command-palette";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import {
|
||||
type ConsistencyResult,
|
||||
ConsistencyWarningDialog,
|
||||
isConsistencyWarningSuppressed,
|
||||
} from "@/components/consistency-warning-dialog";
|
||||
import { CookieBotPage, type CookieBotTab } from "@/components/cookie-bot-page";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||
@@ -33,6 +28,11 @@ import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
||||
import { IntegrationsDialog } from "@/components/integrations-dialog";
|
||||
import { ONBOARDING_TOUR } from "@/components/onboarding-provider";
|
||||
import { PermissionDialog } from "@/components/permission-dialog";
|
||||
import {
|
||||
type GateDecision,
|
||||
type GateFindings,
|
||||
PreLaunchGateDialog,
|
||||
} from "@/components/pre-launch-gate-dialog";
|
||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||
import {
|
||||
type PasswordDialogMode,
|
||||
@@ -67,7 +67,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import { canUseCookieBot, getEntitlements } from "@/lib/entitlements";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
import {
|
||||
@@ -88,7 +88,42 @@ import {
|
||||
showSyncProgressToast,
|
||||
showToast,
|
||||
} from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, SyncSettings, WayfernConfig } from "@/types";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
ConsistencyResult,
|
||||
PreLaunchChecks,
|
||||
SyncSettings,
|
||||
WayfernConfig,
|
||||
} from "@/types";
|
||||
|
||||
type GateRequest = {
|
||||
profile: BrowserProfile;
|
||||
findings: GateFindings;
|
||||
};
|
||||
|
||||
type LaunchResult = {
|
||||
status: "launched" | "cancelled" | "blocked";
|
||||
};
|
||||
|
||||
/**
|
||||
* Rebuild the mismatch detail the gate dialog renders from a
|
||||
* FINGERPRINT_EXIT_MISMATCH error's params. Every param is a string, because
|
||||
* backend error params always are.
|
||||
*/
|
||||
function consistencyFromErrorParams(
|
||||
params?: Record<string, string>,
|
||||
): ConsistencyResult {
|
||||
return {
|
||||
consistent: false,
|
||||
checked: true,
|
||||
exit_ip: params?.exitIp || null,
|
||||
exit_country_code: params?.exitCountry || null,
|
||||
exit_timezone: params?.exitTimezone || null,
|
||||
fingerprint_timezone: params?.fingerprintTimezone || null,
|
||||
fingerprint_language: params?.fingerprintLanguage || null,
|
||||
mismatches: (params?.mismatches ?? "").split(",").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
type BrowserTypeString = "wayfern";
|
||||
|
||||
@@ -252,7 +287,7 @@ export default function Home() {
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const crossOsUnlocked = getEntitlements(cloudUser).crossOsFingerprints;
|
||||
// Bulk run/stop is a paid (browser automation) feature, matching the
|
||||
// /v1/profiles/batch/run API gate. Free/starter users see the bulk Run/Stop
|
||||
// /v1/profiles/batch/run API gate. Free/solo users see the bulk Run/Stop
|
||||
// actions disabled with a Pro badge.
|
||||
const automationUnlocked = getEntitlements(cloudUser).browserAutomation;
|
||||
// The rail needs to show a live run from every page, so the shell subscribes
|
||||
@@ -279,7 +314,12 @@ export default function Home() {
|
||||
}
|
||||
}, [cloudUser]);
|
||||
|
||||
const syncUnlocked = crossOsUnlocked || selfHostedSyncConfigured;
|
||||
// Cloud sync follows `cloudBackup`, NOT `crossOsFingerprints`. They agreed on
|
||||
// every plan until Solo, which buys 20 cloud backups and deliberately has no
|
||||
// fingerprint editing — so deriving sync from the fingerprint capability put a
|
||||
// Pro badge on the one feature a Solo customer is paying for.
|
||||
const cloudBackupUnlocked = getEntitlements(cloudUser).cloudBackup;
|
||||
const syncUnlocked = cloudBackupUnlocked || selfHostedSyncConfigured;
|
||||
|
||||
const [currentPage, setCurrentPage] = useState<AppPage>("profiles");
|
||||
const [accountDialogOpen, setAccountDialogOpen] = useState(false);
|
||||
@@ -365,10 +405,40 @@ export default function Home() {
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false);
|
||||
const [aboutDialogOpen, setAboutDialogOpen] = useState(false);
|
||||
const [consistencyWarning, setConsistencyWarning] = useState<{
|
||||
profile: BrowserProfile;
|
||||
result: ConsistencyResult;
|
||||
// Pre-launch gate. Requests queue instead of overwriting a single resolver:
|
||||
// a bulk run enqueues one per profile, and every waiter must settle or the
|
||||
// Promise.allSettled below it never resolves and the bulk spinner sticks.
|
||||
const gateQueueRef = useRef<
|
||||
Array<{
|
||||
id: number;
|
||||
req: GateRequest;
|
||||
/// The bulk run this request belongs to, or undefined for a single
|
||||
/// launch. Carried per entry so a blanket "apply to the rest" can only
|
||||
/// ever claim the run its own dialog came from.
|
||||
runId: number | undefined;
|
||||
resolve: (decision: GateDecision) => void;
|
||||
}>
|
||||
>([]);
|
||||
const gateRequestSeqRef = useRef(0);
|
||||
const [gateState, setGateState] = useState<{
|
||||
id: number;
|
||||
req: GateRequest;
|
||||
remaining: number;
|
||||
} | null>(null);
|
||||
// Set when the user ticks "apply to the remaining profiles" during a bulk
|
||||
// run, so the rest are answered without prompting again. Scoped to one bulk
|
||||
// run and to the severity it was given for.
|
||||
const blanketGateDecisionRef = useRef<{
|
||||
decision: GateDecision;
|
||||
/// Only auto-answers gates no more severe than the one the user saw. A
|
||||
/// choice made on an extension warning must never silently bypass a hard
|
||||
/// block on a later profile.
|
||||
coversBlocking: boolean;
|
||||
/// Identifies the bulk run, so a single launch started while a bulk run is
|
||||
/// in flight still gets its own dialog.
|
||||
runId: number;
|
||||
} | null>(null);
|
||||
const bulkRunIdRef = useRef(0);
|
||||
// Owned by page.tsx so the command palette can request opening the profile
|
||||
// info dialog. ProfilesDataTable consumes it through controlled props.
|
||||
const [profileInfoDialog, setProfileInfoDialog] =
|
||||
@@ -933,8 +1003,153 @@ export default function Home() {
|
||||
[selectedGroupId, t],
|
||||
);
|
||||
|
||||
// Unattended launches — REST and MCP automation — are the only ones the
|
||||
// backend gate lets past a measured mismatch, because there is no dialog for
|
||||
// them to answer. Without a listener that finding was emitted into the void.
|
||||
useEffect(() => {
|
||||
const unlisten = listen<ConsistencyResult>(
|
||||
"fingerprint-consistency-warning",
|
||||
(event) => {
|
||||
const { exit_timezone, fingerprint_timezone } = event.payload;
|
||||
showErrorToast(t("backendErrors.fingerprintExitMismatch"), {
|
||||
description:
|
||||
exit_timezone && fingerprint_timezone
|
||||
? t("consistencyWarning.timezoneDetail", {
|
||||
exit: exit_timezone,
|
||||
fingerprint: fingerprint_timezone,
|
||||
})
|
||||
: undefined,
|
||||
id: `fingerprint-mismatch-${exit_timezone ?? "unknown"}`,
|
||||
});
|
||||
},
|
||||
);
|
||||
return () => {
|
||||
void unlisten.then((fn) => {
|
||||
fn();
|
||||
});
|
||||
};
|
||||
}, [t]);
|
||||
|
||||
// Show the queue's head, and how many are waiting behind it.
|
||||
const syncGateUi = useCallback(() => {
|
||||
const queue = gateQueueRef.current;
|
||||
setGateState(
|
||||
queue.length > 0
|
||||
? { id: queue[0].id, req: queue[0].req, remaining: queue.length - 1 }
|
||||
: null,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const requestGateDecision = useCallback(
|
||||
(req: GateRequest, runId?: number): Promise<GateDecision> => {
|
||||
const blanket = blanketGateDecisionRef.current;
|
||||
const isBlocking = req.findings.fingerprint !== null;
|
||||
if (
|
||||
blanket &&
|
||||
blanket.runId === runId &&
|
||||
(blanket.coversBlocking || !isBlocking)
|
||||
) {
|
||||
// A blanket answer covers only whether to launch. The acknowledgements
|
||||
// it carried were about the first profile's specific mismatch and
|
||||
// extensions, and must not be persisted against profiles the user
|
||||
// never saw.
|
||||
return Promise.resolve({
|
||||
...blanket.decision,
|
||||
ackFingerprint: false,
|
||||
ackExtensionKeys: [],
|
||||
});
|
||||
}
|
||||
return new Promise<GateDecision>((resolve) => {
|
||||
gateRequestSeqRef.current += 1;
|
||||
gateQueueRef.current.push({
|
||||
id: gateRequestSeqRef.current,
|
||||
req,
|
||||
runId,
|
||||
resolve,
|
||||
});
|
||||
syncGateUi();
|
||||
});
|
||||
},
|
||||
[syncGateUi],
|
||||
);
|
||||
|
||||
const settleGate = useCallback(
|
||||
(decision: GateDecision) => {
|
||||
const entry = gateQueueRef.current.shift();
|
||||
if (!entry) {
|
||||
return;
|
||||
}
|
||||
entry.resolve(decision);
|
||||
if (decision.applyToRemaining) {
|
||||
const coversBlocking = entry.req.findings.fingerprint !== null;
|
||||
// Only a bulk run gets a standing blanket, and it claims the run the
|
||||
// answered dialog belonged to — never whichever run happens to be in
|
||||
// flight when the dialog is settled. Outside a run there is nothing to
|
||||
// scope one to, and a session-wide blanket would silently answer
|
||||
// unrelated launches later. The queue is still drained either way,
|
||||
// which is what the checkbox actually promises.
|
||||
if (entry.runId !== undefined) {
|
||||
blanketGateDecisionRef.current = {
|
||||
decision,
|
||||
coversBlocking,
|
||||
runId: entry.runId,
|
||||
};
|
||||
}
|
||||
// Drain the queue rather than leaving promises pending forever — but
|
||||
// only those the blanket actually covers. A hard block still deserves
|
||||
// its own dialog even after the user blanket-approved a warning, and a
|
||||
// launch started outside this run was never part of the answer.
|
||||
const remaining = gateQueueRef.current.splice(0);
|
||||
const kept = [];
|
||||
for (const queued of remaining) {
|
||||
const covered =
|
||||
queued.runId === entry.runId &&
|
||||
(coversBlocking || queued.req.findings.fingerprint === null);
|
||||
if (!covered) {
|
||||
kept.push(queued);
|
||||
continue;
|
||||
}
|
||||
queued.resolve({
|
||||
...decision,
|
||||
ackFingerprint: false,
|
||||
ackExtensionKeys: [],
|
||||
});
|
||||
}
|
||||
gateQueueRef.current = kept;
|
||||
}
|
||||
syncGateUi();
|
||||
},
|
||||
[syncGateUi],
|
||||
);
|
||||
|
||||
const persistGateAcks = useCallback(
|
||||
async (profileId: string, decision: GateDecision) => {
|
||||
// Only on proceed. Cancel is the autofocused default action, so a stray
|
||||
// Enter would otherwise permanently disarm the gate for this profile.
|
||||
if (!decision.proceed) {
|
||||
return;
|
||||
}
|
||||
if (!decision.ackFingerprint && decision.ackExtensionKeys.length === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await invoke("ack_launch_gate", {
|
||||
profileId,
|
||||
ackFingerprint: decision.ackFingerprint,
|
||||
ackExtensionKeys: decision.ackExtensionKeys,
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn("Failed to persist launch gate acknowledgement:", err);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const launchProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
async (
|
||||
profile: BrowserProfile,
|
||||
opts?: { bulkRunId?: number },
|
||||
): Promise<LaunchResult> => {
|
||||
console.log("Starting launch for profile:", profile.name);
|
||||
|
||||
// Password-protected: must be unlocked before launch
|
||||
@@ -947,7 +1162,7 @@ export default function Home() {
|
||||
pendingLaunchAfterUnlockRef.current = profile;
|
||||
setPasswordDialogMode("unlock");
|
||||
setPasswordDialogProfile(profile);
|
||||
return;
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check profile lock state:", err);
|
||||
@@ -966,7 +1181,7 @@ export default function Home() {
|
||||
setWindowResizeWarningOpen(true);
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -974,30 +1189,117 @@ export default function Home() {
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 1: purely local checks — an extension scan and a cached exit
|
||||
// verdict. No network, no worker started, so a profile whose exit is
|
||||
// already known blocks before the launch touches anything.
|
||||
let consentToken: string | null = null;
|
||||
// Kept for the tier-2 dialog below: the extensions are the same ones,
|
||||
// and a mismatch measured mid-launch is exactly when knowing that one of
|
||||
// them can change the proxy matters most. Minus anything the user just
|
||||
// acknowledged, so a box they ticked seconds ago is not shown again.
|
||||
let localChecks: PreLaunchChecks | null = null;
|
||||
let ackedExtensionKeys: string[] = [];
|
||||
try {
|
||||
// One-shot migration of the old per-profile "don't warn again" flag,
|
||||
// so a user who already dismissed this profile isn't hard-blocked by
|
||||
// the new gate. Granted against the profile's current exit, which is
|
||||
// the mismatch they were looking at when they dismissed it.
|
||||
const legacySkipKey = `consistency-warn-skip-${profile.id}`;
|
||||
if (localStorage.getItem(legacySkipKey) === "1") {
|
||||
await invoke("ack_launch_gate", {
|
||||
profileId: profile.id,
|
||||
ackFingerprint: true,
|
||||
ackExtensionKeys: [],
|
||||
}).catch((err: unknown) => {
|
||||
console.warn("Failed to migrate consistency skip flag:", err);
|
||||
});
|
||||
localStorage.removeItem(legacySkipKey);
|
||||
}
|
||||
|
||||
const checks = await invoke<PreLaunchChecks>(
|
||||
"get_profile_pre_launch_checks",
|
||||
{ profileId: profile.id },
|
||||
);
|
||||
localChecks = checks;
|
||||
const blocked =
|
||||
checks.consistency.checked && !checks.consistency.consistent;
|
||||
if (blocked || checks.vpn_extensions.length > 0) {
|
||||
const decision = await requestGateDecision(
|
||||
{
|
||||
profile,
|
||||
findings: {
|
||||
vpnExtensions: checks.vpn_extensions,
|
||||
scanState: checks.scan_state,
|
||||
fingerprint: blocked ? checks.consistency : null,
|
||||
measurementUnreliable: checks.exit_measurement_unreliable,
|
||||
probePending: checks.exit_probe_pending,
|
||||
},
|
||||
},
|
||||
opts?.bulkRunId,
|
||||
);
|
||||
await persistGateAcks(profile.id, decision);
|
||||
if (!decision.proceed) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
ackedExtensionKeys = decision.ackExtensionKeys;
|
||||
consentToken = checks.consent_token;
|
||||
}
|
||||
} catch (err) {
|
||||
// Same posture as the password and window-resize gates: a check that
|
||||
// cannot run must not make profiles unlaunchable.
|
||||
console.warn("Pre-launch checks failed, launching anyway:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invoke<BrowserProfile>("launch_browser_profile", {
|
||||
profile,
|
||||
consentToken,
|
||||
});
|
||||
console.log("Successfully launched profile:", result.name);
|
||||
|
||||
// Non-blocking: after a successful launch, check that the proxy exit
|
||||
// node's timezone/country agrees with the fingerprint. A mismatch is a
|
||||
// strong anti-bot tell even though the real device never leaks.
|
||||
if (profile.proxy_id && !isConsistencyWarningSuppressed(profile.id)) {
|
||||
void invoke<ConsistencyResult>(
|
||||
"check_profile_fingerprint_consistency",
|
||||
{ profileId: profile.id },
|
||||
)
|
||||
.then((res) => {
|
||||
if (res.checked && !res.consistent) {
|
||||
setConsistencyWarning({ profile, result: res });
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.warn("Consistency check failed:", e);
|
||||
});
|
||||
}
|
||||
return { status: "launched" };
|
||||
} catch (err: unknown) {
|
||||
// Tier 2: the enforcing gate measured the exit mid-launch and stopped
|
||||
// before spawning the browser. Offer the same decision, then retry
|
||||
// exactly once with the token it minted — bounded, so a gate loop is
|
||||
// structurally impossible.
|
||||
const parsed = parseBackendError(err);
|
||||
if (parsed?.code === "FINGERPRINT_EXIT_MISMATCH") {
|
||||
const decision = await requestGateDecision(
|
||||
{
|
||||
profile,
|
||||
findings: {
|
||||
vpnExtensions: (localChecks?.vpn_extensions ?? []).filter(
|
||||
(ext) => !ackedExtensionKeys.includes(ext.key),
|
||||
),
|
||||
scanState: localChecks?.scan_state ?? "scanned",
|
||||
fingerprint: consistencyFromErrorParams(parsed.params),
|
||||
measurementUnreliable:
|
||||
localChecks?.exit_measurement_unreliable ?? false,
|
||||
probePending: false,
|
||||
},
|
||||
},
|
||||
opts?.bulkRunId,
|
||||
);
|
||||
await persistGateAcks(profile.id, decision);
|
||||
if (!decision.proceed) {
|
||||
return { status: "cancelled" };
|
||||
}
|
||||
try {
|
||||
await invoke<BrowserProfile>("launch_browser_profile", {
|
||||
profile,
|
||||
consentToken: parsed.params?.token ?? null,
|
||||
});
|
||||
return { status: "launched" };
|
||||
} catch (retryErr: unknown) {
|
||||
showErrorToast(
|
||||
t("errors.launchBrowserFailed", {
|
||||
error: translateBackendError(t, retryErr),
|
||||
}),
|
||||
);
|
||||
return { status: "blocked" };
|
||||
}
|
||||
}
|
||||
|
||||
console.error("Failed to launch browser:", err);
|
||||
const errorMessage = translateBackendError(t, err);
|
||||
showErrorToast(
|
||||
@@ -1006,7 +1308,7 @@ export default function Home() {
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[t],
|
||||
[persistGateAcks, requestGateDecision, t],
|
||||
);
|
||||
|
||||
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
|
||||
@@ -1203,15 +1505,34 @@ export default function Home() {
|
||||
const executeBulkRun = useCallback(
|
||||
async (targets: BrowserProfile[]) => {
|
||||
setIsBulkActing(true);
|
||||
blanketGateDecisionRef.current = null;
|
||||
bulkRunIdRef.current += 1;
|
||||
const runId = bulkRunIdRef.current;
|
||||
try {
|
||||
await Promise.allSettled(targets.map((p) => launchProfile(p)));
|
||||
const results = await Promise.allSettled(
|
||||
targets.map((p) => launchProfile(p, { bulkRunId: runId })),
|
||||
);
|
||||
const stopped = results.filter(
|
||||
(r) => r.status === "fulfilled" && r.value.status !== "launched",
|
||||
).length;
|
||||
if (stopped > 0) {
|
||||
// Previously a declined launch resolved to undefined, so allSettled
|
||||
// reported success and the user was told nothing.
|
||||
showErrorToast(
|
||||
t("prelaunchGate.cancelledSummary", {
|
||||
cancelled: stopped,
|
||||
total: targets.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
setSelectedProfiles([]);
|
||||
} finally {
|
||||
blanketGateDecisionRef.current = null;
|
||||
setIsBulkActing(false);
|
||||
setPendingBulkAction(null);
|
||||
}
|
||||
},
|
||||
[launchProfile],
|
||||
[launchProfile, t],
|
||||
);
|
||||
|
||||
const executeBulkStop = useCallback(
|
||||
@@ -1898,14 +2219,14 @@ export default function Home() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<ConsistencyWarningDialog
|
||||
isOpen={consistencyWarning !== null}
|
||||
onClose={() => {
|
||||
setConsistencyWarning(null);
|
||||
}}
|
||||
profileName={consistencyWarning?.profile.name ?? ""}
|
||||
profileId={consistencyWarning?.profile.id ?? ""}
|
||||
result={consistencyWarning?.result ?? null}
|
||||
<PreLaunchGateDialog
|
||||
isOpen={gateState !== null}
|
||||
profileName={gateState?.req.profile.name ?? ""}
|
||||
profileId={gateState?.req.profile.id ?? ""}
|
||||
requestId={gateState?.id ?? 0}
|
||||
findings={gateState?.req.findings ?? null}
|
||||
remainingCount={gateState?.remaining ?? 0}
|
||||
onResult={settleGate}
|
||||
/>
|
||||
|
||||
{pendingUrls.map((pendingUrl) => (
|
||||
|
||||
@@ -1,176 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
export interface ConsistencyResult {
|
||||
consistent: boolean;
|
||||
checked: boolean;
|
||||
exit_ip: string | null;
|
||||
exit_country_code: string | null;
|
||||
exit_timezone: string | null;
|
||||
fingerprint_timezone: string | null;
|
||||
fingerprint_language: string | null;
|
||||
mismatches: string[];
|
||||
}
|
||||
|
||||
const GLOBAL_DISABLE_KEY = "consistency-warn-disabled";
|
||||
const perProfileKey = (id: string) => `consistency-warn-skip-${id}`;
|
||||
|
||||
export function isConsistencyWarningSuppressed(profileId: string): boolean {
|
||||
try {
|
||||
return (
|
||||
localStorage.getItem(GLOBAL_DISABLE_KEY) === "1" ||
|
||||
localStorage.getItem(perProfileKey(profileId)) === "1"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
interface ConsistencyWarningDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profileName: string;
|
||||
profileId: string;
|
||||
result: ConsistencyResult | null;
|
||||
}
|
||||
|
||||
export function ConsistencyWarningDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profileName,
|
||||
profileId,
|
||||
result,
|
||||
}: ConsistencyWarningDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dontWarnAgain, setDontWarnAgain] = useState(false);
|
||||
const [isMatching, setIsMatching] = useState(false);
|
||||
|
||||
const handleClose = () => {
|
||||
if (dontWarnAgain) {
|
||||
try {
|
||||
localStorage.setItem(perProfileKey(profileId), "1");
|
||||
} catch {
|
||||
// localStorage unavailable — nothing to persist
|
||||
}
|
||||
}
|
||||
setDontWarnAgain(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const mismatches = result?.mismatches ?? [];
|
||||
const exitIp = result?.exit_ip ?? null;
|
||||
|
||||
const handleMatch = async () => {
|
||||
if (!exitIp) {
|
||||
return;
|
||||
}
|
||||
setIsMatching(true);
|
||||
try {
|
||||
await invoke("match_profile_fingerprint_to_exit", {
|
||||
profileId,
|
||||
exitIp,
|
||||
});
|
||||
showSuccessToast(t("consistencyWarning.matchSuccess"));
|
||||
handleClose();
|
||||
} catch (e) {
|
||||
showErrorToast(translateBackendError(t, e));
|
||||
} finally {
|
||||
setIsMatching(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuTriangleAlert className="size-5 text-warning-text" />
|
||||
{t("consistencyWarning.title")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{t("consistencyWarning.intro", { name: profileName })}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2 rounded-md border border-warning/40 bg-warning/10 p-3">
|
||||
{mismatches.includes("timezone") && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("consistencyWarning.timezoneTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.timezoneDetail", {
|
||||
exit: result?.exit_timezone ?? "?",
|
||||
fingerprint: result?.fingerprint_timezone ?? "?",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{mismatches.includes("language") && (
|
||||
<div>
|
||||
<p className="font-medium">
|
||||
{t("consistencyWarning.languageTitle")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.languageDetail", {
|
||||
country: result?.exit_country_code ?? "?",
|
||||
fingerprint: result?.fingerprint_language ?? "?",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.explainer")}
|
||||
</p>
|
||||
|
||||
<label
|
||||
htmlFor="consistency-dont-warn"
|
||||
className="flex cursor-pointer items-center gap-2 text-xs"
|
||||
>
|
||||
<Checkbox
|
||||
id="consistency-dont-warn"
|
||||
checked={dontWarnAgain}
|
||||
onCheckedChange={(v) => setDontWarnAgain(v === true)}
|
||||
/>
|
||||
{t("consistencyWarning.dontWarnAgain")}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isMatching}
|
||||
>
|
||||
{t("common.buttons.close")}
|
||||
</RippleButton>
|
||||
{exitIp && (
|
||||
<RippleButton onClick={handleMatch} disabled={isMatching}>
|
||||
{isMatching
|
||||
? t("consistencyWarning.matching")
|
||||
: t("consistencyWarning.matchToProxy")}
|
||||
</RippleButton>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,11 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
|
||||
import type {
|
||||
CookieBotRun,
|
||||
CookieBotSchedule,
|
||||
CookieBotSlot,
|
||||
RemoteHoursQuota,
|
||||
} from "@/lib/cookie-bot";
|
||||
import { MOTION_EASE_OUT } from "@/lib/motion";
|
||||
@@ -66,6 +68,84 @@ export function nightsPerWeek(mask: number): number {
|
||||
return count;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------- */
|
||||
/* Slots */
|
||||
/* -------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Every time-of-day an enrolment fires, from whichever shape the server sent.
|
||||
*
|
||||
* ALWAYS at least one slot. A server that predates multi-slot scheduling sends
|
||||
* only the mirrored `run_at_minute` / `days_mask` pair, and a renderer that read
|
||||
* `slots` directly would show an enrolment as firing at no time at all. Reading
|
||||
* the wire through here is what keeps that fallback in one place.
|
||||
*/
|
||||
export function scheduleSlots(schedule: {
|
||||
slots?: CookieBotSlot[];
|
||||
run_at_minute: number;
|
||||
days_mask: number;
|
||||
}): CookieBotSlot[] {
|
||||
const slots = schedule.slots ?? [];
|
||||
if (slots.length > 0) return slots;
|
||||
return [
|
||||
{ days_mask: schedule.days_mask, run_at_minute: schedule.run_at_minute },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* How many times a week a whole calendar fires.
|
||||
*
|
||||
* Counted across slots, not read off the first one: an enrolment with three
|
||||
* slots costs three times the hours, and the budget estimate beside it is the
|
||||
* only place a user sees that before committing.
|
||||
*
|
||||
* DISTINCT (weekday, minute) pairs rather than a sum of night counts, because
|
||||
* two slots landing on the same weekday at the same minute are the same
|
||||
* instant and the server dispatches them as ONE run — `upcomingSlotsMulti`
|
||||
* collapses coincident fires. Summing quoted a Mon+Tue and a Tue+Wed slot at
|
||||
* 02:00 as four nights when it is three, and that inflated figure is what the
|
||||
* over-budget warning is compared against.
|
||||
*/
|
||||
export function weeklyRuns(
|
||||
slots: { days_mask: number; run_at_minute: number }[],
|
||||
): number {
|
||||
const fires = new Set<number>();
|
||||
for (const slot of slots) {
|
||||
for (let bit = 0; bit < 7; bit += 1) {
|
||||
if ((slot.days_mask & (1 << bit)) !== 0) {
|
||||
fires.add(bit * 1440 + slot.run_at_minute);
|
||||
}
|
||||
}
|
||||
}
|
||||
return fires.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* Monday-first weekday names, from the viewer's own locale.
|
||||
*
|
||||
* Derived rather than translated into ten locale files: `narrow` already gives
|
||||
* each language its own single-letter convention, and a hand-written table
|
||||
* would be ten more places for Monday-first to be got wrong. The reference week
|
||||
* is formatted in UTC so a user east of Greenwich does not see it shift by a
|
||||
* day.
|
||||
*/
|
||||
export function weekdayNames(): { narrow: string; long: string }[] {
|
||||
// 2024-01-01 was a Monday, which is bit 0 of the server's mask.
|
||||
const monday = Date.UTC(2024, 0, 1);
|
||||
const narrow = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "narrow",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const long = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: "long",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return Array.from({ length: 7 }, (_, index) => {
|
||||
const day = new Date(monday + index * 24 * 60 * 60 * 1000);
|
||||
return { narrow: narrow.format(day), long: long.format(day) };
|
||||
});
|
||||
}
|
||||
|
||||
/** A human cadence label. Unknown masks fall back to the night count. */
|
||||
export function describeCadence(t: TFunction, mask: number): string {
|
||||
const id = cadenceForMask(mask);
|
||||
@@ -237,6 +317,24 @@ export function preflight(profile: BrowserProfile): PreflightResult {
|
||||
return ELIGIBLE;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether this profile could be launched on a remote host.
|
||||
*
|
||||
* A strict subset of {@link preflight}: a remote session needs the profile to
|
||||
* exist in cloud storage in a form a host can read, and nothing more. The bot's
|
||||
* extra requirement — an exit node — exists because a night of unattended
|
||||
* traffic from a datacenter address is worse for the profile than not warming
|
||||
* it, and that reasoning does not apply to a session the user is driving.
|
||||
*
|
||||
* Mirrors `remote_launch_profile_rules` in `api_server.rs`, which is
|
||||
* authoritative; this only avoids offering an action that would be refused.
|
||||
*/
|
||||
export function canLaunchRemotely(profile: BrowserProfile): boolean {
|
||||
const syncMode = profile.sync_mode ?? "Disabled";
|
||||
if (syncMode === "Disabled" || syncMode === "Encrypted") return false;
|
||||
return resolvedOs(profile) !== null;
|
||||
}
|
||||
|
||||
export function preflightReason(t: TFunction, result: PreflightResult): string {
|
||||
switch (result.code) {
|
||||
case "syncOff":
|
||||
@@ -391,6 +489,36 @@ export function outcomeLabel(
|
||||
return key ? t(key) : t("cookieBot.outcome.unknown", { code });
|
||||
}
|
||||
|
||||
/**
|
||||
* The three refusals only the saved-list routes can raise.
|
||||
*
|
||||
* They are absent from the shared `backendErrors` table, so
|
||||
* `translateBackendError` renders them through its unknown-code fallback: a
|
||||
* user who reuses a name would be told "Something went wrong:
|
||||
* COOKIE_BOT_TEMPLATE_NAME_TAKEN" instead of that the name is taken, in the one
|
||||
* dialog where the fix is a single keystroke. Everything else — a signed-out
|
||||
* desktop, an unreachable cloud — still goes through the shared translator.
|
||||
*/
|
||||
const TEMPLATE_ERROR_KEYS: Record<string, string> = {
|
||||
COOKIE_BOT_TEMPLATE_NAME_TAKEN: "cookieBot.enrol.templateNameTaken",
|
||||
COOKIE_BOT_INVALID_TEMPLATE_NAME: "cookieBot.enrol.templateNameInvalid",
|
||||
COOKIE_BOT_TEMPLATE_NOT_FOUND: "cookieBot.enrol.templateMissing",
|
||||
};
|
||||
|
||||
export function templateErrorMessage(t: TFunction, error: unknown): string {
|
||||
const parsed = parseBackendError(error);
|
||||
const key = parsed ? TEMPLATE_ERROR_KEYS[parsed.code] : undefined;
|
||||
if (!key) return translateBackendError(t, error);
|
||||
const max = parsed?.params?.max;
|
||||
// The server does not always send a limit. Interpolating an empty string
|
||||
// rendered "a name of characters or fewer", so fall back to wording that
|
||||
// does not need the number.
|
||||
if (key === "cookieBot.enrol.templateNameInvalid" && !max) {
|
||||
return t("cookieBot.enrol.templateNameInvalidNoMax");
|
||||
}
|
||||
return t(key, { max });
|
||||
}
|
||||
|
||||
/**
|
||||
* The session state machine, named honestly. `provisioning -> ready -> live ->
|
||||
* closed`, with `error` reachable from any of the first three, is what the
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
|
||||
import { useWindowDecorations } from "@/hooks/use-window-decorations";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
@@ -59,8 +60,14 @@ const HomeHeader = ({
|
||||
}, []);
|
||||
|
||||
const isMacOS = platform === "macos";
|
||||
const isLinux = platform === "linux";
|
||||
const showProfileToolbar = !pageTitle;
|
||||
|
||||
// Same hook the controls use, so the reserved space can never disagree with
|
||||
// what is actually drawn.
|
||||
const decorations = useWindowDecorations();
|
||||
const linuxLayout = decorations.clientSide ? decorations.layout : null;
|
||||
|
||||
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
|
||||
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
|
||||
const holdTimeoutRef = useRef<number | null>(null);
|
||||
@@ -179,14 +186,29 @@ const HomeHeader = ({
|
||||
onPointerCancel={handlePointerEnd}
|
||||
onDoubleClick={handleDoubleClick}
|
||||
className={cn(
|
||||
"flex h-11 items-center gap-2 border-b border-border bg-card pl-3 select-none",
|
||||
"flex h-11 items-center gap-2 border-b border-border bg-card select-none",
|
||||
// Windows: WindowDragArea renders three 44px native-style controls
|
||||
// (minimize + maximize/restore + close) fixed at top-right with
|
||||
// z-50, total 132px wide. Reserve 144px on the right edge so the
|
||||
// "+ New" button and search input clear them with a few pixels of
|
||||
// breathing room and never sit underneath the controls.
|
||||
isWindows ? "pr-[144px]" : "pr-3",
|
||||
isWindows ? "pl-3 pr-[144px]" : null,
|
||||
// Linux reserves its space through the inline style below, because the
|
||||
// desktop chooses which side the controls sit on and how many there
|
||||
// are. Everything else keeps the plain symmetric padding.
|
||||
!isWindows && !isLinux ? "pl-3 pr-3" : null,
|
||||
)}
|
||||
style={
|
||||
isLinux
|
||||
? {
|
||||
// Each control is 44px wide; add the usual 12px gutter. Before
|
||||
// the layout resolves, fall back to the gutter alone rather than
|
||||
// to no padding, which would visibly shift the content.
|
||||
paddingLeft: (linuxLayout?.left.length ?? 0) * 44 + 12,
|
||||
paddingRight: (linuxLayout?.right.length ?? 0) * 44 + 12,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{isMacOS && (
|
||||
<div
|
||||
|
||||
@@ -53,10 +53,88 @@ import type {
|
||||
ImportProfileItem,
|
||||
ProfileImportBatchResult,
|
||||
ProfileImportProgress,
|
||||
ProfileImportReport,
|
||||
WayfernConfig,
|
||||
} from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
/**
|
||||
* What an import actually carried, and what it could not.
|
||||
*
|
||||
* The counts matter more than they look: an import that reports zero of
|
||||
* everything is the exact symptom of the bug where copied data landed where
|
||||
* the browser never reads it, and it used to be indistinguishable from success.
|
||||
*/
|
||||
function ImportReportSummary({ report }: { report: ProfileImportReport }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
// Label-then-value rather than "{{count}} cookies": it keeps the row scannable
|
||||
// and sidesteps needing correct plural forms in ten languages.
|
||||
const carried = (
|
||||
[
|
||||
["importProfile.reportCookies", report.cookies_migrated],
|
||||
["importProfile.reportPasswords", report.passwords_migrated],
|
||||
["importProfile.reportAutofill", report.payment_methods_migrated],
|
||||
["importProfile.reportExtensions", report.extensions_migrated],
|
||||
["importProfile.reportHistory", report.history_entries],
|
||||
["importProfile.reportBookmarks", report.bookmarks],
|
||||
["importProfile.reportLocalStorage", report.local_storage_origins],
|
||||
] as const
|
||||
)
|
||||
.filter(([, count]) => count > 0)
|
||||
.map(([key, count]) => `${t(key)} ${count.toLocaleString()}`);
|
||||
|
||||
const unrecoverable =
|
||||
report.cookies_unrecoverable +
|
||||
report.passwords_unrecoverable +
|
||||
report.payment_methods_unrecoverable;
|
||||
|
||||
return (
|
||||
<div className="mt-0.5 space-y-0.5 pl-1 text-xs text-muted-foreground">
|
||||
<p>
|
||||
{carried.length > 0
|
||||
? carried.join(" · ")
|
||||
: t("importProfile.reportNothingCarried")}
|
||||
</p>
|
||||
{unrecoverable > 0 && (
|
||||
<p>
|
||||
{t("importProfile.reportUnrecoverable", { count: unrecoverable })}
|
||||
</p>
|
||||
)}
|
||||
{report.warnings.map((code) => (
|
||||
<p key={code} className="text-warning-text">
|
||||
{t(`importProfile.warnings.${code}`)}
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fold a retry's results back into the batch it came from.
|
||||
*
|
||||
* A retry only resubmits the items that failed, so the previous batch is still
|
||||
* authoritative for every other row. Replacing it wholesale would make the
|
||||
* successful imports disappear from the summary.
|
||||
*/
|
||||
function mergeImportResults(
|
||||
previous: ProfileImportBatchResult,
|
||||
retry: ProfileImportBatchResult,
|
||||
): ProfileImportBatchResult {
|
||||
const byPath = new Map(retry.results.map((item) => [item.source_path, item]));
|
||||
const results = previous.results.map(
|
||||
(item) => byPath.get(item.source_path) ?? item,
|
||||
);
|
||||
const count = (status: string) =>
|
||||
results.filter((item) => item.status === status).length;
|
||||
return {
|
||||
imported_count: count("imported"),
|
||||
skipped_count: count("skipped"),
|
||||
failed_count: count("failed"),
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
interface ImportProfileDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -283,69 +361,99 @@ export function ImportProfileDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (selectedProfiles.length === 0) {
|
||||
toast.error(t("importProfile.selectAtLeastOne"));
|
||||
return;
|
||||
}
|
||||
if (
|
||||
selectedProfiles.some((p) => !(profileNames[p.path] ?? p.name).trim())
|
||||
) {
|
||||
toast.error(t("importProfile.emptyNames"));
|
||||
return;
|
||||
}
|
||||
|
||||
const items: ImportProfileItem[] = selectedProfiles.map((p, index) => ({
|
||||
source_path: p.path,
|
||||
browser_type: p.browser,
|
||||
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
|
||||
proxy_id: proxyIdForIndex(index),
|
||||
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
|
||||
}));
|
||||
|
||||
setCurrentStep("importing");
|
||||
setIsImporting(true);
|
||||
setProgress(null);
|
||||
setResult(null);
|
||||
try {
|
||||
const batchResult = await invoke<ProfileImportBatchResult>(
|
||||
"import_browser_profiles",
|
||||
{
|
||||
items,
|
||||
groupId: selectedGroupId === "none" ? null : selectedGroupId,
|
||||
duplicateStrategy: duplicateStrategy,
|
||||
wayfernConfig,
|
||||
},
|
||||
);
|
||||
setResult(batchResult);
|
||||
toast.success(
|
||||
t("importProfile.resultsSummary", {
|
||||
imported: batchResult.imported_count,
|
||||
skipped: batchResult.skipped_count,
|
||||
failed: batchResult.failed_count,
|
||||
}),
|
||||
);
|
||||
if (batchResult.imported_count > 0 && !reducedMotion) {
|
||||
fireSprinkleConfetti();
|
||||
const handleImport = useCallback(
|
||||
async (allowRunning = false, retryPaths?: ReadonlySet<string>) => {
|
||||
if (selectedProfiles.length === 0) {
|
||||
toast.error(t("importProfile.selectAtLeastOne"));
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to import profiles:", error);
|
||||
toast.error(translateBackendError(t, error));
|
||||
setCurrentStep("configure");
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [
|
||||
selectedProfiles,
|
||||
profileNames,
|
||||
proxyIdForIndex,
|
||||
vpnAssignment,
|
||||
selectedGroupId,
|
||||
duplicateStrategy,
|
||||
wayfernConfig,
|
||||
reducedMotion,
|
||||
t,
|
||||
]);
|
||||
if (
|
||||
selectedProfiles.some((p) => !(profileNames[p.path] ?? p.name).trim())
|
||||
) {
|
||||
toast.error(t("importProfile.emptyNames"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Filter AFTER the map, so a retry keeps the proxy each profile was
|
||||
// originally assigned by the index-based round-robin.
|
||||
const items: ImportProfileItem[] = selectedProfiles
|
||||
.map((p, index) => ({
|
||||
source_path: p.path,
|
||||
browser_type: p.browser,
|
||||
new_profile_name: (profileNames[p.path] ?? p.name).trim(),
|
||||
proxy_id: proxyIdForIndex(index),
|
||||
vpn_id: vpnAssignment === "none" ? null : vpnAssignment,
|
||||
allow_running: allowRunning,
|
||||
}))
|
||||
.filter((item) => !retryPaths || retryPaths.has(item.source_path));
|
||||
|
||||
if (items.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentStep("importing");
|
||||
setIsImporting(true);
|
||||
setProgress(null);
|
||||
// A retry covers only the failed subset, so the earlier results are still
|
||||
// the truth for everything else and must not be thrown away.
|
||||
const previous = retryPaths ? result : null;
|
||||
setResult(null);
|
||||
try {
|
||||
const batchResult = await invoke<ProfileImportBatchResult>(
|
||||
"import_browser_profiles",
|
||||
{
|
||||
items,
|
||||
groupId: selectedGroupId === "none" ? null : selectedGroupId,
|
||||
duplicateStrategy: duplicateStrategy,
|
||||
wayfernConfig,
|
||||
},
|
||||
);
|
||||
setResult(
|
||||
previous ? mergeImportResults(previous, batchResult) : batchResult,
|
||||
);
|
||||
toast.success(
|
||||
t("importProfile.resultsSummary", {
|
||||
imported: batchResult.imported_count,
|
||||
skipped: batchResult.skipped_count,
|
||||
failed: batchResult.failed_count,
|
||||
}),
|
||||
);
|
||||
if (batchResult.imported_count > 0 && !reducedMotion) {
|
||||
fireSprinkleConfetti();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to import profiles:", error);
|
||||
toast.error(translateBackendError(t, error));
|
||||
setCurrentStep("configure");
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedProfiles,
|
||||
profileNames,
|
||||
proxyIdForIndex,
|
||||
vpnAssignment,
|
||||
selectedGroupId,
|
||||
duplicateStrategy,
|
||||
wayfernConfig,
|
||||
reducedMotion,
|
||||
result,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
// A source browser that is still running is the one failure the user can fix
|
||||
// without starting over, so offer the override right where it happened.
|
||||
const hasRunningBrowserFailure = useMemo(
|
||||
() =>
|
||||
(result?.results ?? []).some(
|
||||
(item) =>
|
||||
item.status === "failed" &&
|
||||
item.error?.includes("IMPORT_SOURCE_BROWSER_RUNNING"),
|
||||
),
|
||||
[result],
|
||||
);
|
||||
|
||||
const handleClose = () => {
|
||||
void cleanupExtractedDir(extractedDir);
|
||||
@@ -840,38 +948,74 @@ export function ImportProfileDialog({
|
||||
</h3>
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto rounded-lg border border-border p-2">
|
||||
{result.results.map((item) => (
|
||||
<div
|
||||
key={item.source_path}
|
||||
className="flex items-center gap-2 p-1 text-sm"
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-medium",
|
||||
item.status === "imported" && "text-success-text",
|
||||
item.status === "skipped" &&
|
||||
"text-muted-foreground",
|
||||
item.status === "failed" &&
|
||||
"text-destructive-text",
|
||||
)}
|
||||
>
|
||||
{item.status === "imported" &&
|
||||
t("importProfile.statusImported")}
|
||||
{item.status === "skipped" &&
|
||||
t("importProfile.statusSkipped")}
|
||||
{item.status === "failed" &&
|
||||
t("importProfile.statusFailed")}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{item.name || item.source_path}
|
||||
</span>
|
||||
{item.error && (
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-destructive-text">
|
||||
{translateBackendError(t, new Error(item.error))}
|
||||
<div key={item.source_path} className="p-1 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
"shrink-0 text-xs font-medium",
|
||||
item.status === "imported" &&
|
||||
"text-success-text",
|
||||
item.status === "skipped" &&
|
||||
"text-muted-foreground",
|
||||
item.status === "failed" &&
|
||||
"text-destructive-text",
|
||||
)}
|
||||
>
|
||||
{item.status === "imported" &&
|
||||
t("importProfile.statusImported")}
|
||||
{item.status === "skipped" &&
|
||||
t("importProfile.statusSkipped")}
|
||||
{item.status === "failed" &&
|
||||
t("importProfile.statusFailed")}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">
|
||||
{item.name || item.source_path}
|
||||
</span>
|
||||
{item.error && (
|
||||
<span className="min-w-0 flex-1 truncate text-xs text-destructive-text">
|
||||
{translateBackendError(
|
||||
t,
|
||||
new Error(item.error),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{item.report && (
|
||||
<ImportReportSummary report={item.report} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasRunningBrowserFailure && (
|
||||
<Alert>
|
||||
<AlertDescription className="space-y-2">
|
||||
<p>{t("importProfile.closeSourceBrowserHint")}</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
void handleImport(
|
||||
true,
|
||||
new Set(
|
||||
result.results
|
||||
.filter(
|
||||
(item) =>
|
||||
item.status === "failed" &&
|
||||
item.error?.includes(
|
||||
"IMPORT_SOURCE_BROWSER_RUNNING",
|
||||
),
|
||||
)
|
||||
.map((item) => item.source_path),
|
||||
),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{t("importProfile.importAnyway")}
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type {
|
||||
ConsistencyResult,
|
||||
DetectedVpnExtension,
|
||||
ExtensionScanState,
|
||||
} from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
export interface GateFindings {
|
||||
/// Extensions that could reroute traffic. A warning: the user may proceed.
|
||||
vpnExtensions: DetectedVpnExtension[];
|
||||
scanState: ExtensionScanState;
|
||||
/// A measured exit/fingerprint mismatch. A block: the browser has not started.
|
||||
fingerprint: ConsistencyResult | null;
|
||||
/// An extension holds the proxy permission, so the exit measurement may not
|
||||
/// describe the route the browser takes. A caveat on the block, not a waiver.
|
||||
measurementUnreliable: boolean;
|
||||
/// The exit has not been measured yet; the launch itself will still check.
|
||||
probePending: boolean;
|
||||
}
|
||||
|
||||
export interface GateDecision {
|
||||
proceed: boolean;
|
||||
ackFingerprint: boolean;
|
||||
ackExtensionKeys: string[];
|
||||
applyToRemaining: boolean;
|
||||
}
|
||||
|
||||
interface PreLaunchGateDialogProps {
|
||||
isOpen: boolean;
|
||||
profileName: string;
|
||||
profileId: string;
|
||||
/// Identifies this specific request, so state resets even when one gate
|
||||
/// replaces another without the dialog ever closing.
|
||||
requestId: number;
|
||||
findings: GateFindings | null;
|
||||
/// How many further profiles are queued behind this one; >0 offers to apply
|
||||
/// the same decision to all of them.
|
||||
remainingCount: number;
|
||||
/// The single exit route. Every path out of this dialog calls it exactly
|
||||
/// once, so a caller awaiting a decision can never be left hanging.
|
||||
onResult: (decision: GateDecision) => void;
|
||||
}
|
||||
|
||||
/// Everything the user can change while one gate is on screen, stamped with
|
||||
/// the gate it belongs to.
|
||||
interface GateAnswerState {
|
||||
requestId: number;
|
||||
ackFingerprint: boolean;
|
||||
ackExtensions: boolean;
|
||||
applyToRemaining: boolean;
|
||||
isMatching: boolean;
|
||||
decided: boolean;
|
||||
}
|
||||
|
||||
/// How long after a decision the footer stops accepting another one. Long
|
||||
/// enough that a double-click cannot answer the gate promoted by its first
|
||||
/// half, short enough that nobody deliberately answering two queued gates in a
|
||||
/// row notices it.
|
||||
const DECISION_COOLDOWN_MS = 500;
|
||||
|
||||
function answersFor(requestId: number): GateAnswerState {
|
||||
return {
|
||||
requestId,
|
||||
ackFingerprint: false,
|
||||
ackExtensions: false,
|
||||
applyToRemaining: false,
|
||||
isMatching: false,
|
||||
decided: false,
|
||||
};
|
||||
}
|
||||
|
||||
function ExtensionEntry({ extension }: { extension: DetectedVpnExtension }) {
|
||||
const { t } = useTranslation();
|
||||
const capability = t(
|
||||
extension.confidence === "confirmed"
|
||||
? "prelaunchGate.vpnExtensionConfirmed"
|
||||
: extension.confidence === "likely"
|
||||
? "prelaunchGate.vpnExtensionLikely"
|
||||
: "prelaunchGate.vpnExtensionCapability",
|
||||
);
|
||||
const source = t(
|
||||
extension.source === "donut"
|
||||
? "prelaunchGate.sourceDonut"
|
||||
: "prelaunchGate.sourceBrowser",
|
||||
);
|
||||
|
||||
return (
|
||||
<li className="text-xs">
|
||||
<span className="font-medium">{extension.name}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{/* A version-less manifest is legal, and interpolating an empty string
|
||||
into the one template left a doubled space before the dash. */}
|
||||
{extension.version
|
||||
? t("prelaunchGate.vpnExtensionEntry", {
|
||||
version: extension.version,
|
||||
capability,
|
||||
source,
|
||||
})
|
||||
: t("prelaunchGate.vpnExtensionEntryNoVersion", {
|
||||
capability,
|
||||
source,
|
||||
})}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function PreLaunchGateDialog({
|
||||
isOpen,
|
||||
profileName,
|
||||
profileId,
|
||||
requestId,
|
||||
findings,
|
||||
remainingCount,
|
||||
onResult,
|
||||
}: PreLaunchGateDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
// All mutable state is stamped with the request it belongs to, and anything
|
||||
// stamped with an older request is ignored rather than reset. The dialog
|
||||
// never unmounts and a queued gate promotes the next profile without ever
|
||||
// closing it, so state carried across that boundary would tick a checkbox
|
||||
// for a profile the user never saw — and `decided` carried across it left
|
||||
// every button disabled on a dialog that also refused Escape, which is the
|
||||
// freeze this shape exists to make unrepresentable.
|
||||
//
|
||||
// Deliberately not an effect keyed on `requestId`: a reset effect whose body
|
||||
// reads none of its dependencies is exactly what a lint autofix reduces to
|
||||
// `[]`, and that is how the freeze shipped.
|
||||
const [state, setState] = useState<GateAnswerState>(() => answersFor(0));
|
||||
const answers = state.requestId === requestId ? state : answersFor(requestId);
|
||||
|
||||
// The gate on screen right now, readable from an async callback whose
|
||||
// closure was captured while an earlier gate was showing.
|
||||
const liveRequestRef = useRef(requestId);
|
||||
useEffect(() => {
|
||||
liveRequestRef.current = requestId;
|
||||
}, [requestId]);
|
||||
|
||||
const patch = (next: Partial<GateAnswerState>) => {
|
||||
// A callback that resumes after its gate was answered must not write into
|
||||
// the slot the next gate is now using — that would silently untick boxes
|
||||
// the user has since ticked on a different profile.
|
||||
if (liveRequestRef.current !== requestId) {
|
||||
return;
|
||||
}
|
||||
setState((prev) => ({
|
||||
...(prev.requestId === requestId ? prev : answersFor(requestId)),
|
||||
...next,
|
||||
requestId,
|
||||
}));
|
||||
};
|
||||
const {
|
||||
ackFingerprint,
|
||||
ackExtensions,
|
||||
applyToRemaining,
|
||||
isMatching,
|
||||
decided,
|
||||
} = answers;
|
||||
|
||||
const fingerprint = findings?.fingerprint ?? null;
|
||||
const extensions = findings?.vpnExtensions ?? [];
|
||||
// Two different claims, kept visually apart. The first names extensions as
|
||||
// VPN/proxy tools; the second says only that an extension holds Chromium's
|
||||
// proxy permission, which a download manager needs to route its own
|
||||
// transfers and which says nothing about what the extension is.
|
||||
const vpnExtensions = extensions.filter((e) => e.confidence !== "capability");
|
||||
const proxyCapableExtensions = extensions.filter(
|
||||
(e) => e.confidence === "capability",
|
||||
);
|
||||
const mismatches = fingerprint?.mismatches ?? [];
|
||||
const exitIp = fingerprint?.exit_ip ?? null;
|
||||
const isBlocked = fingerprint !== null;
|
||||
|
||||
// Two guards, because answering a gate promotes the next one into the same
|
||||
// DOM node rather than closing the dialog. The ref settles one gate exactly
|
||||
// once even if two clicks land in the same React batch; the cooldown stops
|
||||
// the second half of a double-click from answering a dialog that appeared
|
||||
// between the two clicks and that nobody has read.
|
||||
const decidedRef = useRef<number | null>(null);
|
||||
const lastDecisionAtRef = useRef(Number.NEGATIVE_INFINITY);
|
||||
|
||||
const decide = (proceed: boolean) => {
|
||||
if (decided || decidedRef.current === requestId) {
|
||||
return;
|
||||
}
|
||||
const now = performance.now();
|
||||
if (now - lastDecisionAtRef.current < DECISION_COOLDOWN_MS) {
|
||||
return;
|
||||
}
|
||||
decidedRef.current = requestId;
|
||||
lastDecisionAtRef.current = now;
|
||||
patch({ decided: true });
|
||||
onResult({
|
||||
proceed,
|
||||
ackFingerprint: ackFingerprint && isBlocked,
|
||||
ackExtensionKeys: ackExtensions ? extensions.map((e) => e.key) : [],
|
||||
applyToRemaining,
|
||||
});
|
||||
};
|
||||
|
||||
const handleMatchFingerprint = async () => {
|
||||
if (!exitIp) {
|
||||
return;
|
||||
}
|
||||
const request = requestId;
|
||||
patch({ isMatching: true });
|
||||
try {
|
||||
await invoke("match_profile_fingerprint_to_exit", {
|
||||
profileId,
|
||||
exitIp,
|
||||
});
|
||||
showSuccessToast(t("consistencyWarning.matchSuccess"));
|
||||
patch({ isMatching: false });
|
||||
// Rewriting the fingerprint takes long enough for the user to dismiss
|
||||
// this gate meanwhile. The profile change still stands, but the launch
|
||||
// it belonged to is already settled, and deciding now would answer
|
||||
// whichever gate took its place.
|
||||
if (liveRequestRef.current !== request) {
|
||||
return;
|
||||
}
|
||||
// The fingerprint the block was measured against no longer exists, so
|
||||
// this launch is abandoned rather than forced through with a stale
|
||||
// consent token; the user relaunches against the corrected profile.
|
||||
decide(false);
|
||||
} catch (e) {
|
||||
showErrorToast(translateBackendError(t, e));
|
||||
patch({ isMatching: false });
|
||||
}
|
||||
};
|
||||
|
||||
const scanNotice = (() => {
|
||||
switch (findings?.scanState) {
|
||||
case "encrypted":
|
||||
return t("prelaunchGate.scanIncompleteEncrypted");
|
||||
case "ephemeral":
|
||||
return t("prelaunchGate.scanIncompleteEphemeral");
|
||||
case "partial":
|
||||
return t("prelaunchGate.scanIncompletePartial");
|
||||
case "missing":
|
||||
return t("prelaunchGate.scanIncompleteMissing");
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
|
||||
return (
|
||||
// Dismissible on purpose: cancelling is the safe outcome, so every way out
|
||||
// of this dialog — Escape, the close X, a click outside — resolves the
|
||||
// waiting launch as "don't start". A gate that can only be answered by two
|
||||
// buttons is one disabled button away from trapping the whole app.
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
decide(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<LuTriangleAlert className="size-5 text-warning-text" />
|
||||
{isBlocked
|
||||
? t("prelaunchGate.titleBlocked")
|
||||
: t("prelaunchGate.titleWarning")}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-3 text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
{t("prelaunchGate.intro", { name: profileName })}
|
||||
</p>
|
||||
|
||||
{isBlocked && (
|
||||
<div className="space-y-2 rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
||||
<p className="font-medium">
|
||||
{t("prelaunchGate.fingerprintHeading")}
|
||||
</p>
|
||||
{mismatches.includes("timezone") && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.timezoneDetail", {
|
||||
exit: fingerprint?.exit_timezone ?? "?",
|
||||
fingerprint: fingerprint?.fingerprint_timezone ?? "?",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
{mismatches.includes("language") && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.languageDetail", {
|
||||
country: fingerprint?.exit_country_code ?? "?",
|
||||
fingerprint: fingerprint?.fingerprint_language ?? "?",
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("consistencyWarning.explainer")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vpnExtensions.length > 0 && (
|
||||
<div className="space-y-2 rounded-md border border-warning/50 bg-warning/10 p-3">
|
||||
<p className="font-medium">
|
||||
{t("prelaunchGate.vpnExtensionHeading")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prelaunchGate.vpnExtensionIntro")}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{vpnExtensions.map((ext) => (
|
||||
<ExtensionEntry key={ext.key} extension={ext} />
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prelaunchGate.vpnExtensionExplainer")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{proxyCapableExtensions.length > 0 && (
|
||||
<div className="space-y-2 rounded-md border border-border bg-muted/40 p-3">
|
||||
<p className="font-medium">
|
||||
{t("prelaunchGate.proxyCapableHeading")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prelaunchGate.proxyCapableIntro")}
|
||||
</p>
|
||||
<ul className="space-y-1">
|
||||
{proxyCapableExtensions.map((ext) => (
|
||||
<ExtensionEntry key={ext.key} extension={ext} />
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{findings?.measurementUnreliable && isBlocked && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prelaunchGate.measurementUnreliable")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{findings?.probePending && !isBlocked && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("prelaunchGate.probePending")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{scanNotice && (
|
||||
<p className="text-xs text-muted-foreground">{scanNotice}</p>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{isBlocked && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="gate-ack-fingerprint"
|
||||
checked={ackFingerprint}
|
||||
onCheckedChange={(v) => patch({ ackFingerprint: v === true })}
|
||||
/>
|
||||
<Label htmlFor="gate-ack-fingerprint" className="text-xs">
|
||||
{t("prelaunchGate.dontBlockAgain")}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{extensions.length > 0 && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="gate-ack-extensions"
|
||||
checked={ackExtensions}
|
||||
onCheckedChange={(v) => patch({ ackExtensions: v === true })}
|
||||
/>
|
||||
<Label htmlFor="gate-ack-extensions" className="text-xs">
|
||||
{t("prelaunchGate.dontWarnExtensions")}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
{remainingCount > 0 && (
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Checkbox
|
||||
id="gate-apply-remaining"
|
||||
checked={applyToRemaining}
|
||||
onCheckedChange={(v) =>
|
||||
patch({ applyToRemaining: v === true })
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="gate-apply-remaining" className="text-xs">
|
||||
{t("prelaunchGate.applyToRemaining")}
|
||||
</Label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-row justify-between sm:justify-between">
|
||||
{/* Cancel is the default action: the browser has not started, and
|
||||
not starting it is the safe outcome. Never disabled by `decided`
|
||||
— `decide` is already idempotent, and the one control that ends
|
||||
the dialog safely must not be something a stale flag can switch
|
||||
off. */}
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => decide(false)}
|
||||
disabled={isMatching}
|
||||
autoFocus
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<div className="flex gap-2">
|
||||
{isBlocked && exitIp && (
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={() => void handleMatchFingerprint()}
|
||||
disabled={isMatching || decided}
|
||||
>
|
||||
{isMatching
|
||||
? t("consistencyWarning.matching")
|
||||
: t("consistencyWarning.matchToProxy")}
|
||||
</RippleButton>
|
||||
)}
|
||||
<RippleButton
|
||||
variant={isBlocked ? "destructive" : "default"}
|
||||
onClick={() => decide(true)}
|
||||
disabled={isMatching || decided}
|
||||
>
|
||||
{t("prelaunchGate.launchAnyway")}
|
||||
</RippleButton>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -101,6 +101,7 @@ import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useRemoteHandoff } from "@/hooks/use-remote-handoff";
|
||||
import { useScrollFade } from "@/hooks/use-scroll-fade";
|
||||
import { useTableSorting } from "@/hooks/use-table-sorting";
|
||||
import { useTeamLocks } from "@/hooks/use-team-locks";
|
||||
@@ -121,6 +122,7 @@ import {
|
||||
import { DNS_BLOCKLIST_LEVELS } from "@/lib/dns-blocklist-levels";
|
||||
import { canUseCookieBot } from "@/lib/entitlements";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import type { RemoteHandoffState } from "@/lib/remote-sessions";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
@@ -240,7 +242,7 @@ interface TableMeta {
|
||||
setLaunchingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
setStoppingProfiles: React.Dispatch<React.SetStateAction<Set<string>>>;
|
||||
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
|
||||
|
||||
// Overflow actions
|
||||
onAssignProfilesToGroup?: (profileIds: string[]) => void;
|
||||
@@ -273,6 +275,15 @@ interface TableMeta {
|
||||
isProfileLockedByAnother: (profileId: string) => boolean;
|
||||
getProfileLockEmail: (profileId: string) => string | undefined;
|
||||
|
||||
// Remote execution.
|
||||
//
|
||||
// `getRemoteHandoff` is the authoritative answer to "can this be opened
|
||||
// here", read from the same store the backend gate reads. The team-lock cache
|
||||
// above cannot serve it: it refreshes on a 30-second poll and says nothing at
|
||||
// all about a session that has finished but whose work has not been pulled
|
||||
// back yet.
|
||||
getRemoteHandoff: (profileId: string) => RemoteHandoffState | null;
|
||||
|
||||
// Synchronizer
|
||||
getProfileSyncInfo: (profileId: string) =>
|
||||
| {
|
||||
@@ -1383,7 +1394,7 @@ BotCell.displayName = "BotCell";
|
||||
|
||||
interface ProfilesDataTableProps {
|
||||
profiles: BrowserProfile[];
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<unknown>;
|
||||
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onCloneProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
@@ -1585,6 +1596,10 @@ export function ProfilesDataTable({
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
const { user } = useCloudAuth();
|
||||
const { isProfileLocked, getLockInfo } = useTeamLocks(user?.id);
|
||||
// Which profiles cannot be opened on this computer, and why. Event-driven and
|
||||
// read from the backend's own gate, so the button state and the refusal the
|
||||
// backend would give can never disagree.
|
||||
const { handoffFor } = useRemoteHandoff();
|
||||
|
||||
// Cookie Bot. Enrolments and live runs both live server-side, so the table
|
||||
// reads them from the shared store rather than from BrowserProfile.
|
||||
@@ -2445,6 +2460,9 @@ export function ProfilesDataTable({
|
||||
getProfileLockEmail: (profileId: string) =>
|
||||
getLockInfo(profileId)?.lockedByEmail,
|
||||
|
||||
// Remote execution
|
||||
getRemoteHandoff: handoffFor,
|
||||
|
||||
// Synchronizer
|
||||
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
|
||||
onLaunchWithSync:
|
||||
@@ -2524,6 +2542,7 @@ export function ProfilesDataTable({
|
||||
handleCreateCountryProxy,
|
||||
isProfileLocked,
|
||||
getLockInfo,
|
||||
handoffFor,
|
||||
getProfileSyncInfo,
|
||||
onLaunchWithSync,
|
||||
cookieBotUnlocked,
|
||||
@@ -2725,20 +2744,37 @@ export function ProfilesDataTable({
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const handoff = meta.getRemoteHandoff(profile.id);
|
||||
// A profile open on the fleet IS running, and the button has to say
|
||||
// so: it is the control that stops it, and stopping now reaches the
|
||||
// remote browser rather than looking for a local process that was
|
||||
// never there.
|
||||
const isRunningRemotely = handoff === "running";
|
||||
const isPendingRemotePull = handoff === "pending_sync";
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
(meta.isClient && meta.runningProfiles.has(profile.id)) ||
|
||||
isRunningRemotely;
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isLockedByAnother = meta.isProfileLockedByAnother(profile.id);
|
||||
const isSyncing = meta.syncStatuses[profile.id]?.status === "syncing";
|
||||
const canLaunch =
|
||||
meta.browserState.canLaunchProfile(profile) &&
|
||||
!isLockedByAnother &&
|
||||
!isSyncing;
|
||||
// A remote session holds the profile lock under its own holder id, so
|
||||
// `isLockedByAnother` is true for the user's OWN fleet session. That
|
||||
// must not disable the control that stops it.
|
||||
const canLaunch = isRunningRemotely
|
||||
? true
|
||||
: meta.browserState.canLaunchProfile(profile) &&
|
||||
!isPendingRemotePull &&
|
||||
!isLockedByAnother &&
|
||||
!isSyncing;
|
||||
const lockEmail = meta.getProfileLockEmail(profile.id);
|
||||
const tooltipContent = isLockedByAnother
|
||||
? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail })
|
||||
: meta.browserState.getLaunchTooltipContent(profile);
|
||||
const tooltipContent = isRunningRemotely
|
||||
? meta.t("profiles.remote.runningTooltip")
|
||||
: isPendingRemotePull
|
||||
? meta.t("profiles.remote.pendingSyncTooltip")
|
||||
: isLockedByAnother
|
||||
? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail })
|
||||
: meta.browserState.getLaunchTooltipContent(profile);
|
||||
|
||||
const handleProfileStop = async (profile: BrowserProfile) => {
|
||||
meta.setStoppingProfiles((prev: Set<string>) =>
|
||||
|
||||
@@ -28,7 +28,9 @@ import {
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { CopyToClipboard } from "./ui/copy-to-clipboard";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
@@ -108,10 +110,15 @@ export function ProfileSelectorDialog({
|
||||
await invoke("open_url_with_profile", {
|
||||
profileId: selected.id,
|
||||
url,
|
||||
consentToken: null,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL with profile:", error);
|
||||
// This path reaches the browser without going through page.tsx's gate,
|
||||
// so a launch the gate blocks surfaces here. Without a toast the deep
|
||||
// link would simply appear to do nothing.
|
||||
showErrorToast(translateBackendError(t, error));
|
||||
} finally {
|
||||
setIsLaunching(false);
|
||||
if (selected) {
|
||||
@@ -122,7 +129,7 @@ export function ProfileSelectorDialog({
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedProfile, url, onClose, profiles]);
|
||||
}, [selectedProfile, url, onClose, profiles, t]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setSelectedProfile(null);
|
||||
|
||||
@@ -89,6 +89,12 @@ export function ProxyFormDialog({
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [form, setForm] = useState<ProxyFormData>(DEFAULT_FORM);
|
||||
// The local parse only covers scheme/host/port. Whether Donut can actually
|
||||
// use the server — REALITY, XTLS Vision, plain TCP — is decided by the Rust
|
||||
// parser, so ask it (below) and show the specific reason while the user is
|
||||
// still editing rather than after they save. Declared here because
|
||||
// `handleSubmit` guards on it.
|
||||
const [vlessUnsupported, setVlessUnsupported] = useState<string | null>(null);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setForm(DEFAULT_FORM);
|
||||
@@ -134,6 +140,11 @@ export function ProxyFormDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVless && vlessUnsupported) {
|
||||
toast.error(vlessUnsupported);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVless && (!form.host.trim() || !form.port)) {
|
||||
toast.error(t("proxies.form.hostPortRequired"));
|
||||
return;
|
||||
@@ -183,7 +194,7 @@ export function ProxyFormDialog({
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [editingProxy, form, onClose, t]);
|
||||
}, [editingProxy, form, onClose, t, vlessUnsupported]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
@@ -193,12 +204,37 @@ export function ProxyFormDialog({
|
||||
|
||||
const isVless = form.proxy_type === "vless";
|
||||
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
|
||||
|
||||
const trimmedVlessUri = form.vless_uri.trim();
|
||||
useEffect(() => {
|
||||
if (!isVless || trimmedVlessUri.length === 0) {
|
||||
setVlessUnsupported(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void invoke("validate_vless_uri", { uri: trimmedVlessUri })
|
||||
.then(() => {
|
||||
if (!cancelled) setVlessUnsupported(null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) setVlessUnsupported(translateBackendError(t, error));
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [isVless, trimmedVlessUri, t]);
|
||||
|
||||
const hasInvalidVlessUri =
|
||||
isVless && form.vless_uri.trim().length > 0 && !vlessEndpoint;
|
||||
isVless &&
|
||||
trimmedVlessUri.length > 0 &&
|
||||
(!vlessEndpoint || vlessUnsupported !== null);
|
||||
const isFormValid =
|
||||
form.name.trim() &&
|
||||
(isVless
|
||||
? vlessEndpoint !== null
|
||||
? vlessEndpoint !== null && vlessUnsupported === null
|
||||
: form.host.trim() &&
|
||||
form.port > 0 &&
|
||||
form.port <= 65535 &&
|
||||
@@ -286,7 +322,7 @@ export function ProxyFormDialog({
|
||||
role={hasInvalidVlessUri ? "alert" : undefined}
|
||||
>
|
||||
{hasInvalidVlessUri
|
||||
? t("proxies.form.vlessUriInvalid")
|
||||
? (vlessUnsupported ?? t("proxies.form.vlessUriInvalid"))
|
||||
: t("proxies.form.vlessUriHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user