mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-28 09:29:58 +02:00
Compare commits
45 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 | |||
| 5e6faf4e2c | |||
| cf1e49c761 | |||
| d05ab23404 | |||
| 8511535d69 | |||
| 29dd5abb34 | |||
| b2d1456aa9 | |||
| e3fc715cfa | |||
| 2cf9013d28 | |||
| 76dd0d84e8 |
@@ -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 sync "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
|
||||
aws s3 sync "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete 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 sync /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete
|
||||
aws s3 sync /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
|
||||
--endpoint-url "${R2_ENDPOINT}"
|
||||
aws s3 sync /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete
|
||||
aws s3 sync /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
|
||||
--endpoint-url "${R2_ENDPOINT}"
|
||||
|
||||
- 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 "RPM repo:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}"
|
||||
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
|
||||
|
||||
Executable
+10
@@ -0,0 +1,10 @@
|
||||
# Prevent pushing the 'nightly' tag — it is managed by CI
|
||||
if git rev-parse nightly >/dev/null 2>&1; then
|
||||
LOCAL_NIGHTLY=$(git rev-parse nightly)
|
||||
REMOTE_NIGHTLY=$(git ls-remote --tags origin refs/tags/nightly 2>/dev/null | awk '{print $1}')
|
||||
if [ -n "$REMOTE_NIGHTLY" ] && [ "$LOCAL_NIGHTLY" != "$REMOTE_NIGHTLY" ]; then
|
||||
echo "⚠ Skipping push of 'nightly' tag (managed by CI)"
|
||||
# Delete the local nightly tag so --tags won't try to push it
|
||||
git tag -d nightly >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
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.0",
|
||||
"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.0"
|
||||
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.0"
|
||||
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 {
|
||||
|
||||
+103
-30
@@ -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,9 +307,15 @@ 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);
|
||||
|
||||
// Determine if we have a partial file to resume
|
||||
let mut existing_size: u64 = 0;
|
||||
@@ -314,9 +323,10 @@ impl Downloader {
|
||||
existing_size = meta.len();
|
||||
}
|
||||
|
||||
// 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)
|
||||
@@ -329,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) {
|
||||
@@ -387,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;
|
||||
@@ -417,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;
|
||||
@@ -435,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();
|
||||
@@ -482,6 +526,9 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining buffered data to disk
|
||||
file.flush()?;
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
@@ -683,11 +730,16 @@ impl Downloader {
|
||||
// Do not remove the archive here. We keep it until verification succeeds.
|
||||
}
|
||||
Err(e) => {
|
||||
// Do not remove the archive or extracted files. Just drop the registry entry
|
||||
// so it won't be reported as downloaded.
|
||||
log::error!("Extraction failed for {browser_str} {version}: {e}");
|
||||
|
||||
// Delete the corrupt/invalid archive so a fresh download happens next time
|
||||
if download_path.exists() {
|
||||
log::info!("Deleting corrupt archive: {}", download_path.display());
|
||||
let _ = std::fs::remove_file(&download_path);
|
||||
}
|
||||
|
||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||
let _ = self.registry.save();
|
||||
// Remove browser-version pair from downloading set on error
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
@@ -696,6 +748,20 @@ impl Downloader {
|
||||
let mut tokens = DOWNLOAD_CANCELLATION_TOKENS.lock().unwrap();
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
|
||||
// Emit error stage so the UI shows a toast
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_str.clone(),
|
||||
version: version.clone(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 0.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "error".to_string(),
|
||||
};
|
||||
let _ = events::emit("download-progress", &progress);
|
||||
|
||||
return Err(format!("Failed to extract browser: {e}").into());
|
||||
}
|
||||
}
|
||||
@@ -906,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();
|
||||
|
||||
+43
-20
@@ -7,7 +7,7 @@ use crate::downloader::DownloadProgress;
|
||||
use crate::events;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::process::Command;
|
||||
use tokio::process::Command;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::fs::create_dir_all;
|
||||
@@ -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,22 +246,21 @@ impl Extractor {
|
||||
&self,
|
||||
file_path: &Path,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// First check file extension for DMG files since they're common on macOS
|
||||
// and can have misleading magic numbers
|
||||
// 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()) {
|
||||
if ext.to_lowercase() == "dmg" {
|
||||
return Ok("dmg".to_string());
|
||||
}
|
||||
if ext.to_lowercase() == "msi" {
|
||||
return Ok("msi".to_string());
|
||||
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())
|
||||
@@ -357,16 +370,20 @@ impl Extractor {
|
||||
.args([
|
||||
"attach",
|
||||
"-nobrowse",
|
||||
"-noverify",
|
||||
"-noautoopen",
|
||||
"-mountpoint",
|
||||
mount_point.to_str().unwrap(),
|
||||
dmg_path.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
.stdin(std::process::Stdio::null())
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
log::info!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
|
||||
log::error!("Failed to mount DMG. stdout: {stdout}, stderr: {stderr}");
|
||||
|
||||
// Clean up mount point before returning error
|
||||
let _ = fs::remove_dir_all(&mount_point);
|
||||
@@ -382,12 +399,13 @@ impl Extractor {
|
||||
let app_entry = match app_result {
|
||||
Ok(app_path) => app_path,
|
||||
Err(e) => {
|
||||
log::info!("Failed to find .app in mount point: {e}");
|
||||
log::error!("Failed to find .app in mount point: {e}");
|
||||
|
||||
// Try to unmount before returning error
|
||||
let _ = Command::new("hdiutil")
|
||||
.args(["detach", "-force", mount_point.to_str().unwrap()])
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
let _ = fs::remove_dir_all(&mount_point);
|
||||
|
||||
return Err("No .app found after extraction".into());
|
||||
@@ -407,16 +425,18 @@ impl Extractor {
|
||||
app_entry.to_str().unwrap(),
|
||||
app_path.to_str().unwrap(),
|
||||
])
|
||||
.output()?;
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
log::info!("Failed to copy app: {stderr}");
|
||||
log::error!("Failed to copy app: {stderr}");
|
||||
|
||||
// Unmount before returning error
|
||||
let _ = Command::new("hdiutil")
|
||||
.args(["detach", "-force", mount_point.to_str().unwrap()])
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
let _ = fs::remove_dir_all(&mount_point);
|
||||
|
||||
return Err(format!("Failed to copy app: {stderr}").into());
|
||||
@@ -427,18 +447,21 @@ impl Extractor {
|
||||
// Remove quarantine attributes
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-dr", "com.apple.quarantine", app_path.to_str().unwrap()])
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-cr", app_path.to_str().unwrap()])
|
||||
.output();
|
||||
.output()
|
||||
.await;
|
||||
|
||||
log::info!("Removed quarantine attributes");
|
||||
|
||||
// Unmount the DMG
|
||||
let output = Command::new("hdiutil")
|
||||
.args(["detach", mount_point.to_str().unwrap()])
|
||||
.output()?;
|
||||
.output()
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
|
||||
@@ -174,6 +174,13 @@ impl GeoIPDownloader {
|
||||
|
||||
let mmdb_path = Self::get_mmdb_file_path()?;
|
||||
|
||||
// Always download to a temp file first, then atomically rename.
|
||||
// This prevents corruption if the app is closed mid-download.
|
||||
let temp_path = mmdb_path.with_extension("mmdb.downloading");
|
||||
|
||||
// Remove any leftover temp file from a previous interrupted download
|
||||
let _ = fs::remove_file(&temp_path).await;
|
||||
|
||||
// Download the file
|
||||
let response = self.client.get(&download_url).send().await?;
|
||||
|
||||
@@ -189,7 +196,7 @@ impl GeoIPDownloader {
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut file = fs::File::create(&mmdb_path).await?;
|
||||
let mut file = fs::File::create(&temp_path).await?;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
@@ -237,6 +244,10 @@ impl GeoIPDownloader {
|
||||
}
|
||||
|
||||
file.flush().await?;
|
||||
drop(file);
|
||||
|
||||
// Atomically replace the old database with the new one
|
||||
fs::rename(&temp_path, &mmdb_path).await?;
|
||||
|
||||
// Write download timestamp
|
||||
let timestamp_path = Self::get_timestamp_path();
|
||||
|
||||
+9
-19
@@ -293,9 +293,17 @@ async fn fetch_dynamic_proxy(
|
||||
url: String,
|
||||
format: String,
|
||||
) -> Result<crate::browser::ProxySettings, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
let settings = crate::proxy_manager::PROXY_MANAGER
|
||||
.fetch_dynamic_proxy(&url, &format)
|
||||
.await?;
|
||||
|
||||
// Validate the proxy actually works by connecting through it
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.check_proxy_validity("_dynamic_test", &settings)
|
||||
.await
|
||||
.map_err(|e| format!("Proxy resolved but connection failed: {e}"))?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -348,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?;
|
||||
|
||||
@@ -389,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?;
|
||||
|
||||
@@ -418,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"
|
||||
|
||||
+152
-15
@@ -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,19 +985,45 @@ impl ProxyManager {
|
||||
url
|
||||
}
|
||||
|
||||
// Check if a proxy is valid by making HTTP requests through it
|
||||
// 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,
|
||||
proxy_settings: &ProxySettings,
|
||||
) -> Result<ProxyCheckResult, String> {
|
||||
let proxy_url = Self::build_proxy_url(proxy_settings);
|
||||
let upstream_url = Self::build_proxy_url(proxy_settings);
|
||||
|
||||
// Fetch public IP through the proxy using shared IP utilities
|
||||
let ip = match ip_utils::fetch_public_ip(Some(&proxy_url)).await {
|
||||
// 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 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,
|
||||
Err(e) => {
|
||||
// Save failed check result
|
||||
let failed_result = ProxyCheckResult {
|
||||
ip: String::new(),
|
||||
city: None,
|
||||
@@ -950,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1035,12 +1121,21 @@ impl ProxyManager {
|
||||
.as_object()
|
||||
.ok_or_else(|| "JSON response is not an object".to_string())?;
|
||||
|
||||
let host = obj
|
||||
let raw_host = obj
|
||||
.get("ip")
|
||||
.or_else(|| obj.get("host"))
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?
|
||||
.to_string();
|
||||
.ok_or_else(|| "Missing 'ip' or 'host' field in JSON response".to_string())?;
|
||||
|
||||
// Strip protocol prefix from host if present (e.g. "socks5://1.2.3.4" -> "1.2.3.4")
|
||||
// and extract the proxy type from it if no explicit type field is provided
|
||||
let (host, protocol_from_host) = if let Some(rest) = raw_host.strip_prefix("://") {
|
||||
(rest.to_string(), None)
|
||||
} else if let Some((proto, rest)) = raw_host.split_once("://") {
|
||||
(rest.to_string(), Some(proto.to_lowercase()))
|
||||
} else {
|
||||
(raw_host.to_string(), None)
|
||||
};
|
||||
|
||||
let port = obj
|
||||
.get("port")
|
||||
@@ -1054,9 +1149,11 @@ impl ProxyManager {
|
||||
let proxy_type = obj
|
||||
.get("type")
|
||||
.or_else(|| obj.get("proxy_type"))
|
||||
.or_else(|| obj.get("protocol"))
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("http")
|
||||
.to_string();
|
||||
.map(|s| s.to_lowercase())
|
||||
.or(protocol_from_host)
|
||||
.unwrap_or_else(|| "http".to_string());
|
||||
|
||||
let username = obj
|
||||
.get("username")
|
||||
@@ -2716,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();
|
||||
|
||||
@@ -3437,6 +3536,44 @@ mod tests {
|
||||
let body2 = r#"{"ip": "1.2.3.4", "port": 1080, "proxy_type": "socks4"}"#;
|
||||
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
|
||||
assert_eq!(result2.proxy_type, "socks4");
|
||||
|
||||
// "protocol" field alias
|
||||
let body3 = r#"{"ip": "1.2.3.4", "port": 1080, "protocol": "socks5"}"#;
|
||||
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
|
||||
assert_eq!(result3.proxy_type, "socks5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_normalizes_case() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 1080, "type": "SOCKS5"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
|
||||
let body2 = r#"{"ip": "1.2.3.4", "port": 8080, "protocol": "HTTP"}"#;
|
||||
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
|
||||
assert_eq!(result2.proxy_type, "http");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_strips_protocol_from_host() {
|
||||
// User's API returns "ip": "socks5://1.2.3.4" with protocol embedded in host
|
||||
let body = r#"{"ip": "socks5://1.2.3.4", "port": 1080, "username": "u", "password": "p"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.host, "1.2.3.4");
|
||||
assert_eq!(result.proxy_type, "socks5");
|
||||
assert_eq!(result.port, 1080);
|
||||
|
||||
// Protocol in host should be used as proxy_type when no explicit type field
|
||||
let body2 = r#"{"ip": "http://10.0.0.1", "port": 8080}"#;
|
||||
let result2 = ProxyManager::parse_dynamic_proxy_json(body2).unwrap();
|
||||
assert_eq!(result2.host, "10.0.0.1");
|
||||
assert_eq!(result2.proxy_type, "http");
|
||||
|
||||
// Explicit type field takes precedence over protocol in host
|
||||
let body3 = r#"{"ip": "http://10.0.0.1", "port": 1080, "type": "socks5"}"#;
|
||||
let result3 = ProxyManager::parse_dynamic_proxy_json(body3).unwrap();
|
||||
assert_eq!(result3.host, "10.0.0.1");
|
||||
assert_eq!(result3.proxy_type, "socks5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+83
-134
@@ -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,143 +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 {
|
||||
// Read first bytes to detect CONNECT requests
|
||||
// CONNECT requests need special handling for tunneling
|
||||
// Use a larger buffer to ensure we can detect CONNECT even with partial reads
|
||||
let mut peek_buffer = [0u8; 16];
|
||||
match stream.read(&mut peek_buffer).await {
|
||||
Ok(0) => {
|
||||
log::error!("DEBUG: Connection closed immediately (0 bytes read)");
|
||||
}
|
||||
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.0",
|
||||
"version": "0.17.6",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
@@ -1121,3 +1121,180 @@ async fn test_no_bypass_rules_all_through_upstream(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Start a minimal SOCKS5 proxy that tunnels connections to the real destination.
|
||||
/// Returns (port, JoinHandle).
|
||||
async fn start_mock_socks5_server() -> (u16, tokio::task::JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
while let Ok((mut client, _)) = listener.accept().await {
|
||||
tokio::spawn(async move {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
// SOCKS5 handshake: client sends version + methods
|
||||
let mut buf = [0u8; 256];
|
||||
let n = client.read(&mut buf).await.unwrap_or(0);
|
||||
if n < 2 || buf[0] != 0x05 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Reply: version 5, no auth required
|
||||
client.write_all(&[0x05, 0x00]).await.ok();
|
||||
|
||||
// Read connect request: VER CMD RSV ATYP DST.ADDR DST.PORT
|
||||
let n = client.read(&mut buf).await.unwrap_or(0);
|
||||
if n < 7 || buf[1] != 0x01 {
|
||||
client
|
||||
.write_all(&[0x05, 0x07, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
|
||||
let (target_host, target_port) = match buf[3] {
|
||||
0x01 => {
|
||||
// IPv4
|
||||
if n < 10 {
|
||||
return;
|
||||
}
|
||||
let ip = format!("{}.{}.{}.{}", buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
(ip, port)
|
||||
}
|
||||
0x03 => {
|
||||
// Domain
|
||||
let domain_len = buf[4] as usize;
|
||||
if n < 5 + domain_len + 2 {
|
||||
return;
|
||||
}
|
||||
let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).to_string();
|
||||
let port = u16::from_be_bytes([buf[5 + domain_len], buf[6 + domain_len]]);
|
||||
(domain, port)
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
|
||||
// Connect to target
|
||||
let target =
|
||||
match tokio::net::TcpStream::connect(format!("{}:{}", target_host, target_port)).await {
|
||||
Ok(t) => t,
|
||||
Err(_) => {
|
||||
client
|
||||
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await
|
||||
.ok();
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
// Success reply
|
||||
client
|
||||
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
|
||||
.await
|
||||
.ok();
|
||||
|
||||
// Bidirectional relay
|
||||
let (mut cr, mut cw) = tokio::io::split(client);
|
||||
let (mut tr, mut tw) = tokio::io::split(target);
|
||||
tokio::select! {
|
||||
_ = tokio::io::copy(&mut cr, &mut tw) => {}
|
||||
_ = tokio::io::copy(&mut tr, &mut cw) => {}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
/// Test that a SOCKS5 upstream proxy works end-to-end through donut-proxy.
|
||||
/// Starts a mock SOCKS5 server, a mock HTTP target server,
|
||||
/// then routes requests through donut-proxy -> SOCKS5 -> target.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_local_proxy_with_socks5_upstream(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let binary_path = setup_test().await?;
|
||||
let mut tracker = ProxyTestTracker::new(binary_path.clone());
|
||||
|
||||
// Start a mock HTTP server as the final destination
|
||||
let (target_port, target_handle) = start_mock_http_server("SOCKS5-TARGET-RESPONSE").await;
|
||||
println!("Mock target HTTP server on port {target_port}");
|
||||
|
||||
// Start a mock SOCKS5 proxy
|
||||
let (socks_port, socks_handle) = start_mock_socks5_server().await;
|
||||
println!("Mock SOCKS5 server on port {socks_port}");
|
||||
|
||||
// Helper to start a socks5 proxy
|
||||
async fn start_socks5_proxy(
|
||||
binary_path: &std::path::PathBuf,
|
||||
socks_port: u16,
|
||||
) -> Result<(String, u16), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let output = TestUtils::execute_command(
|
||||
binary_path,
|
||||
&[
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--proxy-port",
|
||||
&socks_port.to_string(),
|
||||
"--type",
|
||||
"socks5",
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Proxy start failed: {stderr}").into());
|
||||
}
|
||||
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
|
||||
let id = config["id"].as_str().unwrap().to_string();
|
||||
let port = config["localPort"].as_u64().unwrap() as u16;
|
||||
|
||||
// Wait for proxy to be fully ready by verifying it accepts and responds
|
||||
for _ in 0..20 {
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
if TcpStream::connect(("127.0.0.1", port)).await.is_ok() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Extra settle time for the accept loop to be fully initialized
|
||||
sleep(Duration::from_millis(200)).await;
|
||||
|
||||
Ok((id, port))
|
||||
}
|
||||
|
||||
// Test 1: HTTP request through donut-proxy -> SOCKS5 -> target
|
||||
let (proxy_id, local_port) = start_socks5_proxy(&binary_path, socks_port).await?;
|
||||
tracker.track_proxy(proxy_id);
|
||||
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
let request = format!(
|
||||
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
|
||||
let mut response = vec![0u8; 8192];
|
||||
let n = tokio::time::timeout(Duration::from_secs(10), stream.read(&mut response))
|
||||
.await
|
||||
.map_err(|_| "HTTP request through SOCKS5 timed out")?
|
||||
.map_err(|e| format!("Read error: {e}"))?;
|
||||
let response_str = String::from_utf8_lossy(&response[..n]);
|
||||
|
||||
assert!(
|
||||
response_str.contains("SOCKS5-TARGET-RESPONSE"),
|
||||
"HTTP request should be tunneled through SOCKS5 to target, got: {}",
|
||||
&response_str[..response_str.len().min(500)]
|
||||
);
|
||||
println!("SOCKS5 upstream proxy test passed");
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
target_handle.abort();
|
||||
socks_handle.abort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -335,6 +335,23 @@ export function useBrowserDownload() {
|
||||
`download-${browserName.toLowerCase()}-${progress.version}`,
|
||||
);
|
||||
setDownloadProgress(null);
|
||||
} else if (progress.stage === "error") {
|
||||
setDownloadingBrowsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(progress.browser);
|
||||
return next;
|
||||
});
|
||||
dismissToast(
|
||||
`download-${browserName.toLowerCase()}-${progress.version}`,
|
||||
);
|
||||
setDownloadProgress(null);
|
||||
showErrorToast(
|
||||
`${browserName} ${progress.version}: extraction failed`,
|
||||
{
|
||||
description:
|
||||
"The corrupt file was deleted. It will be re-downloaded on next attempt.",
|
||||
},
|
||||
);
|
||||
} else if (progress.stage === "completed") {
|
||||
setDownloadingBrowsers((prev) => {
|
||||
const next = new Set(prev);
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "Expects text like: host:port:username:password or protocol://user:pass@host:port",
|
||||
"testUrl": "Test URL",
|
||||
"testing": "Testing...",
|
||||
"testSuccess": "Dynamic proxy resolved to {{host}}:{{port}}",
|
||||
"testFailed": "Failed to fetch proxy: {{error}}",
|
||||
"testSuccess": "Proxy working: {{host}}:{{port}}",
|
||||
"testFailed": "Proxy test failed: {{error}}",
|
||||
"fetchFailed": "Failed to fetch dynamic proxy: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
@@ -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",
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "Espera texto como: host:port:username:password o protocol://user:pass@host:port",
|
||||
"testUrl": "Probar URL",
|
||||
"testing": "Probando...",
|
||||
"testSuccess": "El proxy dinámico se resolvió a {{host}}:{{port}}",
|
||||
"testFailed": "Error al obtener el proxy: {{error}}",
|
||||
"testSuccess": "Proxy funcionando: {{host}}:{{port}}",
|
||||
"testFailed": "Prueba de proxy fallida: {{error}}",
|
||||
"fetchFailed": "Error al obtener el proxy dinámico: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
@@ -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",
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "Attend du texte comme : host:port:username:password ou protocol://user:pass@host:port",
|
||||
"testUrl": "Tester l'URL",
|
||||
"testing": "Test en cours...",
|
||||
"testSuccess": "Le proxy dynamique a été résolu en {{host}}:{{port}}",
|
||||
"testFailed": "Échec de la récupération du proxy : {{error}}",
|
||||
"testSuccess": "Proxy fonctionnel : {{host}}:{{port}}",
|
||||
"testFailed": "Échec du test de proxy : {{error}}",
|
||||
"fetchFailed": "Échec de la récupération du proxy dynamique : {{error}}"
|
||||
},
|
||||
"check": {
|
||||
@@ -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",
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "テキスト形式: host:port:username:password または protocol://user:pass@host:port",
|
||||
"testUrl": "URLをテスト",
|
||||
"testing": "テスト中...",
|
||||
"testSuccess": "ダイナミックプロキシは {{host}}:{{port}} に解決されました",
|
||||
"testFailed": "プロキシの取得に失敗しました: {{error}}",
|
||||
"testSuccess": "プロキシ動作中: {{host}}:{{port}}",
|
||||
"testFailed": "プロキシテスト失敗: {{error}}",
|
||||
"fetchFailed": "ダイナミックプロキシの取得に失敗しました: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
@@ -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": "プロファイルをインポート",
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "Espera texto como: host:port:username:password ou protocol://user:pass@host:port",
|
||||
"testUrl": "Testar URL",
|
||||
"testing": "Testando...",
|
||||
"testSuccess": "O proxy dinâmico foi resolvido para {{host}}:{{port}}",
|
||||
"testFailed": "Falha ao obter o proxy: {{error}}",
|
||||
"testSuccess": "Proxy funcionando: {{host}}:{{port}}",
|
||||
"testFailed": "Falha no teste de proxy: {{error}}",
|
||||
"fetchFailed": "Falha ao obter o proxy dinâmico: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
@@ -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",
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "Ожидается текст вида: host:port:username:password или protocol://user:pass@host:port",
|
||||
"testUrl": "Проверить URL",
|
||||
"testing": "Проверка...",
|
||||
"testSuccess": "Динамический прокси разрешён в {{host}}:{{port}}",
|
||||
"testFailed": "Не удалось получить прокси: {{error}}",
|
||||
"testSuccess": "Прокси работает: {{host}}:{{port}}",
|
||||
"testFailed": "Тест прокси не пройден: {{error}}",
|
||||
"fetchFailed": "Не удалось получить динамический прокси: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
@@ -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": "Импорт профиля",
|
||||
|
||||
@@ -296,8 +296,8 @@
|
||||
"formatTextHint": "期望文本格式: host:port:username:password 或 protocol://user:pass@host:port",
|
||||
"testUrl": "测试URL",
|
||||
"testing": "测试中...",
|
||||
"testSuccess": "动态代理已解析为 {{host}}:{{port}}",
|
||||
"testFailed": "获取代理失败: {{error}}",
|
||||
"testSuccess": "代理正常运行: {{host}}:{{port}}",
|
||||
"testFailed": "代理测试失败: {{error}}",
|
||||
"fetchFailed": "获取动态代理失败: {{error}}"
|
||||
},
|
||||
"check": {
|
||||
@@ -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