mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-09 04:36:15 +02:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
11b130df46 | ||
|
|
b8e5b4f4e6 | ||
|
|
d80e127cd3 | ||
|
|
e11967509d | ||
|
|
6d9a44faad | ||
|
|
f8532be8af | ||
|
|
70a8deb7eb | ||
|
|
b89f002c1d | ||
|
|
3b1feb3f1b | ||
|
|
bc2b93d902 | ||
|
|
5c24e84eaf | ||
|
|
ffbbaa732a | ||
|
|
f12a84e18f | ||
|
|
39bbdcb547 | ||
|
|
29cb83d063 | ||
|
|
7d82a25107 |
@@ -30,4 +30,9 @@ messages:
|
||||
### Documentation
|
||||
### Dependencies
|
||||
### Developer Experience
|
||||
model: openai/gpt-4.1
|
||||
# `auto` lets the Copilot CLI pick. Deliberately not a pinned model id: it is
|
||||
# the only value valid on every Copilot plan (Free and Student get auto
|
||||
# selection only), and it cannot go stale the way `openai/gpt-4.1` did when
|
||||
# GitHub Models was retired on 2026-07-30 and took both of these workflows
|
||||
# down with it.
|
||||
model: auto
|
||||
|
||||
@@ -20,4 +20,9 @@ messages:
|
||||
{{commits}}
|
||||
|
||||
Format: one short opening sentence, a blank line, then bullets starting with "- " (one per line). Nothing else.
|
||||
model: openai/gpt-4.1
|
||||
# `auto` lets the Copilot CLI pick. Deliberately not a pinned model id: it is
|
||||
# the only value valid on every Copilot plan (Free and Student get auto
|
||||
# selection only), and it cannot go stale the way `openai/gpt-4.1` did when
|
||||
# GitHub Models was retired on 2026-07-30 and took both of these workflows
|
||||
# down with it.
|
||||
model: auto
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -96,7 +96,8 @@ jobs:
|
||||
-d "$PAYLOAD" || echo "000")
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "::warning::GitHub Models returned HTTP $STATUS; treating as compliant"
|
||||
echo "::error::GitHub Models returned HTTP $STATUS; treating as compliant"
|
||||
printf '%s\n' "inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
|
||||
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
|
||||
exit 0
|
||||
fi
|
||||
@@ -107,7 +108,8 @@ jobs:
|
||||
# to a compliant result so a flaky model never closes a legitimate issue.
|
||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||
echo "::warning::Model returned non-JSON; treating as compliant"
|
||||
echo "::error::Model returned non-JSON; treating as compliant"
|
||||
printf '%s\n' "model returned output that was not JSON" >> /tmp/ai-degraded
|
||||
echo '{"is_compliant": true, "non_compliance_reasons": []}' > /tmp/result.json
|
||||
fi
|
||||
echo "Compliance response validated"
|
||||
@@ -145,3 +147,16 @@ jobs:
|
||||
run: |
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
|
||||
gh issue close "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --reason "not planned"
|
||||
|
||||
# The steps above deliberately degrade rather than block: an inference
|
||||
# outage must never close a contributor's issue or flag their pull
|
||||
# request. But a run that skipped the check it exists to perform has not
|
||||
# succeeded, and reporting green hides that the automation is dead.
|
||||
- name: Fail if the AI check did not actually run
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ai-degraded ]; then
|
||||
echo "::error::This check degraded to a no-op and its result was not verified:"
|
||||
sed 's/^/ - /' /tmp/ai-degraded
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -257,7 +257,8 @@ jobs:
|
||||
if [ "$STATUS" = "200" ]; then
|
||||
jq -r '.choices[0].message.content // empty' /tmp/triage-response.json > /tmp/triage-raw.txt || : > /tmp/triage-raw.txt
|
||||
else
|
||||
echo "::warning::GitHub Models returned HTTP $STATUS for triage"
|
||||
echo "::error::GitHub Models returned HTTP $STATUS for triage"
|
||||
printf '%s\n' "triage inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
|
||||
: > /tmp/triage-raw.txt
|
||||
fi
|
||||
|
||||
@@ -266,7 +267,8 @@ jobs:
|
||||
|
||||
# Fall back to a safe classification when the response is not JSON.
|
||||
if ! jq -e . /tmp/triage.json >/dev/null 2>&1; then
|
||||
echo "::warning::Triage returned non-JSON; using fallback classification"
|
||||
echo "::error::Triage returned non-JSON; using fallback classification"
|
||||
printf '%s\n' "triage returned output that was not JSON" >> /tmp/ai-degraded
|
||||
jq -n '{
|
||||
language: "en",
|
||||
classification: "bug-in-scope",
|
||||
@@ -436,7 +438,8 @@ jobs:
|
||||
-d "$PAYLOAD" || echo "000")
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "::warning::GitHub Models returned HTTP $STATUS; skipping the triage comment"
|
||||
echo "::error::GitHub Models returned HTTP $STATUS; skipping the triage comment"
|
||||
printf '%s\n' "composer inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
|
||||
echo "has_comment=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -444,7 +447,8 @@ jobs:
|
||||
jq -r '.choices[0].message.content // empty' /tmp/compose-response.json > /tmp/ai-comment.txt || : > /tmp/ai-comment.txt
|
||||
|
||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||
echo "::warning::Composer returned empty response; skipping the triage comment"
|
||||
echo "::error::Composer returned empty response; skipping the triage comment"
|
||||
printf '%s\n' "composer returned an empty response" >> /tmp/ai-degraded
|
||||
echo "has_comment=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -482,6 +486,20 @@ jobs:
|
||||
run: |
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
|
||||
|
||||
|
||||
# The steps above deliberately degrade rather than block: an inference
|
||||
# outage must never close a contributor's issue or flag their pull
|
||||
# request. But a run that skipped the check it exists to perform has not
|
||||
# succeeded, and reporting green hides that the automation is dead.
|
||||
- name: Fail if the AI check did not actually run
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ai-degraded ]; then
|
||||
echo "::error::This check degraded to a no-op and its result was not verified:"
|
||||
sed 's/^/ - /' /tmp/ai-degraded
|
||||
exit 1
|
||||
fi
|
||||
|
||||
analyze-pr:
|
||||
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
@@ -619,7 +637,8 @@ jobs:
|
||||
-d "$PAYLOAD" || echo "000")
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "::warning::GitHub Models returned HTTP $STATUS; skipping the review comment"
|
||||
echo "::error::GitHub Models returned HTTP $STATUS; skipping the review comment"
|
||||
printf '%s\n' "PR review inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
|
||||
echo "has_comment=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -627,7 +646,8 @@ jobs:
|
||||
jq -r '.choices[0].message.content // empty' /tmp/pr-response.json > /tmp/ai-comment.txt || : > /tmp/ai-comment.txt
|
||||
|
||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||
echo "::warning::AI response was empty; skipping the review comment"
|
||||
echo "::error::AI response was empty; skipping the review comment"
|
||||
printf '%s\n' "PR review returned an empty response" >> /tmp/ai-degraded
|
||||
echo "has_comment=false" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
@@ -642,6 +662,20 @@ jobs:
|
||||
run: |
|
||||
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
|
||||
|
||||
|
||||
# The steps above deliberately degrade rather than block: an inference
|
||||
# outage must never close a contributor's issue or flag their pull
|
||||
# request. But a run that skipped the check it exists to perform has not
|
||||
# succeeded, and reporting green hides that the automation is dead.
|
||||
- name: Fail if the AI check did not actually run
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ai-degraded ]; then
|
||||
echo "::error::This check degraded to a no-op and its result was not verified:"
|
||||
sed 's/^/ - /' /tmp/ai-degraded
|
||||
exit 1
|
||||
fi
|
||||
|
||||
opencode-command:
|
||||
if: |
|
||||
github.repository == 'zhom/donutbrowser' &&
|
||||
@@ -659,7 +693,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@4da7bb44c84e013fa53e9c5d02ac753d1435c81a #v1.18.9
|
||||
uses: anomalyco/opencode/github@65cf14df16c191f3e9684f0d9a8bae69103ced6d #v1.18.14
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
models: read
|
||||
copilot-requests: write
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
@@ -123,17 +123,27 @@ jobs:
|
||||
echo "previous-tag=${PREV_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "Collected $(wc -l < commits.txt) commits between ${PREV_TAG} and ${TAG}."
|
||||
|
||||
# The Copilot CLI is not preinstalled on GitHub-hosted runners, and
|
||||
# ai-inference v3 shells out to it.
|
||||
- name: Install Copilot CLI
|
||||
if: steps.gate.outputs.skip != 'true'
|
||||
run: npm install -g @github/copilot
|
||||
|
||||
- name: Generate summary with AI
|
||||
id: ai
|
||||
if: steps.gate.outputs.skip != 'true'
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3
|
||||
with:
|
||||
prompt-file: .github/prompts/telegram-release-summary.prompt.yml
|
||||
input: |
|
||||
version: ${{ steps.tag.outputs.tag }}
|
||||
file_input: |
|
||||
commits: ./commits.txt
|
||||
max-tokens: 1024
|
||||
env:
|
||||
# The Copilot CLI reads its credential from the environment; the
|
||||
# workflow token carries it under the `copilot-requests` permission
|
||||
# granted above, so no PAT is needed.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Post release announcement to Telegram
|
||||
if: steps.gate.outputs.skip != 'true'
|
||||
|
||||
@@ -134,7 +134,8 @@ jobs:
|
||||
-d "$PAYLOAD" || echo "000")
|
||||
|
||||
if [ "$STATUS" != "200" ]; then
|
||||
echo "::warning::GitHub Models returned HTTP $STATUS; treating as compliant"
|
||||
echo "::error::GitHub Models returned HTTP $STATUS; treating as compliant"
|
||||
printf '%s\n' "inference call failed with HTTP $STATUS" >> /tmp/ai-degraded
|
||||
echo '{"compliant": true, "violations": []}' > /tmp/result.json
|
||||
exit 0
|
||||
fi
|
||||
@@ -146,7 +147,8 @@ jobs:
|
||||
# The deterministic trailer scan still stands on its own below.
|
||||
sed -E 's/^```(json)?$//; s/```$//' /tmp/raw.txt > /tmp/result.json
|
||||
if ! jq -e . /tmp/result.json >/dev/null 2>&1; then
|
||||
echo "::warning::Model returned non-JSON; treating as compliant"
|
||||
echo "::error::Model returned non-JSON; treating as compliant"
|
||||
printf '%s\n' "model returned output that was not JSON" >> /tmp/ai-degraded
|
||||
echo '{"compliant": true, "violations": []}' > /tmp/result.json
|
||||
fi
|
||||
echo "Policy response validated"
|
||||
@@ -205,3 +207,16 @@ jobs:
|
||||
run: |
|
||||
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/comment.md
|
||||
gh pr close "$PR_NUMBER" --repo "$GITHUB_REPOSITORY"
|
||||
|
||||
# The steps above deliberately degrade rather than block: an inference
|
||||
# outage must never close a contributor's issue or flag their pull
|
||||
# request. But a run that skipped the check it exists to perform has not
|
||||
# succeeded, and reporting green hides that the automation is dead.
|
||||
- name: Fail if the AI check did not actually run
|
||||
if: always()
|
||||
run: |
|
||||
if [ -f /tmp/ai-degraded ]; then
|
||||
echo "::error::This check degraded to a no-op and its result was not verified:"
|
||||
sed 's/^/ - /' /tmp/ai-degraded
|
||||
exit 1
|
||||
fi
|
||||
|
||||
@@ -8,7 +8,7 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
models: read
|
||||
copilot-requests: write
|
||||
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
@@ -79,17 +79,27 @@ jobs:
|
||||
echo "commits-file=commits.txt" >> $GITHUB_OUTPUT
|
||||
echo "changes-file=changes.txt" >> $GITHUB_OUTPUT
|
||||
|
||||
# The Copilot CLI is not preinstalled on GitHub-hosted runners, and
|
||||
# ai-inference v3 shells out to it.
|
||||
- name: Install Copilot CLI
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
run: npm install -g @github/copilot
|
||||
|
||||
- name: Generate release notes with AI
|
||||
id: generate-notes
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
uses: actions/ai-inference@a7805884c80886efc241e94a5351df715968a0ad # v2.1.1
|
||||
uses: actions/ai-inference@2c43c91ae16266ca159d311430343c67a5ffa222 # v3
|
||||
with:
|
||||
prompt-file: .github/prompts/release-notes.prompt.yml
|
||||
input: |
|
||||
version: ${{ steps.get-previous-tag.outputs.current-tag }}
|
||||
file_input: |
|
||||
commits: ./commits.txt
|
||||
max-tokens: 4096
|
||||
env:
|
||||
# The Copilot CLI reads its credential from the environment; the
|
||||
# workflow token carries it under the `copilot-requests` permission
|
||||
# granted above, so no PAT is needed.
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Update release with generated notes
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 #v6.0.9
|
||||
uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 #v6.0.10
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 #v7.0.1
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@bee27e3a4fd1ea2111cf90ab89cd076c870fce14 #v1.48.0
|
||||
uses: crate-ci/typos@8a48f81b6c64dcfea44b3633223084c4be58ac5f #v1.49.0
|
||||
|
||||
@@ -64,3 +64,10 @@ nodecar/nodecar-bin
|
||||
|
||||
# claude
|
||||
.claude/
|
||||
# Claude Code session-recovery runtime state
|
||||
HANDOFF.md
|
||||
.claude/settings.local.json
|
||||
.claude/rate-limit-state.json
|
||||
.claude/stop-failure-events.jsonl
|
||||
.claude/quota-blocked.json
|
||||
session-recover.yaml
|
||||
|
||||
+132
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
Generated
+2
-1
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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" }),
|
||||
);
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -22,6 +22,30 @@ test("fresh app renders, completes onboarding, persists settings, and never touc
|
||||
true,
|
||||
);
|
||||
|
||||
// Where the app draws its own titlebar it also owns the window controls,
|
||||
// so it needs the desktop's button layout to know which side they go on.
|
||||
const decorations = await app.invoke("get_window_decoration_layout");
|
||||
assert.equal(typeof decorations?.client_side, "boolean");
|
||||
if (decorations.client_side) {
|
||||
// Only reported where decorations were actually dropped, which is
|
||||
// every Linux session except KDE on Wayland.
|
||||
assert.equal(process.platform, "linux");
|
||||
// `layout` may be null when GtkSettings is unavailable; the frontend
|
||||
// falls back to the default arrangement rather than drawing nothing,
|
||||
// so asserting a string here would be stricter than the contract.
|
||||
if (decorations.layout !== null) {
|
||||
assert.equal(typeof decorations.layout, "string");
|
||||
assert.match(
|
||||
decorations.layout,
|
||||
/close|minimize|maximize/,
|
||||
`layout must name a drawable control, got: ${decorations.layout}`,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// The platform still draws a titlebar; the app must not draw a second.
|
||||
assert.equal(decorations.layout, null);
|
||||
}
|
||||
|
||||
const saved = await app.invoke("save_app_settings", {
|
||||
settings: {
|
||||
...initial,
|
||||
@@ -113,8 +137,12 @@ test("keyboard command palette and major navigation surfaces are operable throug
|
||||
assert.match(body, /Settings/i);
|
||||
|
||||
// Exercise native WebDriver element marshalling and click, not just script execution.
|
||||
// Scoped to the open dialog on purpose: on Linux the app draws its own
|
||||
// titlebar, whose "Close window" control appears earlier in the DOM, and
|
||||
// clicking that would exercise the window lifecycle instead of the palette.
|
||||
const close = await app.execute(
|
||||
`return [...document.querySelectorAll("button")].find(
|
||||
`const dialog = document.querySelector("[role='dialog']") ?? document;
|
||||
return [...dialog.querySelectorAll("button")].find(
|
||||
(button) => /close/i.test(button.getAttribute("aria-label") || button.textContent || "")
|
||||
) ?? null;`,
|
||||
);
|
||||
|
||||
+17
-1
@@ -474,7 +474,7 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
|
||||
await app.clickSelector('[aria-label="New proxy"]');
|
||||
await app.waitForText("Add Proxy");
|
||||
await app.fillSelector("#proxy-name", "E2E VLESS");
|
||||
await chooseSelectOption(app, "#proxy-type", "VLESS · Vision · REALITY");
|
||||
await chooseSelectOption(app, "#proxy-type", "VLESS");
|
||||
|
||||
assert.equal(
|
||||
await app.execute(
|
||||
@@ -512,6 +512,22 @@ test("VLESS proxy form keeps the share URI as one clear, validated input", async
|
||||
true,
|
||||
);
|
||||
|
||||
// A well-formed URI for a setup Donut cannot use must say WHICH part is
|
||||
// unsupported, rather than implying the user mistyped it.
|
||||
await app.fillSelector(
|
||||
"#proxy-vless-uri",
|
||||
`${uri.replace("type=tcp", "type=ws")}&path=%2Fray`,
|
||||
);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
app.execute(
|
||||
`return /only|TCP|transport/i.test(
|
||||
document.querySelector("#proxy-vless-uri-help")?.textContent || ""
|
||||
);`,
|
||||
),
|
||||
{ description: "transport-specific unsupported message" },
|
||||
);
|
||||
|
||||
await app.fillSelector("#proxy-vless-uri", uri);
|
||||
await app.waitFor(
|
||||
() =>
|
||||
|
||||
@@ -96,17 +96,17 @@
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.28.2";
|
||||
releaseVersion = "0.29.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
@@ -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;
|
||||
}
|
||||
Generated
+29
-28
@@ -9,21 +9,22 @@ overrides:
|
||||
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
||||
postcss@<8.5.18: '>=8.5.18'
|
||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||
fast-uri@<3.1.2: '>=3.1.2 <4'
|
||||
fast-uri@<3.1.5: '>=3.1.5 <4'
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||
js-cookie@<3.0.7: '>=3.0.7'
|
||||
nanoid@<3.3.17: '>=3.3.17 <4'
|
||||
fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
|
||||
multer@>=2.0.0 <2.2.0: '>=2.2.0'
|
||||
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
|
||||
js-yaml@<3.15.0: '>=3.15.0 <4'
|
||||
js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5'
|
||||
js-yaml@<3.15.1: '>=3.15.1 <4'
|
||||
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
|
||||
'@babel/core@<7.29.6': '>=7.29.6 <8'
|
||||
brace-expansion@<5.0.8: 5.0.8
|
||||
brace-expansion@<5.0.9: 5.0.9
|
||||
sharp@<0.35.0: '>=0.35.0 <0.36'
|
||||
|
||||
patchedDependencies:
|
||||
brace-expansion@5.0.8: 6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e
|
||||
brace-expansion@5.0.9: bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208
|
||||
|
||||
importers:
|
||||
|
||||
@@ -2852,8 +2853,8 @@ packages:
|
||||
bowser@2.14.1:
|
||||
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||
|
||||
brace-expansion@5.0.8:
|
||||
resolution: {integrity: sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==}
|
||||
brace-expansion@5.0.9:
|
||||
resolution: {integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
browserslist@4.28.4:
|
||||
@@ -3340,8 +3341,8 @@ packages:
|
||||
fast-safe-stringify@2.1.1:
|
||||
resolution: {integrity: sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==}
|
||||
|
||||
fast-uri@3.1.4:
|
||||
resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==}
|
||||
fast-uri@3.1.5:
|
||||
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
|
||||
|
||||
fb-watchman@2.0.2:
|
||||
resolution: {integrity: sha512-p5161BqbuCaSnB8jIbzQHOlpgsPmK5rJVDfDKO91Axs5NC1uu3HRQm6wt9cd9/+GtQQIO53JdGXXoyDpTAsgYA==}
|
||||
@@ -3773,12 +3774,12 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
js-yaml@3.15.0:
|
||||
resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==}
|
||||
js-yaml@3.15.1:
|
||||
resolution: {integrity: sha512-S99WuO3HlhO3XN41EtYUNl9zzXjoJx7QvmipxsJVxtCBT0YHEFy+iOJhjSvrmV12nYhWpZaM8lPHkJm0yUMbag==}
|
||||
hasBin: true
|
||||
|
||||
js-yaml@4.3.0:
|
||||
resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==}
|
||||
js-yaml@4.3.1:
|
||||
resolution: {integrity: sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==}
|
||||
hasBin: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
@@ -4093,8 +4094,8 @@ packages:
|
||||
resolution: {integrity: sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==}
|
||||
engines: {node: ^18.17.0 || >=20.5.0}
|
||||
|
||||
nanoid@3.3.16:
|
||||
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
|
||||
nanoid@3.3.17:
|
||||
resolution: {integrity: sha512-xQLf0A3HOMlgHq0n247/LRuAOYmB7dXJ/DvAxGvsSBij45XtBSmQycu+F8ODbHwns/XyFZagyL1+J0Offw1E0g==}
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
@@ -6040,7 +6041,7 @@ snapshots:
|
||||
camelcase: 5.3.1
|
||||
find-up: 4.1.0
|
||||
get-package-type: 0.1.0
|
||||
js-yaml: 3.15.0
|
||||
js-yaml: 3.15.1
|
||||
resolve-from: 5.0.0
|
||||
|
||||
'@istanbuljs/schema@0.1.6': {}
|
||||
@@ -7857,14 +7858,14 @@ snapshots:
|
||||
ajv@8.18.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.4
|
||||
fast-uri: 3.1.5
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ajv@8.20.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.4
|
||||
fast-uri: 3.1.5
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
@@ -8038,7 +8039,7 @@ snapshots:
|
||||
|
||||
bowser@2.14.1: {}
|
||||
|
||||
brace-expansion@5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e):
|
||||
brace-expansion@5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208):
|
||||
dependencies:
|
||||
balanced-match: 4.0.4
|
||||
|
||||
@@ -8232,7 +8233,7 @@ snapshots:
|
||||
cosmiconfig@8.3.6(typescript@5.9.3):
|
||||
dependencies:
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.3.0
|
||||
js-yaml: 4.3.1
|
||||
parse-json: 5.2.0
|
||||
path-type: 4.0.0
|
||||
optionalDependencies:
|
||||
@@ -8480,7 +8481,7 @@ snapshots:
|
||||
|
||||
fast-safe-stringify@2.1.1: {}
|
||||
|
||||
fast-uri@3.1.4: {}
|
||||
fast-uri@3.1.5: {}
|
||||
|
||||
fb-watchman@2.0.2:
|
||||
dependencies:
|
||||
@@ -9102,12 +9103,12 @@ snapshots:
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@3.15.0:
|
||||
js-yaml@3.15.1:
|
||||
dependencies:
|
||||
argparse: 1.0.10
|
||||
esprima: 4.0.1
|
||||
|
||||
js-yaml@4.3.0:
|
||||
js-yaml@4.3.1:
|
||||
dependencies:
|
||||
argparse: 2.0.1
|
||||
|
||||
@@ -9332,15 +9333,15 @@ snapshots:
|
||||
|
||||
minimatch@10.2.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
|
||||
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
|
||||
|
||||
minimatch@3.1.5:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
|
||||
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 5.0.8(patch_hash=6f8c2bb08760f9abf1133095a5475f8ac32a360b9dcbcb85e4750dd1b616339e)
|
||||
brace-expansion: 5.0.9(patch_hash=bb2702fb4e1ea6a45630f2a0384edbcbb57800ec9bfc3845e096d85c88aa8208)
|
||||
|
||||
minimist@1.2.8: {}
|
||||
|
||||
@@ -9371,7 +9372,7 @@ snapshots:
|
||||
|
||||
mute-stream@2.0.0: {}
|
||||
|
||||
nanoid@3.3.16: {}
|
||||
nanoid@3.3.17: {}
|
||||
|
||||
napi-postinstall@0.3.4: {}
|
||||
|
||||
@@ -9535,7 +9536,7 @@ snapshots:
|
||||
|
||||
postcss@8.5.23:
|
||||
dependencies:
|
||||
nanoid: 3.3.16
|
||||
nanoid: 3.3.17
|
||||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
|
||||
+6
-5
@@ -24,17 +24,18 @@ overrides:
|
||||
path-to-regexp@>=8.0.0 <8.4.0: '>=8.4.0'
|
||||
postcss@<8.5.18: '>=8.5.18'
|
||||
fast-xml-parser@<5.7.0: '>=5.7.2'
|
||||
fast-uri@<3.1.2: '>=3.1.2 <4'
|
||||
fast-uri@<3.1.5: '>=3.1.5 <4'
|
||||
fast-xml-builder@<1.2.0: '>=1.2.0'
|
||||
qs@>=6.11.1 <6.15.2: '>=6.15.2'
|
||||
js-cookie@<3.0.7: '>=3.0.7'
|
||||
nanoid@<3.3.17: '>=3.3.17 <4'
|
||||
fast-uri@>=4.0.0 <4.1.1: '>=4.1.1 <5'
|
||||
multer@>=2.0.0 <2.2.0: '>=2.2.0'
|
||||
form-data@>=4.0.0 <4.0.6: '>=4.0.6'
|
||||
js-yaml@<3.15.0: '>=3.15.0 <4'
|
||||
js-yaml@>=4.0.0 <4.3.0: '>=4.3.0 <5'
|
||||
js-yaml@<3.15.1: '>=3.15.1 <4'
|
||||
js-yaml@>=4.0.0 <4.3.1: '>=4.3.1 <5'
|
||||
'@babel/core@<7.29.6': '>=7.29.6 <8'
|
||||
brace-expansion@<5.0.8: 5.0.8
|
||||
brace-expansion@<5.0.9: 5.0.9
|
||||
sharp@<0.35.0: '>=0.35.0 <0.36'
|
||||
|
||||
allowBuilds:
|
||||
@@ -97,4 +98,4 @@ minimumReleaseAgeExclude:
|
||||
- '@aws-sdk/token-providers@3.1081.0'
|
||||
|
||||
patchedDependencies:
|
||||
brace-expansion@5.0.8: patches/brace-expansion@5.0.8.patch
|
||||
brace-expansion@5.0.9: patches/brace-expansion@5.0.9.patch
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# cargo-audit configuration.
|
||||
#
|
||||
# `cargo audit` reads Cargo.lock, which records optional dependencies even when
|
||||
# no enabled feature pulls them into the build. An advisory against such a
|
||||
# package fails CI while the vulnerable code is never compiled into the binary.
|
||||
# Entries here are for exactly that case and must each carry the evidence.
|
||||
#
|
||||
# Before adding an ignore, prove the crate is genuinely not built:
|
||||
# cd src-tauri
|
||||
# cargo tree -i <crate> --target all # must print "nothing to print"
|
||||
# cargo tree --target all | grep <crate> # must find nothing
|
||||
# If either finds it, the crate IS in the build and the advisory must be fixed,
|
||||
# not ignored.
|
||||
|
||||
[advisories]
|
||||
ignore = [
|
||||
# RUSTSEC-2026-0235 — rkyv: insufficient archive validation can cause
|
||||
# out-of-bounds reads in archives containing Rc/Arc. Fixed in rkyv >= 0.8.17.
|
||||
#
|
||||
# Not reachable here. rkyv is an OPTIONAL dependency of rust_decimal, which
|
||||
# arrives via tauri-plugin-log -> byte-unit -> rust_decimal. No enabled
|
||||
# feature activates it, so it is present in Cargo.lock but absent from the
|
||||
# build graph. Verified with the two commands above (both find nothing) and
|
||||
# by there being no rkyv artifact in target/.
|
||||
#
|
||||
# There is no upgrade path: rust_decimal 1.42.1 is the newest release and
|
||||
# still pins rkyv 0.7.x, so `cargo update` cannot reach 0.8.17.
|
||||
#
|
||||
# REMOVE THIS as soon as either becomes true:
|
||||
# - rust_decimal ships a release depending on rkyv >= 0.8.17, or
|
||||
# - `cargo tree -i rkyv --target all` starts printing a path, which would
|
||||
# mean the crate is now genuinely compiled and the advisory applies.
|
||||
"RUSTSEC-2026-0235",
|
||||
]
|
||||
Generated
+4
-3
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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)) {
|
||||
|
||||
+1696
-82
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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()
|
||||
|
||||
@@ -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
@@ -18,8 +18,13 @@ impl DefaultBrowser {
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::is_default_browser();
|
||||
|
||||
// Linux answers this by running `xdg-mime`, a shell script that forks
|
||||
// further. That is blocking work with no upper bound, and this command
|
||||
// runs on the same async runtime as every other command, the REST API and
|
||||
// the sync scheduler — so doing it inline occupies a worker for as long as
|
||||
// the desktop takes to answer. The Settings page polls this on a timer.
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::is_default_browser();
|
||||
return blocking(linux::is_default_browser).await;
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
Err("Unsupported platform".to_string())
|
||||
@@ -32,14 +37,27 @@ impl DefaultBrowser {
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::set_as_default_browser();
|
||||
|
||||
// Same reasoning, and this one additionally sleeps 500ms before verifying.
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::set_as_default_browser();
|
||||
return blocking(linux::set_as_default_browser).await;
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
Err("Unsupported platform".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Run blocking work off the async runtime's worker threads.
|
||||
#[cfg(target_os = "linux")]
|
||||
async fn blocking<T, F>(work: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce() -> Result<T, String> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
tokio::task::spawn_blocking(work)
|
||||
.await
|
||||
.map_err(|e| format!("Default browser check did not run: {e}"))?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use core_foundation::base::OSStatus;
|
||||
|
||||
+153
-34
@@ -59,25 +59,48 @@ impl BlocklistLevel {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn url(&self) -> Option<&'static str> {
|
||||
/// Where this tier's `domains/*.txt` list is fetched from.
|
||||
///
|
||||
/// `raw.githubusercontent.com` only, deliberately. This used to be a jsDelivr
|
||||
/// URL against `hagezi/dns-blocklists`, and it broke every blocklisted launch:
|
||||
/// that repo grew past jsDelivr's 150 MB package-resolution limit, so
|
||||
/// `@latest` began answering `403 Package size exceeded the configured limit
|
||||
/// of 150 MB` for every tier. Nothing was wrong locally and nothing a user
|
||||
/// could do would fix it — a third party's repo got too big and a CDN's
|
||||
/// package resolver gave up.
|
||||
///
|
||||
/// raw.githubusercontent.com serves the file straight from the ref and
|
||||
/// resolves no package at all, so it cannot fail that way. The
|
||||
/// `domains/*.txt` format now lives in `hagezi/dns-blocklists-legacy`.
|
||||
///
|
||||
/// Returned as a slice so the fetch path can try several sources if one is
|
||||
/// ever added; today there is exactly one on purpose.
|
||||
pub fn urls(&self) -> &'static [&'static str] {
|
||||
match self {
|
||||
Self::None | Self::Custom => None,
|
||||
Self::None | Self::Custom => &[],
|
||||
Self::Light => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/light.txt")
|
||||
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/light.txt"]
|
||||
}
|
||||
Self::Normal => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/multi.txt")
|
||||
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/multi.txt"]
|
||||
}
|
||||
Self::Pro => Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.txt"),
|
||||
Self::ProPlus => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/pro.plus.txt")
|
||||
}
|
||||
Self::Ultimate => {
|
||||
Some("https://cdn.jsdelivr.net/gh/hagezi/dns-blocklists@latest/domains/ultimate.txt")
|
||||
Self::Pro => {
|
||||
&["https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/pro.txt"]
|
||||
}
|
||||
Self::ProPlus => &[
|
||||
"https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/pro.plus.txt",
|
||||
],
|
||||
Self::Ultimate => &[
|
||||
"https://raw.githubusercontent.com/hagezi/dns-blocklists-legacy/main/domains/ultimate.txt",
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/// The preferred source, for callers that only need to name one.
|
||||
pub fn url(&self) -> Option<&'static str> {
|
||||
self.urls().first().copied()
|
||||
}
|
||||
|
||||
pub fn filename(&self) -> Option<&'static str> {
|
||||
match self {
|
||||
Self::None => None,
|
||||
@@ -295,49 +318,85 @@ impl BlocklistManager {
|
||||
}
|
||||
|
||||
pub async fn fetch_blocklist(level: BlocklistLevel) -> Result<PathBuf, String> {
|
||||
let production_url = level
|
||||
.url()
|
||||
.ok_or_else(|| format!("No URL for level {:?}", level))?;
|
||||
let production_urls: Vec<String> = level.urls().iter().map(|u| (*u).to_string()).collect();
|
||||
if production_urls.is_empty() {
|
||||
return Err(format!("No URL for level {:?}", level));
|
||||
}
|
||||
#[cfg(feature = "e2e")]
|
||||
let url = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
|
||||
let urls = std::env::var("DONUT_E2E_DNS_BLOCKLIST_BASE_URL")
|
||||
.ok()
|
||||
.filter(|base| !base.is_empty())
|
||||
.map(|base| {
|
||||
format!(
|
||||
vec![format!(
|
||||
"{}/{}",
|
||||
base.trim_end_matches('/'),
|
||||
level.filename().unwrap_or("blocklist.txt")
|
||||
)
|
||||
)]
|
||||
})
|
||||
.unwrap_or_else(|| production_url.to_string());
|
||||
.unwrap_or(production_urls);
|
||||
#[cfg(not(feature = "e2e"))]
|
||||
let url = production_url.to_string();
|
||||
let urls = production_urls;
|
||||
let path =
|
||||
Self::cached_file_path(level).ok_or_else(|| format!("No filename for level {:?}", level))?;
|
||||
|
||||
let cache_dir = Self::cache_dir();
|
||||
std::fs::create_dir_all(&cache_dir).map_err(|e| format!("Failed to create cache dir: {e}"))?;
|
||||
|
||||
log::info!(
|
||||
"[dns-blocklist] Fetching {} from {}",
|
||||
level.display_name(),
|
||||
url
|
||||
);
|
||||
// Try each source in turn. A tier is only a failure once EVERY source has
|
||||
// refused it: the outage this replaced was one CDN answering 403 for a
|
||||
// reason that had nothing to do with the user, and falling back would have
|
||||
// made it invisible.
|
||||
let mut body: Option<String> = None;
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
|
||||
let response = HTTP_CLIENT
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch blocklist: {e}"))?;
|
||||
for url in &urls {
|
||||
log::info!(
|
||||
"[dns-blocklist] Fetching {} from {}",
|
||||
level.display_name(),
|
||||
url
|
||||
);
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("HTTP {} when fetching {}", response.status(), url));
|
||||
let response = match HTTP_CLIENT.get(url).send().await {
|
||||
Ok(response) => response,
|
||||
Err(e) => {
|
||||
failures.push(format!("{url}: {e}"));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if !response.status().is_success() {
|
||||
failures.push(format!("{url}: HTTP {}", response.status()));
|
||||
continue;
|
||||
}
|
||||
|
||||
match response.text().await {
|
||||
Ok(text) => {
|
||||
if failures.is_empty() {
|
||||
log::info!("[dns-blocklist] {} fetched", level.display_name());
|
||||
} else {
|
||||
// Worth saying out loud: the primary source is down and somebody
|
||||
// should know before the backup goes too.
|
||||
log::warn!(
|
||||
"[dns-blocklist] {} came from a fallback source after {} failure(s): {}",
|
||||
level.display_name(),
|
||||
failures.len(),
|
||||
failures.join("; ")
|
||||
);
|
||||
}
|
||||
body = Some(text);
|
||||
break;
|
||||
}
|
||||
Err(e) => failures.push(format!("{url}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read response body: {e}"))?;
|
||||
let Some(body) = body else {
|
||||
return Err(format!(
|
||||
"Failed to fetch blocklist {} from any source ({})",
|
||||
level.display_name(),
|
||||
failures.join("; ")
|
||||
));
|
||||
};
|
||||
|
||||
// Write atomically: write to temp file, then rename
|
||||
let tmp_path = path.with_extension("tmp");
|
||||
@@ -796,6 +855,66 @@ mod tests {
|
||||
assert!(BlocklistLevel::None.filename().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_tier_is_served_only_from_raw_githubusercontent() {
|
||||
// jsDelivr is deliberately not a source. It resolves a whole package to
|
||||
// serve one file, so when `hagezi/dns-blocklists` grew past its 150 MB
|
||||
// limit every tier began answering 403 — an outage nothing local could fix.
|
||||
// raw.githubusercontent.com serves the file straight from the ref and
|
||||
// resolves no package, so it cannot fail that way.
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
let urls = level.urls();
|
||||
assert_eq!(
|
||||
urls.len(),
|
||||
1,
|
||||
"{} should have exactly one source: {urls:?}",
|
||||
level.as_str()
|
||||
);
|
||||
for url in urls {
|
||||
assert!(
|
||||
url.starts_with("https://raw.githubusercontent.com/"),
|
||||
"{} must be served from raw.githubusercontent.com: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
assert!(
|
||||
!url.contains("jsdelivr"),
|
||||
"{} must not reintroduce jsDelivr: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_tier_points_at_the_oversized_upstream_repo() {
|
||||
// The `domains/*.txt` format moved to `-legacy`, which is small enough for
|
||||
// jsDelivr to resolve. Pointing any tier back at the original repo
|
||||
// reintroduces the 403.
|
||||
for &level in BlocklistLevel::all_downloadable() {
|
||||
for url in level.urls() {
|
||||
assert!(
|
||||
!url.contains("/hagezi/dns-blocklists@") && !url.contains("/hagezi/dns-blocklists/"),
|
||||
"{} still points at the oversized repo: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
assert!(
|
||||
url.contains("dns-blocklists-legacy"),
|
||||
"{} should read the legacy list repo: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
assert!(
|
||||
url.ends_with(
|
||||
level
|
||||
.filename()
|
||||
.expect("downloadable tiers have a filename")
|
||||
),
|
||||
"{} source must serve its own tier file: {url}",
|
||||
level.as_str()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_status_returns_all_levels() {
|
||||
let statuses = BlocklistManager::get_cache_status();
|
||||
|
||||
@@ -26,6 +26,29 @@ pub struct DownloadedBrowsersRegistry {
|
||||
geoip_downloader: &'static GeoIPDownloader,
|
||||
}
|
||||
|
||||
/// Filename suffixes that identify a *downloaded artifact* — the container we
|
||||
/// fetched from the network — rather than a file belonging to the extracted
|
||||
/// install. Cleanup preserves these so a manually placed archive survives.
|
||||
///
|
||||
/// `.exe` and `.AppImage` are deliberately absent even though both can be
|
||||
/// downloaded. On Windows the extracted Wayfern payload is flat at the version
|
||||
/// root (`extraction::ensure_correct_directory_structure` returns early rather
|
||||
/// than nesting it), so preserving `.exe` kept `chrome.exe` while deleting every
|
||||
/// sibling `.dll`, the `.manifest`, `.pak` and `locales/` — a gutted install
|
||||
/// that then failed to launch with os error 14001. On Linux the `.AppImage`
|
||||
/// *is* the extracted payload. Cleanup must never leave behind something that
|
||||
/// still reads as an installed browser; the archive is deleted right after a
|
||||
/// successful download anyway, so nothing of value is lost.
|
||||
const DOWNLOAD_ARTIFACT_SUFFIXES: [&str; 7] =
|
||||
["zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "pkg", "msi"];
|
||||
|
||||
fn is_download_artifact(file_name: &str) -> bool {
|
||||
let lowered = file_name.to_lowercase();
|
||||
DOWNLOAD_ARTIFACT_SUFFIXES
|
||||
.iter()
|
||||
.any(|suffix| lowered.ends_with(suffix))
|
||||
}
|
||||
|
||||
impl DownloadedBrowsersRegistry {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
@@ -174,15 +197,19 @@ impl DownloadedBrowsersRegistry {
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Never delete files out from under a live download or extraction. Both the
|
||||
// detached task that runs the moment a download completes and the periodic
|
||||
// maintenance task land here, and a freshly downloaded version is referenced
|
||||
// by no persisted profile while profile creation is still in flight.
|
||||
if crate::downloader::is_downloading(browser, version) {
|
||||
log::info!("Skipping cleanup of {browser} {version}: a download is in progress");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(info) = self.remove_browser(browser, version) {
|
||||
// Clean up extracted binaries but preserve downloaded archives
|
||||
if info.file_path.exists() {
|
||||
if info.file_path.is_dir() {
|
||||
// Allowed archive extensions to preserve
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
|
||||
for entry in fs::read_dir(&info.file_path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
@@ -192,16 +219,11 @@ impl DownloadedBrowsersRegistry {
|
||||
continue;
|
||||
}
|
||||
|
||||
// For files, preserve if they look like downloaded archives/installers
|
||||
// For files, preserve only genuine downloaded archives/installers
|
||||
let keep = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|name| {
|
||||
// Match suffixes (handles multi-part extensions like .tar.xz)
|
||||
archive_exts
|
||||
.iter()
|
||||
.any(|ext| name.to_lowercase().ends_with(&ext.to_lowercase()))
|
||||
})
|
||||
.map(is_download_artifact)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !keep {
|
||||
@@ -215,13 +237,7 @@ impl DownloadedBrowsersRegistry {
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
let is_archive = archive_exts
|
||||
.iter()
|
||||
.any(|ext| file_name.to_lowercase().ends_with(&ext.to_lowercase()));
|
||||
if !is_archive {
|
||||
if !is_download_artifact(file_name) {
|
||||
fs::remove_file(&info.file_path)?;
|
||||
}
|
||||
}
|
||||
@@ -1230,6 +1246,130 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// The Windows payload is extracted flat at the version root, so preserving
|
||||
/// every `*.exe` used to leave `chrome.exe` behind while deleting the `.dll`
|
||||
/// files and the `.manifest` next to it. That gutted directory still passed
|
||||
/// the "is it downloaded?" check, was re-registered as healthy, and launching
|
||||
/// it failed in the Windows loader with os error 14001.
|
||||
#[test]
|
||||
fn test_cleanup_removes_the_browser_executable_not_just_its_libraries() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let version_dir = temp.path().join("wayfern").join("140.0");
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
|
||||
for name in [
|
||||
"chrome.exe",
|
||||
"wayfern.exe",
|
||||
"notification_helper.exe",
|
||||
"chrome.dll",
|
||||
"chrome_elf.dll",
|
||||
"chrome.exe.manifest",
|
||||
"resources.pak",
|
||||
] {
|
||||
std::fs::File::create(version_dir.join(name)).unwrap();
|
||||
}
|
||||
std::fs::create_dir_all(version_dir.join("locales")).unwrap();
|
||||
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "wayfern".to_string(),
|
||||
version: "140.0".to_string(),
|
||||
file_path: version_dir.clone(),
|
||||
});
|
||||
|
||||
registry
|
||||
.cleanup_failed_download("wayfern", "140.0")
|
||||
.expect("cleanup should succeed");
|
||||
|
||||
let leftovers: Vec<String> = std::fs::read_dir(&version_dir)
|
||||
.unwrap()
|
||||
.flatten()
|
||||
.map(|e| e.file_name().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
assert!(
|
||||
leftovers.is_empty(),
|
||||
"cleanup must not leave a half-deleted install behind, found: {leftovers:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The preserve rule still exists for its actual purpose: a downloaded
|
||||
/// archive (including one placed there by hand) survives the cleanup.
|
||||
#[test]
|
||||
fn test_cleanup_preserves_a_downloaded_archive() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let version_dir = temp.path().join("wayfern").join("141.0");
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
|
||||
std::fs::File::create(version_dir.join("wayfern-win64.zip")).unwrap();
|
||||
std::fs::File::create(version_dir.join("wayfern-mac.tar.xz")).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.exe")).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.dll")).unwrap();
|
||||
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "wayfern".to_string(),
|
||||
version: "141.0".to_string(),
|
||||
file_path: version_dir.clone(),
|
||||
});
|
||||
|
||||
registry
|
||||
.cleanup_failed_download("wayfern", "141.0")
|
||||
.expect("cleanup should succeed");
|
||||
|
||||
assert!(
|
||||
version_dir.join("wayfern-win64.zip").exists(),
|
||||
"a downloaded archive must be preserved"
|
||||
);
|
||||
assert!(
|
||||
version_dir.join("wayfern-mac.tar.xz").exists(),
|
||||
"multi-part archive extensions must still be recognised"
|
||||
);
|
||||
assert!(
|
||||
!version_dir.join("chrome.exe").exists(),
|
||||
"the extracted executable must be removed"
|
||||
);
|
||||
assert!(
|
||||
!version_dir.join("chrome.dll").exists(),
|
||||
"the extracted libraries must be removed"
|
||||
);
|
||||
}
|
||||
|
||||
/// Cleanup runs on a detached task the moment a download completes and again
|
||||
/// on a periodic timer, either of which can land while an install is still
|
||||
/// being written. It must stand down instead of deleting live files.
|
||||
#[test]
|
||||
fn test_cleanup_stands_down_while_a_download_is_in_progress() {
|
||||
use tempfile::TempDir;
|
||||
let temp = TempDir::new().unwrap();
|
||||
let version_dir = temp.path().join("wayfern").join("142.0");
|
||||
std::fs::create_dir_all(&version_dir).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.exe")).unwrap();
|
||||
std::fs::File::create(version_dir.join("chrome.dll")).unwrap();
|
||||
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "wayfern".to_string(),
|
||||
version: "142.0".to_string(),
|
||||
file_path: version_dir.clone(),
|
||||
});
|
||||
|
||||
crate::downloader::mark_downloading_for_test("wayfern", "142.0");
|
||||
let result = registry.cleanup_failed_download("wayfern", "142.0");
|
||||
crate::downloader::clear_download_state_for_browser("wayfern");
|
||||
result.expect("cleanup should succeed");
|
||||
|
||||
assert!(
|
||||
version_dir.join("chrome.exe").exists() && version_dir.join("chrome.dll").exists(),
|
||||
"an in-flight download must not be deleted out from under itself"
|
||||
);
|
||||
assert!(
|
||||
registry.is_browser_registered("wayfern", "142.0"),
|
||||
"the registry entry must survive too, the version is still being installed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_browser_registered_vs_downloaded() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
@@ -879,6 +879,17 @@ pub fn is_downloading(browser: &str, version: &str) -> bool {
|
||||
downloading.contains(&download_key)
|
||||
}
|
||||
|
||||
/// Test-only: mark a browser-version pair as in flight so guards that consult
|
||||
/// `is_downloading` can be exercised without running a real download. Clear it
|
||||
/// again with `clear_download_state_for_browser`.
|
||||
#[cfg(test)]
|
||||
pub fn mark_downloading_for_test(browser: &str, version: &str) {
|
||||
DOWNLOADING_BROWSERS
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(format!("{browser}-{version}"));
|
||||
}
|
||||
|
||||
/// Clear all in-progress download bookkeeping for a browser.
|
||||
///
|
||||
/// Used as a last-resort cleanup when a download future is abandoned (e.g. dropped
|
||||
|
||||
@@ -86,6 +86,62 @@ fn find_zip_start(data: &[u8]) -> usize {
|
||||
0
|
||||
}
|
||||
|
||||
/// Read and parse an extension archive's `manifest.json`. Handles the CRX3
|
||||
/// header by seeking to the embedded ZIP. Shared with
|
||||
/// `vpn_extension_detect`, which classifies from the raw manifest rather than
|
||||
/// from the metadata subset persisted on `Extension`.
|
||||
pub(crate) fn read_manifest_from_archive(
|
||||
file_data: &[u8],
|
||||
file_type: &str,
|
||||
) -> Option<serde_json::Value> {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut contents = String::new();
|
||||
{
|
||||
let mut file = archive.by_name("manifest.json").ok()?;
|
||||
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
|
||||
}
|
||||
serde_json::from_str(&contents).ok()
|
||||
}
|
||||
|
||||
/// Resolve a `__MSG_key__` placeholder against the archive's default locale
|
||||
/// messages. Chromium extensions routinely localize `name`/`description`, and
|
||||
/// showing the raw placeholder in a warning dialog reads as a bug.
|
||||
pub(crate) fn resolve_archive_i18n(
|
||||
file_data: &[u8],
|
||||
file_type: &str,
|
||||
manifest: &serde_json::Value,
|
||||
value: &str,
|
||||
) -> Option<String> {
|
||||
let key = crate::vpn_extension_detect::message_placeholder_key(value)?;
|
||||
let default_locale = manifest.get("default_locale")?.as_str()?;
|
||||
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let cursor = std::io::Cursor::new(file_data.get(zip_start..)?);
|
||||
let mut archive = zip::ZipArchive::new(cursor).ok()?;
|
||||
|
||||
let mut contents = String::new();
|
||||
{
|
||||
let mut file = archive
|
||||
.by_name(&format!("_locales/{default_locale}/messages.json"))
|
||||
.ok()?;
|
||||
std::io::Read::read_to_string(&mut file, &mut contents).ok()?;
|
||||
}
|
||||
let messages: serde_json::Value = serde_json::from_str(&contents).ok()?;
|
||||
crate::vpn_extension_detect::lookup_message(&messages, &key)
|
||||
}
|
||||
|
||||
#[allow(clippy::type_complexity)]
|
||||
fn extract_manifest_metadata(
|
||||
file_data: &[u8],
|
||||
@@ -97,39 +153,11 @@ fn extract_manifest_metadata(
|
||||
Option<String>,
|
||||
Option<String>,
|
||||
) {
|
||||
let zip_start = if file_type == "crx" {
|
||||
find_zip_start(file_data)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
|
||||
let cursor = std::io::Cursor::new(&file_data[zip_start..]);
|
||||
let mut archive = match zip::ZipArchive::new(cursor) {
|
||||
Ok(a) => a,
|
||||
Err(_) => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let manifest_content = if let Ok(mut file) = archive.by_name("manifest.json") {
|
||||
let mut contents = String::new();
|
||||
if std::io::Read::read_to_string(&mut file, &mut contents).is_ok() {
|
||||
Some(contents)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let manifest_content = match manifest_content {
|
||||
Some(c) => c,
|
||||
let manifest = match read_manifest_from_archive(file_data, file_type) {
|
||||
Some(v) => v,
|
||||
None => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let manifest: serde_json::Value = match serde_json::from_str(&manifest_content) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return (None, None, None, None, None),
|
||||
};
|
||||
|
||||
let name = manifest
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
//! Launch-time consistency check: resolve the proxy's exit IP, geolocate it
|
||||
//! with the bundled MaxMind database (the same source the fingerprint generator
|
||||
//! uses), then compare its timezone and country against the profile
|
||||
//! fingerprint's timezone and language. A mismatch (e.g. a US fingerprint
|
||||
//! behind a German exit IP) is a strong anti-bot tell even though the real
|
||||
//! device never leaks — so we warn the user after launch and offer to match the
|
||||
//! fingerprint to the exit. Launches never rewrite the fingerprint silently, so
|
||||
//! a real mismatch always surfaces here.
|
||||
//! Measures a proxy's exit node and compares it to a profile's fingerprint.
|
||||
//!
|
||||
//! Resolve the exit IP through the upstream, geolocate it with the bundled
|
||||
//! MaxMind database (the same source the fingerprint generator uses), then
|
||||
//! compare its timezone and country against the fingerprint's timezone and
|
||||
//! language. A mismatch (e.g. a US fingerprint behind a German exit IP) is a
|
||||
//! strong anti-bot tell even though the real device never leaks.
|
||||
//!
|
||||
//! This module only measures. Deciding what a mismatch *means* for a launch —
|
||||
//! block, warn, or ignore — belongs to `launch_gate`, which calls
|
||||
//! `probe_and_check_consistency` before the browser is spawned. Launches never
|
||||
//! rewrite the fingerprint silently, so a real mismatch always surfaces.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -19,20 +23,68 @@ use crate::proxy_manager::PROXY_MANAGER;
|
||||
/// on every launch is wasteful.
|
||||
const EXIT_CACHE_TTL_SECS: u64 = 30 * 60;
|
||||
|
||||
/// Ceiling on a single exit probe. `fetch_public_ip` races six endpoints with
|
||||
/// a 10s timeout each, which is fine for a background check but far longer
|
||||
/// than a user will wait staring at a launch that has not started yet.
|
||||
const PROBE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(8);
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedExit {
|
||||
fetched_at: u64,
|
||||
/// The proxy URL this exit was measured through. Editing a stored proxy keeps
|
||||
/// The endpoint this exit was measured through. Editing a stored proxy keeps
|
||||
/// its id, so without this an entry outlives the endpoint it describes: the
|
||||
/// check would compare a re-generated fingerprint against the *old* exit and
|
||||
/// either warn about a correct profile or — worse — call a genuinely
|
||||
/// mismatched one consistent, which is exactly the tell it exists to catch.
|
||||
proxy_url: String,
|
||||
///
|
||||
/// Never a loopback URL: the Xray/VPN workers a launch spins up get a fresh
|
||||
/// random port and credentials each time, so keying on those would miss on
|
||||
/// every relaunch and re-probe forever.
|
||||
identity: String,
|
||||
timezone: Option<String>,
|
||||
country_code: Option<String>,
|
||||
ip: Option<String>,
|
||||
}
|
||||
|
||||
/// Identity of the exit a profile routes through, stable across worker
|
||||
/// restarts.
|
||||
///
|
||||
/// `scope` keys the cache; `identity` detects that the endpoint behind that
|
||||
/// key changed. Cloud-derived proxies inject a per-profile sticky-session id,
|
||||
/// so two profiles sharing one stored proxy correctly get different identities
|
||||
/// and never inherit each other's verdict.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExitCacheKey {
|
||||
pub scope: String,
|
||||
pub identity: String,
|
||||
}
|
||||
|
||||
/// Resolve the cache identity from the profile's *stored* configuration.
|
||||
///
|
||||
/// Deliberately not derived from the normalized upstream the launcher passes
|
||||
/// to the probe: for VLESS and VPN that upstream is a loopback worker whose
|
||||
/// port and credentials are regenerated per launch.
|
||||
pub fn exit_cache_key(profile: &BrowserProfile) -> Option<ExitCacheKey> {
|
||||
if let Some(proxy_id) = &profile.proxy_id {
|
||||
let settings = PROXY_MANAGER
|
||||
.resolve_proxy_for_profile(proxy_id, &profile.id.to_string())
|
||||
.or_else(|| PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))?;
|
||||
// build_proxy_url returns the VLESS URI verbatim for vless proxies, so one
|
||||
// call covers every transport.
|
||||
return Some(ExitCacheKey {
|
||||
scope: format!("proxy:{proxy_id}"),
|
||||
identity: crate::proxy_manager::ProxyManager::build_proxy_url(&settings),
|
||||
});
|
||||
}
|
||||
if let Some(vpn_id) = &profile.vpn_id {
|
||||
return Some(ExitCacheKey {
|
||||
scope: format!("vpn:{vpn_id}"),
|
||||
identity: vpn_id.clone(),
|
||||
});
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EXIT_CACHE: Mutex<HashMap<String, CachedExit>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
@@ -54,7 +106,7 @@ pub struct ConsistencyResult {
|
||||
}
|
||||
|
||||
impl ConsistencyResult {
|
||||
fn skip() -> Self {
|
||||
pub fn skip() -> Self {
|
||||
Self {
|
||||
consistent: true,
|
||||
checked: false,
|
||||
@@ -68,18 +120,15 @@ impl ConsistencyResult {
|
||||
}
|
||||
}
|
||||
|
||||
/// URL for handing this proxy to reqwest. VLESS is reached through the
|
||||
/// authenticated loopback Xray-core worker already serving the profile.
|
||||
fn proxy_url(settings: &crate::browser::ProxySettings, profile_id: Option<&str>) -> Option<String> {
|
||||
/// Whether this upstream can carry a probe request at all.
|
||||
///
|
||||
/// Shadowsocks and anything else reqwest cannot dial directly is skipped
|
||||
/// rather than guessed at.
|
||||
fn probe_url(settings: &crate::browser::ProxySettings) -> Option<String> {
|
||||
match settings.proxy_type.to_lowercase().as_str() {
|
||||
"http" | "https" | "socks4" | "socks5" => Some(
|
||||
crate::proxy_manager::ProxyManager::build_proxy_url(settings),
|
||||
crate::proxy_manager::ProxyManager::build_probe_proxy_url(settings),
|
||||
),
|
||||
"vless" => profile_id
|
||||
.and_then(crate::xray_worker_storage::find_xray_worker_by_profile_id)
|
||||
.map(|worker| {
|
||||
crate::proxy_manager::ProxyManager::build_proxy_url(&worker.local_proxy_settings())
|
||||
}),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -119,120 +168,174 @@ fn fingerprint_locale(profile: &BrowserProfile) -> (Option<String>, Option<Strin
|
||||
(timezone, language)
|
||||
}
|
||||
|
||||
/// Run the check for a profile. No-ops (consistent, unchecked) when the
|
||||
/// profile has no proxy or the exit node can't be reached.
|
||||
pub async fn check_profile_consistency(
|
||||
/// A mutex whose poison is not fatal.
|
||||
///
|
||||
/// A panic anywhere under this lock used to brick the check process-wide.
|
||||
/// That was tolerable when a failed check only skipped a warning; now a launch
|
||||
/// consults it, so a poisoned lock must degrade rather than propagate.
|
||||
fn exit_cache() -> std::sync::MutexGuard<'static, HashMap<String, CachedExit>> {
|
||||
EXIT_CACHE.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// Compare a measured exit against a profile's fingerprint. Pure — no I/O.
|
||||
pub fn compare_exit_to_fingerprint(
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<ConsistencyResult, String> {
|
||||
let Some(proxy_id) = &profile.proxy_id else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let Some(settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let profile_id = profile.id.to_string();
|
||||
let Some(url) = proxy_url(&settings, Some(&profile_id)) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let cache_identity = if settings.proxy_type.eq_ignore_ascii_case("vless") {
|
||||
settings.vless_uri.clone().unwrap_or_else(|| url.clone())
|
||||
} else {
|
||||
url.clone()
|
||||
};
|
||||
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
|
||||
// Serve a fresh cached exit lookup for this proxy if we have one, but only if
|
||||
// it was measured through the proxy's current endpoint and credentials.
|
||||
let cached = {
|
||||
let cache = EXIT_CACHE.lock().unwrap();
|
||||
cache
|
||||
.get(proxy_id)
|
||||
.filter(|c| {
|
||||
c.proxy_url == cache_identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
|
||||
})
|
||||
.cloned()
|
||||
};
|
||||
|
||||
let (exit_tz, exit_cc, exit_ip) = if let Some(c) = cached {
|
||||
(c.timezone, c.country_code, c.ip)
|
||||
} else {
|
||||
// Resolve the exit IP through the proxy, then geolocate it with the SAME
|
||||
// bundled MaxMind database the fingerprint generator (and the on-demand
|
||||
// match) use. Using one geo source everywhere means the check can never
|
||||
// disagree with what generation produced — a second source (e.g. ip-api)
|
||||
// routinely reports a different IANA zone for the same IP in multi-zone
|
||||
// countries, which would flag correctly-generated fingerprints and would
|
||||
// leave the "match to proxy" fix unable to satisfy the check.
|
||||
let exit_ip = crate::ip_utils::fetch_public_ip(Some(&url))
|
||||
.await
|
||||
.map_err(|e| format!("exit-node lookup failed: {e}"))?;
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => {
|
||||
let tz = Some(geo.timezone);
|
||||
let cc = geo.locale.region.clone();
|
||||
let ip = Some(exit_ip);
|
||||
EXIT_CACHE.lock().unwrap().insert(
|
||||
proxy_id.clone(),
|
||||
CachedExit {
|
||||
fetched_at: now,
|
||||
proxy_url: cache_identity,
|
||||
timezone: tz.clone(),
|
||||
country_code: cc.clone(),
|
||||
ip: ip.clone(),
|
||||
},
|
||||
);
|
||||
(tz, cc, ip)
|
||||
}
|
||||
// Reached the exit but couldn't place it (database missing, or a private
|
||||
// exit IP). Skip rather than warn on an unknown location — the same
|
||||
// database gates fingerprint geo, so there's nothing to disagree with.
|
||||
Err(e) => {
|
||||
log::debug!("Consistency check: could not geolocate exit IP: {e}");
|
||||
return Ok(ConsistencyResult::skip());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exit_timezone: Option<String>,
|
||||
exit_country_code: Option<String>,
|
||||
exit_ip: Option<String>,
|
||||
) -> ConsistencyResult {
|
||||
let (fp_tz, fp_lang) = fingerprint_locale(profile);
|
||||
let mut mismatches = Vec::new();
|
||||
|
||||
if let (Some(exit), Some(fp)) = (&exit_tz, &fp_tz) {
|
||||
if let (Some(exit), Some(fp)) = (&exit_timezone, &fp_tz) {
|
||||
if !exit.eq_ignore_ascii_case(fp) {
|
||||
mismatches.push("timezone".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let (Some(cc), Some(lang)) = (&exit_cc, &fp_lang) {
|
||||
if let (Some(cc), Some(lang)) = (&exit_country_code, &fp_lang) {
|
||||
if language_matches_country(cc, lang) == Some(false) {
|
||||
mismatches.push("language".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(ConsistencyResult {
|
||||
ConsistencyResult {
|
||||
consistent: mismatches.is_empty(),
|
||||
checked: true,
|
||||
exit_ip,
|
||||
exit_country_code: exit_cc,
|
||||
exit_timezone: exit_tz,
|
||||
exit_country_code,
|
||||
exit_timezone,
|
||||
fingerprint_timezone: fp_tz,
|
||||
fingerprint_language: fp_lang,
|
||||
mismatches,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_profile_fingerprint_consistency(
|
||||
profile_id: String,
|
||||
/// Look up a still-valid cached exit for this profile.
|
||||
fn cached_exit(key: &ExitCacheKey) -> Option<CachedExit> {
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
exit_cache()
|
||||
.get(&key.scope)
|
||||
.filter(|c| {
|
||||
c.identity == key.identity && now.saturating_sub(c.fetched_at) < EXIT_CACHE_TTL_SECS
|
||||
})
|
||||
.cloned()
|
||||
}
|
||||
|
||||
/// Cache-only check. Never performs I/O, so it is safe to call before a launch
|
||||
/// and for every profile in a bulk run. Returns an unchecked result on a miss.
|
||||
pub fn check_profile_consistency_cached(profile: &BrowserProfile) -> ConsistencyResult {
|
||||
let Some(key) = exit_cache_key(profile) else {
|
||||
return ConsistencyResult::skip();
|
||||
};
|
||||
let Some(cached) = cached_exit(&key) else {
|
||||
return ConsistencyResult::skip();
|
||||
};
|
||||
compare_exit_to_fingerprint(profile, cached.timezone, cached.country_code, cached.ip)
|
||||
}
|
||||
|
||||
/// Drop any cached exit for this profile, so the next check re-measures.
|
||||
pub fn invalidate_exit_cache(profile: &BrowserProfile) {
|
||||
if let Some(key) = exit_cache_key(profile) {
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the exit through an already-normalized upstream and compare it to
|
||||
/// the fingerprint.
|
||||
///
|
||||
/// `upstream` is what the launcher will actually hand the browser — a loopback
|
||||
/// worker for VLESS and VPN, the resolved endpoint for a stored proxy — so one
|
||||
/// code path covers every transport. `None` means a genuine direct connection,
|
||||
/// which has nothing to disagree with.
|
||||
pub async fn probe_and_check_consistency(
|
||||
profile: &BrowserProfile,
|
||||
upstream: Option<&crate::browser::ProxySettings>,
|
||||
key: &ExitCacheKey,
|
||||
) -> Result<ConsistencyResult, String> {
|
||||
let profiles = crate::profile::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| e.to_string())?;
|
||||
let profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| serde_json::json!({ "code": "PROFILE_NOT_FOUND" }).to_string())?;
|
||||
check_profile_consistency(&profile).await
|
||||
if let Some(cached) = cached_exit(key) {
|
||||
return Ok(compare_exit_to_fingerprint(
|
||||
profile,
|
||||
cached.timezone,
|
||||
cached.country_code,
|
||||
cached.ip,
|
||||
));
|
||||
}
|
||||
|
||||
let Some(settings) = upstream else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
let Some(url) = probe_url(settings) else {
|
||||
return Ok(ConsistencyResult::skip());
|
||||
};
|
||||
|
||||
// Resolve the exit IP through the proxy, then geolocate it with the SAME
|
||||
// bundled MaxMind database the fingerprint generator (and the on-demand
|
||||
// match) use. Using one geo source everywhere means the check can never
|
||||
// disagree with what generation produced — a second source (e.g. ip-api)
|
||||
// routinely reports a different IANA zone for the same IP in multi-zone
|
||||
// countries, which would flag correctly-generated fingerprints and would
|
||||
// leave the "match to proxy" fix unable to satisfy the check.
|
||||
//
|
||||
// Bounded independently of fetch_public_ip's own per-request timeout: that
|
||||
// one races six endpoints and can add up to far longer than a user will wait
|
||||
// in front of a launch.
|
||||
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(Some(&url)))
|
||||
.await
|
||||
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
|
||||
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
|
||||
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => {
|
||||
let tz = Some(geo.timezone);
|
||||
let cc = geo.locale.region.clone();
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs(),
|
||||
identity: key.identity.clone(),
|
||||
timezone: tz.clone(),
|
||||
country_code: cc.clone(),
|
||||
ip: Some(exit_ip.clone()),
|
||||
},
|
||||
);
|
||||
Ok(compare_exit_to_fingerprint(profile, tz, cc, Some(exit_ip)))
|
||||
}
|
||||
// Reached the exit but couldn't place it (database missing, or a private
|
||||
// exit IP). Skip rather than warn on an unknown location — the same
|
||||
// database gates fingerprint geo, so there's nothing to disagree with.
|
||||
Err(e) => {
|
||||
log::debug!("Consistency check: could not geolocate exit IP: {e}");
|
||||
Ok(ConsistencyResult::skip())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Measure the exit this machine reaches without any proxy, and compare it to
|
||||
/// the fingerprint.
|
||||
///
|
||||
/// Used when a profile declares a route that did not materialize: the browser
|
||||
/// is about to connect directly, so the direct exit is the one that matters.
|
||||
/// Deliberately NOT cached — a direct exit belongs to this machine's network,
|
||||
/// not to any stored proxy, and it changes without a config edit.
|
||||
pub async fn probe_direct_and_check(profile: &BrowserProfile) -> Result<ConsistencyResult, String> {
|
||||
let fetched = tokio::time::timeout(PROBE_TIMEOUT, crate::ip_utils::fetch_public_ip(None))
|
||||
.await
|
||||
.map_err(|_| crate::backend_error("EXIT_PROBE_FAILED"))?;
|
||||
let exit_ip = fetched.map_err(|e| crate::backend_error_with_detail("EXIT_PROBE_FAILED", e))?;
|
||||
|
||||
match crate::geolocation::get_geolocation(&exit_ip) {
|
||||
Ok(geo) => Ok(compare_exit_to_fingerprint(
|
||||
profile,
|
||||
Some(geo.timezone),
|
||||
geo.locale.region.clone(),
|
||||
Some(exit_ip),
|
||||
)),
|
||||
Err(e) => {
|
||||
log::debug!("Consistency check: could not geolocate direct exit IP: {e}");
|
||||
Ok(ConsistencyResult::skip())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rewrite a profile's stored fingerprint so its geolocation (timezone,
|
||||
@@ -280,6 +383,10 @@ pub async fn match_profile_fingerprint_to_exit(
|
||||
.to_string()
|
||||
})?;
|
||||
|
||||
// The stored verdict was computed against the fingerprint we just rewrote.
|
||||
// Leaving it would re-block the very launch this fix exists to unblock.
|
||||
invalidate_exit_cache(&profile);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -344,55 +451,218 @@ mod tests {
|
||||
assert!(language_matches_country("CH", "de-CH").is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn proxy_url_percent_encodes_credentials_and_skips_shadowsocks() {
|
||||
let http = crate::browser::ProxySettings {
|
||||
proxy_type: "http".into(),
|
||||
host: "h".into(),
|
||||
fn settings(
|
||||
proxy_type: &str,
|
||||
user: Option<&str>,
|
||||
pass: Option<&str>,
|
||||
) -> crate::browser::ProxySettings {
|
||||
crate::browser::ProxySettings {
|
||||
proxy_type: proxy_type.into(),
|
||||
host: "gw.provider.io".into(),
|
||||
port: 8080,
|
||||
username: Some("u".into()),
|
||||
password: Some("p".into()),
|
||||
username: user.map(str::to_string),
|
||||
password: pass.map(str::to_string),
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(proxy_url(&http, None).as_deref(), Some("http://u:p@h:8080"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_url_percent_encodes_credentials_and_skips_shadowsocks() {
|
||||
assert_eq!(
|
||||
probe_url(&settings("http", Some("u"), Some("p"))).as_deref(),
|
||||
Some("http://u:p@gw.provider.io:8080")
|
||||
);
|
||||
|
||||
// A password with URL-reserved characters must not break the authority —
|
||||
// unencoded, the `/` truncates the host and reqwest targets `u` instead.
|
||||
let reserved = crate::browser::ProxySettings {
|
||||
proxy_type: "http".into(),
|
||||
host: "gw.provider.io".into(),
|
||||
port: 8080,
|
||||
username: Some("user".into()),
|
||||
password: Some("ab/cd@ef".into()),
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(
|
||||
proxy_url(&reserved, None).as_deref(),
|
||||
probe_url(&settings("http", Some("user"), Some("ab/cd@ef"))).as_deref(),
|
||||
Some("http://user:ab%2Fcd%40ef@gw.provider.io:8080")
|
||||
);
|
||||
|
||||
// Username-only proxies keep their auth.
|
||||
let user_only = crate::browser::ProxySettings {
|
||||
proxy_type: "socks5".into(),
|
||||
host: "h".into(),
|
||||
port: 1080,
|
||||
username: Some("justuser".into()),
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
};
|
||||
assert_eq!(
|
||||
proxy_url(&user_only, None).as_deref(),
|
||||
Some("socks5://justuser@h:1080")
|
||||
probe_url(&settings("socks4", Some("justuser"), None)).as_deref(),
|
||||
Some("socks4://justuser@gw.provider.io:8080")
|
||||
);
|
||||
|
||||
let ss = crate::browser::ProxySettings {
|
||||
proxy_type: "ss".into(),
|
||||
host: "h".into(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
// Shadowsocks cannot carry a reqwest probe, so it is skipped rather than
|
||||
// guessed at.
|
||||
assert_eq!(probe_url(&settings("ss", None, None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn probe_url_uses_socks5h_so_dns_resolves_at_the_exit() {
|
||||
let url = probe_url(&settings("socks5", Some("u"), Some("p"))).unwrap();
|
||||
assert!(
|
||||
url.starts_with("socks5h://"),
|
||||
"probe must not resolve the echo host locally, got {url}"
|
||||
);
|
||||
// The browser-facing builder is deliberately left alone.
|
||||
assert!(
|
||||
crate::proxy_manager::ProxyManager::build_proxy_url(&settings(
|
||||
"socks5",
|
||||
Some("u"),
|
||||
Some("p")
|
||||
))
|
||||
.starts_with("socks5://")
|
||||
);
|
||||
}
|
||||
|
||||
fn profile_with_fingerprint(timezone: &str, language: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "p".into(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(proxy_url(&ss, None), None);
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(
|
||||
serde_json::json!({ "timezone": timezone, "language": language }).to_string(),
|
||||
),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_flags_a_timezone_mismatch() {
|
||||
let profile = profile_with_fingerprint("America/New_York", "en-US");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
Some("1.2.3.4".into()),
|
||||
);
|
||||
assert!(result.checked);
|
||||
assert!(!result.consistent);
|
||||
assert!(result.mismatches.contains(&"timezone".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_accepts_a_matching_exit() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
Some("1.2.3.4".into()),
|
||||
);
|
||||
assert!(result.consistent, "{:?}", result.mismatches);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_is_case_insensitive_on_timezone() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("europe/berlin".into()),
|
||||
Some("DE".into()),
|
||||
None,
|
||||
);
|
||||
assert!(result.consistent);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compare_skips_dimensions_the_fingerprint_does_not_declare() {
|
||||
// A profile with no fingerprint has nothing to contradict; it must not be
|
||||
// reported as a mismatch and so must never block a launch.
|
||||
let profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let result = compare_exit_to_fingerprint(
|
||||
&profile,
|
||||
Some("Europe/Berlin".into()),
|
||||
Some("DE".into()),
|
||||
None,
|
||||
);
|
||||
assert!(result.consistent);
|
||||
assert!(result.mismatches.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_check_reports_unchecked_without_a_proxy_or_vpn() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
let result = check_profile_consistency_cached(&profile);
|
||||
assert!(!result.checked);
|
||||
assert!(result.consistent, "an unchecked profile must never block");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_key_is_absent_without_a_proxy_or_vpn() {
|
||||
let profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
assert_eq!(exit_cache_key(&profile), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_key_scopes_a_vpn_profile_by_vpn_id() {
|
||||
let mut profile = profile_with_fingerprint("Europe/Berlin", "de-DE");
|
||||
profile.vpn_id = Some("vpn-abc".into());
|
||||
let key = exit_cache_key(&profile).expect("vpn profiles must be cacheable");
|
||||
assert_eq!(key.scope, "vpn:vpn-abc");
|
||||
assert_eq!(key.identity, "vpn-abc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_entry_is_ignored_once_the_endpoint_identity_changes() {
|
||||
let key = ExitCacheKey {
|
||||
scope: "proxy:test-identity-change".into(),
|
||||
identity: "http://old@host:1".into(),
|
||||
};
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs(),
|
||||
identity: key.identity.clone(),
|
||||
timezone: Some("Europe/Berlin".into()),
|
||||
country_code: Some("DE".into()),
|
||||
ip: Some("1.2.3.4".into()),
|
||||
},
|
||||
);
|
||||
assert!(cached_exit(&key).is_some());
|
||||
|
||||
// Editing a stored proxy keeps its id but changes the endpoint; the old
|
||||
// measurement must not be reused for the new one.
|
||||
let rotated = ExitCacheKey {
|
||||
identity: "http://new@host:2".into(),
|
||||
..key.clone()
|
||||
};
|
||||
assert!(cached_exit(&rotated).is_none());
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cached_entry_expires_after_the_ttl() {
|
||||
let key: ExitCacheKey = ExitCacheKey {
|
||||
scope: "proxy:test-ttl".into(),
|
||||
identity: "http://host:1".into(),
|
||||
};
|
||||
exit_cache().insert(
|
||||
key.scope.clone(),
|
||||
CachedExit {
|
||||
fetched_at: crate::proxy_manager::now_secs() - EXIT_CACHE_TTL_SECS - 1,
|
||||
identity: key.identity.clone(),
|
||||
timezone: Some("Europe/Berlin".into()),
|
||||
country_code: Some("DE".into()),
|
||||
ip: None,
|
||||
},
|
||||
);
|
||||
assert!(cached_exit(&key).is_none());
|
||||
exit_cache().remove(&key.scope);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exit_cache_survives_a_poisoned_lock() {
|
||||
// A panic under this lock must degrade the check, not brick every
|
||||
// subsequent launch that consults it.
|
||||
let _ = std::thread::spawn(|| {
|
||||
let _guard = EXIT_CACHE.lock().unwrap();
|
||||
panic!("poison the cache");
|
||||
})
|
||||
.join();
|
||||
assert!(EXIT_CACHE.is_poisoned());
|
||||
exit_cache().remove("nonexistent-scope");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
//! The pre-spawn launch gate.
|
||||
//!
|
||||
//! Two findings can stop a launch being what the user expects:
|
||||
//!
|
||||
//! * a **VPN/proxy extension** in the profile, which can override the proxy
|
||||
//! Donut configured and silently move the browser's exit away from the one
|
||||
//! the fingerprint was generated for — a warning, since Donut cannot tell
|
||||
//! from outside whether it is actually routing anything;
|
||||
//! * a measured **exit/fingerprint mismatch**, which is a hard block: the
|
||||
//! browser does not start until the user explicitly proceeds.
|
||||
//!
|
||||
//! The enforcing half runs inside `browser_runner::launch_browser_internal`,
|
||||
//! after the upstream has been normalized (so VLESS and VPN profiles are
|
||||
//! reachable at all) and before the local proxy starts or the browser spawns.
|
||||
//! `get_profile_pre_launch_checks` is the cheap, local-only half the UI calls
|
||||
//! first, so a profile whose exit is already known blocks without starting a
|
||||
//! single worker.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::fingerprint_consistency::{self, ConsistencyResult};
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::vpn_extension_detect::{self, DetectedVpnExtension};
|
||||
|
||||
/// How long a "launch anyway" decision stays redeemable. Long enough to read
|
||||
/// the dialog, short enough that a token cannot sit around across a session.
|
||||
const CONSENT_TTL_SECS: u64 = 10 * 60;
|
||||
|
||||
/// What the gate is allowed to do on this launch.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum FingerprintGate {
|
||||
/// Block on a measured mismatch. The default, and what the GUI uses.
|
||||
#[default]
|
||||
Enforce,
|
||||
/// Measure only from cache and report; never block, never probe the network.
|
||||
/// Automation runs here: a headless client has no dialog to answer and
|
||||
/// cannot regenerate its fingerprint mid-run, so a hard failure would turn a
|
||||
/// warning into an outage for a whole fleet.
|
||||
Advisory,
|
||||
/// The user already said "launch anyway" and handed back a token.
|
||||
Consented(String),
|
||||
}
|
||||
|
||||
struct PendingConsent {
|
||||
profile_id: String,
|
||||
fingerprint_hash: String,
|
||||
exit_identity: String,
|
||||
issued_at: u64,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref CONSENTS: Mutex<HashMap<String, PendingConsent>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
fn consents() -> std::sync::MutexGuard<'static, HashMap<String, PendingConsent>> {
|
||||
CONSENTS.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn random_token() -> String {
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
let mut bytes = [0u8; 16];
|
||||
rng.fill_bytes(&mut bytes);
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Issue a single-use token authorizing one launch of this exact
|
||||
/// (profile, fingerprint, exit) combination.
|
||||
///
|
||||
/// A plain `bypass: bool` cannot express this: a "proceed" the user granted
|
||||
/// while looking at proxy A would silently authorize a launch through proxy B
|
||||
/// if they changed it before the retry landed.
|
||||
pub fn mint_consent(profile: &BrowserProfile, exit_identity: &str) -> String {
|
||||
let token = random_token();
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
let mut store = consents();
|
||||
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
|
||||
store.insert(
|
||||
token.clone(),
|
||||
PendingConsent {
|
||||
profile_id: profile.id.to_string(),
|
||||
fingerprint_hash: crate::launch_gate_prefs::fingerprint_hash(profile),
|
||||
exit_identity: exit_identity.to_string(),
|
||||
issued_at: now,
|
||||
},
|
||||
);
|
||||
token
|
||||
}
|
||||
|
||||
/// Redeem a consent token. Single use — a redeemed token is removed whether or
|
||||
/// not it validated, so a leaked token cannot be replayed.
|
||||
pub fn redeem_consent(
|
||||
token: &str,
|
||||
profile: &BrowserProfile,
|
||||
exit_identity: &str,
|
||||
) -> Result<(), String> {
|
||||
let now = crate::proxy_manager::now_secs();
|
||||
let pending = {
|
||||
let mut store = consents();
|
||||
store.retain(|_, c| now.saturating_sub(c.issued_at) < CONSENT_TTL_SECS);
|
||||
store.remove(token)
|
||||
};
|
||||
|
||||
let Some(pending) = pending else {
|
||||
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
|
||||
};
|
||||
if pending.profile_id != profile.id.to_string()
|
||||
|| pending.fingerprint_hash != crate::launch_gate_prefs::fingerprint_hash(profile)
|
||||
|| pending.exit_identity != exit_identity
|
||||
{
|
||||
return Err(crate::backend_error("LAUNCH_CONSENT_EXPIRED"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn mismatch_error(result: &ConsistencyResult, token: &str) -> String {
|
||||
serde_json::json!({
|
||||
"code": "FINGERPRINT_EXIT_MISMATCH",
|
||||
"params": {
|
||||
"token": token,
|
||||
"exitIp": result.exit_ip.clone().unwrap_or_default(),
|
||||
"exitCountry": result.exit_country_code.clone().unwrap_or_default(),
|
||||
"exitTimezone": result.exit_timezone.clone().unwrap_or_default(),
|
||||
"fingerprintTimezone": result.fingerprint_timezone.clone().unwrap_or_default(),
|
||||
"fingerprintLanguage": result.fingerprint_language.clone().unwrap_or_default(),
|
||||
"mismatches": result.mismatches.join(","),
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn gate_disabled() -> bool {
|
||||
crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|s| s.fingerprint_gate_disabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn extension_warning_disabled() -> bool {
|
||||
crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|s| s.vpn_extension_warning_disabled)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Identity used for consent and acknowledgement when the browser will connect
|
||||
/// directly. Distinct from any proxy identity, so accepting a direct-exit
|
||||
/// mismatch never disarms the gate for a proxied one.
|
||||
const DIRECT_EXIT_IDENTITY: &str = "direct";
|
||||
|
||||
/// Gate a launch that will connect directly despite the profile declaring a
|
||||
/// route. Measures the exit the browser will really use.
|
||||
async fn enforce_direct_exit(
|
||||
profile: &BrowserProfile,
|
||||
gate: &FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
if gate_disabled() {
|
||||
return Ok(());
|
||||
}
|
||||
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, DIRECT_EXIT_IDENTITY) {
|
||||
return Ok(());
|
||||
}
|
||||
if let FingerprintGate::Consented(token) = gate {
|
||||
return redeem_consent(token, profile, DIRECT_EXIT_IDENTITY);
|
||||
}
|
||||
// Automation never probes; without a cache to consult there is nothing to say.
|
||||
if matches!(gate, FingerprintGate::Advisory) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = match fingerprint_consistency::probe_direct_and_check(profile).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Fingerprint gate: direct exit probe failed for profile {}, allowing launch: {e}",
|
||||
profile.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
};
|
||||
if !result.checked || result.consistent {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = mint_consent(profile, DIRECT_EXIT_IDENTITY);
|
||||
Err(mismatch_error(&result, &token))
|
||||
}
|
||||
|
||||
/// The enforcing gate. Called from the launch pipeline once the upstream is
|
||||
/// normalized and before anything expensive or user-visible happens.
|
||||
///
|
||||
/// Fails **open** on every degradation — probe failure, timeout, missing geo
|
||||
/// database, private exit IP. The gate blocks only on a positively measured
|
||||
/// mismatch; a flaky IP-echo endpoint must never make profiles unlaunchable.
|
||||
pub async fn enforce_fingerprint_gate(
|
||||
profile: &BrowserProfile,
|
||||
upstream: Option<&crate::browser::ProxySettings>,
|
||||
gate: &FingerprintGate,
|
||||
) -> Result<(), String> {
|
||||
// A profile that declares no route is genuinely direct: the browser's exit is
|
||||
// this machine, which is what an un-proxied fingerprint should describe.
|
||||
//
|
||||
// But a profile that DOES declare one and still arrives here with no upstream
|
||||
// is about to go direct anyway — a deleted or unresolvable proxy resolves to
|
||||
// `None` and the launch continues. That is the exact leak this gate exists to
|
||||
// stop, so it must be measured, not waved through.
|
||||
let declares_route = profile.proxy_id.is_some() || profile.vpn_id.is_some();
|
||||
if upstream.is_none() && !declares_route {
|
||||
return Ok(());
|
||||
}
|
||||
if gate_disabled() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Decide *once*, before any consent handling, whether this launch is going
|
||||
// out directly. Both a route that no longer resolves (deleted proxy) and one
|
||||
// that produced no usable upstream (a VPN worker with no local port) end up
|
||||
// connecting directly, and both must mint and redeem consent under the same
|
||||
// identity — splitting that decision across the function meant the first
|
||||
// attempt minted under "direct" while the retry redeemed against the proxy
|
||||
// identity, so "Launch anyway" could never succeed.
|
||||
let key = fingerprint_consistency::exit_cache_key(profile);
|
||||
if key.is_none() || upstream.is_none() {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} declares a proxy/VPN that yielded no usable upstream; \
|
||||
measuring the direct exit it will actually use",
|
||||
profile.name
|
||||
);
|
||||
return enforce_direct_exit(profile, gate).await;
|
||||
}
|
||||
let key = key.expect("checked above");
|
||||
|
||||
// Ack first: a persisted acknowledgement already permits this launch, so a
|
||||
// stale token must not turn it into a hard failure.
|
||||
if crate::launch_gate_prefs::fingerprint_ack_matches(profile, &key.identity) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let FingerprintGate::Consented(token) = gate {
|
||||
redeem_consent(token, profile, &key.identity)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let result = if matches!(gate, FingerprintGate::Advisory) {
|
||||
// Automation: answer from a warm cache or say nothing. Probing here would
|
||||
// add seconds to every profile in a batch run.
|
||||
fingerprint_consistency::check_profile_consistency_cached(profile)
|
||||
} else {
|
||||
match fingerprint_consistency::probe_and_check_consistency(profile, upstream, &key).await {
|
||||
Ok(result) => result,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Fingerprint gate: exit probe failed for profile {}, allowing launch: {e}",
|
||||
profile.name
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if !result.checked || result.consistent {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Automation is the only caller allowed past a measured mismatch, because it
|
||||
// has no dialog to answer. A proxy-capable extension in the profile does NOT
|
||||
// earn the same pass: it makes the measurement less trustworthy, and a route
|
||||
// that might be worse than measured is a reason for more scrutiny, not less.
|
||||
// Waiving the block on it also meant any download manager holding Chromium's
|
||||
// `proxy` permission silently disarmed the gate for good.
|
||||
if matches!(gate, FingerprintGate::Advisory) {
|
||||
log::warn!(
|
||||
"Fingerprint gate: {} launching with a known exit mismatch ({})",
|
||||
profile.name,
|
||||
result.mismatches.join(", ")
|
||||
);
|
||||
if let Err(e) = crate::events::emit("fingerprint-consistency-warning", &result) {
|
||||
log::warn!("Failed to emit fingerprint consistency warning: {e}");
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = mint_consent(profile, &key.identity);
|
||||
Err(mismatch_error(&result, &token))
|
||||
}
|
||||
|
||||
/// Everything the UI needs to decide whether to stop a launch, answered
|
||||
/// without touching the network or starting any worker.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PreLaunchChecks {
|
||||
pub vpn_extensions: Vec<DetectedVpnExtension>,
|
||||
pub scan_state: String,
|
||||
/// Cache-only; `checked` is false when the exit has not been measured yet.
|
||||
pub consistency: ConsistencyResult,
|
||||
/// True when the enforcing gate will still probe during the launch, so the
|
||||
/// UI can say the check is not finished rather than implying it passed.
|
||||
pub exit_probe_pending: bool,
|
||||
/// An extension holding the `proxy` permission is present, so any exit
|
||||
/// measurement describes a route the browser may not take. Informational
|
||||
/// only — it never relaxes the block.
|
||||
pub exit_measurement_unreliable: bool,
|
||||
/// Present only when a cached mismatch is already blocking, so "launch
|
||||
/// anyway" can proceed without a second round trip.
|
||||
pub consent_token: Option<String>,
|
||||
}
|
||||
|
||||
fn load_profile(profile_id: &str) -> Result<BrowserProfile, String> {
|
||||
crate::profile::ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| crate::backend_error("PROFILE_NOT_FOUND"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_pre_launch_checks(profile_id: String) -> Result<PreLaunchChecks, String> {
|
||||
let profile = load_profile(&profile_id)?;
|
||||
|
||||
// The setting suppresses the extension report entirely, which is safe
|
||||
// precisely because nothing enforcing depends on it: the scan feeds the
|
||||
// dialog's warning and the "measurement may be unreliable" note, never the
|
||||
// decision to block.
|
||||
let scan = if extension_warning_disabled() {
|
||||
vpn_extension_detect::ExtensionScan {
|
||||
extensions: Vec::new(),
|
||||
scan_state: "scanned".to_string(),
|
||||
}
|
||||
} else {
|
||||
vpn_extension_detect::scan_profile(&profile)
|
||||
};
|
||||
|
||||
// Drop anything the user has already acknowledged for this profile, so the
|
||||
// dialog only ever opens for something new.
|
||||
let vpn_extensions: Vec<DetectedVpnExtension> = scan
|
||||
.extensions
|
||||
.iter()
|
||||
.filter(|e| {
|
||||
!crate::launch_gate_prefs::extensions_acked(&profile_id, std::slice::from_ref(&e.key))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
let exit_measurement_unreliable = vpn_extension_detect::has_proxy_control(&scan);
|
||||
|
||||
let disabled = gate_disabled();
|
||||
let key = fingerprint_consistency::exit_cache_key(&profile);
|
||||
|
||||
let consistency = if disabled {
|
||||
ConsistencyResult::skip()
|
||||
} else {
|
||||
fingerprint_consistency::check_profile_consistency_cached(&profile)
|
||||
};
|
||||
|
||||
let already_acked = key
|
||||
.as_ref()
|
||||
.is_some_and(|k| crate::launch_gate_prefs::fingerprint_ack_matches(&profile, &k.identity));
|
||||
|
||||
let blocking = consistency.checked && !consistency.consistent && !already_acked;
|
||||
let consent_token = match (&key, blocking) {
|
||||
(Some(k), true) => Some(mint_consent(&profile, &k.identity)),
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Ok(PreLaunchChecks {
|
||||
vpn_extensions,
|
||||
scan_state: scan.scan_state,
|
||||
consistency: if blocking {
|
||||
consistency
|
||||
} else {
|
||||
ConsistencyResult::skip()
|
||||
},
|
||||
exit_probe_pending: !disabled && !already_acked && key.is_some() && !blocking,
|
||||
exit_measurement_unreliable,
|
||||
consent_token,
|
||||
})
|
||||
}
|
||||
|
||||
/// Persist "don't ask me again" choices from the gate dialog.
|
||||
#[tauri::command]
|
||||
pub async fn ack_launch_gate(
|
||||
profile_id: String,
|
||||
ack_fingerprint: bool,
|
||||
ack_extension_keys: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let profile = load_profile(&profile_id)?;
|
||||
|
||||
if ack_fingerprint {
|
||||
// Must match the identity the block was issued against. A profile whose
|
||||
// route did not resolve is gated on the direct exit and has no cache key,
|
||||
// so falling back here is what makes "don't block again" stick for it.
|
||||
let identity = fingerprint_consistency::exit_cache_key(&profile)
|
||||
.map(|key| key.identity)
|
||||
.unwrap_or_else(|| DIRECT_EXIT_IDENTITY.to_string());
|
||||
crate::launch_gate_prefs::ack_fingerprint(&profile, &identity);
|
||||
}
|
||||
crate::launch_gate_prefs::ack_extensions(&profile_id, &ack_extension_keys);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile_with(fingerprint: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(fingerprint.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_authorizes_exactly_one_launch() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_ok());
|
||||
// Replaying it must fail, so a leaked token cannot re-authorize.
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_for_a_different_profile() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let other = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &other, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_after_the_fingerprint_changes() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
|
||||
let mut regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
regenerated.id = profile.id;
|
||||
assert!(redeem_consent(&token, ®enerated, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consent_token_is_rejected_after_the_exit_changes() {
|
||||
// The reason a bare `bypass: bool` is not enough: consent granted for one
|
||||
// proxy must not authorize a launch through another.
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
assert!(redeem_consent(&token, &profile, "http://other:2").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_token_is_rejected() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let err = redeem_consent("deadbeef", &profile, "http://gw:1").unwrap_err();
|
||||
assert!(err.contains("LAUNCH_CONSENT_EXPIRED"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expired_tokens_are_swept_and_rejected() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let token = mint_consent(&profile, "http://gw:1");
|
||||
// Back-date it past the TTL.
|
||||
{
|
||||
let mut store = consents();
|
||||
if let Some(pending) = store.get_mut(&token) {
|
||||
pending.issued_at = crate::proxy_manager::now_secs() - CONSENT_TTL_SECS - 1;
|
||||
}
|
||||
}
|
||||
assert!(redeem_consent(&token, &profile, "http://gw:1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mismatch_error_carries_the_details_the_dialog_renders() {
|
||||
let result = ConsistencyResult {
|
||||
consistent: false,
|
||||
checked: true,
|
||||
exit_ip: Some("1.2.3.4".into()),
|
||||
exit_country_code: Some("DE".into()),
|
||||
exit_timezone: Some("Europe/Berlin".into()),
|
||||
fingerprint_timezone: Some("America/New_York".into()),
|
||||
fingerprint_language: Some("en-US".into()),
|
||||
mismatches: vec!["timezone".into(), "language".into()],
|
||||
};
|
||||
let encoded = mismatch_error(&result, "tok");
|
||||
let parsed: serde_json::Value = serde_json::from_str(&encoded).unwrap();
|
||||
assert_eq!(parsed["code"], "FINGERPRINT_EXIT_MISMATCH");
|
||||
assert_eq!(parsed["params"]["token"], "tok");
|
||||
assert_eq!(parsed["params"]["exitTimezone"], "Europe/Berlin");
|
||||
assert_eq!(parsed["params"]["fingerprintTimezone"], "America/New_York");
|
||||
// params values must be strings for the frontend's interpolation.
|
||||
assert_eq!(parsed["params"]["mismatches"], "timezone,language");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_allows_a_direct_connection_without_measuring() {
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert!(
|
||||
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn gate_allows_a_profile_with_no_proxy_or_vpn() {
|
||||
// A profile that declares no route has no upstream either — that pairing is
|
||||
// the only one the launcher can actually produce. It must return without
|
||||
// measuring anything, so this stays a pure unit test with no network.
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert!(
|
||||
enforce_fingerprint_gate(&profile, None, &FingerprintGate::Enforce)
|
||||
.await
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn consent_for_a_direct_launch_is_redeemable_by_the_gate() {
|
||||
// Regression: a route that yields no usable upstream is gated on the direct
|
||||
// exit, so consent is minted under DIRECT_EXIT_IDENTITY. If the gate then
|
||||
// redeemed against the proxy/VPN identity instead, "Launch anyway" would
|
||||
// fail forever and the profile could never be started.
|
||||
let mut profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
profile.vpn_id = Some("vpn-with-no-port".into());
|
||||
|
||||
let token = mint_consent(&profile, DIRECT_EXIT_IDENTITY);
|
||||
// No upstream: the launcher could not bring the route up.
|
||||
let result = enforce_fingerprint_gate(&profile, None, &FingerprintGate::Consented(token)).await;
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"consent minted for the direct exit must be redeemable, got {result:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! Persisted "I know, launch it anyway" acknowledgements for the launch gate.
|
||||
//!
|
||||
//! Deliberately NOT synced. An acknowledgement is a statement about this
|
||||
//! machine's operator ("I understand this profile's exit disagrees with its
|
||||
//! fingerprint"), not a property of the profile. Syncing it would let one
|
||||
//! teammate disarm another's gate, and writing it into profile metadata would
|
||||
//! bump `updated_at` and make a local dismissal look like a remote edit.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FingerprintAck {
|
||||
/// Hash of the fingerprint that was acknowledged.
|
||||
pub fingerprint_hash: String,
|
||||
/// Exit endpoint identity it was acknowledged against.
|
||||
pub exit_identity: String,
|
||||
pub acked_at: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct LaunchGatePrefs {
|
||||
#[serde(default)]
|
||||
pub fingerprint_acks: HashMap<String, FingerprintAck>,
|
||||
/// Profile id -> acknowledged extension keys.
|
||||
#[serde(default)]
|
||||
pub vpn_extension_acks: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
/// Serializes read-modify-write so two concurrent acknowledgements in a bulk
|
||||
/// run cannot clobber each other.
|
||||
static ref PREFS_LOCK: Mutex<()> = Mutex::new(());
|
||||
}
|
||||
|
||||
fn prefs_file() -> PathBuf {
|
||||
crate::app_dirs::data_subdir().join("launch_gate_prefs.json")
|
||||
}
|
||||
|
||||
pub fn load() -> LaunchGatePrefs {
|
||||
let Ok(content) = std::fs::read_to_string(prefs_file()) else {
|
||||
return LaunchGatePrefs::default();
|
||||
};
|
||||
serde_json::from_str(&content).unwrap_or_else(|e| {
|
||||
log::warn!("Failed to parse launch gate prefs, ignoring them: {e}");
|
||||
LaunchGatePrefs::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn save(prefs: &LaunchGatePrefs) {
|
||||
let path = prefs_file();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("Failed to create launch gate prefs dir: {e}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_string_pretty(prefs) {
|
||||
Ok(json) => {
|
||||
if let Err(e) = std::fs::write(&path, json) {
|
||||
log::warn!("Failed to write launch gate prefs: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Failed to serialize launch gate prefs: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn update(mutate: impl FnOnce(&mut LaunchGatePrefs)) {
|
||||
let _guard = PREFS_LOCK.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let mut prefs = load();
|
||||
mutate(&mut prefs);
|
||||
save(&prefs);
|
||||
}
|
||||
|
||||
/// Stable digest of a profile's stored fingerprint, so an acknowledgement stops
|
||||
/// applying the moment the fingerprint is regenerated or matched to a new exit.
|
||||
pub fn fingerprint_hash(profile: &BrowserProfile) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let fingerprint = profile
|
||||
.wayfern_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.fingerprint.as_deref())
|
||||
.unwrap_or("");
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(fingerprint.as_bytes());
|
||||
hasher
|
||||
.finalize()
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Record that the user accepted this exact (fingerprint, exit) mismatch.
|
||||
pub fn ack_fingerprint(profile: &BrowserProfile, exit_identity: &str) {
|
||||
let ack = FingerprintAck {
|
||||
fingerprint_hash: fingerprint_hash(profile),
|
||||
exit_identity: exit_identity.to_string(),
|
||||
acked_at: crate::proxy_manager::now_secs(),
|
||||
};
|
||||
let profile_id = profile.id.to_string();
|
||||
update(|prefs| {
|
||||
prefs.fingerprint_acks.insert(profile_id, ack);
|
||||
});
|
||||
}
|
||||
|
||||
/// Whether the user already accepted the mismatch this profile currently has.
|
||||
///
|
||||
/// Bound to both the fingerprint and the exit endpoint on purpose: the old
|
||||
/// per-profile "don't warn again" flag never expired, so one dismissal left a
|
||||
/// profile unprotected forever, including after its proxy was swapped for one
|
||||
/// in a different country.
|
||||
pub fn fingerprint_ack_matches(profile: &BrowserProfile, exit_identity: &str) -> bool {
|
||||
let prefs = load();
|
||||
prefs
|
||||
.fingerprint_acks
|
||||
.get(&profile.id.to_string())
|
||||
.is_some_and(|ack| {
|
||||
ack.fingerprint_hash == fingerprint_hash(profile) && ack.exit_identity == exit_identity
|
||||
})
|
||||
}
|
||||
|
||||
pub fn ack_extensions(profile_id: &str, keys: &[String]) {
|
||||
if keys.is_empty() {
|
||||
return;
|
||||
}
|
||||
let profile_id = profile_id.to_string();
|
||||
let keys = keys.to_vec();
|
||||
update(|prefs| {
|
||||
let entry = prefs.vpn_extension_acks.entry(profile_id).or_default();
|
||||
for key in keys {
|
||||
if !entry.contains(&key) {
|
||||
entry.push(key);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// True when every one of these extensions has already been acknowledged for
|
||||
/// this profile. Installing a *different* VPN extension later re-warns, because
|
||||
/// its key is not in the acknowledged set.
|
||||
pub fn extensions_acked(profile_id: &str, keys: &[String]) -> bool {
|
||||
if keys.is_empty() {
|
||||
return true;
|
||||
}
|
||||
let prefs = load();
|
||||
let Some(acked) = prefs.vpn_extension_acks.get(profile_id) else {
|
||||
return false;
|
||||
};
|
||||
keys.iter().all(|k| acked.contains(k))
|
||||
}
|
||||
|
||||
/// Drop everything remembered for a profile, for use when it is deleted.
|
||||
pub fn forget_profile(profile_id: &str) {
|
||||
let profile_id = profile_id.to_string();
|
||||
update(|prefs| {
|
||||
prefs.fingerprint_acks.remove(&profile_id);
|
||||
prefs.vpn_extension_acks.remove(&profile_id);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn profile_with(fingerprint: &str) -> BrowserProfile {
|
||||
let mut profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
browser: "wayfern".into(),
|
||||
..Default::default()
|
||||
};
|
||||
profile.wayfern_config = Some(crate::wayfern_manager::WayfernConfig {
|
||||
fingerprint: Some(fingerprint.to_string()),
|
||||
..Default::default()
|
||||
});
|
||||
profile
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_hash_changes_with_the_fingerprint() {
|
||||
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let b = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
assert_ne!(fingerprint_hash(&a), fingerprint_hash(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_hash_is_stable_for_the_same_fingerprint() {
|
||||
let a = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let b = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert_eq!(fingerprint_hash(&a), fingerprint_hash(&b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_profile_without_a_fingerprint_still_hashes() {
|
||||
let profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
..Default::default()
|
||||
};
|
||||
assert!(!fingerprint_hash(&profile).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn acks_round_trip_and_rearm_on_change() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
|
||||
ack_fingerprint(&profile, "http://gw:1");
|
||||
assert!(fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
|
||||
// Swapping the proxy re-arms the gate: the mismatch the user accepted is
|
||||
// not the mismatch they now have.
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://other:2"));
|
||||
|
||||
// Regenerating the fingerprint re-arms it too.
|
||||
let regenerated = profile_with(r#"{"timezone":"America/New_York"}"#);
|
||||
let mut same_id = regenerated.clone();
|
||||
same_id.id = profile.id;
|
||||
assert!(!fingerprint_ack_matches(&same_id, "http://gw:1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extension_acks_are_per_key() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile_id = uuid::Uuid::new_v4().to_string();
|
||||
let nord = vec!["crx:aaaa".to_string()];
|
||||
let other = vec!["crx:bbbb".to_string()];
|
||||
|
||||
assert!(!extensions_acked(&profile_id, &nord));
|
||||
ack_extensions(&profile_id, &nord);
|
||||
assert!(extensions_acked(&profile_id, &nord));
|
||||
|
||||
// A different extension installed later must warn again.
|
||||
assert!(!extensions_acked(&profile_id, &other));
|
||||
assert!(!extensions_acked(
|
||||
&profile_id,
|
||||
&[nord[0].clone(), other[0].clone()]
|
||||
));
|
||||
|
||||
// Nothing to acknowledge is trivially acknowledged, so an empty scan never
|
||||
// opens the dialog.
|
||||
assert!(extensions_acked(&profile_id, &[]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forgetting_a_profile_clears_both_kinds_of_ack() {
|
||||
let _guard = crate::app_dirs::set_test_data_dir(tempfile::tempdir().expect("tempdir").keep());
|
||||
|
||||
let profile = profile_with(r#"{"timezone":"Europe/Berlin"}"#);
|
||||
let profile_id = profile.id.to_string();
|
||||
ack_fingerprint(&profile, "http://gw:1");
|
||||
ack_extensions(&profile_id, &["crx:aaaa".to_string()]);
|
||||
|
||||
forget_profile(&profile_id);
|
||||
|
||||
assert!(!fingerprint_ack_matches(&profile, "http://gw:1"));
|
||||
assert!(!extensions_acked(&profile_id, &["crx:aaaa".to_string()]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn corrupt_prefs_file_is_ignored_rather_than_fatal() {
|
||||
let dir = tempfile::tempdir().expect("tempdir").keep();
|
||||
let _guard = crate::app_dirs::set_test_data_dir(dir.clone());
|
||||
std::fs::create_dir_all(dir.join("data")).unwrap();
|
||||
std::fs::write(
|
||||
dir.join("data").join("launch_gate_prefs.json"),
|
||||
"{ not json",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
// Must not panic, and must fail closed (nothing acknowledged).
|
||||
let prefs = load();
|
||||
assert!(prefs.fingerprint_acks.is_empty());
|
||||
}
|
||||
}
|
||||
+417
-2
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -41,7 +41,7 @@ fn is_kept(name: &str) -> bool {
|
||||
/// step since it regenerates — leaves a populated `Default/` without it. Such a
|
||||
/// directory would then be treated as stale and removed wholesale, destroying the
|
||||
/// Extensions and Bookmarks this feature exists to preserve.
|
||||
fn is_profile_dir_name(name: &str) -> bool {
|
||||
pub(crate) fn is_profile_dir_name(name: &str) -> bool {
|
||||
matches!(name, "Default" | "Guest Profile" | "System Profile")
|
||||
|| name
|
||||
.strip_prefix("Profile ")
|
||||
|
||||
@@ -479,6 +479,10 @@ impl ProfileManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Launch-gate acknowledgements are keyed by profile id and are not synced,
|
||||
// so nothing else would ever clean them up.
|
||||
crate::launch_gate_prefs::forget_profile(profile_id);
|
||||
|
||||
// Remember sync mode before deleting local files
|
||||
let was_sync_enabled = profile.is_sync_enabled();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -464,10 +464,10 @@ impl ProxyManager {
|
||||
.as_deref()
|
||||
.filter(|uri| !uri.is_empty())
|
||||
.ok_or_else(|| crate::backend_error("VLESS_CONFIG_INVALID"))?;
|
||||
let parsed = crate::xray::parse_vless_uri(uri)
|
||||
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
let parsed =
|
||||
crate::xray::parse_vless_uri(uri).map_err(|error| crate::vless_config_error(&error))?;
|
||||
let canonical_uri = crate::xray::export_vless_uri(&parsed.config, parsed.name.as_deref())
|
||||
.map_err(|error| crate::backend_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| crate::vless_config_error(&error))?;
|
||||
|
||||
proxy_settings.proxy_type = "vless".to_string();
|
||||
proxy_settings.host = parsed.config.address;
|
||||
@@ -1152,6 +1152,21 @@ impl ProxyManager {
|
||||
url
|
||||
}
|
||||
|
||||
/// Proxy URL for a diagnostic probe made by the app itself (reqwest), as
|
||||
/// opposed to `build_proxy_url`, which feeds the browser.
|
||||
///
|
||||
/// SOCKS5 becomes `socks5h://` so the probe endpoint's hostname resolves at
|
||||
/// the exit rather than on this machine. Resolving locally would leak the
|
||||
/// real DNS and, behind a split-horizon resolver, can reach a different host
|
||||
/// than the browser would.
|
||||
pub fn build_probe_proxy_url(proxy_settings: &ProxySettings) -> String {
|
||||
let url = Self::build_proxy_url(proxy_settings);
|
||||
if proxy_settings.proxy_type.eq_ignore_ascii_case("socks5") {
|
||||
return url.replacen("socks5://", "socks5h://", 1);
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
// Check if a proxy is valid by routing through a temporary donut-proxy process.
|
||||
// This tests the exact same code path the browser uses.
|
||||
// Falls back to direct reqwest check if the proxy worker fails to start.
|
||||
|
||||
@@ -0,0 +1,511 @@
|
||||
//! Whether a profile's exit node can be reached from somewhere that is not this
|
||||
//! machine.
|
||||
//!
|
||||
//! Remote execution — an interactive remote session or a Cookie Bot night — runs
|
||||
//! the browser on a leased fleet host, but the PROFILE (and its proxy, and its
|
||||
//! VPN config) is pulled from the user's sync namespace. Nothing in that
|
||||
//! handover rewrites addresses, so a proxy recorded as `127.0.0.1:8080` arrives
|
||||
//! on the fleet host meaning *the fleet host's own loopback*.
|
||||
//!
|
||||
//! That is the whole bug this module exists to prevent. The server already
|
||||
//! refuses a profile with NO exit (`proxy_required`), because a night browsed
|
||||
//! from the fleet's datacenter address damages an identity rather than building
|
||||
//! it — but it was asking whether an exit was *configured*, never whether it was
|
||||
//! *reachable*. A local proxy satisfied the first question and failed the
|
||||
//! second, so the run was accepted, dispatched, and burned a leased host either
|
||||
//! erroring out or (worse) egressing direct from the datacenter: exactly the
|
||||
//! outcome `proxy_required` exists to stop, reached by the one route it did not
|
||||
//! check.
|
||||
//!
|
||||
//! Local proxies are not an exotic case. A local MITM proxy, an SSH tunnel, a
|
||||
//! locally-run SOCKS client and Donut's own VLESS support all present to the
|
||||
//! browser as `127.0.0.1:<port>`.
|
||||
//!
|
||||
//! This module is the single answer, shared by every caller, and it FAILS
|
||||
//! CLOSED: anything it cannot parse is reported as unreachable. Refusing a
|
||||
//! working setup costs the user one support question; accepting a broken one
|
||||
//! costs a burned hour and a damaged profile identity.
|
||||
|
||||
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
||||
|
||||
/// Whether a leased fleet host could dial this profile's exit.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExitReachability {
|
||||
/// No proxy and no VPN. The caller's existing "no exit" refusal applies.
|
||||
None,
|
||||
/// An address a host elsewhere on the internet can reach.
|
||||
Remote,
|
||||
/// An address that only means anything on this machine or this LAN.
|
||||
LocalOnly {
|
||||
/// The offending host, for a message the user can act on.
|
||||
host: String,
|
||||
/// Which part of the config it came from: "proxy" or "VPN".
|
||||
source: &'static str,
|
||||
},
|
||||
/// Configured, but this code could not determine the host.
|
||||
///
|
||||
/// Treated as unreachable by [`ExitReachability::is_remote`] — see the
|
||||
/// fail-closed note in the module docs.
|
||||
Unknown {
|
||||
reason: String,
|
||||
source: &'static str,
|
||||
},
|
||||
}
|
||||
|
||||
impl ExitReachability {
|
||||
/// Whether remote execution may proceed.
|
||||
pub fn is_remote(&self) -> bool {
|
||||
matches!(self, ExitReachability::Remote)
|
||||
}
|
||||
|
||||
/// A one-line reason for a refusal, or None when there is nothing to refuse.
|
||||
pub fn refusal_detail(&self) -> Option<String> {
|
||||
match self {
|
||||
ExitReachability::Remote | ExitReachability::None => None,
|
||||
ExitReachability::LocalOnly { host, source } => Some(format!(
|
||||
"The {source} for this profile points at {host}, which only exists on this computer. \
|
||||
Remote runs happen on our hosts and cannot reach it."
|
||||
)),
|
||||
ExitReachability::Unknown { reason, source } => Some(format!(
|
||||
"The {source} for this profile could not be read ({reason}), so we cannot confirm a \
|
||||
remote host could use it."
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a hostname or IP literal is reachable from another machine.
|
||||
///
|
||||
/// Rejects, in order: empty/whitespace, unparsable-as-either, and every IP
|
||||
/// range that is scoped to a machine or a private network. Hostnames that are
|
||||
/// not IP literals are accepted unless they use a name suffix that is
|
||||
/// definitionally local — a public DNS name cannot be validated here without a
|
||||
/// lookup, and doing a lookup would make this impure and slow on a hot path.
|
||||
pub fn host_is_remote_reachable(host: &str) -> bool {
|
||||
let host = normalize_host(host);
|
||||
if host.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Ok(ip) = host.parse::<IpAddr>() {
|
||||
return ip_is_remote_reachable(ip);
|
||||
}
|
||||
|
||||
let lower = host.to_ascii_lowercase();
|
||||
|
||||
// `localhost` and anything under it resolve to loopback everywhere.
|
||||
if lower == "localhost" || lower.ends_with(".localhost") {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Suffixes reserved for local/private name resolution (RFC 6762 mDNS, RFC
|
||||
// 8375, and the names router vendors hand out on a LAN). A fleet host
|
||||
// resolving one of these gets its own network's answer, not the user's.
|
||||
const LOCAL_SUFFIXES: [&str; 7] = [
|
||||
".local",
|
||||
".localdomain",
|
||||
".internal",
|
||||
".home",
|
||||
".home.arpa",
|
||||
".lan",
|
||||
".intranet",
|
||||
];
|
||||
if LOCAL_SUFFIXES.iter().any(|suffix| lower.ends_with(suffix)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A bare single-label name ("my-proxy", "router") is only resolvable through
|
||||
// a local search domain, so it is no more use to a fleet host than `.local`.
|
||||
if !lower.contains('.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Whether an IP literal is routable from another machine.
|
||||
fn ip_is_remote_reachable(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => ipv4_is_remote_reachable(v4),
|
||||
IpAddr::V6(v6) => ipv6_is_remote_reachable(v6),
|
||||
}
|
||||
}
|
||||
|
||||
fn ipv4_is_remote_reachable(ip: Ipv4Addr) -> bool {
|
||||
// `is_private`/`is_loopback`/`is_link_local` are stable; the rest are not, so
|
||||
// the remaining ranges are spelled out rather than gated behind a nightly
|
||||
// feature.
|
||||
if ip.is_loopback() || ip.is_private() || ip.is_link_local() || ip.is_unspecified() {
|
||||
return false;
|
||||
}
|
||||
if ip.is_broadcast() || ip.is_multicast() || ip.is_documentation() {
|
||||
return false;
|
||||
}
|
||||
let [a, b, ..] = ip.octets();
|
||||
// 100.64.0.0/10 — carrier-grade NAT (RFC 6598). Reachable inside one
|
||||
// carrier's network and nowhere else.
|
||||
if a == 100 && (64..128).contains(&b) {
|
||||
return false;
|
||||
}
|
||||
// 0.0.0.0/8 "this network", and 240.0.0.0/4 reserved.
|
||||
if a == 0 || a >= 240 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn ipv6_is_remote_reachable(ip: Ipv6Addr) -> bool {
|
||||
if ip.is_loopback() || ip.is_unspecified() || ip.is_multicast() {
|
||||
return false;
|
||||
}
|
||||
// An IPv4 address wearing an IPv6 hat is still that IPv4 address — classify
|
||||
// it as one, or `::ffff:127.0.0.1` walks straight through.
|
||||
if let Some(v4) = ip.to_ipv4_mapped() {
|
||||
return ipv4_is_remote_reachable(v4);
|
||||
}
|
||||
if let Some(v4) = ip.to_ipv4() {
|
||||
return ipv4_is_remote_reachable(v4);
|
||||
}
|
||||
let segments = ip.segments();
|
||||
// fc00::/7 unique-local, fe80::/10 link-local.
|
||||
if (segments[0] & 0xfe00) == 0xfc00 {
|
||||
return false;
|
||||
}
|
||||
if (segments[0] & 0xffc0) == 0xfe80 {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Strip the decoration a host can arrive wrapped in: whitespace, `[...]`
|
||||
/// around an IPv6 literal, a trailing dot on an FQDN, and any `user@` or
|
||||
/// `:port` that came along from a URI.
|
||||
fn normalize_host(raw: &str) -> String {
|
||||
let mut host = raw.trim();
|
||||
if host.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
// `user:pass@host` — take what follows the LAST '@', since a password may
|
||||
// itself contain one.
|
||||
if let Some(at) = host.rfind('@') {
|
||||
host = &host[at + 1..];
|
||||
}
|
||||
|
||||
// Bracketed IPv6, optionally with a port: `[::1]:1080`.
|
||||
if let Some(stripped) = host.strip_prefix('[') {
|
||||
if let Some(end) = stripped.find(']') {
|
||||
return stripped[..end].trim().to_string();
|
||||
}
|
||||
return stripped.trim().to_string();
|
||||
}
|
||||
|
||||
// `host:port`, but only when there is exactly one colon — more than one means
|
||||
// a bare IPv6 literal, whose colons are part of the address.
|
||||
if host.matches(':').count() == 1 {
|
||||
if let Some((left, _port)) = host.split_once(':') {
|
||||
host = left;
|
||||
}
|
||||
}
|
||||
|
||||
host.trim().trim_end_matches('.').to_string()
|
||||
}
|
||||
|
||||
/// The host a VLESS URI actually dials.
|
||||
///
|
||||
/// Load-bearing because of an asymmetry that is easy to get backwards: a VLESS
|
||||
/// proxy presents to the browser as `127.0.0.1:<port>` — Donut runs a local xray
|
||||
/// worker and points the browser at it — but the address that decides whether
|
||||
/// anyone else could use this config is the SERVER inside the URI. The local
|
||||
/// port is an implementation detail of this machine; the URI is the exit.
|
||||
pub fn vless_uri_host(uri: &str) -> Option<String> {
|
||||
let rest = uri.trim().strip_prefix("vless://")?;
|
||||
// Cut the fragment (`#label`) and query (`?type=...`) before looking for the
|
||||
// authority — either may contain '@' or ':'.
|
||||
let rest = rest.split('#').next()?;
|
||||
let rest = rest.split('?').next()?;
|
||||
// `uuid@host:port/...`
|
||||
let authority = rest.split('/').next()?;
|
||||
let host_port = authority
|
||||
.rsplit_once('@')
|
||||
.map(|(_, h)| h)
|
||||
.unwrap_or(authority);
|
||||
let host = normalize_host(host_port);
|
||||
if host.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(host)
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit host a stored proxy represents, as a remote host would have to dial
|
||||
/// it.
|
||||
pub fn proxy_exit_host(settings: &crate::browser::ProxySettings) -> Result<String, String> {
|
||||
if settings.proxy_type.eq_ignore_ascii_case("vless") {
|
||||
let uri = settings
|
||||
.vless_uri
|
||||
.as_deref()
|
||||
.filter(|uri| !uri.trim().is_empty())
|
||||
.ok_or_else(|| "VLESS proxy has no server URI".to_string())?;
|
||||
return vless_uri_host(uri).ok_or_else(|| "VLESS server URI is malformed".to_string());
|
||||
}
|
||||
|
||||
let host = normalize_host(&settings.host);
|
||||
if host.is_empty() {
|
||||
return Err("proxy has no host".to_string());
|
||||
}
|
||||
Ok(host)
|
||||
}
|
||||
|
||||
/// Classify a stored proxy.
|
||||
pub fn classify_proxy(settings: &crate::browser::ProxySettings) -> ExitReachability {
|
||||
match proxy_exit_host(settings) {
|
||||
Err(reason) => ExitReachability::Unknown {
|
||||
reason,
|
||||
source: "proxy",
|
||||
},
|
||||
Ok(host) => {
|
||||
if host_is_remote_reachable(&host) {
|
||||
ExitReachability::Remote
|
||||
} else {
|
||||
ExitReachability::LocalOnly {
|
||||
host,
|
||||
source: "proxy",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify a WireGuard peer endpoint (`host:port`).
|
||||
pub fn classify_wireguard_endpoint(peer_endpoint: &str) -> ExitReachability {
|
||||
let host = normalize_host(peer_endpoint);
|
||||
if host.is_empty() {
|
||||
return ExitReachability::Unknown {
|
||||
reason: "VPN config has no peer endpoint".to_string(),
|
||||
source: "VPN",
|
||||
};
|
||||
}
|
||||
if host_is_remote_reachable(&host) {
|
||||
ExitReachability::Remote
|
||||
} else {
|
||||
ExitReachability::LocalOnly {
|
||||
host,
|
||||
source: "VPN",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::browser::ProxySettings;
|
||||
|
||||
fn proxy(proxy_type: &str, host: &str) -> ProxySettings {
|
||||
ProxySettings {
|
||||
proxy_type: proxy_type.to_string(),
|
||||
host: host.to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
vless_uri: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loopback_in_every_spelling_is_local() {
|
||||
// The literal case the bug was reported for, plus the spellings that reach
|
||||
// the same place. `::ffff:127.0.0.1` is the one a naive IPv6 check misses.
|
||||
for host in [
|
||||
"127.0.0.1",
|
||||
"127.1.2.3",
|
||||
"localhost",
|
||||
"LOCALHOST",
|
||||
"foo.localhost",
|
||||
"::1",
|
||||
"[::1]",
|
||||
"::ffff:127.0.0.1",
|
||||
"0.0.0.0",
|
||||
"::",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_and_carrier_ranges_are_local() {
|
||||
for host in [
|
||||
"10.0.0.1",
|
||||
"192.168.1.1",
|
||||
"172.16.0.1",
|
||||
"172.31.255.254",
|
||||
"169.254.1.1", // link-local / APIPA
|
||||
"100.64.0.1", // CGNAT
|
||||
"100.127.255.1",
|
||||
"fd00::1", // unique-local
|
||||
"fe80::1", // link-local
|
||||
"240.0.0.1",
|
||||
"0.1.2.3",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn public_addresses_and_names_are_reachable() {
|
||||
for host in [
|
||||
"1.1.1.1",
|
||||
"8.8.8.8",
|
||||
"172.15.0.1", // just outside 172.16/12
|
||||
"172.32.0.1",
|
||||
"100.63.255.255", // just outside 100.64/10
|
||||
"100.128.0.1",
|
||||
"2606:4700:4700::1111",
|
||||
"proxy.example.com",
|
||||
"gate.smartproxy.net.",
|
||||
"residential.example.co.uk",
|
||||
] {
|
||||
assert!(
|
||||
host_is_remote_reachable(host),
|
||||
"{host} should be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lan_only_names_are_local() {
|
||||
// A fleet host resolving these gets ITS network's answer, not the user's —
|
||||
// which is worse than failing, because it may well succeed against
|
||||
// something unrelated.
|
||||
for host in [
|
||||
"my-proxy", // single label: needs a search domain
|
||||
"router.local",
|
||||
"nas.home.arpa",
|
||||
"proxy.lan",
|
||||
"box.internal",
|
||||
"server.localdomain",
|
||||
"gateway.intranet",
|
||||
] {
|
||||
assert!(
|
||||
!host_is_remote_reachable(host),
|
||||
"{host} should not be remote-reachable"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn host_port_and_credentials_are_stripped_before_classifying() {
|
||||
assert!(!host_is_remote_reachable("127.0.0.1:8080"));
|
||||
assert!(!host_is_remote_reachable("user:pass@127.0.0.1:8080"));
|
||||
assert!(!host_is_remote_reachable("[::1]:1080"));
|
||||
assert!(host_is_remote_reachable("user:p@ss@proxy.example.com:8080"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vless_proxy_is_judged_by_its_server_not_its_local_port() {
|
||||
// THE asymmetry. Donut points the browser at a local xray worker, so the
|
||||
// browser-facing address of every VLESS proxy is 127.0.0.1 — but the stored
|
||||
// config names a real server, and that is what a fleet host would dial.
|
||||
// Classifying VLESS off `settings.host` would refuse every VLESS profile.
|
||||
let mut settings = proxy("vless", "127.0.0.1");
|
||||
settings.vless_uri =
|
||||
Some("vless://6d6e21a1-4829-4d2b-bc7f-1b25707b61e4@vpn.example.com:443?type=tcp#node".into());
|
||||
|
||||
assert_eq!(classify_proxy(&settings), ExitReachability::Remote);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_vless_uri_pointing_at_loopback_is_still_local() {
|
||||
let mut settings = proxy("vless", "127.0.0.1");
|
||||
settings.vless_uri = Some("vless://uuid@127.0.0.1:443?type=tcp".into());
|
||||
|
||||
assert_eq!(
|
||||
classify_proxy(&settings),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".to_string(),
|
||||
source: "proxy",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vless_host_parsing_survives_query_and_fragment() {
|
||||
assert_eq!(
|
||||
vless_uri_host("vless://uuid@example.com:443?sni=a@b.com&x=1#my@label"),
|
||||
Some("example.com".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
vless_uri_host("vless://uuid@[2606:4700::1111]:443?type=ws"),
|
||||
Some("2606:4700::1111".to_string())
|
||||
);
|
||||
assert_eq!(vless_uri_host("not-a-vless-uri"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_config_fails_closed() {
|
||||
// Unknown must never be treated as usable: the point of the check is that
|
||||
// we could not confirm reachability, and guessing "yes" reintroduces the
|
||||
// exact failure it prevents.
|
||||
let mut settings = proxy("vless", "");
|
||||
settings.vless_uri = None;
|
||||
let verdict = classify_proxy(&settings);
|
||||
|
||||
assert!(matches!(verdict, ExitReachability::Unknown { .. }));
|
||||
assert!(!verdict.is_remote());
|
||||
assert!(verdict.refusal_detail().is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_proxies_are_classified_by_host() {
|
||||
assert_eq!(
|
||||
classify_proxy(&proxy("socks5", "gate.example.com")),
|
||||
ExitReachability::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
classify_proxy(&proxy("http", "192.168.0.10")),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "192.168.0.10".to_string(),
|
||||
source: "proxy",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireguard_endpoints_are_classified_by_their_peer() {
|
||||
assert_eq!(
|
||||
classify_wireguard_endpoint("vpn.example.com:51820"),
|
||||
ExitReachability::Remote
|
||||
);
|
||||
assert_eq!(
|
||||
classify_wireguard_endpoint("10.0.0.1:51820"),
|
||||
ExitReachability::LocalOnly {
|
||||
host: "10.0.0.1".to_string(),
|
||||
source: "VPN",
|
||||
}
|
||||
);
|
||||
assert!(matches!(
|
||||
classify_wireguard_endpoint(" "),
|
||||
ExitReachability::Unknown { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_remote_permits_a_run() {
|
||||
assert!(ExitReachability::Remote.is_remote());
|
||||
assert!(!ExitReachability::None.is_remote());
|
||||
assert!(!ExitReachability::LocalOnly {
|
||||
host: "127.0.0.1".into(),
|
||||
source: "proxy"
|
||||
}
|
||||
.is_remote());
|
||||
// `None` has no detail: the caller's existing "no exit at all" refusal is
|
||||
// the better message, and two refusals for one condition read as a bug.
|
||||
assert!(ExitReachability::None.refusal_detail().is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,561 @@
|
||||
//! What a remote session owes this machine, and the gate that collects it.
|
||||
//!
|
||||
//! A profile that runs on the leased fleet is written by the host, not here.
|
||||
//! The host pushes it back to cloud storage when the session ends, and until
|
||||
//! this machine has pulled that push, the local profile directory is a stale
|
||||
//! copy of something that has moved on.
|
||||
//!
|
||||
//! Opening that stale copy is not a cosmetic problem, it is destructive. The
|
||||
//! local browser writes, every local mtime jumps past the host's push, and the
|
||||
//! next ordinary sync therefore reads local as the newer side: it uploads the
|
||||
//! pre-session files and puts everything the host wrote into
|
||||
//! `files_to_delete_remote`. A night of cookie warming is deleted with no error
|
||||
//! anywhere. Nothing in the manifest can prevent this, because by then the local
|
||||
//! clock genuinely IS later.
|
||||
//!
|
||||
//! So the gate is here instead, and it is deliberately a LOCAL, per-machine
|
||||
//! fact rather than a synced one. "This computer has not yet pulled" is true of
|
||||
//! one computer at a time; putting it in the profile's synced metadata would let
|
||||
//! a second device that had already pulled clear it for a first device that had
|
||||
//! not.
|
||||
//!
|
||||
//! Two states, and the difference matters to the user:
|
||||
//!
|
||||
//! - [`HandoffState::Running`]: a session is live on the fleet. The profile lock
|
||||
//! is held server-side, so a launch would be refused anyway; this makes the
|
||||
//! refusal instant and legible instead of a round trip and a raw string.
|
||||
//! - [`HandoffState::PendingSync`]: the session is over, the lock is released,
|
||||
//! and the work is sitting in cloud storage. This is the window that used to
|
||||
//! be wide open.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::RwLock;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Emitted whenever the set of gated profiles changes.
|
||||
pub const EVENT_REMOTE_HANDOFF: &str = "remote-handoff-changed";
|
||||
|
||||
/// Attempts at pulling a finished session's work before giving up for now.
|
||||
///
|
||||
/// The entry survives a failure, so "giving up" only means this burst stops;
|
||||
/// the next stream event, app start or manual sync tries again. What the retries
|
||||
/// buy is the common case: the profile lock is released server-side a moment
|
||||
/// before this machine's cached copy of it expires, and a single attempt would
|
||||
/// hit `Skipped("profile is locked elsewhere")` and leave the user blocked for
|
||||
/// no reason.
|
||||
const PULL_ATTEMPTS: u32 = 5;
|
||||
|
||||
/// Delay before the second pull attempt. Doubles, capped by [`PULL_RETRY_MAX`].
|
||||
const PULL_RETRY_BASE: Duration = Duration::from_secs(2);
|
||||
|
||||
/// Ceiling on the pull backoff. Above the 30s profile-lock refresh, so a run of
|
||||
/// attempts is guaranteed to span at least one refresh of the lock cache.
|
||||
const PULL_RETRY_MAX: Duration = Duration::from_secs(45);
|
||||
|
||||
/// Where a profile stands with respect to the fleet.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum HandoffState {
|
||||
/// A session is live on the fleet right now.
|
||||
Running,
|
||||
/// A session has finished and its work has not been pulled down yet.
|
||||
PendingSync,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct HandoffEntry {
|
||||
session_id: String,
|
||||
state: HandoffState,
|
||||
/// When this entry last changed, unix seconds. Diagnostics only; the gate
|
||||
/// never expires on its own, because an entry that timed out would reopen
|
||||
/// exactly the window it exists to close.
|
||||
observed_at: u64,
|
||||
}
|
||||
|
||||
type Store = HashMap<String, HandoffEntry>;
|
||||
|
||||
static STORE: RwLock<Option<Store>> = RwLock::new(None);
|
||||
|
||||
fn store_path() -> std::path::PathBuf {
|
||||
crate::app_dirs::settings_dir().join("remote_handoff.json")
|
||||
}
|
||||
|
||||
fn load_from_disk() -> Store {
|
||||
let path = store_path();
|
||||
let Ok(bytes) = std::fs::read(&path) else {
|
||||
return Store::new();
|
||||
};
|
||||
match serde_json::from_slice::<Store>(&bytes) {
|
||||
Ok(store) => store,
|
||||
Err(e) => {
|
||||
// Losing the file means losing the gate, so say so loudly rather than
|
||||
// starting empty and quietly permitting a launch over pending work.
|
||||
log::error!(
|
||||
"Could not read {}: {e}. Profiles with unsynced remote work will not be gated until \
|
||||
the next session event.",
|
||||
path.display()
|
||||
);
|
||||
Store::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn persist(store: &Store) {
|
||||
let path = store_path();
|
||||
if let Some(parent) = path.parent() {
|
||||
if let Err(e) = std::fs::create_dir_all(parent) {
|
||||
log::warn!("Could not create {}: {e}", parent.display());
|
||||
return;
|
||||
}
|
||||
}
|
||||
match serde_json::to_vec_pretty(store) {
|
||||
Ok(bytes) => {
|
||||
if let Err(e) = crate::app_dirs::write_owner_only(&path, &bytes) {
|
||||
log::warn!("Could not write {}: {e}", path.display());
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("Could not encode the remote handoff store: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn with_store<T>(f: impl FnOnce(&mut Store) -> T) -> T {
|
||||
let mut guard = STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let store = guard.get_or_insert_with(load_from_disk);
|
||||
f(store)
|
||||
}
|
||||
|
||||
/// Apply a mutation, and persist plus announce it only if it changed anything.
|
||||
fn mutate(f: impl FnOnce(&mut Store) -> bool) {
|
||||
let changed = with_store(|store| {
|
||||
let changed = f(store);
|
||||
if changed {
|
||||
persist(store);
|
||||
}
|
||||
changed
|
||||
});
|
||||
if changed {
|
||||
announce();
|
||||
}
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn announce() {
|
||||
let _ = crate::events::emit(EVENT_REMOTE_HANDOFF, states());
|
||||
}
|
||||
|
||||
/// Every gated profile, for the UI and for one-shot reads.
|
||||
pub fn states() -> HashMap<String, HandoffState> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.map(|(profile_id, entry)| (profile_id.clone(), entry.state))
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
/// Where this profile stands, if it is gated at all.
|
||||
pub fn state_for(profile_id: &str) -> Option<HandoffState> {
|
||||
with_store(|store| store.get(profile_id).map(|entry| entry.state))
|
||||
}
|
||||
|
||||
/// The session currently holding this profile on the fleet, if any.
|
||||
///
|
||||
/// Answers for a `provisioning` session too, which the drivable-session index
|
||||
/// deliberately does not. Stopping a session that has not finished coming up is
|
||||
/// the single most common thing a user does after starting one by mistake, and
|
||||
/// an index built for "where do I attach a CDP client" cannot serve it.
|
||||
pub fn running_session_for_profile(profile_id: &str) -> Option<String> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.get(profile_id)
|
||||
.filter(|entry| entry.state == HandoffState::Running)
|
||||
.map(|entry| entry.session_id.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Which profile a session belongs to, as this machine last recorded it.
|
||||
///
|
||||
/// The backend's stop reply carries a session id and a duration but no profile,
|
||||
/// and the caller that pressed stop needs to know whose work to pull. Reading it
|
||||
/// back from the gate avoids a second round trip for something already known.
|
||||
pub fn profile_for_session(session_id: &str) -> Option<String> {
|
||||
with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.find(|(_, entry)| entry.session_id == session_id)
|
||||
.map(|(profile_id, _)| profile_id.clone())
|
||||
})
|
||||
}
|
||||
|
||||
/// Record that a session is live on the fleet for this profile.
|
||||
///
|
||||
/// Written to disk immediately, and this is the point of the whole store: if the
|
||||
/// app is closed while a session runs, nothing on restart would otherwise
|
||||
/// distinguish "this profile is fine" from "a host has been writing to this
|
||||
/// profile for the last hour".
|
||||
pub fn note_running(profile_id: &str, session_id: &str) {
|
||||
mutate(|store| {
|
||||
let entry = store.get(profile_id);
|
||||
if entry
|
||||
.is_some_and(|held| held.state == HandoffState::Running && held.session_id == session_id)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
store.insert(
|
||||
profile_id.to_string(),
|
||||
HandoffEntry {
|
||||
session_id: session_id.to_string(),
|
||||
state: HandoffState::Running,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
/// Record that a session has finished and its work is waiting in cloud storage.
|
||||
///
|
||||
/// Returns whether this call is the one that moved the profile into
|
||||
/// `PendingSync`, so the caller starts exactly one pull for a transition that
|
||||
/// the stream may well deliver more than once.
|
||||
pub fn note_ended(profile_id: &str, session_id: &str) -> bool {
|
||||
let mut transitioned = false;
|
||||
mutate(|store| {
|
||||
// Only a session this machine was watching can hand work over to it.
|
||||
//
|
||||
// No entry means one of two things and both say "do nothing": the pull for
|
||||
// this session already completed and cleared the gate, or this machine
|
||||
// never held the profile. The backend's listing returns closed sessions
|
||||
// alongside live ones, so the snapshot on every reconnect replays each
|
||||
// finished session — treating those as fresh handoffs would gate a
|
||||
// perfectly current profile on every app start, and keep it blocked for as
|
||||
// long as the machine happened to be offline.
|
||||
let Some(entry) = store.get(profile_id) else {
|
||||
return false;
|
||||
};
|
||||
// A late `closed` for a session that has already been replaced by a newer
|
||||
// one must not mark the newer one's profile as finished.
|
||||
if entry.session_id != session_id {
|
||||
return false;
|
||||
}
|
||||
if entry.state == HandoffState::PendingSync {
|
||||
return false;
|
||||
}
|
||||
transitioned = true;
|
||||
store.insert(
|
||||
profile_id.to_string(),
|
||||
HandoffEntry {
|
||||
session_id: session_id.to_string(),
|
||||
state: HandoffState::PendingSync,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
true
|
||||
});
|
||||
transitioned
|
||||
}
|
||||
|
||||
/// Drop the gate. Called only after a pull has actually completed.
|
||||
pub fn clear(profile_id: &str) {
|
||||
mutate(|store| store.remove(profile_id).is_some());
|
||||
}
|
||||
|
||||
/// Bring stored `Running` entries back in line with what the backend reports.
|
||||
///
|
||||
/// The stream is how a transition normally arrives, and it cannot deliver one
|
||||
/// that happened while the app was shut. Any profile this machine last saw
|
||||
/// running, whose session the backend no longer reports as live, finished
|
||||
/// without being observed — and its work is sitting in cloud storage unpulled.
|
||||
/// Returns the profiles that just moved into `PendingSync`.
|
||||
pub fn reconcile(live_session_ids: &std::collections::HashSet<String>) -> Vec<String> {
|
||||
let mut ended = Vec::new();
|
||||
mutate(|store| {
|
||||
let stale: Vec<(String, String)> = store
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.state == HandoffState::Running)
|
||||
.filter(|(_, entry)| !live_session_ids.contains(&entry.session_id))
|
||||
.map(|(profile_id, entry)| (profile_id.clone(), entry.session_id.clone()))
|
||||
.collect();
|
||||
for (profile_id, session_id) in stale {
|
||||
log::info!(
|
||||
"Remote session {session_id} for profile {profile_id} ended while this machine was not \
|
||||
watching; its work is still in cloud storage"
|
||||
);
|
||||
store.insert(
|
||||
profile_id.clone(),
|
||||
HandoffEntry {
|
||||
session_id,
|
||||
state: HandoffState::PendingSync,
|
||||
observed_at: now_secs(),
|
||||
},
|
||||
);
|
||||
ended.push(profile_id);
|
||||
}
|
||||
!ended.is_empty()
|
||||
});
|
||||
ended
|
||||
}
|
||||
|
||||
/// Refuse a local launch that would run over unsynced remote work.
|
||||
///
|
||||
/// Returns the `{"code":…}` string a Tauri command and the REST layer both
|
||||
/// surface. Every local launch path calls this: the two that did not are how a
|
||||
/// profile could be opened locally while a host was still writing to it.
|
||||
pub fn ensure_local_launch_allowed(profile_id: &str) -> Result<(), String> {
|
||||
match state_for(profile_id) {
|
||||
None => Ok(()),
|
||||
Some(HandoffState::Running) => Err(crate::backend_error("PROFILE_RUNNING_REMOTELY")),
|
||||
Some(HandoffState::PendingSync) => Err(crate::backend_error("PROFILE_REMOTE_SYNC_PENDING")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the pull for every profile still waiting on one.
|
||||
///
|
||||
/// A pull can fail for as long as the machine is offline, and its retries are
|
||||
/// bounded, so without this a profile could stay blocked from launching until
|
||||
/// the user found the manual sync button. Called whenever the app has a cloud
|
||||
/// session again, which is exactly when a previously impossible pull becomes
|
||||
/// possible.
|
||||
pub fn resume_pending_pulls(app_handle: &tauri::AppHandle) {
|
||||
let pending: Vec<String> = with_store(|store| {
|
||||
store
|
||||
.iter()
|
||||
.filter(|(_, entry)| entry.state == HandoffState::PendingSync)
|
||||
.map(|(profile_id, _)| profile_id.clone())
|
||||
.collect()
|
||||
});
|
||||
for profile_id in pending {
|
||||
log::info!("Resuming the post-session pull for profile {profile_id}");
|
||||
schedule_pull(app_handle.clone(), profile_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull one profile's finished session down, then lift its gate.
|
||||
///
|
||||
/// Spawned rather than awaited by its callers: a stream frame and a stop button
|
||||
/// must not block on a transfer that can take minutes. The gate stays up for the
|
||||
/// whole attempt, so there is no window in which the user can open the stale
|
||||
/// copy while this is in flight.
|
||||
pub fn schedule_pull(app_handle: tauri::AppHandle, profile_id: String) {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
for attempt in 0..PULL_ATTEMPTS {
|
||||
if state_for(&profile_id) != Some(HandoffState::PendingSync) {
|
||||
// A new session started, or another pull got there first.
|
||||
return;
|
||||
}
|
||||
if attempt > 0 {
|
||||
let delay = PULL_RETRY_BASE
|
||||
.saturating_mul(1u32 << (attempt - 1).min(16))
|
||||
.min(PULL_RETRY_MAX);
|
||||
tokio::time::sleep(delay).await;
|
||||
}
|
||||
|
||||
match crate::sync::pull_profile_after_remote_session(&app_handle, &profile_id).await {
|
||||
Ok(outcome) if outcome.is_completed() => {
|
||||
log::info!("Pulled remote session work for profile {profile_id}");
|
||||
clear(&profile_id);
|
||||
return;
|
||||
}
|
||||
Ok(crate::sync::ProfileSyncOutcome::Skipped(reason)) => {
|
||||
log::info!("Post-session pull for profile {profile_id} did nothing ({reason}); retrying");
|
||||
}
|
||||
Ok(_) => unreachable!("is_completed covers every completed outcome"),
|
||||
Err(e) => {
|
||||
log::warn!("Post-session pull for profile {profile_id} failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::warn!(
|
||||
"Could not pull remote session work for profile {profile_id} yet; it stays blocked from \
|
||||
launching locally until the pull succeeds"
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/// Serialises every test that can reach [`STORE`], wherever it lives.
|
||||
///
|
||||
/// `remote_session`'s tests drive session transitions through `note_running`
|
||||
/// and `note_ended`, so they mutate this module's global store too — with the
|
||||
/// same `p1`/`p2` fixture ids. Two mutexes meant the two groups could interleave
|
||||
/// and clobber each other, which showed up as an intermittent failure in the
|
||||
/// suite guarding a data-loss bug.
|
||||
#[cfg(test)]
|
||||
pub(crate) static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Take the store lock and start from an empty store. Callers must hold the
|
||||
/// returned guard for the whole test.
|
||||
#[cfg(test)]
|
||||
pub(crate) fn lock_for_test() -> std::sync::MutexGuard<'static, ()> {
|
||||
let lock = TEST_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
|
||||
lock
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::collections::HashSet;
|
||||
|
||||
/// Point the store at a scratch directory and start it empty.
|
||||
///
|
||||
/// Everything returned must outlive the test body: dropping the guard
|
||||
/// restores the real data directory, and a test that let it drop early would
|
||||
/// write a gate file into the developer's own app data. `TEST_DATA_DIR` is
|
||||
/// thread-local but [`STORE`] is process-global, so [`lock_for_test`] is what
|
||||
/// keeps two tests from sharing one store while pointing at different
|
||||
/// directories.
|
||||
fn isolated() -> (
|
||||
tempfile::TempDir,
|
||||
crate::app_dirs::TestDirGuard,
|
||||
std::sync::MutexGuard<'static, ()>,
|
||||
) {
|
||||
let lock = lock_for_test();
|
||||
let dir = tempfile::TempDir::new().expect("a scratch directory");
|
||||
let guard = crate::app_dirs::set_test_data_dir(dir.path().to_path_buf());
|
||||
// Re-taken after the data dir is redirected, so nothing loads from the
|
||||
// real one.
|
||||
*STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Store::new());
|
||||
(dir, guard, lock)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_live_session_blocks_a_local_launch() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let err = ensure_local_launch_allowed("p1").expect_err("a live session must block a launch");
|
||||
assert!(err.contains("PROFILE_RUNNING_REMOTELY"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_finished_session_still_blocks_until_the_work_is_pulled() {
|
||||
// The whole point. The profile lock is released the moment the session
|
||||
// closes, so without this the user can open the stale copy and the next
|
||||
// sync deletes everything the host wrote.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(note_ended("p1", "s1"));
|
||||
let err = ensure_local_launch_allowed("p1").expect_err("pending work must block a launch");
|
||||
assert!(err.contains("PROFILE_REMOTE_SYNC_PENDING"));
|
||||
|
||||
clear("p1");
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_ungated_profile_is_not_blocked() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(ensure_local_launch_allowed("p2").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_end_transition_is_reported_once_however_often_the_frame_arrives() {
|
||||
// The stream re-delivers a snapshot on every reconnect, and `closed` can
|
||||
// arrive alongside it. Starting a pull per frame would run several
|
||||
// concurrent transfers of the same profile.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
assert!(note_ended("p1", "s1"));
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_closed_session_this_machine_never_watched_does_not_gate_anything() {
|
||||
// `listForUser` returns closed sessions next to live ones, so the snapshot
|
||||
// on every reconnect replays every session that ever finished. Treating
|
||||
// those as fresh handoffs would block the Run button on a perfectly current
|
||||
// profile at each app start, and block it indefinitely while offline.
|
||||
let _iso = isolated();
|
||||
assert!(!note_ended("p1", "s-finished-last-week"));
|
||||
assert_eq!(state_for("p1"), None);
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_pulled_profile_is_not_re_gated_by_a_replayed_close() {
|
||||
// Same frame, one step later: the pull completed and cleared the gate. The
|
||||
// next reconnect must not put it back.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
clear("p1");
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert!(ensure_local_launch_allowed("p1").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_late_close_for_a_replaced_session_does_not_gate_the_new_one() {
|
||||
// Session s1 finished and was pulled; s2 is now live on the same profile. A
|
||||
// straggling `closed` for s1 must not declare s2's profile finished, or the
|
||||
// gate lifts while a host is still writing.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s2");
|
||||
assert!(!note_ended("p1", "s1"));
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_session_that_ended_while_the_app_was_shut_is_recovered() {
|
||||
// Nothing streams a transition to a process that is not running. Without
|
||||
// this the profile reads as still-running for ever and can never be
|
||||
// launched again, and its work is never pulled.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let live: HashSet<String> = HashSet::new();
|
||||
assert_eq!(reconcile(&live), vec!["p1".to_string()]);
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::PendingSync));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_leaves_a_session_that_is_genuinely_still_live() {
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
let live: HashSet<String> = ["s1".to_string()].into_iter().collect();
|
||||
assert!(reconcile(&live).is_empty());
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::Running));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconcile_does_not_reopen_a_pending_profile() {
|
||||
// `PendingSync` is not a session state and no listing will ever contain it.
|
||||
// Re-deriving it from the snapshot would report the same handoff as new on
|
||||
// every reconnect and start a pull each time.
|
||||
let _iso = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
let live: HashSet<String> = HashSet::new();
|
||||
assert!(reconcile(&live).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_gate_survives_a_restart() {
|
||||
// Held on disk precisely because the dangerous window outlives the process:
|
||||
// an app killed mid-session comes back with no memory of it.
|
||||
let (_dir, _guard, _lock) = isolated();
|
||||
note_running("p1", "s1");
|
||||
note_ended("p1", "s1");
|
||||
|
||||
*STORE
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
|
||||
|
||||
assert_eq!(state_for("p1"), Some(HandoffState::PendingSync));
|
||||
}
|
||||
}
|
||||
+1520
-7
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -414,11 +414,41 @@ impl ManifestDiff {
|
||||
}
|
||||
|
||||
/// Compute what needs to be synced between local and remote
|
||||
/// Which side a sync should believe when both have moved.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum DiffBias {
|
||||
/// Newest `updated_at` wins. What an ordinary background sync uses.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Remote wins regardless of timestamps.
|
||||
///
|
||||
/// Used for exactly one thing: the pull that follows a remote session. A
|
||||
/// leased host has just written the authoritative copy of this profile, and
|
||||
/// the local directory is whatever it was before the session started. If the
|
||||
/// user launched locally in between, local mtimes are NEWER than the host's
|
||||
/// push, so `Auto` would upload the stale copy and put every file the host
|
||||
/// wrote into `files_to_delete_remote` — the whole session's work destroyed,
|
||||
/// silently. There is no timestamp comparison that gets this right, because
|
||||
/// the local clock genuinely is later; only the caller knows that the remote
|
||||
/// copy is the one that matters.
|
||||
PreferRemote,
|
||||
}
|
||||
|
||||
pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> ManifestDiff {
|
||||
compute_diff_with_bias(local, remote, DiffBias::Auto)
|
||||
}
|
||||
|
||||
pub fn compute_diff_with_bias(
|
||||
local: &SyncManifest,
|
||||
remote: Option<&SyncManifest>,
|
||||
bias: DiffBias,
|
||||
) -> ManifestDiff {
|
||||
let mut diff = ManifestDiff::default();
|
||||
|
||||
let Some(remote) = remote else {
|
||||
// No remote manifest - upload everything
|
||||
// No remote manifest - upload everything. Even under PreferRemote: there is
|
||||
// no remote copy to prefer, and refusing to upload would leave the profile
|
||||
// with no cloud copy at all.
|
||||
diff.files_to_upload = local.files.clone();
|
||||
return diff;
|
||||
};
|
||||
@@ -446,11 +476,14 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani
|
||||
let local_updated = local.updated_at_datetime();
|
||||
let remote_updated = remote.updated_at_datetime();
|
||||
|
||||
let local_is_newer = match (local_updated, remote_updated) {
|
||||
(Some(l), Some(r)) => l > r,
|
||||
(Some(_), None) => true,
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => true, // Default to uploading
|
||||
let local_is_newer = match bias {
|
||||
DiffBias::PreferRemote => false,
|
||||
DiffBias::Auto => match (local_updated, remote_updated) {
|
||||
(Some(l), Some(r)) => l > r,
|
||||
(Some(_), None) => true,
|
||||
(None, Some(_)) => false,
|
||||
(None, None) => true, // Default to uploading
|
||||
},
|
||||
};
|
||||
|
||||
if local_is_newer {
|
||||
@@ -674,6 +707,68 @@ mod tests {
|
||||
assert!(diff.files_to_delete_remote.is_empty());
|
||||
}
|
||||
|
||||
/// A manifest with one file, at a stated time.
|
||||
fn manifest_at(updated_at: &str, files: &[(&str, &str)]) -> SyncManifest {
|
||||
SyncManifest {
|
||||
version: 1,
|
||||
profile_id: "test".to_string(),
|
||||
generated_at: updated_at.to_string(),
|
||||
updated_at: updated_at.to_string(),
|
||||
exclude_globs: vec![],
|
||||
files: files
|
||||
.iter()
|
||||
.map(|(path, hash)| ManifestFileEntry {
|
||||
path: (*path).to_string(),
|
||||
size: 10,
|
||||
mtime: 1000,
|
||||
hash: (*hash).to_string(),
|
||||
})
|
||||
.collect(),
|
||||
encrypted: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefer_remote_downloads_even_though_the_local_clock_is_later() {
|
||||
// The exact shape of the data-loss bug. A remote session finishes and the
|
||||
// host pushes the profile; the user then launches locally before the pull
|
||||
// lands, so every local mtime is newer than the host's write. Under Auto
|
||||
// that uploads the stale copy and deletes the session's own files.
|
||||
let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "before-session")]);
|
||||
let remote = manifest_at(
|
||||
"2026-01-01T00:00:00Z",
|
||||
&[("Cookies", "after-session"), ("History", "warmed")],
|
||||
);
|
||||
|
||||
let lossy = compute_diff_with_bias(&local, Some(&remote), DiffBias::Auto);
|
||||
assert_eq!(lossy.files_to_delete_remote, vec!["History".to_string()]);
|
||||
assert_eq!(lossy.files_to_upload.len(), 1);
|
||||
|
||||
let safe = compute_diff_with_bias(&local, Some(&remote), DiffBias::PreferRemote);
|
||||
assert!(
|
||||
safe.files_to_delete_remote.is_empty(),
|
||||
"a post-session pull must never delete what the host just wrote"
|
||||
);
|
||||
assert!(safe.files_to_upload.is_empty());
|
||||
let downloaded: Vec<&str> = safe
|
||||
.files_to_download
|
||||
.iter()
|
||||
.map(|f| f.path.as_str())
|
||||
.collect();
|
||||
assert_eq!(downloaded.len(), 2);
|
||||
assert!(downloaded.contains(&"Cookies"));
|
||||
assert!(downloaded.contains(&"History"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefer_remote_still_uploads_when_there_is_no_remote_copy() {
|
||||
// Nothing to prefer. Refusing to upload here would leave a profile with no
|
||||
// cloud copy because a session once ran against it.
|
||||
let local = manifest_at("2026-01-02T00:00:00Z", &[("Cookies", "only-local")]);
|
||||
let diff = compute_diff_with_bias(&local, None, DiffBias::PreferRemote);
|
||||
assert_eq!(diff.files_to_upload.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_diff_detect_changes() {
|
||||
let old_time = "2024-01-01T00:00:00Z";
|
||||
|
||||
@@ -15,12 +15,16 @@ pub use engine::{
|
||||
enable_proxy_sync_if_needed, enable_sync_for_all_entities, enable_vpn_sync_if_needed,
|
||||
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_group_used_by_synced_profile,
|
||||
is_proxy_in_use_by_synced_profile, is_proxy_used_by_synced_profile, is_sync_configured,
|
||||
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile, request_profile_sync,
|
||||
rollover_encryption_for_all_entities, set_extension_group_sync_enabled,
|
||||
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
|
||||
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
|
||||
is_vpn_in_use_by_synced_profile, is_vpn_used_by_synced_profile,
|
||||
pull_profile_after_remote_session, request_profile_sync, rollover_encryption_for_all_entities,
|
||||
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
|
||||
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile,
|
||||
trigger_sync_for_profile, ProfileSyncOutcome, SyncEngine,
|
||||
};
|
||||
pub use manifest::{
|
||||
compute_diff, compute_diff_with_bias, generate_manifest, DiffBias, HashCache, ManifestDiff,
|
||||
SyncManifest,
|
||||
};
|
||||
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
|
||||
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
|
||||
pub use subscription::{SubscriptionManager, SyncWorkItem};
|
||||
pub use types::{SyncError, SyncResult};
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -168,7 +168,7 @@ impl SynchronizerManager {
|
||||
);
|
||||
|
||||
// Launch leader first so it gets focus
|
||||
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None)
|
||||
crate::browser_runner::launch_browser_profile(app_handle.clone(), leader.clone(), None, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch leader: {e}"))?;
|
||||
|
||||
@@ -179,7 +179,7 @@ impl SynchronizerManager {
|
||||
let ah = app_handle.clone();
|
||||
let fp = fp.clone();
|
||||
set.spawn(async move {
|
||||
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None)
|
||||
crate::browser_runner::launch_browser_profile(ah, fp.clone(), None, None)
|
||||
.await
|
||||
.map_err(|e| (fp.name.clone(), e.to_string()))
|
||||
});
|
||||
|
||||
+102
-20
@@ -95,8 +95,8 @@ impl ProfileLockManager {
|
||||
|
||||
pub async fn acquire_lock(&self, profile_id: &str) -> Result<(), String> {
|
||||
let client = Client::new();
|
||||
let access_token =
|
||||
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
||||
let access_token = CloudAuthManager::load_access_token()?
|
||||
.ok_or_else(|| crate::backend_error("PROFILE_LOCK_UNAVAILABLE"))?;
|
||||
|
||||
let url = format!("{CLOUD_API_URL}/api/profile-locks/{profile_id}");
|
||||
let response = client
|
||||
@@ -104,24 +104,29 @@ impl ProfileLockManager {
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to acquire lock: {e}"))?;
|
||||
.map_err(|e| {
|
||||
log::warn!("Failed to acquire profile lock for {profile_id}: {e}");
|
||||
crate::backend_error("PROFILE_LOCK_UNAVAILABLE")
|
||||
})?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Lock acquisition failed ({status}): {body}"));
|
||||
log::warn!("Profile lock acquisition for {profile_id} failed ({status}): {body}");
|
||||
return Err(crate::backend_error("PROFILE_LOCK_UNAVAILABLE"));
|
||||
}
|
||||
|
||||
let result: AcquireLockResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse lock response: {e}"))?;
|
||||
let result: AcquireLockResponse = response.json().await.map_err(|e| {
|
||||
log::warn!("Could not parse the profile lock response for {profile_id}: {e}");
|
||||
crate::backend_error("PROFILE_LOCK_UNAVAILABLE")
|
||||
})?;
|
||||
|
||||
if !result.success {
|
||||
let email = result
|
||||
.locked_by_email
|
||||
.unwrap_or_else(|| "another device".to_string());
|
||||
return Err(format!("Profile is in use by {email}"));
|
||||
return Err(lock_conflict_error(
|
||||
profile_id,
|
||||
result.locked_by.as_deref(),
|
||||
result.locked_by_email.as_deref(),
|
||||
));
|
||||
}
|
||||
|
||||
// Update local cache
|
||||
@@ -274,15 +279,51 @@ impl ProfileLockManager {
|
||||
}
|
||||
}
|
||||
|
||||
/// Separator the backend puts between a user id and a non-desktop holder's
|
||||
/// sub-identity. Mirrors `HOLDER_SEPARATOR` in donutbrowser-infra's
|
||||
/// `profile-locks.service.ts`.
|
||||
///
|
||||
/// A remote VM session takes the lock under `<user id>:vm:<session id>` so it
|
||||
/// contends with this desktop instead of silently sharing its lock. That makes
|
||||
/// the holder string the one place a client can tell "a teammate has this open"
|
||||
/// apart from "this is my own profile, running on the fleet" — two refusals that
|
||||
/// need completely different words.
|
||||
const VM_HOLDER_SEPARATOR: &str = ":vm:";
|
||||
|
||||
/// The `{"code":…}` for a lock this caller could not take.
|
||||
fn lock_conflict_error(
|
||||
profile_id: &str,
|
||||
holder: Option<&str>,
|
||||
holder_email: Option<&str>,
|
||||
) -> String {
|
||||
if holder.is_some_and(|id| id.contains(VM_HOLDER_SEPARATOR)) {
|
||||
// The user's own remote session. Saying "in use by you@example.com" here,
|
||||
// which is what the raw backend message did, reads as a bug.
|
||||
log::info!("Profile {profile_id} is held by a remote session");
|
||||
return crate::backend_error("PROFILE_RUNNING_REMOTELY");
|
||||
}
|
||||
match holder_email {
|
||||
Some(email) if !email.is_empty() => serde_json::json!({
|
||||
"code": "PROFILE_LOCKED_BY_MEMBER",
|
||||
"params": { "email": email }
|
||||
})
|
||||
.to_string(),
|
||||
_ => crate::backend_error("PROFILE_LOCKED_ELSEWHERE"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire profile lock if profile is sync-enabled and user has a paid subscription.
|
||||
/// Returns whether a lock was actually taken, so a caller that unwinds a failed
|
||||
/// launch releases only what it acquired. Releasing unconditionally would drop
|
||||
/// a lock a REST handler up the stack still owns.
|
||||
pub async fn acquire_team_lock_if_needed(
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
) -> Result<(), String> {
|
||||
) -> Result<bool, String> {
|
||||
if !profile.is_sync_enabled() {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Ok(());
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
// Ensure lock manager is connected
|
||||
@@ -294,13 +335,18 @@ pub async fn acquire_team_lock_if_needed(
|
||||
.is_locked_by_another(&profile.id.to_string())
|
||||
.await
|
||||
{
|
||||
if let Some(lock) = PROFILE_LOCK.get_lock_status(&profile.id.to_string()).await {
|
||||
return Err(format!("Profile is in use by {}", lock.locked_by_email));
|
||||
}
|
||||
return Err("Profile is in use on another device".to_string());
|
||||
let held = PROFILE_LOCK.get_lock_status(&profile.id.to_string()).await;
|
||||
return Err(lock_conflict_error(
|
||||
&profile.id.to_string(),
|
||||
held.as_ref().map(|lock| lock.locked_by.as_str()),
|
||||
held.as_ref().map(|lock| lock.locked_by_email.as_str()),
|
||||
));
|
||||
}
|
||||
|
||||
PROFILE_LOCK.acquire_lock(&profile.id.to_string()).await
|
||||
PROFILE_LOCK
|
||||
.acquire_lock(&profile.id.to_string())
|
||||
.await
|
||||
.map(|()| true)
|
||||
}
|
||||
|
||||
/// Release profile lock if profile is sync-enabled and user has a paid subscription.
|
||||
@@ -328,3 +374,39 @@ pub async fn get_team_locks() -> Result<Vec<ProfileLockInfo>, String> {
|
||||
pub async fn get_team_lock_status(profile_id: String) -> Result<Option<ProfileLockInfo>, String> {
|
||||
Ok(PROFILE_LOCK.get_lock_status(&profile_id).await)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_users_own_remote_session_is_not_reported_as_a_teammate() {
|
||||
// The holder for a fleet session is `<user id>:vm:<session id>` and the row
|
||||
// carries the OWNER's email, so the previous message read "Profile is in use
|
||||
// by you@example.com" — the user's own address, about their own profile.
|
||||
let err = lock_conflict_error(
|
||||
"p1",
|
||||
Some("11111111-2222-3333-4444-555555555555:vm:run-remote:p1:abc"),
|
||||
Some("owner@example.com"),
|
||||
);
|
||||
assert_eq!(err, r#"{"code":"PROFILE_RUNNING_REMOTELY"}"#);
|
||||
assert!(!err.contains("owner@example.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_teammates_lock_names_them_through_a_translatable_code() {
|
||||
let err = lock_conflict_error("p1", Some("other-user-id"), Some("mate@example.com"));
|
||||
let json: serde_json::Value = serde_json::from_str(&err).expect("a code envelope");
|
||||
assert_eq!(json["code"], "PROFILE_LOCKED_BY_MEMBER");
|
||||
assert_eq!(json["params"]["email"], "mate@example.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_lock_with_no_identifiable_holder_still_produces_a_code() {
|
||||
// Raw English here is what reaches a Russian user untranslated.
|
||||
for holder in [None, Some("")] {
|
||||
let err = lock_conflict_error("p1", holder, None);
|
||||
assert_eq!(err, r#"{"code":"PROFILE_LOCKED_ELSEWHERE"}"#);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,709 @@
|
||||
//! Enumerates extensions the user installed from inside the browser, by
|
||||
//! walking the Chromium profile directory on disk.
|
||||
//!
|
||||
//! Depends only on `std`, `serde_json`, the sibling `rules` module, and the
|
||||
//! shared profile-directory-name predicate, so the directory-layout handling —
|
||||
//! the riskiest part of detection — stays testable without app state.
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use super::rules::{
|
||||
classify, lookup_message, manifest_str, message_placeholder_key, signal_labels,
|
||||
signals_from_manifest, version_dir_sort_key, vpn_keyword_hit, DetectedVpnExtension,
|
||||
};
|
||||
|
||||
/// Upper bound on extension directories walked per profile. A launch must not
|
||||
/// stall behind a pathological profile; whatever was found is still reported,
|
||||
/// flagged `partial`.
|
||||
const MAX_EXTENSION_DIRS: usize = 300;
|
||||
/// Manifests are a few KiB. Anything past this is not a manifest we can use.
|
||||
const MAX_MANIFEST_BYTES: u64 = 512 * 1024;
|
||||
/// Wall-clock ceiling for the whole scan. This runs on the launch path.
|
||||
const SCAN_DEADLINE: Duration = Duration::from_millis(750);
|
||||
/// Chromium preference files are larger than a manifest but still bounded; a
|
||||
/// pathological one must not be parsed while the user waits for a browser.
|
||||
const MAX_PREFERENCES_BYTES: u64 = 32 * 1024 * 1024;
|
||||
|
||||
/// Chromium profile directories to search inside a user-data dir.
|
||||
///
|
||||
/// Two layouts are real here: Donut launches Wayfern with only
|
||||
/// `--user-data-dir`, so Chromium uses `Default/`; but an imported profile is
|
||||
/// copied in as the profile directory itself, putting `Extensions/` at the
|
||||
/// root. Checking only one layout misses every profile of the other kind.
|
||||
fn candidate_profile_dirs(user_data_dir: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
if user_data_dir.join("Preferences").exists() || user_data_dir.join("Extensions").is_dir() {
|
||||
dirs.push(user_data_dir.to_path_buf());
|
||||
}
|
||||
|
||||
if let Ok(entries) = std::fs::read_dir(user_data_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(name) = path.file_name().and_then(|n| n.to_str()) else {
|
||||
continue;
|
||||
};
|
||||
if crate::profile::clear_on_close::is_profile_dir_name(name)
|
||||
|| path.join("Preferences").exists()
|
||||
{
|
||||
dirs.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dirs.sort();
|
||||
dirs.dedup();
|
||||
dirs
|
||||
}
|
||||
|
||||
fn read_json_file(path: &Path, max_bytes: Option<u64>) -> Option<serde_json::Value> {
|
||||
let metadata = std::fs::metadata(path).ok()?;
|
||||
if !metadata.is_file() {
|
||||
return None;
|
||||
}
|
||||
if max_bytes.is_some_and(|cap| metadata.len() > cap) {
|
||||
return None;
|
||||
}
|
||||
serde_json::from_str(&std::fs::read_to_string(path).ok()?).ok()
|
||||
}
|
||||
|
||||
/// Chromium locale directory names are `[A-Za-z0-9_-]` (`en`, `en_GB`,
|
||||
/// `zh_CN`). Anything else in a manifest we did not write is untrusted input
|
||||
/// being joined into a filesystem path, so it is refused rather than sanitized
|
||||
/// — this runs on the launch path against extensions the user may have
|
||||
/// sideloaded.
|
||||
fn is_safe_locale_name(name: &str) -> bool {
|
||||
!name.is_empty()
|
||||
&& name.len() <= 32
|
||||
&& name
|
||||
.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
|
||||
}
|
||||
|
||||
fn resolve_dir_i18n(
|
||||
version_dir: &Path,
|
||||
manifest: &serde_json::Value,
|
||||
value: &str,
|
||||
) -> Option<String> {
|
||||
let key = message_placeholder_key(value)?;
|
||||
let default_locale = manifest.get("default_locale")?.as_str()?;
|
||||
if !is_safe_locale_name(default_locale) {
|
||||
log::warn!("Ignoring extension with a suspicious default_locale: {default_locale:?}");
|
||||
return None;
|
||||
}
|
||||
let messages = read_json_file(
|
||||
&version_dir
|
||||
.join("_locales")
|
||||
.join(default_locale)
|
||||
.join("messages.json"),
|
||||
Some(MAX_MANIFEST_BYTES),
|
||||
)?;
|
||||
lookup_message(&messages, &key)
|
||||
}
|
||||
|
||||
/// What the profile's preference files say about installed extensions.
|
||||
///
|
||||
/// Read-only on purpose: `Secure Preferences` is MAC-protected, and rewriting
|
||||
/// it invalidates the signature, which disables every extension in the profile.
|
||||
#[derive(Default)]
|
||||
struct PreferenceExtensions {
|
||||
/// Explicitly disabled. A disabled extension cannot touch the proxy, so
|
||||
/// warning about it would be a false alarm.
|
||||
disabled: HashSet<String>,
|
||||
/// Unpacked ("Load unpacked" / developer mode) extensions, which live
|
||||
/// OUTSIDE `Extensions/` and are therefore invisible to the directory walk.
|
||||
/// Sideloading is exactly how someone gets a VPN extension in without the
|
||||
/// Web Store, so missing these would leave the obvious hole open.
|
||||
unpacked: Vec<(String, PathBuf)>,
|
||||
}
|
||||
|
||||
fn preference_extensions(profile_dir: &Path) -> PreferenceExtensions {
|
||||
let mut out = PreferenceExtensions::default();
|
||||
for file in ["Secure Preferences", "Preferences"] {
|
||||
// Larger than a manifest, but still capped: this is parsed while the user
|
||||
// waits for a browser to start.
|
||||
let Some(prefs) = read_json_file(&profile_dir.join(file), Some(MAX_PREFERENCES_BYTES)) else {
|
||||
continue;
|
||||
};
|
||||
let Some(settings) = prefs
|
||||
.get("extensions")
|
||||
.and_then(|e| e.get("settings"))
|
||||
.and_then(|s| s.as_object())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
for (id, entry) in settings {
|
||||
// Chromium's Extension::State: 0 = disabled.
|
||||
if entry.get("state").and_then(serde_json::Value::as_i64) == Some(0) {
|
||||
out.disabled.insert(id.clone());
|
||||
continue;
|
||||
}
|
||||
// A packed extension's `path` is relative to Extensions/; an unpacked
|
||||
// one records an absolute path elsewhere on disk.
|
||||
if let Some(path) = entry.get("path").and_then(|p| p.as_str()) {
|
||||
let path = PathBuf::from(path);
|
||||
if path.is_absolute() {
|
||||
out.unpacked.push((id.clone(), path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Walk a user-data dir for VPN/proxy extensions.
|
||||
///
|
||||
/// Returns false when the walk was cut short by a cap or the deadline, so the
|
||||
/// caller can report the scan as incomplete rather than clean.
|
||||
pub(super) fn scan_browser_extensions(
|
||||
user_data_dir: &Path,
|
||||
out: &mut Vec<DetectedVpnExtension>,
|
||||
started: Instant,
|
||||
) -> bool {
|
||||
let mut walked = 0usize;
|
||||
|
||||
for profile_dir in candidate_profile_dirs(user_data_dir) {
|
||||
// Read preferences first: unpacked extensions live outside Extensions/, so
|
||||
// a profile that has only sideloaded ones has no Extensions/ dir at all and
|
||||
// must not be skipped before they are considered.
|
||||
if started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
let prefs = preference_extensions(&profile_dir);
|
||||
let disabled = &prefs.disabled;
|
||||
|
||||
for (crx_id, unpacked_dir) in &prefs.unpacked {
|
||||
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
walked += 1;
|
||||
if let Some(found) = detect_in_version_dir(crx_id, unpacked_dir) {
|
||||
out.push(found);
|
||||
}
|
||||
}
|
||||
|
||||
let Ok(entries) = std::fs::read_dir(profile_dir.join("Extensions")) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if walked >= MAX_EXTENSION_DIRS || started.elapsed() > SCAN_DEADLINE {
|
||||
return false;
|
||||
}
|
||||
walked += 1;
|
||||
|
||||
let ext_dir = entry.path();
|
||||
if !ext_dir.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(crx_id) = ext_dir
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(str::to_string)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if disabled.contains(&crx_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Several versions can coexist on disk; Chromium runs the highest.
|
||||
let Ok(version_entries) = std::fs::read_dir(&ext_dir) else {
|
||||
continue;
|
||||
};
|
||||
let mut versions: Vec<PathBuf> = version_entries
|
||||
.flatten()
|
||||
.map(|e| e.path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
versions.sort_by_key(|p| {
|
||||
p.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(version_dir_sort_key)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let Some(version_dir) = versions.last() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if let Some(found) = detect_in_version_dir(&crx_id, version_dir) {
|
||||
out.push(found);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
/// Classify the extension whose unpacked files live in `version_dir`.
|
||||
/// Shared by the packed walk and the unpacked (developer-mode) entries.
|
||||
fn detect_in_version_dir(crx_id: &str, version_dir: &Path) -> Option<DetectedVpnExtension> {
|
||||
let manifest = read_json_file(&version_dir.join("manifest.json"), Some(MAX_MANIFEST_BYTES))?;
|
||||
|
||||
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| crx_id.to_string());
|
||||
let name = resolve_dir_i18n(version_dir, &manifest, &raw_name).unwrap_or_else(|| {
|
||||
if message_placeholder_key(&raw_name).is_some() {
|
||||
crx_id.to_string()
|
||||
} else {
|
||||
raw_name.clone()
|
||||
}
|
||||
});
|
||||
let description = manifest_str(&manifest, "description").and_then(|d| {
|
||||
resolve_dir_i18n(version_dir, &manifest, &d).or(if message_placeholder_key(&d).is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(d)
|
||||
})
|
||||
});
|
||||
|
||||
let signals = signals_from_manifest(&manifest);
|
||||
let keyword = vpn_keyword_hit(&name, description.as_deref());
|
||||
let confidence = classify(Some(crx_id), &signals, keyword)?;
|
||||
|
||||
Some(DetectedVpnExtension {
|
||||
key: format!("crx:{crx_id}"),
|
||||
name,
|
||||
version: manifest_str(&manifest, "version"),
|
||||
source: "browser".to_string(),
|
||||
confidence: confidence.to_string(),
|
||||
proxy_control: signals.proxy_permission,
|
||||
signals: signal_labels(Some(crx_id), &signals, keyword),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs;
|
||||
|
||||
fn write(path: &Path, contents: &str) {
|
||||
fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||
fs::write(path, contents).unwrap();
|
||||
}
|
||||
|
||||
/// Build a Chromium `Preferences` blob for one extension.
|
||||
///
|
||||
/// Serialized rather than string-interpolated on purpose: a Windows path is
|
||||
/// `C:\Users\...`, and pasting it into a JSON string literal produces invalid
|
||||
/// escape sequences, so the file silently fails to parse and every assertion
|
||||
/// about it passes for the wrong reason.
|
||||
fn preferences_json(crx_id: &str, state: i64, path: &Path) -> String {
|
||||
serde_json::json!({
|
||||
"extensions": {
|
||||
"settings": {
|
||||
crx_id: { "state": state, "path": path.to_string_lossy() }
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
const VPN_MANIFEST: &str = r#"{"name":"Turbo VPN","version":"2.1.0","permissions":["proxy"]}"#;
|
||||
const CRX_ID: &str = "abcdefghijklmnopabcdefghijklmnop";
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_accepts_the_default_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Default").join("Preferences"), "{}");
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.join("Default")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_accepts_the_imported_root_layout() {
|
||||
// profile_importer copies a Chromium profile dir in as the root, so
|
||||
// Preferences and Extensions/ sit at the top level.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Preferences"), "{}");
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.to_path_buf()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn candidate_dirs_finds_named_profile_dirs_without_preferences() {
|
||||
// Chromium writes Preferences lazily, so a populated Default/ can lack it.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
fs::create_dir_all(root.join("Profile 2").join("Extensions")).unwrap();
|
||||
assert_eq!(candidate_profile_dirs(root), vec![root.join("Profile 2")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_a_vpn_extension_in_the_default_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
|
||||
assert_eq!(out[0].name, "Turbo VPN");
|
||||
assert_eq!(out[0].confidence, "confirmed");
|
||||
assert_eq!(out[0].source, "browser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_a_vpn_extension_in_the_imported_root_layout() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(&root.join("Preferences"), "{}");
|
||||
write(
|
||||
&root
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reads_the_highest_version_directory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let ext = root.join("Default").join("Extensions").join(CRX_ID);
|
||||
// Lexicographically "1.9.0_0" > "1.10.0_0"; numerically it is not.
|
||||
write(
|
||||
&ext.join("1.9.0_0").join("manifest.json"),
|
||||
r#"{"name":"Old","version":"1.9.0","permissions":["storage"]}"#,
|
||||
);
|
||||
write(
|
||||
&ext.join("1.10.0_0").join("manifest.json"),
|
||||
r#"{"name":"New VPN","version":"1.10.0","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].version.as_deref(), Some("1.10.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_skips_extensions_chromium_has_disabled() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let profile = root.join("Default");
|
||||
write(
|
||||
&profile
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
write(
|
||||
&profile.join("Secure Preferences"),
|
||||
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":0}}}}}}}}"#),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(
|
||||
out.is_empty(),
|
||||
"a disabled extension cannot touch the proxy"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_keeps_enabled_extensions_listed_in_preferences() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let profile = root.join("Default");
|
||||
write(
|
||||
&profile
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
write(
|
||||
&profile.join("Secure Preferences"),
|
||||
&format!(r#"{{"extensions":{{"settings":{{"{CRX_ID}":{{"state":1}}}}}}}}"#),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_resolves_a_localized_extension_name() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let version_dir = root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0");
|
||||
write(
|
||||
&version_dir.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"en","permissions":["proxy"]}"#,
|
||||
);
|
||||
write(
|
||||
&version_dir
|
||||
.join("_locales")
|
||||
.join("en")
|
||||
.join("messages.json"),
|
||||
r#"{"appName":{"message":"Nord VPN"}}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out[0].name, "Nord VPN");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_falls_back_to_the_crx_id_when_a_placeholder_cannot_be_resolved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out[0].name, CRX_ID, "never show a raw __MSG_ placeholder");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reports_a_proxy_holding_download_manager_as_a_capability() {
|
||||
// End-to-end shape of the false positive that prompted the audit: the
|
||||
// extension must still be surfaced (it really can change the proxy) but
|
||||
// never as a VPN.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join("ngpampappnmepgilojfohadhhmbhlaek")
|
||||
.join("6.43.1_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"IDM Integration Module","version":"6.43.1","description":"Download files with Internet Download Manager","permissions":["downloads","storage","proxy","nativeMessaging"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].confidence, "capability");
|
||||
assert!(out[0].proxy_control);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_confirms_a_known_vpn_whose_name_gives_nothing_away() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join("nlbejmccbhkncgokjcmghpfloaajcffj")
|
||||
.join("10.0.0_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"Hotspot Shield","version":"10.0.0","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].confidence, "confirmed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_ignores_an_ordinary_extension() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("1.0.0_0")
|
||||
.join("manifest.json"),
|
||||
r#"{"name":"Dark Reader","version":"1.0.0","permissions":["storage","activeTab"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_reports_incomplete_when_the_deadline_has_passed() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
VPN_MANIFEST,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
// A start time already past the deadline stands in for a slow disk.
|
||||
let expired = Instant::now() - SCAN_DEADLINE - Duration::from_millis(10);
|
||||
assert!(!scan_browser_extensions(root, &mut out, expired));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_survives_a_malformed_manifest() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0")
|
||||
.join("manifest.json"),
|
||||
"{ not json",
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preferences_json_escapes_windows_style_paths() {
|
||||
// The Windows CI failure this guards: a raw `C:\Users\...` pasted into a
|
||||
// JSON string literal is invalid (`\U` is not an escape), so Preferences
|
||||
// failed to parse, `preference_extensions` returned nothing, and the
|
||||
// unpacked extension silently vanished — on Linux the same test passed
|
||||
// because POSIX paths contain no backslashes.
|
||||
let json = preferences_json(CRX_ID, 1, Path::new(r"C:\Users\runner\ext\my-vpn"));
|
||||
let parsed: serde_json::Value =
|
||||
serde_json::from_str(&json).expect("Preferences must be valid JSON on every platform");
|
||||
assert_eq!(
|
||||
parsed["extensions"]["settings"][CRX_ID]["path"],
|
||||
r"C:\Users\runner\ext\my-vpn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_finds_an_unpacked_developer_mode_extension() {
|
||||
// Sideloading via "Load unpacked" is exactly how a VPN extension gets in
|
||||
// without the Web Store, and those files live outside Extensions/.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let unpacked = root.join("somewhere-else").join("my-vpn");
|
||||
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&preferences_json(CRX_ID, 1, &unpacked),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert_eq!(out.len(), 1, "unpacked extensions must not be invisible");
|
||||
assert_eq!(out[0].key, format!("crx:{CRX_ID}"));
|
||||
assert_eq!(out[0].confidence, "confirmed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_ignores_a_disabled_unpacked_extension() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let unpacked = root.join("somewhere-else").join("my-vpn");
|
||||
write(&unpacked.join("manifest.json"), VPN_MANIFEST);
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&preferences_json(CRX_ID, 0, &unpacked),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn packed_relative_paths_are_not_treated_as_unpacked() {
|
||||
// A packed extension records a path relative to Extensions/; following it
|
||||
// as if absolute would read the wrong place (or nothing).
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
write(
|
||||
&root.join("Default").join("Preferences"),
|
||||
&preferences_json(CRX_ID, 1, Path::new(&format!("{CRX_ID}/2.1.0_0"))),
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversing_default_locale_is_refused() {
|
||||
// `default_locale` comes from a manifest we did not write. Joined naively
|
||||
// it reads any file the user can read, on the launch path.
|
||||
assert!(!is_safe_locale_name("../../../../etc"));
|
||||
assert!(!is_safe_locale_name("..\\..\\windows"));
|
||||
assert!(!is_safe_locale_name("/etc/passwd"));
|
||||
assert!(!is_safe_locale_name(""));
|
||||
assert!(is_safe_locale_name("en"));
|
||||
assert!(is_safe_locale_name("en_GB"));
|
||||
assert!(is_safe_locale_name("zh-CN"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_localized_name_with_a_traversing_locale_is_not_resolved() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
let version_dir = root
|
||||
.join("Default")
|
||||
.join("Extensions")
|
||||
.join(CRX_ID)
|
||||
.join("2.1.0_0");
|
||||
write(
|
||||
&version_dir.join("manifest.json"),
|
||||
r#"{"name":"__MSG_appName__","version":"2.1.0","default_locale":"../../../../etc","permissions":["proxy"]}"#,
|
||||
);
|
||||
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(root, &mut out, Instant::now()));
|
||||
// Still detected (the proxy permission is what matters), but the name
|
||||
// falls back rather than the traversal being followed.
|
||||
assert_eq!(out.len(), 1);
|
||||
assert_eq!(out[0].name, CRX_ID);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_tolerates_a_missing_user_data_dir() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let mut out = Vec::new();
|
||||
assert!(scan_browser_extensions(
|
||||
&tmp.path().join("nope"),
|
||||
&mut out,
|
||||
Instant::now()
|
||||
));
|
||||
assert!(out.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
//! Detects VPN/proxy browser extensions present in a profile.
|
||||
//!
|
||||
//! An extension holding Chromium's `proxy` permission can override the proxy
|
||||
//! Donut passes on the command line, so the browser's real exit stops being the
|
||||
//! one Donut measured and generated the fingerprint against. That produces
|
||||
//! exactly the geo/timezone/language mismatch the fingerprint exists to avoid,
|
||||
//! except Donut cannot observe it from the outside — hence a launch-time
|
||||
//! warning rather than a measurement.
|
||||
//!
|
||||
//! That permission is a capability, not an identity. Chromium exposes no
|
||||
//! read-only variant of it, so a download manager replicating the browser's
|
||||
//! route for its own transfers declares exactly what a VPN hijacking it
|
||||
//! declares. The two are reported as different things — see `rules::classify`.
|
||||
//!
|
||||
//! Two sources, deliberately both: Donut-managed extensions live in the app's
|
||||
//! own store and are handed to Chromium via `--load-extension` from *outside*
|
||||
//! the profile directory, while extensions the user installed from the Web
|
||||
//! Store live *inside* it. Neither set appears in the other.
|
||||
|
||||
mod browser_scan;
|
||||
mod rules;
|
||||
|
||||
// `message_placeholder_key`/`lookup_message` are shared with
|
||||
// `extension_manager`, which resolves the same placeholders out of a zip.
|
||||
use rules::{classify, manifest_str, signal_labels, signals_from_manifest, vpn_keyword_hit};
|
||||
pub use rules::{lookup_message, message_placeholder_key, DetectedVpnExtension};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::time::Instant;
|
||||
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ExtensionScan {
|
||||
pub extensions: Vec<DetectedVpnExtension>,
|
||||
/// `scanned` | `partial` | `encrypted` | `ephemeral` | `missing`.
|
||||
///
|
||||
/// Reported honestly so the dialog can say the scan was incomplete rather
|
||||
/// than implying a clean profile it never managed to read.
|
||||
pub scan_state: String,
|
||||
}
|
||||
|
||||
/// Donut-managed extensions, reached through the profile's extension group.
|
||||
///
|
||||
/// Read live from the stored archive rather than from the metadata cached on
|
||||
/// `Extension`, so replacing an extension's file cannot leave a stale verdict
|
||||
/// behind. N is the group size — typically a handful.
|
||||
fn scan_donut_extensions(profile: &BrowserProfile, out: &mut Vec<DetectedVpnExtension>) {
|
||||
let Some(group_id) = &profile.extension_group_id else {
|
||||
return;
|
||||
};
|
||||
let Ok(manager) = crate::extension_manager::EXTENSION_MANAGER.lock() else {
|
||||
log::warn!("VPN extension scan: extension manager lock poisoned, skipping managed extensions");
|
||||
return;
|
||||
};
|
||||
let Ok(group) = manager.get_group(group_id) else {
|
||||
return;
|
||||
};
|
||||
|
||||
for ext_id in &group.extension_ids {
|
||||
let Ok(ext) = manager.get_extension(ext_id) else {
|
||||
continue;
|
||||
};
|
||||
let path = manager.get_file_dir_public(ext_id).join(&ext.file_name);
|
||||
let Ok(data) = std::fs::read(&path) else {
|
||||
continue;
|
||||
};
|
||||
let Some(manifest) =
|
||||
crate::extension_manager::read_manifest_from_archive(&data, &ext.file_type)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
|
||||
let raw_name = manifest_str(&manifest, "name").unwrap_or_else(|| ext.name.clone());
|
||||
let name =
|
||||
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &raw_name)
|
||||
.unwrap_or_else(|| {
|
||||
// An unresolvable placeholder is not a name — fall back to the one
|
||||
// the extension carries in Donut.
|
||||
if message_placeholder_key(&raw_name).is_some() {
|
||||
ext.name.clone()
|
||||
} else {
|
||||
raw_name.clone()
|
||||
}
|
||||
});
|
||||
let description = manifest_str(&manifest, "description").and_then(|d| {
|
||||
crate::extension_manager::resolve_archive_i18n(&data, &ext.file_type, &manifest, &d).or(
|
||||
if message_placeholder_key(&d).is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(d)
|
||||
},
|
||||
)
|
||||
});
|
||||
|
||||
let signals = signals_from_manifest(&manifest);
|
||||
let keyword = vpn_keyword_hit(&name, description.as_deref());
|
||||
// A Donut-managed extension is stored under Donut's own uuid, not the Web
|
||||
// Store id the known-VPN list is keyed on, so it is classified on what its
|
||||
// manifest says about itself.
|
||||
let Some(confidence) = classify(None, &signals, keyword) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
out.push(DetectedVpnExtension {
|
||||
key: format!("donut:{ext_id}"),
|
||||
name,
|
||||
version: manifest_str(&manifest, "version").or_else(|| ext.version.clone()),
|
||||
source: "donut".to_string(),
|
||||
confidence: confidence.to_string(),
|
||||
proxy_control: signals.proxy_permission,
|
||||
signals: signal_labels(None, &signals, keyword),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan a profile for VPN/proxy extensions from both sources.
|
||||
///
|
||||
/// Never fails: an unreadable profile reports whatever it could see plus a
|
||||
/// `scan_state` explaining why the picture is incomplete.
|
||||
pub fn scan_profile(profile: &BrowserProfile) -> ExtensionScan {
|
||||
let started = Instant::now();
|
||||
let mut extensions = Vec::new();
|
||||
|
||||
scan_donut_extensions(profile, &mut extensions);
|
||||
|
||||
let profiles_dir = crate::app_dirs::profiles_dir();
|
||||
let user_data_dir = crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
|
||||
// `get_effective_profile_path` only returns the decrypted RAM copy while the
|
||||
// profile is unlocked; locked, it falls back to the on-disk directory, which
|
||||
// exists but is ciphertext. Walking that finds nothing — so the state has to
|
||||
// be decided on whether we actually got a readable copy, not on the path
|
||||
// existing, or a locked profile reports as verified-clean.
|
||||
let has_plaintext_dir = !(profile.password_protected || profile.ephemeral)
|
||||
|| crate::ephemeral_dirs::get_ephemeral_dir(&profile.id.to_string()).is_some();
|
||||
|
||||
let scan_state = if !has_plaintext_dir {
|
||||
if profile.password_protected {
|
||||
"encrypted"
|
||||
} else {
|
||||
"ephemeral"
|
||||
}
|
||||
} else if !user_data_dir.is_dir() {
|
||||
// Never launched, so there is no profile directory to inspect yet.
|
||||
"missing"
|
||||
} else if browser_scan::scan_browser_extensions(&user_data_dir, &mut extensions, started) {
|
||||
"scanned"
|
||||
} else {
|
||||
"partial"
|
||||
};
|
||||
|
||||
// Collapse only exact duplicates of the same extension. `key` is the real
|
||||
// identity (`donut:<uuid>` / `crx:<id>`); name+version is not, and two
|
||||
// distinct extensions sharing a display name would silently fold into one,
|
||||
// hiding a real detection behind an unrelated namesake.
|
||||
let mut seen = HashSet::new();
|
||||
extensions.retain(|e| seen.insert(e.key.clone()));
|
||||
|
||||
ExtensionScan {
|
||||
extensions,
|
||||
scan_state: scan_state.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// True when at least one extension holds the `proxy` permission outright, so
|
||||
/// it can redirect the browser's traffic without asking for anything further.
|
||||
///
|
||||
/// Informational: it tells the user an exit measurement may describe a route
|
||||
/// the browser will not take. It deliberately does not relax the gate — a
|
||||
/// measurement that might be wrong is a reason for more scrutiny, not less,
|
||||
/// and this signal is true for every download manager on the machine.
|
||||
pub fn has_proxy_control(scan: &ExtensionScan) -> bool {
|
||||
scan.extensions.iter().any(|e| e.proxy_control)
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
//! Pure classification rules for VPN/proxy extension detection.
|
||||
//!
|
||||
//! Deliberately free of crate-internal dependencies (`std` + `serde_json`
|
||||
//! only): these rules are the heart of the feature and the part most worth
|
||||
//! testing in isolation, so nothing here may reach for app state, the
|
||||
//! filesystem, or the network. Enumerating the two extension sources and
|
||||
//! reading them off disk lives in the parent module.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Chrome Web Store ids of extensions whose whole purpose is routing the
|
||||
/// browser somewhere else. Sorted, so membership is a binary search.
|
||||
///
|
||||
/// This list is what lets a VPN with an unrevealing name — "Hotspot Shield"
|
||||
/// says nothing about what it does — be named as one instead of appearing as
|
||||
/// an anonymous holder of the proxy permission. Every id was verified by
|
||||
/// downloading the extension and reading its manifest; a wrong id is worse
|
||||
/// than a missing one, because a stale list only ever loses recall while a
|
||||
/// wrong one accuses the wrong extension.
|
||||
const KNOWN_VPN_EXTENSION_IDS: &[&str] = &[
|
||||
"adlpodnneegcnbophopdmhedicjbcgco", // Troywell VPN
|
||||
"ailoabdmgclmfmhdagmlohpjlbpffblp", // Surfshark
|
||||
"akcocjjpkmlniicdeemdceeajlmoabhg", // 1VPN
|
||||
"apbcbecdpjefgklcokinpapmmdekecah", // Ninja VPN
|
||||
"bihmplhobchoageeokmgbdihknkjbknd", // Touch VPN (delisted 2025, still installed in old profiles)
|
||||
"blapeiihifiknfmceddkceklnpopgclm", // Proxy Switcher Pro
|
||||
"bnlofglpdlboacepdieejiecfbfpmhlb", // Turbo VPN
|
||||
"dookpfaalaaappcdneeahomimbllocnb", // FoxyProxy Basic
|
||||
"eppiocemhmnlbhjplcgkofciiegomcon", // Urban VPN
|
||||
"fcfhplploccackoneaefokcmbjfbkenj", // 1clickVPN
|
||||
"fdcgdnkidjaadafnichfpabhfomcebme", // ZenMate (delisted 2025)
|
||||
"ffbkglfijbcbgblgflchnbphjdllaogb", // CyberGhost
|
||||
"fgddmllnllkalaagkghckoinaemmogpe", // ExpressVPN
|
||||
"fjoaledfpmneenckfbpdfhkmimnjocfa", // NordVPN
|
||||
"gcknhkkoolaabfmlnjonogaaifnjlfnp", // FoxyProxy
|
||||
"gdpehpfhegefkjelaifkdbppjbhilaom", // Proxy-Cheap Proxy Manager
|
||||
"gjakohbhfclfjmhhlenfdkldieofkpjl", // IPRoyal Proxy Manager
|
||||
"gjknjjomckknofjidppipffbpoekiipm", // Betternet
|
||||
"gkojfkhlekighikafcpjkiklfbnlmeio", // Hola VPN
|
||||
"hnmpcagpplmpfojmgmnngilcnanddlhb", // Windscribe
|
||||
"jaoafpkngncfpfggjefnekilbkcpjdgp", // uVPN
|
||||
"jedieiamjmoflcknjdjhpieklepfglin", // FastestVPN
|
||||
"jpadbaildllggkcgibilkeacpcodailn", // Planet VPN lite
|
||||
"jplgfhpmjnbigmhklmmbgecoobifkmpa", // Proton VPN
|
||||
"jplnlifepflhkbkgonidnobkakhmpnmh", // Private Internet Access
|
||||
"kgepmkaldicdcljckhamnhkigddnbcbd", // PACify Proxy Manager
|
||||
"kpiecbcckbofpmkkkdibbllpinceiihk", // DotVPN
|
||||
"majdfhpaihoncoakbjgbdhglocklcgno", // VeePN
|
||||
"nbcojefnccbanplpoffopkoepjmhgdgh", // Hoxx VPN
|
||||
"nlbejmccbhkncgokjcmghpfloaajcffj", // Hotspot Shield
|
||||
"ohjocgmpmlfahafbipehkhbaacoemojp", // hide.me Proxy
|
||||
"omdakjcmkglenbhjadbccaookpfjihpa", // TunnelBear
|
||||
"omghfjlpggmjjaagoclmmobgdodcjboh", // Browsec
|
||||
"onnfghpihccifgojkpnnncpagjcdbjod", // Proxy Switcher and Manager
|
||||
"oofgbpoabipfcfjapgnbbjjaenockbdp", // SetupVPN
|
||||
"padekgcemlokbadohgkifijomclgjgif", // Proxy SwitchyOmega
|
||||
"pphgdbgldlmicfdkhondlafkiomnelnk", // 1ClickVPN Proxy
|
||||
];
|
||||
|
||||
/// Terms specific enough to name a VPN wherever they appear, including in a
|
||||
/// 132-character manifest description.
|
||||
const STRONG_KEYWORDS: &[&str] = &["vpn", "wireguard", "shadowsocks", "openvpn"];
|
||||
|
||||
/// Terms that only mean "VPN" in a product's *name*. In a description they are
|
||||
/// ordinary English — "no proxy setup required", "carpal tunnel", "unblock
|
||||
/// right click" — and matching them there is where the noise comes from.
|
||||
const NAME_ONLY_KEYWORDS: &[&str] = &["proxy", "unblock"];
|
||||
|
||||
/// Matched as whole tokens rather than substrings, and in the name only. Too
|
||||
/// short to be safe inside other words ("tussocks", "tunnelling").
|
||||
const NAME_TOKEN_KEYWORDS: &[&str] = &["socks", "socks5", "tunnel"];
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DetectedVpnExtension {
|
||||
/// Stable acknowledgement identity: `donut:<uuid>` or `crx:<32-char-id>`.
|
||||
pub key: String,
|
||||
pub name: String,
|
||||
pub version: Option<String>,
|
||||
/// `"donut"` (managed by Donut) or `"browser"` (installed inside the profile).
|
||||
pub source: String,
|
||||
/// `"confirmed"` and `"likely"` are claims that this IS a VPN/proxy tool.
|
||||
/// `"capability"` claims only that it *could* change the proxy.
|
||||
pub confidence: String,
|
||||
/// Whether the manifest holds Chromium's `proxy` permission outright, so the
|
||||
/// extension can call `chrome.proxy.settings.set` without asking again.
|
||||
/// Separate from `confidence`: a download manager reading the browser's
|
||||
/// proxy declares the identical permission as a VPN hijacking it.
|
||||
pub proxy_control: bool,
|
||||
/// Why it matched, for the dialog's detail line.
|
||||
pub signals: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct ManifestSignals {
|
||||
pub proxy_permission: bool,
|
||||
pub optional_proxy_permission: bool,
|
||||
pub declarative_net_request: bool,
|
||||
pub web_request_blocking: bool,
|
||||
pub broad_host_permissions: bool,
|
||||
}
|
||||
|
||||
fn string_list<'a>(manifest: &'a serde_json::Value, key: &str) -> Vec<&'a str> {
|
||||
manifest
|
||||
.get(key)
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|a| a.iter().filter_map(|v| v.as_str()).collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn is_broad_host(pattern: &str) -> bool {
|
||||
matches!(pattern, "<all_urls>" | "*://*/*")
|
||||
}
|
||||
|
||||
pub fn signals_from_manifest(manifest: &serde_json::Value) -> ManifestSignals {
|
||||
let permissions = string_list(manifest, "permissions");
|
||||
let optional_permissions = string_list(manifest, "optional_permissions");
|
||||
let host_permissions = string_list(manifest, "host_permissions");
|
||||
let optional_host_permissions = string_list(manifest, "optional_host_permissions");
|
||||
|
||||
let has = |list: &[&str], name: &str| list.contains(&name);
|
||||
|
||||
// MV2 keeps host patterns inside `permissions`; MV3 splits them into
|
||||
// `host_permissions`. Look in both so one manifest version isn't silently
|
||||
// under-detected.
|
||||
let all_hosts: Vec<&str> = permissions
|
||||
.iter()
|
||||
.chain(host_permissions.iter())
|
||||
.chain(optional_host_permissions.iter())
|
||||
.copied()
|
||||
.collect();
|
||||
let broad = all_hosts.iter().any(|p| is_broad_host(p))
|
||||
|| (all_hosts.contains(&"http://*/*") && all_hosts.contains(&"https://*/*"));
|
||||
|
||||
ManifestSignals {
|
||||
proxy_permission: has(&permissions, "proxy"),
|
||||
optional_proxy_permission: has(&optional_permissions, "proxy"),
|
||||
declarative_net_request: has(&permissions, "declarativeNetRequest")
|
||||
|| has(&permissions, "declarativeNetRequestWithHostAccess"),
|
||||
web_request_blocking: has(&permissions, "webRequest")
|
||||
&& has(&permissions, "webRequestBlocking"),
|
||||
broad_host_permissions: broad,
|
||||
}
|
||||
}
|
||||
|
||||
/// True when this is the id of an extension known to route browser traffic.
|
||||
pub fn is_known_vpn_extension(extension_id: &str) -> bool {
|
||||
KNOWN_VPN_EXTENSION_IDS.binary_search(&extension_id).is_ok()
|
||||
}
|
||||
|
||||
fn has_token(haystack: &str, tokens: &[&str]) -> bool {
|
||||
haystack
|
||||
.split(|c: char| !c.is_alphanumeric())
|
||||
.any(|token| tokens.contains(&token))
|
||||
}
|
||||
|
||||
/// Does the extension describe itself as a VPN or proxy tool?
|
||||
///
|
||||
/// The name is weighted far more heavily than the description, because that is
|
||||
/// where the evidence actually lives: a VPN vendor puts "VPN" in the name — it
|
||||
/// is how the store surfaces them — while a description is 132 characters of
|
||||
/// ordinary prose in which "proxy", "tunnel" and "unblock" are all innocent.
|
||||
/// Matching those three against descriptions is what flags carpal-tunnel
|
||||
/// reminders, right-click unblockers, and tools whose pitch is that they need
|
||||
/// *no* proxy setup.
|
||||
pub fn vpn_keyword_hit(name: &str, description: Option<&str>) -> bool {
|
||||
let name = name.to_lowercase();
|
||||
if STRONG_KEYWORDS.iter().any(|k| name.contains(k))
|
||||
|| NAME_ONLY_KEYWORDS.iter().any(|k| name.contains(k))
|
||||
|| has_token(&name, NAME_TOKEN_KEYWORDS)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
description
|
||||
.map(str::to_lowercase)
|
||||
.is_some_and(|d| STRONG_KEYWORDS.iter().any(|k| d.contains(k)))
|
||||
}
|
||||
|
||||
/// Classify an extension from its id, manifest signals and self-description.
|
||||
///
|
||||
/// Two different questions are answered here, and fusing them is what made an
|
||||
/// ordinary download manager get reported as a VPN. Chromium has no read-only
|
||||
/// variant of the `proxy` permission: `chrome.proxy.settings.get()` and
|
||||
/// `.set()` sit behind the same manifest string, so an extension replicating
|
||||
/// the browser's proxy for its own transfers declares exactly what a VPN
|
||||
/// hijacking it declares. The permission therefore proves a *capability* and
|
||||
/// nothing more; naming something a VPN needs separate evidence — a known id,
|
||||
/// or the extension saying so itself.
|
||||
///
|
||||
/// The request-blocking tier's keyword requirement is not optional either:
|
||||
/// `declarativeNetRequest` plus `<all_urls>` describes every content blocker in
|
||||
/// the ecosystem, so without it the warning fires on uBlock Origin — which
|
||||
/// would teach users to dismiss the dialog on sight, destroying the value of
|
||||
/// the mismatch block that shares it.
|
||||
///
|
||||
/// An `optional_permissions` entry the user has never granted is deliberately
|
||||
/// not a capability at all: the extension cannot call `chrome.proxy` until it
|
||||
/// asks and is allowed.
|
||||
pub fn classify(
|
||||
extension_id: Option<&str>,
|
||||
signals: &ManifestSignals,
|
||||
keyword: bool,
|
||||
) -> Option<&'static str> {
|
||||
if extension_id.is_some_and(is_known_vpn_extension) {
|
||||
return Some("confirmed");
|
||||
}
|
||||
if keyword {
|
||||
if signals.proxy_permission {
|
||||
return Some("confirmed");
|
||||
}
|
||||
if signals.optional_proxy_permission
|
||||
|| ((signals.declarative_net_request || signals.web_request_blocking)
|
||||
&& signals.broad_host_permissions)
|
||||
{
|
||||
return Some("likely");
|
||||
}
|
||||
}
|
||||
if signals.proxy_permission {
|
||||
return Some("capability");
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn signal_labels(
|
||||
extension_id: Option<&str>,
|
||||
signals: &ManifestSignals,
|
||||
keyword: bool,
|
||||
) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
if extension_id.is_some_and(is_known_vpn_extension) {
|
||||
out.push("knownVpnExtension".to_string());
|
||||
}
|
||||
if signals.proxy_permission {
|
||||
out.push("permissions:proxy".to_string());
|
||||
}
|
||||
if signals.optional_proxy_permission {
|
||||
out.push("optionalPermissions:proxy".to_string());
|
||||
}
|
||||
if signals.declarative_net_request {
|
||||
out.push("declarativeNetRequest".to_string());
|
||||
}
|
||||
if signals.web_request_blocking {
|
||||
out.push("webRequestBlocking".to_string());
|
||||
}
|
||||
if signals.broad_host_permissions {
|
||||
out.push("broadHostPermissions".to_string());
|
||||
}
|
||||
if keyword {
|
||||
out.push("keyword".to_string());
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// `__MSG_someKey__` -> `someKey`.
|
||||
pub fn message_placeholder_key(value: &str) -> Option<String> {
|
||||
value
|
||||
.strip_prefix("__MSG_")
|
||||
.and_then(|rest| rest.strip_suffix("__"))
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Chromium's `messages.json` shape: `{ "key": { "message": "..." } }`, with
|
||||
/// keys compared case-insensitively.
|
||||
pub fn lookup_message(messages: &serde_json::Value, key: &str) -> Option<String> {
|
||||
let obj = messages.as_object()?;
|
||||
obj
|
||||
.iter()
|
||||
.find(|(k, _)| k.eq_ignore_ascii_case(key))
|
||||
.and_then(|(_, v)| v.get("message"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn manifest_str(manifest: &serde_json::Value, key: &str) -> Option<String> {
|
||||
manifest
|
||||
.get(key)
|
||||
.and_then(|v| v.as_str())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
/// Sort key for an extension version directory (`1.10.0_0`), compared
|
||||
/// numerically so `1.10.0` sorts above `1.9.0` where a lexicographic compare
|
||||
/// would put it below.
|
||||
pub fn version_dir_sort_key(name: &str) -> Vec<u64> {
|
||||
name
|
||||
.split(['.', '_'])
|
||||
.map(|part| part.parse::<u64>().unwrap_or(0))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn signals_of(manifest: serde_json::Value) -> ManifestSignals {
|
||||
signals_from_manifest(&manifest)
|
||||
}
|
||||
|
||||
fn classify_named(
|
||||
manifest: serde_json::Value,
|
||||
name: &str,
|
||||
description: Option<&str>,
|
||||
) -> Option<&'static str> {
|
||||
let s = signals_of(manifest);
|
||||
classify(None, &s, vpn_keyword_hit(name, description))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_confirms_a_self_described_vpn_holding_the_proxy_permission() {
|
||||
let s = signals_of(json!({ "permissions": ["proxy", "storage"] }));
|
||||
assert!(s.proxy_permission);
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
|
||||
Some("confirmed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_confirms_proxy_permission_in_mv2() {
|
||||
// `proxy` is an API permission, so MV3's host_permissions split does not
|
||||
// move it — the same key works for both manifest versions.
|
||||
let s = signals_of(json!({
|
||||
"manifest_version": 2,
|
||||
"permissions": ["proxy", "<all_urls>", "webRequest"]
|
||||
}));
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Hoxx VPN Proxy", None)),
|
||||
Some("confirmed")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_download_manager_is_reported_as_a_capability_never_as_a_vpn() {
|
||||
// The bug this whole split exists for. IDM Integration Module declares
|
||||
// `proxy` so the desktop binary can replicate the browser's route for a
|
||||
// handed-off download, and says nothing about VPNs anywhere. Verified
|
||||
// against the real published manifest.
|
||||
let verdict = classify_named(
|
||||
json!({
|
||||
"permissions": [
|
||||
"scripting", "tabs", "cookies", "contextMenus", "webNavigation",
|
||||
"webRequest", "declarativeNetRequest", "downloads", "downloads.shelf",
|
||||
"downloads.ui", "management", "storage", "proxy", "nativeMessaging"
|
||||
]
|
||||
}),
|
||||
"IDM Integration Module",
|
||||
Some("Download files with Internet Download Manager"),
|
||||
);
|
||||
assert_eq!(verdict, Some("capability"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_known_vpn_is_confirmed_from_its_id_alone() {
|
||||
// Hotspot Shield's name contains no keyword at all, so without the id list
|
||||
// the biggest VPN in the store would be indistinguishable from a download
|
||||
// manager.
|
||||
let s = signals_of(json!({ "permissions": ["proxy"] }));
|
||||
let id = "nlbejmccbhkncgokjcmghpfloaajcffj";
|
||||
assert_eq!(
|
||||
classify(Some(id), &s, vpn_keyword_hit("Hotspot Shield", None)),
|
||||
Some("confirmed")
|
||||
);
|
||||
assert!(signal_labels(Some(id), &s, false).contains(&"knownVpnExtension".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_known_vpn_id_list_is_sorted_and_well_formed() {
|
||||
// Membership is a binary search, so an unsorted entry is silently missed.
|
||||
assert!(KNOWN_VPN_EXTENSION_IDS.windows(2).all(|w| w[0] < w[1]));
|
||||
for id in KNOWN_VPN_EXTENSION_IDS {
|
||||
assert_eq!(id.len(), 32, "{id} is not a Chrome extension id");
|
||||
assert!(
|
||||
id.bytes().all(|b| (b'a'..=b'p').contains(&b)),
|
||||
"{id} is not a Chrome extension id"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_ignores_content_blocker() {
|
||||
// The regression guard: a content blocker declares exactly these and is
|
||||
// not a VPN. Firing here would train users to dismiss the dialog.
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["<all_urls>"]
|
||||
}));
|
||||
assert!(s.declarative_net_request && s.broad_host_permissions);
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("uBlock Origin", None)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_likely_on_dnr_plus_keyword() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["<all_urls>"]
|
||||
}));
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Free VPN Proxy", None)),
|
||||
Some("likely")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_likely_on_optional_proxy_plus_keyword() {
|
||||
// Optional and ungranted is not a capability, so it only matters when the
|
||||
// extension also says what it is.
|
||||
let s = signals_of(json!({ "optional_permissions": ["proxy"] }));
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Some VPN", None)),
|
||||
Some("likely")
|
||||
);
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Request Interceptor", None)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_ignores_keyword_only() {
|
||||
// A name alone proves nothing; without a capability signal this is noise.
|
||||
let s = signals_of(json!({ "permissions": ["storage"] }));
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("VPN Deals Finder", None)),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_requires_broad_hosts_for_the_blocking_tier() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["declarativeNetRequest"],
|
||||
"host_permissions": ["https://example.com/*"]
|
||||
}));
|
||||
assert_eq!(classify(None, &s, vpn_keyword_hit("Some VPN", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_hosts_detected_from_split_http_and_https() {
|
||||
let s = signals_of(json!({
|
||||
"permissions": ["webRequest", "webRequestBlocking"],
|
||||
"host_permissions": ["http://*/*", "https://*/*"]
|
||||
}));
|
||||
assert!(s.broad_host_permissions);
|
||||
assert!(s.web_request_blocking);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn broad_hosts_detected_from_mv2_permissions_array() {
|
||||
// MV2 puts host patterns in `permissions`; the split-out key is absent.
|
||||
let s = signals_of(json!({
|
||||
"manifest_version": 2,
|
||||
"permissions": ["webRequest", "webRequestBlocking", "<all_urls>"]
|
||||
}));
|
||||
assert!(s.broad_host_permissions);
|
||||
assert_eq!(
|
||||
classify(None, &s, vpn_keyword_hit("Turbo VPN", None)),
|
||||
Some("likely")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keyword_matching_reads_the_name_broadly_and_the_description_narrowly() {
|
||||
assert!(vpn_keyword_hit("TouchVPN", None));
|
||||
assert!(vpn_keyword_hit("Unblock Sites", None));
|
||||
assert!(vpn_keyword_hit("Shadowsocks Client", None));
|
||||
// Whole-token terms must not match inside longer words. "socks" in a name
|
||||
// is the protocol often enough to keep; "tussocks" and "tunnelling" are
|
||||
// exactly why it cannot be a substring.
|
||||
assert!(vpn_keyword_hit("SOCKS5 Configurator", None));
|
||||
assert!(!vpn_keyword_hit("Tussocks Field Guide", None));
|
||||
assert!(!vpn_keyword_hit("Tunnelling Contractors CRM", None));
|
||||
|
||||
// A description says "VPN" only when it means one...
|
||||
assert!(vpn_keyword_hit(
|
||||
"Anything",
|
||||
Some("a free VPN for your browser")
|
||||
));
|
||||
// ...but these three are ordinary English and must not promote anything.
|
||||
assert!(!vpn_keyword_hit(
|
||||
"Requestly",
|
||||
Some("Modify HTTP requests, no proxy setup required")
|
||||
));
|
||||
assert!(!vpn_keyword_hit(
|
||||
"Stretch Reminder",
|
||||
Some("Avoid carpal tunnel syndrome while you work")
|
||||
));
|
||||
assert!(!vpn_keyword_hit(
|
||||
"Absolute Right Click",
|
||||
Some("Unblock right click and text selection on any site")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn message_placeholder_round_trip() {
|
||||
assert_eq!(
|
||||
message_placeholder_key("__MSG_appName__").as_deref(),
|
||||
Some("appName")
|
||||
);
|
||||
assert_eq!(message_placeholder_key("Plain Name"), None);
|
||||
let messages = json!({ "appName": { "message": "Nord VPN" } });
|
||||
assert_eq!(
|
||||
lookup_message(&messages, "appName").as_deref(),
|
||||
Some("Nord VPN")
|
||||
);
|
||||
// Chromium compares message keys case-insensitively.
|
||||
assert_eq!(
|
||||
lookup_message(&messages, "APPNAME").as_deref(),
|
||||
Some("Nord VPN")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn version_dirs_sort_numerically_not_lexicographically() {
|
||||
let mut dirs = ["1.9.0_0", "1.10.0_0", "1.2.0_0"];
|
||||
dirs.sort_by_key(|d| version_dir_sort_key(d));
|
||||
assert_eq!(dirs.last(), Some(&"1.10.0_0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn malformed_manifest_yields_no_signals() {
|
||||
// Arrays of non-strings, wrong types, and missing keys must not panic.
|
||||
let s = signals_of(json!({ "permissions": [1, 2, {"a": "b"}], "host_permissions": "nope" }));
|
||||
assert_eq!(s, ManifestSignals::default());
|
||||
assert_eq!(classify(None, &s, true), None);
|
||||
}
|
||||
}
|
||||
@@ -99,7 +99,50 @@ async fn wait_for_vpn_worker_ready(
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes worker startup for a given VPN, so two concurrent launches cannot
|
||||
/// both observe "no worker" and both believe they created it. Benign until a
|
||||
/// launch guard may stop one on failure; then double-ownership means a
|
||||
/// cancelled launch tears down a tunnel another profile is using. Mirrors
|
||||
/// `xray_worker_runner::XRAY_START_LOCK`.
|
||||
static VPN_START_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
|
||||
|
||||
/// A started VPN worker plus whether *this* call spawned it.
|
||||
pub struct VpnWorkerStart {
|
||||
pub config: VpnWorkerConfig,
|
||||
/// False when an already-running worker was adopted. Only the creator may
|
||||
/// stop it while unwinding a failed launch.
|
||||
pub created: bool,
|
||||
}
|
||||
|
||||
/// Whether any profile with a live browser process is routing through this VPN.
|
||||
///
|
||||
/// Extracted from the startup sweep so the launch guard and the sweep agree on
|
||||
/// what "in use" means instead of each carrying its own copy.
|
||||
pub fn vpn_id_in_use_by_running_browser(vpn_id: &str) -> bool {
|
||||
let Ok(profiles) = crate::profile::ProfileManager::instance().list_profiles() else {
|
||||
// Unable to tell — assume in use rather than tear down a live tunnel.
|
||||
return true;
|
||||
};
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|p| p.process_id.is_some_and(is_process_running))
|
||||
.any(|p| p.vpn_id.as_deref() == Some(vpn_id))
|
||||
}
|
||||
|
||||
/// Hold the start lock across an adopt-sensitive section (a launch guard
|
||||
/// deciding whether to stop a worker it created).
|
||||
pub async fn lock_vpn_starts() -> tokio::sync::MutexGuard<'static, ()> {
|
||||
VPN_START_LOCK.lock().await
|
||||
}
|
||||
|
||||
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
|
||||
start_vpn_worker_tracked(vpn_id).await.map(|s| s.config)
|
||||
}
|
||||
|
||||
pub async fn start_vpn_worker_tracked(
|
||||
vpn_id: &str,
|
||||
) -> Result<VpnWorkerStart, Box<dyn std::error::Error>> {
|
||||
let _start_guard = VPN_START_LOCK.lock().await;
|
||||
crate::proxy_runner::ensure_sidecar_version().await?;
|
||||
|
||||
for config in list_vpn_worker_configs() {
|
||||
@@ -117,10 +160,18 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
if let Some(pid) = existing.pid {
|
||||
if is_process_running(pid) {
|
||||
if vpn_worker_accepting_connections(&existing).await {
|
||||
return Ok(existing);
|
||||
return Ok(VpnWorkerStart {
|
||||
config: existing,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
|
||||
return wait_for_vpn_worker_ready(&existing.id).await;
|
||||
return wait_for_vpn_worker_ready(&existing.id)
|
||||
.await
|
||||
.map(|config| VpnWorkerStart {
|
||||
config,
|
||||
created: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Worker config exists but process is dead, clean up
|
||||
@@ -263,7 +314,12 @@ pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn s
|
||||
drop(child);
|
||||
}
|
||||
|
||||
wait_for_vpn_worker_ready(&id).await
|
||||
wait_for_vpn_worker_ready(&id)
|
||||
.await
|
||||
.map(|config| VpnWorkerStart {
|
||||
config,
|
||||
created: true,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -0,0 +1,182 @@
|
||||
//! Client-side window decorations on Linux.
|
||||
//!
|
||||
//! The app draws its own titlebar (as it already does on macOS and Windows), so
|
||||
//! the window is built without server-side decorations. That means the app also
|
||||
//! owns the window *controls*, and their side and order are a desktop-wide user
|
||||
//! preference that differs between environments — GNOME defaults to
|
||||
//! `:minimize,maximize,close` (all on the right), and a user who has moved them
|
||||
//! to the left expects every app to follow.
|
||||
//!
|
||||
//! `GtkSettings::gtk-decoration-layout` is the one place every desktop
|
||||
//! publishes that preference to GTK applications: GNOME mirrors
|
||||
//! `org.gnome.desktop.wm.preferences button-layout` into it, and on KDE Plasma
|
||||
//! `kde-gtk-config` mirrors KWin's decoration button configuration into it.
|
||||
//! Reading this property therefore gets both environments right without any
|
||||
//! desktop-specific branching.
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
/// Whether this window draws its own titlebar, and how.
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct WindowDecorations {
|
||||
/// True when the app owns the titlebar and must draw controls and resize
|
||||
/// edges. False means the platform still draws a real titlebar and the
|
||||
/// frontend must render nothing.
|
||||
pub client_side: bool,
|
||||
/// The desktop's button layout, e.g. `":minimize,maximize,close"`. Only
|
||||
/// meaningful when `client_side` is true.
|
||||
pub layout: Option<String>,
|
||||
}
|
||||
|
||||
/// Whether to drop server-side decorations on this Linux session.
|
||||
///
|
||||
/// Enabled everywhere except KDE Plasma on Wayland, and overridable with
|
||||
/// `DONUT_LINUX_CLIENT_DECORATIONS=1|0`.
|
||||
///
|
||||
/// The KDE/Wayland exclusion is deliberate and is about a failure mode, not a
|
||||
/// preference. GTK3 speaks no `xdg-decoration`; when a window is built
|
||||
/// undecorated, GTK does not mark it client-decorated, and on Wayland it
|
||||
/// therefore *announces server-side decorations* to the compositor. mutter
|
||||
/// ignores that (it never decorates Wayland toplevels), which is why GNOME
|
||||
/// works. KWin honors it, so Plasma would be free to draw a Breeze titlebar
|
||||
/// directly above the one the app draws — two titlebars, worse than the
|
||||
/// feature is good. Whether it actually does depends on the decoration mode
|
||||
/// KWin advertises, which could not be established from documentation and
|
||||
/// cannot be tested from here, so this stays off until somebody can run it.
|
||||
///
|
||||
/// KDE on X11 is *not* excluded: there the request travels as `_MOTIF_WM_HINTS`,
|
||||
/// which KWin has honored for as long as it has existed.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn use_client_side_decorations() -> bool {
|
||||
if let Ok(value) = std::env::var("DONUT_LINUX_CLIENT_DECORATIONS") {
|
||||
let forced = matches!(value.trim(), "1" | "true" | "yes");
|
||||
log::info!("Client-side decorations forced to {forced} by DONUT_LINUX_CLIENT_DECORATIONS");
|
||||
return forced;
|
||||
}
|
||||
|
||||
let env = |key: &str| std::env::var(key).unwrap_or_default().to_lowercase();
|
||||
|
||||
// GDK_BACKEND is a comma-separated preference list ("wayland,x11"), and GDK
|
||||
// takes the FIRST entry it can open. Testing for a substring would read
|
||||
// "wayland,x11" as X11 and hand a Plasma Wayland session the undecorated
|
||||
// path this guard exists to withhold.
|
||||
let backend = env("GDK_BACKEND");
|
||||
let preferred = backend
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.find(|value| !value.is_empty());
|
||||
let on_wayland = match preferred {
|
||||
Some("x11") => false,
|
||||
Some("wayland") => true,
|
||||
// Unset or something exotic: fall back to what the session advertises.
|
||||
_ => {
|
||||
!std::env::var("WAYLAND_DISPLAY")
|
||||
.unwrap_or_default()
|
||||
.is_empty()
|
||||
&& env("XDG_SESSION_TYPE") != "x11"
|
||||
}
|
||||
};
|
||||
|
||||
let on_kde = env("XDG_CURRENT_DESKTOP").contains("kde")
|
||||
|| env("XDG_SESSION_DESKTOP").contains("plasma")
|
||||
|| env("DESKTOP_SESSION").contains("plasma")
|
||||
|| !std::env::var("KDE_FULL_SESSION")
|
||||
.unwrap_or_default()
|
||||
.is_empty();
|
||||
|
||||
if on_kde && on_wayland {
|
||||
log::info!(
|
||||
"Keeping server-side decorations: KWin on Wayland may draw its own titlebar over the \
|
||||
app's. Set DONUT_LINUX_CLIENT_DECORATIONS=1 to override."
|
||||
);
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod imp {
|
||||
use std::sync::Mutex;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref LAYOUT: Mutex<Option<String>> = Mutex::new(None);
|
||||
}
|
||||
|
||||
fn store(layout: Option<String>) {
|
||||
if let Ok(mut slot) = LAYOUT.lock() {
|
||||
*slot = layout;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cached() -> Option<String> {
|
||||
LAYOUT.lock().ok().and_then(|slot| slot.clone())
|
||||
}
|
||||
|
||||
/// Read the layout and subscribe to changes.
|
||||
///
|
||||
/// MUST be called on the GTK main thread — `gtk::Settings::default()` panics
|
||||
/// elsewhere, and the notify subscription has to be attached from the thread
|
||||
/// owning the GTK main context. The app's `setup` hook already runs there.
|
||||
pub fn init<R: tauri::Runtime>(app: &tauri::AppHandle<R>) {
|
||||
use gtk::prelude::*;
|
||||
use tauri::Emitter;
|
||||
|
||||
let Some(settings) = gtk::Settings::default() else {
|
||||
log::warn!("No GTK settings available; using the default decoration layout");
|
||||
return;
|
||||
};
|
||||
|
||||
store(settings.gtk_decoration_layout().map(|v| v.to_string()));
|
||||
log::info!(
|
||||
"Window decoration layout: {}",
|
||||
cached().as_deref().unwrap_or("<unset>")
|
||||
);
|
||||
|
||||
// The user can rearrange titlebar buttons while the app is running, and
|
||||
// every other application follows immediately. Note this never fires where
|
||||
// the value comes only from gtk-3.0/settings.ini (a KDE X11 box with no
|
||||
// xsettings daemon) — there it is simply static until restart.
|
||||
let handle = app.clone();
|
||||
settings.connect_gtk_decoration_layout_notify(move |settings| {
|
||||
let layout = settings.gtk_decoration_layout().map(|v| v.to_string());
|
||||
log::info!(
|
||||
"Window decoration layout changed to: {}",
|
||||
layout.as_deref().unwrap_or("<unset>")
|
||||
);
|
||||
store(layout.clone());
|
||||
if let Err(e) = handle.emit("window-decoration-layout-changed", layout) {
|
||||
log::warn!("Failed to emit window decoration layout change: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod imp {
|
||||
pub fn init<R: tauri::Runtime>(_app: &tauri::AppHandle<R>) {}
|
||||
}
|
||||
|
||||
pub use imp::init;
|
||||
|
||||
/// How this window is decorated, and the desktop's button layout when the app
|
||||
/// owns the titlebar.
|
||||
#[tauri::command]
|
||||
pub fn get_window_decoration_layout() -> WindowDecorations {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let client_side = use_client_side_decorations();
|
||||
WindowDecorations {
|
||||
client_side,
|
||||
layout: if client_side { imp::cached() } else { None },
|
||||
}
|
||||
}
|
||||
// Every other platform keeps a real titlebar: macOS makes the native one
|
||||
// transparent, Windows draws its own controls on a fixed layout.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
WindowDecorations {
|
||||
client_side: false,
|
||||
layout: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,3 +27,42 @@ pub enum XrayError {
|
||||
#[error("failed to serialize Xray client configuration")]
|
||||
Serialization,
|
||||
}
|
||||
|
||||
impl XrayError {
|
||||
/// A stable, translatable identifier for *why* a URI was rejected.
|
||||
///
|
||||
/// Donut supports one VLESS shape — REALITY + XTLS Vision over TCP — so most
|
||||
/// rejections are "your setup is a kind we do not support", not "you made a
|
||||
/// typo". The frontend turns these into a sentence naming the unsupported
|
||||
/// part; without them every rejection reads as a malformed URI and a user
|
||||
/// with a working WebSocket or plain-TLS server has no idea why it failed.
|
||||
pub fn reason_code(&self) -> &'static str {
|
||||
match self {
|
||||
Self::UnsupportedScheme => "scheme",
|
||||
Self::UnsupportedValue { field, .. } | Self::InvalidField { field, .. } => match *field {
|
||||
"security" => "security",
|
||||
"flow" => "flow",
|
||||
"type" => "transport",
|
||||
"encryption" => "encryption",
|
||||
"headerType" => "headerType",
|
||||
"fp" => "fingerprint",
|
||||
// A malformed sni/public key is the same user-facing problem as a
|
||||
// missing one, so it earns the same specific help rather than the
|
||||
// generic "invalid URI".
|
||||
"sni" | "server_name" => "sni",
|
||||
"pbk" | "public_key" => "publicKey",
|
||||
_ => "malformed",
|
||||
},
|
||||
Self::MissingField(field) => match *field {
|
||||
"sni" => "sni",
|
||||
"pbk" => "publicKey",
|
||||
"security" => "security",
|
||||
"flow" => "flow",
|
||||
_ => "malformed",
|
||||
},
|
||||
Self::UnsupportedParameter(_) => "parameter",
|
||||
Self::DuplicateParameter(_) => "malformed",
|
||||
Self::InvalidUri | Self::Serialization => "malformed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+133
-8
@@ -61,10 +61,10 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
|
||||
};
|
||||
let port = url.port().ok_or(XrayError::MissingField("port"))?;
|
||||
|
||||
let parameters = parse_parameters(&url)?;
|
||||
require_value(¶meters, "security", "reality")?;
|
||||
require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
|
||||
optional_value(¶meters, "encryption", "none")?;
|
||||
let (parameters, unsupported) = parse_parameters(&url)?;
|
||||
|
||||
// Transport first: it is the most common reason a real-world VLESS server is
|
||||
// unusable here, and it explains the stray parameters that come with it.
|
||||
match parameters.get("type").map(String::as_str) {
|
||||
None | Some("tcp" | "raw") => {}
|
||||
Some(_) => {
|
||||
@@ -74,8 +74,17 @@ pub fn parse_vless_uri(input: &str) -> XrayResult<ParsedVlessUri> {
|
||||
});
|
||||
}
|
||||
}
|
||||
require_value(¶meters, "security", "reality")?;
|
||||
require_value(¶meters, "flow", VlessFlow::Vision.as_str())?;
|
||||
optional_value(¶meters, "encryption", "none")?;
|
||||
optional_value(¶meters, "headerType", "none")?;
|
||||
|
||||
// Only once the shape is known-good does an unrecognized parameter become
|
||||
// the most useful thing to report.
|
||||
if let Some(name) = unsupported.into_iter().next() {
|
||||
return Err(XrayError::UnsupportedParameter(name));
|
||||
}
|
||||
|
||||
let server_name = required_parameter(¶meters, "sni")?.to_string();
|
||||
let public_key = required_parameter(¶meters, "pbk")?.to_string();
|
||||
let short_id = parameters.get("sid").cloned().unwrap_or_default();
|
||||
@@ -162,15 +171,28 @@ pub fn export_vless_uri(config: &VlessRealityConfig, name: Option<&str>) -> Xray
|
||||
query.append_pair("type", "tcp");
|
||||
query.append_pair("headerType", "none");
|
||||
}
|
||||
url.set_fragment(name);
|
||||
// The parser percent-DECODES the fragment, so the exporter must encode it or
|
||||
// a name containing `%` (or `#`) comes back different every time the URI is
|
||||
// canonicalized — the name mutates a little more on each save.
|
||||
let encoded_name = name.map(|value| urlencoding::encode(value).into_owned());
|
||||
url.set_fragment(encoded_name.as_deref());
|
||||
Ok(url.into())
|
||||
}
|
||||
|
||||
fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
|
||||
/// Split the query into recognized parameters and the names of the rest.
|
||||
///
|
||||
/// Unrecognized names are returned rather than rejected on the spot so the
|
||||
/// caller can report the *shape* problem first. A WebSocket URI always carries
|
||||
/// `path` (and usually `host`), gRPC carries `serviceName` — naming those keys
|
||||
/// instead of the transport sends the user deleting parameters when the real
|
||||
/// answer is that Donut only speaks plain TCP.
|
||||
fn parse_parameters(url: &Url) -> XrayResult<(HashMap<String, String>, Vec<String>)> {
|
||||
let mut parameters = HashMap::new();
|
||||
let mut unsupported = Vec::new();
|
||||
for (name, value) in url.query_pairs() {
|
||||
if !SUPPORTED_PARAMETERS.contains(&name.as_ref()) {
|
||||
return Err(XrayError::UnsupportedParameter(name.into_owned()));
|
||||
unsupported.push(name.into_owned());
|
||||
continue;
|
||||
}
|
||||
if parameters
|
||||
.insert(name.to_string(), value.into_owned())
|
||||
@@ -179,7 +201,7 @@ fn parse_parameters(url: &Url) -> XrayResult<HashMap<String, String>> {
|
||||
return Err(XrayError::DuplicateParameter(name.into_owned()));
|
||||
}
|
||||
}
|
||||
Ok(parameters)
|
||||
Ok((parameters, unsupported))
|
||||
}
|
||||
|
||||
fn required_parameter<'a>(
|
||||
@@ -230,6 +252,109 @@ mod tests {
|
||||
|
||||
const ID: &str = "6d6e21a1-4829-4d2b-bc7f-1b25707b61e4";
|
||||
|
||||
/// Donut accepts exactly one VLESS shape, so most rejections mean "your
|
||||
/// server is a kind we do not support" rather than "you mistyped". These pin
|
||||
/// the reason each rejection reports, because the UI turns it into the one
|
||||
/// sentence that tells a user with a working WebSocket or plain-TLS server
|
||||
/// why Donut will not take it.
|
||||
#[test]
|
||||
fn unsupported_setups_report_which_part_is_unsupported() {
|
||||
let good = format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&sid=00&fp=chrome"
|
||||
);
|
||||
assert!(parse_vless_uri(&good).is_ok(), "baseline URI must parse");
|
||||
|
||||
let reason = |uri: &str| parse_vless_uri(uri).unwrap_err().reason_code();
|
||||
|
||||
// Plain TLS instead of REALITY — the most common real-world setup.
|
||||
assert_eq!(
|
||||
reason(&good.replace("security=reality", "security=tls")),
|
||||
"security"
|
||||
);
|
||||
assert_eq!(
|
||||
reason(&good.replace("flow=xtls-rprx-vision", "flow=none")),
|
||||
"flow"
|
||||
);
|
||||
// WebSocket / gRPC transports.
|
||||
assert_eq!(reason(&good.replace("type=tcp", "type=ws")), "transport");
|
||||
assert_eq!(reason(&good.replace("type=tcp", "type=grpc")), "transport");
|
||||
assert_eq!(reason(&good.replace("&sni=a.com", "")), "sni");
|
||||
assert_eq!(
|
||||
reason(&good.replace("&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4", "")),
|
||||
"publicKey"
|
||||
);
|
||||
assert_eq!(reason(&good.replace("vless://", "vmess://")), "scheme");
|
||||
assert_eq!(reason("not a uri"), "malformed");
|
||||
}
|
||||
|
||||
/// The URIs users actually paste, not canonical-REALITY-with-one-field-changed.
|
||||
///
|
||||
/// A real WebSocket link carries `path` (and usually `host`); a gRPC link
|
||||
/// carries `serviceName`. Those keys are not in SUPPORTED_PARAMETERS, so
|
||||
/// before the shape was checked first they produced "unsupported option"
|
||||
/// and sent the user deleting query parameters instead of telling them
|
||||
/// Donut only speaks plain TCP.
|
||||
#[test]
|
||||
fn a_display_name_survives_an_export_parse_round_trip() {
|
||||
// Percent signs are legal in a fragment, so they used to pass through
|
||||
// unencoded and then get decoded on the way back in — "50% off" became
|
||||
// "50 off"-ish and drifted further on every canonicalizing save.
|
||||
for name in ["50% off", "a#b", "spaced name", "100%25", "üñî"] {
|
||||
let parsed = parse_vless_uri(&format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
|
||||
))
|
||||
.expect("baseline parses");
|
||||
|
||||
let exported = export_vless_uri(&parsed.config, Some(name)).expect("exports");
|
||||
let reparsed = parse_vless_uri(&exported).expect("re-parses");
|
||||
assert_eq!(
|
||||
reparsed.name.as_deref(),
|
||||
Some(name),
|
||||
"display name mutated across a round trip: {exported}"
|
||||
);
|
||||
|
||||
// And a second round trip must be a fixed point, not drift again.
|
||||
let exported_again =
|
||||
export_vless_uri(&reparsed.config, reparsed.name.as_deref()).expect("re-exports");
|
||||
assert_eq!(exported, exported_again);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn real_world_websocket_and_grpc_links_name_the_transport() {
|
||||
let ws = format!(
|
||||
"vless://{ID}@cdn.example.com:443?encryption=none&security=tls&type=ws\
|
||||
&path=%2Fray&host=cdn.example.com&sni=cdn.example.com#WS%20node"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&ws).unwrap_err().reason_code(),
|
||||
"transport",
|
||||
"a WebSocket link must be told its transport is unsupported"
|
||||
);
|
||||
|
||||
let grpc = format!(
|
||||
"vless://{ID}@grpc.example.com:443?encryption=none&security=reality&type=grpc\
|
||||
&serviceName=gun&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&grpc).unwrap_err().reason_code(),
|
||||
"transport"
|
||||
);
|
||||
|
||||
// A genuinely unknown option on an otherwise-supported URI still reports
|
||||
// as a parameter problem, which is the accurate answer there.
|
||||
let odd = format!(
|
||||
"vless://{ID}@example.com:443?security=reality&flow=xtls-rprx-vision\
|
||||
&encryption=none&type=tcp&sni=a.com&pbk=mQB9jxUDHO7g49VaNXLEdcNQ_jLhTbLolUsMUNwb6W4&madeUpKey=1"
|
||||
);
|
||||
assert_eq!(
|
||||
parse_vless_uri(&odd).unwrap_err().reason_code(),
|
||||
"parameter"
|
||||
);
|
||||
}
|
||||
|
||||
fn public_key() -> String {
|
||||
URL_SAFE_NO_PAD.encode([7_u8; 32])
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ pub async fn start_xray_worker(
|
||||
) -> Result<XrayWorkerConfig, Box<dyn std::error::Error>> {
|
||||
let _start_guard = XRAY_START_LOCK.lock().await;
|
||||
parse_vless_uri(vless_uri)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
crate::proxy_runner::ensure_sidecar_version().await?;
|
||||
ensure_xray_binary()?;
|
||||
let owner_pid = std::process::id();
|
||||
@@ -521,14 +521,14 @@ pub async fn run_xray_worker(config_path: &Path) -> Result<(), Box<dyn std::erro
|
||||
save_xray_worker_config_to_path(&config, config_path)
|
||||
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
|
||||
let parsed = parse_vless_uri(&config.vless_uri)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
let runtime = XrayClientRuntime {
|
||||
listen_port: config.local_port,
|
||||
username: config.username.clone(),
|
||||
password: config.password.clone(),
|
||||
};
|
||||
let runtime_json = build_client_config_json(&parsed.config, &runtime)
|
||||
.map_err(|error| structured_error_with_detail("VLESS_CONFIG_INVALID", error))?;
|
||||
.map_err(|error| -> Box<dyn std::error::Error> { crate::vless_config_error(&error).into() })?;
|
||||
write_xray_runtime_config(&config.id, runtime_json.as_bytes())
|
||||
.map_err(|error| structured_error_with_detail("XRAY_START_FAILED", error))?;
|
||||
let runtime_path = crate::xray_worker_storage::xray_runtime_config_path(&config.id);
|
||||
|
||||
@@ -182,6 +182,39 @@ fn worker_is_tombstoned(id: &str) -> bool {
|
||||
xray_worker_tombstone_path(id).exists()
|
||||
}
|
||||
|
||||
/// How long a tombstone has to outlive its worker.
|
||||
///
|
||||
/// It only has to survive long enough to beat a write already in flight from
|
||||
/// the process that owned that id. A day is many orders of magnitude more than
|
||||
/// that, and bounds a directory that otherwise gains a file per worker forever.
|
||||
const TOMBSTONE_TTL: std::time::Duration = std::time::Duration::from_secs(24 * 60 * 60);
|
||||
|
||||
/// Drop tombstones old enough that nothing could still be racing them.
|
||||
fn prune_stale_tombstones() {
|
||||
let Ok(entries) = fs::read_dir(crate::proxy_storage::get_storage_dir()) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("stopped") {
|
||||
continue;
|
||||
}
|
||||
let aged_out = path
|
||||
.metadata()
|
||||
.and_then(|meta| meta.modified())
|
||||
.map(|modified| {
|
||||
modified
|
||||
.elapsed()
|
||||
.map(|age| age > TOMBSTONE_TTL)
|
||||
.unwrap_or(false)
|
||||
})
|
||||
.unwrap_or(false);
|
||||
if aged_out {
|
||||
let _ = fs::remove_file(&path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn create_xray_worker_log(id: &str) -> std::io::Result<std::fs::File> {
|
||||
ensure_private_storage_dir()?;
|
||||
if worker_is_tombstoned(id) {
|
||||
@@ -275,6 +308,8 @@ pub fn delete_xray_worker_config(id: &str) -> bool {
|
||||
}
|
||||
|
||||
pub fn list_xray_worker_configs() -> Vec<XrayWorkerConfig> {
|
||||
// Cheap, and this is the one call every sweep already makes.
|
||||
prune_stale_tombstones();
|
||||
let storage_dir = crate::proxy_storage::get_storage_dir();
|
||||
let Ok(entries) = fs::read_dir(storage_dir) else {
|
||||
return Vec::new();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.28.2",
|
||||
"version": "0.29.1",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
@@ -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
@@ -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) => (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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()}`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -28,7 +28,9 @@ import {
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { translateBackendError } from "@/lib/backend-errors";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { CopyToClipboard } from "./ui/copy-to-clipboard";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
@@ -108,10 +110,15 @@ export function ProfileSelectorDialog({
|
||||
await invoke("open_url_with_profile", {
|
||||
profileId: selected.id,
|
||||
url,
|
||||
consentToken: null,
|
||||
});
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to open URL with profile:", error);
|
||||
// This path reaches the browser without going through page.tsx's gate,
|
||||
// so a launch the gate blocks surfaces here. Without a toast the deep
|
||||
// link would simply appear to do nothing.
|
||||
showErrorToast(translateBackendError(t, error));
|
||||
} finally {
|
||||
setIsLaunching(false);
|
||||
if (selected) {
|
||||
@@ -122,7 +129,7 @@ export function ProfileSelectorDialog({
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedProfile, url, onClose, profiles]);
|
||||
}, [selectedProfile, url, onClose, profiles, t]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setSelectedProfile(null);
|
||||
|
||||
@@ -89,6 +89,12 @@ export function ProxyFormDialog({
|
||||
const { t } = useTranslation();
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [form, setForm] = useState<ProxyFormData>(DEFAULT_FORM);
|
||||
// The local parse only covers scheme/host/port. Whether Donut can actually
|
||||
// use the server — REALITY, XTLS Vision, plain TCP — is decided by the Rust
|
||||
// parser, so ask it (below) and show the specific reason while the user is
|
||||
// still editing rather than after they save. Declared here because
|
||||
// `handleSubmit` guards on it.
|
||||
const [vlessUnsupported, setVlessUnsupported] = useState<string | null>(null);
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setForm(DEFAULT_FORM);
|
||||
@@ -134,6 +140,11 @@ export function ProxyFormDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
if (isVless && vlessUnsupported) {
|
||||
toast.error(vlessUnsupported);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isVless && (!form.host.trim() || !form.port)) {
|
||||
toast.error(t("proxies.form.hostPortRequired"));
|
||||
return;
|
||||
@@ -183,7 +194,7 @@ export function ProxyFormDialog({
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [editingProxy, form, onClose, t]);
|
||||
}, [editingProxy, form, onClose, t, vlessUnsupported]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
@@ -193,12 +204,37 @@ export function ProxyFormDialog({
|
||||
|
||||
const isVless = form.proxy_type === "vless";
|
||||
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
|
||||
|
||||
const trimmedVlessUri = form.vless_uri.trim();
|
||||
useEffect(() => {
|
||||
if (!isVless || trimmedVlessUri.length === 0) {
|
||||
setVlessUnsupported(null);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void invoke("validate_vless_uri", { uri: trimmedVlessUri })
|
||||
.then(() => {
|
||||
if (!cancelled) setVlessUnsupported(null);
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
if (!cancelled) setVlessUnsupported(translateBackendError(t, error));
|
||||
});
|
||||
}, 300);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [isVless, trimmedVlessUri, t]);
|
||||
|
||||
const hasInvalidVlessUri =
|
||||
isVless && form.vless_uri.trim().length > 0 && !vlessEndpoint;
|
||||
isVless &&
|
||||
trimmedVlessUri.length > 0 &&
|
||||
(!vlessEndpoint || vlessUnsupported !== null);
|
||||
const isFormValid =
|
||||
form.name.trim() &&
|
||||
(isVless
|
||||
? vlessEndpoint !== null
|
||||
? vlessEndpoint !== null && vlessUnsupported === null
|
||||
: form.host.trim() &&
|
||||
form.port > 0 &&
|
||||
form.port <= 65535 &&
|
||||
@@ -286,7 +322,7 @@ export function ProxyFormDialog({
|
||||
role={hasInvalidVlessUri ? "alert" : undefined}
|
||||
>
|
||||
{hasInvalidVlessUri
|
||||
? t("proxies.form.vlessUriInvalid")
|
||||
? (vlessUnsupported ?? t("proxies.form.vlessUriInvalid"))
|
||||
: t("proxies.form.vlessUriHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user