Compare commits

..
Author SHA1 Message Date
zhom 11b130df46 chore: linting 2026-08-08 23:50:12 +04:00
zhom b8e5b4f4e6 chore: update pnpm 2026-08-08 22:29:53 +04:00
zhom d80e127cd3 chore: switch to ai-inference v3 and fail workflows on 410 2026-08-08 22:29:01 +04:00
zhom e11967509d chore: version bump 2026-08-08 22:28:35 +04:00
zhom 6d9a44faad fix: prevent settings page from crashing on some systems 2026-08-08 20:36:51 +04:00
zhom f8532be8af refactor: update logic and locks around vpn extensions 2026-08-08 19:27:39 +04:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
70a8deb7eb chore: update flake.nix for v0.29.0 [skip ci] (#542)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 14:11:50 +00:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
b89f002c1d docs: update CHANGELOG.md and README.md for v0.29.0 [skip ci] (#541)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 14:11:35 +00:00
zhom 3b1feb3f1b chore: version bump 2026-08-08 16:35:46 +04:00
github-actions[bot]GitHubgithub-actions[bot] <github-actions[bot]@users.noreply.github.com>
bc2b93d902 docs: update CHANGELOG.md and README.md for v0.29.0 [skip ci] (#539)
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
2026-08-08 12:26:50 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5c24e84eaf ci(deps): bump the github-actions group with 3 updates (#538)
Bumps the github-actions group with 3 updates: [pnpm/action-setup](https://github.com/pnpm/action-setup), [anomalyco/opencode/github](https://github.com/anomalyco/opencode) and [crate-ci/typos](https://github.com/crate-ci/typos).


Updates `pnpm/action-setup` from 6.0.9 to 6.0.10
- [Release notes](https://github.com/pnpm/action-setup/releases)
- [Commits](https://github.com/pnpm/action-setup/compare/0ebf47130e4866e96fce0953f49152a61190b271...0977fd99725f1db4007ccb2928dbb4e90d06cc86)

Updates `anomalyco/opencode/github` from 1.18.9 to 1.18.14
- [Release notes](https://github.com/anomalyco/opencode/releases)
- [Commits](https://github.com/anomalyco/opencode/compare/4da7bb44c84e013fa53e9c5d02ac753d1435c81a...65cf14df16c191f3e9684f0d9a8bae69103ced6d)

Updates `crate-ci/typos` from 1.48.0 to 1.49.0
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/bee27e3a4fd1ea2111cf90ab89cd076c870fce14...8a48f81b6c64dcfea44b3633223084c4be58ac5f)

---
updated-dependencies:
- dependency-name: pnpm/action-setup
  dependency-version: 6.0.10
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: anomalyco/opencode/github
  dependency-version: 1.18.14
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-08-08 09:08:52 +00:00
zhom ffbbaa732a chore: linting 2026-08-07 06:16:00 -07:00
zhom f12a84e18f refactor: cleanup 2026-08-06 14:39:53 -07:00
zhom 39bbdcb547 feat: prevent launch with inconsistent geodata 2026-08-05 21:39:10 -07:00
zhom 29cb83d063 refactor: cleanup 2026-08-03 18:44:24 +04:00
zhom 7d82a25107 feat: cookie bot 2026-08-03 07:57:45 +04:00
122 changed files with 28767 additions and 1591 deletions
+6 -1
View File
@@ -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
+1 -1
View File
@@ -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
+17 -2
View File
@@ -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
+41 -7
View File
@@ -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 }}
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+13 -3
View File
@@ -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'
+17 -2
View File
@@ -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
+13 -3
View File
@@ -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'
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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
+7
View File
@@ -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
+132
View File
@@ -1,6 +1,138 @@
# Changelog
## 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
+5 -5
View File
@@ -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.0/Donut_0.29.0_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_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.0/Donut_0.29.0_x64-setup.exe) · [Portable (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_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.0/Donut_0.29.0_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_arm64.deb) |
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut-0.29.0-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut-0.29.0-1.aarch64.rpm) |
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.29.0/Donut_0.29.0_aarch64.AppImage) |
<!-- install-links-end -->
Or install via package manager:
+4
View File
@@ -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"
+2 -1
View File
@@ -1785,7 +1785,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.28.2"
version = "0.29.0"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
@@ -1809,6 +1809,7 @@ dependencies = [
"flate2",
"futures-util",
"globset",
"gtk",
"http-body-util",
"hyper",
"hyper-util",
+38 -1
View File
@@ -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",
@@ -248,6 +251,40 @@ export const commandCoverage = {
"team_lock::get_team_lock_status",
],
},
remoteSessions: {
suite: "integrations",
level: "contract",
commands: [
"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",
],
},
cookieBot: {
suite: "integrations",
level: "contract",
commands: [
"get_cookie_bot_schedules",
"get_cookie_bot_schedule",
"save_cookie_bot_schedule",
"delete_cookie_bot_schedule",
"check_cookie_bot_conflicts",
"get_cookie_bot_runs",
"run_cookie_bot_now",
"cancel_cookie_bot_run",
"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: {
suite: "integrations",
level: "contract",
+123 -7
View File
@@ -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,
+28
View File
@@ -93,6 +93,34 @@ test("profile, group, proxy, tag, metadata, clone, and bulk-delete lifecycle", a
});
assert.ok(cachedValidity === null || cachedValidity.is_valid === false);
// 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" }),
);
+153
View File
@@ -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}`);
}
@@ -652,6 +659,152 @@ test("offline cloud, update, team-lock, trial, and synchronizer contracts are de
assert.ok(versionStatus && typeof versionStatus === "object");
assert.equal(typeof (await app.invoke("is_default_browser")), "boolean");
// Remote sessions and the cookie bot are brokered by the cloud backend.
// Signed out, every one of them must fail as a code the UI can
// translate — a raw English string from the transport would reach the
// user untranslated, which is what the {"code":…} convention prevents.
const notSignedIn = /"code":"CLOUD_NOT_SIGNED_IN"/;
const missingProfileId = "00000000-0000-0000-0000-0000000000ff";
assert.match(await app.invokeError("list_remote_sessions"), notSignedIn);
assert.match(
await app.invokeError("get_remote_session", {
sessionId: "missing-e2e-session",
}),
notSignedIn,
);
assert.match(
await app.invokeError("stop_remote_session", {
sessionId: "missing-e2e-session",
}),
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
// calls are repeated: a second start must not open a second socket, and
// a second stop must not fail.
assert.equal(await app.invoke("get_remote_session_events_status"), false);
await app.invoke("start_remote_session_events");
assert.equal(await app.invoke("get_remote_session_events_status"), true);
await app.invoke("start_remote_session_events");
assert.equal(await app.invoke("get_remote_session_events_status"), true);
await app.invoke("stop_remote_session_events");
assert.equal(await app.invoke("get_remote_session_events_status"), false);
await app.invoke("stop_remote_session_events");
assert.equal(await app.invoke("get_remote_session_events_status"), false);
assert.match(
await app.invokeError("get_cookie_bot_schedules", { scope: "mine" }),
notSignedIn,
);
assert.match(
await app.invokeError("get_cookie_bot_schedule", {
profileId: missingProfileId,
}),
notSignedIn,
);
assert.match(
await app.invokeError("delete_cookie_bot_schedule", {
profileId: missingProfileId,
}),
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,
runAtMinute: 120,
daysMask: 127,
}),
notSignedIn,
);
assert.match(
await app.invokeError("get_cookie_bot_runs", { limit: 10 }),
notSignedIn,
);
assert.match(
await app.invokeError("cancel_cookie_bot_run", {
runId: "missing-e2e-run",
}),
notSignedIn,
);
assert.match(
await app.invokeError("get_cookie_bot_presets"),
notSignedIn,
);
assert.match(
await app.invokeError("get_remote_hours_quota"),
notSignedIn,
);
assert.match(
await app.invokeError("get_cookie_bot_usage", { period: "2026-01" }),
notSignedIn,
);
// Enrolling and running act on a profile this machine holds: both are
// refused before any network call when it does not exist, so a bad id
// can never reach a leased host or an hour of the pooled budget.
assert.match(
await app.invokeError("save_cookie_bot_schedule", {
profileId: missingProfileId,
schedule: {
profile_name: "E2E missing profile",
platform: "windows",
enabled: true,
run_at_minute: 120,
days_mask: 127,
timezone: "UTC",
preset: "balanced",
max_minutes: 60,
sites: ["https://example.com"],
},
acknowledgeConflict: false,
}),
/"code":"PROFILE_NOT_FOUND"/,
);
assert.match(
await app.invokeError("run_cookie_bot_now", {
profileId: missingProfileId,
maxMinutes: 30,
}),
/"code":"PROFILE_NOT_FOUND"/,
);
const trial = await app.invoke("get_commercial_trial_status");
assert.ok(trial && typeof trial === "object");
await app.invoke("acknowledge_trial_expiration");
+29 -1
View File
@@ -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
View File
@@ -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(
() =>
+5 -5
View File
@@ -96,17 +96,17 @@
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
);
releaseVersion = "0.28.2";
releaseVersion = "0.29.0";
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.0/Donut_0.29.0_amd64.AppImage";
hash = "sha256-CPPiB7kOvlBJRZhcZAjnIIxKptwUqZOgsYdYBBJhu5M=";
}
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.0/Donut_0.29.0_aarch64.AppImage";
hash = "sha256-qzeAfe4PjsVAyDgsuIDTKlqXw+Bwqk3E5APfkLbl2oY=";
}
else
null;
+5 -3
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.28.2",
"version": "0.29.1",
"type": "module",
"scripts": {
"predev": "pnpm licenses:generate",
@@ -10,8 +10,10 @@
"prebuild": "pnpm licenses:generate",
"build": "next build",
"start": "next start",
"test": "pnpm test:themes && 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",
"licenses:generate": "node scripts/generate-licenses.mjs",
@@ -109,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;
}
+29 -28
View File
@@ -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
View File
@@ -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
+34
View File
@@ -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",
]
+4 -3
View File
@@ -1797,7 +1797,7 @@ dependencies = [
[[package]]
name = "donutbrowser"
version = "0.28.2"
version = "0.29.1"
dependencies = [
"aes 0.9.1",
"aes-gcm 0.11.0",
@@ -1821,6 +1821,7 @@ dependencies = [
"flate2",
"futures-util",
"globset",
"gtk",
"http-body-util",
"hyper",
"hyper-util",
@@ -4092,7 +4093,7 @@ version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
dependencies = [
"proc-macro-crate 3.5.0",
"proc-macro-crate 1.3.1",
"proc-macro2",
"quote",
"syn 2.0.118",
@@ -6860,7 +6861,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [
"fastrand",
"getrandom 0.4.3",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
+5 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.28.2"
version = "0.29.1"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -116,6 +116,10 @@ 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"
[target.'cfg(target_os = "macos")'.dependencies]
core-foundation = "0.10"
objc2 = "0.6.4"
+1
View File
@@ -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",
+45 -14
View File
@@ -121,6 +121,46 @@ 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;
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})`,
);
}
writeFileSync(archive, Buffer.from(await response.arrayBuffer()));
const actual = sha256(archive);
if (actual !== expectedSha256) {
throw new Error(
`Xray-core checksum mismatch: expected ${expectedSha256}, got ${actual}`,
);
}
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) {
@@ -157,20 +197,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)) {
File diff suppressed because it is too large Load Diff
+82 -2
View File
@@ -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 {
+368 -64
View File
@@ -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.
@@ -546,11 +625,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 +769,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 +779,9 @@ impl BrowserRunner {
app_handle,
profile,
url,
None,
remote_debugging_port,
headless,
gate,
)
.await
}
@@ -709,6 +792,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 +873,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 +906,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 +1340,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 +1353,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 +1384,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 +1425,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 +1549,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 +1580,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 +1597,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 +1632,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 +1812,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
+66 -8
View File
@@ -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)]
@@ -43,10 +43,26 @@ pub struct Entitlements {
pub cloud_backup: bool,
#[serde(rename = "teamCollaboration", default)]
pub team_collaboration: bool,
/// Overnight profile warming on a leased remote host. Present on the wire
/// since the cookie-bot release; a field missing here is silently dropped on
/// the way to the UI, which is why every mirror of this struct has to move
/// 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)]
pub requests_per_hour: i64,
/// Per-seat monthly remote-session allowance. Reporting only — a team pools
/// it across seats, so the spendable figure comes from the quota route.
#[serde(rename = "remoteBrowserHours", default)]
pub remote_browser_hours: i64,
}
/// Local fallback mirror of the backend plan -> capability matrix, used only when
@@ -66,15 +82,30 @@ fn derive_entitlements(
cross_os_fingerprints: false,
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,
@@ -82,12 +113,17 @@ fn derive_entitlements(
cross_os_fingerprints,
cloud_backup,
team_collaboration,
cookie_bot,
remote_interactive,
profile_limit,
requests_per_hour: if browser_automation {
DEFAULT_REQUESTS_PER_HOUR
} else {
0
},
// Deliberately 0 in the fallback: the allowance is the server's to state and
// guessing it here would show a customer hours they may not have.
remote_browser_hours: 0,
}
}
@@ -137,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(
@@ -776,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()
+438
View File
@@ -0,0 +1,438 @@
//! Turning a donutbrowser-infra HTTP failure into a stable, translatable code.
//!
//! Every cloud transport in this crate flattens its failures through
//! `api_call_with_retry`, which needs a `String` so it can sniff for a 401.
//! That flattening loses the status, and the body it carries is the backend's
//! own English — which would reach the user untranslated, the exact bug the
//! `{"code":…}` convention exists to prevent.
//!
//! So the backend sends a machine code, this module recovers it, and the
//! frontend resolves it through `translateBackendError`. When the backend
//! sends something else (a proxy error page, a gateway 502), the status alone
//! still picks a code the user can act on.
use serde_json::Value;
use std::collections::BTreeMap;
/// A backend failure reduced to the shape `translateBackendError` consumes.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BackendFailure {
/// The HTTP status it came from. 0 when the request never got that far.
pub status: u16,
pub code: String,
pub params: BTreeMap<String, String>,
}
impl BackendFailure {
/// Render as the `{"code":…,"params":{…}}` string a Tauri command returns.
pub fn to_error_json(&self) -> String {
let mut object = serde_json::Map::new();
object.insert("code".to_string(), Value::String(self.code.clone()));
if !self.params.is_empty() {
let params = self
.params
.iter()
.map(|(k, v)| (k.clone(), Value::String(v.clone())))
.collect::<serde_json::Map<_, _>>();
object.insert("params".to_string(), Value::Object(params));
}
Value::Object(object).to_string()
}
}
/// Which code a status maps to when the body carries none.
///
/// 404 and 409 mean different things per route — "no schedule for this
/// profile" and "that run id is not yours" are both 404 — so each caller
/// supplies its own, rather than every route sharing one vague code.
#[derive(Debug, Clone, Copy)]
pub struct FailureCodes {
pub bad_request: &'static str,
pub forbidden: &'static str,
pub not_found: &'static str,
pub conflict: &'static str,
}
/// The desktop has no cloud session at all.
pub const NOT_SIGNED_IN: &str = "CLOUD_NOT_SIGNED_IN";
/// The request never reached donutbrowser-infra.
pub const UNREACHABLE: &str = "CLOUD_UNREACHABLE";
/// The backend answered, but with nothing the user can act on.
pub const UNAVAILABLE: &str = "CLOUD_REQUEST_FAILED";
/// Too many automation requests, backend side.
pub const RATE_LIMITED: &str = "REMOTE_RATE_LIMITED";
/// No host of the profile's OS has a free slot.
pub const NO_CAPACITY: &str = "REMOTE_NO_CAPACITY";
/// Recover `(status, body)` from the string `api_call_with_retry` hands back.
///
/// The transports encode a non-2xx as `"(503) no macos host free"` so the
/// helper can spot a 401 and still let the caller recover the kind. Anything
/// that is not that shape is a transport failure, not a status.
pub fn split_status(message: &str) -> Option<(u16, &str)> {
let rest = message.strip_prefix('(')?;
let (code, tail) = rest.split_once(')')?;
let status = code.trim().parse::<u16>().ok()?;
Some((status, tail.trim()))
}
/// Classify one HTTP failure.
pub fn classify(status: u16, body: &str, codes: FailureCodes) -> BackendFailure {
if let Some(failure) = from_body(status, body) {
return failure;
}
BackendFailure {
status,
code: code_for_status(status, codes).to_string(),
params: BTreeMap::new(),
}
}
/// Classify a flattened error string, whether or not it encodes a status.
///
/// Some callers strip the status before they get here (a typed error that
/// kept only the body), so a bare `{"code":…}` envelope is still recognised.
pub fn classify_message(message: &str, codes: FailureCodes) -> BackendFailure {
if let Some((status, body)) = split_status(message) {
return classify(status, body, codes);
}
if let Some(failure) = from_body(0, message) {
return failure;
}
transport_failure(message)
}
/// A failure that never became an HTTP response.
///
/// `api_call_with_retry` reports a missing token as plain text, so the
/// signed-out case is recognised here rather than surfacing as "something went
/// wrong" — being signed out is a state the user can fix.
pub fn transport_failure(message: &str) -> BackendFailure {
let code = if message.contains("Not logged in") || message.contains("No refresh token") {
NOT_SIGNED_IN
} else {
UNREACHABLE
};
BackendFailure {
status: 0,
code: code.to_string(),
params: BTreeMap::new(),
}
}
fn code_for_status(status: u16, codes: FailureCodes) -> &'static str {
match status {
400 | 422 => codes.bad_request,
401 => NOT_SIGNED_IN,
402 | 403 => codes.forbidden,
404 => codes.not_found,
409 => codes.conflict,
429 => RATE_LIMITED,
503 => NO_CAPACITY,
_ => UNAVAILABLE,
}
}
/// Read the backend's own `{"code":…}` envelope when it sent one.
fn from_body(status: u16, body: &str) -> Option<BackendFailure> {
let parsed = serde_json::from_str::<Value>(body).ok()?;
let object = parsed.as_object()?;
let code = object.get("code")?.as_str()?;
if code.is_empty() {
return None;
}
let mut params = BTreeMap::new();
// The nested shape first, so a top-level key of the same name still wins.
//
// The cookie-bot routes send every interpolated value under `params`
// (`{"code":…,"params":{…}}`) while the remote-session routes spread theirs
// at the top level. Only the flat one was read, so
// COOKIE_BOT_INVALID_TIMEZONE rendered with an empty timezone name,
// COOKIE_BOT_SITE_LIMIT always showed the hardcoded fallback, and a team out
// of hours was told it had "used 0 of 0".
if let Some(Value::Object(nested)) = object.get("params") {
collect_scalars(nested, &mut params);
}
for (key, value) in object {
if key == "code" || key == "params" {
continue;
}
if let Value::Array(items) = value {
if key == "conflicts" {
collect_conflict_params(items, &mut params);
}
continue;
}
if let Some(text) = scalar(value) {
params.insert(key.clone(), text);
}
}
Some(BackendFailure {
status,
code: code.to_string(),
params,
})
}
/// A JSON value that can be substituted into a translated sentence.
///
/// An object or an array has no rendering, so it is dropped rather than
/// stringified into the user's face.
fn scalar(value: &Value) -> Option<String> {
match value {
Value::String(text) => Some(text.clone()),
Value::Number(number) => Some(number.to_string()),
Value::Bool(flag) => Some(flag.to_string()),
_ => None,
}
}
fn collect_scalars(object: &serde_json::Map<String, Value>, params: &mut BTreeMap<String, String>) {
for (key, value) in object {
if let Some(text) = scalar(value) {
params.insert(key.clone(), text);
}
}
}
/// Name the teammate whose enrolment blocks this one.
///
/// A schedule conflict is only actionable if the user learns WHO and WHEN, and
/// the list arrives as an array the generic scalar copy would drop. Only the
/// first entry is surfaced; the full list is in the response body for the UI.
fn collect_conflict_params(items: &[Value], params: &mut BTreeMap<String, String>) {
params.insert("conflict_count".to_string(), items.len().to_string());
let Some(first) = items.first().and_then(Value::as_object) else {
return;
};
if let Some(email) = first.get("email").and_then(Value::as_str) {
params.insert("email".to_string(), email.to_string());
}
if let Some(timezone) = first.get("timezone").and_then(Value::as_str) {
params.insert("timezone".to_string(), timezone.to_string());
}
if let Some(minute) = first.get("run_at_minute").and_then(Value::as_u64) {
params.insert("run_at_minute".to_string(), minute.to_string());
params.insert("time".to_string(), format_minute_of_day(minute));
}
}
/// Minute-of-day to a zero-padded 24h clock reading.
///
/// The value is a wall-clock offset in the conflicting enrolment's own
/// timezone, so there is no date and nothing to convert — only to render.
pub fn format_minute_of_day(minute: u64) -> String {
let minute = minute % 1440;
format!("{:02}:{:02}", minute / 60, minute % 60)
}
#[cfg(test)]
mod tests {
use super::*;
const CODES: FailureCodes = FailureCodes {
bad_request: "BAD",
forbidden: "FORBIDDEN",
not_found: "MISSING",
conflict: "CLASH",
};
#[test]
fn the_backends_own_code_wins_over_the_status_default() {
// The status table is a fallback for gateway pages. When infra names the
// failure, that name is the one the user's locale has a string for.
let failure = classify(403, r#"{"code":"COOKIE_BOT_NOT_ENTITLED"}"#, CODES);
assert_eq!(failure.code, "COOKIE_BOT_NOT_ENTITLED");
assert_eq!(failure.status, 403);
}
#[test]
fn a_body_without_a_code_falls_back_to_the_routes_own_meaning() {
// 404 means "no schedule" on one route and "no such run" on another;
// sharing one code would tell the user the wrong thing on one of them.
assert_eq!(classify(404, "Not Found", CODES).code, "MISSING");
assert_eq!(classify(409, "", CODES).code, "CLASH");
assert_eq!(classify(400, "<html>", CODES).code, "BAD");
}
#[test]
fn capacity_and_rate_limits_are_never_reported_as_a_fault() {
// 503 is "come back in a minute" — the fleet is four Windows hosts wide,
// so a busy fleet is normal and must not look like an outage.
assert_eq!(classify(503, "", CODES).code, NO_CAPACITY);
assert_eq!(classify(429, "", CODES).code, RATE_LIMITED);
}
#[test]
fn an_unauthenticated_response_is_always_the_signed_out_code() {
// Never the route's forbidden code: "sign in" and "upgrade your plan" are
// different instructions and the user can only follow one of them.
assert_eq!(classify(401, "", CODES).code, NOT_SIGNED_IN);
assert_eq!(classify(402, "", CODES).code, "FORBIDDEN");
}
#[test]
fn scalar_body_fields_become_translation_params() {
let failure = classify(
403,
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"used":201.5,"pooled":true}"#,
CODES,
);
assert_eq!(
failure.params.get("granted").map(String::as_str),
Some("200")
);
assert_eq!(
failure.params.get("used").map(String::as_str),
Some("201.5")
);
assert_eq!(
failure.params.get("pooled").map(String::as_str),
Some("true")
);
}
#[test]
fn nested_params_are_read_because_that_is_the_shape_cookie_bot_sends() {
// `body(code, params)` in cookie-bot.errors.ts returns `{code, params}`,
// which Nest serialises verbatim. Reading only the top level dropped every
// interpolated value: the timezone the user typed, the site limit, the
// hours a team had actually spent.
let failure = classify(
400,
r#"{"code":"COOKIE_BOT_INVALID_TIMEZONE","params":{"timezone":"Europe/Nowhere"}}"#,
CODES,
);
assert_eq!(failure.code, "COOKIE_BOT_INVALID_TIMEZONE");
assert_eq!(
failure.params.get("timezone").map(String::as_str),
Some("Europe/Nowhere")
);
let limit = classify(
400,
r#"{"code":"COOKIE_BOT_SITE_LIMIT","params":{"min":1,"max":40}}"#,
CODES,
);
assert_eq!(limit.params.get("min").map(String::as_str), Some("1"));
assert_eq!(limit.params.get("max").map(String::as_str), Some("40"));
let hours = classify(
403,
r#"{"code":"REMOTE_HOURS_EXHAUSTED","params":{"granted":200,"used":214.5}}"#,
CODES,
);
assert_eq!(hours.params.get("granted").map(String::as_str), Some("200"));
assert_eq!(hours.params.get("used").map(String::as_str), Some("214.5"));
}
#[test]
fn both_body_shapes_coexist_and_the_top_level_one_wins() {
// The two planes disagree about where params live, and neither is going to
// change for the other. A key present in both must resolve once.
let failure = classify(
403,
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200,"params":{"granted":1,"used":5}}"#,
CODES,
);
assert_eq!(
failure.params.get("granted").map(String::as_str),
Some("200")
);
assert_eq!(failure.params.get("used").map(String::as_str), Some("5"));
}
#[test]
fn a_non_scalar_param_is_dropped_rather_than_rendered_as_json() {
// These values are substituted into a translated sentence. An object has
// no rendering, and `[object Object]` in a toast is worse than nothing.
let failure = classify(
400,
r#"{"code":"COOKIE_BOT_INVALID_SCHEDULE","params":{"field":"sites","detail":{"a":1},"list":[1,2]}}"#,
CODES,
);
assert_eq!(
failure.params.get("field").map(String::as_str),
Some("sites")
);
assert!(!failure.params.contains_key("detail"));
assert!(!failure.params.contains_key("list"));
}
#[test]
fn a_schedule_conflict_names_the_teammate_and_the_time() {
// Without these the dialog can only say "someone else already warms this
// profile", which is not something the user can act on.
let failure = classify(
409,
r#"{"code":"COOKIE_BOT_SCHEDULE_CONFLICT","conflicts":[{"email":"alex@example.com","run_at_minute":120,"timezone":"Europe/Berlin"}]}"#,
CODES,
);
assert_eq!(
failure.params.get("email").map(String::as_str),
Some("alex@example.com")
);
assert_eq!(
failure.params.get("time").map(String::as_str),
Some("02:00")
);
assert_eq!(
failure.params.get("conflict_count").map(String::as_str),
Some("1")
);
}
#[test]
fn minute_of_day_renders_as_a_padded_clock_reading() {
assert_eq!(format_minute_of_day(0), "00:00");
assert_eq!(format_minute_of_day(9 * 60 + 5), "09:05");
assert_eq!(format_minute_of_day(1439), "23:59");
}
#[test]
fn a_status_encoded_message_round_trips_to_its_code() {
assert_eq!(
classify_message(r#"(409) {"code":"COOKIE_BOT_RUN_IN_PROGRESS"}"#, CODES).code,
"COOKIE_BOT_RUN_IN_PROGRESS"
);
}
#[test]
fn a_signed_out_desktop_is_told_to_sign_in_not_that_the_network_failed() {
assert_eq!(classify_message("Not logged in", CODES).code, NOT_SIGNED_IN);
assert_eq!(
classify_message("reach backend: connection refused", CODES).code,
UNREACHABLE
);
}
#[test]
fn the_rendered_json_is_what_translate_backend_error_parses() {
let failure = classify(404, r#"{"code":"COOKIE_BOT_NOT_ENROLLED"}"#, CODES);
assert_eq!(
failure.to_error_json(),
r#"{"code":"COOKIE_BOT_NOT_ENROLLED"}"#
);
let with_params = classify(
403,
r#"{"code":"REMOTE_HOURS_EXHAUSTED","granted":200}"#,
CODES,
);
let parsed: Value = serde_json::from_str(&with_params.to_error_json())
.expect("the rendered error must be valid JSON");
assert_eq!(parsed["code"], "REMOTE_HOURS_EXHAUSTED");
assert_eq!(parsed["params"]["granted"], "200");
}
#[test]
fn split_status_does_not_misread_ordinary_prose() {
assert_eq!(split_status("(503) busy"), Some((503, "busy")));
assert_eq!(split_status("(nope) busy"), None);
assert_eq!(split_status("decode response: expected value"), None);
}
}
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -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
View File
@@ -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();
+159 -19
View File
@@ -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();
+11
View File
@@ -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
+58 -30
View File
@@ -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())
+417 -147
View File
@@ -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");
}
}
+538
View File
@@ -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, &regenerated, "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:?}"
);
}
}
+280
View File
@@ -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());
}
}
+417 -2
View File
@@ -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,6 +75,8 @@ 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;
@@ -72,6 +85,8 @@ 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,9 +95,12 @@ 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;
mod commercial_license;
mod cookie_bot;
mod cookie_manager;
pub mod events;
mod mcp_integrations;
@@ -91,6 +109,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;
@@ -310,6 +329,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())
@@ -1286,6 +1315,264 @@ async fn generate_sample_fingerprint(
}
}
// --- Remote sessions --------------------------------------------------------
//
// Everything below is transport only. The session state machine, the fleet, the
// schedule, the browsing behaviour and the budget all live behind
// donutbrowser-infra; these commands carry the user's own scalars there and
// render back what the server says.
/// Turn a remote-session failure into the code the frontend translates.
///
/// The typed variants carry the backend's own English, which reaches the user
/// untranslated if it is surfaced as-is. The raw text is kept in the app log,
/// where support can read it, and never in the toast.
fn remote_session_error(context: &str, err: remote_session::RemoteSessionError) -> String {
log::warn!("Remote session {context} failed: {err}");
err.to_error_json()
}
/// Every remote session the signed-in user currently owns.
#[tauri::command]
async fn list_remote_sessions() -> Result<Vec<remote_session::RemoteSessionState>, String> {
remote_session::list_remote_sessions()
.await
.map_err(|e| remote_session_error("list", e))
}
/// One session's real state.
///
/// The stream is how the desktop normally learns a transition; this is the
/// one-shot read for a window opened after the fact, or a reconnect confirming
/// what it missed.
#[tauri::command]
async fn get_remote_session(
session_id: String,
) -> Result<remote_session::RemoteSessionState, String> {
remote_session::get_remote_session(&session_id)
.await
.map_err(|e| remote_session_error("read", e))
}
/// Stop a remote session and settle what it cost.
///
/// Without this the only thing that ends a session is the fleet's two-hour cap,
/// 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> {
let outcome = remote_session::end_remote_session(&session_id)
.await
.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.
///
/// Called once the desktop has a cloud session: signed out there is nothing to
/// stream and the socket would only be refused on a loop.
#[tauri::command]
fn start_remote_session_events(app_handle: tauri::AppHandle) {
remote_session::start_session_events(app_handle);
}
/// Unsubscribe. Safe when nothing is running; called on sign-out.
#[tauri::command]
fn stop_remote_session_events() {
remote_session::stop_session_events();
}
/// Whether the desktop is subscribed to session transitions.
///
/// A UI that mounts after the stream started has no `remote-session-stream`
/// event to read, so this is how it decides whether to trust the live state or
/// fall back to `list_remote_sessions`.
#[tauri::command]
fn get_remote_session_events_status() -> bool {
remote_session::session_events_running()
}
// --- Cookie bot -------------------------------------------------------------
/// Turn a cookie-bot failure into the code the frontend translates.
fn cookie_bot_error(context: &str, err: cookie_bot::CookieBotError) -> String {
log::warn!(
"Cookie bot {context} failed: {} (HTTP {})",
err.code(),
err.status()
);
err.to_error_json()
}
/// The local profile a cookie-bot write refers to.
///
/// Enrolment and run-now act on a profile this machine holds: the client-side
/// preconditions read its sync mode, OS and exit node, and none of that can be
/// checked for a profile that is not here.
fn cookie_bot_profile(profile_id: &str) -> Result<profile::BrowserProfile, String> {
let profiles = profile::manager::ProfileManager::instance()
.list_profiles()
.map_err(|e| wrap_backend_error(e, "Failed to read profiles"))?;
profiles
.into_iter()
.find(|p| p.id.to_string() == profile_id)
.ok_or_else(|| backend_error("PROFILE_NOT_FOUND"))
}
/// Every enrolment the caller can see. `scope` is `mine` or `team`.
#[tauri::command]
async fn get_cookie_bot_schedules(
scope: Option<String>,
) -> Result<cookie_bot::CookieBotScheduleList, String> {
cookie_bot::list_schedules(scope.as_deref())
.await
.map_err(|e| cookie_bot_error("schedule list", e))
}
/// This profile's enrolment, or `None` when it has none.
#[tauri::command]
async fn get_cookie_bot_schedule(
profile_id: String,
) -> Result<Option<cookie_bot::CookieBotSchedule>, String> {
cookie_bot::get_schedule(&profile_id)
.await
.map_err(|e| cookie_bot_error("schedule read", e))
}
/// Create or replace this profile's enrolment.
///
/// `acknowledge_conflict` is the second half of a two-step write: a teammate's
/// existing enrolment refuses the first PUT and names them, and the same call
/// with the flag set goes through.
#[tauri::command]
async fn save_cookie_bot_schedule(
profile_id: String,
schedule: cookie_bot::CookieBotScheduleInput,
acknowledge_conflict: bool,
) -> Result<cookie_bot::CookieBotScheduleSaved, String> {
// 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::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.
let schedule = schedule.with_profile_state(cookie_bot::profile_state(&profile));
cookie_bot::save_schedule(&profile_id, &schedule, acknowledge_conflict)
.await
.map_err(|e| cookie_bot_error("schedule write", e))
}
/// Turn the bot off for this profile. `false` means there was nothing enrolled.
#[tauri::command]
async fn delete_cookie_bot_schedule(profile_id: String) -> Result<bool, String> {
cookie_bot::delete_schedule(&profile_id)
.await
.map(|outcome| outcome.deleted)
.map_err(|e| cookie_bot_error("schedule delete", e))
}
/// Who else already warms this profile, without writing anything.
#[tauri::command]
async fn check_cookie_bot_conflicts(
profile_id: String,
run_at_minute: Option<u16>,
timezone: Option<String>,
days_mask: Option<u8>,
) -> Result<Vec<cookie_bot::CookieBotConflict>, String> {
cookie_bot::check_conflicts(&profile_id, run_at_minute, timezone.as_deref(), days_mask)
.await
.map(|check| check.conflicts)
.map_err(|e| cookie_bot_error("conflict check", e))
}
/// One page of run history, newest first.
#[tauri::command]
async fn get_cookie_bot_runs(
profile_id: Option<String>,
scope: Option<String>,
limit: Option<u32>,
before: Option<String>,
) -> Result<cookie_bot::CookieBotRunPage, String> {
cookie_bot::list_runs(
profile_id.as_deref(),
scope.as_deref(),
limit,
before.as_deref(),
)
.await
.map_err(|e| cookie_bot_error("run list", e))
}
/// Start a run now instead of waiting for tonight.
///
/// The preset and the site list come from the stored enrolment, so an
/// unenrolled profile is refused rather than run with client-chosen defaults.
#[tauri::command]
async fn run_cookie_bot_now(
profile_id: String,
max_minutes: Option<u32>,
) -> Result<cookie_bot::CookieBotRunStarted, String> {
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))
}
/// Stop a run that is still going.
#[tauri::command]
async fn cancel_cookie_bot_run(run_id: String) -> Result<cookie_bot::CookieBotRun, String> {
cookie_bot::cancel_run(&run_id)
.await
.map_err(|e| cookie_bot_error("run cancel", e))
}
/// The intensities the server offers today. Opaque ids and a typical duration —
/// what each one actually does is the server's to know.
#[tauri::command]
async fn get_cookie_bot_presets() -> Result<cookie_bot::CookieBotPresetList, String> {
cookie_bot::list_presets()
.await
.map_err(|e| cookie_bot_error("preset list", e))
}
/// The pooled remote-hour budget: bot and interactive sessions share one pool.
///
/// Being refused a launch must not be the only way to learn a limit exists.
#[tauri::command]
async fn get_remote_hours_quota() -> Result<cookie_bot::RemoteHoursQuota, String> {
cookie_bot::remote_hours_quota()
.await
.map_err(|e| cookie_bot_error("quota read", e))
}
/// Per-member and per-profile spend for a calendar month (`YYYY-MM`).
#[tauri::command]
async fn get_cookie_bot_usage(
period: Option<String>,
) -> Result<cookie_bot::CookieBotUsage, String> {
cookie_bot::team_usage(period.as_deref())
.await
.map_err(|e| cookie_bot_error("usage read", e))
}
/// Confirm a quit chosen from the close-confirmation dialog and exit the app.
#[tauri::command]
fn confirm_quit(app_handle: tauri::AppHandle) {
@@ -1505,7 +1792,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(),
);
@@ -1545,9 +1837,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();
@@ -1580,6 +1884,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")]
{
@@ -2286,6 +2628,18 @@ pub fn run_with_builder(
}
};
tokio::join!(sync_token_fut, proxy_fut, wayfern_fut);
// Subscribe to remote-session transitions. Started here rather than
// unconditionally because a signed-out desktop has nothing to stream
// 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;
});
@@ -2400,8 +2754,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,
@@ -2478,6 +2835,34 @@ pub fn run_with_builder(
dns_blocklist::set_custom_dns_config,
dns_blocklist::import_custom_dns_rules,
dns_blocklist::export_custom_dns_rules,
// Remote session commands
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,
// Cookie bot commands
get_cookie_bot_schedules,
get_cookie_bot_schedule,
save_cookie_bot_schedule,
delete_cookie_bot_schedule,
check_cookie_bot_conflicts,
get_cookie_bot_runs,
run_cookie_bot_now,
cancel_cookie_bot_run,
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,
@@ -2490,6 +2875,12 @@ pub fn run_with_builder(
.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(|_app_handle, _event| {
// Drop the session stream before the runtime goes away, so a shutdown
// never waits out a reconnect backoff that is about to be pointless.
if let tauri::RunEvent::Exit = _event {
remote_session::stop_session_events();
}
#[cfg(target_os = "macos")]
if let tauri::RunEvent::Reopen { .. } = _event {
if let Some(window) = _app_handle.get_webview_window("main") {
@@ -2523,6 +2914,30 @@ mod tests {
);
}
#[test]
fn the_frontend_listens_for_the_remote_session_events_that_are_emitted() {
// These names are the whole of BUG-2's fix: the backend answers a launch
// with `provisioning` and nothing else, so a desktop that subscribes to a
// name the emitter does not use is blind between launch and stop and shows
// nothing at all. Renaming one side is silent everywhere else.
let client = fs::read_to_string("../src/lib/remote-sessions.ts")
.expect("the frontend remote-session client must exist");
for event in [
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}\"")),
"no frontend listener for the emitted event {event}"
);
}
}
#[test]
fn test_no_unused_tauri_commands() {
check_unused_commands(false); // Run in strict mode for CI
+1106 -415
View File
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -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 ")
+14
View File
@@ -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();
@@ -1189,6 +1193,12 @@ impl ProfileManager {
crate::sync::queue_profile_sync_if_eligible(&profile);
// The cookie bot refuses a run on a profile with no exit node, using the
// copy of that fact the desktop last declared. Detaching a proxy has to
// move that copy, or tonight's run egresses from the leased host's own
// datacenter address.
crate::cookie_bot::report_profile_state(&profile);
// Auto-enable sync for new proxy if profile has sync enabled
if profile.is_sync_enabled() {
if let Some(ref new_proxy_id) = proxy_id {
@@ -1250,6 +1260,10 @@ impl ProfileManager {
crate::sync::queue_profile_sync_if_eligible(&profile);
// Same reason as the proxy path: a VPN is the profile's exit node too, and
// the server only knows what this machine last told it.
crate::cookie_bot::report_profile_state(&profile);
// Auto-enable sync for the new VPN if profile has sync enabled.
if profile.is_sync_enabled() {
if let Some(ref new_vpn_id) = vpn_id {
+18 -3
View File
@@ -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.
+511
View File
@@ -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());
}
}
+561
View File
@@ -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));
}
}
File diff suppressed because it is too large Load Diff
+12
View File
@@ -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,
+96 -11
View File
@@ -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());
@@ -313,7 +331,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 +468,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 +506,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 +518,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 +634,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 +646,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 +814,7 @@ impl SyncEngine {
);
log::info!("Profile {} synced successfully", profile_id);
Ok(())
Ok(ProfileSyncOutcome::Completed)
}
async fn download_manifest(
@@ -3321,6 +3366,12 @@ pub async fn set_profile_sync_mode(
.save_profile(&profile)
.map_err(|e| format!("Failed to save profile: {e}"))?;
// The bot materialises the profile from donut-sync, so switching sync off (or
// to Encrypted, which the host cannot decrypt) is a refusal reason. The server
// holds only the copy this machine declared; without this, an enrolment keeps
// claiming a syncable profile every night after the user turned sync off.
crate::cookie_bot::report_profile_state(&profile);
let _ = events::emit("profiles-changed", ());
// When (re-)enabling sync, clear any stale tombstone from a previous
@@ -3540,6 +3591,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,
+101 -6
View File
@@ -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";
+9 -5
View File
@@ -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};
+18
View File
@@ -72,6 +72,24 @@ impl SyncScheduler {
self.running.store(false, Ordering::SeqCst);
}
/// Whether this specific profile is mid-sync or queued to sync.
///
/// A remote host materialises the profile by pulling the synced manifest, so
/// launching one while the upload is still running hands it a torn snapshot:
/// the manifest is written last, but a launch that races a *queued* sync can
/// still pull files that are about to be replaced. Either way the browser
/// comes up on a profile that never existed on this machine.
///
/// Deliberately per-profile rather than the global
/// {@link Self::is_sync_in_progress}: an unrelated profile uploading 80 MB
/// must not block launching this one.
pub async fn is_profile_sync_in_progress(&self, profile_id: &str) -> bool {
if self.in_flight_profiles.lock().await.contains(profile_id) {
return true;
}
self.pending_profiles.lock().await.contains_key(profile_id)
}
/// Check if any sync operation is currently in progress
pub async fn is_sync_in_progress(&self) -> bool {
let in_flight = self.in_flight_profiles.lock().await;
+2 -2
View File
@@ -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
View File
@@ -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());
}
}
+176
View File
@@ -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)
}
+529
View File
@@ -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);
}
}
+59 -3
View File
@@ -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>> {
+182
View File
@@ -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,
}
}
}
+39
View File
@@ -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
View File
@@ -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(&parameters, "security", "reality")?;
require_value(&parameters, "flow", VlessFlow::Vision.as_str())?;
optional_value(&parameters, "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(&parameters, "security", "reality")?;
require_value(&parameters, "flow", VlessFlow::Vision.as_str())?;
optional_value(&parameters, "encryption", "none")?;
optional_value(&parameters, "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(&parameters, "sni")?.to_string();
let public_key = required_parameter(&parameters, "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])
}
+3 -3
View File
@@ -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);
+35
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.28.2",
"version": "0.29.1",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
+45 -4
View File
@@ -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: {}",
+414 -45
View File
@@ -13,11 +13,7 @@ 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";
import { CreateProfileDialog } from "@/components/create-profile-dialog";
@@ -32,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,
@@ -55,6 +56,7 @@ import { WindowResizeWarningDialog } from "@/components/window-resize-warning-di
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { useGroupEvents } from "@/hooks/use-group-events";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
@@ -65,8 +67,8 @@ 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 { getEntitlements } from "@/lib/entitlements";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import { canUseCookieBot, getEntitlements } from "@/lib/entitlements";
import { MOTION_EASE_OUT } from "@/lib/motion";
import {
ONBOARDING_TOUR_CLOSED_EVENT,
@@ -86,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";
@@ -250,9 +287,17 @@ 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
// to the shared cookie-bot store too. It is a module singleton, so this costs
// one more listener and no extra request. This is also what starts the event
// stream for a user who signs in without restarting the app.
const { liveSessions: cookieBotLiveSessions } = useCookieBot(
canUseCookieBot(cloudUser),
cookieBotScopeFor(cloudUser),
);
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
useState(false);
@@ -269,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);
@@ -283,6 +333,9 @@ export default function Home() {
const [integrationsInitialTab, setIntegrationsInitialTab] = useState<
"api" | "mcp"
>("api");
const [cookieBotDialogOpen, setCookieBotDialogOpen] = useState(false);
const [cookieBotInitialTab, setCookieBotInitialTab] =
useState<CookieBotTab>("overview");
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
@@ -352,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] =
@@ -379,6 +462,7 @@ export default function Home() {
setIntegrationsDialogOpen(false);
setImportProfileDialogOpen(false);
setAccountDialogOpen(false);
setCookieBotDialogOpen(false);
setCurrentPage(page);
switch (page) {
@@ -397,6 +481,9 @@ export default function Home() {
case "groups":
setGroupManagementDialogOpen(true);
break;
case "cookieBot":
setCookieBotDialogOpen(true);
break;
case "integrations":
setIntegrationsDialogOpen(true);
break;
@@ -462,6 +549,19 @@ export default function Home() {
case "goGroups":
handleRailNavigate("groups");
break;
case "goCookieBot": {
// Mod+B: navigate first time; flip overview↔activity while already
// there, matching how Mod+I flips the integrations tabs.
if (currentPage === "cookieBot") {
setCookieBotInitialTab((cur) =>
cur === "overview" ? "activity" : "overview",
);
} else {
setCookieBotInitialTab("overview");
handleRailNavigate("cookieBot");
}
break;
}
case "goIntegrations": {
// Mod+I: flip api↔mcp tab when already on integrations.
if (currentPage === "integrations") {
@@ -903,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
@@ -917,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);
@@ -936,7 +1181,7 @@ export default function Home() {
setWindowResizeWarningOpen(true);
});
if (!proceed) {
return;
return { status: "cancelled" };
}
}
} catch (error) {
@@ -944,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(
@@ -976,7 +1308,7 @@ export default function Home() {
throw err;
}
},
[t],
[persistGateAcks, requestGateDecision, t],
);
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
@@ -1173,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(
@@ -1636,6 +1987,7 @@ export default function Home() {
onOpenAbout={() => {
setAboutDialogOpen(true);
}}
cookieBotRunning={Object.keys(cookieBotLiveSessions).length > 0}
/>
<main className="flex min-w-0 flex-1 flex-col overflow-hidden">
{currentPage === "profiles" && (
@@ -1666,6 +2018,7 @@ export default function Home() {
isUpdating={isUpdating}
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
onAssignProfilesToGroup={handleAssignProfilesToGroup}
onAssignProfilesToProxy={handleAssignProfilesToProxy}
selectedGroupId={selectedGroupId}
selectedProfiles={selectedProfiles}
onSelectedProfilesChange={setSelectedProfiles}
@@ -1786,6 +2139,22 @@ export default function Home() {
/>
)}
{cookieBotDialogOpen && (
<CookieBotPage
isOpen={cookieBotDialogOpen}
onClose={() => {
setCookieBotDialogOpen(false);
setCurrentPage("profiles");
}}
subPage={currentPage === "cookieBot"}
initialTab={cookieBotInitialTab}
profiles={profiles}
cloudUser={cloudUser}
onOpenProfileSync={handleOpenProfileSyncDialog}
onAssignProxy={handleAssignProfilesToProxy}
/>
)}
{accountDialogOpen && (
<AccountPage
isOpen={accountDialogOpen}
@@ -1850,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) => (
+100 -2
View File
@@ -11,7 +11,13 @@ import {
LuRefreshCw,
LuUser,
} from "react-icons/lu";
import {
formatDate,
formatHours,
RemoteHoursMeter,
} from "@/components/cookie-bot-shared";
import { LoadingButton } from "@/components/loading-button";
import { TeamUsagePanel } from "@/components/team-usage-panel";
import {
AnimatedTabs,
AnimatedTabsContent,
@@ -24,8 +30,13 @@ import { Dialog, DialogContent } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { translateBackendError } from "@/lib/backend-errors";
import { getEntitlements } from "@/lib/entitlements";
import {
canUseCookieBot,
getEntitlements,
isTeamOwner,
} from "@/lib/entitlements";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { SyncSettings } from "@/types";
@@ -56,6 +67,25 @@ export function AccountPage({
const [isRefreshing, setIsRefreshing] = useState(false);
const [isLoggingOut, setIsLoggingOut] = useState(false);
// Remote hours are plan truth, so they belong here rather than only next to
// the controls that spend them. Until this landed, `remote-sessions/quota`
// had no caller anywhere and a customer's first sight of their allowance was
// a refused launch.
const remoteHoursVisible = isLoggedIn && canUseCookieBot(user);
const showTeamUsage = remoteHoursVisible && isTeamOwner(user);
const { quota, isLoading: isQuotaLoading } = useCookieBot(
remoteHoursVisible,
cookieBotScopeFor(user),
);
const [activeTab, setActiveTab] = useState("account");
// Signing out (or losing the team) removes the tab while it is the selected
// one, which would leave the page showing an empty panel with no trigger to
// click back to.
useEffect(() => {
if (!showTeamUsage && activeTab === "team-usage") setActiveTab("account");
}, [showTeamUsage, activeTab]);
// Self-hosted server state. Loaded once when the dialog opens and persisted
// via `save_sync_settings` so the rest of the app picks up the new URL/token
// from `SettingsManager`.
@@ -201,11 +231,16 @@ export function AccountPage({
<DialogContent className="flex max-h-[calc(100vh-5rem)] max-w-3xl flex-col">
<div className="min-h-0 flex-1 overflow-y-auto">
<div className={cn(subPage && "mx-auto w-full max-w-4xl")}>
<AnimatedTabs defaultValue="account">
<AnimatedTabs value={activeTab} onValueChange={setActiveTab}>
<AnimatedTabsList>
<AnimatedTabsTrigger value="account">
{t("account.tabs.account")}
</AnimatedTabsTrigger>
{showTeamUsage && (
<AnimatedTabsTrigger value="team-usage">
{t("account.tabs.teamUsage")}
</AnimatedTabsTrigger>
)}
<AnimatedTabsTrigger
value="self-hosted"
disabled={selfHostedDisabled}
@@ -251,6 +286,63 @@ export function AccountPage({
</div>
</div>
{remoteHoursVisible && (
// A headline block, not one field among six: the allowance
// is the number a customer needs before a launch is
// refused, which is the only way they ever saw it before.
<div className="rounded-md border border-border bg-muted/40 px-3 py-2.5">
<div className="flex items-baseline justify-between gap-3">
<p className="text-[10px] tracking-wide text-muted-foreground uppercase">
{t("cookieBot.hours.label")}
</p>
{formatDate(quota?.period_end) && (
<p className="text-xs tabular-nums text-muted-foreground">
{t("cookieBot.hours.resets", {
date: formatDate(quota?.period_end),
})}
</p>
)}
</div>
<p className="mt-1 text-lg leading-none font-semibold tabular-nums">
{quota ? formatHours(quota.remaining_hours) : "—"}
<span className="ml-1 text-sm font-normal text-muted-foreground">
{t("cookieBot.hours.remainingOf", {
total: quota
? formatHours(quota.granted_hours)
: "—",
})}
</span>
</p>
<RemoteHoursMeter
quota={quota}
isLoading={isQuotaLoading}
variant="inline"
className="mt-2"
/>
<div className="mt-2 flex items-baseline justify-between gap-3">
<p className="text-xs tabular-nums text-muted-foreground">
{t("cookieBot.hours.used", {
used: quota ? formatHours(quota.used_hours) : "—",
total: quota
? formatHours(quota.granted_hours)
: "—",
})}
</p>
{showTeamUsage && (
<button
type="button"
onClick={() => {
setActiveTab("team-usage");
}}
className="text-xs text-muted-foreground underline underline-offset-2 transition-colors duration-100 hover:text-foreground"
>
{t("account.viewTeamUsage")}
</button>
)}
</div>
</div>
)}
{isLoggedIn && user && (
<div className="grid grid-cols-2 gap-2 text-xs">
<div className="rounded-md border border-border bg-muted/40 px-3 py-2">
@@ -362,6 +454,12 @@ export function AccountPage({
</div>
</AnimatedTabsContent>
{showTeamUsage && (
<AnimatedTabsContent value="team-usage" className="mt-4">
<TeamUsagePanel quota={quota} />
</AnimatedTabsContent>
)}
<AnimatedTabsContent value="self-hosted" className="mt-4">
{selfHostedDisabled ? (
// Defensive: the tab trigger is disabled while the user is
+2
View File
@@ -8,6 +8,7 @@ import {
LuBadgeInfo,
LuCircleStop,
LuCloud,
LuCookie,
LuInfo,
LuKeyboard,
LuPlay,
@@ -67,6 +68,7 @@ const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
goProxies: FiWifi,
goExtensions: LuPuzzle,
goGroups: LuUsers,
goCookieBot: LuCookie,
goIntegrations: LuPlug,
goAccount: LuCloud,
goSettings: GoGear,
@@ -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>
);
}
+583
View File
@@ -0,0 +1,583 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuSearch } from "react-icons/lu";
import {
formatDateTime,
formatDuration,
formatElapsed,
hasRunCounters,
indexProfiles,
indexRunsById,
indexRunsBySession,
outcomeLabel,
parseIso,
runStatusLabel,
runStatusTone,
StatusDot,
sessionCloseReason,
sessionDisplayName,
sessionElapsedSeconds,
sessionPhaseLabel,
sessionTone,
useSecondTicker,
} from "@/components/cookie-bot-shared";
import { Button } from "@/components/ui/button";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Input } from "@/components/ui/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { translateBackendError } from "@/lib/backend-errors";
import { type CookieBotRun, cancelCookieBotRun } from "@/lib/cookie-bot";
import { MOTION_EASE_OUT } from "@/lib/motion";
import {
type RemoteSessionState,
stopRemoteSession,
} from "@/lib/remote-sessions";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile } from "@/types";
export type RunFilter = "all" | "succeeded" | "partial" | "failed";
interface CookieBotActivityProps {
live: RemoteSessionState[];
streamConnected: boolean;
runs: CookieBotRun[];
isLoading: boolean;
profiles: BrowserProfile[];
showOperator: boolean;
filter: RunFilter;
onFilterChange: (filter: RunFilter) => void;
onRefresh: () => void;
}
export function CookieBotActivity({
live,
streamConnected,
runs,
isLoading,
profiles,
showOperator,
filter,
onFilterChange,
onRefresh,
}: CookieBotActivityProps) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const profileIndex = useMemo(() => indexProfiles(profiles), [profiles]);
const runsBySession = useMemo(() => indexRunsBySession(runs), [runs]);
const runsById = useMemo(() => indexRunsById(runs), [runs]);
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase();
return runs.filter((run) => {
if (filter !== "all" && run.status !== filter) return false;
if (!needle) return true;
const name = run.profile_name ?? profileIndex.get(run.profile_id)?.name;
return (
(name ?? "").toLowerCase().includes(needle) ||
(run.email ?? "").toLowerCase().includes(needle)
);
});
}, [runs, filter, search, profileIndex]);
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<LiveSessions
live={live}
streamConnected={streamConnected}
profileIndex={profileIndex}
runsBySession={runsBySession}
runsById={runsById}
onChanged={onRefresh}
/>
<div className="flex shrink-0 items-center gap-2">
<div className="relative min-w-0 flex-1">
<LuSearch className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
className="h-8 pl-8 text-sm"
placeholder={t("cookieBot.history.searchPlaceholder")}
/>
</div>
<Select
value={filter}
onValueChange={(value) => {
onFilterChange(value as RunFilter);
}}
>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">
{t("cookieBot.history.filterAll")}
</SelectItem>
<SelectItem value="succeeded">
{t("cookieBot.history.filterComplete")}
</SelectItem>
<SelectItem value="partial">
{t("cookieBot.history.filterPartial")}
</SelectItem>
<SelectItem value="failed">
{t("cookieBot.history.filterFailed")}
</SelectItem>
</SelectContent>
</Select>
</div>
<FadingScrollArea
className="min-h-0 flex-1"
style={{ "--scroll-fade-top-offset": "32px" } as React.CSSProperties}
>
<Table
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead className="w-40">
{t("cookieBot.history.columnStarted")}
</TableHead>
<TableHead className="max-w-0">
{t("cookieBot.history.columnProfile")}
</TableHead>
<TableHead className="hidden w-24 @2xl:table-cell">
{t("cookieBot.history.columnDuration")}
</TableHead>
<TableHead className="hidden w-20 text-right @3xl:table-cell">
{t("cookieBot.history.columnSites")}
</TableHead>
<TableHead className="w-32">
{t("cookieBot.history.columnStatus")}
</TableHead>
{showOperator && (
<TableHead className="hidden max-w-0 @4xl:table-cell">
{t("cookieBot.history.columnOperator")}
</TableHead>
)}
</TableRow>
</TableHeader>
<TableBody>
{isLoading && runs.length === 0 ? (
Array.from({ length: 6 }, (_, i) => (
<TableRow key={`skeleton-${i}`}>
<TableCell colSpan={showOperator ? 6 : 5}>
<div className="flex items-center gap-3">
<Skeleton className="h-3 w-28" />
<Skeleton
className="h-3"
style={{ width: `${30 + ((i * 17) % 40)}%` }}
/>
<div className="flex-1" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
</TableCell>
</TableRow>
))
) : filtered.length === 0 ? (
<TableRow className="border-0! hover:bg-transparent">
<TableCell colSpan={showOperator ? 6 : 5} className="py-16">
<p className="text-center text-sm text-muted-foreground">
{runs.length === 0
? t("cookieBot.history.empty")
: t("cookieBot.history.noMatch")}
</p>
</TableCell>
</TableRow>
) : (
filtered.map((run) => (
<RunRow
key={run.id}
run={run}
profileName={
run.profile_name ??
profileIndex.get(run.profile_id)?.name ??
null
}
showOperator={showOperator}
/>
))
)}
</TableBody>
</Table>
</FadingScrollArea>
</div>
);
}
function RunRow({
run,
profileName,
showOperator,
}: {
run: CookieBotRun;
profileName: string | null;
showOperator: boolean;
}) {
const { t } = useTranslation();
const [expanded, setExpanded] = useState(false);
const reduceMotion = useReducedMotion();
const started = parseIso(run.started_at);
const ended = parseIso(run.ended_at);
const durationSeconds =
started && ended
? Math.max(0, Math.floor((ended.getTime() - started.getTime()) / 1000))
: run.billed_seconds > 0
? run.billed_seconds
: null;
const countersKnown = hasRunCounters(run);
const hasDetail = Boolean(run.outcome_code) || run.sites_failed > 0;
return (
<>
<TableRow
className={cn("hover:bg-muted/30", hasDetail && "cursor-pointer")}
onClick={() => {
if (hasDetail) setExpanded((open) => !open);
}}
>
<TableCell className="tabular-nums text-muted-foreground">
{formatDateTime(run.started_at ?? run.scheduled_for) ?? "—"}
</TableCell>
<TableCell className="max-w-0 truncate">
{profileName ?? t("cookieBot.history.unknownProfile")}
</TableCell>
<TableCell className="hidden tabular-nums @2xl:table-cell">
{durationSeconds === null ? "—" : formatDuration(t, durationSeconds)}
</TableCell>
{/* An em dash, not a confident `0/12`: the counters are not written
until the fleet's figures are ingested, and printing the column
default as a fact tells a paying user their run did nothing. */}
<TableCell className="hidden text-right tabular-nums text-muted-foreground @3xl:table-cell">
{!countersKnown ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default"></span>
</TooltipTrigger>
<TooltipContent>
{t("cookieBot.history.sitesUnknown")}
</TooltipContent>
</Tooltip>
) : run.sites_total > 0 ? (
`${run.sites_visited}/${run.sites_total}`
) : (
String(run.sites_visited)
)}
</TableCell>
<TableCell>
<span className="flex items-center gap-2 text-xs">
<StatusDot tone={runStatusTone(run.status)} />
{runStatusLabel(t, run.status)}
</span>
</TableCell>
{showOperator && (
<TableCell className="hidden max-w-0 truncate text-muted-foreground @4xl:table-cell">
{run.email ?? "—"}
</TableCell>
)}
</TableRow>
{hasDetail && (
<TableRow className="border-0! hover:bg-transparent">
<TableCell
colSpan={showOperator ? 6 : 5}
className={cn("p-0", !expanded && "border-0!")}
>
<AnimatePresence initial={false}>
{expanded && (
<motion.div
initial={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: reduceMotion ? 0 : -4 }}
transition={{
duration: reduceMotion ? 0.15 : 0.16,
ease: MOTION_EASE_OUT,
}}
className="flex flex-col gap-1 px-2 pb-3 text-xs text-muted-foreground"
>
{run.outcome_code && (
<span>
{t("cookieBot.history.outcome", {
reason: outcomeLabel(t, run.outcome_code) ?? "",
})}
</span>
)}
{run.sites_failed > 0 && (
<span>
{t("cookieBot.history.sitesFailed", {
count: run.sites_failed,
})}
</span>
)}
{run.consent_dismissed > 0 && (
<span>
{t("cookieBot.history.consentHandled", {
count: run.consent_dismissed,
})}
</span>
)}
</motion.div>
)}
</AnimatePresence>
</TableCell>
</TableRow>
)}
</>
);
}
/* -------------------------------------------------------------------------- */
/* Live */
/* -------------------------------------------------------------------------- */
function LiveSessions({
live,
streamConnected,
profileIndex,
runsBySession,
runsById,
onChanged,
}: {
live: RemoteSessionState[];
streamConnected: boolean;
profileIndex: Map<string, BrowserProfile>;
runsBySession: Map<string, CookieBotRun>;
runsById: Map<string, CookieBotRun>;
onChanged: () => void;
}) {
const { t } = useTranslation();
const now = useSecondTicker(live.length > 0);
if (live.length === 0) {
return (
<div className="flex shrink-0 items-center gap-2 rounded-md border border-border bg-card px-3 py-2.5">
<StatusDot tone="muted" />
<span className="text-sm text-muted-foreground">
{streamConnected
? t("cookieBot.live.idle")
: t("cookieBot.live.streamOffline")}
</span>
</div>
);
}
return (
<div className="flex shrink-0 flex-col gap-2">
{!streamConnected && (
<p className="text-xs text-warning-text">
{t("cookieBot.live.streamOfflineDetail")}
</p>
)}
{live.map((session) => (
<LiveSessionRow
key={session.session_id}
session={session}
now={now}
name={sessionDisplayName(
session,
profileIndex,
session.run_id
? runsById.get(session.run_id)
: runsBySession.get(session.session_id),
)}
run={
session.run_id
? runsById.get(session.run_id)
: runsBySession.get(session.session_id)
}
onChanged={onChanged}
/>
))}
</div>
);
}
function LiveSessionRow({
session,
now,
name,
run,
onChanged,
}: {
session: RemoteSessionState;
now: number;
name: string | null;
run: CookieBotRun | undefined;
onChanged: () => void;
}) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const [isStopping, setIsStopping] = useState(false);
const elapsed = sessionElapsedSeconds(session, now);
const phase = sessionPhaseLabel(t, session);
const tone = sessionTone(session);
const closeReason = sessionCloseReason(t, session);
// Only once the backend has actually written a counter. Until then the bar
// sat at zero for the whole run and read as "nothing is happening".
const countersKnown = run ? hasRunCounters(run) : false;
const total = run?.sites_total ?? 0;
const visited = run?.sites_visited ?? 0;
const progress =
countersKnown && total > 0 ? Math.min(1, visited / total) : null;
// A night longer than one session's cap is split into chunks, and the run row
// is the only place that can say which one is running. `chunk_index` counts
// chunks STARTED — the server bumps it as it launches each one and treats 0
// as "never got going" — so it already reads as a 1-based position and must
// not be incremented again.
const chunks =
run && run.chunks_total > 1 && run.chunk_index > 0
? t("cookieBot.live.chunk", {
index: Math.min(run.chunk_index, run.chunks_total),
total: run.chunks_total,
})
: null;
const stop = useCallback(async () => {
setIsStopping(true);
try {
if (session.run_id) {
await cancelCookieBotRun(session.run_id);
} else {
await stopRemoteSession(session.session_id);
}
showSuccessToast(t("cookieBot.running.stopped"));
onChanged();
} catch (error) {
showErrorToast(translateBackendError(t, error));
} finally {
setIsStopping(false);
}
}, [session.run_id, session.session_id, onChanged, t]);
return (
<div className="flex flex-col gap-2 rounded-md border border-border bg-card px-3 py-2.5">
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<StatusDot tone={tone} pulse={session.state === "provisioning"} />
<span className="min-w-0 flex-1 truncate text-sm font-medium text-foreground">
{name ?? t("cookieBot.live.unnamedSession")}
</span>
{/* The phase swaps in place: the words change, the row does not move.
The slot is a fixed width so the elapsed clock beside it never
shifts, and the entering label starts at 0.55 rather than 0 if
the animation never runs, the single most important live signal on
the screen is still legible. */}
<span className="w-36 shrink-0 text-right">
<AnimatePresence mode="wait" initial={false}>
<motion.span
key={phase}
initial={{ opacity: reduceMotion ? 1 : 0.55 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0.01 : 0.12 }}
className="block truncate text-xs text-muted-foreground"
>
{phase}
</motion.span>
</AnimatePresence>
</span>
<span className="shrink-0 text-xs tabular-nums text-foreground">
{elapsed === null ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default text-muted-foreground"></span>
</TooltipTrigger>
<TooltipContent>
{t("cookieBot.live.notStartedYet")}
</TooltipContent>
</Tooltip>
) : (
formatElapsed(elapsed)
)}
</span>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 text-xs"
disabled={isStopping}
onClick={() => {
void stop();
}}
>
{t("cookieBot.running.stop")}
</Button>
</div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-muted-foreground">
<span className="tabular-nums">
{countersKnown && total > 0
? t("cookieBot.live.sitesProgress", { visited, total })
: t("cookieBot.live.sitesUnknown")}
</span>
<span className="tabular-nums">
{countersKnown && run
? t("cookieBot.live.consentHandled", {
count: run.consent_dismissed,
})
: t("cookieBot.live.consentUnknown")}
</span>
<span className="tabular-nums">
{session.billed_seconds !== null &&
session.billed_seconds !== undefined
? t("cookieBot.live.billed", {
duration: formatElapsed(session.billed_seconds),
})
: t("cookieBot.live.billedUnknown")}
</span>
{chunks && <span className="tabular-nums">{chunks}</span>}
{closeReason && (
<span className="text-destructive-text">{closeReason}</span>
)}
</div>
{progress !== null && (
<div className="h-1 overflow-hidden rounded-full bg-muted">
<motion.div
initial={false}
animate={{ scaleX: progress }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.22, ease: MOTION_EASE_OUT }
}
style={{ transformOrigin: "left", willChange: "transform" }}
className="h-full w-full bg-success"
/>
</div>
)}
</div>
);
}
File diff suppressed because it is too large Load Diff
+575
View File
@@ -0,0 +1,575 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { LuCookie, LuPencil, LuTrash2 } from "react-icons/lu";
import {
Area,
AreaChart,
CartesianGrid,
Tooltip as ChartTooltip,
ResponsiveContainer,
XAxis,
YAxis,
} from "recharts";
import type { RunFilter } from "@/components/cookie-bot-activity";
import {
describeCadence,
formatDate,
formatDateTime,
minutesToClock,
parseIso,
StatusDot,
scheduleBlockedReason,
scheduleTone,
sessionDisplayName,
sessionPhaseLabel,
sessionTone,
useNextDue,
} from "@/components/cookie-bot-shared";
import { Button } from "@/components/ui/button";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { RippleButton } from "@/components/ui/ripple";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type {
CookieBotRun,
CookieBotSchedule,
RemoteHoursQuota,
} from "@/lib/cookie-bot";
import type { RemoteSessionState } from "@/lib/remote-sessions";
import { cn } from "@/lib/utils";
import type { BrowserProfile } from "@/types";
const CHART_NIGHTS = 30;
interface CookieBotOverviewProps {
schedules: CookieBotSchedule[];
runs: CookieBotRun[];
live: RemoteSessionState[];
quota: RemoteHoursQuota | null;
profiles: BrowserProfile[];
isLoading: boolean;
currentUserId: string | null;
onEnrol: () => void;
onEditSchedule: (schedule: CookieBotSchedule) => void;
onRemoveSchedule: (schedule: CookieBotSchedule) => void;
onJumpToActivity: (filter: RunFilter) => void;
}
export function CookieBotOverview({
schedules,
runs,
live,
quota,
profiles,
isLoading,
currentUserId,
onEnrol,
onEditSchedule,
onRemoveSchedule,
onJumpToActivity,
}: CookieBotOverviewProps) {
const { t } = useTranslation();
const { next, nextAt, dueCount } = useNextDue(schedules);
const profileIndex = useMemo(
() => new Map(profiles.map((p) => [p.id, p])),
[profiles],
);
const recent = useMemo(() => summariseRecent(runs), [runs]);
const chartData = useMemo(() => nightlyMinutes(runs), [runs]);
const exhausted =
quota !== null && quota.granted_hours > 0 && quota.remaining_hours <= 0;
const resetDate = formatDate(quota?.period_end);
if (!isLoading && schedules.length === 0) {
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 py-16 text-center">
<LuCookie className="size-12 text-muted-foreground" />
<div>
<p className="text-sm font-medium text-foreground">
{t("cookieBot.empty.title")}
</p>
<p className="mt-1 max-w-md text-xs text-muted-foreground">
{t("cookieBot.empty.hint")}
</p>
</div>
<RippleButton size="sm" onClick={onEnrol}>
{t("cookieBot.empty.cta")}
</RippleButton>
</div>
);
}
return (
<div className="flex min-h-0 flex-1 flex-col gap-3">
<TonightStrip
live={live}
nextAt={nextAt}
nextMinute={next?.run_at_minute ?? null}
dueCount={dueCount}
profileIndex={profileIndex}
/>
{exhausted && (
<div className="shrink-0 rounded-md border border-warning/50 bg-warning/10 px-3 py-2 text-xs text-warning-text">
{resetDate
? t("cookieBot.hours.exhaustedOn", { date: resetDate })
: t("cookieBot.hours.exhausted")}
</div>
)}
<div className="flex shrink-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs">
<span className="text-muted-foreground">
{t("cookieBot.lastDay.label")}
</span>
{recent.total === 0 ? (
<span className="text-muted-foreground">
{t("cookieBot.lastDay.none")}
</span>
) : (
<>
<RecentSegment
label={t("cookieBot.lastDay.ran", { count: recent.succeeded })}
tone="text-foreground"
onClick={() => {
onJumpToActivity("succeeded");
}}
/>
{recent.partial > 0 && (
<RecentSegment
label={t("cookieBot.lastDay.partial", {
count: recent.partial,
})}
tone="text-warning-text"
onClick={() => {
onJumpToActivity("partial");
}}
/>
)}
{recent.failed > 0 && (
<RecentSegment
label={t("cookieBot.lastDay.failed", { count: recent.failed })}
tone="text-destructive-text"
onClick={() => {
onJumpToActivity("failed");
}}
/>
)}
</>
)}
</div>
<div className="shrink-0">
<p className="mb-1 text-xs font-medium text-foreground">
{t("cookieBot.chart.machineTime")}
</p>
<div className="h-[clamp(140px,20vh,260px)] w-full">
{isLoading && runs.length === 0 ? (
<Skeleton className="size-full" />
) : (
<ResponsiveContainer
width="100%"
height="100%"
minWidth={1}
minHeight={1}
>
<AreaChart
data={chartData}
margin={{ top: 6, right: 8, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="cookieBotMinutesGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-1)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="stroke-muted" />
<XAxis
dataKey="label"
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
minTickGap={24}
/>
<YAxis
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
width={36}
/>
<ChartTooltip
content={({ active, payload, label }) => {
if (!active || !payload?.length) return null;
const minutes = Number(payload[0]?.value ?? 0);
return (
<div className="rounded-lg border bg-popover px-3 py-2 shadow-lg">
<p className="text-xs font-medium text-popover-foreground">
{String(label)}
</p>
<p className="text-xs tabular-nums text-muted-foreground">
{t("cookieBot.chart.minutes", { minutes })}
</p>
</div>
);
}}
/>
<Area
type="monotone"
dataKey="minutes"
stroke="var(--chart-1)"
fill="url(#cookieBotMinutesGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
)}
</div>
</div>
<FadingScrollArea
className="min-h-0 flex-1"
style={{ "--scroll-fade-top-offset": "32px" } as React.CSSProperties}
>
<Table
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead className="max-w-0">
{t("cookieBot.enrolled.columnProfile")}
</TableHead>
<TableHead className="hidden w-32 @2xl:table-cell">
{t("cookieBot.enrolled.columnCadence")}
</TableHead>
<TableHead className="w-20">
{t("cookieBot.enrolled.columnTime")}
</TableHead>
<TableHead className="hidden w-40 @3xl:table-cell">
{t("cookieBot.enrolled.columnNextRun")}
</TableHead>
<TableHead className="hidden w-40 @4xl:table-cell">
{t("cookieBot.enrolled.columnLastRun")}
</TableHead>
<TableHead className="w-20" />
</TableRow>
</TableHeader>
<TableBody>
{isLoading && schedules.length === 0
? Array.from({ length: 5 }, (_, i) => (
<TableRow key={`enrolled-skeleton-${i}`}>
<TableCell colSpan={6}>
<div className="flex items-center gap-3">
<Skeleton
className="h-3"
style={{ width: `${30 + ((i * 17) % 40)}%` }}
/>
<div className="flex-1" />
<Skeleton className="h-3 w-16" />
<Skeleton className="h-3 w-10" />
</div>
</TableCell>
</TableRow>
))
: schedules.map((schedule) => (
<EnrolledRow
key={`${schedule.owner_user_id ?? "me"}-${schedule.profile_id}`}
schedule={schedule}
mine={
!schedule.owner_user_id ||
schedule.owner_user_id === currentUserId
}
exhausted={exhausted}
onEdit={onEditSchedule}
onRemove={onRemoveSchedule}
/>
))}
</TableBody>
</Table>
</FadingScrollArea>
</div>
);
}
function RecentSegment({
label,
tone,
onClick,
}: {
label: string;
tone: string;
onClick: () => void;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"cursor-pointer tabular-nums underline-offset-2 transition-colors duration-100 hover:underline",
tone,
)}
>
{label}
</button>
);
}
function TonightStrip({
live,
nextAt,
nextMinute,
dueCount,
profileIndex,
}: {
live: RemoteSessionState[];
nextAt: Date | null;
nextMinute: number | null;
dueCount: number;
profileIndex: Map<string, BrowserProfile>;
}) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const running = live.length > 0;
return (
<div className="flex shrink-0 items-center gap-3 rounded-md border border-border bg-card px-3 py-2.5">
<span className="text-[10px] uppercase tracking-wide text-muted-foreground">
{running ? t("cookieBot.running.label") : t("cookieBot.tonight.label")}
</span>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={running ? `running-${live.length}` : "idle"}
initial={{ opacity: reduceMotion ? 1 : 0.55 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: reduceMotion ? 0.01 : 0.12 }}
className="flex min-w-0 flex-1 items-center gap-2"
>
{running ? (
<>
<StatusDot
tone={sessionTone(live[0])}
pulse={live[0].state === "provisioning"}
/>
<span className="min-w-0 truncate text-sm font-medium text-foreground">
{sessionDisplayName(live[0], profileIndex, undefined) ??
t("cookieBot.live.unnamedSession")}
</span>
<span className="shrink-0 text-sm text-muted-foreground">
{sessionPhaseLabel(t, live[0])}
</span>
{live.length > 1 && (
<span className="shrink-0 text-xs tabular-nums text-muted-foreground">
{t("cookieBot.running.more", { count: live.length - 1 })}
</span>
)}
</>
) : nextMinute === null ? (
<span className="text-sm text-muted-foreground">
{t("cookieBot.tonight.nothingScheduled")}
</span>
) : (
<span className="text-sm tabular-nums text-foreground">
{t("cookieBot.tonight.nextRun", {
time: minutesToClock(nextMinute),
})}
{nextAt ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="ml-2 cursor-default text-muted-foreground">
{t("cookieBot.tonight.dueCount", { count: dueCount })}
</span>
</TooltipTrigger>
<TooltipContent>
{formatDateTime(nextAt.toISOString()) ?? ""}
</TooltipContent>
</Tooltip>
) : (
<span className="ml-2 text-muted-foreground">
{t("cookieBot.tonight.dueCount", { count: dueCount })}
</span>
)}
</span>
)}
</motion.div>
</AnimatePresence>
</div>
);
}
function EnrolledRow({
schedule,
mine,
exhausted,
onEdit,
onRemove,
}: {
schedule: CookieBotSchedule;
mine: boolean;
exhausted: boolean;
onEdit: (schedule: CookieBotSchedule) => void;
onRemove: (schedule: CookieBotSchedule) => void;
}) {
const { t } = useTranslation();
const blocked = scheduleBlockedReason(t, schedule);
return (
<TableRow className="hover:bg-muted/30">
<TableCell className="max-w-0 truncate">
<span className="flex items-center gap-2">
<StatusDot tone={scheduleTone(schedule)} />
<span className="min-w-0 truncate">{schedule.profile_name}</span>
{!mine && schedule.owner_email && (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">
{schedule.owner_email}
</span>
)}
</span>
</TableCell>
<TableCell className="hidden text-muted-foreground @2xl:table-cell">
{describeCadence(t, schedule.days_mask)}
</TableCell>
<TableCell className="tabular-nums">
{minutesToClock(schedule.run_at_minute)}
</TableCell>
{/* The server publishes why tonight would be refused on every read. A
next-run time the enrolment cannot keep is worse than no time. */}
<TableCell className="hidden tabular-nums text-muted-foreground @3xl:table-cell">
{blocked ? (
<span className="text-warning-text">{blocked}</span>
) : exhausted ? (
<span className="text-warning-text">
{t("cookieBot.enrolled.pausedNoHours")}
</span>
) : (
(formatDateTime(schedule.next_run_at) ?? "—")
)}
</TableCell>
<TableCell className="hidden tabular-nums text-muted-foreground @4xl:table-cell">
{formatDateTime(schedule.last_run_at) ??
t("cookieBot.enrolled.neverRun")}
</TableCell>
<TableCell>
<div className="flex items-center justify-end gap-0.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7"
aria-label={t("cookieBot.enrolled.edit")}
onClick={() => {
onEdit(schedule);
}}
>
<LuPencil className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("cookieBot.enrolled.edit")}</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="size-7 text-destructive-text hover:bg-destructive/10"
aria-label={t("cookieBot.schedule.unenrol")}
onClick={() => {
onRemove(schedule);
}}
>
<LuTrash2 className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("cookieBot.schedule.unenrol")}</TooltipContent>
</Tooltip>
</div>
</TableCell>
</TableRow>
);
}
/* -------------------------------------------------------------------------- */
/* Derivations — all of these read run rows the server wrote. Nothing here */
/* predicts, estimates or fills in a value the backend did not report. */
/* -------------------------------------------------------------------------- */
function summariseRecent(runs: CookieBotRun[]) {
const cutoff = Date.now() - 24 * 60 * 60 * 1000;
let succeeded = 0;
let partial = 0;
let failed = 0;
let total = 0;
for (const run of runs) {
const at = parseIso(run.started_at ?? run.scheduled_for);
if (!at || at.getTime() < cutoff) continue;
total += 1;
if (run.status === "succeeded") succeeded += 1;
else if (run.status === "partial" || run.status === "skipped") partial += 1;
else if (run.status === "failed") failed += 1;
}
return { succeeded, partial, failed, total };
}
function nightlyMinutes(runs: CookieBotRun[]) {
const buckets = new Map<string, number>();
const days: { key: string; label: string }[] = [];
const today = new Date();
today.setHours(0, 0, 0, 0);
for (let i = CHART_NIGHTS - 1; i >= 0; i -= 1) {
const day = new Date(today);
day.setDate(day.getDate() - i);
const key = dayKey(day);
buckets.set(key, 0);
days.push({
key,
label: day.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
}),
});
}
for (const run of runs) {
const at = parseIso(run.started_at ?? run.scheduled_for);
if (!at) continue;
const key = dayKey(at);
if (!buckets.has(key)) continue;
buckets.set(key, (buckets.get(key) ?? 0) + run.billed_seconds / 60);
}
return days.map(({ key, label }) => ({
label,
minutes: Math.round(buckets.get(key) ?? 0),
}));
}
function dayKey(date: Date): string {
return `${date.getFullYear()}-${date.getMonth() + 1}-${date.getDate()}`;
}
+604
View File
@@ -0,0 +1,604 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuCookie, LuSearch } from "react-icons/lu";
import {
CookieBotActivity,
type RunFilter,
} from "@/components/cookie-bot-activity";
import { CookieBotEnrolDialog } from "@/components/cookie-bot-enrol-dialog";
import { CookieBotOverview } from "@/components/cookie-bot-overview";
import { CookieBotScheduleTab } from "@/components/cookie-bot-schedule";
import {
preflight,
preflightReason,
RemoteHoursMeter,
} from "@/components/cookie-bot-shared";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { TeamUsagePanel } from "@/components/team-usage-panel";
import {
AnimatedTabs,
AnimatedTabsContent,
AnimatedTabsList,
AnimatedTabsTrigger,
} from "@/components/ui/animated-tabs";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Input } from "@/components/ui/input";
import { ProBadge } from "@/components/ui/pro-badge";
import { RippleButton } from "@/components/ui/ripple";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cookieBotScopeFor, useCookieBot } from "@/hooks/use-cookie-bot";
import { translateBackendError } from "@/lib/backend-errors";
import {
type CookieBotRun,
type CookieBotSchedule,
deleteCookieBotSchedule,
getCookieBotRuns,
} from "@/lib/cookie-bot";
import { canUseCookieBot, getEntitlements } from "@/lib/entitlements";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, CloudUser } from "@/types";
export type CookieBotTab = "overview" | "schedule" | "activity" | "team";
/** How often the run rows are re-read while something is running. A run's site
* counter advances without a session transition, so the stream alone would show
* a frozen number for an hour. */
const LIVE_POLL_MS = 15_000;
const RUN_PAGE_SIZE = 100;
interface CookieBotPageProps {
isOpen: boolean;
onClose: () => void;
subPage?: boolean;
initialTab?: CookieBotTab;
profiles: BrowserProfile[];
cloudUser: CloudUser | null;
/** Opens the profile's sync settings for an end-to-end encrypted profile. */
onOpenProfileSync: (profile: BrowserProfile) => void;
/** Opens proxy assignment for profiles with no exit node. */
onAssignProxy: (profileIds: string[]) => void;
}
export function CookieBotPage({
isOpen,
onClose,
subPage,
initialTab = "overview",
profiles,
cloudUser,
onOpenProfileSync,
onAssignProxy,
}: CookieBotPageProps) {
const { t } = useTranslation();
const entitlements = getEntitlements(cloudUser);
const unlocked = canUseCookieBot(cloudUser);
const isTeam = entitlements.teamCollaboration && Boolean(cloudUser?.teamId);
const isOwnerOrAdmin =
cloudUser?.teamRole === "owner" || cloudUser?.teamRole === "admin";
const showTeamTab = isTeam && cloudUser?.teamRole === "owner";
const scope = cookieBotScopeFor(cloudUser);
// Enrolments, the pooled budget and the live sessions all come from the one
// shared store the profile table reads, so an edit here moves both at once.
//
// Enabled is `unlocked`, NOT `isOpen && unlocked`: the store is a module
// singleton whose enabled flag is set by whichever consumer's effect ran
// last, so a closed page passing `false` would switch the event stream off
// underneath the rail and the profile table.
const {
schedules: schedulesByProfile,
liveSessions,
quota,
isLoading: isStoreLoading,
error: storeError,
streamConnected,
refresh: refreshStore,
} = useCookieBot(unlocked, scope);
const schedules = useMemo(
() =>
Object.values(schedulesByProfile).sort(
(a, b) =>
a.run_at_minute - b.run_at_minute ||
a.profile_name.localeCompare(b.profile_name),
),
[schedulesByProfile],
);
const live = useMemo(() => Object.values(liveSessions), [liveSessions]);
const liveCount = live.length;
const [activeTab, setActiveTab] = useState<CookieBotTab>(initialTab);
const [runs, setRuns] = useState<CookieBotRun[]>([]);
const [isLoadingRuns, setIsLoadingRuns] = useState(true);
const [runsError, setRunsError] = useState<unknown>(null);
const [runFilter, setRunFilter] = useState<RunFilter>("all");
const hasLoadedRuns = useRef(false);
const [pickerOpen, setPickerOpen] = useState(false);
const [enrolTargets, setEnrolTargets] = useState<BrowserProfile[]>([]);
const [editing, setEditing] = useState<CookieBotSchedule | null>(null);
const [enrolOpen, setEnrolOpen] = useState(false);
const [pendingRemoval, setPendingRemoval] =
useState<CookieBotSchedule | null>(null);
const [isRemoving, setIsRemoving] = useState(false);
const isLoading = isStoreLoading || isLoadingRuns;
const loadError: unknown = runsError ?? storeError;
/**
* Runs are the one thing the shared store does not hold: only this page and
* the per-profile history read them, and they page. `withSpinner` is false
* for every background pass, because swapping a correct table for a skeleton
* every fifteen seconds is worse than a row being a few seconds stale.
*/
const loadRuns = useCallback(
async (withSpinner: boolean) => {
if (withSpinner) setIsLoadingRuns(true);
try {
const page = await getCookieBotRuns({ scope, limit: RUN_PAGE_SIZE });
setRuns(page.runs);
setRunsError(null);
} catch (error) {
// A background refresh that fails leaves the previous rows on screen.
if (withSpinner) setRunsError(error);
} finally {
if (withSpinner) setIsLoadingRuns(false);
}
},
[scope],
);
const reload = useCallback(() => {
void refreshStore();
void loadRuns(true);
}, [refreshStore, loadRuns]);
useEffect(() => {
setActiveTab(initialTab);
}, [initialTab]);
useEffect(() => {
if (!isOpen || !unlocked) {
hasLoadedRuns.current = false;
return;
}
// The first pass shows a skeleton. Every later pass — a session appearing
// or closing, or the poll below — swaps rows in silently. Both matter: the
// live set changing means a run row moved, and a run's site counter
// advances with no session transition at all.
const first = !hasLoadedRuns.current;
hasLoadedRuns.current = true;
void loadRuns(first);
if (liveCount === 0) return;
const id = window.setInterval(() => {
void loadRuns(false);
}, LIVE_POLL_MS);
return () => {
window.clearInterval(id);
};
}, [isOpen, unlocked, liveCount, loadRuns]);
const enrolledIds = useMemo(
() => new Set(Object.keys(schedulesByProfile)),
[schedulesByProfile],
);
const openEnrolFor = useCallback(
(targets: BrowserProfile[], schedule: CookieBotSchedule | null) => {
if (targets.length === 0) return;
setEnrolTargets(targets);
setEditing(schedule);
setEnrolOpen(true);
},
[],
);
const handleEditSchedule = useCallback(
(schedule: CookieBotSchedule) => {
const profile = profiles.find((p) => p.id === schedule.profile_id);
if (!profile) {
showErrorToast(t("cookieBot.enrolled.profileMissing"));
return;
}
openEnrolFor([profile], schedule);
},
[profiles, openEnrolFor, t],
);
const confirmRemoval = useCallback(async () => {
if (!pendingRemoval) return;
setIsRemoving(true);
try {
await deleteCookieBotSchedule(pendingRemoval.profile_id);
showSuccessToast(t("cookieBot.schedule.unenrolled"));
setPendingRemoval(null);
reload();
} catch (error) {
showErrorToast(translateBackendError(t, error));
} finally {
setIsRemoving(false);
}
}, [pendingRemoval, reload, t]);
const dialogBody = !unlocked ? (
<LockedState />
) : (
<div className="@container flex min-h-0 w-full flex-1 flex-col">
<AnimatedTabs
value={activeTab}
onValueChange={(value) => {
setActiveTab(value as CookieBotTab);
}}
className="flex min-h-0 flex-1 flex-col"
>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2">
<AnimatedTabsList>
<AnimatedTabsTrigger value="overview">
<span>{t("cookieBot.tabs.overview")}</span>
<span className="text-xs tabular-nums">{schedules.length}</span>
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="schedule">
{t("cookieBot.tabs.schedule")}
</AnimatedTabsTrigger>
<AnimatedTabsTrigger value="activity">
<span>{t("cookieBot.tabs.activity")}</span>
{live.length > 0 && (
<span className="size-1.5 rounded-full bg-success" />
)}
</AnimatedTabsTrigger>
{showTeamTab && (
<AnimatedTabsTrigger value="team">
{t("cookieBot.tabs.team")}
</AnimatedTabsTrigger>
)}
</AnimatedTabsList>
<div className="flex items-center gap-3">
<RemoteHoursMeter
quota={quota}
isLoading={isStoreLoading && quota === null}
variant="compact"
/>
<Tooltip>
<TooltipTrigger asChild>
<RippleButton
size="sm"
className="flex items-center gap-2"
aria-label={t("cookieBot.enrolled.enrolProfiles")}
onClick={() => {
setPickerOpen(true);
}}
>
<GoPlus className="size-4" />
<span className="hidden @2xl:inline">
{t("cookieBot.enrolled.enrolProfiles")}
</span>
</RippleButton>
</TooltipTrigger>
<TooltipContent>
{t("cookieBot.enrolled.enrolProfiles")}
</TooltipContent>
</Tooltip>
</div>
</div>
{loadError !== null && (
<div className="mt-4 flex shrink-0 items-center gap-3 rounded-md border border-destructive/50 bg-destructive/10 p-3">
<p className="min-w-0 flex-1 text-sm text-destructive-text">
{translateBackendError(t, loadError)}
</p>
<RippleButton variant="outline" size="sm" onClick={reload}>
{t("common.buttons.retry")}
</RippleButton>
</div>
)}
<AnimatedTabsContent
value="overview"
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
>
<CookieBotOverview
schedules={schedules}
runs={runs}
live={live}
quota={quota}
profiles={profiles}
isLoading={isLoading}
currentUserId={cloudUser?.id ?? null}
onEnrol={() => {
setPickerOpen(true);
}}
onEditSchedule={handleEditSchedule}
onRemoveSchedule={setPendingRemoval}
onJumpToActivity={(filter) => {
setRunFilter(filter);
setActiveTab("activity");
}}
/>
</AnimatedTabsContent>
<AnimatedTabsContent
value="schedule"
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
>
<CookieBotScheduleTab
schedules={schedules}
isLoading={isStoreLoading}
currentUserId={cloudUser?.id ?? null}
canEditOthers={isOwnerOrAdmin}
onEdit={handleEditSchedule}
onRemove={setPendingRemoval}
/>
</AnimatedTabsContent>
<AnimatedTabsContent
value="activity"
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
>
<CookieBotActivity
live={live}
streamConnected={streamConnected}
runs={runs}
isLoading={isLoadingRuns}
profiles={profiles}
showOperator={isTeam}
filter={runFilter}
onFilterChange={setRunFilter}
onRefresh={reload}
/>
</AnimatedTabsContent>
{showTeamTab && (
<AnimatedTabsContent
value="team"
className="mt-4 min-h-0 flex-1 flex-col data-[state=active]:flex"
>
{/* One team-usage implementation, shared with the Account page,
so the two never disagree about who spent the pool. */}
<TeamUsagePanel quota={quota} className="min-h-0 flex-1" />
</AnimatedTabsContent>
)}
</AnimatedTabs>
</div>
);
return (
<>
<Dialog open={isOpen} onOpenChange={onClose} subPage={subPage}>
<DialogContent className="flex max-h-[85vh] max-w-[min(80rem,calc(100%-4rem))] flex-col">
{!subPage && (
<DialogHeader>
<DialogTitle>{t("cookieBot.title")}</DialogTitle>
<DialogDescription>
{t("cookieBot.description")}
</DialogDescription>
</DialogHeader>
)}
{dialogBody}
</DialogContent>
</Dialog>
<EnrolPickerDialog
isOpen={pickerOpen}
profiles={profiles}
enrolledIds={enrolledIds}
onClose={() => {
setPickerOpen(false);
}}
onConfirm={(selected) => {
setPickerOpen(false);
openEnrolFor(selected, null);
}}
/>
<CookieBotEnrolDialog
isOpen={enrolOpen}
onClose={() => {
setEnrolOpen(false);
}}
profiles={enrolTargets}
existing={editing}
onSaved={reload}
onOpenProfileSync={onOpenProfileSync}
onAssignProxy={onAssignProxy}
/>
<DeleteConfirmationDialog
isOpen={pendingRemoval !== null}
onClose={() => {
setPendingRemoval(null);
}}
onConfirm={() => {
void confirmRemoval();
}}
title={t("cookieBot.schedule.unenrolTitle", {
name: pendingRemoval?.profile_name ?? "",
})}
description={t("cookieBot.schedule.unenrolDescription")}
confirmButtonText={t("cookieBot.schedule.unenrol")}
isLoading={isRemoving}
/>
</>
);
}
function LockedState() {
const { t } = useTranslation();
return (
<div className="flex min-h-0 flex-1 flex-col items-center justify-center gap-3 py-16 text-center">
<LuCookie className="size-12 text-muted-foreground" />
<div className="flex items-center gap-2">
<p className="text-sm font-medium text-foreground">
{t("cookieBot.locked.title")}
</p>
<ProBadge />
</div>
<p className="max-w-md text-xs text-muted-foreground">
{t("cookieBot.locked.hint")}
</p>
</div>
);
}
/**
* Picking which profiles to enrol. Ineligible profiles are shown with their
* reason rather than hidden, so the list matches what the operator sees in the
* main table and the refusal is discovered here, not at 02:00.
*/
function EnrolPickerDialog({
isOpen,
profiles,
enrolledIds,
onClose,
onConfirm,
}: {
isOpen: boolean;
profiles: BrowserProfile[];
enrolledIds: Set<string>;
onClose: () => void;
onConfirm: (selected: BrowserProfile[]) => void;
}) {
const { t } = useTranslation();
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Set<string>>(new Set());
useEffect(() => {
if (!isOpen) return;
setSearch("");
setSelected(new Set());
}, [isOpen]);
const rows = useMemo(() => {
const needle = search.trim().toLowerCase();
return profiles
.filter((profile) => profile.name.toLowerCase().includes(needle))
.map((profile) => ({
profile,
check: preflight(profile),
enrolled: enrolledIds.has(profile.id),
}))
.sort((a, b) => {
if (a.check.eligible !== b.check.eligible) {
return a.check.eligible ? -1 : 1;
}
return a.profile.name.localeCompare(b.profile.name);
});
}, [profiles, search, enrolledIds]);
const chosen = useMemo(
() => profiles.filter((profile) => selected.has(profile.id)),
[profiles, selected],
);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="flex max-h-[70vh] max-w-lg flex-col">
<DialogHeader>
<DialogTitle>{t("cookieBot.picker.title")}</DialogTitle>
<DialogDescription>
{t("cookieBot.picker.description")}
</DialogDescription>
</DialogHeader>
<div className="relative shrink-0">
<LuSearch className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(event) => {
setSearch(event.target.value);
}}
className="h-8 pl-8 text-sm"
placeholder={t("cookieBot.picker.searchPlaceholder")}
/>
</div>
<FadingScrollArea className="min-h-0 flex-1">
<div className="flex flex-col gap-0.5 pr-1">
{rows.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("cookieBot.picker.noProfiles")}
</p>
) : (
rows.map(({ profile, check, enrolled }) => (
<label
key={profile.id}
htmlFor={`cookie-bot-pick-${profile.id}`}
className={cn(
"flex h-8 cursor-pointer items-center gap-2 rounded-md px-2 text-xs transition-colors duration-100 hover:bg-accent hover:text-accent-foreground",
!check.eligible && "cursor-not-allowed opacity-60",
)}
>
<Checkbox
id={`cookie-bot-pick-${profile.id}`}
checked={selected.has(profile.id)}
disabled={!check.eligible}
onCheckedChange={(value) => {
setSelected((prev) => {
const next = new Set(prev);
if (value === true) next.add(profile.id);
else next.delete(profile.id);
return next;
});
}}
/>
<span className="min-w-0 flex-1 truncate">
{profile.name}
</span>
{enrolled && (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-muted-foreground">
{t("cookieBot.picker.alreadyEnrolled")}
</span>
)}
{!check.eligible && (
<span className="shrink-0 text-muted-foreground">
{preflightReason(t, check)}
</span>
)}
</label>
))
)}
</div>
</FadingScrollArea>
<DialogFooter>
<Button variant="outline" size="sm" onClick={onClose}>
{t("common.buttons.cancel")}
</Button>
<RippleButton
size="sm"
disabled={chosen.length === 0}
onClick={() => {
onConfirm(chosen);
}}
>
{t("cookieBot.picker.continue", { count: chosen.length })}
</RippleButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+237
View File
@@ -0,0 +1,237 @@
"use client";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
formatDateTime,
formatDuration,
hasRunCounters,
outcomeLabel,
runStatusLabel,
runStatusTone,
StatusDot,
} from "@/components/cookie-bot-shared";
import { LoadingButton } from "@/components/loading-button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Skeleton } from "@/components/ui/skeleton";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { translateBackendError } from "@/lib/backend-errors";
import {
type CookieBotRun,
cancelCookieBotRun,
getCookieBotRuns,
} from "@/lib/cookie-bot";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
const RUN_PAGE_SIZE = 25;
/** Statuses that are still moving, so the row can offer a stop. */
const IN_FLIGHT = new Set(["pending", "running"]);
function runDurationSeconds(run: CookieBotRun): number | null {
if (run.billed_seconds > 0) return run.billed_seconds;
const started = run.started_at ? new Date(run.started_at).getTime() : NaN;
const ended = run.ended_at ? new Date(run.ended_at).getTime() : NaN;
if (!Number.isNaN(started) && !Number.isNaN(ended) && ended > started) {
return (ended - started) / 1000;
}
return null;
}
interface CookieBotRunsDialogProps {
isOpen: boolean;
onClose: () => void;
profileId: string | null;
profileName?: string;
/** Called after a run is cancelled, so shared state can be re-read. */
onRunCancelled?: () => void;
}
/**
* What the bot actually did for one profile, reached from that profile's row.
*
* The Cookie Bot page owns the fleet-wide activity view; this is the same data
* narrowed to a single profile, which is the question an operator asks while
* looking at the table. Every number is the server's the desktop keeps no run
* history of its own and the status vocabulary is the shared one, so a status
* cannot read differently in two places.
*/
export function CookieBotRunsDialog({
isOpen,
onClose,
profileId,
profileName,
onRunCancelled,
}: CookieBotRunsDialogProps) {
const { t } = useTranslation();
const [runs, setRuns] = React.useState<CookieBotRun[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [cancellingId, setCancellingId] = React.useState<string | null>(null);
const load = React.useCallback(async () => {
if (!profileId) return;
setIsLoading(true);
setError(null);
try {
const page = await getCookieBotRuns({ profileId, limit: RUN_PAGE_SIZE });
setRuns(page.runs);
} catch (err) {
setError(translateBackendError(t as never, err));
} finally {
setIsLoading(false);
}
}, [profileId, t]);
React.useEffect(() => {
if (!isOpen) return;
setRuns([]);
void load();
}, [isOpen, load]);
const handleCancel = React.useCallback(
async (run: CookieBotRun) => {
setCancellingId(run.id);
try {
await cancelCookieBotRun(run.id);
showSuccessToast(t("cookieBot.running.stopped"));
onRunCancelled?.();
await load();
} catch (err) {
showErrorToast(translateBackendError(t as never, err));
} finally {
setCancellingId(null);
}
},
[load, onRunCancelled, t],
);
return (
<Dialog
open={isOpen}
onOpenChange={(open) => {
if (!open) onClose();
}}
>
<DialogContent className="flex max-h-[80vh] max-w-2xl flex-col">
<DialogHeader className="shrink-0">
<DialogTitle>{t("cookieBot.history.title")}</DialogTitle>
<DialogDescription>
{profileName ?? t("cookieBot.history.allProfiles")}
</DialogDescription>
</DialogHeader>
<div className="min-h-0 flex-1 overflow-y-auto">
{error ? (
<p className="py-8 text-center text-sm text-destructive-text">
{error}
</p>
) : isLoading && runs.length === 0 ? (
<div className="space-y-2 py-2">
{Array.from({ length: 5 }, (_, index) => (
<Skeleton
key={`run-skeleton-${index}`}
className="h-7 w-full"
/>
))}
</div>
) : runs.length === 0 ? (
<p className="py-8 text-center text-sm text-muted-foreground">
{t("cookieBot.history.empty")}
</p>
) : (
<Table>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableRow>
<TableHead>{t("cookieBot.history.columnStarted")}</TableHead>
<TableHead>{t("cookieBot.history.columnDuration")}</TableHead>
<TableHead>{t("cookieBot.history.columnSites")}</TableHead>
<TableHead>{t("cookieBot.history.columnStatus")}</TableHead>
<TableHead />
</TableRow>
</TableHeader>
<TableBody>
{runs.map((run) => {
const duration = runDurationSeconds(run);
const started =
formatDateTime(run.started_at ?? run.scheduled_for) ?? "—";
return (
<TableRow key={run.id}>
<TableCell className="text-xs tabular-nums whitespace-nowrap">
{started}
</TableCell>
<TableCell className="text-xs tabular-nums">
{duration === null ? "—" : formatDuration(t, duration)}
</TableCell>
{/* Never a confident `0/12`: nothing writes these
counters yet, so the column default is not a fact
about what the bot did. */}
<TableCell className="text-xs tabular-nums">
{hasRunCounters(run)
? t("cookieBot.history.sitesVisited", {
visited: run.sites_visited,
total: run.sites_total,
})
: t("cookieBot.history.sitesUnknown")}
{run.sites_failed > 0 && (
<span className="ml-1 text-warning-text">
{t("cookieBot.history.sitesFailed", {
count: run.sites_failed,
})}
</span>
)}
</TableCell>
<TableCell className="text-xs">
<span className="flex items-center gap-1.5">
<StatusDot
tone={runStatusTone(run.status)}
pulse={run.status === "running"}
className="size-1.5"
/>
{runStatusLabel(t, run.status)}
</span>
{run.outcome_code && (
<span className="mt-0.5 block text-[11px] text-muted-foreground">
{outcomeLabel(t, run.outcome_code)}
</span>
)}
</TableCell>
<TableCell className="text-right">
{IN_FLIGHT.has(run.status) && (
<LoadingButton
size="sm"
variant="outline"
isLoading={cancellingId === run.id}
onClick={() => {
void handleCancel(run);
}}
className="h-6 text-[11px]"
>
{t("cookieBot.running.stop")}
</LoadingButton>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</div>
</DialogContent>
</Dialog>
);
}
+264
View File
@@ -0,0 +1,264 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { LuPencil, LuTrash2 } from "react-icons/lu";
import {
describeCadence,
minutesToClock,
StatusDot,
scheduleBlockedReason,
scheduleTone,
} from "@/components/cookie-bot-shared";
import { Button } from "@/components/ui/button";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import type { CookieBotSchedule } from "@/lib/cookie-bot";
import { cn } from "@/lib/utils";
/** A slot holding this many enrolments is worth flagging: the fleet leases a
* handful of machines per platform, so a pile-up at one minute is a real
* capacity fact, not a decoration. */
const CROWDED_SLOT = 4;
interface Slot {
hour: number;
entries: CookieBotSchedule[];
}
interface CookieBotScheduleTabProps {
schedules: CookieBotSchedule[];
isLoading: boolean;
currentUserId: string | null;
canEditOthers: boolean;
onEdit: (schedule: CookieBotSchedule) => void;
onRemove: (schedule: CookieBotSchedule) => void;
}
/**
* The night, drawn as a night. Every enrolment the caller can see sits under
* the hour it starts, so two operators aiming at the same profile or twelve
* profiles aiming at 02:00 is visible before it becomes a 409 at 02:00.
*/
export function CookieBotScheduleTab({
schedules,
isLoading,
currentUserId,
canEditOthers,
onEdit,
onRemove,
}: CookieBotScheduleTabProps) {
const { t } = useTranslation();
const rows = useMemo(() => buildRows(schedules), [schedules]);
if (isLoading && schedules.length === 0) {
return (
<div className="flex min-h-0 flex-1 flex-col gap-3 pt-2">
{Array.from({ length: 5 }, (_, i) => (
<div key={`slot-skeleton-${i}`} className="flex items-center gap-3">
<Skeleton className="h-3 w-10" />
<Skeleton
className="h-3"
style={{ width: `${30 + ((i * 17) % 40)}%` }}
/>
</div>
))}
</div>
);
}
if (schedules.length === 0) {
return (
<div className="flex min-h-0 flex-1 items-center justify-center py-16">
<p className="text-sm text-muted-foreground">
{t("cookieBot.schedule.empty")}
</p>
</div>
);
}
return (
<FadingScrollArea
className="min-h-0 flex-1"
style={{ "--scroll-fade-top-offset": "16px" } as React.CSSProperties}
>
<div className="flex flex-col pr-1">
{rows.map((row) =>
row.kind === "gap" ? (
<div
key={`gap-${row.from}`}
className="flex h-6 items-center gap-2 pl-14 text-[10px] uppercase tracking-wide text-muted-foreground"
>
<span>
{t("cookieBot.schedule.quietHours", { count: row.count })}
</span>
<span className="h-px flex-1 rounded-full bg-border" />
</div>
) : (
<div key={`slot-${row.slot.hour}`} className="flex gap-3 pb-4">
<span className="w-14 shrink-0 pt-1 text-right text-xs tabular-nums text-muted-foreground">
{minutesToClock(row.slot.hour * 60)}
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5 border-l border-border pl-3">
{row.slot.entries.map((schedule) => {
const mine =
!schedule.owner_user_id ||
schedule.owner_user_id === currentUserId;
const editable = mine || canEditOthers;
const blocked = scheduleBlockedReason(t, schedule);
return (
<div
key={`${schedule.owner_user_id ?? "me"}-${schedule.profile_id}`}
className="group flex h-7 items-center gap-2 rounded-md px-2 text-xs transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
>
<StatusDot
tone={scheduleTone(schedule)}
className="size-1.5"
/>
<span className="min-w-0 flex-1 truncate">
{schedule.profile_name}
</span>
{/* "This cannot run" beats "it runs nightly": an
enrolment the server will refuse should not read as a
cadence it is about to keep. */}
<span
className={cn(
"shrink-0 text-[10px] uppercase tracking-wide",
blocked
? "text-warning-text"
: "text-muted-foreground",
)}
>
{blocked ?? describeCadence(t, schedule.days_mask)}
</span>
<span className="shrink-0 tabular-nums text-muted-foreground">
{minutesToClock(schedule.run_at_minute)}
</span>
{!mine && schedule.owner_email && (
<span className="hidden max-w-40 shrink-0 truncate text-[10px] uppercase tracking-wide text-muted-foreground @2xl:inline">
{schedule.owner_email}
</span>
)}
<SlotAction
label={t("cookieBot.enrolled.edit")}
forbidden={!editable}
onClick={() => {
onEdit(schedule);
}}
>
<LuPencil className="size-3.5" />
</SlotAction>
<SlotAction
label={t("cookieBot.schedule.unenrol")}
forbidden={!editable}
destructive
onClick={() => {
onRemove(schedule);
}}
>
<LuTrash2 className="size-3.5" />
</SlotAction>
</div>
);
})}
{row.slot.entries.length >= CROWDED_SLOT && (
<span className="px-2 pt-1 text-[11px] text-warning-text">
{t("cookieBot.schedule.crowded", {
count: row.slot.entries.length,
})}
</span>
)}
</div>
</div>
),
)}
</div>
</FadingScrollArea>
);
}
function SlotAction({
label,
forbidden,
destructive,
onClick,
children,
}: {
label: string;
forbidden: boolean;
destructive?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
const { t } = useTranslation();
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex shrink-0">
<Button
variant="ghost"
size="icon"
className={cn(
"size-7",
destructive && "text-destructive-text hover:bg-destructive/10",
)}
aria-label={label}
disabled={forbidden}
onClick={onClick}
>
{children}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
{forbidden ? t("cookieBot.conflict.replaceForbidden") : label}
</TooltipContent>
</Tooltip>
);
}
type Row =
| { kind: "slot"; slot: Slot }
| { kind: "gap"; from: number; count: number };
/**
* Groups enrolments into the hour they start and collapses the empty stretches
* between them. A 24-row skeleton of empty hours would be a grid pretending to
* be information.
*/
function buildRows(schedules: CookieBotSchedule[]): Row[] {
const byHour = new Map<number, CookieBotSchedule[]>();
for (const schedule of schedules) {
const hour = Math.floor(schedule.run_at_minute / 60) % 24;
const bucket = byHour.get(hour);
if (bucket) bucket.push(schedule);
else byHour.set(hour, [schedule]);
}
const hours = [...byHour.keys()].sort((a, b) => a - b);
const rows: Row[] = [];
let previous: number | null = null;
for (const hour of hours) {
if (previous !== null && hour - previous > 1) {
rows.push({
kind: "gap",
from: previous + 1,
count: hour - previous - 1,
});
}
const entries = (byHour.get(hour) ?? []).sort(
(a, b) =>
a.run_at_minute - b.run_at_minute ||
a.profile_name.localeCompare(b.profile_name),
);
rows.push({ kind: "slot", slot: { hour, entries } });
previous = hour;
}
return rows;
}
+949
View File
@@ -0,0 +1,949 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import type { TFunction } from "i18next";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Skeleton } from "@/components/ui/skeleton";
import {
Tooltip,
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";
import type { RemoteSessionState } from "@/lib/remote-sessions";
import { cn } from "@/lib/utils";
import type { BrowserProfile, WayfernFingerprintConfig } from "@/types";
/* -------------------------------------------------------------------------- */
/* Cadence */
/* -------------------------------------------------------------------------- */
/**
* Weekday bitmask, bit 0 = Monday. The masks below are the only three the
* enrolment dialog offers; anything else that comes back from the server is
* rendered as its own weekday list rather than forced into one of these.
*/
export const DAYS_NIGHTLY = 127;
export const DAYS_WEEKNIGHTS = 31;
export const DAYS_ALTERNATE = 85; // Mon / Wed / Fri / Sun
export type CadenceId = "nightly" | "weeknights" | "alternate";
export const CADENCES: { id: CadenceId; mask: number; labelKey: string }[] = [
{
id: "nightly",
mask: DAYS_NIGHTLY,
labelKey: "cookieBot.enrol.cadenceNightly",
},
{
id: "weeknights",
mask: DAYS_WEEKNIGHTS,
labelKey: "cookieBot.enrol.cadenceWeeknights",
},
{
id: "alternate",
mask: DAYS_ALTERNATE,
labelKey: "cookieBot.enrol.cadenceAlternate",
},
];
export function cadenceForMask(mask: number): CadenceId | null {
return CADENCES.find((c) => c.mask === mask)?.id ?? null;
}
export function nightsPerWeek(mask: number): number {
let count = 0;
for (let bit = 0; bit < 7; bit += 1) {
if ((mask & (1 << bit)) !== 0) count += 1;
}
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);
if (id) {
return t(CADENCES.find((c) => c.id === id)?.labelKey ?? "");
}
return t("cookieBot.enrol.cadenceCustom", { count: nightsPerWeek(mask) });
}
/* -------------------------------------------------------------------------- */
/* Time formatting */
/* -------------------------------------------------------------------------- */
/** `137` -> `02:17`. Always zero-padded so the column stays on one grid. */
export function minutesToClock(minutes: number): string {
const safe = ((Math.round(minutes) % 1440) + 1440) % 1440;
const h = Math.floor(safe / 60);
const m = safe % 60;
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`;
}
/** `02:17` -> `137`. Returns null for anything that isn't a real time. */
export function clockToMinutes(value: string): number | null {
const match = /^(\d{1,2}):(\d{2})$/.exec(value.trim());
if (!match) return null;
const h = Number(match[1]);
const m = Number(match[2]);
if (!Number.isFinite(h) || !Number.isFinite(m)) return null;
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
return h * 60 + m;
}
/** `724` -> `12:04`. Used for the live elapsed clock; never rounds up. */
export function formatElapsed(seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) {
return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
return `${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
}
/** `724` -> `12m 04s`, for a finished run's duration column. */
export function formatDuration(t: TFunction, seconds: number): string {
const total = Math.max(0, Math.floor(seconds));
const h = Math.floor(total / 3600);
const m = Math.floor((total % 3600) / 60);
const s = total % 60;
if (h > 0) return t("cookieBot.duration.hm", { hours: h, minutes: m });
if (m > 0) {
return t("cookieBot.duration.ms", {
minutes: m,
seconds: String(s).padStart(2, "0"),
});
}
return t("cookieBot.duration.s", { seconds: s });
}
/** Parses a server ISO timestamp. Returns null rather than an Invalid Date. */
export function parseIso(value: string | null | undefined): Date | null {
if (!value) return null;
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
export function formatDateTime(
value: string | null | undefined,
): string | null {
const date = parseIso(value);
if (!date) return null;
return date.toLocaleString(undefined, {
dateStyle: "medium",
timeStyle: "short",
});
}
export function formatDate(value: string | null | undefined): string | null {
const date = parseIso(value);
if (!date) return null;
return date.toLocaleDateString(undefined, {
day: "numeric",
month: "short",
});
}
/* -------------------------------------------------------------------------- */
/* Preflight */
/* -------------------------------------------------------------------------- */
/**
* Hosts the fleet can lease. Mirrors `BOT_PLATFORMS` in
* `src-tauri/src/cookie_bot.rs`; a profile built for anything else has no
* machine to run on and is refused before a schedule row is ever written.
*/
const BOT_PLATFORMS = ["windows", "macos"];
export type PreflightCode =
| "syncOff"
| "encrypted"
| "unknownPlatform"
| "unsupportedPlatform"
| "noExitNode";
/** The one-click repairs a failed preflight can name. */
export type PreflightFix = "sync" | "syncSettings" | "proxy";
export interface PreflightResult {
eligible: boolean;
code: PreflightCode | null;
/** Substituted into the reason line, e.g. the refused OS name. */
params: Record<string, string>;
/** Which one-click repair applies, when one does. */
fix: PreflightFix | null;
}
const ELIGIBLE: PreflightResult = {
eligible: true,
code: null,
params: {},
fix: null,
};
/** The OS a profile claims, from its own record then its fingerprint. */
export function resolvedOs(profile: BrowserProfile): string | null {
return profile.host_os ?? profile.wayfern_config?.os ?? null;
}
/**
* The exact refusals `cookie_bot::bot_precondition` applies, evaluated here so
* a user finds out at enrolment rather than at 02:00. Keeping the two in step
* matters: a profile this says is fine but the backend refuses would burn a
* schedule row and a night.
*/
export function preflight(profile: BrowserProfile): PreflightResult {
const syncMode = profile.sync_mode ?? "Disabled";
if (syncMode === "Disabled") {
return { eligible: false, code: "syncOff", params: {}, fix: "sync" };
}
if (syncMode === "Encrypted") {
return {
eligible: false,
code: "encrypted",
params: {},
fix: "syncSettings",
};
}
const os = resolvedOs(profile);
if (!os) {
return {
eligible: false,
code: "unknownPlatform",
params: {},
fix: null,
};
}
if (!BOT_PLATFORMS.includes(os)) {
return {
eligible: false,
code: "unsupportedPlatform",
params: { os },
fix: null,
};
}
if (!profile.proxy_id && !profile.vpn_id) {
return { eligible: false, code: "noExitNode", params: {}, fix: "proxy" };
}
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":
return t("cookieBot.preflight.reasonSync");
case "encrypted":
return t("cookieBot.preflight.reasonEncrypted");
case "unknownPlatform":
return t("cookieBot.preflight.reasonNoFingerprint");
case "unsupportedPlatform":
return t("cookieBot.preflight.reasonCrossOs", {
os: result.params.os ?? "",
});
case "noExitNode":
return t("cookieBot.preflight.reasonNoExitNode");
default:
return "";
}
}
export function preflightFixLabel(
t: TFunction,
fix: PreflightResult["fix"],
): string | null {
switch (fix) {
case "sync":
return t("cookieBot.preflight.fixSync");
case "syncSettings":
return t("cookieBot.preflight.fixEncrypted");
case "proxy":
return t("cookieBot.preflight.fixProxy");
default:
return null;
}
}
/** Turning sync on is the one repair the dialog can perform by itself. */
export function enableProfileSync(profileId: string): Promise<void> {
return invoke<void>("set_profile_sync_mode", {
profileId,
syncMode: "Regular",
});
}
/* -------------------------------------------------------------------------- */
/* Timezone */
/* -------------------------------------------------------------------------- */
/**
* The timezone the profile pretends to live in. The run is anchored to it so
* a "02:00" enrolment means 02:00 where the identity claims to be, not where
* the operator happens to be sitting. Falls back to this machine's zone.
*/
export function profileTimezone(profile: BrowserProfile): string {
const raw = profile.wayfern_config?.fingerprint;
if (raw) {
try {
const parsed = JSON.parse(raw) as WayfernFingerprintConfig;
if (typeof parsed.timezone === "string" && parsed.timezone.length > 0) {
return parsed.timezone;
}
} catch {
// A fingerprint we cannot parse is not an error here — the local zone is
// a correct, if less specific, anchor.
}
}
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
}
/* -------------------------------------------------------------------------- */
/* Run + session status */
/* -------------------------------------------------------------------------- */
export type StatusTone =
| "success"
| "warning"
| "destructive"
| "muted"
| "live";
export function runStatusTone(status: string): StatusTone {
switch (status) {
case "succeeded":
return "success";
case "running":
case "pending":
return "live";
case "partial":
case "skipped":
return "warning";
case "failed":
return "destructive";
default:
return "muted";
}
}
export function runStatusLabel(t: TFunction, status: string): string {
switch (status) {
case "pending":
return t("cookieBot.runStatus.pending");
case "running":
return t("cookieBot.runStatus.running");
case "succeeded":
return t("cookieBot.runStatus.succeeded");
case "partial":
return t("cookieBot.runStatus.partial");
case "failed":
return t("cookieBot.runStatus.failed");
case "skipped":
return t("cookieBot.runStatus.skipped");
case "cancelled":
return t("cookieBot.runStatus.cancelled");
default:
// The status vocabulary belongs to the server. One it adds after this
// build renders as itself rather than as a blank cell.
return status;
}
}
/**
* Every value of the server's `CookieBotOutcomeCode`, mapped to a translated
* sentence.
*
* The code exists so a refusal is something a user can SEE in their history.
* Printed raw it was a snake_case English token in ten locales: a Russian
* operator asking why last night did nothing read "Причина: no_capacity".
* A value newer than this build still falls through to its own name, which
* beats a blank cell, but every code the server defines today has a sentence.
*/
const OUTCOME_KEYS: Record<string, string> = {
not_entitled: "cookieBot.outcome.notEntitled",
sync_disabled: "cookieBot.outcome.syncDisabled",
encrypted_sync: "cookieBot.outcome.encryptedSync",
proxy_required: "cookieBot.outcome.proxyRequired",
touch_fingerprint: "cookieBot.outcome.touchFingerprint",
platform_unsupported: "cookieBot.outcome.platformUnsupported",
no_sites: "cookieBot.outcome.noSites",
quota_exhausted: "cookieBot.outcome.quotaExhausted",
profile_locked: "cookieBot.outcome.profileLocked",
no_capacity: "cookieBot.outcome.noCapacity",
manager_error: "cookieBot.outcome.managerError",
budget_exceeded: "cookieBot.outcome.budgetExceeded",
cancelled_by_user: "cookieBot.outcome.cancelledByUser",
};
export function outcomeLabel(
t: TFunction,
code: string | null | undefined,
): string | null {
if (!code) return null;
const key = OUTCOME_KEYS[code];
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
* backend actually reports; nothing here infers a phase the backend has not
* sent.
*/
export function sessionPhaseLabel(
t: TFunction,
session: RemoteSessionState,
): string {
switch (session.state) {
case "provisioning":
return t("cookieBot.status.provisioning");
case "ready":
return t("cookieBot.status.ready");
case "live":
return t("cookieBot.status.warming");
case "closed":
return t("cookieBot.status.finished");
case "error":
return t("cookieBot.status.failed");
default:
return session.state;
}
}
export function sessionTone(session: RemoteSessionState): StatusTone {
switch (session.state) {
case "provisioning":
return "warning";
case "ready":
return "live";
case "live":
return "success";
case "closed":
return "muted";
// A session the fleet failed is not a session that quietly finished. It
// read as an untranslated `error` beside the same grey dot as an idle one,
// in the exact place a user checks whether last night worked.
case "error":
return "destructive";
default:
return "muted";
}
}
/**
* Why a session ended, when the backend named a reason.
*
* `close_reason` has been on the wire since the stream existed and nothing read
* it, so a session that hit the two-hour cap and one the user stopped looked
* identical.
*/
export function sessionCloseReason(
t: TFunction,
session: RemoteSessionState,
): string | null {
switch (session.close_reason) {
case null:
case undefined:
case "":
return null;
case "stopped_by_user":
return t("cookieBot.closeReason.stoppedByUser");
case "max_duration":
return t("cookieBot.closeReason.maxDuration");
default:
// The vocabulary is the fleet's and it grows. An unknown reason still
// beats no reason, but it is labelled so it does not read as a sentence.
return t("cookieBot.closeReason.other", { reason: session.close_reason });
}
}
const TONE_DOT: Record<StatusTone, string> = {
success: "bg-success",
warning: "bg-warning",
destructive: "bg-destructive",
muted: "bg-muted-foreground",
live: "bg-warning",
};
/**
* The app's one status vocabulary: a bare dot, no chip and no background.
* `pulse` is reserved for "a transfer is in progress", exactly as the sync dots
* use it, so a pulsing dot always means the same thing.
*/
export function StatusDot({
tone,
pulse,
className,
}: {
tone: StatusTone;
pulse?: boolean;
className?: string;
}) {
return (
<span
aria-hidden="true"
className={cn(
"inline-block size-2 shrink-0 rounded-full",
TONE_DOT[tone],
pulse && "animate-pulse",
className,
)}
/>
);
}
/* -------------------------------------------------------------------------- */
/* Numbers */
/* -------------------------------------------------------------------------- */
/**
* A cookie delta. A gain reads as a gain, a loss reads with a real minus sign
* (U+2212, not a hyphen), and "nothing happened" reads as an em dash instead of
* a confident zero.
*/
export function CookieDelta({
value,
className,
}: {
value: number | null | undefined;
className?: string;
}) {
if (value === null || value === undefined) {
return (
<span className={cn("text-muted-foreground", className)} aria-hidden>
</span>
);
}
if (value === 0) {
return (
<span className={cn("text-muted-foreground", className)} aria-hidden>
</span>
);
}
const positive = value > 0;
return (
<span
className={cn(
"tabular-nums",
positive ? "text-chart-1" : "text-muted-foreground",
className,
)}
>
{positive ? `+${value}` : `${Math.abs(value)}`}
</span>
);
}
/** One decimal, but only when it earns one: `4.7 h`, `128 h`. */
export function formatHours(hours: number): string {
if (!Number.isFinite(hours)) return "0";
if (hours >= 100 || Number.isInteger(hours)) return String(Math.round(hours));
return hours.toFixed(1);
}
/* -------------------------------------------------------------------------- */
/* Remote hours */
/* -------------------------------------------------------------------------- */
function meterFill(usedRatio: number): string {
if (usedRatio >= 1) return "bg-destructive";
if (usedRatio >= 0.9) return "bg-warning";
return "bg-foreground";
}
/**
* The hours meter. The track renders at full opacity on the first paint with
* the label already in place; only the numerals wait for the server. The fill
* is a scaleX transform with `initial={false}` so the first frame is the true
* value and never grows in and the radius lives on the track, so the fill's
* caps cannot flip shape halfway through a change.
*/
export function RemoteHoursMeter({
quota,
isLoading,
variant = "compact",
className,
}: {
quota: RemoteHoursQuota | null;
isLoading: boolean;
variant?: "compact" | "full" | "inline";
className?: string;
}) {
const reduceMotion = useReducedMotion();
const granted = quota?.granted_hours ?? 0;
const used = quota?.used_hours ?? 0;
const remaining = quota?.remaining_hours ?? 0;
const ratio = granted > 0 ? Math.min(1, Math.max(0, used / granted)) : 0;
const resets = formatDate(quota?.period_end);
const bar = (
<div
className={cn(
"h-1 overflow-hidden rounded-full bg-muted",
variant === "compact" ? "w-32" : "w-full",
)}
>
<motion.div
initial={false}
animate={{ scaleX: ratio }}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.22, ease: MOTION_EASE_OUT }
}
style={{ transformOrigin: "left", willChange: "transform" }}
className={cn("h-full w-full", meterFill(ratio))}
/>
</div>
);
if (variant === "inline") {
return <div className={cn("w-full", className)}>{bar}</div>;
}
return (
<div className={cn("flex flex-col items-end gap-1", className)}>
<div className="flex items-baseline gap-1.5">
{isLoading ? (
<Skeleton className="h-3 w-24" />
) : (
<RemoteHoursReadout
remaining={remaining}
granted={granted}
resets={resets}
/>
)}
</div>
{bar}
</div>
);
}
function RemoteHoursReadout({
remaining,
granted,
resets,
}: {
remaining: number;
granted: number;
resets: string | null;
}) {
const { t } = useTranslation();
const text = t("cookieBot.hours.remaining", {
remaining: formatHours(remaining),
total: formatHours(granted),
});
if (!resets) {
return (
<span className="text-xs tabular-nums text-muted-foreground">{text}</span>
);
}
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default text-xs tabular-nums text-muted-foreground">
{text}
</span>
</TooltipTrigger>
<TooltipContent>
{t("cookieBot.hours.resets", { date: resets })}
</TooltipContent>
</Tooltip>
);
}
/* -------------------------------------------------------------------------- */
/* Live sessions */
/* -------------------------------------------------------------------------- */
/**
* A ticking wall clock, only while something is actually running. Returns
* `Date.now()` once a second; consumers render it into `tabular-nums` so a
* changing digit never reflows the row.
*/
export function useSecondTicker(active: boolean): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (!active) return;
setNow(Date.now());
const id = window.setInterval(() => {
setNow(Date.now());
}, 1000);
return () => {
window.clearInterval(id);
};
}, [active]);
return now;
}
/* -------------------------------------------------------------------------- */
/* Joins */
/* -------------------------------------------------------------------------- */
export function indexProfiles(
profiles: BrowserProfile[],
): Map<string, BrowserProfile> {
return new Map(profiles.map((p) => [p.id, p]));
}
export function indexRunsBySession(
runs: CookieBotRun[],
): Map<string, CookieBotRun> {
const map = new Map<string, CookieBotRun>();
for (const run of runs) {
if (run.session_id) map.set(run.session_id, run);
}
return map;
}
export function indexRunsById(runs: CookieBotRun[]): Map<string, CookieBotRun> {
return new Map(runs.map((run) => [run.id, run]));
}
/**
* The name to print for a session. The local profile record wins because it is
* what the operator renamed; the run row is the fallback for a teammate's
* profile this machine has never held.
*/
export function sessionDisplayName(
session: RemoteSessionState,
profiles: Map<string, BrowserProfile>,
run: CookieBotRun | undefined,
): string | null {
if (session.profile_id) {
const profile = profiles.get(session.profile_id);
if (profile) return profile.name;
}
return run?.profile_name ?? null;
}
export function scheduleSortKey(schedule: CookieBotSchedule): number {
return schedule.run_at_minute;
}
/**
* The dot a stored enrolment gets.
*
* `blocked_by` outranks `enabled`: an armed schedule the server will refuse is
* not a healthy one, and showing it green with a next-run time is how a
* detached proxy stayed invisible until the run was skipped at 02:00.
*/
export function scheduleTone(schedule: CookieBotSchedule): StatusTone {
if (!schedule.enabled) return "muted";
return schedule.blocked_by ? "warning" : "success";
}
/** Why this enrolment cannot run tonight, translated, or null. */
export function scheduleBlockedReason(
t: TFunction,
schedule: CookieBotSchedule,
): string | null {
if (!schedule.enabled) return null;
return outcomeLabel(t, schedule.blocked_by);
}
/** Seconds a session has been alive, or null when the backend has not said. */
export function sessionElapsedSeconds(
session: RemoteSessionState,
now: number,
): number | null {
const started = parseIso(session.started_at);
if (!started) return null;
const end = parseIso(session.ended_at)?.getTime() ?? now;
return Math.max(0, Math.floor((end - started.getTime()) / 1000));
}
/**
* Whether a run's per-site counters mean anything yet.
*
* `sites_visited`, `sites_failed` and `consent_dismissed` are columns the
* server declares with `DEFAULT 0` and, today, nothing ever writes: the fleet
* computes them but donutbrowser-infra does not ingest them. Rendering the
* default as a fact told a paying user their run visited "0 of 12 sites" and
* drew a success-green progress bar pinned at zero for the whole night.
*
* So a run is only credited with counters once one of them is non-zero. Until
* then the UI says it does not know, which is the truth. The moment the
* ingestion lands this starts reporting real numbers with no further change.
*/
export function hasRunCounters(run: {
sites_visited: number;
sites_failed: number;
consent_dismissed: number;
}): boolean {
return (
run.sites_visited > 0 || run.sites_failed > 0 || run.consent_dismissed > 0
);
}
/**
* Runs whose schedule fires on the next occurrence of their local start time.
* Purely a read of the server's own `next_run_at` nothing here computes a
* schedule, it only sorts what the server already decided.
*/
export function sortByNextRun(
schedules: CookieBotSchedule[],
): CookieBotSchedule[] {
return [...schedules].sort((a, b) => {
const at = parseIso(a.next_run_at)?.getTime();
const bt = parseIso(b.next_run_at)?.getTime();
if (at !== undefined && bt !== undefined) return at - bt;
if (at !== undefined) return -1;
if (bt !== undefined) return 1;
return scheduleSortKey(a) - scheduleSortKey(b);
});
}
export function useNextDue(schedules: CookieBotSchedule[]) {
return useMemo(() => {
const enabled = schedules.filter((s) => s.enabled);
const sorted = sortByNextRun(enabled);
const first = sorted[0] ?? null;
const firstAt = parseIso(first?.next_run_at ?? null);
const dueCount = firstAt
? sorted.filter((s) => {
const at = parseIso(s.next_run_at);
if (!at) return false;
return at.getTime() - firstAt.getTime() < 12 * 60 * 60 * 1000;
}).length
: enabled.length;
return { next: first, nextAt: firstAt, dueCount };
}, [schedules]);
}
+24 -2
View File
@@ -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
+449
View File
@@ -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>
);
}
+632 -18
View File
@@ -27,6 +27,7 @@ import {
LuCookie,
LuInfo,
LuLock,
LuMoon,
LuPlay,
LuPuzzle,
LuSquare,
@@ -35,6 +36,22 @@ import {
LuUserSearch,
LuUsers,
} from "react-icons/lu";
import { CookieBotEnrolDialog } from "@/components/cookie-bot-enrol-dialog";
import { CookieBotRunsDialog } from "@/components/cookie-bot-runs-dialog";
import {
describeCadence,
enableProfileSync,
minutesToClock,
outcomeLabel,
type PreflightFix,
preflight,
preflightFixLabel,
preflightReason,
runStatusLabel,
StatusDot,
sessionPhaseLabel,
sessionTone,
} from "@/components/cookie-bot-shared";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import {
ProfileBypassRulesDialog,
@@ -57,6 +74,8 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import {
@@ -80,24 +99,37 @@ import {
} from "@/components/ui/tooltip";
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";
import { useVpnEvents } from "@/hooks/use-vpn-events";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import {
getBrowserDisplayName,
getOSDisplayName,
getProfileIcon,
isCrossOsProfile,
} from "@/lib/browser-utils";
import {
type CookieBotSchedule,
cancelCookieBotRun,
deleteCookieBotSchedule,
runCookieBotNow,
} from "@/lib/cookie-bot";
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 {
BrowserProfile,
ExtensionGroup,
LocationItem,
ProfileBotState,
ProxyCheckResult,
StoredProxy,
SyncSessionInfo,
@@ -210,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;
@@ -243,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) =>
| {
@@ -252,8 +293,52 @@ interface TableMeta {
}
| undefined;
onLaunchWithSync: (profile: BrowserProfile) => void;
// Cookie Bot
cookieBotUnlocked: boolean;
/** Narrow container: the bot column shows its state mark without the label. */
cookieBotCompact: boolean;
getProfileBotState: (profileId: string) => ProfileBotState;
/** A run this desktop has just asked for, before the stream confirms it. */
botPendingProfiles: Set<string>;
onBotEnrol: (profile: BrowserProfile) => void;
onBotEdit: (profile: BrowserProfile, schedule: CookieBotSchedule) => void;
onBotRunNow: (profile: BrowserProfile) => void;
onBotStopRun: (runId: string) => void;
onBotViewActivity: (profile: BrowserProfile) => void;
onBotUnenrol: (profile: BrowserProfile) => void;
/**
* Perform the repair a failed preflight names, or null when this surface has
* no way to reach it. A reason with no affordance is what the menu showed
* before: "No proxy or VPN · Attach a proxy" as inert label text that reads
* like a button and answers no click.
*/
onBotFix: ((profile: BrowserProfile, fix: PreflightFix) => void) | null;
}
/**
* Below this container width the bot column keeps its state mark but drops the
* "next run" label: an operator still sees at a glance which rows are enrolled
* and which are running, and the row menu stays reachable, without taking the
* width the name needs.
*/
const BOT_LABEL_WIDTH = 880;
/** Below this the bot column leaves entirely, like the other low-priority ones. */
const BOT_COLUMN_MIN_WIDTH = 400;
/** Bulk enrolments of this size or larger are confirmed, as run and stop are. */
const BULK_ENROL_CONFIRM_THRESHOLD = 10;
/**
* Run statuses that mean the browser never came up.
*
* `POST /cookie-bot/runs` answers 202 with the run row it recorded, so a
* refusal no capacity, no sites, a profile someone else has open arrives as
* a successful response carrying a terminal status.
*/
const RUN_DID_NOT_START = new Set(["skipped", "failed", "cancelled"]);
interface SyncStatusDot {
color: string;
tooltip: string;
@@ -1117,9 +1202,199 @@ const NoteCell = React.memo<{
NoteCell.displayName = "NoteCell";
/** `HH:MM` of the server's own next-run instant, in this machine's locale. */
function formatNextRun(schedule: CookieBotSchedule): string | null {
if (!schedule.next_run_at) return null;
const date = new Date(schedule.next_run_at);
if (Number.isNaN(date.getTime())) return null;
return date.toLocaleTimeString(undefined, {
hour: "2-digit",
minute: "2-digit",
});
}
/**
* One row's Cookie Bot state, and the row's bot actions.
*
* The whole cell is the menu trigger. A dedicated kebab would cost another
* column of width in a table that is already dense, and the state mark is
* exactly the thing an operator reaches for when they want to change it. The
* dot, the tone and the phase wording all come from the shared status
* vocabulary, so a run cannot read one way here and another on the Cookie Bot
* page.
*/
const BotCell = React.memo<{
profile: BrowserProfile;
meta: TableMeta;
}>(({ profile, meta }) => {
// Own `t` rather than `meta.t`: the shared status helpers take a real
// `TFunction`, and every other cell in this file resolves it the same way.
const { t } = useTranslation();
const { schedule, liveSession } = meta.getProfileBotState(profile.id);
const check = preflight(profile);
const isPending = meta.botPendingProfiles.has(profile.id);
const isLive = liveSession !== null;
const nextRun = schedule ? formatNextRun(schedule) : null;
// The server computes "why tonight would be refused" on every read, precisely
// so a detached proxy is visible in the afternoon rather than announcing
// itself as a skipped run at 02:00. Dropping it left a broken enrolment
// showing a healthy dot and a next-run time it could never keep.
const blockedReason =
schedule?.enabled && schedule.blocked_by
? outcomeLabel(t, schedule.blocked_by)
: null;
// `provisioning` is a transfer in progress — the one meaning the app already
// reserves a pulsing dot for. Nothing else pulses.
const isPreparing = liveSession?.state === "provisioning" || isPending;
const tone = liveSession
? sessionTone(liveSession)
: isPending
? "warning"
: schedule
? schedule.enabled && !blockedReason
? "muted"
: "warning"
: null;
const label = isPending
? t("cookieBot.status.provisioning")
: liveSession
? sessionPhaseLabel(t, liveSession)
: schedule
? !schedule.enabled
? t("cookieBot.state.paused")
: (blockedReason ?? nextRun ?? t("cookieBot.state.enrolled"))
: "—";
const summary = schedule
? blockedReason
? t("cookieBot.state.blocked", { reason: blockedReason })
: t("cookieBot.state.summary", {
cadence: describeCadence(t, schedule.days_mask),
time: minutesToClock(schedule.run_at_minute),
})
: check.eligible
? t("cookieBot.state.notEnrolled")
: // The repair is its own menu item when this surface can reach it, so
// the label stays a statement instead of looking like a second button.
[
preflightReason(t, check),
meta.onBotFix ? null : preflightFixLabel(t, check.fix),
]
.filter(Boolean)
.join(" · ");
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
aria-label={t("cookieBot.state.rowMenu", { name: profile.name })}
className="flex h-9 w-full min-w-0 cursor-pointer items-center gap-1.5 rounded border-none bg-transparent px-1.5 text-left transition-colors duration-100 hover:bg-muted"
>
{tone ? (
<StatusDot tone={tone} pulse={isPreparing} className="size-1.5" />
) : (
<span aria-hidden="true" className="size-1.5 shrink-0" />
)}
{!meta.cookieBotCompact && (
<span
className={cn(
"min-w-0 truncate text-xs tabular-nums",
isLive || isPending
? "text-foreground"
: "text-muted-foreground",
)}
>
{label}
</span>
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-56">
<DropdownMenuLabel className="font-normal text-muted-foreground">
{summary}
</DropdownMenuLabel>
<DropdownMenuSeparator />
{schedule ? (
<>
<DropdownMenuItem
onClick={() => {
meta.onBotEdit(profile, schedule);
}}
>
{t("cookieBot.actions.editSchedule")}
</DropdownMenuItem>
{liveSession?.run_id ? (
<DropdownMenuItem
onClick={() => {
if (liveSession.run_id) {
meta.onBotStopRun(liveSession.run_id);
}
}}
>
{t("cookieBot.running.stop")}
</DropdownMenuItem>
) : (
<DropdownMenuItem
disabled={isLive || isPending}
onClick={() => {
meta.onBotRunNow(profile);
}}
>
{t("cookieBot.actions.runNow")}
</DropdownMenuItem>
)}
<DropdownMenuItem
onClick={() => {
meta.onBotViewActivity(profile);
}}
>
{t("cookieBot.actions.viewActivity")}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
variant="destructive"
onClick={() => {
meta.onBotUnenrol(profile);
}}
>
{t("cookieBot.schedule.unenrol")}
</DropdownMenuItem>
</>
) : (
<>
{!check.eligible && check.fix && meta.onBotFix && (
<DropdownMenuItem
onClick={() => {
meta.onBotFix?.(profile, check.fix as PreflightFix);
}}
>
{preflightFixLabel(t, check.fix)}
</DropdownMenuItem>
)}
<DropdownMenuItem
disabled={!check.eligible}
onClick={() => {
meta.onBotEnrol(profile);
}}
>
{t("cookieBot.actions.enrol")}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
});
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>;
@@ -1131,6 +1406,8 @@ interface ProfilesDataTableProps {
isUpdating: (browser: string) => boolean;
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
onAssignProfilesToGroup: (profileIds: string[]) => void;
/** Opens proxy assignment for a specific set of profiles. */
onAssignProfilesToProxy?: (profileIds: string[]) => void;
selectedGroupId: string | null;
selectedProfiles: string[];
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
@@ -1186,6 +1463,7 @@ export function ProfilesDataTable({
runningProfiles,
isUpdating,
onAssignProfilesToGroup,
onAssignProfilesToProxy,
selectedProfiles,
onSelectedProfilesChange,
onBulkDelete,
@@ -1318,6 +1596,40 @@ 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.
const cookieBotUnlocked = canUseCookieBot(user);
const {
scheduleFor,
liveSessionFor,
refresh: refreshCookieBotState,
} = useCookieBot(cookieBotUnlocked, cookieBotScopeFor(user));
const [botPendingProfiles, setBotPendingProfiles] = React.useState<
Set<string>
>(new Set());
const [botScheduleDialog, setBotScheduleDialog] = React.useState<{
profiles: BrowserProfile[];
existing: CookieBotSchedule | null;
} | null>(null);
const [botRunsProfile, setBotRunsProfile] =
React.useState<BrowserProfile | null>(null);
const [botUnenrolProfile, setBotUnenrolProfile] =
React.useState<BrowserProfile | null>(null);
const [isUnenrolling, setIsUnenrolling] = React.useState(false);
const [pendingBulkEnrol, setPendingBulkEnrol] = React.useState<
BrowserProfile[] | null
>(null);
// Content columns grow proportionally with the container but never drop
// below the compact-layout floor; the name column takes the remainder.
// Computed in px from the observed container width because fixed table
// layout ignores max()/calc() column widths.
const [containerWidth, setContainerWidth] = React.useState(0);
const [proxyOverrides, setProxyOverrides] = React.useState<
Record<string, string | null>
@@ -1512,6 +1824,142 @@ export function ProfilesDataTable({
[handleProxySelection],
);
const getProfileBotState = React.useCallback(
(profileId: string): ProfileBotState => ({
schedule: scheduleFor(profileId),
liveSession: liveSessionFor(profileId),
}),
[scheduleFor, liveSessionFor],
);
const handleBotEnrol = React.useCallback((profile: BrowserProfile) => {
setBotScheduleDialog({ profiles: [profile], existing: null });
}, []);
const handleBotEdit = React.useCallback(
(profile: BrowserProfile, schedule: CookieBotSchedule) => {
setBotScheduleDialog({ profiles: [profile], existing: schedule });
},
[],
);
const handleBotViewActivity = React.useCallback((profile: BrowserProfile) => {
setBotRunsProfile(profile);
}, []);
const handleBotFix = React.useCallback(
(profile: BrowserProfile, fix: PreflightFix) => {
if (fix === "proxy") {
onAssignProfilesToProxy?.([profile.id]);
return;
}
if (fix === "syncSettings") {
onOpenProfileSyncDialog?.(profile);
return;
}
void enableProfileSync(profile.id).catch((error: unknown) => {
showErrorToast(
parseBackendError(error)
? translateBackendError(t as never, error)
: t("cookieBot.preflight.fixFailed"),
);
});
},
[onAssignProfilesToProxy, onOpenProfileSyncDialog, t],
);
// Null rather than a no-op when nothing is wired: the menu then states the
// reason without offering a repair it cannot perform.
const botFixHandler =
onAssignProfilesToProxy || onOpenProfileSyncDialog ? handleBotFix : null;
const handleBotRunNow = React.useCallback(
async (profile: BrowserProfile) => {
// Held locally until the stream reports the session: the run is real the
// moment the command returns, and a row that still looks idle invites a
// second click that would spend a second hour.
setBotPendingProfiles((prev) => new Set(prev).add(profile.id));
try {
const started = await runCookieBotNow(profile.id);
// 202, not 200: the route answers with a RECORDED run, and a run that
// could not get a host comes back already terminal, carrying an
// `outcome_code`, rather than as an HTTP error. Treating every 2xx as
// "started" told a user their run had begun on a night when every
// Windows host in a four-slot fleet was busy, and the only trace was a
// row in a history panel they had to go and open.
if (RUN_DID_NOT_START.has(started.run.status)) {
showErrorToast(
t("cookieBot.actions.runNotStarted", {
reason:
outcomeLabel(t as never, started.run.outcome_code) ??
runStatusLabel(t as never, started.run.status),
}),
);
} else {
showSuccessToast(t("cookieBot.actions.runStarted"));
}
await refreshCookieBotState();
} catch (error) {
showErrorToast(translateBackendError(t as never, error));
} finally {
setBotPendingProfiles((prev) => {
const next = new Set(prev);
next.delete(profile.id);
return next;
});
}
},
[refreshCookieBotState, t],
);
const handleBotStopRun = React.useCallback(
async (runId: string) => {
try {
await cancelCookieBotRun(runId);
showSuccessToast(t("cookieBot.running.stopped"));
await refreshCookieBotState();
} catch (error) {
showErrorToast(translateBackendError(t as never, error));
}
},
[refreshCookieBotState, t],
);
const handleBotUnenrol = React.useCallback(async () => {
if (!botUnenrolProfile) return;
setIsUnenrolling(true);
try {
await deleteCookieBotSchedule(botUnenrolProfile.id);
showSuccessToast(t("cookieBot.schedule.unenrolled"));
setBotUnenrolProfile(null);
await refreshCookieBotState();
} catch (error) {
showErrorToast(translateBackendError(t as never, error));
} finally {
setIsUnenrolling(false);
}
}, [botUnenrolProfile, refreshCookieBotState, t]);
const handleBulkCookieBotEnrol = React.useCallback(() => {
const targets = profiles.filter((p) => selectedProfiles.includes(p.id));
if (targets.length === 0) return;
const eligible = targets.filter((p) => preflight(p).eligible);
// Same guard as bulk run: an action that can touch nothing says so instead
// of opening a dialog whose only outcome is a refusal.
if (eligible.length === 0) {
showErrorToast(t("cookieBot.actionBar.noneEligible"));
return;
}
// Ten or more is the threshold bulk run and stop already use, and enrolling
// is the heavier commitment of the three: each row books a nightly job
// against a shared budget.
if (eligible.length >= BULK_ENROL_CONFIRM_THRESHOLD) {
setPendingBulkEnrol(targets);
return;
}
setBotScheduleDialog({ profiles: targets, existing: null });
}, [profiles, selectedProfiles, t]);
// Use shared browser state hook
const browserState = useBrowserState(
profiles,
@@ -2012,6 +2460,9 @@ export function ProfilesDataTable({
getProfileLockEmail: (profileId: string) =>
getLockInfo(profileId)?.lockedByEmail,
// Remote execution
getRemoteHandoff: handoffFor,
// Synchronizer
getProfileSyncInfo: getProfileSyncInfo ?? (() => undefined),
onLaunchWithSync:
@@ -2019,6 +2470,23 @@ export function ProfilesDataTable({
(() => {
/* empty */
}),
// Cookie Bot
cookieBotUnlocked,
cookieBotCompact: containerWidth > 0 && containerWidth < BOT_LABEL_WIDTH,
getProfileBotState,
botPendingProfiles,
onBotEnrol: handleBotEnrol,
onBotEdit: handleBotEdit,
onBotRunNow: (profile: BrowserProfile) => {
void handleBotRunNow(profile);
},
onBotStopRun: (runId: string) => {
void handleBotStopRun(runId);
},
onBotViewActivity: handleBotViewActivity,
onBotUnenrol: setBotUnenrolProfile,
onBotFix: botFixHandler,
}),
[
t,
@@ -2074,8 +2542,19 @@ export function ProfilesDataTable({
handleCreateCountryProxy,
isProfileLocked,
getLockInfo,
handoffFor,
getProfileSyncInfo,
onLaunchWithSync,
cookieBotUnlocked,
containerWidth,
getProfileBotState,
botPendingProfiles,
handleBotEnrol,
handleBotEdit,
handleBotRunNow,
handleBotStopRun,
handleBotViewActivity,
botFixHandler,
],
);
@@ -2265,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>) =>
@@ -2976,6 +3472,19 @@ export function ProfilesDataTable({
return <DnsCell profile={profile} meta={meta} />;
},
},
{
id: "bot",
size: 84,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
if (meta.cookieBotCompact) return null;
return meta.t("profiles.table.bot");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
return <BotCell profile={row.original} meta={meta} />;
},
},
{
id: "sync",
header: "",
@@ -3053,14 +3562,11 @@ export function ProfilesDataTable({
// Low-priority columns leave the table as the container narrows (most
// expendable first); their data stays reachable via the profile info
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
// `bot` starts hidden and is switched on by the resize effect below. An
// unentitled account must never see a paid column, not even for the frame
// before the observer's first measurement lands.
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({ created_at: false });
// Content columns grow proportionally with the container but never drop
// below the compact-layout floor; the name column takes the remainder.
// Computed in px from the observed container width because fixed table
// layout ignores max()/calc() column widths.
const [containerWidth, setContainerWidth] = React.useState(0);
React.useState<VisibilityState>({ created_at: false, bot: false });
const table = useReactTable({
data: profiles,
@@ -3090,6 +3596,14 @@ export function ProfilesDataTable({
const scrollParentRef = React.useRef<HTMLDivElement | null>(null);
const columnWidth = React.useCallback(
(id: string, sizePx: number) => {
// The bot column is the one column with two shapes: a labelled state at
// full width, a bare mark when the table is narrow. Taking a proportion
// in the compact shape would waste the space the name column needs.
if (id === "bot") {
return containerWidth > 0 && containerWidth < BOT_LABEL_WIDTH
? "28px"
: `${Math.max(84, Math.round(containerWidth * 0.09))}px`;
}
const proportions: Record<string, { pct: number; floor: number }> = {
tags: { pct: 0.12, floor: 100 },
note: { pct: 0.1, floor: 80 },
@@ -3120,6 +3634,10 @@ export function ProfilesDataTable({
ext: w >= 672,
note: w >= 576,
tags: w >= 512,
// Bot state survives further down than the other content columns:
// by then it is a 28px mark, and it is the only place a row's
// enrolment and its actions can be reached.
bot: cookieBotUnlocked && w >= BOT_COLUMN_MIN_WIDTH,
};
return Object.keys(next).every((k) => prev[k] === next[k])
? prev
@@ -3132,7 +3650,7 @@ export function ProfilesDataTable({
return () => {
ro.disconnect();
};
}, []);
}, [cookieBotUnlocked]);
// Compact 36px row from the redesign spec; estimateSize must match the
// actual rendered row height or virtualizer placement drifts under scroll.
@@ -3508,6 +4026,23 @@ export function ProfilesDataTable({
<LuCookie />
</DataTableActionBarAction>
)}
<span className="relative inline-flex">
<DataTableActionBarAction
tooltip={
cookieBotUnlocked
? t("cookieBot.actionBar.enrol")
: t("cookieBot.actionBar.proRequired")
}
onClick={cookieBotUnlocked ? handleBulkCookieBotEnrol : undefined}
disabled={!cookieBotUnlocked}
size="icon"
>
<LuMoon />
</DataTableActionBarAction>
{!cookieBotUnlocked && (
<ProBadge className="pointer-events-none absolute -top-2 -right-2" />
)}
</span>
{onBulkDelete && (
<DataTableActionBarAction
tooltip={t("common.buttons.delete")}
@@ -3554,6 +4089,85 @@ export function ProfilesDataTable({
profileId={launchHookProfile?.id ?? null}
currentLaunchHook={launchHookProfile?.launch_hook ?? null}
/>
{botScheduleDialog && (
<CookieBotEnrolDialog
isOpen
onClose={() => {
setBotScheduleDialog(null);
}}
profiles={botScheduleDialog.profiles}
existing={botScheduleDialog.existing}
onOpenProfileSync={onOpenProfileSyncDialog}
// "A proxy or VPN is required" is the precondition most profiles
// fail, and without this the dialog showed the reason with no way to
// act on it — one-click fixable from the Cookie Bot page and a dead
// end from the row menu that is the primary entry point.
onAssignProxy={onAssignProfilesToProxy}
onSaved={() => {
// Clearing after a bulk write mirrors the other bulk actions: the
// selection has been acted on, and leaving it live invites a second
// pass over profiles that are already enrolled.
if (botScheduleDialog.profiles.length > 1) {
onSelectedProfilesChange([]);
}
}}
/>
)}
<DeleteConfirmationDialog
isOpen={pendingBulkEnrol !== null}
onClose={() => {
setPendingBulkEnrol(null);
}}
onConfirm={() => {
if (!pendingBulkEnrol) return;
setBotScheduleDialog({
profiles: pendingBulkEnrol,
existing: null,
});
setPendingBulkEnrol(null);
}}
title={t("cookieBot.enrol.confirmBulkTitle", {
count:
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
})}
description={t("cookieBot.enrol.confirmBulkDescription", {
count:
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
})}
confirmButtonText={t("cookieBot.enrol.confirmBulkButton", {
count:
pendingBulkEnrol?.filter((p) => preflight(p).eligible).length ?? 0,
})}
confirmButtonVariant="default"
profileIds={pendingBulkEnrol
?.filter((p) => preflight(p).eligible)
.map((p) => p.id)}
profiles={pendingBulkEnrol?.map((p) => ({ id: p.id, name: p.name }))}
/>
<CookieBotRunsDialog
isOpen={botRunsProfile !== null}
onClose={() => {
setBotRunsProfile(null);
}}
profileId={botRunsProfile?.id ?? null}
profileName={botRunsProfile?.name}
onRunCancelled={() => {
void refreshCookieBotState();
}}
/>
<DeleteConfirmationDialog
isOpen={botUnenrolProfile !== null}
onClose={() => {
setBotUnenrolProfile(null);
}}
onConfirm={handleBotUnenrol}
title={t("cookieBot.schedule.unenrolTitle", {
name: botUnenrolProfile?.name ?? "",
})}
description={t("cookieBot.schedule.unenrolDescription")}
confirmButtonText={t("cookieBot.schedule.unenrol")}
isLoading={isUnenrolling}
/>
</>
);
}
+1 -2
View File
@@ -2034,8 +2034,7 @@ function SecuritySectionInline({
}
if (mode === "set" || mode === "change") {
if (password.length < 8) return t("profilePassword.errors.tooShort");
if (password !== confirm)
return t("profilePassword.errors.passwordMismatch");
if (password !== confirm) return t("profilePassword.errors.mismatch");
}
return null;
};
+8 -1
View File
@@ -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);
+40 -4
View File
@@ -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>
+21 -1
View File
@@ -8,6 +8,7 @@ import { FiWifi } from "react-icons/fi";
import { GoGear, GoKebabHorizontal } from "react-icons/go";
import {
LuCloud,
LuCookie,
LuInfo,
LuKeyboard,
LuPlug,
@@ -26,6 +27,7 @@ export type AppPage =
| "proxies"
| "extensions"
| "groups"
| "cookieBot"
| "vpns"
| "settings"
| "integrations"
@@ -174,6 +176,12 @@ interface RailNavProps {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
onOpenAbout: () => void;
/**
* A remote session is running right now. The Cookie Bot item carries a dot so
* the state is legible from every other page an overnight job you cannot
* see from where you are standing may as well not be observable at all.
*/
cookieBotRunning?: boolean;
}
/** Shared-element indicator that slides between the active rail items. */
@@ -199,6 +207,7 @@ const TOP_ITEMS: RailItem[] = [
{ page: "proxies", Icon: FiWifi, labelKey: "rail.network" },
{ page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
{ page: "cookieBot", Icon: LuCookie, labelKey: "rail.cookieBot" },
{ page: "integrations", Icon: LuPlug, labelKey: "rail.integrations" },
{ page: "account", Icon: LuCloud, labelKey: "rail.account" },
];
@@ -229,6 +238,7 @@ export function RailNav({
currentPage,
onNavigate,
onOpenAbout,
cookieBotRunning = false,
}: RailNavProps) {
const { t } = useTranslation();
const [moreOpen, setMoreOpen] = useState(false);
@@ -325,9 +335,19 @@ export function RailNav({
>
{active && <ActiveIndicator />}
<Icon className="size-3.5" />
{page === "cookieBot" && cookieBotRunning && (
<span
aria-hidden="true"
className="absolute top-1 right-1 size-1.5 rounded-full bg-success"
/>
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">{t(labelKey)}</TooltipContent>
<TooltipContent side="right">
{page === "cookieBot" && cookieBotRunning
? t("rail.cookieBotRunning")
: t(labelKey)}
</TooltipContent>
</Tooltip>
);
})}
+114 -43
View File
@@ -73,6 +73,8 @@ interface AppSettings {
api_token?: string;
disable_auto_updates?: boolean;
keep_decrypted_profiles_in_ram?: boolean;
fingerprint_gate_disabled?: boolean;
vpn_extension_warning_disabled?: boolean;
}
interface CustomThemeState {
@@ -127,15 +129,6 @@ export function SettingsDialog({
const [isSettingDefault, setIsSettingDefault] = useState(false);
const [isClearingCache, setIsClearingCache] = useState(false);
const [isClearingTraffic, setIsClearingTraffic] = useState(false);
const [consistencyWarningEnabled, setConsistencyWarningEnabled] = useState(
() => {
try {
return localStorage.getItem("consistency-warn-disabled") !== "1";
} catch {
return true;
}
},
);
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
const [requestingPermission, setRequestingPermission] =
@@ -239,16 +232,30 @@ export function SettingsDialog({
[t],
);
const applyCustomTheme = useCallback((vars: Record<string, string>) => {
withThemeTransition(() => {
applyThemeColors(vars);
});
}, []);
// `animate: false` on the restore paths. Opening Settings re-applies the
// theme already on screen, so a whole-document cross-fade to an identical
// palette animates nothing. Worse, the mount effect below did it twice in a
// row, and the second transition aborts the first mid-snapshot.
const applyCustomTheme = useCallback(
(vars: Record<string, string>, options?: { animate?: boolean }) => {
const apply = () => {
applyThemeColors(vars);
};
if (options?.animate === false) {
apply();
return;
}
withThemeTransition(apply);
},
[],
);
const clearCustomTheme = useCallback(() => {
withThemeTransition(() => {
const clearCustomTheme = useCallback((options?: { animate?: boolean }) => {
if (options?.animate === false) {
clearThemeColors();
});
return;
}
withThemeTransition(clearThemeColors);
}, []);
const loadSettings = useCallback(async () => {
@@ -267,9 +274,28 @@ export function SettingsDialog({
? normalizeThemeColors(appSettings.custom_theme)
: tokyoNightTheme.colors,
};
setSettings(merged);
setOriginalSettings(merged);
originalSettingsRef.current = merged;
// One-shot migration off the old localStorage flag. Without it, a user
// who explicitly turned the warning off would start getting hard blocks
// after updating — the single most likely support complaint here.
let migrated = merged;
try {
if (
localStorage.getItem("consistency-warn-disabled") === "1" &&
!merged.fingerprint_gate_disabled
) {
migrated = { ...merged, fingerprint_gate_disabled: true };
await invoke<AppSettings>("save_app_settings", {
settings: migrated,
});
}
localStorage.removeItem("consistency-warn-disabled");
} catch (err) {
console.warn("Failed to migrate consistency warning preference:", err);
}
setSettings(migrated);
setOriginalSettings(migrated);
originalSettingsRef.current = migrated;
hasLoadedSettingsRef.current = true;
setHasLoadedSettings(true);
@@ -351,12 +377,24 @@ export function SettingsDialog({
isMicrophoneAccessGranted,
]);
// The Linux implementation shells out to `which` plus two `xdg-mime query`
// calls, and `xdg-mime` is a shell script that forks further. Without this
// guard a slow desktop lets the poll below stack one unfinished call on top
// of another every few seconds, and each one occupies a worker of the same
// runtime every other Tauri command shares.
const defaultBrowserCheckInFlight = useRef(false);
const checkDefaultBrowserStatus = useCallback(async () => {
if (defaultBrowserCheckInFlight.current) {
return;
}
defaultBrowserCheckInFlight.current = true;
try {
const isDefault = await invoke<boolean>("is_default_browser");
setIsDefaultBrowser(isDefault);
} catch (error) {
console.error("Failed to check default browser status:", error);
} finally {
defaultBrowserCheckInFlight.current = false;
}
}, []);
@@ -553,11 +591,13 @@ export function SettingsDialog({
const handleClose = useCallback(() => {
// Restore original theme when closing without saving
// Only a revert the user can see is worth animating.
const changed = originalSettings.theme !== settings.theme;
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
applyCustomTheme(originalSettings.custom_theme);
applyCustomTheme(originalSettings.custom_theme, { animate: changed });
} else {
clearCustomTheme();
setTheme(originalSettings.theme);
clearCustomTheme({ animate: false });
setTheme(originalSettings.theme, { animate: changed });
}
// Reset custom theme state to original
@@ -577,16 +617,29 @@ export function SettingsDialog({
clearCustomTheme,
onClose,
setTheme,
settings.theme,
]);
// Only clear custom theme when switching away from custom, don't apply live
// changes. Gated on the async settings load: before it resolves the state
// still holds the "system" default, and clearing then wipes the user's
// custom theme vars on every Settings visit (the theme-reverts-to-dark bug).
//
// This effect is both the restore-on-open and the live switch when the user
// picks a theme, so it animates only a real change: the first run after the
// settings load is re-applying the palette already on screen. Clearing the
// inline custom vars is never the animated half — switching to a stylesheet
// palette makes them invisible either way, and running two transitions
// back to back just aborts the first one mid-snapshot.
const appliedThemeRef = useRef<string | null>(null);
useEffect(() => {
if (hasLoadedSettings && settings.theme !== "custom") {
clearCustomTheme();
setTheme(settings.theme);
const previous = appliedThemeRef.current;
appliedThemeRef.current = settings.theme;
clearCustomTheme({ animate: false });
setTheme(settings.theme, {
animate: previous !== null && previous !== settings.theme,
});
}
}, [hasLoadedSettings, settings.theme, clearCustomTheme, setTheme]);
@@ -604,7 +657,7 @@ export function SettingsDialog({
// stylesheet palette — strip any leftover inline custom vars so a
// just-saved switch away from custom isn't reverted on unmount.
clearThemeColors();
setTheme(s.theme);
setTheme(s.theme, { animate: false });
}
};
}, [setTheme]);
@@ -622,12 +675,15 @@ export function SettingsDialog({
loadPermissions();
}
// Set up interval to check default browser status
// Re-check periodically so the badge follows a change the user made in
// their desktop settings. Ten seconds rather than two: on Linux each
// check is three subprocesses, and nobody flips their default browser
// often enough to notice the difference.
const intervalId = setInterval(() => {
checkDefaultBrowserStatus().catch((err: unknown) => {
console.error(err);
});
}, 2000);
}, 10000);
// Cleanup interval on component unmount or dialog close
return () => {
@@ -687,7 +743,11 @@ export function SettingsDialog({
(settings.theme !== "custom" &&
JSON.stringify(settings.custom_theme ?? {}) !==
JSON.stringify(originalSettings.custom_theme ?? {})) ||
settings.disable_auto_updates !== originalSettings.disable_auto_updates;
settings.disable_auto_updates !== originalSettings.disable_auto_updates ||
settings.fingerprint_gate_disabled !==
originalSettings.fingerprint_gate_disabled ||
settings.vpn_extension_warning_disabled !==
originalSettings.vpn_extension_warning_disabled;
return (
<>
@@ -1392,21 +1452,32 @@ export function SettingsDialog({
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.consistencyWarning")}
checked={consistencyWarningEnabled}
checked={!(settings.fingerprint_gate_disabled ?? false)}
onCheckedChange={(v) => {
setConsistencyWarningEnabled(v === true);
try {
if (v === true) {
localStorage.removeItem("consistency-warn-disabled");
} else {
localStorage.setItem(
"consistency-warn-disabled",
"1",
);
}
} catch {
// localStorage unavailable
}
updateSetting("fingerprint_gate_disabled", v !== true);
}}
/>
</div>
<div className="flex items-start justify-between gap-x-3 rounded-lg border p-3">
<div className="min-w-0 flex-1">
<span className="text-sm font-medium">
{t("settings.privacy.vpnExtensionWarning")}
</span>
<span className="block text-xs text-muted-foreground">
{t("settings.privacy.vpnExtensionWarningDescription")}
</span>
</div>
<AnimatedSwitch
aria-label={t("settings.privacy.vpnExtensionWarning")}
checked={
!(settings.vpn_extension_warning_disabled ?? false)
}
onCheckedChange={(v) => {
updateSetting(
"vpn_extension_warning_disabled",
v !== true,
);
}}
/>
</div>
+470
View File
@@ -0,0 +1,470 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import * as React from "react";
import { useTranslation } from "react-i18next";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type {
NameType,
ValueType,
} from "recharts/types/component/DefaultTooltipContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { formatHours, RemoteHoursMeter } from "@/components/cookie-bot-shared";
import { AnimatedDisclosureItem } from "@/components/ui/animated-disclosure";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Skeleton } from "@/components/ui/skeleton";
import { translateBackendError } from "@/lib/backend-errors";
import {
type CookieBotUsage,
type CookieBotUsageMember,
getCookieBotUsage,
type RemoteHoursQuota,
} from "@/lib/cookie-bot";
import { MOTION_EASE_OUT } from "@/lib/motion";
import { cn } from "@/lib/utils";
/** How many past billing periods the selector offers. */
const PERIOD_COUNT = 6;
function periodKey(date: Date): string {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`;
}
function recentPeriods(): { value: string; label: string }[] {
const now = new Date();
return Array.from({ length: PERIOD_COUNT }, (_, index) => {
const date = new Date(now.getFullYear(), now.getMonth() - index, 1);
return {
value: periodKey(date),
label: date.toLocaleDateString(undefined, {
month: "long",
year: "numeric",
}),
};
});
}
/** The part of an address that identifies the person, for a dense axis. */
function shortName(email: string): string {
const local = email.split("@")[0] ?? email;
return local.length > 14 ? `${local.slice(0, 13)}` : local;
}
interface MemberDatum {
name: string;
email: string;
bot: number;
interactive: number;
total: number;
runs: number;
/** How many of those runs did not do what they were asked. */
runsFailed: number;
sessions: number;
}
interface TeamUsagePanelProps {
/**
* The live pooled budget. Only used before the selected period's own figures
* arrive, so the block is never empty on first paint; once `usage` lands the
* period's numbers win, because looking at June must show what June allowed
* rather than what is left today.
*/
quota?: RemoteHoursQuota | null;
className?: string;
}
/**
* Who spent the pooled remote hours this period.
*
* The account page owns plan truth what was bought and this is the other
* half of that: what it was spent on and by whom. Every figure is served; the
* desktop computes no allowance and no share of one.
*/
export function TeamUsagePanel({ quota, className }: TeamUsagePanelProps) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const periods = React.useMemo(() => recentPeriods(), []);
const [period, setPeriod] = React.useState(periods[0].value);
const [usage, setUsage] = React.useState<CookieBotUsage | null>(null);
const [isLoading, setIsLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
let active = true;
setIsLoading(true);
setError(null);
void getCookieBotUsage(period)
.then((result) => {
if (active) setUsage(result);
})
.catch((err: unknown) => {
if (active) {
setUsage(null);
setError(translateBackendError(t as never, err));
}
})
.finally(() => {
if (active) setIsLoading(false);
});
return () => {
active = false;
};
}, [period, t]);
// Heaviest first: the whole point of the view is to make the biggest
// consumer the first thing read, both in the chart and in the table.
const members: MemberDatum[] = React.useMemo(() => {
if (!usage) return [];
return [...usage.members]
.sort(
(a: CookieBotUsageMember, b: CookieBotUsageMember) =>
b.used_hours - a.used_hours,
)
.map((member) => ({
name: shortName(member.email),
email: member.email,
bot: member.bot_hours,
interactive: member.interactive_hours,
total: member.used_hours,
runs: member.bot_runs,
runsFailed: member.bot_runs_failed,
sessions: member.sessions,
}));
}, [usage]);
const heaviest = members[0]?.total ?? 0;
const isSolo = members.length <= 1;
// The meter takes a quota shape; the usage response carries the same numbers
// for the period being looked at, so it is adapted rather than
// re-implemented. The live quota is only the stand-in until it arrives.
const pooled: RemoteHoursQuota | null = React.useMemo(
() =>
usage
? {
granted_hours: usage.granted_hours,
used_hours: usage.used_hours,
remaining_hours: usage.remaining_hours,
period_start: usage.period_start,
period_end: usage.period_end,
team_id: usage.team_id,
seats: usage.seats,
per_seat_hours: 0,
members: [],
}
: (quota ?? null),
[usage, quota],
);
const renderTooltip = React.useCallback(
({ active, payload }: TooltipContentProps<ValueType, NameType>) => {
if (!active || !payload || payload.length === 0) return null;
const datum = payload[0].payload as MemberDatum;
return (
<div className="rounded-md border border-border bg-popover px-2.5 py-2 text-xs text-popover-foreground shadow-sm">
<p className="font-medium">{datum.email}</p>
<p className="mt-1 flex items-center justify-between gap-4 tabular-nums">
<span className="text-chart-1">
{t("cookieBot.team.legendBot")}
</span>
<span>
{t("cookieBot.team.hours", { hours: formatHours(datum.bot) })}
</span>
</p>
<p className="flex items-center justify-between gap-4 tabular-nums">
<span className="text-chart-2">
{t("cookieBot.team.legendInteractive")}
</span>
<span>
{t("cookieBot.team.hours", {
hours: formatHours(datum.interactive),
})}
</span>
</p>
</div>
);
},
[t],
);
return (
<div className={cn("flex flex-col gap-3", className)}>
<div className="flex items-center justify-between gap-3">
<h3 className="text-sm font-medium">{t("cookieBot.team.title")}</h3>
<Select
value={period}
onValueChange={(value) => {
setPeriod(value);
}}
>
<SelectTrigger className="h-8 w-[150px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{periods.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Driven by the selected period's own figures, not by the live quota:
choosing June must show what June was allowed, not what is left
today. */}
<div className="flex flex-col gap-1.5">
{pooled ? (
<p className="text-xs tabular-nums text-muted-foreground">
{t("cookieBot.team.pooled", {
used: formatHours(pooled.used_hours),
total: formatHours(pooled.granted_hours),
// `count` (not `seats`) so i18next can pluralise: "1 seat" and
// "across 4 seats" are different sentences in most locales.
count: pooled.seats,
})}
</p>
) : (
<Skeleton className="h-3 w-56" />
)}
<RemoteHoursMeter
quota={pooled}
isLoading={isLoading && usage === null}
variant="inline"
/>
</div>
{error ? (
<p className="py-6 text-center text-xs text-destructive-text">
{error}
</p>
) : isLoading && !usage ? (
<div className="space-y-2">
<Skeleton className="h-[180px] w-full" />
<Skeleton className="h-6 w-full" />
<Skeleton className="h-6 w-full" />
</div>
) : members.length === 0 ? (
<p className="py-6 text-center text-xs text-muted-foreground">
{t("cookieBot.team.noActivity")}
</p>
) : (
<>
{!isSolo && (
<>
<div className="h-[clamp(160px,22vh,240px)] w-full">
<ResponsiveContainer
width="100%"
height="100%"
minWidth={1}
minHeight={1}
>
<AreaChart
data={members}
margin={{ top: 8, right: 8, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="cookieBotHoursGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-1)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient
id="interactiveHoursGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-2)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-2)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="name"
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
interval={0}
/>
<YAxis
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
width={40}
/>
<Tooltip content={renderTooltip} />
{/* `linear` because the axis is a ranking, not time: a
monotone curve would invent values between people. */}
<Area
type="linear"
dataKey="bot"
stackId="1"
stroke="var(--chart-1)"
fill="url(#cookieBotHoursGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
<Area
type="linear"
dataKey="interactive"
stackId="1"
stroke="var(--chart-2)"
fill="url(#interactiveHoursGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-6">
<div className="flex items-center gap-2">
<div
className="size-2.5 rounded"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">
{t("cookieBot.team.legendBot")}
</span>
</div>
<div className="flex items-center gap-2">
<div
className="size-2.5 rounded"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">
{t("cookieBot.team.legendInteractive")}
</span>
</div>
</div>
</>
)}
{isSolo && (
<p className="text-xs text-muted-foreground">
{t("cookieBot.team.soloNote")}
</p>
)}
<div className="overflow-hidden rounded-md border border-border">
<div className="grid grid-cols-[1fr_auto_auto_5rem] items-center gap-3 border-b border-border bg-muted/40 px-3 py-1.5 text-[10px] tracking-wide text-muted-foreground uppercase">
<span>{t("cookieBot.team.columnMember")}</span>
<span className="text-right">
{t("cookieBot.team.columnRuns")}
</span>
<span className="text-right">
{t("cookieBot.team.columnHours")}
</span>
<span className="text-right">
{t("cookieBot.team.columnShare")}
</span>
</div>
{members.map((member, index) => (
// The ranking genuinely re-orders when the period changes, so the
// rows travel to their new places instead of teleporting. Layout
// only — the row is fully rendered and readable on first paint.
<AnimatedDisclosureItem
key={member.email}
className="grid grid-cols-[1fr_auto_auto_5rem] items-center gap-3 px-3 py-1.5 text-xs"
>
<span
className={cn(
"min-w-0 truncate",
index === 0 ? "font-medium text-foreground" : "",
)}
title={member.email}
>
{member.email}
</span>
{/* The failure count is already on the wire and answers the
question the run count cannot: whether the hours bought
anything. */}
<span className="text-right tabular-nums text-muted-foreground">
{member.runs}
{member.runsFailed > 0 && (
<span className="ml-1 text-warning-text">
{t("cookieBot.team.runsFailed", {
n: member.runsFailed,
})}
</span>
)}
</span>
<span
className={cn(
"text-right tabular-nums",
index === 0
? "font-semibold text-foreground"
: "text-muted-foreground",
)}
>
{t("cookieBot.team.hours", {
hours: formatHours(member.total),
})}
</span>
<span className="h-1 overflow-hidden rounded-full bg-muted">
{/* Radius on the track, scale on the fill: a rounded cap that
is being scaled flips shape mid-transition. `initial=
{false}` keeps the first paint at the true share. */}
<motion.span
initial={false}
animate={{
scaleX: heaviest > 0 ? member.total / heaviest : 0,
}}
transition={
reduceMotion
? { duration: 0 }
: { duration: 0.22, ease: MOTION_EASE_OUT }
}
style={{ transformOrigin: "left", willChange: "transform" }}
className={cn(
"block h-full w-full rounded-full",
index === 0 ? "bg-foreground" : "bg-muted-foreground",
)}
/>
</span>
</AnimatedDisclosureItem>
))}
</div>
</>
)}
</div>
);
}
+22 -8
View File
@@ -22,7 +22,9 @@ interface AppSettings {
interface ThemeContextValue {
theme: string;
setTheme: (theme: string) => void;
/// `animate: false` applies the theme without a view transition, for the
/// restore paths where nothing visually changes.
setTheme: (theme: string, options?: { animate?: boolean }) => void;
}
const ThemeContext = createContext<ThemeContextValue>({
@@ -56,14 +58,26 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
const [isLoading, setIsLoading] = useState(true);
const [theme, setThemeState] = useState("system");
const setTheme = useCallback((newTheme: string) => {
setThemeState(newTheme);
withThemeTransition(() => {
if (newTheme !== "custom") {
applyClassToHtml(newTheme);
// `animate: false` is for restoring the theme the app is already showing —
// opening or leaving Settings re-applies the current theme, and cross-fading
// the whole document to the palette already on screen animates nothing while
// still paying for a full-document snapshot.
const setTheme = useCallback(
(newTheme: string, options?: { animate?: boolean }) => {
setThemeState(newTheme);
const apply = () => {
if (newTheme !== "custom") {
applyClassToHtml(newTheme);
}
};
if (options?.animate === false) {
apply();
return;
}
});
}, []);
withThemeTransition(apply);
},
[],
);
// Load initial theme from Tauri settings
useEffect(() => {
+165 -118
View File
@@ -3,19 +3,23 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useWindowDecorations } from "@/hooks/use-window-decorations";
import { getCurrentOS, type OperatingSystem } from "@/lib/platform";
import type { WindowControl } from "@/lib/window-decorations";
import { WindowResizeHandles } from "./window-resize-handles";
export function WindowDragArea() {
const { t } = useTranslation();
const [platform, setPlatform] = useState<OperatingSystem | null>(null);
const [isMaximized, setIsMaximized] = useState(false);
const decorations = useWindowDecorations();
useEffect(() => {
setPlatform(getCurrentOS());
}, []);
useEffect(() => {
if (platform !== "windows") return;
if (platform !== "windows" && platform !== "linux") return;
const win = getCurrentWindow();
let cancelled = false;
const sync = async () => {
@@ -38,42 +42,6 @@ export function WindowDragArea() {
};
}, [platform]);
const handlePointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startDrag = async () => {
try {
const window = getCurrentWindow();
await window.startDragging();
} catch (error) {
console.error("Failed to start window dragging:", error);
}
};
void startDrag();
};
// Linux: system decorations handle everything
if (!platform || platform === "linux" || platform === "unknown") {
return null;
}
// macOS: nothing to render here. The transparent native titlebar (set via
// `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
// handle dragging directly, and the sys-bar inside `home-header.tsx`
// declares its own `data-tauri-drag-region` overlay for the WebView area.
// The previous full-width fixed z-[999999] button was stealing every
// click in the top 40px of the window.
if (platform === "macos") {
return null;
}
// Windows: minimize/maximize/close controls anchored at the top-right
// corner of the sys-bar. The HomeHeader's own drag-region overlay handles window
// dragging via Tauri 2, so we don't need a separate draggable spacer
// covering the whole width.
const handleMinimize = async () => {
try {
await getCurrentWindow().minimize();
@@ -97,93 +65,172 @@ export function WindowDragArea() {
console.error("Failed to close window:", error);
}
};
void handlePointerDown; // kept for backwards-compat; not used on Windows now
const renderControl = (control: WindowControl) => {
switch (control) {
case "minimize":
return (
<button
key="minimize"
type="button"
onClick={() => {
void handleMinimize();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
aria-label={t("common.window.minimize")}
>
<svg
width="10"
height="1"
viewBox="0 0 10 1"
fill="currentColor"
role="img"
aria-label={t("common.window.minimize")}
>
<rect width="10" height="1" />
</svg>
</button>
);
case "maximize":
return (
<button
key="maximize"
type="button"
onClick={() => {
void handleToggleMaximize();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
aria-label={
isMaximized
? t("common.window.restore")
: t("common.window.maximize")
}
>
{isMaximized ? (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.window.restore")}
>
<rect x="1" y="3" width="6" height="6" />
<path d="M3 3 V1 H9 V7 H7" />
</svg>
) : (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.window.maximize")}
>
<rect x="1" y="1" width="8" height="8" />
</svg>
)}
</button>
);
case "close":
return (
<button
key="close"
type="button"
onClick={() => {
void handleClose();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t("common.window.close")}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.window.close")}
>
<line x1="1" y1="1" x2="9" y2="9" />
<line x1="9" y1="1" x2="1" y2="9" />
</svg>
</button>
);
}
};
if (!platform || platform === "unknown") {
return null;
}
// macOS: nothing to render here. The transparent native titlebar (set via
// `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
// handle dragging directly, and the sys-bar inside `home-header.tsx`
// declares its own `data-tauri-drag-region` overlay for the WebView area.
// The previous full-width fixed z-[999999] button was stealing every
// click in the top 40px of the window.
if (platform === "macos") {
return null;
}
// Linux: the window has no server-side decorations, so the app owns both the
// controls and the resize edges. Which buttons appear and on which side is a
// desktop-wide preference (GNOME's `button-layout`, KWin's decoration
// settings), read through GTK so both environments are honored.
if (platform === "linux") {
// Not resolved yet, or the session keeps server-side decorations (KDE on
// Wayland — see `use_client_side_decorations` in the backend). Either way
// there is a real titlebar and nothing for the app to draw.
if (!decorations.resolved || !decorations.clientSide) {
return null;
}
const { layout } = decorations;
return (
<>
{/* Dropping decorations also drops the compositor's drop shadow, and
neither Tauri nor tao exposes a Linux shadow API. Without some edge
the window is invisible against a similarly coloured desktop, so
draw a hairline. Not rounded: that needs a transparent window, which
would conflict with the WebView and the resize strips. */}
{!isMaximized && (
<div
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-[99999] border border-border"
/>
)}
<WindowResizeHandles isMaximized={isMaximized} />
{layout.left.length > 0 && (
<div className="fixed top-0 left-0 z-[100000] flex h-11 items-center select-none pointer-events-auto">
{layout.left.map(renderControl)}
</div>
)}
{layout.right.length > 0 && (
<div className="fixed top-0 right-0 z-[100000] flex h-11 items-center select-none pointer-events-auto">
{layout.right.map(renderControl)}
</div>
)}
</>
);
}
// Windows: minimize/maximize/close controls anchored at the top-right
// corner of the sys-bar. The HomeHeader's own drag-region overlay handles
// window dragging via Tauri 2, so we don't need a separate draggable spacer
// covering the whole width.
return (
<div
className="fixed top-0 right-0 z-50 flex h-11 items-center select-none"
aria-hidden="false"
>
<button
type="button"
onClick={() => {
void handleMinimize();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
aria-label={t("common.window.minimize")}
>
<svg
width="10"
height="1"
viewBox="0 0 10 1"
fill="currentColor"
role="img"
aria-label={t("common.window.minimize")}
>
<rect width="10" height="1" />
</svg>
</button>
<button
type="button"
onClick={() => {
void handleToggleMaximize();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
aria-label={
isMaximized ? t("common.window.restore") : t("common.window.maximize")
}
>
{isMaximized ? (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.window.restore")}
>
<rect x="1" y="3" width="6" height="6" />
<path d="M3 3 V1 H9 V7 H7" />
</svg>
) : (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.window.maximize")}
>
<rect x="1" y="1" width="8" height="8" />
</svg>
)}
</button>
<button
type="button"
onClick={() => {
void handleClose();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t("common.buttons.close")}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.buttons.close")}
>
<line x1="1" y1="1" x2="9" y2="9" />
<line x1="9" y1="1" x2="1" y2="9" />
</svg>
</button>
{(["minimize", "maximize", "close"] as WindowControl[]).map(
renderControl,
)}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { getCurrentWindow } from "@tauri-apps/api/window";
/**
* Mirrors the API's own `ResizeDirection`, which it declares but does not
* export. Structurally identical, so a drift would fail the call below.
*/
type ResizeDirection =
| "East"
| "North"
| "NorthEast"
| "NorthWest"
| "South"
| "SouthEast"
| "SouthWest"
| "West";
/**
* Mouse resize areas for a window with no server-side decorations.
*
* `gtk_window_set_decorated(false)` removes GTK's own invisible resize border
* along with the frame, so without these the window can only be resized through
* window-manager shortcuts (Super+right-drag and friends). Each handle hands the
* pointer to the compositor via `begin_resize_drag`, which is the same call
* GTK's client-side decorations make, so edge snapping and the resize cursor
* come from the WM exactly as they do for a native window.
*
* Rendered only where the app owns the frame; on macOS the native titlebar is
* still in place and the system draws its own resize edges.
*/
/**
* GTK's own grab area is far wider, but all of it sits *outside* the window in
* the shadow margin. Ours is inside, so every pixel is taken from real content.
*/
/** Top edge only — it overlaps the 44px-tall window controls. */
const TOP_EDGE = "6px";
/** Sides and bottom overlap nothing, so they can be comfortably grabbable. */
const EDGE = "8px";
/** Corners need to win over the edges that overlap them. */
const CORNER = "16px";
interface Handle {
direction: ResizeDirection;
style: React.CSSProperties;
cursor: string;
}
const HANDLES: Handle[] = [
// Edges.
{
direction: "North",
cursor: "ns-resize",
style: { top: 0, left: CORNER, right: CORNER, height: TOP_EDGE },
},
{
direction: "South",
cursor: "ns-resize",
style: { bottom: 0, left: CORNER, right: CORNER, height: EDGE },
},
{
direction: "West",
cursor: "ew-resize",
style: { left: 0, top: CORNER, bottom: CORNER, width: EDGE },
},
{
direction: "East",
cursor: "ew-resize",
style: { right: 0, top: CORNER, bottom: CORNER, width: EDGE },
},
// Corners, drawn after the edges so they sit on top of the overlap.
{
direction: "NorthWest",
cursor: "nwse-resize",
style: { top: 0, left: 0, width: CORNER, height: TOP_EDGE },
},
{
direction: "NorthEast",
cursor: "nesw-resize",
style: { top: 0, right: 0, width: CORNER, height: TOP_EDGE },
},
{
direction: "SouthWest",
cursor: "nesw-resize",
style: { bottom: 0, left: 0, width: CORNER, height: CORNER },
},
{
direction: "SouthEast",
cursor: "nwse-resize",
style: { bottom: 0, right: 0, width: CORNER, height: CORNER },
},
];
export function WindowResizeHandles({ isMaximized }: { isMaximized: boolean }) {
// A maximized window has no resizable edge, and leaving the strips live would
// put invisible hit areas over real content. Tauri's own built-in undecorated
// resizing disables itself while maximized for the same reason.
if (isMaximized) {
return null;
}
const startResize =
(direction: ResizeDirection) => (e: React.PointerEvent) => {
// Left button only: right-click belongs to the WM/window menu, and a
// middle-click drag should not resize.
if (e.button !== 0) {
return;
}
e.preventDefault();
e.stopPropagation();
void getCurrentWindow()
.startResizeDragging(direction)
.catch((error: unknown) => {
console.error("Failed to start window resize:", error);
});
};
return (
<>
{HANDLES.map((handle) => (
<div
key={handle.direction}
// Deliberately BELOW the window controls (z-100000) rather than
// above: a real CSD window keeps its resize border outside the
// buttons, but ours is inside the window, so layering it on top
// would steal the corner of whichever control sits in that corner.
// Edges still win over ordinary content, which is all they need.
className="fixed z-[99998] pointer-events-auto"
style={{ ...handle.style, cursor: handle.cursor }}
onPointerDown={startResize(handle.direction)}
aria-hidden="true"
/>
))}
</>
);
}
+306
View File
@@ -0,0 +1,306 @@
"use client";
import { useCallback, useEffect, useId, useSyncExternalStore } from "react";
import {
type CookieBotSchedule,
type CookieBotScope,
getCookieBotSchedules,
getRemoteHoursQuota,
type RemoteHoursQuota,
} from "@/lib/cookie-bot";
import {
isSessionOver,
onRemoteSessionSnapshot,
onRemoteSessionState,
onRemoteSessionStream,
type RemoteSessionState,
startRemoteSessionEvents,
stopRemoteSessionEvents,
} from "@/lib/remote-sessions";
/**
* One shared read of the cookie-bot plane.
*
* Several surfaces need the same three facts at once the profile table, the
* account page, the enrolment dialog and each is mounted independently. A
* per-component fetch would mean three requests on open and three different
* answers after an edit, so the state lives in one module store and every
* consumer subscribes to it.
*
* `POST /api/remote-sessions` answers `provisioning` and nothing more, so a
* live run is only observable through the event stream. This store is what
* starts that stream on the desktop: without it a signed-in user is blind
* between launch and stop.
*/
export interface CookieBotSnapshot {
/** Enrolments by profile id. */
schedules: Record<string, CookieBotSchedule>;
/** Remote sessions that have not closed yet, by profile id. */
liveSessions: Record<string, RemoteSessionState>;
/** The pooled remote-hour budget, or null before the first answer. */
quota: RemoteHoursQuota | null;
/** True only while the first load of a newly enabled session is in flight. */
isLoading: boolean;
/** The last load failure, as a backend error code envelope or message. */
error: string | null;
/** Whether transitions are currently arriving. */
streamConnected: boolean;
}
const EMPTY: CookieBotSnapshot = {
schedules: {},
liveSessions: {},
quota: null,
isLoading: false,
error: null,
streamConnected: false,
};
type Listener = () => void;
const listeners = new Set<Listener>();
let snapshot: CookieBotSnapshot = EMPTY;
let subscriberCount = 0;
let unlisteners: (() => void)[] = [];
let attachGeneration = 0;
let enabled = false;
let scope: CookieBotScope = "mine";
let loadToken = 0;
function emit(next: Partial<CookieBotSnapshot>) {
snapshot = { ...snapshot, ...next };
for (const listener of listeners) listener();
}
function getSnapshot(): CookieBotSnapshot {
return snapshot;
}
/** Sessions still in flight, keyed by the profile they are warming. */
function indexOpenSessions(
sessions: RemoteSessionState[],
): Record<string, RemoteSessionState> {
const next: Record<string, RemoteSessionState> = {};
for (const session of sessions) {
if (!session.profile_id || isSessionOver(session)) continue;
next[session.profile_id] = session;
}
return next;
}
async function attachStream() {
// Idempotent on the Rust side; calling it here is what covers the user who
// signs in without restarting the app.
try {
await startRemoteSessionEvents();
} catch (error) {
// A signed-out or unentitled desktop refuses the subscription. That is not
// a UI failure — the rest of the state still renders — so it is logged and
// the stream simply stays disconnected.
console.error("Failed to subscribe to remote session events:", error);
}
}
async function attachListeners() {
// `listen()` resolves a tick later, and subscribe/unsubscribe can both happen
// before it does (React runs mount effects twice in development). Without
// this generation check the first attach would install its handlers after the
// detach had already run, leaking a listener that no unsubscribe can reach
// and double-emitting every session transition for the rest of the session.
const generation = ++attachGeneration;
const offs = await Promise.all([
onRemoteSessionState((session) => {
if (!session.profile_id) return;
const over = isSessionOver(session);
const next = { ...snapshot.liveSessions };
if (over) {
delete next[session.profile_id];
} else {
next[session.profile_id] = session;
}
emit({ liveSessions: next });
// A run that just ended has spent hours and may have moved the
// schedule's next slot, so both are re-read rather than guessed at.
if (over) void refreshCookieBot();
}),
onRemoteSessionSnapshot((payload) => {
emit({ liveSessions: indexOpenSessions(payload.sessions) });
}),
onRemoteSessionStream((status) => {
emit({ streamConnected: status.connected });
}),
]);
if (generation !== attachGeneration) {
for (const off of offs) off();
return;
}
unlisteners = offs;
}
function detachListeners() {
attachGeneration += 1;
for (const off of unlisteners) off();
unlisteners = [];
}
async function load() {
const token = ++loadToken;
emit({ isLoading: snapshot.quota === null, error: null });
const [schedules, quota] = await Promise.allSettled([
getCookieBotSchedules(scope),
getRemoteHoursQuota(),
]);
// A later load (or a sign-out) has already superseded this one.
if (token !== loadToken || !enabled) return;
const next: Partial<CookieBotSnapshot> = { isLoading: false };
if (schedules.status === "fulfilled") {
const byProfile: Record<string, CookieBotSchedule> = {};
for (const schedule of schedules.value.schedules) {
byProfile[schedule.profile_id] = schedule;
}
next.schedules = byProfile;
}
if (quota.status === "fulfilled") next.quota = quota.value;
// Both failing is a real outage worth surfacing; one failing leaves the
// other half of the screen correct, which beats blanking everything.
if (schedules.status === "rejected" && quota.status === "rejected") {
next.error = String(schedules.reason);
} else {
next.error = null;
}
emit(next);
}
/** Re-read schedules and the quota. Call after any write. */
export async function refreshCookieBot(): Promise<void> {
if (!enabled) return;
await load();
}
function reconcile(nextEnabled: boolean, nextScope: CookieBotScope) {
const scopeChanged = nextScope !== scope;
scope = nextScope;
if (nextEnabled === enabled) {
if (nextEnabled && scopeChanged) void load();
return;
}
enabled = nextEnabled;
if (enabled) {
void attachStream();
void load();
} else {
loadToken++;
void stopRemoteSessionEvents().catch((error: unknown) => {
console.error("Failed to unsubscribe from remote session events:", error);
});
snapshot = EMPTY;
for (const listener of listeners) listener();
}
}
/**
* What each mounted consumer wants. The store is a singleton with several
* consumers whose lifetimes differ the shell holds it open for the whole
* session, a dialog only while it is on screen so the wish is a union, never
* last-writer-wins. Without this, closing the enrol dialog (which passes
* `isOpen && entitled`) would tear down the event stream the shell and the
* profile table are still reading from.
*/
const wishes = new Map<string, { enabled: boolean; scope: CookieBotScope }>();
let applyScheduled = false;
function applyWishes() {
let nextEnabled = false;
let nextScope: CookieBotScope = "mine";
for (const wish of wishes.values()) {
if (wish.enabled) nextEnabled = true;
if (wish.scope === "team") nextScope = "team";
}
reconcile(nextEnabled, nextScope);
}
/**
* React runs an effect's cleanup and its next body back to back, so a consumer
* re-registering would otherwise be seen as a momentary "nobody wants this" and
* flush the whole snapshot. Coalescing into a microtask means only the settled
* state is ever acted on.
*/
function scheduleApplyWishes() {
if (applyScheduled) return;
applyScheduled = true;
queueMicrotask(() => {
applyScheduled = false;
applyWishes();
});
}
function subscribe(listener: Listener): () => void {
listeners.add(listener);
subscriberCount += 1;
if (subscriberCount === 1) void attachListeners();
return () => {
listeners.delete(listener);
subscriberCount -= 1;
if (subscriberCount === 0) detachListeners();
};
}
/**
* Which enrolments to read. Derive it here rather than at each call site: the
* store is shared, so two consumers asking for different scopes would refetch
* over each other, and the server refuses `team` from a caller with no team.
*/
export function cookieBotScopeFor(
user: { teamId?: string } | null | undefined,
): CookieBotScope {
return user?.teamId ? "team" : "mine";
}
export interface UseCookieBotResult extends CookieBotSnapshot {
refresh: () => Promise<void>;
/** This profile's enrolment, or null. */
scheduleFor: (profileId: string) => CookieBotSchedule | null;
/** An unfinished remote session for this profile, or null. */
liveSessionFor: (profileId: string) => RemoteSessionState | null;
}
/**
* @param isEnabled the user is signed in and entitled. False keeps the store
* idle so an unentitled desktop never polls a route that will refuse it.
* @param teamScope read the whole team's enrolments. Only pass `"team"` when
* the user actually belongs to one the server refuses it otherwise.
*/
export function useCookieBot(
isEnabled: boolean,
teamScope: CookieBotScope = "mine",
): UseCookieBotResult {
const state = useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
const consumerId = useId();
useEffect(() => {
wishes.set(consumerId, { enabled: isEnabled, scope: teamScope });
scheduleApplyWishes();
return () => {
wishes.delete(consumerId);
scheduleApplyWishes();
};
}, [consumerId, isEnabled, teamScope]);
const scheduleFor = useCallback(
(profileId: string) => state.schedules[profileId] ?? null,
[state.schedules],
);
const liveSessionFor = useCallback(
(profileId: string) => state.liveSessions[profileId] ?? null,
[state.liveSessions],
);
return {
...state,
refresh: refreshCookieBot,
scheduleFor,
liveSessionFor,
};
}
+51
View File
@@ -0,0 +1,51 @@
import { useCallback, useEffect, useState } from "react";
import {
getRemoteHandoffStates,
onRemoteHandoffChanged,
type RemoteHandoffState,
} from "@/lib/remote-sessions";
/**
* Which profiles cannot be opened on this computer right now.
*
* Reads the same store the backend launch gate reads, so the button this
* disables and the refusal the backend would produce can never disagree. That
* matters more than it sounds: the previous signal was the profile-lock cache,
* which refreshes on a 30-second server poll and only refetches on this
* device's own lock events. A profile running on the fleet therefore looked
* launchable for up to half a minute, and a profile whose finished session had
* not been pulled back looked launchable indefinitely.
*
* Updates arrive as an event rather than a poll because every transition that
* can change this already emits one.
*/
export function useRemoteHandoff() {
const [states, setStates] = useState<Record<string, RemoteHandoffState>>({});
const refresh = useCallback(async () => {
try {
setStates(await getRemoteHandoffStates());
} catch (error) {
// Not signed in, or the app is still starting. The backend gate still
// applies; the button is simply not pre-disabled.
console.warn("Could not read remote handoff state:", error);
}
}, []);
useEffect(() => {
void refresh();
const unlisten = onRemoteHandoffChanged(setStates);
return () => {
void unlisten.then((off) => {
off();
});
};
}, [refresh]);
const handoffFor = useCallback(
(profileId: string): RemoteHandoffState | null => states[profileId] ?? null,
[states],
);
return { handoffStates: states, handoffFor, refreshHandoff: refresh };
}

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