mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-26 16:39:58 +02:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06fcd0cfd8 | |||
| befccef2c3 | |||
| 946bd1b81b | |||
| cae758f0ab | |||
| aa2e9e2528 | |||
| 084e63eb1e | |||
| c2d59e7faf | |||
| e8b800e83b | |||
| b00b773c07 | |||
| c782ef1961 | |||
| 888631bc48 | |||
| cd5fd2c970 | |||
| f63650fa5d | |||
| 7092f2155b | |||
| 861d301451 | |||
| e1a4d8f389 | |||
| 65d417d17c | |||
| 0fa3922202 | |||
| f46f7e8961 | |||
| 378ece5ea5 | |||
| 6c76dc1a34 | |||
| e45f4a792f | |||
| 0860a3b6e0 | |||
| 0222c7e904 | |||
| 786acc4356 | |||
| a813358c49 | |||
| a3fd056d6e | |||
| 806e2497c0 | |||
| c742964d86 | |||
| 57e17b46e9 | |||
| 116a54942d | |||
| 8936816613 | |||
| db05ffdef6 | |||
| 96614a3f33 | |||
| 222a8b89f5 | |||
| 69e68a7331 |
@@ -14,6 +14,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
contrib-readme-job:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
runs-on: ubuntu-latest
|
||||
name: Automatically update the contributors list in the README
|
||||
permissions:
|
||||
|
||||
@@ -12,7 +12,7 @@ permissions:
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
with:
|
||||
scan-args: |-
|
||||
@@ -28,7 +28,7 @@ jobs:
|
||||
|
||||
lint-js:
|
||||
name: Lint JavaScript/TypeScript
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
lint-rust:
|
||||
name: Lint Rust
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
|
||||
codeql:
|
||||
name: CodeQL
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
@@ -55,7 +55,7 @@ jobs:
|
||||
|
||||
spellcheck:
|
||||
name: Spell Check
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: ./.github/workflows/spellcheck.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
|
||||
dependabot-automerge:
|
||||
name: Dependabot Automerge
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
name: Flake Test
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- ".github/workflows/flake-test.yml"
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- "flake.nix"
|
||||
- "flake.lock"
|
||||
- ".github/workflows/flake-test.yml"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
flake:
|
||||
name: validate-flake
|
||||
runs-on: ubuntu-22.04
|
||||
timeout-minutes: 90
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
extra_nix_config: |
|
||||
experimental-features = nix-command flakes
|
||||
|
||||
- name: Evaluate flake outputs
|
||||
run: nix flake show --all-systems
|
||||
|
||||
- name: Check setup app is exposed
|
||||
run: nix eval .#apps.x86_64-linux.setup.program --raw
|
||||
|
||||
- name: Run flake setup app
|
||||
env:
|
||||
CI: "true"
|
||||
run: nix run .#setup
|
||||
|
||||
- name: Run flake info app
|
||||
run: nix run .#info
|
||||
@@ -14,17 +14,13 @@ permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
models: read
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
analyze-issue:
|
||||
if: github.event_name == 'issues'
|
||||
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
env:
|
||||
@@ -41,40 +37,87 @@ jobs:
|
||||
echo "is_first_time=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Analyze issue
|
||||
uses: anomalyco/opencode/github@d954026dd855e018302a6c0733a1dd74140931df #v1.2.26
|
||||
- name: Analyze issue with AI
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
model: zai-coding-plan/glm-4.7
|
||||
prompt: |
|
||||
You are a triage bot for Donut Browser (open-source anti-detect browser, Tauri + Next.js + Rust).
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
|
||||
run: |
|
||||
GREETING=""
|
||||
if [ "$IS_FIRST_TIME" = "true" ]; then
|
||||
GREETING='This is a first-time contributor. Start your comment with: "Thanks for opening your first issue!"'
|
||||
fi
|
||||
|
||||
${{ steps.check-first-time.outputs.is_first_time == 'true' && 'This is a first-time contributor. Start your comment with: "Thanks for opening your first issue!"' || '' }}
|
||||
# Write all user content to files to avoid shell escaping issues
|
||||
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
||||
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
|
||||
printf '%s' "$ISSUE_AUTHOR" > /tmp/issue-author.txt
|
||||
printf '%s' "$GREETING" > /tmp/greeting.txt
|
||||
|
||||
Analyze this issue and post a single concise comment. Format:
|
||||
# Build the JSON payload entirely in jq — never interpolate user content in shell
|
||||
PAYLOAD=$(jq -n \
|
||||
--rawfile title /tmp/issue-title.txt \
|
||||
--rawfile body /tmp/issue-body.txt \
|
||||
--rawfile author /tmp/issue-author.txt \
|
||||
--rawfile greeting /tmp/greeting.txt \
|
||||
'{
|
||||
model: "z-ai/glm-5",
|
||||
max_tokens: 1024,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a triage bot for Donut Browser (open-source anti-detect browser, Tauri + Next.js + Rust).\n\nAnalyze the issue and produce a single concise comment. Format:\n\n1. One sentence acknowledging what the user wants.\n2. A short **Action items** list - what specific info is missing or what the user should do next. Only include items that are actually missing. If the issue is complete, say so and skip this section.\n3. Suggest a label at the very end of your response on its own line in the exact format: Label: bug OR Label: enhancement\n\nRules:\n- Be brief. No filler, no generic tips, no templates.\n- If it is a bug report, check for: reproduction steps, OS/version, error messages. Only ask for what is actually missing.\n- If it is a feature request, check for: clear description of desired behavior, use case. Only ask for what is actually missing.\n- If the issue already has everything needed, just acknowledge it.\n- Never exceed 6 items total."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: (
|
||||
(if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
|
||||
"Analyze this issue:\n\nTitle: " + $title +
|
||||
"\nAuthor: " + $author +
|
||||
"\n\nBody:\n" + $body
|
||||
)
|
||||
}
|
||||
]
|
||||
}')
|
||||
|
||||
1. One sentence acknowledging what the user wants.
|
||||
2. A short **Action items** list — what specific info is missing or what the user should do next. Only include items that are actually missing. If the issue is complete, say so and skip this section.
|
||||
3. Label the issue: add "bug" label for bug reports, "enhancement" label for feature requests.
|
||||
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")
|
||||
|
||||
Rules:
|
||||
- Be brief. No filler, no generic tips, no templates.
|
||||
- If it's a bug report, check for: reproduction steps, OS/version, error messages. Only ask for what's actually missing.
|
||||
- If it's a feature request, check for: clear description of desired behavior, use case. Only ask for what's actually missing.
|
||||
- If the issue already has everything needed, just acknowledge it and label it.
|
||||
- Never exceed 6 items total.
|
||||
# Extract the comment using jq — never parse AI output in shell
|
||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
|
||||
|
||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||
echo "::error::AI response was empty"
|
||||
echo "Raw response:"
|
||||
echo "$RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Post comment and label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number }}
|
||||
run: |
|
||||
# Extract and strip the label line before posting
|
||||
LABEL=$(grep -oP '^Label:\s*\K.*' /tmp/ai-comment.txt | tail -1 | tr '[:upper:]' '[:lower:]' | xargs)
|
||||
sed -i '/^Label:/d' /tmp/ai-comment.txt
|
||||
|
||||
gh issue comment "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
|
||||
|
||||
if [ "$LABEL" = "bug" ]; then
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "bug" 2>/dev/null || true
|
||||
elif [ "$LABEL" = "enhancement" ]; then
|
||||
gh issue edit "$ISSUE_NUMBER" --repo "$GITHUB_REPOSITORY" --add-label "enhancement" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
analyze-pr:
|
||||
if: github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
|
||||
if: github.repository == 'zhom/donutbrowser' && github.event_name == 'pull_request_target' && github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
env:
|
||||
@@ -91,33 +134,91 @@ jobs:
|
||||
echo "is_first_time=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Analyze PR
|
||||
uses: anomalyco/opencode/github@d954026dd855e018302a6c0733a1dd74140931df #v1.2.26
|
||||
- name: Analyze PR with AI
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
model: zai-coding-plan/glm-4.7
|
||||
prompt: |
|
||||
You are a review bot for Donut Browser (open-source anti-detect browser, Tauri + Next.js + Rust).
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login }}
|
||||
PR_BASE: ${{ github.event.pull_request.base.ref }}
|
||||
PR_HEAD: ${{ github.event.pull_request.head.ref }}
|
||||
IS_FIRST_TIME: ${{ steps.check-first-time.outputs.is_first_time }}
|
||||
run: |
|
||||
GREETING=""
|
||||
if [ "$IS_FIRST_TIME" = "true" ]; then
|
||||
GREETING='This is a first-time contributor. Start your comment with: "Thanks for your first PR!"'
|
||||
fi
|
||||
|
||||
${{ steps.check-first-time.outputs.is_first_time == 'true' && 'This is a first-time contributor. Start your comment with: "Thanks for your first PR!"' || '' }}
|
||||
# Write all user content to files to avoid shell escaping issues
|
||||
printf '%s' "$PR_TITLE" > /tmp/pr-title.txt
|
||||
printf '%s' "${PR_BODY:-}" > /tmp/pr-body.txt
|
||||
printf '%s' "$PR_AUTHOR" > /tmp/pr-author.txt
|
||||
printf '%s' "$PR_BASE" > /tmp/pr-base.txt
|
||||
printf '%s' "$PR_HEAD" > /tmp/pr-head.txt
|
||||
printf '%s' "$GREETING" > /tmp/greeting.txt
|
||||
|
||||
Review this PR and post a single concise comment. Format:
|
||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
|
||||
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
|
||||
> /tmp/pr-files.txt
|
||||
|
||||
1. One sentence summarizing what this PR does.
|
||||
2. **Action items** — only list things that actually need to be fixed or addressed. If the PR looks good, say so and skip this section.
|
||||
# Build the JSON payload entirely in jq — never interpolate user content in shell
|
||||
PAYLOAD=$(jq -n \
|
||||
--rawfile title /tmp/pr-title.txt \
|
||||
--rawfile body /tmp/pr-body.txt \
|
||||
--rawfile author /tmp/pr-author.txt \
|
||||
--rawfile base /tmp/pr-base.txt \
|
||||
--rawfile head /tmp/pr-head.txt \
|
||||
--rawfile files /tmp/pr-files.txt \
|
||||
--rawfile greeting /tmp/greeting.txt \
|
||||
'{
|
||||
model: "z-ai/glm-5",
|
||||
max_tokens: 1024,
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a review bot for Donut Browser (open-source anti-detect browser, Tauri + Next.js + Rust).\n\nReview this PR and produce a single concise comment. Format:\n\n1. One sentence summarizing what this PR does.\n2. **Action items** - only list things that actually need to be fixed or addressed. If the PR looks good, say so and skip this section.\n\nRules:\n- Be brief. No filler, no praise padding.\n- Focus on: bugs, security issues, missing edge cases, breaking changes.\n- If the PR touches UI text or adds new strings, remind to update translation files in src/i18n/locales/.\n- If the PR modifies Tauri commands, remind to check the unused-commands test.\n- Do not nitpick style or formatting - the project has automated linting.\n- Never exceed 8 lines total."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: (
|
||||
(if ($greeting | length) > 0 then $greeting + "\n\n" else "" end) +
|
||||
"Review this PR:\n\nTitle: " + $title +
|
||||
"\nAuthor: " + $author +
|
||||
"\nBase: " + $base + " <- Head: " + $head +
|
||||
"\n\nDescription:\n" + $body +
|
||||
"\n\nChanged files:\n" + $files
|
||||
)
|
||||
}
|
||||
]
|
||||
}')
|
||||
|
||||
Rules:
|
||||
- Be brief. No filler, no praise padding.
|
||||
- Focus on: bugs, security issues, missing edge cases, breaking changes.
|
||||
- If the PR touches UI text or adds new strings, remind to update translation files in src/i18n/locales/.
|
||||
- If the PR modifies Tauri commands, remind to check the unused-commands test.
|
||||
- Do not nitpick style or formatting — the project has automated linting.
|
||||
- Never exceed 8 lines total.
|
||||
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")
|
||||
|
||||
# Extract the comment using jq — never parse AI output in shell
|
||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/ai-comment.txt
|
||||
|
||||
if [ ! -s /tmp/ai-comment.txt ]; then
|
||||
echo "::error::AI response was empty"
|
||||
echo "Raw response:"
|
||||
echo "$RESPONSE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Post comment
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
gh pr comment "$PR_NUMBER" --repo "$GITHUB_REPOSITORY" --body-file /tmp/ai-comment.txt
|
||||
|
||||
opencode-command:
|
||||
if: |
|
||||
github.repository == 'zhom/donutbrowser' &&
|
||||
(github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment') &&
|
||||
(contains(github.event.comment.body, ' /oc') ||
|
||||
startsWith(github.event.comment.body, '/oc') ||
|
||||
@@ -129,7 +230,7 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@d954026dd855e018302a6c0733a1dd74140931df #v1.2.26
|
||||
uses: anomalyco/opencode/github@4ee426ba549131c4903a71dfb6259200467aca81 #v1.2.27
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -13,7 +13,7 @@ permissions:
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v')
|
||||
if: github.repository == 'zhom/donutbrowser' && github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v')
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
+128
-85
@@ -18,6 +18,7 @@ env:
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
with:
|
||||
@@ -33,6 +34,7 @@ jobs:
|
||||
actions: read
|
||||
|
||||
lint-js:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Lint JavaScript/TypeScript
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
@@ -40,6 +42,7 @@ jobs:
|
||||
contents: read
|
||||
|
||||
lint-rust:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Lint Rust
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
@@ -47,6 +50,7 @@ jobs:
|
||||
contents: read
|
||||
|
||||
codeql:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: CodeQL
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
@@ -57,6 +61,7 @@ jobs:
|
||||
actions: read
|
||||
|
||||
spellcheck:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Spell Check
|
||||
uses: ./.github/workflows/spellcheck.yml
|
||||
secrets: inherit
|
||||
@@ -64,6 +69,7 @@ jobs:
|
||||
contents: read
|
||||
|
||||
release:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -202,7 +208,7 @@ jobs:
|
||||
rm -f $CERT_PATH $KEY_PATH $PEM_PATH $P12_PATH
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@73fb865345c54760d875b94642314f8c0c894afa #v0.6.1
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 #v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
@@ -225,98 +231,135 @@ jobs:
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||
rm -f $RUNNER_TEMP/build_certificate.p12 || true
|
||||
|
||||
# - name: Commit CHANGELOG.md
|
||||
# uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 #v6.0.1
|
||||
# with:
|
||||
# branch: main
|
||||
# commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
|
||||
|
||||
publish-repos:
|
||||
changelog:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
contents: write
|
||||
steps:
|
||||
- name: Download Linux packages from release
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
PREV_TAG=$(git tag --sort=-version:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| grep -v "^${TAG}$" \
|
||||
| head -n 1)
|
||||
|
||||
if [ -z "$PREV_TAG" ]; then
|
||||
PREV_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
|
||||
echo "Generating changelog: ${PREV_TAG}..${TAG}"
|
||||
|
||||
features=""
|
||||
fixes=""
|
||||
refactors=""
|
||||
perf=""
|
||||
docs=""
|
||||
maintenance=""
|
||||
other=""
|
||||
|
||||
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
|
||||
|
||||
while IFS= read -r msg; do
|
||||
[ -z "$msg" ] && continue
|
||||
case "$msg" in
|
||||
feat\(*\):*|feat:*)
|
||||
features="${features}- $(strip_prefix "$msg")"$'\n' ;;
|
||||
fix\(*\):*|fix:*)
|
||||
fixes="${fixes}- $(strip_prefix "$msg")"$'\n' ;;
|
||||
refactor\(*\):*|refactor:*)
|
||||
refactors="${refactors}- $(strip_prefix "$msg")"$'\n' ;;
|
||||
perf\(*\):*|perf:*)
|
||||
perf="${perf}- $(strip_prefix "$msg")"$'\n' ;;
|
||||
docs\(*\):*|docs:*)
|
||||
docs="${docs}- $(strip_prefix "$msg")"$'\n' ;;
|
||||
build*|ci*|chore*|test*)
|
||||
maintenance="${maintenance}- ${msg}"$'\n' ;;
|
||||
*)
|
||||
other="${other}- ${msg}"$'\n' ;;
|
||||
esac
|
||||
done < <(git log --pretty=format:"%s" "${PREV_TAG}..${TAG}" --no-merges)
|
||||
|
||||
{
|
||||
echo "## ${TAG} ($(date -u +%Y-%m-%d))"
|
||||
echo ""
|
||||
[ -n "$features" ] && printf "### Features\n\n%s\n" "$features"
|
||||
[ -n "$fixes" ] && printf "### Bug Fixes\n\n%s\n" "$fixes"
|
||||
[ -n "$refactors" ] && printf "### Refactoring\n\n%s\n" "$refactors"
|
||||
[ -n "$perf" ] && printf "### Performance\n\n%s\n" "$perf"
|
||||
[ -n "$docs" ] && printf "### Documentation\n\n%s\n" "$docs"
|
||||
[ -n "$maintenance" ] && printf "### Maintenance\n\n%s\n" "$maintenance"
|
||||
[ -n "$other" ] && printf "### Other\n\n%s\n" "$other"
|
||||
} > /tmp/release-changelog.md
|
||||
|
||||
echo "Generated changelog:"
|
||||
cat /tmp/release-changelog.md
|
||||
|
||||
- name: Update CHANGELOG.md
|
||||
run: |
|
||||
if [ -f CHANGELOG.md ]; then
|
||||
# Insert new entry after the "# Changelog" header (first 2 lines)
|
||||
{
|
||||
head -n 2 CHANGELOG.md
|
||||
echo ""
|
||||
cat /tmp/release-changelog.md
|
||||
tail -n +3 CHANGELOG.md
|
||||
} > CHANGELOG.tmp
|
||||
mv CHANGELOG.tmp CHANGELOG.md
|
||||
else
|
||||
{
|
||||
echo "# Changelog"
|
||||
echo ""
|
||||
cat /tmp/release-changelog.md
|
||||
} > CHANGELOG.md
|
||||
fi
|
||||
|
||||
- name: Commit CHANGELOG.md
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add CHANGELOG.md
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changelog changes to commit"
|
||||
else
|
||||
git commit -m "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
|
||||
git push origin main
|
||||
fi
|
||||
|
||||
- name: Update release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
mkdir -p /tmp/packages
|
||||
gh release download "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "*.deb" \
|
||||
--dir /tmp/packages
|
||||
gh release download "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "*.rpm" \
|
||||
--dir /tmp/packages
|
||||
echo "Downloaded packages:"
|
||||
ls -la /tmp/packages/
|
||||
gh release edit "$TAG" --notes-file /tmp/release-changelog.md
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 #v6.3.0
|
||||
with:
|
||||
go-version: "1.23"
|
||||
cache: false
|
||||
|
||||
- name: Install repogen
|
||||
run: |
|
||||
go install github.com/ralt/repogen/cmd/repogen@latest
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Sync existing repo metadata from R2
|
||||
notify-discord:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send Discord notification
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_STABLE_WEBHOOK_URL }}
|
||||
run: |
|
||||
mkdir -p /tmp/repo
|
||||
aws s3 cp "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive 2>/dev/null || true
|
||||
aws s3 cp "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive 2>/dev/null || true
|
||||
VERSION="${GITHUB_REF_NAME}"
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${VERSION}"
|
||||
|
||||
- name: Generate repository with repogen
|
||||
run: |
|
||||
repogen generate \
|
||||
--input-dir /tmp/packages \
|
||||
--output-dir /tmp/repo \
|
||||
--incremental \
|
||||
--arch amd64,arm64 \
|
||||
--origin "Donut Browser" \
|
||||
--label "Donut Browser" \
|
||||
--codename stable \
|
||||
--components main \
|
||||
--verbose
|
||||
|
||||
- name: Upload repository to R2
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
aws s3 cp /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive
|
||||
aws s3 cp /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive
|
||||
aws s3 cp /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive
|
||||
aws s3 cp /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --recursive
|
||||
|
||||
- name: Verify upload
|
||||
env:
|
||||
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
|
||||
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
|
||||
AWS_DEFAULT_REGION: auto
|
||||
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
echo "DEB repo:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}" || echo " (listing not available)"
|
||||
echo "RPM repo:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}" || echo " (listing not available)"
|
||||
curl -fsSL -H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"embeds\": [{
|
||||
\"title\": \"Donut Browser ${VERSION} Released\",
|
||||
\"url\": \"${RELEASE_URL}\",
|
||||
\"description\": \"A new stable release of Donut Browser is available.\",
|
||||
\"color\": 5814783
|
||||
}]
|
||||
}" \
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
|
||||
@@ -17,6 +17,7 @@ env:
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
with:
|
||||
@@ -32,6 +33,7 @@ jobs:
|
||||
actions: read
|
||||
|
||||
lint-js:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Lint JavaScript/TypeScript
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
@@ -39,6 +41,7 @@ jobs:
|
||||
contents: read
|
||||
|
||||
lint-rust:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Lint Rust
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
@@ -46,6 +49,7 @@ jobs:
|
||||
contents: read
|
||||
|
||||
codeql:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: CodeQL
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
@@ -56,6 +60,7 @@ jobs:
|
||||
actions: read
|
||||
|
||||
spellcheck:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
name: Spell Check
|
||||
uses: ./.github/workflows/spellcheck.yml
|
||||
secrets: inherit
|
||||
@@ -63,6 +68,7 @@ jobs:
|
||||
contents: read
|
||||
|
||||
rolling-release:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -210,7 +216,7 @@ jobs:
|
||||
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@73fb865345c54760d875b94642314f8c0c894afa #v0.6.1
|
||||
uses: tauri-apps/tauri-action@84b9d35b5fc46c1e45415bdb6144030364f7ebc5 #v0.6.2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
@@ -236,6 +242,7 @@ jobs:
|
||||
rm -f $RUNNER_TEMP/build_certificate.p12 || true
|
||||
|
||||
update-nightly-release:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [rolling-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -250,6 +257,56 @@ jobs:
|
||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate nightly changelog
|
||||
id: nightly-changelog
|
||||
run: |
|
||||
LAST_STABLE=$(git tag --sort=-version:refname \
|
||||
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
|
||||
| head -n 1)
|
||||
|
||||
if [ -z "$LAST_STABLE" ]; then
|
||||
LAST_STABLE=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
|
||||
COMMIT_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
{
|
||||
echo "**Nightly build from main branch**"
|
||||
echo ""
|
||||
echo "Commit: ${GITHUB_SHA}"
|
||||
echo "Changes since ${LAST_STABLE}:"
|
||||
echo ""
|
||||
} > /tmp/nightly-notes.md
|
||||
|
||||
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
|
||||
|
||||
features=""
|
||||
fixes=""
|
||||
refactors=""
|
||||
other=""
|
||||
|
||||
while IFS= read -r msg; do
|
||||
[ -z "$msg" ] && continue
|
||||
case "$msg" in
|
||||
feat\(*\):*|feat:*)
|
||||
features="${features}- $(strip_prefix "$msg")"$'\n' ;;
|
||||
fix\(*\):*|fix:*)
|
||||
fixes="${fixes}- $(strip_prefix "$msg")"$'\n' ;;
|
||||
refactor\(*\):*|refactor:*)
|
||||
refactors="${refactors}- $(strip_prefix "$msg")"$'\n' ;;
|
||||
build*|ci*|chore*|test*|docs*|perf*)
|
||||
;; # skip maintenance commits from nightly notes
|
||||
*)
|
||||
other="${other}- ${msg}"$'\n' ;;
|
||||
esac
|
||||
done < <(git log --pretty=format:"%s" "${LAST_STABLE}..HEAD" --no-merges)
|
||||
|
||||
{
|
||||
[ -n "$features" ] && printf "### Features\n\n%s\n" "$features"
|
||||
[ -n "$fixes" ] && printf "### Bug Fixes\n\n%s\n" "$fixes"
|
||||
[ -n "$refactors" ] && printf "### Refactoring\n\n%s\n" "$refactors"
|
||||
[ -n "$other" ] && printf "### Other\n\n%s\n" "$other"
|
||||
} >> /tmp/nightly-notes.md
|
||||
|
||||
- name: Update rolling nightly release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -284,5 +341,28 @@ jobs:
|
||||
"$ASSETS_DIR"/Donut_aarch64.app.tar.gz \
|
||||
"$ASSETS_DIR"/Donut_x64.app.tar.gz \
|
||||
--title "Donut Browser Nightly" \
|
||||
--notes "Automatically updated nightly build from the latest main branch.\n\nCommit: ${GITHUB_SHA}" \
|
||||
--notes-file /tmp/nightly-notes.md \
|
||||
--prerelease
|
||||
|
||||
notify-discord:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [update-nightly-release]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Send Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_NIGHTLY_WEBHOOK_URL }}
|
||||
run: |
|
||||
COMMIT_SHORT=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/nightly"
|
||||
|
||||
curl -fsSL -H "Content-Type: application/json" \
|
||||
-d "{
|
||||
\"embeds\": [{
|
||||
\"title\": \"Donut Browser Nightly Updated\",
|
||||
\"url\": \"${RELEASE_URL}\",
|
||||
\"description\": \"A new nightly build is available (${COMMIT_SHORT}).\",
|
||||
\"color\": 16752128
|
||||
}]
|
||||
}" \
|
||||
"$DISCORD_WEBHOOK_URL"
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
Vendored
+12
@@ -10,6 +10,7 @@
|
||||
"appindicator",
|
||||
"applescript",
|
||||
"asyncio",
|
||||
"autocheckpoint",
|
||||
"autoconfig",
|
||||
"autologin",
|
||||
"biomejs",
|
||||
@@ -46,6 +47,7 @@
|
||||
"desynced",
|
||||
"devedition",
|
||||
"direnv",
|
||||
"diskutil",
|
||||
"distro",
|
||||
"dists",
|
||||
"DMABUF",
|
||||
@@ -58,6 +60,7 @@
|
||||
"dtolnay",
|
||||
"dyld",
|
||||
"elif",
|
||||
"erasevolume",
|
||||
"errorlevel",
|
||||
"esac",
|
||||
"esbuild",
|
||||
@@ -82,6 +85,7 @@
|
||||
"idlelib",
|
||||
"idletime",
|
||||
"idna",
|
||||
"imdisk",
|
||||
"infobars",
|
||||
"inkey",
|
||||
"Inno",
|
||||
@@ -95,6 +99,7 @@
|
||||
"langpack",
|
||||
"launchservices",
|
||||
"letterboxing",
|
||||
"leveldb",
|
||||
"libappindicator",
|
||||
"libatk",
|
||||
"libayatana",
|
||||
@@ -114,6 +119,7 @@
|
||||
"macchiato",
|
||||
"Matchalk",
|
||||
"maxminddb",
|
||||
"minidumps",
|
||||
"minioadmin",
|
||||
"mmdb",
|
||||
"mountpoint",
|
||||
@@ -129,13 +135,16 @@
|
||||
"nodecar",
|
||||
"NODELAY",
|
||||
"nodemon",
|
||||
"nomount",
|
||||
"norestart",
|
||||
"NSIS",
|
||||
"ntfs",
|
||||
"ntlm",
|
||||
"numpy",
|
||||
"objc",
|
||||
"oneshot",
|
||||
"opencode",
|
||||
"OPENROUTER",
|
||||
"orhun",
|
||||
"orjson",
|
||||
"osascript",
|
||||
@@ -155,6 +164,7 @@
|
||||
"plasmohq",
|
||||
"platformdirs",
|
||||
"prefs",
|
||||
"presign",
|
||||
"PRIO",
|
||||
"propertylist",
|
||||
"psutil",
|
||||
@@ -179,6 +189,7 @@
|
||||
"rusqlite",
|
||||
"rustc",
|
||||
"rwxr",
|
||||
"safebrowsing",
|
||||
"SARIF",
|
||||
"scipy",
|
||||
"screeninfo",
|
||||
@@ -222,6 +233,7 @@
|
||||
"titlebar",
|
||||
"tkinter",
|
||||
"tmpfs",
|
||||
"tombstoned",
|
||||
"tqdm",
|
||||
"trackingprotection",
|
||||
"trailhead",
|
||||
|
||||
@@ -36,14 +36,26 @@
|
||||
- Automatic updates for browsers
|
||||
- Set Donut Browser as your default browser to control in which profile to open links
|
||||
|
||||
## Download
|
||||
## Install
|
||||
|
||||
> For Linux, .deb and .rpm packages are available as well as standalone .AppImage files.
|
||||
### macOS
|
||||
|
||||
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
|
||||
```bash
|
||||
brew install --cask donut
|
||||
```
|
||||
|
||||
### Linux
|
||||
|
||||
```bash
|
||||
curl -fsSL https://donutbrowser.com/install.sh | sh
|
||||
```
|
||||
|
||||
This sets up the package repository and installs Donut Browser via your system package manager. Supports Debian/Ubuntu, Fedora/RHEL, and openSUSE.
|
||||
|
||||
Standalone `.AppImage` files are also available on the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
|
||||
|
||||
<details>
|
||||
<summary>Troubleshooting AppImage on Linux</summary>
|
||||
<summary>Troubleshooting AppImage</summary>
|
||||
|
||||
If the AppImage segfaults on launch, install **libfuse2** (`sudo apt install libfuse2` / `yay -S libfuse2` / `sudo dnf install fuse-libs`), or bypass FUSE entirely:
|
||||
|
||||
@@ -55,11 +67,9 @@ If that gives an EGL display error, try adding `WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
|
||||
</details>
|
||||
|
||||
<!-- ## Supported Platforms
|
||||
### Windows
|
||||
|
||||
- ✅ **macOS** (Apple Silicon)
|
||||
- ✅ **Linux** (x64)
|
||||
- ✅ **Windows** (x64) -->
|
||||
Download the installer from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
|
||||
|
||||
## Development
|
||||
|
||||
@@ -77,7 +87,7 @@ Donut Browser supports syncing profiles, proxies, and groups across devices via
|
||||
|
||||
## Community
|
||||
|
||||
Have questions or want to contribute? The team would love to hear from you!
|
||||
Have questions or want to contribute? I'd love to hear from you!
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
|
||||
@@ -112,6 +122,13 @@ Have questions or want to contribute? The team would love to hear from you!
|
||||
<sub><b>Hassiy</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/drunkod">
|
||||
<img src="https://avatars.githubusercontent.com/u/9677471?v=4" width="100;" alt="drunkod"/>
|
||||
<br />
|
||||
<sub><b>drunkod</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center">
|
||||
<a href="https://github.com/JorySeverijnse">
|
||||
<img src="https://avatars.githubusercontent.com/u/117462355?v=4" width="100;" alt="JorySeverijnse"/>
|
||||
@@ -126,7 +143,7 @@ Have questions or want to contribute? The team would love to hear from you!
|
||||
|
||||
## Contact
|
||||
|
||||
Have an urgent question or want to report a security vulnerability? Send an email to [contact@donutbrowser.com](mailto:contact@donutbrowser.com) and the team will get back to you as fast as possible.
|
||||
Have an urgent question or want to report a security vulnerability? Send an email to [contact@donutbrowser.com](mailto:contact@donutbrowser.com) and I'll get back to you as fast as possible.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+8
-8
@@ -4,13 +4,13 @@
|
||||
|
||||
Thanks for helping make Donut Browser safe for everyone! ❤️
|
||||
|
||||
We take the security of Donut Browser seriously. If you believe you have found a security vulnerability in Donut Browser, please report it to us through coordinated disclosure.
|
||||
I take the security of Donut Browser seriously. If you believe you have found a security vulnerability in Donut Browser, please report it to me through coordinated disclosure.
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
|
||||
|
||||
Instead, please send an email to **[contact@donutbrowser.com](mailto:contact@donutbrowser.com)** with the subject line "Security Vulnerability Report".
|
||||
|
||||
Please include as much of the information listed below as you can to help us better understand and resolve the issue:
|
||||
Please include as much of the information listed below as you can to help me better understand and resolve the issue:
|
||||
|
||||
- The type of issue (e.g., buffer overflow, injection attack, privilege escalation, or cross-site scripting)
|
||||
- Full paths of source file(s) related to the manifestation of the issue
|
||||
@@ -21,18 +21,18 @@ Please include as much of the information listed below as you can to help us bet
|
||||
- Impact of the issue, including how an attacker might exploit the issue
|
||||
- Your assessment of the severity level
|
||||
|
||||
This information will help us triage your report more quickly.
|
||||
This information will help me triage your report more quickly.
|
||||
|
||||
## What to Expect
|
||||
|
||||
- **Response Time**: We will acknowledge receipt of your vulnerability report within 72 hours.
|
||||
- **Investigation**: We will investigate the issue and provide you with updates on our progress.
|
||||
- **Resolution**: We aim to resolve critical security issues as fast as possible, but no longer than in 30 days after the initial report.
|
||||
- **Disclosure**: We will coordinate with you on the timing of any public disclosure.
|
||||
- **Response Time**: I will acknowledge receipt of your vulnerability report within 72 hours.
|
||||
- **Investigation**: I will investigate the issue and provide you with updates on my progress.
|
||||
- **Resolution**: I aim to resolve critical security issues as fast as possible, but no longer than in 30 days after the initial report.
|
||||
- **Disclosure**: I will coordinate with you on the timing of any public disclosure.
|
||||
|
||||
## Contact
|
||||
|
||||
For urgent security matters, please contact us at **[contact@donutbrowser.com](mailto:contact@donutbrowser.com)**.
|
||||
For urgent security matters, please contact me at **[contact@donutbrowser.com](mailto:contact@donutbrowser.com)**.
|
||||
|
||||
For general questions about this security policy, you can also reach out through:
|
||||
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1009.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1009.0",
|
||||
"@nestjs/common": "^11.1.16",
|
||||
"@aws-sdk/client-s3": "^3.1015.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1015.0",
|
||||
"@nestjs/common": "^11.1.17",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.16",
|
||||
"@nestjs/platform-express": "^11.1.16",
|
||||
"@nestjs/core": "^11.1.17",
|
||||
"@nestjs/platform-express": "^11.1.17",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
@@ -31,7 +31,7 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@nestjs/testing": "^11.1.16",
|
||||
"@nestjs/testing": "^11.1.17",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
|
||||
Generated
+1
-22
@@ -37,28 +37,7 @@
|
||||
"root": {
|
||||
"inputs": {
|
||||
"flake-utils": "flake-utils",
|
||||
"nixpkgs": "nixpkgs",
|
||||
"rust-overlay": "rust-overlay"
|
||||
}
|
||||
},
|
||||
"rust-overlay": {
|
||||
"inputs": {
|
||||
"nixpkgs": [
|
||||
"nixpkgs"
|
||||
]
|
||||
},
|
||||
"locked": {
|
||||
"lastModified": 1767926800,
|
||||
"narHash": "sha256-x0n73J6ufD/EhDlVdcoAmF0OQHZ+b0a2cKDc8RZyt+o=",
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"rev": "499e9eed88ff9494b6604205b42847e847dfeb91",
|
||||
"type": "github"
|
||||
},
|
||||
"original": {
|
||||
"owner": "oxalica",
|
||||
"repo": "rust-overlay",
|
||||
"type": "github"
|
||||
"nixpkgs": "nixpkgs"
|
||||
}
|
||||
},
|
||||
"systems": {
|
||||
|
||||
@@ -1,66 +1,341 @@
|
||||
{
|
||||
description = "Donut Browser Development Environment";
|
||||
description = "Donut Browser development environment and quick-start commands";
|
||||
|
||||
inputs = {
|
||||
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
|
||||
flake-utils.url = "github:numtide/flake-utils";
|
||||
rust-overlay = {
|
||||
url = "github:oxalica/rust-overlay";
|
||||
inputs.nixpkgs.follows = "nixpkgs";
|
||||
};
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs, flake-utils, rust-overlay, ... }:
|
||||
outputs = { self, nixpkgs, flake-utils, ... }:
|
||||
flake-utils.lib.eachDefaultSystem (system:
|
||||
let
|
||||
overlays = [ (import rust-overlay) ];
|
||||
pkgs = import nixpkgs {
|
||||
inherit system overlays;
|
||||
inherit system;
|
||||
config.allowUnfree = true;
|
||||
};
|
||||
lib = pkgs.lib;
|
||||
|
||||
# Rust toolchain
|
||||
rustToolchain = pkgs.rust-bin.stable.latest.default.override {
|
||||
extensions = [ "rust-src" "rust-analyzer" "clippy" "rustfmt" ];
|
||||
};
|
||||
nodejs =
|
||||
if pkgs ? nodejs_23 then
|
||||
pkgs.nodejs_23
|
||||
else
|
||||
pkgs.nodejs_22;
|
||||
|
||||
# System dependencies for Tauri on Linux
|
||||
libraries = with pkgs; [
|
||||
rustPackages = with pkgs; [
|
||||
cargo
|
||||
clippy
|
||||
rust-analyzer
|
||||
rustc
|
||||
rustfmt
|
||||
];
|
||||
|
||||
commonLibs = with pkgs; [
|
||||
webkitgtk_4_1
|
||||
libsoup_3
|
||||
glib
|
||||
gtk3
|
||||
cairo
|
||||
gdk-pixbuf
|
||||
glib
|
||||
pango
|
||||
atk
|
||||
at-spi2-atk
|
||||
at-spi2-core
|
||||
dbus
|
||||
librsvg
|
||||
libsoup_3
|
||||
nss
|
||||
nspr
|
||||
libdrm
|
||||
libgbm
|
||||
libxkbcommon
|
||||
libx11
|
||||
libxcomposite
|
||||
libxdamage
|
||||
libxext
|
||||
libxfixes
|
||||
libxrandr
|
||||
libxcb
|
||||
libxshmfence
|
||||
libxtst
|
||||
libxi
|
||||
xdotool
|
||||
libxrender
|
||||
libxinerama
|
||||
libxcursor
|
||||
libxscrnsaver
|
||||
fontconfig
|
||||
freetype
|
||||
fribidi
|
||||
harfbuzz
|
||||
expat
|
||||
libglvnd
|
||||
libgpg-error
|
||||
e2fsprogs
|
||||
gmp
|
||||
zlib
|
||||
stdenv.cc.cc.lib
|
||||
];
|
||||
|
||||
packages = with pkgs; [
|
||||
rustToolchain
|
||||
nodejs_22
|
||||
pnpm
|
||||
pkg-config
|
||||
cargo-tauri
|
||||
openssl
|
||||
# App specific tools
|
||||
biome
|
||||
] ++ libraries;
|
||||
runtimeLibPath = lib.makeLibraryPath commonLibs;
|
||||
nixLd = pkgs.stdenv.cc.bintools.dynamicLinker;
|
||||
pkgConfigLibs = [
|
||||
pkgs.at-spi2-atk
|
||||
pkgs.at-spi2-core
|
||||
pkgs.cairo
|
||||
pkgs.dbus
|
||||
pkgs.gdk-pixbuf
|
||||
pkgs.glib
|
||||
pkgs.gtk3
|
||||
pkgs.libsoup_3
|
||||
pkgs.libxkbcommon
|
||||
pkgs.openssl
|
||||
pkgs.pango
|
||||
pkgs.harfbuzz
|
||||
pkgs.webkitgtk_4_1
|
||||
];
|
||||
pkgConfigPath = lib.makeSearchPath "lib/pkgconfig" (
|
||||
pkgConfigLibs ++ map lib.getDev pkgConfigLibs
|
||||
);
|
||||
releaseVersion = "0.17.6";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v${releaseVersion}/Donut_0.17.6_amd64.AppImage";
|
||||
hash = "sha256-Bmqmb0zgaC02DKC9gcyI/St9wfwFWTEoYybOb1LXiS0=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v${releaseVersion}/Donut_0.17.6_aarch64.AppImage";
|
||||
hash = "sha256-FOV0PlYw59gY1QSoFrUcixtUcnFt27EYAzZE/KNQUrM=";
|
||||
}
|
||||
else
|
||||
null;
|
||||
releaseUnpacked =
|
||||
if releaseAppImage != null then
|
||||
pkgs.stdenvNoCC.mkDerivation {
|
||||
pname = "donut-release-unpacked";
|
||||
version = releaseVersion;
|
||||
src = releaseAppImage;
|
||||
dontUnpack = true;
|
||||
nativeBuildInputs = [ pkgs.xz ];
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
cp "$src" ./donut.AppImage
|
||||
chmod +x ./donut.AppImage
|
||||
./donut.AppImage --appimage-extract >/dev/null
|
||||
|
||||
mkdir -p "$out"
|
||||
cp -a ./squashfs-root "$out/"
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
else
|
||||
null;
|
||||
releaseWrapped =
|
||||
if releaseAppImage != null then
|
||||
pkgs.appimageTools.wrapType2 {
|
||||
pname = "donut";
|
||||
version = releaseVersion;
|
||||
src = releaseAppImage;
|
||||
extraPkgs = _: commonLibs;
|
||||
extraInstallCommands = ''
|
||||
for bin in "$out"/bin/*; do
|
||||
if [ -f "$bin" ]; then
|
||||
mv "$bin" "$out/bin/donut-release"
|
||||
break
|
||||
fi
|
||||
done
|
||||
'';
|
||||
}
|
||||
else
|
||||
null;
|
||||
releaseLauncher =
|
||||
if releaseUnpacked != null then
|
||||
pkgs.writeShellApplication {
|
||||
name = "donut-release-start";
|
||||
runtimeInputs = with pkgs; [
|
||||
coreutils
|
||||
xdg-utils
|
||||
];
|
||||
text = ''
|
||||
set -euo pipefail
|
||||
|
||||
if [ -x "${releaseWrapped}/bin/donut-release" ]; then
|
||||
if "${releaseWrapped}/bin/donut-release" "$@"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "Wrapped AppImage failed, retrying with direct AppRun..." >&2
|
||||
fi
|
||||
|
||||
export LD_LIBRARY_PATH="${releaseUnpacked}/squashfs-root/usr/lib:${releaseUnpacked}/squashfs-root/usr/lib64:${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
|
||||
export NIX_LD_LIBRARY_PATH="$LD_LIBRARY_PATH"
|
||||
export LIBRARY_PATH="$LD_LIBRARY_PATH"
|
||||
export XDG_DATA_DIRS="${releaseUnpacked}/squashfs-root/usr/share:''${XDG_DATA_DIRS:-}"
|
||||
exec "${releaseUnpacked}/squashfs-root/AppRun" "$@"
|
||||
'';
|
||||
}
|
||||
else
|
||||
pkgs.writeShellApplication {
|
||||
name = "donut-release-start";
|
||||
text = ''
|
||||
echo "Release launcher is supported only on Linux (x86_64/aarch64)."
|
||||
exit 1
|
||||
'';
|
||||
};
|
||||
|
||||
mkApp = name: text:
|
||||
let
|
||||
app = pkgs.writeShellApplication {
|
||||
inherit name;
|
||||
runtimeInputs = with pkgs; [
|
||||
bash
|
||||
coreutils
|
||||
findutils
|
||||
git
|
||||
gnugrep
|
||||
gnused
|
||||
curl
|
||||
gcc
|
||||
pkg-config
|
||||
openssl
|
||||
cargo
|
||||
clippy
|
||||
rustc
|
||||
rustfmt
|
||||
nodejs
|
||||
pnpm
|
||||
cargo-tauri
|
||||
];
|
||||
text = ''
|
||||
export NODE_ENV=development
|
||||
export NIX_LD="${nixLd}"
|
||||
export NIX_LD_LIBRARY_PATH="${runtimeLibPath}:''${NIX_LD_LIBRARY_PATH:-}"
|
||||
export LD_LIBRARY_PATH="${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
|
||||
export LIBRARY_PATH="${runtimeLibPath}:''${LIBRARY_PATH:-}"
|
||||
export PKG_CONFIG_PATH="${pkgConfigPath}:''${PKG_CONFIG_PATH:-}"
|
||||
export RUST_SRC_PATH="${pkgs.rustPlatform.rustLibSrc}"
|
||||
${text}
|
||||
'';
|
||||
};
|
||||
in
|
||||
{
|
||||
type = "app";
|
||||
program = "${app}/bin/${name}";
|
||||
};
|
||||
in
|
||||
{
|
||||
devShells.default = pkgs.mkShell {
|
||||
buildInputs = packages;
|
||||
packages = with pkgs; [
|
||||
nodejs
|
||||
pnpm
|
||||
cargo-tauri
|
||||
pkg-config
|
||||
openssl
|
||||
git
|
||||
bashInteractive
|
||||
gnumake
|
||||
clang
|
||||
llvmPackages.bintools
|
||||
python3
|
||||
curl
|
||||
wget
|
||||
unzip
|
||||
zip
|
||||
xz
|
||||
biome
|
||||
docker
|
||||
] ++ rustPackages ++ commonLibs;
|
||||
|
||||
shellHook = ''
|
||||
export LD_LIBRARY_PATH=${pkgs.lib.makeLibraryPath libraries}:$LD_LIBRARY_PATH
|
||||
export XDG_DATA_DIRS=${pkgs.gsettings-desktop-schemas}/share/gsettings-schemas/${pkgs.gsettings-desktop-schemas.name}:${pkgs.gtk3}/share/gsettings-schemas/${pkgs.gtk3.name}:$XDG_DATA_DIRS
|
||||
|
||||
echo "🍩 Donut Browser Dev Environment Loaded!"
|
||||
echo "Node: $(node --version)"
|
||||
echo "Rust: $(rustc --version)"
|
||||
echo "Tauri CLI: $(cargo-tauri --version)"
|
||||
export NODE_ENV=development
|
||||
export NIX_LD="${nixLd}"
|
||||
export NIX_LD_LIBRARY_PATH="${runtimeLibPath}:''${NIX_LD_LIBRARY_PATH:-}"
|
||||
export LD_LIBRARY_PATH="${runtimeLibPath}:''${LD_LIBRARY_PATH:-}"
|
||||
export LIBRARY_PATH="${runtimeLibPath}:''${LIBRARY_PATH:-}"
|
||||
export PKG_CONFIG_PATH="${pkgConfigPath}:''${PKG_CONFIG_PATH:-}"
|
||||
export RUST_SRC_PATH="${pkgs.rustPlatform.rustLibSrc}"
|
||||
export XDG_DATA_DIRS="${pkgs.gsettings-desktop-schemas}/share:${pkgs.gtk3}/share:''${XDG_DATA_DIRS:-}"
|
||||
|
||||
echo "Donut Browser dev shell ready."
|
||||
echo "Quick start:"
|
||||
echo " nix run .#setup"
|
||||
echo " nix run .#tauri-dev"
|
||||
echo " nix run .#full-dev"
|
||||
echo " nix run .#build"
|
||||
echo " nix run .#test"
|
||||
echo " nix run .#release-start"
|
||||
'';
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
apps.info = mkApp "donut-info" ''
|
||||
set -euo pipefail
|
||||
echo "Node: $(node --version)"
|
||||
echo "pnpm: $(pnpm --version)"
|
||||
echo "Rust: $(rustc --version)"
|
||||
echo "Cargo: $(cargo --version)"
|
||||
echo "Tauri CLI: $(cargo-tauri --version)"
|
||||
'';
|
||||
|
||||
apps.deps = mkApp "donut-deps" ''
|
||||
set -euo pipefail
|
||||
pnpm install
|
||||
'';
|
||||
|
||||
apps.dev = mkApp "donut-dev" ''
|
||||
set -euo pipefail
|
||||
pnpm dev
|
||||
'';
|
||||
|
||||
apps."tauri-dev" = mkApp "donut-tauri-dev" ''
|
||||
set -euo pipefail
|
||||
pnpm tauri dev
|
||||
'';
|
||||
|
||||
apps."full-dev" = mkApp "donut-full-dev" ''
|
||||
set -euo pipefail
|
||||
chmod +x ./scripts/dev.sh
|
||||
./scripts/dev.sh
|
||||
'';
|
||||
|
||||
apps.build = mkApp "donut-build" ''
|
||||
set -euo pipefail
|
||||
pnpm build
|
||||
(cd src-tauri && cargo build)
|
||||
'';
|
||||
|
||||
apps.start = mkApp "donut-start" ''
|
||||
set -euo pipefail
|
||||
pnpm start
|
||||
'';
|
||||
|
||||
apps.test = mkApp "donut-test" ''
|
||||
set -euo pipefail
|
||||
pnpm format && pnpm lint && pnpm test
|
||||
'';
|
||||
|
||||
apps.setup = mkApp "donut-setup" ''
|
||||
set -euo pipefail
|
||||
|
||||
if [ ! -f "package.json" ]; then
|
||||
echo "package.json not found. Run this from the donutbrowser repo root."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
pnpm install
|
||||
pnpm copy-proxy-binary
|
||||
|
||||
echo "Setup complete."
|
||||
echo "Run the app with:"
|
||||
echo " nix run .#tauri-dev"
|
||||
echo "Or run full local stack (sync + minio + tauri):"
|
||||
echo " nix run .#full-dev"
|
||||
'';
|
||||
|
||||
apps."release-start" = {
|
||||
type = "app";
|
||||
program = "${releaseLauncher}/bin/donut-release-start";
|
||||
};
|
||||
|
||||
apps.default = self.apps.${system}.setup;
|
||||
});
|
||||
}
|
||||
|
||||
+11
-11
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
@@ -51,21 +51,21 @@
|
||||
"@tauri-apps/plugin-fs": "~2.4.5",
|
||||
"@tauri-apps/plugin-log": "^2.8.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"ahooks": "^3.9.6",
|
||||
"ahooks": "^3.9.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"i18next": "^25.8.18",
|
||||
"i18next": "^25.10.5",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.36.0",
|
||||
"next": "^16.1.6",
|
||||
"motion": "^12.38.0",
|
||||
"next": "^16.2.1",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.8",
|
||||
"react-i18next": "^16.6.2",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "3.8.0",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -73,17 +73,17 @@
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.7",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@biomejs/biome": "2.4.8",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tauri-apps/cli": "~2.10.1",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/color": "^4.2.1",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.3.4",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"lint-staged": "^16.4.0",
|
||||
"tailwindcss": "^4.2.2",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3"
|
||||
|
||||
Generated
+648
-647
File diff suppressed because it is too large
Load Diff
Generated
+185
-124
@@ -122,21 +122,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "0.6.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse 0.2.7",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
"is_terminal_polyfill",
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstream"
|
||||
version = "1.0.0"
|
||||
@@ -144,7 +129,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"anstyle-parse 1.0.0",
|
||||
"anstyle-parse",
|
||||
"anstyle-query",
|
||||
"anstyle-wincon",
|
||||
"colorchoice",
|
||||
@@ -158,15 +143,6 @@ version = "1.0.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "0.2.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
|
||||
dependencies = [
|
||||
"utf8parse",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anstyle-parse"
|
||||
version = "1.0.0"
|
||||
@@ -182,7 +158,7 @@ version = "1.1.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||
dependencies = [
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -193,7 +169,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||
dependencies = [
|
||||
"anstyle",
|
||||
"once_cell_polyfill",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -513,7 +489,7 @@ dependencies = [
|
||||
"sha1",
|
||||
"sync_wrapper",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.28.0",
|
||||
"tower",
|
||||
"tower-layer",
|
||||
"tower-service",
|
||||
@@ -709,19 +685,20 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.6.0"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1da5ab77c1437701eeff7c88d968729e7766172279eab0676857b3d63af7a6f"
|
||||
checksum = "cfd1e3f8955a5d7de9fab72fc8373fade9fb8a703968cb200ae3dc6cf08e185a"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive"
|
||||
version = "1.6.0"
|
||||
version = "1.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0686c856aa6aac0c4498f936d7d6a02df690f614c03e4d906d1018062b5c5e2c"
|
||||
checksum = "bfcfdc083699101d5a7965e49925975f2f55060f94f9a05e7187be95d530ca59"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro-crate 3.5.0",
|
||||
@@ -843,6 +820,15 @@ dependencies = [
|
||||
"bzip2-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f3a53fac24f34a81bc9954b5d6cfce0c21e18ec6959f44f56e8e90e4bb7c346c"
|
||||
dependencies = [
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
@@ -1089,7 +1075,7 @@ version = "4.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f"
|
||||
dependencies = [
|
||||
"anstream 1.0.0",
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"clap_lex",
|
||||
"strsim",
|
||||
@@ -1504,9 +1490,9 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||
|
||||
[[package]]
|
||||
name = "deflate64"
|
||||
version = "0.1.11"
|
||||
version = "0.1.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2"
|
||||
checksum = "ac6b926516df9c60bfa16e107b21086399f8285a44ca9711344b9e553c5146e2"
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
@@ -1642,7 +1628,7 @@ dependencies = [
|
||||
"libc",
|
||||
"option-ext",
|
||||
"redox_users",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1702,22 +1688,22 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "dom_query"
|
||||
version = "0.25.1"
|
||||
version = "0.27.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4d9c2e7f1d22d0f2ce07626d259b8a55f4a47cb0938d4006dd8ae037f17d585e"
|
||||
checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89"
|
||||
dependencies = [
|
||||
"bit-set",
|
||||
"cssparser 0.36.0",
|
||||
"foldhash 0.2.0",
|
||||
"html5ever 0.36.1",
|
||||
"html5ever 0.38.0",
|
||||
"precomputed-hash",
|
||||
"selectors 0.35.0",
|
||||
"tendril",
|
||||
"selectors 0.36.1",
|
||||
"tendril 0.5.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.17.2"
|
||||
version = "0.17.6"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"aes-gcm",
|
||||
@@ -1728,7 +1714,7 @@ dependencies = [
|
||||
"base64 0.22.1",
|
||||
"blake3",
|
||||
"boringtun",
|
||||
"bzip2",
|
||||
"bzip2 0.6.1",
|
||||
"cbc",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
@@ -1788,7 +1774,7 @@ dependencies = [
|
||||
"tempfile",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-tungstenite 0.29.0",
|
||||
"tokio-util",
|
||||
"tower",
|
||||
"tower-http",
|
||||
@@ -1801,7 +1787,7 @@ dependencies = [
|
||||
"windows 0.62.2",
|
||||
"winreg 0.56.0",
|
||||
"wiremock",
|
||||
"zip 8.2.0",
|
||||
"zip 8.4.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1848,9 +1834,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.6"
|
||||
version = "3.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "55a075fc573c64510038d7ee9abc7990635863992f83ebc52c8b433b8411a02e"
|
||||
checksum = "47ec73ddcf6b7f23173d5c3c5a32b5507dc0a734de7730aa14abc5d5e296bb5f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
@@ -1914,9 +1900,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "env_filter"
|
||||
version = "1.0.0"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7a1c3cc8e57274ec99de65301228b537f1e4eedc1b8e0f9411c6caac8ae7308f"
|
||||
checksum = "32e90c2accc4b07a8456ea0debdc2e7587bdd890680d71173a15d4ae604f6eef"
|
||||
dependencies = [
|
||||
"log",
|
||||
"regex",
|
||||
@@ -1924,13 +1910,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "env_logger"
|
||||
version = "0.11.9"
|
||||
version = "0.11.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2daee4ea451f429a58296525ddf28b45a3b64f1acf6587e2067437bb11e218d"
|
||||
checksum = "0621c04f2196ac3f488dd583365b9c09be011a4ab8b9f37248ffcc8f6198b56a"
|
||||
dependencies = [
|
||||
"anstream 0.6.21",
|
||||
"anstream",
|
||||
"anstyle",
|
||||
"env_filter 1.0.0",
|
||||
"env_filter 1.0.1",
|
||||
"jiff",
|
||||
"log",
|
||||
]
|
||||
@@ -1979,14 +1965,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "euclid"
|
||||
version = "0.22.13"
|
||||
version = "0.22.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df61bf483e837f88d5c2291dcf55c67be7e676b3a51acc48db3a7b163b91ed63"
|
||||
checksum = "f1a05365e3b1c6d1650318537c7460c6923f1abdd272ad6842baa2b509957a06"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
@@ -2773,9 +2759,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "heapless"
|
||||
version = "0.8.0"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0bfb9eb618601c89945a70e254898da93b13be0388091d42117462b265bb3fad"
|
||||
checksum = "2af2455f757db2b292a9b1768c4b70186d443bcb3b316252d6b540aec1cd89ed"
|
||||
dependencies = [
|
||||
"hash32",
|
||||
"stable_deref_trait",
|
||||
@@ -2828,12 +2814,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "html5ever"
|
||||
version = "0.36.1"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6452c4751a24e1b99c3260d505eaeee76a050573e61f30ac2c924ddc7236f01e"
|
||||
checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2"
|
||||
dependencies = [
|
||||
"log",
|
||||
"markup5ever 0.36.1",
|
||||
"markup5ever 0.38.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3251,9 +3237,9 @@ checksum = "cf370abdafd54d13e54a620e8c3e1145f28e46cc9d704bc6d94414559df41763"
|
||||
|
||||
[[package]]
|
||||
name = "iri-string"
|
||||
version = "0.7.10"
|
||||
version = "0.7.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a"
|
||||
checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
"serde",
|
||||
@@ -3295,9 +3281,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.17"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "javascriptcore-rs"
|
||||
@@ -3355,7 +3341,7 @@ dependencies = [
|
||||
"cesu8",
|
||||
"cfg-if",
|
||||
"combine",
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"thiserror 1.0.69",
|
||||
"walkdir",
|
||||
@@ -3364,9 +3350,31 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130"
|
||||
checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258"
|
||||
dependencies = [
|
||||
"jni-sys 0.4.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2"
|
||||
dependencies = [
|
||||
"jni-sys-macros",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jni-sys-macros"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jobserver"
|
||||
@@ -3486,6 +3494,12 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libbz2-rs-sys"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.183"
|
||||
@@ -3659,17 +3673,17 @@ dependencies = [
|
||||
"phf_codegen 0.11.3",
|
||||
"string_cache 0.8.9",
|
||||
"string_cache_codegen 0.5.4",
|
||||
"tendril",
|
||||
"tendril 0.4.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "markup5ever"
|
||||
version = "0.36.1"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c3294c4d74d0742910f8c7b466f44dda9eb2d5742c1e430138df290a1e8451c"
|
||||
checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862"
|
||||
dependencies = [
|
||||
"log",
|
||||
"tendril",
|
||||
"tendril 0.5.0",
|
||||
"web_atoms",
|
||||
]
|
||||
|
||||
@@ -3860,7 +3874,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
"log",
|
||||
"ndk-sys",
|
||||
"num_enum",
|
||||
@@ -3880,7 +3894,7 @@ version = "0.6.0+11769913"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873"
|
||||
dependencies = [
|
||||
"jni-sys",
|
||||
"jni-sys 0.3.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4011,9 +4025,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num_enum"
|
||||
version = "0.7.5"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b1207a7e20ad57b847bbddc6776b968420d38292bbfe2089accff5e19e82454c"
|
||||
checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26"
|
||||
dependencies = [
|
||||
"num_enum_derive",
|
||||
"rustversion",
|
||||
@@ -4021,9 +4035,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "num_enum_derive"
|
||||
version = "0.7.5"
|
||||
version = "0.7.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7"
|
||||
checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.5.0",
|
||||
"proc-macro2",
|
||||
@@ -4346,7 +4360,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.45.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4874,7 +4888,7 @@ version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.4+spec-1.1.0",
|
||||
"toml_edit 0.25.8+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5608,7 +5622,7 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5635,9 +5649,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "df33b2b81ac578cabaf06b89b0631153a3f416b0a886e8a7a1707fb51abbd1ef"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -5813,9 +5827,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "selectors"
|
||||
version = "0.35.0"
|
||||
version = "0.36.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "93fdfed56cd634f04fe8b9ddf947ae3dc493483e819593d2ba17df9ad05db8b2"
|
||||
checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cssparser 0.36.0",
|
||||
@@ -5939,9 +5953,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.4"
|
||||
version = "1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8bbf91e5a4d6315eee45e704372590b30e260ee83af6639d64557f51b067776"
|
||||
checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -6204,9 +6218,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "smoltcp"
|
||||
version = "0.12.0"
|
||||
version = "0.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dad095989c1533c1c266d9b1e8d70a1329dd3723c3edac6d03bbd67e7bf6f4bb"
|
||||
checksum = "ac729b0a77bd092a3f06ddaddc59fe0d67f48ba0de45a9abe707c2842c7f8767"
|
||||
dependencies = [
|
||||
"bitflags 1.3.2",
|
||||
"byteorder",
|
||||
@@ -6223,7 +6237,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6493,9 +6507,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tao"
|
||||
version = "0.34.6"
|
||||
version = "0.34.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb"
|
||||
checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
@@ -6548,9 +6562,9 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
checksum = "22692a6476a21fa75fdfc11d452fda482af402c008cdbaf3476414e122040973"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -6957,10 +6971,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.2",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6974,6 +6988,16 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tendril"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24"
|
||||
dependencies = [
|
||||
"new_debug_unreachable",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thiserror"
|
||||
version = "1.0.69"
|
||||
@@ -7186,13 +7210,25 @@ name = "tokio-tungstenite"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"tokio",
|
||||
"tungstenite 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokio-tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"log",
|
||||
"native-tls",
|
||||
"tokio",
|
||||
"tokio-native-tls",
|
||||
"tungstenite",
|
||||
"tungstenite 0.29.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -7228,7 +7264,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"serde_core",
|
||||
"serde_spanned 1.0.4",
|
||||
"serde_spanned 1.1.0",
|
||||
"toml_datetime 0.7.5+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
@@ -7255,9 +7291,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.0.0+spec-1.1.0"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "32c2555c699578a4f59f0cc68e5116c8d7cabbd45e1409b989d4be085b53f13e"
|
||||
checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -7288,30 +7324,30 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.4+spec-1.1.0"
|
||||
version = "0.25.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7193cbd0ce53dc966037f54351dbbcf0d5a642c7f0038c382ef9e677ce8c13f2"
|
||||
checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c"
|
||||
dependencies = [
|
||||
"indexmap 2.13.0",
|
||||
"toml_datetime 1.0.0+spec-1.1.0",
|
||||
"toml_datetime 1.1.0+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"winnow 0.7.15",
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.9+spec-1.1.0"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "702d4415e08923e7e1ef96cd5727c0dfed80b4d2fa25db9647fe5eb6f7c5a4c4"
|
||||
checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011"
|
||||
dependencies = [
|
||||
"winnow 0.7.15",
|
||||
"winnow 1.0.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.6+spec-1.1.0"
|
||||
version = "1.1.0+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab16f14aed21ee8bfd8ec22513f7287cd4a91aa92e44edfe2c17ddd004e92607"
|
||||
checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
@@ -7449,13 +7485,29 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tungstenite"
|
||||
version = "0.29.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"data-encoding",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"native-tls",
|
||||
"rand 0.9.2",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
@@ -7482,7 +7534,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e"
|
||||
dependencies = [
|
||||
"memoffset",
|
||||
"tempfile",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8080,7 +8132,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8583,6 +8635,15 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.55.0"
|
||||
@@ -8600,7 +8661,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.59.0",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -8722,9 +8783,9 @@ checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9"
|
||||
|
||||
[[package]]
|
||||
name = "wry"
|
||||
version = "0.54.3"
|
||||
version = "0.54.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a24eda84b5d488f99344e54b807138896cee8df0b2d16c793f1f6b80e6d8df1f"
|
||||
checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"block2",
|
||||
@@ -8923,18 +8984,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.42"
|
||||
version = "0.8.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3"
|
||||
checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.42"
|
||||
version = "0.8.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f"
|
||||
checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -9023,7 +9084,7 @@ checksum = "fabe6324e908f85a1c52063ce7aa26b68dcb7eb6dbc83a2d148403c9bc3eba50"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
"bzip2",
|
||||
"bzip2 0.5.2",
|
||||
"constant_time_eq 0.3.1",
|
||||
"crc32fast",
|
||||
"crossbeam-utils",
|
||||
@@ -9047,9 +9108,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "8.2.0"
|
||||
version = "8.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b680f2a0cd479b4cff6e1233c483fdead418106eae419dc60200ae9850f6d004"
|
||||
checksum = "7756d0206d058333667493c4014f545f4b9603c4330ccd6d9b3f86dcab59f7d9"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"flate2",
|
||||
@@ -9121,9 +9182,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zune-jpeg"
|
||||
version = "0.5.13"
|
||||
version = "0.5.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c"
|
||||
checksum = "0b7a1c0af6e5d8d1363f4994b7a091ccf963d8b694f7da5b0b9cceb82da2c0a6"
|
||||
dependencies = [
|
||||
"zune-core",
|
||||
]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.17.2"
|
||||
version = "0.17.6"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -95,7 +95,7 @@ async-socks5 = "0.6"
|
||||
playwright = { git = "https://github.com/sctg-development/playwright-rust", branch = "master" }
|
||||
|
||||
# Wayfern CDP integration
|
||||
tokio-tungstenite = { version = "0.28", features = ["native-tls"] }
|
||||
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
serde_yaml = "0.9"
|
||||
thiserror = "2.0"
|
||||
@@ -106,7 +106,7 @@ quick-xml = { version = "0.39", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
boringtun = "0.7"
|
||||
smoltcp = { version = "0.12", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
|
||||
smoltcp = { version = "0.13", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
|
||||
|
||||
# Daemon dependencies (tray icon)
|
||||
tray-icon = "0.21"
|
||||
|
||||
@@ -704,7 +704,8 @@ impl AppAutoUpdater {
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
log::info!("Silent download size: {} bytes", total_size);
|
||||
let mut file = fs::File::create(&file_path)?;
|
||||
let raw_file = fs::File::create(&file_path)?;
|
||||
let mut file = std::io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file);
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
@@ -712,6 +713,7 @@ impl AppAutoUpdater {
|
||||
let chunk = chunk?;
|
||||
file.write_all(&chunk)?;
|
||||
}
|
||||
std::io::Write::flush(&mut file)?;
|
||||
|
||||
log::info!("Silent download completed: {}", file_path.display());
|
||||
Ok(file_path)
|
||||
|
||||
@@ -145,6 +145,14 @@ impl AutoUpdater {
|
||||
let registry =
|
||||
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
|
||||
// Skip if this browser-version pair is already being downloaded
|
||||
if crate::downloader::is_downloading(&browser, &new_version) {
|
||||
log::info!(
|
||||
"Browser {browser} {new_version} is already being downloaded, skipping duplicate"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if registry.is_browser_downloaded(&browser, &new_version) {
|
||||
log::info!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
|
||||
|
||||
|
||||
@@ -689,7 +689,6 @@ impl Browser for WayfernBrowser {
|
||||
"--disable-session-crashed-bubble".to_string(),
|
||||
"--hide-crash-restore-bubble".to_string(),
|
||||
"--disable-infobars".to_string(),
|
||||
"--disable-quic".to_string(),
|
||||
// Wayfern-specific args for automation
|
||||
"--disable-features=DialMediaRouteProvider".to_string(),
|
||||
"--use-mock-keychain".to_string(),
|
||||
@@ -1166,6 +1165,67 @@ mod tests {
|
||||
assert_eq!(deserialized.host, proxy.host, "Host should match");
|
||||
assert_eq!(deserialized.port, proxy.port, "Port should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wayfern_config_has_no_executable_path() {
|
||||
// Verify WayfernConfig does not store executable_path
|
||||
let config = crate::wayfern_manager::WayfernConfig::default();
|
||||
let json = serde_json::to_value(&config).unwrap();
|
||||
assert!(
|
||||
json.get("executable_path").is_none(),
|
||||
"WayfernConfig should not have executable_path field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_camoufox_config_has_no_executable_path() {
|
||||
// Verify CamoufoxConfig does not store executable_path
|
||||
let config = crate::camoufox_manager::CamoufoxConfig::default();
|
||||
let json = serde_json::to_value(&config).unwrap();
|
||||
assert!(
|
||||
json.get("executable_path").is_none(),
|
||||
"CamoufoxConfig should not have executable_path field"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_data_path_is_dynamic() {
|
||||
use crate::profile::BrowserProfile;
|
||||
let profiles_dir = std::path::PathBuf::from("/fake/profiles");
|
||||
let profile = BrowserProfile {
|
||||
id: uuid::Uuid::parse_str("12345678-1234-1234-1234-123456789abc").unwrap(),
|
||||
name: "test".to_string(),
|
||||
browser: "wayfern".to_string(),
|
||||
version: "1.0.0".to_string(),
|
||||
proxy_id: None,
|
||||
vpn_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
wayfern_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_mode: crate::profile::types::SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
};
|
||||
|
||||
let path = profile.get_profile_data_path(&profiles_dir);
|
||||
assert_eq!(
|
||||
path,
|
||||
profiles_dir
|
||||
.join("12345678-1234-1234-1234-123456789abc")
|
||||
.join("profile")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
|
||||
@@ -2157,14 +2157,11 @@ impl BrowserRunner {
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
|
||||
|
||||
if profile.is_cross_os()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.is_fingerprint_os_allowed(profile.host_os.as_deref())
|
||||
.await
|
||||
{
|
||||
if profile.is_cross_os() {
|
||||
return Err(format!(
|
||||
"Cannot open URL with profile '{}': cross-OS fingerprints require a paid subscription",
|
||||
"Cannot open URL with profile '{}': this profile was created on {} and cannot be used on a different operating system",
|
||||
profile.name,
|
||||
profile.host_os.as_deref().unwrap_or("another OS"),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2196,14 +2193,11 @@ pub async fn launch_browser_profile(
|
||||
profile.id
|
||||
);
|
||||
|
||||
if profile.is_cross_os()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.is_fingerprint_os_allowed(profile.host_os.as_deref())
|
||||
.await
|
||||
{
|
||||
if profile.is_cross_os() {
|
||||
return Err(format!(
|
||||
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
|
||||
"Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system",
|
||||
profile.name,
|
||||
profile.host_os.as_deref().unwrap_or("another OS"),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2516,14 +2510,11 @@ pub async fn launch_browser_profile_with_debugging(
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
if profile.is_cross_os()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.is_fingerprint_os_allowed(profile.host_os.as_deref())
|
||||
.await
|
||||
{
|
||||
if profile.is_cross_os() {
|
||||
return Err(format!(
|
||||
"Cannot launch profile '{}': cross-OS fingerprints require a paid subscription",
|
||||
"Cannot launch profile '{}': this profile was created on {} and cannot be launched on a different operating system",
|
||||
profile.name,
|
||||
profile.host_os.as_deref().unwrap_or("another OS"),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -21,7 +21,6 @@ pub struct CamoufoxConfig {
|
||||
pub block_images: Option<bool>,
|
||||
pub block_webrtc: Option<bool>,
|
||||
pub block_webgl: Option<bool>,
|
||||
pub executable_path: Option<String>,
|
||||
pub fingerprint: Option<String>, // JSON string of the complete fingerprint config
|
||||
pub randomize_fingerprint_on_launch: Option<bool>, // Generate new fingerprint on every launch
|
||||
pub os: Option<String>, // Operating system for fingerprint generation: "windows", "macos", or "linux"
|
||||
@@ -39,7 +38,6 @@ impl Default for CamoufoxConfig {
|
||||
block_images: None,
|
||||
block_webrtc: None,
|
||||
block_webgl: None,
|
||||
executable_path: None,
|
||||
fingerprint: None,
|
||||
randomize_fingerprint_on_launch: None,
|
||||
os: None,
|
||||
@@ -129,21 +127,9 @@ impl CamoufoxManager {
|
||||
config: &CamoufoxConfig,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get executable path
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
let p = PathBuf::from(path);
|
||||
if p.exists() {
|
||||
p
|
||||
} else {
|
||||
log::warn!("Stored Camoufox executable path does not exist: {path}, falling back to dynamic resolution");
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
|
||||
}
|
||||
} else {
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
|
||||
};
|
||||
let executable_path = BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
|
||||
|
||||
// Build the config using CamoufoxConfigBuilder
|
||||
let mut builder = CamoufoxConfigBuilder::new()
|
||||
@@ -230,21 +216,9 @@ impl CamoufoxManager {
|
||||
};
|
||||
|
||||
// Get executable path
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
let p = PathBuf::from(path);
|
||||
if p.exists() {
|
||||
p
|
||||
} else {
|
||||
log::warn!("Stored Camoufox executable path does not exist: {path}, falling back to dynamic resolution");
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
|
||||
}
|
||||
} else {
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
|
||||
};
|
||||
let executable_path = BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
|
||||
|
||||
// Parse the fingerprint config JSON
|
||||
let fingerprint_config: HashMap<String, serde_json::Value> =
|
||||
|
||||
@@ -591,8 +591,8 @@ impl CloudAuthManager {
|
||||
// Clear wayfern token
|
||||
self.clear_wayfern_token().await;
|
||||
|
||||
// Disconnect team lock manager
|
||||
crate::team_lock::TEAM_LOCK.disconnect().await;
|
||||
// Disconnect profile lock manager
|
||||
crate::team_lock::PROFILE_LOCK.disconnect().await;
|
||||
|
||||
// Try to call the logout API (best-effort)
|
||||
if let Ok(Some(access_token)) = Self::load_access_token() {
|
||||
@@ -1070,10 +1070,10 @@ impl CloudAuthManager {
|
||||
log::debug!("Failed to refresh cloud profile: {e}");
|
||||
}
|
||||
|
||||
// Reconnect team lock manager if needed
|
||||
// Reconnect profile lock manager if needed
|
||||
if let Some(auth_state) = CLOUD_AUTH.get_user().await {
|
||||
if let Some(tid) = &auth_state.user.team_id {
|
||||
crate::team_lock::TEAM_LOCK.connect(tid).await;
|
||||
if auth_state.user.plan != "free" && !crate::team_lock::PROFILE_LOCK.is_connected().await {
|
||||
crate::team_lock::PROFILE_LOCK.connect().await;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1137,11 +1137,9 @@ pub async fn cloud_verify_otp(
|
||||
// Sync cloud proxy after login
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
|
||||
// Connect team lock manager if on a team plan
|
||||
if state.user.team_id.is_some() {
|
||||
if let Some(tid) = &state.user.team_id {
|
||||
crate::team_lock::TEAM_LOCK.connect(tid).await;
|
||||
}
|
||||
// Connect profile lock manager for paid users
|
||||
if state.user.plan != "free" {
|
||||
crate::team_lock::PROFILE_LOCK.connect().await;
|
||||
}
|
||||
|
||||
let _ = crate::events::emit_empty("cloud-auth-changed");
|
||||
|
||||
@@ -10,7 +10,7 @@ use tauri::AppHandle;
|
||||
/// Chromium cookie encryption/decryption support.
|
||||
/// On macOS: uses "Chromium Safe Storage" key from Keychain with PBKDF2 + AES-128-CBC.
|
||||
/// On Linux: uses os_crypt_key file from profile directory with PBKDF2 + AES-128-CBC.
|
||||
mod chrome_decrypt {
|
||||
pub mod chrome_decrypt {
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
|
||||
use std::path::Path;
|
||||
|
||||
|
||||
@@ -513,6 +513,11 @@ impl DownloadedBrowsersRegistry {
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Never remove a directory if a download is in progress for this browser/version
|
||||
if crate::downloader::is_downloading(browser, version) {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||
|
||||
let version_dir = binaries_dir.join(browser).join(version);
|
||||
@@ -593,6 +598,12 @@ impl DownloadedBrowsersRegistry {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if a download is in progress for this browser/version
|
||||
if crate::downloader::is_downloading(browser_name, version_name) {
|
||||
has_non_empty_versions = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if version directory is empty
|
||||
match fs::read_dir(&version_path) {
|
||||
Ok(mut entries) => {
|
||||
@@ -1237,12 +1248,13 @@ pub async fn ensure_active_browsers_downloaded(
|
||||
// Check if any version is already downloaded
|
||||
let existing = registry.get_downloaded_versions(browser);
|
||||
if !existing.is_empty() {
|
||||
log::debug!(
|
||||
"Skipping {browser}: already have {} version(s) downloaded",
|
||||
log::info!(
|
||||
"ensure_active: Skipping {browser}: already have {} version(s) downloaded",
|
||||
existing.len()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
log::info!("ensure_active: No {browser} versions found, will download");
|
||||
|
||||
// Get the latest release type for this browser
|
||||
let release_types = match version_manager.get_browser_release_types(browser).await {
|
||||
|
||||
+82
-56
@@ -42,7 +42,10 @@ pub struct Downloader {
|
||||
impl Downloader {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
client: Client::builder()
|
||||
.connect_timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new()),
|
||||
api_client: ApiClient::instance(),
|
||||
registry: crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(),
|
||||
version_service: crate::browser_version_manager::BrowserVersionManager::instance(),
|
||||
@@ -304,47 +307,26 @@ impl Downloader {
|
||||
let file_path = dest_path.join(&download_info.filename);
|
||||
|
||||
// Resolve the actual download URL
|
||||
log::info!(
|
||||
"Resolving download URL for {} {}",
|
||||
browser_type.as_str(),
|
||||
version
|
||||
);
|
||||
let download_url = self
|
||||
.resolve_download_url(browser_type.clone(), version, download_info)
|
||||
.await?;
|
||||
log::info!("Download URL resolved: {}", download_url);
|
||||
|
||||
// Check existing file size — if it matches the expected size, skip download
|
||||
// Determine if we have a partial file to resume
|
||||
let mut existing_size: u64 = 0;
|
||||
if let Ok(meta) = std::fs::metadata(&file_path) {
|
||||
existing_size = meta.len();
|
||||
}
|
||||
|
||||
// Do a HEAD request to get the expected file size for skip/resume decisions
|
||||
let head_response = self
|
||||
.client
|
||||
.head(&download_url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
)
|
||||
.send()
|
||||
.await
|
||||
.ok();
|
||||
|
||||
let expected_size = head_response.as_ref().and_then(|r| r.content_length());
|
||||
|
||||
// If existing file matches expected size, skip download entirely
|
||||
if existing_size > 0 {
|
||||
if let Some(expected) = expected_size {
|
||||
if existing_size == expected {
|
||||
log::info!(
|
||||
"Archive {} already exists with correct size ({} bytes), skipping download",
|
||||
file_path.display(),
|
||||
existing_size
|
||||
);
|
||||
return Ok(file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build request, add Range only if we have bytes. If the server responds with 416 (Range Not
|
||||
// Satisfiable), delete the partial file and retry once without the Range header.
|
||||
let response = {
|
||||
// Build request with retry logic for transient network errors.
|
||||
let max_retries = 3u32;
|
||||
let mut response: Option<reqwest::Response> = None;
|
||||
for attempt in 0..=max_retries {
|
||||
let mut request = self
|
||||
.client
|
||||
.get(&download_url)
|
||||
@@ -357,27 +339,43 @@ impl Downloader {
|
||||
request = request.header("Range", format!("bytes={existing_size}-"));
|
||||
}
|
||||
|
||||
let first = request.send().await?;
|
||||
|
||||
if first.status().as_u16() == 416 && existing_size > 0 {
|
||||
// Partial file on disk is not acceptable to the server — remove it and retry from scratch
|
||||
let _ = std::fs::remove_file(&file_path);
|
||||
existing_size = 0;
|
||||
|
||||
let retry = self
|
||||
.client
|
||||
.get(&download_url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
retry
|
||||
} else {
|
||||
first
|
||||
log::info!("Sending download request (attempt {})...", attempt + 1);
|
||||
match request.send().await {
|
||||
Ok(resp) => {
|
||||
log::info!(
|
||||
"Download response received: status={}, content-length={:?}",
|
||||
resp.status(),
|
||||
resp.content_length()
|
||||
);
|
||||
if resp.status().as_u16() == 416 && existing_size > 0 {
|
||||
let _ = std::fs::remove_file(&file_path);
|
||||
existing_size = 0;
|
||||
log::warn!("Download returned 416, retrying without Range header");
|
||||
continue;
|
||||
}
|
||||
response = Some(resp);
|
||||
break;
|
||||
}
|
||||
Err(e) => {
|
||||
let is_retryable = e.is_connect() || e.is_timeout() || e.is_request();
|
||||
if is_retryable && attempt < max_retries {
|
||||
let delay = 2u64.pow(attempt);
|
||||
log::warn!(
|
||||
"Download attempt {} failed ({}), retrying in {}s...",
|
||||
attempt + 1,
|
||||
e,
|
||||
delay
|
||||
);
|
||||
tokio::time::sleep(std::time::Duration::from_secs(delay)).await;
|
||||
} else {
|
||||
return Err(format!("Download failed after {} attempts: {}", attempt + 1, e).into());
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
let response = response.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
"Download failed: no response received".into()
|
||||
})?;
|
||||
|
||||
// Check if the response is successful (200 OK or 206 Partial Content)
|
||||
if !(response.status().is_success() || response.status().as_u16() == 206) {
|
||||
@@ -415,6 +413,20 @@ impl Downloader {
|
||||
existing_size = 0;
|
||||
}
|
||||
|
||||
// If the existing file already matches the total size, skip the download
|
||||
if existing_size > 0 {
|
||||
if let Some(total) = total_size {
|
||||
if existing_size >= total {
|
||||
log::info!(
|
||||
"Archive {} already complete ({} bytes), skipping download",
|
||||
file_path.display(),
|
||||
existing_size
|
||||
);
|
||||
return Ok(file_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut downloaded = existing_size;
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut last_update = start_time;
|
||||
@@ -445,12 +457,16 @@ impl Downloader {
|
||||
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
// Open file in append mode (resuming) or create new
|
||||
// Open file in append mode (resuming) or create new.
|
||||
// Wrap in BufWriter with a large buffer to reduce the number of disk writes,
|
||||
// which dramatically improves download speed on Windows (NTFS + Defender overhead).
|
||||
use std::fs::OpenOptions;
|
||||
let mut file = OpenOptions::new()
|
||||
use std::io::Write;
|
||||
let raw_file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&file_path)?;
|
||||
let mut file = io::BufWriter::with_capacity(8 * 1024 * 1024, raw_file);
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
@@ -463,7 +479,7 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
let chunk = chunk?;
|
||||
io::copy(&mut chunk.as_ref(), &mut file)?;
|
||||
file.write_all(&chunk)?;
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
let now = std::time::Instant::now();
|
||||
@@ -510,6 +526,9 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining buffered data to disk
|
||||
file.flush()?;
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
@@ -953,6 +972,13 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a specific browser-version pair is currently being downloaded
|
||||
pub fn is_downloading(browser: &str, version: &str) -> bool {
|
||||
let download_key = format!("{browser}-{version}");
|
||||
let downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.contains(&download_key)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_browser(
|
||||
app_handle: tauri::AppHandle,
|
||||
|
||||
@@ -283,6 +283,9 @@ mod tests {
|
||||
#[test]
|
||||
#[serial_test::serial]
|
||||
fn test_ephemeral_dir_lifecycle() {
|
||||
// Clear global state to avoid interference from other tests
|
||||
EPHEMERAL_DIRS.lock().unwrap().clear();
|
||||
|
||||
let profile_id = uuid::Uuid::new_v4();
|
||||
let id_str = profile_id.to_string();
|
||||
|
||||
|
||||
@@ -1091,12 +1091,6 @@ lazy_static::lazy_static! {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_extensions() -> Result<Vec<Extension>, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Extension management requires an active Pro subscription".to_string());
|
||||
}
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.list_extensions()
|
||||
@@ -1115,12 +1109,6 @@ pub async fn add_extension(
|
||||
file_name: String,
|
||||
file_data: Vec<u8>,
|
||||
) -> Result<Extension, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Extension management requires an active Pro subscription".to_string());
|
||||
}
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.add_extension(name, file_name, file_data)
|
||||
@@ -1134,12 +1122,6 @@ pub async fn update_extension(
|
||||
file_name: Option<String>,
|
||||
file_data: Option<Vec<u8>>,
|
||||
) -> Result<Extension, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Extension management requires an active Pro subscription".to_string());
|
||||
}
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.update_extension(&extension_id, name, file_name, file_data)
|
||||
@@ -1151,12 +1133,6 @@ pub async fn delete_extension(
|
||||
app_handle: tauri::AppHandle,
|
||||
extension_id: String,
|
||||
) -> Result<(), String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Extension management requires an active Pro subscription".to_string());
|
||||
}
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.delete_extension(&app_handle, &extension_id)
|
||||
@@ -1165,12 +1141,6 @@ pub async fn delete_extension(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_extension_groups() -> Result<Vec<ExtensionGroup>, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Extension management requires an active Pro subscription".to_string());
|
||||
}
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.list_groups()
|
||||
@@ -1179,12 +1149,6 @@ pub async fn list_extension_groups() -> Result<Vec<ExtensionGroup>, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_extension_group(name: String) -> Result<ExtensionGroup, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Extension management requires an active Pro subscription".to_string());
|
||||
}
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.create_group(name)
|
||||
@@ -1197,12 +1161,6 @@ pub async fn update_extension_group(
|
||||
name: Option<String>,
|
||||
extension_ids: Option<Vec<String>>,
|
||||
) -> Result<ExtensionGroup, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Extension management requires an active Pro subscription".to_string());
|
||||
}
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.update_group(&group_id, name, extension_ids)
|
||||
@@ -1214,12 +1172,6 @@ pub async fn delete_extension_group(
|
||||
app_handle: tauri::AppHandle,
|
||||
group_id: String,
|
||||
) -> Result<(), String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Extension management requires an active Pro subscription".to_string());
|
||||
}
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.delete_group(&app_handle, &group_id)
|
||||
@@ -1231,12 +1183,6 @@ pub async fn add_extension_to_group(
|
||||
group_id: String,
|
||||
extension_id: String,
|
||||
) -> Result<ExtensionGroup, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Extension management requires an active Pro subscription".to_string());
|
||||
}
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.add_extension_to_group(&group_id, &extension_id)
|
||||
@@ -1248,12 +1194,6 @@ pub async fn remove_extension_from_group(
|
||||
group_id: String,
|
||||
extension_id: String,
|
||||
) -> Result<ExtensionGroup, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Extension management requires an active Pro subscription".to_string());
|
||||
}
|
||||
let mgr = EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.remove_extension_from_group(&group_id, &extension_id)
|
||||
@@ -1265,13 +1205,6 @@ pub async fn assign_extension_group_to_profile(
|
||||
profile_id: String,
|
||||
extension_group_id: Option<String>,
|
||||
) -> Result<crate::profile::BrowserProfile, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Extension management requires an active Pro subscription".to_string());
|
||||
}
|
||||
|
||||
// Validate compatibility if assigning a group
|
||||
if let Some(ref group_id) = extension_group_id {
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
|
||||
@@ -207,6 +207,20 @@ impl Extractor {
|
||||
|
||||
match extraction_result {
|
||||
Ok(path) => {
|
||||
// Remove quarantine attributes on macOS to prevent
|
||||
// "app was prevented from modifying data" prompts
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = tokio::process::Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
dest_dir.to_str().unwrap_or("."),
|
||||
])
|
||||
.output()
|
||||
.await;
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Successfully extracted {} {} to: {}",
|
||||
browser_type.as_str(),
|
||||
@@ -232,13 +246,21 @@ impl Extractor {
|
||||
&self,
|
||||
file_path: &Path,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Always check magic bytes first — the file extension may be wrong
|
||||
// (e.g. CDN serving a ZIP with .dmg extension)
|
||||
// Check file extension first for container formats (DMG, MSI) whose internal
|
||||
// compression makes magic bytes unreliable
|
||||
if let Some(ext) = file_path.extension().and_then(|ext| ext.to_str()) {
|
||||
match ext.to_lowercase().as_str() {
|
||||
"dmg" => return Ok("dmg".to_string()),
|
||||
"msi" => return Ok("msi".to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
let mut file = File::open(file_path)?;
|
||||
let mut buffer = [0u8; 12]; // Read first 12 bytes for magic number detection
|
||||
let mut buffer = [0u8; 12];
|
||||
file.read_exact(&mut buffer)?;
|
||||
|
||||
// Check magic numbers for different file types
|
||||
// Check magic numbers for other file types
|
||||
match &buffer[0..4] {
|
||||
[0x50, 0x4B, 0x03, 0x04] | [0x50, 0x4B, 0x05, 0x06] | [0x50, 0x4B, 0x07, 0x08] => {
|
||||
return Ok("zip".to_string())
|
||||
|
||||
+1
-19
@@ -297,7 +297,7 @@ async fn fetch_dynamic_proxy(
|
||||
.fetch_dynamic_proxy(&url, &format)
|
||||
.await?;
|
||||
|
||||
// Validate the proxy actually works by routing through a temporary local proxy
|
||||
// Validate the proxy actually works by connecting through it
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.check_proxy_validity("_dynamic_test", &settings)
|
||||
.await
|
||||
@@ -356,12 +356,6 @@ async fn copy_profile_cookies(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: cookie_manager::CookieCopyRequest,
|
||||
) -> Result<Vec<cookie_manager::CookieCopyResult>, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Cookie copying requires an active Pro subscription".to_string());
|
||||
}
|
||||
let target_ids = request.target_profile_ids.clone();
|
||||
let results = cookie_manager::CookieManager::copy_cookies(&app_handle, request).await?;
|
||||
|
||||
@@ -397,12 +391,6 @@ async fn import_cookies_from_file(
|
||||
profile_id: String,
|
||||
content: String,
|
||||
) -> Result<cookie_manager::CookieImportResult, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Cookie import requires an active Pro subscription".to_string());
|
||||
}
|
||||
let result =
|
||||
cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await?;
|
||||
|
||||
@@ -426,12 +414,6 @@ async fn import_cookies_from_file(
|
||||
|
||||
#[tauri::command]
|
||||
async fn export_profile_cookies(profile_id: String, format: String) -> Result<String, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Cookie export requires an active Pro subscription".to_string());
|
||||
}
|
||||
cookie_manager::CookieManager::export_cookies(&profile_id, &format)
|
||||
}
|
||||
|
||||
|
||||
+184
-11
@@ -4,16 +4,18 @@ use axum::{
|
||||
http::{header, Request, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::post,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::atomic::{AtomicBool, AtomicU16, Ordering};
|
||||
use std::sync::Arc;
|
||||
use tauri::AppHandle;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::browser::ProxySettings;
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
@@ -34,15 +36,20 @@ pub struct McpTool {
|
||||
#[allow(dead_code)]
|
||||
pub struct McpRequest {
|
||||
jsonrpc: String,
|
||||
id: serde_json::Value,
|
||||
id: Option<serde_json::Value>,
|
||||
method: String,
|
||||
params: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
const PROTOCOL_VERSION: &str = "2025-03-26";
|
||||
const SERVER_NAME: &str = "donut-browser";
|
||||
const SERVER_VERSION: &str = env!("CARGO_PKG_VERSION");
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct McpResponse {
|
||||
jsonrpc: String,
|
||||
id: serde_json::Value,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
id: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<serde_json::Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
@@ -57,10 +64,15 @@ pub struct McpError {
|
||||
|
||||
const DEFAULT_MCP_PORT: u16 = 51080;
|
||||
|
||||
struct McpSession {
|
||||
initialized: bool,
|
||||
}
|
||||
|
||||
struct McpServerInner {
|
||||
app_handle: Option<AppHandle>,
|
||||
token: Option<String>,
|
||||
shutdown_tx: Option<tokio::sync::oneshot::Sender<()>>,
|
||||
sessions: HashMap<String, McpSession>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -82,6 +94,7 @@ impl McpServer {
|
||||
app_handle: None,
|
||||
token: None,
|
||||
shutdown_tx: None,
|
||||
sessions: HashMap::new(),
|
||||
})),
|
||||
is_running: AtomicBool::new(false),
|
||||
port: AtomicU16::new(0),
|
||||
@@ -207,7 +220,13 @@ impl McpServer {
|
||||
shutdown_rx: tokio::sync::oneshot::Receiver<()>,
|
||||
) {
|
||||
let app = Router::new()
|
||||
.route("/mcp", post(Self::handle_mcp_post))
|
||||
.route(
|
||||
"/mcp",
|
||||
post(Self::handle_mcp_post)
|
||||
.get(Self::handle_mcp_get)
|
||||
.delete(Self::handle_mcp_delete),
|
||||
)
|
||||
.route("/health", get(Self::handle_health))
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
Self::auth_middleware,
|
||||
@@ -243,6 +262,11 @@ impl McpServer {
|
||||
req: Request<Body>,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Health endpoint is public
|
||||
if req.uri().path() == "/health" {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
|
||||
let auth_header = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
@@ -257,12 +281,114 @@ impl McpServer {
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
async fn handle_mcp_post(
|
||||
async fn handle_health() -> impl IntoResponse {
|
||||
Json(serde_json::json!({
|
||||
"status": "ok",
|
||||
"server": SERVER_NAME,
|
||||
"version": SERVER_VERSION,
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_mcp_get() -> impl IntoResponse {
|
||||
// We don't support server-initiated SSE streams
|
||||
StatusCode::METHOD_NOT_ALLOWED
|
||||
}
|
||||
|
||||
async fn handle_mcp_delete(
|
||||
State(state): State<McpHttpState>,
|
||||
Json(request): Json<McpRequest>,
|
||||
req: Request<Body>,
|
||||
) -> impl IntoResponse {
|
||||
let response = state.server.handle_request(request).await;
|
||||
Json(response)
|
||||
let session_id = req
|
||||
.headers()
|
||||
.get("mcp-session-id")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if let Some(sid) = session_id {
|
||||
let mut inner = state.server.inner.lock().await;
|
||||
inner.sessions.remove(&sid);
|
||||
log::info!("[mcp] Session terminated: {}", sid);
|
||||
}
|
||||
|
||||
StatusCode::OK
|
||||
}
|
||||
|
||||
async fn handle_mcp_post(State(state): State<McpHttpState>, req: Request<Body>) -> Response {
|
||||
let session_id = req
|
||||
.headers()
|
||||
.get("mcp-session-id")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let body_bytes = match axum::body::to_bytes(req.into_body(), 1024 * 1024).await {
|
||||
Ok(b) => b,
|
||||
Err(_) => {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid request body").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let request: McpRequest = match serde_json::from_slice(&body_bytes) {
|
||||
Ok(r) => r,
|
||||
Err(_) => {
|
||||
return (StatusCode::BAD_REQUEST, "Invalid JSON").into_response();
|
||||
}
|
||||
};
|
||||
|
||||
let is_notification = request.id.is_none();
|
||||
let method = request.method.clone();
|
||||
|
||||
// Handle initialize (no session required)
|
||||
if method == "initialize" {
|
||||
let response = state.server.handle_initialize(request).await;
|
||||
match response {
|
||||
Ok((session_id, result)) => {
|
||||
let body = McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: Some(result.0),
|
||||
result: Some(result.1),
|
||||
error: None,
|
||||
};
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_TYPE, "application/json")
|
||||
.header("mcp-session-id", &session_id)
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap()
|
||||
}
|
||||
Err((id, error)) => {
|
||||
let body = McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: Some(id),
|
||||
result: None,
|
||||
error: Some(error),
|
||||
};
|
||||
Json(body).into_response()
|
||||
}
|
||||
}
|
||||
} else if is_notification {
|
||||
// Notifications (like notifications/initialized) -> 202 Accepted
|
||||
if method == "notifications/initialized" {
|
||||
if let Some(sid) = &session_id {
|
||||
let mut inner = state.server.inner.lock().await;
|
||||
if let Some(session) = inner.sessions.get_mut(sid) {
|
||||
session.initialized = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
StatusCode::ACCEPTED.into_response()
|
||||
} else {
|
||||
// Validate session exists
|
||||
if let Some(sid) = &session_id {
|
||||
let inner = state.server.inner.lock().await;
|
||||
if !inner.sessions.contains_key(sid) {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
}
|
||||
}
|
||||
|
||||
let response = state.server.handle_request(request).await;
|
||||
Json(response).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop(&self) -> Result<(), String> {
|
||||
@@ -273,6 +399,7 @@ impl McpServer {
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.app_handle = None;
|
||||
inner.token = None;
|
||||
inner.sessions.clear();
|
||||
|
||||
// Send shutdown signal
|
||||
if let Some(tx) = inner.shutdown_tx.take() {
|
||||
@@ -1184,11 +1311,56 @@ impl McpServer {
|
||||
]
|
||||
}
|
||||
|
||||
async fn handle_initialize(
|
||||
&self,
|
||||
request: McpRequest,
|
||||
) -> Result<(String, (serde_json::Value, serde_json::Value)), (serde_json::Value, McpError)> {
|
||||
let id = request.id.clone().unwrap_or(serde_json::Value::Null);
|
||||
|
||||
if !self.is_running() {
|
||||
return Err((
|
||||
id,
|
||||
McpError {
|
||||
code: -32001,
|
||||
message: "MCP server is not running".to_string(),
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
// Create session
|
||||
let session_id = Uuid::new_v4().to_string();
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner
|
||||
.sessions
|
||||
.insert(session_id.clone(), McpSession { initialized: false });
|
||||
}
|
||||
|
||||
let result = serde_json::json!({
|
||||
"protocolVersion": PROTOCOL_VERSION,
|
||||
"capabilities": {
|
||||
"tools": {
|
||||
"listChanged": false
|
||||
}
|
||||
},
|
||||
"serverInfo": {
|
||||
"name": SERVER_NAME,
|
||||
"version": SERVER_VERSION,
|
||||
},
|
||||
"instructions": "Donut Browser MCP server. Use tools/list to discover available browser automation tools."
|
||||
});
|
||||
|
||||
log::info!("[mcp] New session initialized: {}", session_id);
|
||||
Ok((session_id, (id, result)))
|
||||
}
|
||||
|
||||
pub async fn handle_request(&self, request: McpRequest) -> McpResponse {
|
||||
let id = request.id.clone().unwrap_or(serde_json::Value::Null);
|
||||
|
||||
if !self.is_running() {
|
||||
return McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: request.id,
|
||||
id: Some(id),
|
||||
result: None,
|
||||
error: Some(McpError {
|
||||
code: -32001,
|
||||
@@ -1198,6 +1370,7 @@ impl McpServer {
|
||||
}
|
||||
|
||||
let result = match request.method.as_str() {
|
||||
"ping" => Ok(serde_json::json!({})),
|
||||
"tools/list" => self.handle_tools_list().await,
|
||||
"tools/call" => self.handle_tool_call(request.params).await,
|
||||
_ => Err(McpError {
|
||||
@@ -1209,13 +1382,13 @@ impl McpServer {
|
||||
match result {
|
||||
Ok(value) => McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: request.id,
|
||||
id: Some(id),
|
||||
result: Some(value),
|
||||
error: None,
|
||||
},
|
||||
Err(error) => McpResponse {
|
||||
jsonrpc: "2.0".to_string(),
|
||||
id: request.id,
|
||||
id: Some(id),
|
||||
result: None,
|
||||
error: Some(error),
|
||||
},
|
||||
|
||||
@@ -94,29 +94,6 @@ impl ProfileManager {
|
||||
crate::camoufox_manager::CamoufoxConfig::default()
|
||||
});
|
||||
|
||||
// Always ensure executable_path is set to the user's binary location
|
||||
if config.executable_path.is_none() {
|
||||
let mut browser_dir = self.get_binaries_dir();
|
||||
browser_dir.push(browser);
|
||||
browser_dir.push(version);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let binary_path = browser_dir
|
||||
.join("Camoufox.app")
|
||||
.join("Contents")
|
||||
.join("MacOS")
|
||||
.join("camoufox");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let binary_path = browser_dir.join("camoufox.exe");
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let binary_path = browser_dir.join("camoufox");
|
||||
|
||||
config.executable_path = Some(binary_path.to_string_lossy().to_string());
|
||||
log::info!("Set Camoufox executable path: {:?}", config.executable_path);
|
||||
}
|
||||
|
||||
// Pass upstream proxy information to config for fingerprint generation
|
||||
if let Some(proxy_id_ref) = &proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||
@@ -219,28 +196,6 @@ impl ProfileManager {
|
||||
});
|
||||
|
||||
// Always ensure executable_path is set to the user's binary location
|
||||
if config.executable_path.is_none() {
|
||||
let mut browser_dir = self.get_binaries_dir();
|
||||
browser_dir.push(browser);
|
||||
browser_dir.push(version);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let binary_path = browser_dir
|
||||
.join("Chromium.app")
|
||||
.join("Contents")
|
||||
.join("MacOS")
|
||||
.join("Chromium");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let binary_path = browser_dir.join("chrome.exe");
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let binary_path = browser_dir.join("chrome");
|
||||
|
||||
config.executable_path = Some(binary_path.to_string_lossy().to_string());
|
||||
log::info!("Set Wayfern executable path: {:?}", config.executable_path);
|
||||
}
|
||||
|
||||
// Pass upstream proxy information to config for fingerprint generation
|
||||
if let Some(proxy_id_ref) = &proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||
@@ -425,8 +380,21 @@ impl ProfileManager {
|
||||
if path.is_dir() {
|
||||
let metadata_file = path.join("metadata.json");
|
||||
if metadata_file.exists() {
|
||||
let content = fs::read_to_string(metadata_file)?;
|
||||
let profile: BrowserProfile = serde_json::from_str(&content)?;
|
||||
let content = fs::read_to_string(&metadata_file)?;
|
||||
let mut profile: BrowserProfile = serde_json::from_str(&content)?;
|
||||
|
||||
// Backfill host_os from browser config for profiles created before
|
||||
// the field existed (or synced without it).
|
||||
if profile.host_os.is_none() {
|
||||
let inferred_os = profile.resolved_os().map(str::to_string);
|
||||
if let Some(os) = inferred_os {
|
||||
profile.host_os = Some(os);
|
||||
if let Ok(json) = serde_json::to_string_pretty(&profile) {
|
||||
let _ = fs::write(&metadata_file, json);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
profiles.push(profile);
|
||||
}
|
||||
}
|
||||
@@ -566,6 +534,29 @@ impl ProfileManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Delete a profile from the local filesystem only, without triggering remote sync deletion.
|
||||
/// Used when a profile was deleted on another device and the local copy should be cleaned up.
|
||||
pub fn delete_profile_local_only(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_dir = profiles_dir.join(profile_id);
|
||||
if profile_dir.exists() {
|
||||
fs::remove_dir_all(&profile_dir)?;
|
||||
log::info!("Deleted local profile {} (tombstoned remotely)", profile_id);
|
||||
}
|
||||
|
||||
if let Err(e) = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance()
|
||||
.cleanup_unused_binaries()
|
||||
{
|
||||
log::warn!("Failed to cleanup binaries after tombstone deletion: {e}");
|
||||
}
|
||||
|
||||
let _ = crate::events::emit_empty("profiles-changed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_profile_version(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
|
||||
@@ -87,11 +87,22 @@ impl BrowserProfile {
|
||||
profiles_dir.join(self.id.to_string()).join("profile")
|
||||
}
|
||||
|
||||
/// Resolve the OS this profile was created on. Checks `host_os` first,
|
||||
/// then falls back to the fingerprint config's `os` field (for profiles
|
||||
/// created before `host_os` was introduced or synced without it).
|
||||
pub fn resolved_os(&self) -> Option<&str> {
|
||||
self
|
||||
.host_os
|
||||
.as_deref()
|
||||
.or_else(|| self.camoufox_config.as_ref().and_then(|c| c.os.as_deref()))
|
||||
.or_else(|| self.wayfern_config.as_ref().and_then(|c| c.os.as_deref()))
|
||||
}
|
||||
|
||||
/// Returns true when the profile was created on a different OS than the current host.
|
||||
/// Profiles without an `os` field (backward compat) are treated as native.
|
||||
/// Checks `host_os` first, then falls back to the browser config's `os` field.
|
||||
pub fn is_cross_os(&self) -> bool {
|
||||
match &self.host_os {
|
||||
Some(host_os) => host_os != &get_host_os(),
|
||||
match self.resolved_os() {
|
||||
Some(os) => os != get_host_os(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -532,27 +532,6 @@ impl ProfileImporter {
|
||||
let final_camoufox_config = if mapped == "camoufox" {
|
||||
let mut config = camoufox_config.unwrap_or_default();
|
||||
|
||||
if config.executable_path.is_none() {
|
||||
let mut browser_dir = self.profile_manager.get_binaries_dir();
|
||||
browser_dir.push(mapped);
|
||||
browser_dir.push(&version);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let binary_path = browser_dir
|
||||
.join("Camoufox.app")
|
||||
.join("Contents")
|
||||
.join("MacOS")
|
||||
.join("camoufox");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let binary_path = browser_dir.join("camoufox.exe");
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let binary_path = browser_dir.join("camoufox");
|
||||
|
||||
config.executable_path = Some(binary_path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
if let Some(ref proxy_id_val) = proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
|
||||
let proxy_url = if let (Some(username), Some(password)) =
|
||||
@@ -631,27 +610,6 @@ impl ProfileImporter {
|
||||
let final_wayfern_config = if mapped == "wayfern" {
|
||||
let mut config = wayfern_config.unwrap_or_default();
|
||||
|
||||
if config.executable_path.is_none() {
|
||||
let mut browser_dir = self.profile_manager.get_binaries_dir();
|
||||
browser_dir.push(mapped);
|
||||
browser_dir.push(&version);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let binary_path = browser_dir
|
||||
.join("Chromium.app")
|
||||
.join("Contents")
|
||||
.join("MacOS")
|
||||
.join("Chromium");
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let binary_path = browser_dir.join("chrome.exe");
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let binary_path = browser_dir.join("chrome");
|
||||
|
||||
config.executable_path = Some(binary_path.to_string_lossy().to_string());
|
||||
}
|
||||
|
||||
if let Some(ref proxy_id_val) = proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_val) {
|
||||
let proxy_url = if let (Some(username), Some(password)) =
|
||||
@@ -1035,7 +993,9 @@ Path=test.profile
|
||||
fn test_get_default_version_for_browser_no_versions() {
|
||||
let (importer, _temp_dir) = create_test_profile_importer();
|
||||
|
||||
let result = importer.get_default_version_for_browser("camoufox");
|
||||
// Use a browser name that is guaranteed to have no downloaded versions,
|
||||
// since the global registry singleton may contain real data from the system.
|
||||
let result = importer.get_default_version_for_browser("nonexistent_browser_xyz");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Should fail when no versions are available"
|
||||
|
||||
@@ -907,6 +907,63 @@ impl ProxyManager {
|
||||
.map(|p| p.proxy_settings.clone())
|
||||
}
|
||||
|
||||
fn classify_proxy_error(raw_error: &str, settings: &ProxySettings) -> String {
|
||||
let err = raw_error.to_lowercase();
|
||||
let proxy_addr = format!("{}:{}", settings.host, settings.port);
|
||||
|
||||
if err.contains("connection refused") {
|
||||
return format!(
|
||||
"Connection refused by {proxy_addr}. The proxy server is not accepting connections."
|
||||
);
|
||||
}
|
||||
if err.contains("connection reset") {
|
||||
return format!(
|
||||
"Connection reset by {proxy_addr}. The proxy server closed the connection unexpectedly."
|
||||
);
|
||||
}
|
||||
if err.contains("timed out") || err.contains("deadline has elapsed") {
|
||||
return format!("Connection to {proxy_addr} timed out. The proxy server is not responding.");
|
||||
}
|
||||
if err.contains("no such host") || err.contains("dns") || err.contains("resolve") {
|
||||
return format!(
|
||||
"Could not resolve proxy host '{}'. Check that the hostname is correct.",
|
||||
settings.host
|
||||
);
|
||||
}
|
||||
if err.contains("authentication") || err.contains("407") || err.contains("proxy auth") {
|
||||
return format!(
|
||||
"Proxy authentication failed for {proxy_addr}. Check your username and password."
|
||||
);
|
||||
}
|
||||
if err.contains("403") || err.contains("forbidden") {
|
||||
return format!("Access denied by {proxy_addr} (403 Forbidden).");
|
||||
}
|
||||
if err.contains("402") {
|
||||
return format!(
|
||||
"Payment required by {proxy_addr} (402). Your proxy subscription may have expired."
|
||||
);
|
||||
}
|
||||
if err.contains("502") || err.contains("bad gateway") {
|
||||
return format!(
|
||||
"Bad gateway from {proxy_addr} (502). The upstream proxy server may be down."
|
||||
);
|
||||
}
|
||||
if err.contains("503") || err.contains("service unavailable") {
|
||||
return format!("Proxy {proxy_addr} is temporarily unavailable (503).");
|
||||
}
|
||||
if err.contains("socks") && err.contains("unreachable") {
|
||||
return format!("SOCKS proxy {proxy_addr} could not reach the target. The proxy server may not have internet access.");
|
||||
}
|
||||
if err.contains("invalid proxy") || err.contains("unsupported proxy") {
|
||||
return format!(
|
||||
"Invalid proxy configuration for {proxy_addr}. Check the proxy type and address."
|
||||
);
|
||||
}
|
||||
|
||||
// Generic fallback — still show the proxy address for context
|
||||
format!("Proxy check failed for {proxy_addr}. Could not connect through the proxy.")
|
||||
}
|
||||
|
||||
// Build proxy URL string from ProxySettings
|
||||
fn build_proxy_url(proxy_settings: &ProxySettings) -> String {
|
||||
let mut url = format!("{}://", proxy_settings.proxy_type);
|
||||
@@ -928,9 +985,9 @@ impl ProxyManager {
|
||||
url
|
||||
}
|
||||
|
||||
// Check if a proxy is valid by routing through a temporary local donut-proxy.
|
||||
// This tests the exact same code path the browser uses, ensuring that if the
|
||||
// check passes, the browser connection will work too.
|
||||
// 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.
|
||||
pub async fn check_proxy_validity(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
@@ -938,19 +995,31 @@ impl ProxyManager {
|
||||
) -> Result<ProxyCheckResult, String> {
|
||||
let upstream_url = Self::build_proxy_url(proxy_settings);
|
||||
|
||||
// Start a temporary local proxy that tunnels through the upstream
|
||||
let proxy_config = crate::proxy_runner::start_proxy_process(Some(upstream_url), None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start test proxy: {e}"))?;
|
||||
// Try process-based check first (identical to browser launch path)
|
||||
// Try process-based check first (identical to browser launch path).
|
||||
// If the proxy worker fails to start (e.g. Gatekeeper, antivirus, signing
|
||||
// restrictions), fall back to a direct reqwest check.
|
||||
let proxy_start_result =
|
||||
crate::proxy_runner::start_proxy_process(Some(upstream_url.clone()), None)
|
||||
.await
|
||||
.map_err(|e| e.to_string());
|
||||
|
||||
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
|
||||
let proxy_id_clone = proxy_config.id.clone();
|
||||
|
||||
// Fetch public IP through the local proxy (same path the browser uses)
|
||||
let ip_result = ip_utils::fetch_public_ip(Some(&local_url)).await;
|
||||
|
||||
// Stop the temporary proxy regardless of result
|
||||
let _ = crate::proxy_runner::stop_proxy_process(&proxy_id_clone).await;
|
||||
let ip_result = match proxy_start_result {
|
||||
Ok(proxy_config) => {
|
||||
let local_url = format!("http://127.0.0.1:{}", proxy_config.local_port.unwrap_or(0));
|
||||
let config_id = proxy_config.id.clone();
|
||||
let result = ip_utils::fetch_public_ip(Some(&local_url)).await;
|
||||
let _ = crate::proxy_runner::stop_proxy_process(&config_id).await;
|
||||
result
|
||||
}
|
||||
Err(err_msg) => {
|
||||
log::warn!(
|
||||
"Proxy worker failed to start ({}), falling back to direct check",
|
||||
err_msg
|
||||
);
|
||||
ip_utils::fetch_public_ip(Some(&upstream_url)).await
|
||||
}
|
||||
};
|
||||
|
||||
let ip = match ip_result {
|
||||
Ok(ip) => ip,
|
||||
@@ -964,7 +1033,10 @@ impl ProxyManager {
|
||||
is_valid: false,
|
||||
};
|
||||
let _ = self.save_proxy_check_cache(proxy_id, &failed_result);
|
||||
return Err(format!("Failed to fetch public IP: {e}"));
|
||||
|
||||
let err_str = e.to_string();
|
||||
let user_message = Self::classify_proxy_error(&err_str, proxy_settings);
|
||||
return Err(user_message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2741,17 +2813,19 @@ mod tests {
|
||||
fn test_process_running_detection_with_child_lifecycle() {
|
||||
use crate::proxy_storage::is_process_running;
|
||||
|
||||
// Spawn a long-lived child so we can check while it runs
|
||||
let mut child = std::process::Command::new(if cfg!(windows) { "timeout" } else { "sleep" })
|
||||
// Spawn a long-lived child so we can check while it runs.
|
||||
// On Windows, `timeout` requires console input and exits immediately in
|
||||
// non-interactive contexts, so use `ping` with a high count instead.
|
||||
let mut child = std::process::Command::new(if cfg!(windows) { "ping" } else { "sleep" })
|
||||
.args(if cfg!(windows) {
|
||||
vec!["/T", "10"]
|
||||
vec!["-n", "100", "127.0.0.1"]
|
||||
} else {
|
||||
vec!["10"]
|
||||
})
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn sleep");
|
||||
.expect("spawn long-lived child");
|
||||
|
||||
let pid = child.id();
|
||||
|
||||
|
||||
+83
-136
@@ -883,6 +883,87 @@ fn build_reqwest_client_with_proxy(
|
||||
Ok(client_builder.proxy(proxy).build()?)
|
||||
}
|
||||
|
||||
/// Handle a single proxy connection (used by both the proxy worker and in-process proxy checks).
|
||||
pub async fn handle_proxy_connection(
|
||||
mut stream: tokio::net::TcpStream,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
) {
|
||||
let _ = stream.set_nodelay(true);
|
||||
|
||||
if stream.readable().await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut peek_buffer = [0u8; 16];
|
||||
match stream.read(&mut peek_buffer).await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => {
|
||||
let request_start_upper = String::from_utf8_lossy(&peek_buffer[..n.min(7)]).to_uppercase();
|
||||
let is_connect = request_start_upper.starts_with("CONNECT");
|
||||
|
||||
if is_connect {
|
||||
let mut full_request = Vec::with_capacity(4096);
|
||||
full_request.extend_from_slice(&peek_buffer[..n]);
|
||||
|
||||
let mut remaining = [0u8; 4096];
|
||||
let mut total_read = n;
|
||||
let max_reads = 100;
|
||||
let mut reads = 0;
|
||||
|
||||
loop {
|
||||
if reads >= max_reads {
|
||||
break;
|
||||
}
|
||||
match stream.read(&mut remaining).await {
|
||||
Ok(0) => {
|
||||
if full_request.ends_with(b"\r\n\r\n")
|
||||
|| full_request.ends_with(b"\n\n")
|
||||
|| total_read > 0
|
||||
{
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
Ok(m) => {
|
||||
reads += 1;
|
||||
total_read += m;
|
||||
full_request.extend_from_slice(&remaining[..m]);
|
||||
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
if total_read > 0 {
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ =
|
||||
handle_connect_from_buffer(stream, full_request, upstream_url, bypass_matcher).await;
|
||||
return;
|
||||
}
|
||||
|
||||
// Non-CONNECT: prepend consumed bytes and pass to hyper
|
||||
let prepended_bytes = peek_buffer[..n].to_vec();
|
||||
let prepended_reader = PrependReader {
|
||||
prepended: prepended_bytes,
|
||||
prepended_pos: 0,
|
||||
inner: stream,
|
||||
};
|
||||
let io = TokioIo::new(prepended_reader);
|
||||
let service =
|
||||
service_fn(move |req| handle_request(req, upstream_url.clone(), bypass_matcher.clone()));
|
||||
|
||||
let _ = http1::Builder::new().serve_connection(io, service).await;
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
|
||||
log::error!(
|
||||
"Proxy worker starting, looking for config id: {}",
|
||||
@@ -1052,145 +1133,11 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
// This ensures the process doesn't exit even if there are no active connections
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((mut stream, peer_addr)) => {
|
||||
// Enable TCP_NODELAY to ensure small packets are sent immediately
|
||||
// This is critical for CONNECT responses to be sent before tunneling begins
|
||||
let _ = stream.set_nodelay(true);
|
||||
log::error!("DEBUG: Accepted connection from {:?}", peer_addr);
|
||||
|
||||
Ok((stream, _peer_addr)) => {
|
||||
let upstream = upstream_url.clone();
|
||||
let matcher = bypass_matcher.clone();
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
// Wait for the stream to have readable data before attempting to read.
|
||||
// This prevents read() from returning 0 on a fresh connection before
|
||||
// the client's data arrives.
|
||||
if stream.readable().await.is_err() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut peek_buffer = [0u8; 16];
|
||||
match stream.read(&mut peek_buffer).await {
|
||||
Ok(0) => {}
|
||||
Ok(n) => {
|
||||
// Check if this looks like a CONNECT request
|
||||
// Be more lenient - check if the first bytes match "CONNECT" (case-insensitive)
|
||||
let request_start_upper =
|
||||
String::from_utf8_lossy(&peek_buffer[..n.min(7)]).to_uppercase();
|
||||
let is_connect = request_start_upper.starts_with("CONNECT");
|
||||
|
||||
log::error!(
|
||||
"DEBUG: Read {} bytes, starts with: {:?}, is_connect: {}",
|
||||
n,
|
||||
String::from_utf8_lossy(&peek_buffer[..n.min(20)]),
|
||||
is_connect
|
||||
);
|
||||
|
||||
if is_connect {
|
||||
// Handle CONNECT request manually for tunneling
|
||||
let mut full_request = Vec::with_capacity(4096);
|
||||
full_request.extend_from_slice(&peek_buffer[..n]);
|
||||
|
||||
// Read the rest of the CONNECT request until we have the full headers
|
||||
// CONNECT requests end with \r\n\r\n (or \n\n)
|
||||
let mut remaining = [0u8; 4096];
|
||||
let mut total_read = n;
|
||||
let max_reads = 100; // Prevent infinite loop
|
||||
let mut reads = 0;
|
||||
|
||||
loop {
|
||||
if reads >= max_reads {
|
||||
log::error!("DEBUG: Max reads reached, breaking");
|
||||
break;
|
||||
}
|
||||
|
||||
match stream.read(&mut remaining).await {
|
||||
Ok(0) => {
|
||||
// Connection closed, but we might have a complete request
|
||||
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
|
||||
break;
|
||||
}
|
||||
// If we have some data, try to process it anyway
|
||||
if total_read > 0 {
|
||||
break;
|
||||
}
|
||||
return; // No data at all
|
||||
}
|
||||
Ok(m) => {
|
||||
reads += 1;
|
||||
total_read += m;
|
||||
full_request.extend_from_slice(&remaining[..m]);
|
||||
|
||||
// Check if we have complete headers
|
||||
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
|
||||
break;
|
||||
}
|
||||
|
||||
// Also check if we have enough to parse (at least "CONNECT host:port HTTP/1.x")
|
||||
if total_read >= 20 {
|
||||
// Check if we have a newline that might indicate end of request line
|
||||
if let Some(pos) = full_request.iter().position(|&b| b == b'\n') {
|
||||
if pos < full_request.len() - 1 {
|
||||
// We have at least the request line, check if we have headers
|
||||
let request_str = String::from_utf8_lossy(&full_request);
|
||||
if request_str.contains("\r\n\r\n") || request_str.contains("\n\n") {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("DEBUG: Error reading CONNECT request: {:?}", e);
|
||||
// If we have some data, try to process it
|
||||
if total_read > 0 {
|
||||
break;
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle CONNECT manually
|
||||
log::error!(
|
||||
"DEBUG: Handling CONNECT manually for: {}",
|
||||
String::from_utf8_lossy(&full_request[..full_request.len().min(200)])
|
||||
);
|
||||
if let Err(e) =
|
||||
handle_connect_from_buffer(stream, full_request, upstream, matcher).await
|
||||
{
|
||||
log::error!("Error handling CONNECT request: {:?}", e);
|
||||
} else {
|
||||
log::error!("DEBUG: CONNECT handled successfully");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Not CONNECT (or partial read) - reconstruct stream with consumed bytes prepended
|
||||
// This is critical: we MUST prepend any bytes we consumed, even if < 7 bytes
|
||||
log::error!(
|
||||
"DEBUG: Non-CONNECT request, first {} bytes: {:?}",
|
||||
n,
|
||||
String::from_utf8_lossy(&peek_buffer[..n.min(50)])
|
||||
);
|
||||
let prepended_bytes = peek_buffer[..n].to_vec();
|
||||
let prepended_reader = PrependReader {
|
||||
prepended: prepended_bytes,
|
||||
prepended_pos: 0,
|
||||
inner: stream,
|
||||
};
|
||||
let io = TokioIo::new(prepended_reader);
|
||||
let service =
|
||||
service_fn(move |req| handle_request(req, upstream.clone(), matcher.clone()));
|
||||
|
||||
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
|
||||
log::error!("Error serving connection: {:?}", err);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Error reading from connection: {:?}", e);
|
||||
}
|
||||
}
|
||||
handle_proxy_connection(stream, upstream, matcher).await;
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
+189
-19
@@ -332,11 +332,11 @@ impl SyncEngine {
|
||||
) -> SyncResult<()> {
|
||||
if profile.is_cross_os() {
|
||||
log::info!(
|
||||
"Skipping file sync for cross-OS profile: {} ({})",
|
||||
"Cross-OS profile: {} ({}) — syncing metadata only",
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
return Ok(());
|
||||
return self.sync_cross_os_metadata(app_handle, profile).await;
|
||||
}
|
||||
|
||||
// Skip team profiles for self-hosted sync
|
||||
@@ -597,15 +597,35 @@ impl SyncEngine {
|
||||
let _ = self.sync_vpn(vpn_id, Some(app_handle)).await;
|
||||
}
|
||||
|
||||
// Update profile last_sync
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.last_sync = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
);
|
||||
let _ = profile_manager.save_profile(&updated_profile);
|
||||
// Download remote metadata and merge changes (name, tags, notes, etc.)
|
||||
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
|
||||
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
|
||||
let mut updated_profile = profile.clone();
|
||||
// Merge fields that can be changed on other devices
|
||||
updated_profile.name = remote_meta.name;
|
||||
updated_profile.tags = remote_meta.tags;
|
||||
updated_profile.note = remote_meta.note;
|
||||
updated_profile.proxy_id = remote_meta.proxy_id;
|
||||
updated_profile.vpn_id = remote_meta.vpn_id;
|
||||
updated_profile.group_id = remote_meta.group_id;
|
||||
updated_profile.last_sync = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
);
|
||||
let _ = profile_manager.save_profile(&updated_profile);
|
||||
} else {
|
||||
// Fallback: just update last_sync
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.last_sync = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
);
|
||||
let _ = profile_manager.save_profile(&updated_profile);
|
||||
}
|
||||
let _ = events::emit("profiles-changed", ());
|
||||
|
||||
let _ = events::emit(
|
||||
@@ -691,6 +711,79 @@ impl SyncEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_profile_metadata(&self, key: &str) -> SyncResult<BrowserProfile> {
|
||||
let stat = self.client.stat(key).await?;
|
||||
if !stat.exists {
|
||||
return Err(SyncError::InvalidData(
|
||||
"Remote metadata not found".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let presign = self.client.presign_download(key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
let profile: BrowserProfile = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse metadata: {e}")))?;
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
/// Sync only metadata for cross-OS profiles (tags, notes, proxies, groups).
|
||||
/// No browser files are synced.
|
||||
async fn sync_cross_os_metadata(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
) -> SyncResult<()> {
|
||||
let profile_id = profile.id.to_string();
|
||||
let key_prefix = Self::get_team_key_prefix(profile).await;
|
||||
let profile_manager = ProfileManager::instance();
|
||||
|
||||
// Upload our metadata
|
||||
self
|
||||
.upload_profile_metadata(&profile_id, profile, &key_prefix)
|
||||
.await?;
|
||||
|
||||
// Download remote metadata and merge if remote has changes
|
||||
let remote_metadata_key = format!("{}profiles/{}/metadata.json", key_prefix, profile_id);
|
||||
if let Ok(remote_meta) = self.download_profile_metadata(&remote_metadata_key).await {
|
||||
let mut updated = profile.clone();
|
||||
updated.name = remote_meta.name;
|
||||
updated.tags = remote_meta.tags;
|
||||
updated.note = remote_meta.note;
|
||||
updated.proxy_id = remote_meta.proxy_id;
|
||||
updated.vpn_id = remote_meta.vpn_id;
|
||||
updated.group_id = remote_meta.group_id;
|
||||
updated.last_sync = Some(
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
);
|
||||
let _ = profile_manager.save_profile(&updated);
|
||||
}
|
||||
|
||||
// Sync associated entities
|
||||
if let Some(proxy_id) = &profile.proxy_id {
|
||||
let _ = self.sync_proxy(proxy_id, Some(app_handle)).await;
|
||||
}
|
||||
if let Some(group_id) = &profile.group_id {
|
||||
let _ = self.sync_group(group_id, Some(app_handle)).await;
|
||||
}
|
||||
|
||||
let _ = events::emit("profiles-changed", ());
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"profile_name": profile.name,
|
||||
"status": "synced"
|
||||
}),
|
||||
);
|
||||
|
||||
log::info!("Cross-OS profile {} metadata synced", profile_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn upload_profile_metadata(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
@@ -2248,6 +2341,42 @@ impl SyncEngine {
|
||||
.await?;
|
||||
}
|
||||
|
||||
// Verify critical files after download
|
||||
let os_crypt_key_path = profile_dir.join("profile").join("os_crypt_key");
|
||||
let cookies_path = profile_dir.join("profile").join("Default").join("Cookies");
|
||||
if os_crypt_key_path.exists() {
|
||||
let key_data = fs::read(&os_crypt_key_path).unwrap_or_default();
|
||||
log::info!(
|
||||
"Profile {} sync: os_crypt_key present ({} bytes, sha256: {:x})",
|
||||
profile_id,
|
||||
key_data.len(),
|
||||
{
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
key_data.hash(&mut h);
|
||||
h.finish()
|
||||
}
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Profile {} sync: os_crypt_key NOT FOUND after download",
|
||||
profile_id
|
||||
);
|
||||
}
|
||||
if cookies_path.exists() {
|
||||
let cookies_meta = fs::metadata(&cookies_path).unwrap_or_else(|_| fs::metadata(".").unwrap());
|
||||
log::info!(
|
||||
"Profile {} sync: Cookies present ({} bytes)",
|
||||
profile_id,
|
||||
cookies_meta.len()
|
||||
);
|
||||
} else {
|
||||
log::warn!(
|
||||
"Profile {} sync: Cookies NOT FOUND after download",
|
||||
profile_id
|
||||
);
|
||||
}
|
||||
|
||||
// Set sync mode and save profile
|
||||
if profile.sync_mode == SyncMode::Disabled {
|
||||
profile.sync_mode = if manifest.encrypted {
|
||||
@@ -2361,6 +2490,54 @@ impl SyncEngine {
|
||||
log::info!("No missing profiles found");
|
||||
}
|
||||
|
||||
// Delete local synced profiles that have a remote tombstone (deleted on another device)
|
||||
{
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let local_synced: Vec<(String, Option<String>)> = profile_manager
|
||||
.list_profiles()
|
||||
.unwrap_or_default()
|
||||
.iter()
|
||||
.filter(|p| p.is_sync_enabled())
|
||||
.map(|p| (p.id.to_string(), p.created_by_id.clone()))
|
||||
.collect();
|
||||
|
||||
let team_prefix = if let Some(auth) = crate::cloud_auth::CLOUD_AUTH.get_user().await {
|
||||
auth.user.team_id.map(|tid| format!("teams/{}/", tid))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
for (pid, created_by_id) in &local_synced {
|
||||
// Check personal tombstone
|
||||
let personal_tombstone = format!("tombstones/profiles/{}.json", pid);
|
||||
let has_personal_tombstone = matches!(
|
||||
self.client.stat(&personal_tombstone).await,
|
||||
Ok(stat) if stat.exists
|
||||
);
|
||||
|
||||
// Check team tombstone
|
||||
let has_team_tombstone = if let (Some(tp), Some(_)) = (&team_prefix, created_by_id) {
|
||||
let team_tombstone = format!("{}tombstones/profiles/{}.json", tp, pid);
|
||||
matches!(
|
||||
self.client.stat(&team_tombstone).await,
|
||||
Ok(stat) if stat.exists
|
||||
)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if has_personal_tombstone || has_team_tombstone {
|
||||
log::info!(
|
||||
"Profile {} has remote tombstone, deleting locally (deleted on another device)",
|
||||
pid
|
||||
);
|
||||
if let Err(e) = profile_manager.delete_profile_local_only(pid) {
|
||||
log::warn!("Failed to delete tombstoned profile {}: {}", pid, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh metadata for local cross-OS profiles (propagate renames, tags, notes from originating device)
|
||||
let profile_manager = ProfileManager::instance();
|
||||
// Collect cross-OS profiles before async operations to avoid holding non-Send Result across await
|
||||
@@ -2731,15 +2908,8 @@ pub async fn set_profile_sync_mode(
|
||||
}
|
||||
}
|
||||
|
||||
// If switching to Encrypted, verify eligibility, password, and generate salt
|
||||
// If switching to Encrypted, verify password is set and generate salt
|
||||
if new_mode == SyncMode::Encrypted {
|
||||
// Only pro users and team owners can enable encryption
|
||||
if let Some(state) = crate::cloud_auth::CLOUD_AUTH.get_user().await {
|
||||
if state.user.plan == "team" && state.user.team_role.as_deref() != Some("owner") {
|
||||
return Err("Profile encryption is available for Pro users and team owners.".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if !encryption::has_e2e_password() {
|
||||
return Err("E2E password not set. Please set a password in Settings first.".to_string());
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ use super::types::{SyncError, SyncResult};
|
||||
/// Patterns use `**/` prefix to match at any directory depth, since the sync
|
||||
/// engine scans from `profiles/{uuid}/` which contains `profile/Default/...`.
|
||||
pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
// Chromium caches (re-downloadable / re-generated)
|
||||
"**/Cache/**",
|
||||
"**/Code Cache/**",
|
||||
"**/GPUCache/**",
|
||||
@@ -23,7 +22,6 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"**/DawnGraphiteCache/**",
|
||||
"**/Service Worker/CacheStorage/**",
|
||||
"**/Service Worker/ScriptCache/**",
|
||||
// Chromium transient / volatile data
|
||||
"**/Session Storage/**",
|
||||
"**/blob_storage/**",
|
||||
"**/Crashpad/**",
|
||||
@@ -32,21 +30,26 @@ pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"**/optimization_guide_model_store/**",
|
||||
"**/Safe Browsing/**",
|
||||
"**/component_crx_cache/**",
|
||||
// Firefox/Camoufox caches (re-downloadable / re-generated)
|
||||
"**/cache2/**",
|
||||
"**/startupCache/**",
|
||||
"**/safebrowsing/**",
|
||||
"**/storage/temporary/**",
|
||||
"**/crashes/**",
|
||||
"**/minidumps/**",
|
||||
// Common volatile files
|
||||
"*.log",
|
||||
"*.tmp",
|
||||
"**/LOG",
|
||||
"**/LOG.old",
|
||||
"**/LOCK",
|
||||
"**/*-journal",
|
||||
"**/*-wal",
|
||||
"**/SingletonLock",
|
||||
"**/SingletonSocket",
|
||||
"**/SingletonCookie",
|
||||
"**/Secure Preferences",
|
||||
"**/GraphiteDawnCache/**",
|
||||
"**/DawnWebGPUCache/**",
|
||||
"**/BrowserMetrics*",
|
||||
"**/.DS_Store",
|
||||
".donut-sync/**",
|
||||
];
|
||||
|
||||
|
||||
@@ -153,30 +153,20 @@ impl SyncScheduler {
|
||||
}
|
||||
|
||||
pub async fn is_profile_running(&self, profile_id: &str) -> bool {
|
||||
// First check our internal tracking
|
||||
// Check our internal tracking (authoritative — immediately updated by mark_profile_stopped)
|
||||
let running = self.running_profiles.lock().await;
|
||||
if running.contains(profile_id) {
|
||||
return true;
|
||||
}
|
||||
drop(running);
|
||||
|
||||
// Also check the actual profile state from ProfileManager
|
||||
let profile_manager = ProfileManager::instance();
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
|
||||
if profile.process_id.is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if locked by another team member (profile in use remotely)
|
||||
if crate::team_lock::TEAM_LOCK
|
||||
// Check if locked by another device (profile in use remotely)
|
||||
if crate::team_lock::PROFILE_LOCK
|
||||
.is_locked_by_another(profile_id)
|
||||
.await
|
||||
{
|
||||
log::debug!(
|
||||
"Profile {} is locked by another team member, treating as running",
|
||||
"Profile {} is locked on another device, treating as running",
|
||||
profile_id
|
||||
);
|
||||
return true;
|
||||
@@ -274,7 +264,7 @@ impl SyncScheduler {
|
||||
|
||||
let sync_enabled_profiles: Vec<_> = profiles
|
||||
.into_iter()
|
||||
.filter(|p| p.is_sync_enabled() && !p.is_cross_os())
|
||||
.filter(|p| p.is_sync_enabled())
|
||||
.collect();
|
||||
|
||||
if sync_enabled_profiles.is_empty() {
|
||||
@@ -428,7 +418,7 @@ impl SyncScheduler {
|
||||
profile_manager.list_profiles().ok().and_then(|profiles| {
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os())
|
||||
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled())
|
||||
})
|
||||
};
|
||||
|
||||
@@ -477,31 +467,12 @@ impl SyncScheduler {
|
||||
});
|
||||
}
|
||||
|
||||
// Wait for all parallel syncs to finish
|
||||
while let Some(result) = sync_set.join_next().await {
|
||||
if let Err(e) = result {
|
||||
log::error!("Profile sync task panicked: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Trigger cleanup if everything is done
|
||||
let all_done = {
|
||||
let in_flight = self.in_flight_profiles.lock().await;
|
||||
in_flight.is_empty()
|
||||
&& self.pending_profiles.lock().await.is_empty()
|
||||
&& self.pending_proxies.lock().await.is_empty()
|
||||
&& self.pending_groups.lock().await.is_empty()
|
||||
&& self.pending_vpns.lock().await.is_empty()
|
||||
&& self.pending_extensions.lock().await.is_empty()
|
||||
&& self.pending_extension_groups.lock().await.is_empty()
|
||||
};
|
||||
if all_done {
|
||||
log::debug!("All profile syncs completed, triggering cleanup");
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||
log::warn!("Cleanup after sync failed: {e}");
|
||||
} else {
|
||||
log::debug!("Cleanup after sync completed successfully");
|
||||
// Wait for all parallel syncs to finish (only if we actually spawned any)
|
||||
if !sync_set.is_empty() {
|
||||
while let Some(result) = sync_set.join_next().await {
|
||||
if let Err(e) = result {
|
||||
log::error!("Profile sync task panicked: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -556,16 +527,6 @@ impl SyncScheduler {
|
||||
}
|
||||
|
||||
// Check if all sync work is complete after proxies finish
|
||||
if !self.is_sync_in_progress().await {
|
||||
log::debug!("All syncs completed after proxy sync, triggering cleanup");
|
||||
let registry =
|
||||
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||
log::warn!("Cleanup after sync failed: {e}");
|
||||
} else {
|
||||
log::debug!("Cleanup after sync completed successfully");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create sync engine: {}", e);
|
||||
@@ -623,16 +584,6 @@ impl SyncScheduler {
|
||||
}
|
||||
|
||||
// Check if all sync work is complete after groups finish
|
||||
if !self.is_sync_in_progress().await {
|
||||
log::debug!("All syncs completed after group sync, triggering cleanup");
|
||||
let registry =
|
||||
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||
log::warn!("Cleanup after sync failed: {e}");
|
||||
} else {
|
||||
log::debug!("Cleanup after sync completed successfully");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create sync engine: {}", e);
|
||||
@@ -685,17 +636,6 @@ impl SyncScheduler {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !self.is_sync_in_progress().await {
|
||||
log::debug!("All syncs completed after VPN sync, triggering cleanup");
|
||||
let registry =
|
||||
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||
log::warn!("Cleanup after sync failed: {e}");
|
||||
} else {
|
||||
log::debug!("Cleanup after sync completed successfully");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create sync engine: {}", e);
|
||||
@@ -725,15 +665,6 @@ impl SyncScheduler {
|
||||
log::error!("Failed to sync extension {}: {}", ext_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.is_sync_in_progress().await {
|
||||
log::debug!("All syncs completed after extension sync, triggering cleanup");
|
||||
let registry =
|
||||
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||
log::warn!("Cleanup after sync failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create sync engine: {}", e);
|
||||
@@ -763,15 +694,6 @@ impl SyncScheduler {
|
||||
log::error!("Failed to sync extension group {}: {}", group_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.is_sync_in_progress().await {
|
||||
log::debug!("All syncs completed after extension group sync, triggering cleanup");
|
||||
let registry =
|
||||
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||
log::warn!("Cleanup after sync failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create sync engine: {}", e);
|
||||
|
||||
@@ -233,10 +233,16 @@ impl SyncSubscription {
|
||||
let key = Self::strip_team_prefix(raw_key);
|
||||
|
||||
let work_item = if key.starts_with("profiles/") {
|
||||
key
|
||||
.strip_prefix("profiles/")
|
||||
.and_then(|s| s.strip_suffix(".tar.gz"))
|
||||
.map(|s| SyncWorkItem::Profile(s.to_string()))
|
||||
// Match both bundle uploads (profiles/{id}.tar.gz) and delta sync updates
|
||||
// (profiles/{id}/manifest.json, profiles/{id}/files/*, profiles/{id}/metadata.json)
|
||||
let profile_id = key.strip_prefix("profiles/").and_then(|rest| {
|
||||
// profiles/{id}.tar.gz → id
|
||||
rest
|
||||
.strip_suffix(".tar.gz")
|
||||
// profiles/{id}/manifest.json → id
|
||||
.or_else(|| rest.split('/').next().filter(|s| !s.is_empty()))
|
||||
});
|
||||
profile_id.map(|s| SyncWorkItem::Profile(s.to_string()))
|
||||
} else if key.starts_with("proxies/") {
|
||||
key
|
||||
.strip_prefix("proxies/")
|
||||
|
||||
+56
-61
@@ -31,42 +31,45 @@ struct AcquireLockResponse {
|
||||
locked_by_email: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TeamLockManager {
|
||||
pub struct ProfileLockManager {
|
||||
locks: RwLock<HashMap<String, ProfileLockInfo>>,
|
||||
heartbeat_handle: Mutex<Option<JoinHandle<()>>>,
|
||||
connected_team_id: Mutex<Option<String>>,
|
||||
connected: Mutex<bool>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref TEAM_LOCK: TeamLockManager = TeamLockManager::new();
|
||||
pub static ref PROFILE_LOCK: ProfileLockManager = ProfileLockManager::new();
|
||||
}
|
||||
|
||||
impl TeamLockManager {
|
||||
// Keep backward compatibility alias
|
||||
pub use PROFILE_LOCK as TEAM_LOCK;
|
||||
|
||||
impl ProfileLockManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
locks: RwLock::new(HashMap::new()),
|
||||
heartbeat_handle: Mutex::new(None),
|
||||
connected_team_id: Mutex::new(None),
|
||||
connected: Mutex::new(false),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(&self, team_id: &str) {
|
||||
log::info!("Connecting team lock manager for team: {team_id}");
|
||||
pub async fn connect(&self) {
|
||||
log::info!("Connecting profile lock manager");
|
||||
|
||||
{
|
||||
let mut tid = self.connected_team_id.lock().await;
|
||||
*tid = Some(team_id.to_string());
|
||||
let mut c = self.connected.lock().await;
|
||||
*c = true;
|
||||
}
|
||||
|
||||
if let Err(e) = self.fetch_initial_locks(team_id).await {
|
||||
log::warn!("Failed to fetch initial locks: {e}");
|
||||
if let Err(e) = self.fetch_locks().await {
|
||||
log::warn!("Failed to fetch initial profile locks: {e}");
|
||||
}
|
||||
|
||||
self.start_heartbeat_loop().await;
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self) {
|
||||
log::info!("Disconnecting team lock manager");
|
||||
log::info!("Disconnecting profile lock manager");
|
||||
|
||||
{
|
||||
let mut handle = self.heartbeat_handle.lock().await;
|
||||
@@ -81,23 +84,24 @@ impl TeamLockManager {
|
||||
}
|
||||
|
||||
{
|
||||
let mut tid = self.connected_team_id.lock().await;
|
||||
*tid = None;
|
||||
let mut c = self.connected.lock().await;
|
||||
*c = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn acquire_lock(&self, profile_id: &str) -> Result<(), String> {
|
||||
let team_id = self.get_team_id().await?;
|
||||
let client = Client::new();
|
||||
pub async fn is_connected(&self) -> bool {
|
||||
*self.connected.lock().await
|
||||
}
|
||||
|
||||
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 url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks");
|
||||
let url = format!("{CLOUD_API_URL}/api/profile-locks/{profile_id}");
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.json(&serde_json::json!({ "profileId": profile_id }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to acquire lock: {e}"))?;
|
||||
@@ -116,7 +120,7 @@ impl TeamLockManager {
|
||||
if !result.success {
|
||||
let email = result
|
||||
.locked_by_email
|
||||
.unwrap_or_else(|| "another user".to_string());
|
||||
.unwrap_or_else(|| "another device".to_string());
|
||||
return Err(format!("Profile is in use by {email}"));
|
||||
}
|
||||
|
||||
@@ -136,21 +140,19 @@ impl TeamLockManager {
|
||||
}
|
||||
|
||||
let _ = crate::events::emit(
|
||||
"team-lock-acquired",
|
||||
serde_json::json!({ "profileId": profile_id }),
|
||||
"profile-lock-changed",
|
||||
serde_json::json!({ "profileId": profile_id, "action": "acquired" }),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn release_lock(&self, profile_id: &str) -> Result<(), String> {
|
||||
let team_id = self.get_team_id().await?;
|
||||
let client = Client::new();
|
||||
|
||||
let access_token =
|
||||
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
||||
|
||||
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks/{profile_id}");
|
||||
let url = format!("{CLOUD_API_URL}/api/profile-locks/{profile_id}");
|
||||
let _ = client
|
||||
.delete(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
@@ -163,8 +165,8 @@ impl TeamLockManager {
|
||||
}
|
||||
|
||||
let _ = crate::events::emit(
|
||||
"team-lock-released",
|
||||
serde_json::json!({ "profileId": profile_id }),
|
||||
"profile-lock-changed",
|
||||
serde_json::json!({ "profileId": profile_id, "action": "released" }),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
@@ -190,12 +192,12 @@ impl TeamLockManager {
|
||||
false
|
||||
}
|
||||
|
||||
async fn fetch_initial_locks(&self, team_id: &str) -> Result<(), String> {
|
||||
async fn fetch_locks(&self) -> Result<(), String> {
|
||||
let client = Client::new();
|
||||
let access_token =
|
||||
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
||||
|
||||
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks");
|
||||
let url = format!("{CLOUD_API_URL}/api/profile-locks");
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
@@ -231,13 +233,13 @@ impl TeamLockManager {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
|
||||
let team_id = match TEAM_LOCK.get_team_id().await {
|
||||
Ok(id) => id,
|
||||
Err(_) => break,
|
||||
};
|
||||
if !PROFILE_LOCK.is_connected().await {
|
||||
break;
|
||||
}
|
||||
|
||||
// Send heartbeat for each held lock
|
||||
let held_locks: Vec<String> = {
|
||||
let locks = TEAM_LOCK.locks.read().await;
|
||||
let locks = PROFILE_LOCK.locks.read().await;
|
||||
if let Some(user) = CLOUD_AUTH.get_user().await {
|
||||
locks
|
||||
.values()
|
||||
@@ -252,7 +254,7 @@ impl TeamLockManager {
|
||||
for profile_id in held_locks {
|
||||
let client = Client::new();
|
||||
if let Ok(Some(token)) = CloudAuthManager::load_access_token() {
|
||||
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks/{profile_id}/heartbeat");
|
||||
let url = format!("{CLOUD_API_URL}/api/profile-locks/{profile_id}/heartbeat");
|
||||
let _ = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
@@ -262,63 +264,56 @@ impl TeamLockManager {
|
||||
}
|
||||
|
||||
// Refresh lock state from server
|
||||
if let Err(e) = TEAM_LOCK.fetch_initial_locks(&team_id).await {
|
||||
log::debug!("Failed to refresh locks: {e}");
|
||||
if let Err(e) = PROFILE_LOCK.fetch_locks().await {
|
||||
log::debug!("Failed to refresh profile locks: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*handle = Some(h);
|
||||
}
|
||||
|
||||
async fn get_team_id(&self) -> Result<String, String> {
|
||||
let tid = self.connected_team_id.lock().await;
|
||||
tid
|
||||
.clone()
|
||||
.ok_or_else(|| "Not connected to a team".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire team lock if profile is sync-enabled and user is on a team.
|
||||
/// Returns Ok(()) if lock acquired or not applicable, Err with message if locked by another.
|
||||
/// Acquire profile lock if profile is sync-enabled and user has a paid subscription.
|
||||
pub async fn acquire_team_lock_if_needed(
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
) -> Result<(), String> {
|
||||
if !profile.is_sync_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
if !CLOUD_AUTH.is_on_team_plan().await {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if TEAM_LOCK
|
||||
// Ensure lock manager is connected
|
||||
if !PROFILE_LOCK.is_connected().await {
|
||||
PROFILE_LOCK.connect().await;
|
||||
}
|
||||
|
||||
if PROFILE_LOCK
|
||||
.is_locked_by_another(&profile.id.to_string())
|
||||
.await
|
||||
{
|
||||
if let Some(lock) = TEAM_LOCK.get_lock_status(&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 by another team member".to_string());
|
||||
return Err("Profile is in use on another device".to_string());
|
||||
}
|
||||
|
||||
TEAM_LOCK.acquire_lock(&profile.id.to_string()).await
|
||||
PROFILE_LOCK.acquire_lock(&profile.id.to_string()).await
|
||||
}
|
||||
|
||||
/// Release team lock if profile is sync-enabled and user is on a team.
|
||||
/// Logs warnings on failure but does not return errors.
|
||||
/// Release profile lock if profile is sync-enabled and user has a paid subscription.
|
||||
pub async fn release_team_lock_if_needed(profile: &crate::profile::BrowserProfile) {
|
||||
if !profile.is_sync_enabled() {
|
||||
return;
|
||||
}
|
||||
if !CLOUD_AUTH.is_on_team_plan().await {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = TEAM_LOCK.release_lock(&profile.id.to_string()).await {
|
||||
log::warn!(
|
||||
"Failed to release team lock for profile {}: {e}",
|
||||
profile.id
|
||||
);
|
||||
if let Err(e) = PROFILE_LOCK.release_lock(&profile.id.to_string()).await {
|
||||
log::warn!("Failed to release profile lock for {}: {e}", profile.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -326,10 +321,10 @@ pub async fn release_team_lock_if_needed(profile: &crate::profile::BrowserProfil
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_team_locks() -> Result<Vec<ProfileLockInfo>, String> {
|
||||
Ok(TEAM_LOCK.get_locks().await)
|
||||
Ok(PROFILE_LOCK.get_locks().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_team_lock_status(profile_id: String) -> Result<Option<ProfileLockInfo>, String> {
|
||||
Ok(TEAM_LOCK.get_lock_status(&profile_id).await)
|
||||
Ok(PROFILE_LOCK.get_lock_status(&profile_id).await)
|
||||
}
|
||||
|
||||
@@ -37,8 +37,6 @@ pub struct WayfernConfig {
|
||||
pub block_webrtc: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub block_webgl: Option<bool>,
|
||||
#[serde(default)]
|
||||
pub executable_path: Option<String>,
|
||||
#[serde(default, skip_serializing)]
|
||||
pub proxy: Option<String>,
|
||||
}
|
||||
@@ -212,21 +210,9 @@ impl WayfernManager {
|
||||
profile: &BrowserProfile,
|
||||
config: &WayfernConfig,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
let p = PathBuf::from(path);
|
||||
if p.exists() {
|
||||
p
|
||||
} else {
|
||||
log::warn!("Stored Wayfern executable path does not exist: {path}, falling back to dynamic resolution");
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?
|
||||
}
|
||||
} else {
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?
|
||||
};
|
||||
let executable_path = BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
|
||||
|
||||
let port = Self::find_free_port().await?;
|
||||
log::info!("Launching headless Wayfern on port {port} for fingerprint generation");
|
||||
@@ -247,9 +233,15 @@ impl WayfernManager {
|
||||
.arg("--disable-background-mode")
|
||||
.arg("--use-mock-keychain")
|
||||
.arg("--password-store=basic")
|
||||
.arg("--disable-features=DialMediaRouteProvider")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
.arg("--disable-features=DialMediaRouteProvider");
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
cmd
|
||||
.arg("--no-sandbox")
|
||||
.arg("--disable-setuid-sandbox")
|
||||
.arg("--disable-dev-shm-usage");
|
||||
|
||||
cmd.stdout(Stdio::null()).stderr(Stdio::null());
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
let child_id = child.id();
|
||||
@@ -456,21 +448,9 @@ impl WayfernManager {
|
||||
extension_paths: &[String],
|
||||
remote_debugging_port: Option<u16>,
|
||||
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
let p = PathBuf::from(path);
|
||||
if p.exists() {
|
||||
p
|
||||
} else {
|
||||
log::warn!("Stored Wayfern executable path does not exist: {path}, falling back to dynamic resolution");
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?
|
||||
}
|
||||
} else {
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?
|
||||
};
|
||||
let executable_path = BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?;
|
||||
|
||||
let port = match remote_debugging_port {
|
||||
Some(p) => p,
|
||||
@@ -478,6 +458,84 @@ impl WayfernManager {
|
||||
};
|
||||
log::info!("Launching Wayfern on CDP port {port}");
|
||||
|
||||
// Diagnostic: verify critical profile files and test cookie decryption
|
||||
{
|
||||
let profile_path_buf = std::path::PathBuf::from(profile_path);
|
||||
let key_path = profile_path_buf.join("os_crypt_key");
|
||||
let cookies_path = profile_path_buf.join("Default").join("Cookies");
|
||||
|
||||
if key_path.exists() {
|
||||
let key_text = std::fs::read_to_string(&key_path).unwrap_or_default();
|
||||
log::info!(
|
||||
"Pre-launch: os_crypt_key present ({} bytes, content: '{}')",
|
||||
key_text.len(),
|
||||
key_text.trim()
|
||||
);
|
||||
} else {
|
||||
log::warn!("Pre-launch: os_crypt_key NOT FOUND");
|
||||
}
|
||||
|
||||
if cookies_path.exists() {
|
||||
// Try to open Cookies DB and check if encrypted cookies can be decrypted
|
||||
if let Ok(conn) = rusqlite::Connection::open_with_flags(
|
||||
&cookies_path,
|
||||
rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY,
|
||||
) {
|
||||
let cookie_count: i64 = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) FROM cookies WHERE length(encrypted_value) > 0",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap_or(0);
|
||||
let total_count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM cookies", [], |r| r.get(0))
|
||||
.unwrap_or(0);
|
||||
log::info!(
|
||||
"Pre-launch: Cookies DB has {} total cookies, {} encrypted",
|
||||
total_count,
|
||||
cookie_count
|
||||
);
|
||||
|
||||
// Try decrypting one cookie using the cookie_manager
|
||||
if let Some(encryption_key) =
|
||||
crate::cookie_manager::chrome_decrypt::get_encryption_key(&profile_path_buf)
|
||||
{
|
||||
if let Ok(mut stmt) = conn.prepare(
|
||||
"SELECT name, host_key, encrypted_value FROM cookies WHERE length(encrypted_value) > 0 LIMIT 1",
|
||||
) {
|
||||
if let Ok(mut rows) = stmt.query([]) {
|
||||
if let Ok(Some(row)) = rows.next() {
|
||||
let name: String = row.get(0).unwrap_or_default();
|
||||
let host: String = row.get(1).unwrap_or_default();
|
||||
let encrypted: Vec<u8> = row.get(2).unwrap_or_default();
|
||||
let decrypted =
|
||||
crate::cookie_manager::chrome_decrypt::decrypt(
|
||||
&encrypted,
|
||||
&encryption_key,
|
||||
);
|
||||
match decrypted {
|
||||
Some(val) => log::info!(
|
||||
"Pre-launch: Cookie decryption SUCCEEDED for '{}' (host: {}, decrypted {} bytes)",
|
||||
name, host, val.len()
|
||||
),
|
||||
None => log::error!(
|
||||
"Pre-launch: Cookie decryption FAILED for '{}' (host: {}, encrypted {} bytes)",
|
||||
name, host, encrypted.len()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("Pre-launch: Failed to derive encryption key from os_crypt_key");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::warn!("Pre-launch: Cookies NOT FOUND");
|
||||
}
|
||||
}
|
||||
|
||||
let mut args = vec![
|
||||
format!("--remote-debugging-port={port}"),
|
||||
"--remote-debugging-address=127.0.0.1".to_string(),
|
||||
@@ -492,12 +550,18 @@ impl WayfernManager {
|
||||
"--disable-session-crashed-bubble".to_string(),
|
||||
"--hide-crash-restore-bubble".to_string(),
|
||||
"--disable-infobars".to_string(),
|
||||
"--disable-quic".to_string(),
|
||||
"--disable-features=DialMediaRouteProvider".to_string(),
|
||||
"--use-mock-keychain".to_string(),
|
||||
"--password-store=basic".to_string(),
|
||||
];
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
args.push("--no-sandbox".to_string());
|
||||
args.push("--disable-setuid-sandbox".to_string());
|
||||
args.push("--disable-dev-shm-usage".to_string());
|
||||
}
|
||||
|
||||
if let Some(proxy) = proxy_url {
|
||||
args.push(format!("--proxy-server={proxy}"));
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.17.2",
|
||||
"version": "0.17.6",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
@@ -22,7 +22,6 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Input } from "./ui/input";
|
||||
import { ProBadge } from "./ui/pro-badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
const CLICK_THRESHOLD = 5;
|
||||
@@ -301,15 +300,12 @@ const HomeHeader = ({
|
||||
{t("header.menu.groups")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!crossOsUnlocked}
|
||||
className={cn(!crossOsUnlocked && "opacity-50")}
|
||||
onClick={() => {
|
||||
onExtensionManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<LuPuzzle className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.extensions")}
|
||||
{!crossOsUnlocked && <ProBadge className="ml-auto" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
|
||||
@@ -361,6 +361,9 @@ export function IntegrationsDialog({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.mcpCopyHint")}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("integrations.mcpConfigPath")}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
@@ -1648,14 +1648,18 @@ export function ProfilesDataTable({
|
||||
|
||||
// Cross-OS profiles: show OS icon when checkboxes aren't visible, show checkbox when they are
|
||||
if (isCrossOs && !meta.showCheckboxes && !isSelected) {
|
||||
const osName = profile.host_os
|
||||
? getOSDisplayName(profile.host_os)
|
||||
const resolvedOs =
|
||||
profile.host_os ||
|
||||
profile.camoufox_config?.os ||
|
||||
profile.wayfern_config?.os;
|
||||
const osName = resolvedOs
|
||||
? getOSDisplayName(resolvedOs)
|
||||
: "another OS";
|
||||
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
|
||||
const OsIcon =
|
||||
profile.host_os === "macos"
|
||||
resolvedOs === "macos"
|
||||
? FaApple
|
||||
: profile.host_os === "windows"
|
||||
: resolvedOs === "windows"
|
||||
? FaWindows
|
||||
: FaLinux;
|
||||
return (
|
||||
@@ -1684,8 +1688,12 @@ export function ProfilesDataTable({
|
||||
|
||||
// Cross-OS profiles with checkboxes visible: show checkbox (selectable for bulk delete)
|
||||
if (isCrossOs && (meta.showCheckboxes || isSelected)) {
|
||||
const osName = profile.host_os
|
||||
? getOSDisplayName(profile.host_os)
|
||||
const resolvedOs =
|
||||
profile.host_os ||
|
||||
profile.camoufox_config?.os ||
|
||||
profile.wayfern_config?.os;
|
||||
const osName = resolvedOs
|
||||
? getOSDisplayName(resolvedOs)
|
||||
: "another OS";
|
||||
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
|
||||
return (
|
||||
@@ -1794,8 +1802,11 @@ export function ProfilesDataTable({
|
||||
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;
|
||||
meta.browserState.canLaunchProfile(profile) &&
|
||||
!isLockedByAnother &&
|
||||
!isSyncing;
|
||||
const lockEmail = meta.getProfileLockEmail(profile.id);
|
||||
const tooltipContent = isLockedByAnother
|
||||
? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail })
|
||||
@@ -1823,13 +1834,14 @@ export function ProfilesDataTable({
|
||||
);
|
||||
try {
|
||||
await meta.onLaunchProfile(profile);
|
||||
} catch (error) {
|
||||
} finally {
|
||||
// Always clear launching state — the running state is tracked
|
||||
// separately via profile-running-changed events
|
||||
meta.setLaunchingProfiles((prev: Set<string>) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(profile.id);
|
||||
return next;
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2017,7 +2029,7 @@ export function ProfilesDataTable({
|
||||
);
|
||||
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
|
||||
const isCrossOsBlocked = isCrossOs;
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
@@ -2078,7 +2090,7 @@ export function ProfilesDataTable({
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
|
||||
const isCrossOsBlocked = isCrossOs;
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
@@ -2107,7 +2119,7 @@ export function ProfilesDataTable({
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
|
||||
const isCrossOsBlocked = isCrossOs;
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
@@ -2134,7 +2146,7 @@ export function ProfilesDataTable({
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isCrossOsBlocked = isCrossOs && !meta.crossOsUnlocked;
|
||||
const isCrossOsBlocked = isCrossOs;
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
@@ -2534,7 +2546,12 @@ export function ProfilesDataTable({
|
||||
const rowIsCrossOs = isCrossOsProfile(row.original);
|
||||
const crossOsTitle = rowIsCrossOs
|
||||
? t("crossOs.viewOnly", {
|
||||
os: getOSDisplayName(row.original.host_os ?? ""),
|
||||
os: getOSDisplayName(
|
||||
row.original.host_os ||
|
||||
row.original.camoufox_config?.os ||
|
||||
row.original.wayfern_config?.os ||
|
||||
"",
|
||||
),
|
||||
})
|
||||
: undefined;
|
||||
return (
|
||||
@@ -2652,40 +2669,20 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkExtensionGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip={
|
||||
crossOsUnlocked
|
||||
? "Assign Extension Group"
|
||||
: "Assign Extension Group (Pro)"
|
||||
}
|
||||
tooltip="Assign Extension Group"
|
||||
onClick={onBulkExtensionGroupAssignment}
|
||||
size="icon"
|
||||
disabled={!crossOsUnlocked}
|
||||
>
|
||||
<span className="relative">
|
||||
<LuPuzzle />
|
||||
{!crossOsUnlocked && (
|
||||
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[6px] font-bold leading-none bg-primary text-primary-foreground px-0.5 rounded-sm">
|
||||
PRO
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<LuPuzzle />
|
||||
</DataTableActionBarAction>
|
||||
)}
|
||||
{onBulkCopyCookies && (
|
||||
<DataTableActionBarAction
|
||||
tooltip={crossOsUnlocked ? "Copy Cookies" : "Copy Cookies (Pro)"}
|
||||
tooltip="Copy Cookies"
|
||||
onClick={onBulkCopyCookies}
|
||||
size="icon"
|
||||
disabled={!crossOsUnlocked}
|
||||
>
|
||||
<span className="relative">
|
||||
<LuCookie />
|
||||
{!crossOsUnlocked && (
|
||||
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[6px] font-bold leading-none bg-primary text-primary-foreground px-0.5 rounded-sm">
|
||||
PRO
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<LuCookie />
|
||||
</DataTableActionBarAction>
|
||||
)}
|
||||
{onBulkDelete && (
|
||||
|
||||
@@ -211,7 +211,7 @@ export function ProfileInfoDialog({
|
||||
profile.release_type.slice(1);
|
||||
const hasTags = profile.tags && profile.tags.length > 0;
|
||||
const hasNote = !!profile.note;
|
||||
const showCrossOs = !!(profile.host_os && isCrossOsProfile(profile));
|
||||
const showCrossOs = isCrossOsProfile(profile);
|
||||
|
||||
type ActionItem = {
|
||||
icon: React.ReactNode;
|
||||
@@ -266,9 +266,8 @@ export function ProfileInfoDialog({
|
||||
icon: <LuCopy className="w-4 h-4" />,
|
||||
label: t("profiles.actions.copyCookiesToProfile"),
|
||||
onClick: () => handleAction(() => onCopyCookiesToProfile?.(profile)),
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
runningBadge: isRunning && crossOsUnlocked,
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
hidden:
|
||||
!isCamoufoxOrWayfern ||
|
||||
profile.ephemeral === true ||
|
||||
@@ -278,9 +277,8 @@ export function ProfileInfoDialog({
|
||||
icon: <LuCookie className="w-4 h-4" />,
|
||||
label: t("profileInfo.actions.manageCookies"),
|
||||
onClick: () => handleAction(() => onOpenCookieManagement?.(profile)),
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
runningBadge: isRunning && crossOsUnlocked,
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
hidden:
|
||||
!isCamoufoxOrWayfern ||
|
||||
profile.ephemeral === true ||
|
||||
@@ -364,10 +362,22 @@ export function ProfileInfoDialog({
|
||||
{t("profiles.ephemeralBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
{showCrossOs && profile.host_os && (
|
||||
{showCrossOs && (
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<OSIcon os={profile.host_os} />
|
||||
{getOSDisplayName(profile.host_os)}
|
||||
<OSIcon
|
||||
os={
|
||||
profile.host_os ||
|
||||
profile.camoufox_config?.os ||
|
||||
profile.wayfern_config?.os ||
|
||||
""
|
||||
}
|
||||
/>
|
||||
{getOSDisplayName(
|
||||
profile.host_os ||
|
||||
profile.camoufox_config?.os ||
|
||||
profile.wayfern_config?.os ||
|
||||
"",
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -41,10 +41,11 @@ export function ProfileSyncDialog({
|
||||
cloudUser.plan !== "free" &&
|
||||
(cloudUser.subscriptionStatus === "active" ||
|
||||
cloudUser.planPeriod === "lifetime");
|
||||
// Encryption available to everyone except team members who aren't owners
|
||||
const canUseEncryption =
|
||||
isCloudSyncEligible &&
|
||||
cloudUser != null &&
|
||||
(cloudUser.plan !== "team" || cloudUser.teamRole === "owner");
|
||||
cloudUser == null ||
|
||||
cloudUser.plan !== "team" ||
|
||||
cloudUser.teamRole === "owner";
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncMode, setSyncMode] = useState<SyncMode>(
|
||||
|
||||
@@ -134,12 +134,11 @@ export function SettingsDialog({
|
||||
} = usePermissions();
|
||||
const { trialStatus } = useCommercialTrial();
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
// Encryption is available to everyone except team members who aren't owners
|
||||
const canUseEncryption =
|
||||
cloudUser != null &&
|
||||
cloudUser.plan !== "free" &&
|
||||
(cloudUser.subscriptionStatus === "active" ||
|
||||
cloudUser.planPeriod === "lifetime") &&
|
||||
(cloudUser.plan !== "team" || cloudUser.teamRole === "owner");
|
||||
cloudUser == null ||
|
||||
cloudUser.plan !== "team" ||
|
||||
cloudUser.teamRole === "owner";
|
||||
const {
|
||||
currentLanguage,
|
||||
changeLanguage,
|
||||
|
||||
@@ -67,7 +67,9 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("cloud");
|
||||
const [liveProxyUsage, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
|
||||
const [_liveProxyUsage, setLiveProxyUsage] = useState<ProxyUsage | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
"unknown" | "testing" | "connected" | "error"
|
||||
@@ -300,40 +302,6 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{liveProxyUsage && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Recurring Proxy Bandwidth
|
||||
</span>
|
||||
<span>
|
||||
{Math.max(
|
||||
0,
|
||||
liveProxyUsage.recurring_limit_mb -
|
||||
liveProxyUsage.used_mb,
|
||||
)}{" "}
|
||||
/ {liveProxyUsage.recurring_limit_mb} MB remaining
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Extra Proxy Bandwidth
|
||||
</span>
|
||||
<span>
|
||||
{Math.max(
|
||||
0,
|
||||
liveProxyUsage.remaining_mb -
|
||||
Math.max(
|
||||
0,
|
||||
liveProxyUsage.recurring_limit_mb -
|
||||
liveProxyUsage.used_mb,
|
||||
),
|
||||
)}{" "}
|
||||
/ {liveProxyUsage.extra_limit_mb} MB remaining
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{user.teamName && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
|
||||
@@ -15,7 +15,7 @@ export function useBrowserState(
|
||||
_isUpdating: (browser: string) => boolean,
|
||||
launchingProfiles: Set<string>,
|
||||
stoppingProfiles: Set<string>,
|
||||
crossOsUnlocked = false,
|
||||
_crossOsUnlocked = false,
|
||||
) {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
@@ -53,7 +53,7 @@ export function useBrowserState(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!isClient) return false;
|
||||
|
||||
if (isCrossOsProfile(profile) && !crossOsUnlocked) return false;
|
||||
if (isCrossOsProfile(profile)) return false;
|
||||
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
@@ -81,7 +81,6 @@ export function useBrowserState(
|
||||
isAnyInstanceRunning,
|
||||
launchingProfiles,
|
||||
stoppingProfiles,
|
||||
crossOsUnlocked,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -158,11 +157,16 @@ export function useBrowserState(
|
||||
(profile: BrowserProfile): string => {
|
||||
if (!isClient) return "Loading...";
|
||||
|
||||
if (isCrossOsProfile(profile) && profile.host_os) {
|
||||
if (!crossOsUnlocked) {
|
||||
const osName = getOSDisplayName(profile.host_os);
|
||||
return `This profile was created on ${osName}. A paid subscription is required to launch cross-OS profiles.`;
|
||||
if (isCrossOsProfile(profile)) {
|
||||
const profileOs =
|
||||
profile.host_os ||
|
||||
profile.camoufox_config?.os ||
|
||||
profile.wayfern_config?.os;
|
||||
if (profileOs) {
|
||||
const osName = getOSDisplayName(profileOs);
|
||||
return `This profile was created on ${osName} and cannot be launched on a different operating system.`;
|
||||
}
|
||||
return "This profile was created on a different operating system and cannot be launched here.";
|
||||
}
|
||||
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
@@ -197,7 +201,6 @@ export function useBrowserState(
|
||||
canLaunchProfile,
|
||||
launchingProfiles,
|
||||
stoppingProfiles,
|
||||
crossOsUnlocked,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -421,7 +421,8 @@
|
||||
"config": "MCP Configuration",
|
||||
"copyConfig": "Copy Configuration"
|
||||
},
|
||||
"mcpCopyHint": "Add this to your MCP client config to connect."
|
||||
"mcpCopyHint": "Copy and paste this into your MCP client configuration.",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import Profile",
|
||||
|
||||
@@ -421,7 +421,8 @@
|
||||
"config": "Configuración MCP",
|
||||
"copyConfig": "Copiar Configuración"
|
||||
},
|
||||
"mcpCopyHint": "Agrega esto a la configuración de tu cliente MCP para conectarte."
|
||||
"mcpCopyHint": "Copia y pega esto en la configuración de tu cliente MCP.",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) o %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar Perfil",
|
||||
|
||||
@@ -421,7 +421,8 @@
|
||||
"config": "Configuration MCP",
|
||||
"copyConfig": "Copier la configuration"
|
||||
},
|
||||
"mcpCopyHint": "Ajoutez ceci à la configuration de votre client MCP pour vous connecter."
|
||||
"mcpCopyHint": "Copiez et collez ceci dans la configuration de votre client MCP.",
|
||||
"mcpConfigPath": "Claude Desktop : ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) ou %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importer un profil",
|
||||
|
||||
@@ -421,7 +421,8 @@
|
||||
"config": "MCP設定",
|
||||
"copyConfig": "設定をコピー"
|
||||
},
|
||||
"mcpCopyHint": "MCPクライアントの設定にこれを追加して接続してください。"
|
||||
"mcpCopyHint": "MCPクライアントの設定にコピーして貼り付けてください。",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) または %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
},
|
||||
"import": {
|
||||
"title": "プロファイルをインポート",
|
||||
|
||||
@@ -421,7 +421,8 @@
|
||||
"config": "Configuração MCP",
|
||||
"copyConfig": "Copiar Configuração"
|
||||
},
|
||||
"mcpCopyHint": "Adicione isso à configuração do seu cliente MCP para conectar."
|
||||
"mcpCopyHint": "Copie e cole isso na configuração do seu cliente MCP.",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) ou %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar Perfil",
|
||||
|
||||
@@ -421,7 +421,8 @@
|
||||
"config": "Конфигурация MCP",
|
||||
"copyConfig": "Копировать конфигурацию"
|
||||
},
|
||||
"mcpCopyHint": "Добавьте это в конфигурацию вашего MCP-клиента для подключения."
|
||||
"mcpCopyHint": "Скопируйте и вставьте это в конфигурацию вашего MCP-клиента.",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) или %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
},
|
||||
"import": {
|
||||
"title": "Импорт профиля",
|
||||
|
||||
@@ -421,7 +421,8 @@
|
||||
"config": "MCP 配置",
|
||||
"copyConfig": "复制配置"
|
||||
},
|
||||
"mcpCopyHint": "将此添加到您的MCP客户端配置中以进行连接。"
|
||||
"mcpCopyHint": "将此内容复制并粘贴到您的 MCP 客户端配置中。",
|
||||
"mcpConfigPath": "Claude Desktop: ~/Library/Application Support/Claude/claude_desktop_config.json (macOS) 或 %APPDATA%\\Claude\\claude_desktop_config.json (Windows)"
|
||||
},
|
||||
"import": {
|
||||
"title": "导入配置文件",
|
||||
|
||||
@@ -57,9 +57,17 @@ export const getCurrentOS = () => {
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
export function isCrossOsProfile(profile: { host_os?: string }): boolean {
|
||||
if (!profile.host_os) return false;
|
||||
return profile.host_os !== getCurrentOS();
|
||||
export function isCrossOsProfile(profile: {
|
||||
host_os?: string;
|
||||
camoufox_config?: { os?: string };
|
||||
wayfern_config?: { os?: string };
|
||||
}): boolean {
|
||||
const profileOs =
|
||||
profile.host_os ||
|
||||
profile.camoufox_config?.os ||
|
||||
profile.wayfern_config?.os;
|
||||
if (!profileOs) return false;
|
||||
return profileOs !== getCurrentOS();
|
||||
}
|
||||
|
||||
export function getOSDisplayName(os: string): string {
|
||||
|
||||
Reference in New Issue
Block a user