mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-19 21:20:07 +02:00
Compare commits
126 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e388e2e85a | |||
| decfdfcfc7 | |||
| c516999f7a | |||
| 1099459dbb | |||
| a3514df0d4 | |||
| 0102cb6c06 | |||
| 612c6610ce | |||
| ba750a3401 | |||
| d0e3e15fd3 | |||
| 248927ae6f | |||
| 6d71dbc62c | |||
| 3f0029c778 | |||
| fff1fe7087 | |||
| 1c971c664f | |||
| 0788797e3f | |||
| 8c338515b7 | |||
| a8c179fca7 | |||
| d0f436ce2d | |||
| 4019701186 | |||
| 53f85abe24 | |||
| 2aafb4c7a4 | |||
| 00d5c655dc | |||
| b12a704d9f | |||
| 0e134fd145 | |||
| adcdc91de2 | |||
| 880014d4c4 | |||
| 71f367f0ae | |||
| daa001cdf2 | |||
| 17056360ab | |||
| 80d5b77a80 | |||
| 701605fa73 | |||
| 19cb24f67f | |||
| c3fec3d095 | |||
| bb8b6ea0b7 | |||
| a6dfc5664b | |||
| 001a292185 | |||
| c7d7ff19a7 | |||
| aec05fb725 | |||
| c420318be0 | |||
| 52c9147092 | |||
| c8a28dde5b | |||
| 915ed06032 | |||
| 9bd5b9f6db | |||
| 2adbf900ae | |||
| 95b17e368d | |||
| 71563c1cdc | |||
| e160f5b2cc | |||
| ad18966294 | |||
| 9a6b500a4f | |||
| e9c4e32df2 | |||
| 21bc1de298 | |||
| 495a91a364 | |||
| 7b1e966b73 | |||
| c33d165c6b | |||
| c0807164cb | |||
| 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 | |||
| ccecd2a1e3 | |||
| 238f7648cf | |||
| c4aee3a00b | |||
| 140e611085 | |||
| b4488ee3ec | |||
| c4bfd4e253 | |||
| 0b3dac5da8 | |||
| db4c1fce6c | |||
| d2d459feeb | |||
| 7648785e39 | |||
| 081a1922df | |||
| 55b8b61f42 | |||
| 5bea6a32e0 | |||
| e72874142b | |||
| 6b5b177482 | |||
| cdaacc5b27 | |||
| f5e068346c | |||
| 07ac2b7ff8 | |||
| ee7160bb9e | |||
| d0ea3f8903 | |||
| 942d193206 | |||
| 90563ea6f5 | |||
| 6a88887a6c | |||
| 0553f76f71 | |||
| 95e5dbb84a | |||
| e9b5442340 |
@@ -197,6 +197,7 @@ These are frequently overlooked issues that make UI look unprofessional:
|
||||
Before delivering UI code, verify these items:
|
||||
|
||||
### Visual Quality
|
||||
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] Brand logos are correct (verified from Simple Icons)
|
||||
@@ -204,24 +205,28 @@ Before delivering UI code, verify these items:
|
||||
- [ ] Use theme colors directly (bg-primary) not var() wrapper
|
||||
|
||||
### Interaction
|
||||
|
||||
- [ ] All clickable elements have `cursor-pointer`
|
||||
- [ ] Hover states provide clear visual feedback
|
||||
- [ ] Transitions are smooth (150-300ms)
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
|
||||
### Light/Dark Mode
|
||||
|
||||
- [ ] Light mode text has sufficient contrast (4.5:1 minimum)
|
||||
- [ ] Glass/transparent elements visible in light mode
|
||||
- [ ] Borders visible in both modes
|
||||
- [ ] Test both modes before delivery
|
||||
|
||||
### Layout
|
||||
|
||||
- [ ] Floating elements have proper spacing from edges
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] Responsive at 320px, 768px, 1024px, 1440px
|
||||
- [ ] No horizontal scroll on mobile
|
||||
|
||||
### Accessibility
|
||||
|
||||
- [ ] All images have alt text
|
||||
- [ ] Form inputs have labels
|
||||
- [ ] Color is not the only indicator
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
---
|
||||
name: "Bug report"
|
||||
about: Report a bug
|
||||
---
|
||||
|
||||
<!--
|
||||
Hi there! To expedite issue processing please search open and closed issues before submitting a new one. Existing issues often contain information about workarounds, resolution, or progress updates.
|
||||
-->
|
||||
|
||||
# Bug Report
|
||||
|
||||
## Description
|
||||
|
||||
<!-- A clear and concise description of the problem. -->
|
||||
|
||||
## Is this a regression?
|
||||
|
||||
<!-- Did this behavior use to work in the previous version? -->
|
||||
|
||||
## Minimal Reproduction
|
||||
|
||||
<!-- Clear steps to re-produce the issue. -->
|
||||
|
||||
1.
|
||||
2.
|
||||
3.
|
||||
|
||||
## Your Environment
|
||||
|
||||
<!-- Please provide as much information as you feel comfortable to help the maintainers understand the issue better -->
|
||||
|
||||
## Exception or Error or Screenshot
|
||||
|
||||
<!-- Please provide any error messages, stack traces, or screenshots that might help -->
|
||||
|
||||
<pre><code>
|
||||
<!-- Paste error logs here -->
|
||||
</code></pre>
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Add any other context about the problem here. -->
|
||||
@@ -1,34 +0,0 @@
|
||||
---
|
||||
name: "Feature request"
|
||||
about: Suggest a feature
|
||||
---
|
||||
|
||||
# Feature Request
|
||||
|
||||
## Description
|
||||
|
||||
<!-- A clear and concise description of the problem or missing capability. -->
|
||||
|
||||
## Describe the solution you'd like
|
||||
|
||||
<!-- If you have a solution in mind, please describe it. -->
|
||||
|
||||
## Describe alternatives you've considered
|
||||
|
||||
<!-- Have you considered any alternative solutions or workarounds? -->
|
||||
|
||||
## Use Case
|
||||
|
||||
<!-- Describe the specific use case and how this feature would benefit users. -->
|
||||
|
||||
## Priority
|
||||
|
||||
<!-- How important is this feature to you? -->
|
||||
|
||||
- [ ] Low - Nice to have
|
||||
- [ ] Medium - Would improve my workflow
|
||||
- [ ] High - Critical for my use case
|
||||
|
||||
## Additional Context
|
||||
|
||||
<!-- Add any other context, mockups, or examples about the feature request here. -->
|
||||
@@ -0,0 +1,63 @@
|
||||
name: Bug Report
|
||||
description: Something isn't working
|
||||
labels: ["bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What happened?
|
||||
placeholder: Describe the bug. What did you expect vs what actually happened?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
placeholder: |
|
||||
1. Go to ...
|
||||
2. Click on ...
|
||||
3. See error
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: os
|
||||
attributes:
|
||||
label: Operating System
|
||||
options:
|
||||
- macOS (Apple Silicon)
|
||||
- macOS (Intel)
|
||||
- Windows
|
||||
- Linux
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Donut Browser version
|
||||
placeholder: e.g. 0.17.6 or nightly-2026-03-21
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: browser
|
||||
attributes:
|
||||
label: Which browser is affected?
|
||||
options:
|
||||
- Wayfern
|
||||
- Camoufox
|
||||
- Both
|
||||
- Not browser-specific
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Error logs or screenshots
|
||||
description: Run from terminal to get logs. Paste errors, screenshots, or screen recordings.
|
||||
placeholder: Paste logs here or drag screenshots
|
||||
validations:
|
||||
required: false
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions & Discussion
|
||||
url: https://github.com/zhom/donutbrowser/discussions
|
||||
about: Ask questions or discuss ideas here instead of opening an issue.
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Feature Request
|
||||
description: Suggest a new feature
|
||||
labels: ["enhancement"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: description
|
||||
attributes:
|
||||
label: What do you want?
|
||||
placeholder: Describe the feature and why you need it.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: use-case
|
||||
attributes:
|
||||
label: Use case
|
||||
placeholder: How would you use this feature? What problem does it solve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: How important is this to you?
|
||||
options:
|
||||
- Nice to have
|
||||
- Would improve my workflow
|
||||
- Critical for my use case
|
||||
validations:
|
||||
required: true
|
||||
@@ -1,54 +1,20 @@
|
||||
# ✨ Pull Request
|
||||
## Which issue does this PR fix?
|
||||
|
||||
## 📓 Referenced Issue
|
||||
<!-- Link the issue. #123 -->
|
||||
|
||||
<!-- Please link the related issue. Use # before the issue number and use the verbs 'fixes', 'resolves' to auto-link it, for eg, Fixes: #<issue-number> -->
|
||||
## How to test
|
||||
|
||||
## ℹ️ About the PR
|
||||
<!-- Steps for the reviewer to verify your changes work -->
|
||||
|
||||
<!-- Please provide a description of your solution if it is not clear in the related issue or if the PR has a breaking change. If there is an interesting topic to discuss or you have questions or there is an issue with Tauri, Rust, or another library that you have used. -->
|
||||
## Checklist
|
||||
|
||||
## 🔄 Type of Change
|
||||
- [ ] Read [CONTRIBUTING.md](https://github.com/zhom/donutbrowser/blob/main/CONTRIBUTING.md)
|
||||
- [ ] Ran `pnpm format && pnpm lint && pnpm test` locally and it passes
|
||||
- [ ] I tested the changes myself by running the app locally
|
||||
- [ ] Updated translations in all locale files (if UI text changed)
|
||||
|
||||
<!-- Mark the relevant option with an "x". -->
|
||||
## AI usage
|
||||
|
||||
- [ ] 🐛 Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] ✨ New feature (non-breaking change which adds functionality)
|
||||
- [ ] 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] 📚 Documentation update
|
||||
- [ ] 🧹 Code cleanup/refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
- [ ] I used AI to help write this PR
|
||||
|
||||
## 🖼️ Testing Scenarios / Screenshots
|
||||
|
||||
<!-- Please include screenshots or gif to showcase the final output. Also, try to explain the testing you did to validate your change. -->
|
||||
|
||||
## ✅ Checklist
|
||||
|
||||
<!-- Mark completed items with an "x". -->
|
||||
|
||||
- [ ] My code follows the style guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published
|
||||
|
||||
## 🧪 How Has This Been Tested?
|
||||
|
||||
<!-- Please describe the tests that you ran to verify your changes. -->
|
||||
|
||||
## 📱 Platform Testing
|
||||
|
||||
<!-- Which platforms have you tested on? -->
|
||||
|
||||
- [ ] macOS (Intel)
|
||||
- [ ] macOS (Apple Silicon)
|
||||
- [ ] Windows (if applicable)
|
||||
- [ ] Linux (if applicable)
|
||||
|
||||
## 📋 Additional Notes
|
||||
|
||||
<!-- Any additional information that reviewers should know about this PR. -->
|
||||
<!-- If you checked the box above, briefly explain how AI was used (e.g. "generated the test", "wrote the initial implementation", "full PR"). -->
|
||||
|
||||
@@ -27,12 +27,14 @@ jobs:
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
- language: rust
|
||||
build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -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:
|
||||
@@ -21,7 +22,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
||||
env:
|
||||
|
||||
@@ -12,8 +12,8 @@ permissions:
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
if: github.repository == 'zhom/donutbrowser' && github.actor == 'dependabot[bot]'
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -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,13 +63,13 @@ 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:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@21025c705c08248db411dc16f3619e6b5f9ea21a #v2.5.0
|
||||
uses: dependabot/fetch-metadata@ffa630c65fa7e0ecfa0625b5ceda64399aea1b36 #v3.0.0
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Enable auto-merge for minor and patch updates
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
name: Build and Push donut-sync Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "donut-sync/**"
|
||||
workflow_call:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Docker tag (e.g., v1.0.0)"
|
||||
required: true
|
||||
type: string
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: "Docker tag (e.g., v1.0.0, latest)"
|
||||
required: true
|
||||
default: "latest"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
REGISTRY: docker.io
|
||||
IMAGE_NAME: donutbrowser/donut-sync
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd #v4.0.0
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 #v4.0.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Determine tags
|
||||
id: tags
|
||||
run: |
|
||||
TAGS=""
|
||||
INPUT_TAG="${{ inputs.tag }}"
|
||||
|
||||
if [ -n "$INPUT_TAG" ]; then
|
||||
# Called from release workflow or manual dispatch
|
||||
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${INPUT_TAG}"
|
||||
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest"
|
||||
elif [ "${{ github.event_name }}" = "push" ]; then
|
||||
# Push to main (nightly): tag with nightly and commit SHA
|
||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
||||
TAGS="${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly"
|
||||
TAGS="${TAGS},${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:nightly-${SHORT_SHA}"
|
||||
fi
|
||||
|
||||
echo "tags=${TAGS}" >> "$GITHUB_OUTPUT"
|
||||
echo "Tags: ${TAGS}"
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 #v7.0.0
|
||||
with:
|
||||
context: .
|
||||
file: ./donut-sync/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
platforms: linux/amd64,linux/arm64
|
||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@a6f7623b2e2401f485f1eead77ced45bd99b09b0 #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
|
||||
@@ -3,7 +3,7 @@ name: Issue & PR Automation
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
@@ -14,16 +14,15 @@ 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
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -31,9 +30,9 @@ jobs:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
ISSUE_AUTHOR: ${{ github.event.issue.user.login }}
|
||||
run: |
|
||||
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues" \
|
||||
--jq "map(select(.user.login == \"$ISSUE_AUTHOR\" and .number != ${{ github.event.issue.number }})) | length" \
|
||||
--paginate || echo "0")
|
||||
ISSUE_COUNT=$(gh api "/repos/${{ github.repository }}/issues?state=all&creator=$ISSUE_AUTHOR&per_page=100" \
|
||||
--jq "[.[] | select(.number != ${{ github.event.issue.number }}) ] | length" \
|
||||
|| echo "0")
|
||||
|
||||
if [ "$ISSUE_COUNT" = "0" ]; then
|
||||
echo "is_first_time=true" >> $GITHUB_OUTPUT
|
||||
@@ -41,38 +40,148 @@ jobs:
|
||||
echo "is_first_time=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Analyze issue
|
||||
uses: anomalyco/opencode/github@6c7d968c4423a0cd6c85099c9377a6066313fa0a #v1.2.20
|
||||
- name: Build repo context and find related files
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
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).
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
run: |
|
||||
# Read project guidelines (contains repo structure)
|
||||
cp CLAUDE.md /tmp/repo-context.txt
|
||||
|
||||
${{ 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!"' || '' }}
|
||||
printf '%s' "$ISSUE_TITLE" > /tmp/issue-title.txt
|
||||
printf '%s' "${ISSUE_BODY:-}" > /tmp/issue-body.txt
|
||||
|
||||
Analyze this issue and post a single concise comment. Format:
|
||||
# List all source files for the AI to pick from
|
||||
find . -type f \( -name "*.rs" -o -name "*.ts" -o -name "*.tsx" \) \
|
||||
! -path "*/node_modules/*" ! -path "*/target/*" ! -path "*/.next/*" ! -path "*/dist/*" \
|
||||
! -path "*/.git/*" ! -path "*/gen/*" ! -path "*/data/*" \
|
||||
| sed 's|^\./||' | sort > /tmp/all-source-files.txt
|
||||
|
||||
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.
|
||||
- name: Select relevant files with AI
|
||||
env:
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
run: |
|
||||
PAYLOAD=$(jq -n \
|
||||
--rawfile title /tmp/issue-title.txt \
|
||||
--rawfile body /tmp/issue-body.txt \
|
||||
--rawfile files /tmp/all-source-files.txt \
|
||||
'{
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: "You are a file selector for Donut Browser (Tauri + Next.js + Rust anti-detect browser). Given an issue and a list of source files, output ONLY the 10 most likely relevant file paths, one per line. No explanations, no numbering, just paths."
|
||||
},
|
||||
{
|
||||
role: "user",
|
||||
content: ("Issue: " + $title + "\n\n" + $body + "\n\nFiles:\n" + $files)
|
||||
}
|
||||
]
|
||||
}')
|
||||
|
||||
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.
|
||||
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")
|
||||
|
||||
jq -r '.choices[0].message.content // empty' <<< "$RESPONSE" > /tmp/selected-files.txt
|
||||
|
||||
# Read the selected files in full (skip binary files)
|
||||
echo "" > /tmp/file-contents.txt
|
||||
while IFS= read -r filepath; do
|
||||
filepath=$(echo "$filepath" | xargs)
|
||||
[ -z "$filepath" ] && continue
|
||||
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
|
||||
echo "=== $filepath ===" >> /tmp/file-contents.txt
|
||||
cat "$filepath" >> /tmp/file-contents.txt
|
||||
echo "" >> /tmp/file-contents.txt
|
||||
fi
|
||||
done < /tmp/selected-files.txt
|
||||
|
||||
# Cap total context at 100KB
|
||||
head -c 100000 /tmp/file-contents.txt > /tmp/file-context.txt
|
||||
|
||||
- name: Analyze issue with AI
|
||||
env:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 \
|
||||
--rawfile repo_context /tmp/repo-context.txt \
|
||||
--rawfile context /tmp/file-context.txt \
|
||||
'{
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: ("You are a triage bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nYou have access to relevant source files for context.\n\nAnalyze the issue and produce a single comment. Your job is to collect missing information needed to diagnose the issue, NOT to guess the cause.\n\nFormat:\n\n1. One sentence acknowledging the issue.\n2. **Missing information** - Ask specific questions about what is missing from the report. Focus on reproducing the issue. Do NOT speculate about root causes or mention internal code/files — you will almost certainly be wrong without logs. Instead, ask for:\n - Exact steps to reproduce (if not provided)\n - Expected vs actual behavior (if unclear)\n - Error messages or screenshots (if not provided)\n - OS and app version (if not provided)\n - For bug reports: if logs are needed, tell the user EXACTLY how to get them:\n - macOS app logs: `~/Library/Logs/Donut Browser/`\n - Linux app logs: `~/.local/share/DonutBrowser/logs/`\n - Windows app logs: `%APPDATA%\\DonutBrowser\\logs\\`\n - Sync server logs: `docker logs <container>` or check the server console\n - Provide a ready-to-run shell command when possible.\n - For self-hosted sync issues: check if the user is using the latest Docker image (`docker pull donutbrowser/donut-sync:latest`).\n - Only ask for information that is actually missing. If the issue is already detailed, just acknowledge it.\n3. Suggest a label: `Label: bug` or `Label: enhancement` on its own line.\n\nRules:\n- Do NOT include a \"Possible cause\" section. Do not speculate about what code might be causing the issue.\n- Be brief and focused on collecting actionable information from the reporter.\n- If the issue already has everything needed (steps to reproduce, logs, version, OS), just acknowledge it.\n- Never exceed 15 lines.")
|
||||
},
|
||||
{
|
||||
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 +
|
||||
"\n\nRelevant source files:\n" + $context
|
||||
)
|
||||
}
|
||||
]
|
||||
}')
|
||||
|
||||
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")
|
||||
|
||||
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: |
|
||||
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' && 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
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -90,32 +199,123 @@ jobs:
|
||||
echo "is_first_time=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Analyze PR
|
||||
uses: anomalyco/opencode/github@6c7d968c4423a0cd6c85099c9377a6066313fa0a #v1.2.20
|
||||
- name: Gather PR context
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
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 }}
|
||||
PR_NUMBER: ${{ github.event.pull_request.number }}
|
||||
run: |
|
||||
# Get changed files list
|
||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" \
|
||||
--jq '.[] | "- \(.filename) (\(.status)) +\(.additions)/-\(.deletions)"' \
|
||||
> /tmp/pr-files.txt
|
||||
|
||||
${{ steps.check-first-time.outputs.is_first_time == 'true' && 'This is a first-time contributor. Start your comment with: "Thanks for your first PR!"' || '' }}
|
||||
# Get the actual diff
|
||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER" \
|
||||
--header "Accept: application/vnd.github.diff" \
|
||||
> /tmp/pr-diff-full.txt 2>/dev/null || true
|
||||
head -c 20000 /tmp/pr-diff-full.txt > /tmp/pr-diff.txt
|
||||
|
||||
Review this PR and post a single concise comment. Format:
|
||||
# Get CONTRIBUTING.md and README.md for context
|
||||
cat CONTRIBUTING.md > /tmp/contributing.txt 2>/dev/null || echo "Not found" > /tmp/contributing.txt
|
||||
head -50 README.md > /tmp/readme.txt 2>/dev/null || echo "Not found" > /tmp/readme.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.
|
||||
# Read project guidelines (contains repo structure)
|
||||
cp CLAUDE.md /tmp/repo-context.txt
|
||||
|
||||
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.
|
||||
# Read full contents of all changed files (skip binary)
|
||||
echo "" > /tmp/related-file-contents.txt
|
||||
gh api "/repos/$GITHUB_REPOSITORY/pulls/$PR_NUMBER/files" --jq '.[].filename' | while IFS= read -r filepath; do
|
||||
if [ -f "$filepath" ] && file --mime "$filepath" | grep -q "text/"; then
|
||||
echo "=== $filepath (full file) ===" >> /tmp/related-file-contents.txt
|
||||
cat "$filepath" >> /tmp/related-file-contents.txt
|
||||
echo "" >> /tmp/related-file-contents.txt
|
||||
fi
|
||||
done
|
||||
head -c 100000 /tmp/related-file-contents.txt > /tmp/pr-file-context.txt
|
||||
|
||||
- name: Analyze PR with AI
|
||||
env:
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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 diff /tmp/pr-diff.txt \
|
||||
--rawfile greeting /tmp/greeting.txt \
|
||||
--rawfile repo_context /tmp/repo-context.txt \
|
||||
--rawfile contributing /tmp/contributing.txt \
|
||||
--rawfile file_context /tmp/pr-file-context.txt \
|
||||
'{
|
||||
model: "anthropic/claude-opus-4.6",
|
||||
messages: [
|
||||
{
|
||||
role: "system",
|
||||
content: ("You are a code review bot for Donut Browser, an open-source anti-detect browser (Tauri desktop app: Rust backend + Next.js frontend).\n\nProject guidelines and structure:\n" + $repo_context + "\n\nContributing guidelines:\n" + $contributing + "\n\nYou have access to the full changed files and the diff. Use them to give a substantive review.\n\nReview this PR and produce a single comment. Format:\n\n1. One sentence summarizing what this PR does and whether the approach is sound.\n2. **Code review** - Specific observations about the actual code changes. Mention file names and what you see in the diff. Look for:\n - Bugs or logic errors in the changed code\n - Security issues (SQL injection, path traversal, XSS, command injection)\n - Missing error handling or edge cases\n - Breaking changes to existing APIs or behavior\n - If UI text was added/changed, check if all 7 translation files (en, es, fr, ja, pt, ru, zh) in src/i18n/locales/ were updated\n - If Tauri commands were added/removed, the unused-commands test in lib.rs needs updating\n3. **Suggestions** - Concrete improvements if any. Skip if the PR looks good.\n\nRules:\n- Be substantive. Review the actual diff, not just the description.\n- Do NOT nitpick formatting or style — the project has automated linting (biome + clippy + rustfmt).\n- Do NOT just summarize the PR description back to the user — they wrote it, they know what it says.\n- If the PR is good, say so briefly.\n- Never exceed 20 lines.")
|
||||
},
|
||||
{
|
||||
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 +
|
||||
"\n\nDiff:\n" + $diff +
|
||||
"\n\nFull file contents:\n" + $file_context
|
||||
)
|
||||
}
|
||||
]
|
||||
}')
|
||||
|
||||
RESPONSE=$(curl -fsSL https://openrouter.ai/api/v1/chat/completions \
|
||||
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "$PAYLOAD")
|
||||
|
||||
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') ||
|
||||
@@ -124,11 +324,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@6c7d968c4423a0cd6c85099c9377a6066313fa0a #v1.2.20
|
||||
uses: anomalyco/opencode/github@54443bfb7e090ec3130dc972e689a3e5cc55a7f9 #v1.3.3
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
model: zai-coding-plan/glm-4.7
|
||||
|
||||
@@ -34,10 +34,10 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -41,10 +41,10 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
scan-scheduled:
|
||||
name: Scheduled Security Scan
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
scan-pr:
|
||||
name: PR Security Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c5996e0193a3df57d695c1b8a1dec2a4c62e8730" # v2.3.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -13,11 +13,11 @@ 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
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
|
||||
+384
-90
@@ -18,8 +18,9 @@ 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
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -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
|
||||
@@ -99,10 +105,10 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -125,7 +131,7 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config xdg-utils
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
@@ -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 }}
|
||||
@@ -219,101 +225,389 @@ jobs:
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
- name: Create portable Windows ZIP
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: bash
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
PORTABLE_DIR="Donut-Portable"
|
||||
mkdir -p "$PORTABLE_DIR"
|
||||
|
||||
# Copy main executable
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/Donut.exe" "$PORTABLE_DIR/"
|
||||
|
||||
# Copy sidecar binaries
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
|
||||
|
||||
# Copy WebView2Loader if present
|
||||
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
|
||||
fi
|
||||
|
||||
# Create .portable marker
|
||||
touch "$PORTABLE_DIR/.portable"
|
||||
|
||||
# Create ZIP
|
||||
7z a "Donut_${VERSION}_x64-portable.zip" "$PORTABLE_DIR"
|
||||
|
||||
- name: Upload portable ZIP to release
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
gh release upload "$TAG" "Donut_${VERSION}_x64-portable.zip" --clobber
|
||||
|
||||
- name: Clean up Apple certificate
|
||||
if: matrix.platform == 'macos-latest' && always()
|
||||
run: |
|
||||
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
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Download Linux packages from release
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
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: Update README download links
|
||||
env:
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
BASE="https://github.com/zhom/donutbrowser/releases/download/${TAG}"
|
||||
|
||||
# Generate the new install section between markers
|
||||
cat > /tmp/install-links.md << LINKS
|
||||
### macOS
|
||||
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](${BASE}/Donut_${VERSION}_aarch64.dmg) | [Download](${BASE}/Donut_${VERSION}_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
\`\`\`bash
|
||||
brew install --cask donut
|
||||
\`\`\`
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](${BASE}/Donut_${VERSION}_x64-setup.exe) · [Portable (x64)](${BASE}/Donut_${VERSION}_x64-portable.zip)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](${BASE}/Donut_${VERSION}_amd64.deb) | [Download](${BASE}/Donut_${VERSION}_arm64.deb) |
|
||||
| **rpm** | [Download](${BASE}/Donut-${VERSION}-1.x86_64.rpm) | [Download](${BASE}/Donut-${VERSION}-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](${BASE}/Donut_${VERSION}_amd64.AppImage) | [Download](${BASE}/Donut_${VERSION}_aarch64.AppImage) |
|
||||
LINKS
|
||||
|
||||
# Strip leading whitespace from heredoc
|
||||
sed -i 's/^ //' /tmp/install-links.md
|
||||
|
||||
# Replace content between markers in README
|
||||
sed -i '/<!-- install-links-start -->/,/<!-- install-links-end -->/{
|
||||
/<!-- install-links-start -->/{
|
||||
p
|
||||
r /tmp/install-links.md
|
||||
}
|
||||
/<!-- install-links-end -->/!d
|
||||
}' README.md
|
||||
|
||||
- name: Create release docs PR
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
BRANCH="docs/release-${VERSION}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$BRANCH"
|
||||
git add CHANGELOG.md README.md
|
||||
if git diff --cached --quiet; then
|
||||
echo "No changes to commit"
|
||||
else
|
||||
git commit -m "docs: update CHANGELOG.md and README.md for ${TAG} [skip ci]"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--title "docs: release notes for ${TAG}" \
|
||||
--body "Automated update of CHANGELOG.md and README.md download links for ${TAG}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
gh pr merge "$BRANCH" --squash --admin
|
||||
fi
|
||||
|
||||
- name: Update release notes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
gh release edit "$TAG" --notes-file /tmp/release-changelog.md
|
||||
|
||||
notify-discord:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release, changelog]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate changelog summary
|
||||
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
|
||||
|
||||
strip_prefix() { echo "$1" | sed -E 's/^[a-z]+(\([^)]*\))?: //'; }
|
||||
|
||||
CHANGES=""
|
||||
while IFS= read -r msg; do
|
||||
[ -z "$msg" ] && continue
|
||||
case "$msg" in
|
||||
feat\(*\):*|feat:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
|
||||
fix\(*\):*|fix:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
|
||||
refactor\(*\):*|refactor:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
|
||||
perf\(*\):*|perf:*) CHANGES="${CHANGES}• $(strip_prefix "$msg")\n" ;;
|
||||
esac
|
||||
done < <(git log --pretty=format:"%s" "${PREV_TAG}..${TAG}" --no-merges)
|
||||
|
||||
# Truncate to fit Discord embed (max 4096 chars)
|
||||
if [ ${#CHANGES} -gt 3900 ]; then
|
||||
CHANGES="${CHANGES:0:3900}\n..."
|
||||
fi
|
||||
|
||||
if [ -z "$CHANGES" ]; then
|
||||
CHANGES="See the full changelog on GitHub."
|
||||
fi
|
||||
|
||||
printf '%s' "$CHANGES" > /tmp/discord-changes.txt
|
||||
|
||||
- name: Send Discord notification
|
||||
env:
|
||||
DISCORD_WEBHOOK_URL: ${{ secrets.DISCORD_STABLE_WEBHOOK_URL }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG}"
|
||||
RELEASE_URL="https://github.com/${GITHUB_REPOSITORY}/releases/tag/${VERSION}"
|
||||
CHANGES=$(cat /tmp/discord-changes.txt)
|
||||
|
||||
# Build JSON with jq to handle escaping
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "Donut Browser ${VERSION} Released" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg changes "$CHANGES" \
|
||||
--arg dl_mac_arm "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_aarch64.dmg" \
|
||||
--arg dl_mac_intel "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_x64.dmg" \
|
||||
--arg dl_win "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_x64-setup.exe" \
|
||||
--arg dl_linux "https://github.com/'"${GITHUB_REPOSITORY}"'/releases/download/'"${VERSION}"'/Donut_'"${VERSION#v}"'_amd64.AppImage" \
|
||||
'{
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
description: $changes,
|
||||
color: 5814783,
|
||||
fields: [
|
||||
{ name: "Download", value: ("[macOS (Apple Silicon)](" + $dl_mac_arm + ") · [macOS (Intel)](" + $dl_mac_intel + ")\n[Windows x64](" + $dl_win + ") · [Linux x64](" + $dl_linux + ")"), inline: false }
|
||||
],
|
||||
footer: { text: "donutbrowser.com" }
|
||||
}]
|
||||
}')
|
||||
|
||||
curl -fsSL -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"
|
||||
|
||||
deploy-website:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Cloudflare Pages deployment
|
||||
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
|
||||
|
||||
docker:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
uses: ./.github/workflows/docker-sync.yml
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
secrets: inherit
|
||||
|
||||
update-flake:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Compute AppImage hashes
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
TAG: ${{ github.ref_name }}
|
||||
run: |
|
||||
VERSION="${TAG#v}"
|
||||
echo "VERSION=${VERSION}" >> "$GITHUB_ENV"
|
||||
|
||||
AMD64_URL="https://github.com/zhom/donutbrowser/releases/download/${TAG}/Donut_${VERSION}_amd64.AppImage"
|
||||
AARCH64_URL="https://github.com/zhom/donutbrowser/releases/download/${TAG}/Donut_${VERSION}_aarch64.AppImage"
|
||||
|
||||
echo "Downloading x86_64 AppImage..."
|
||||
curl -fsSL -o /tmp/amd64.AppImage "$AMD64_URL" || { echo "x86_64 AppImage not found"; exit 1; }
|
||||
|
||||
echo "Downloading aarch64 AppImage..."
|
||||
curl -fsSL -o /tmp/aarch64.AppImage "$AARCH64_URL" || { echo "aarch64 AppImage not found"; exit 1; }
|
||||
|
||||
# Compute SRI hashes (sha256-<base64>)
|
||||
AMD64_HASH="sha256-$(sha256sum /tmp/amd64.AppImage | awk '{print $1}' | xxd -r -p | base64 | tr -d '\n')"
|
||||
AARCH64_HASH="sha256-$(sha256sum /tmp/aarch64.AppImage | awk '{print $1}' | xxd -r -p | base64 | tr -d '\n')"
|
||||
|
||||
echo "AMD64_HASH=${AMD64_HASH}" >> "$GITHUB_ENV"
|
||||
echo "AARCH64_HASH=${AARCH64_HASH}" >> "$GITHUB_ENV"
|
||||
echo "AMD64_URL=${AMD64_URL}" >> "$GITHUB_ENV"
|
||||
echo "AARCH64_URL=${AARCH64_URL}" >> "$GITHUB_ENV"
|
||||
|
||||
echo "x86_64 hash: ${AMD64_HASH}"
|
||||
echo "aarch64 hash: ${AARCH64_HASH}"
|
||||
|
||||
- name: Update flake.nix
|
||||
run: |
|
||||
# Update releaseVersion
|
||||
sed -i "s/releaseVersion = \"[^\"]*\"/releaseVersion = \"${VERSION}\"/" flake.nix
|
||||
|
||||
# Update x86_64 URL and hash
|
||||
sed -i "s|url = \"https://github.com/zhom/donutbrowser/releases/download/v[^\"]*_amd64.AppImage\"|url = \"${AMD64_URL}\"|" flake.nix
|
||||
sed -i "/amd64.AppImage/{ n; s|hash = \"[^\"]*\"|hash = \"${AMD64_HASH}\"|; }" flake.nix
|
||||
|
||||
# Update aarch64 URL and hash
|
||||
sed -i "s|url = \"https://github.com/zhom/donutbrowser/releases/download/v[^\"]*_aarch64.AppImage\"|url = \"${AARCH64_URL}\"|" flake.nix
|
||||
sed -i "/aarch64.AppImage/{ n; s|hash = \"[^\"]*\"|hash = \"${AARCH64_HASH}\"|; }" flake.nix
|
||||
|
||||
echo "Updated flake.nix:"
|
||||
grep -n "releaseVersion\|AppImage\|hash = " flake.nix
|
||||
|
||||
- name: Create pull request
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
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/
|
||||
|
||||
- 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: Configure AWS CLI for Cloudflare R2
|
||||
run: |
|
||||
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||
aws configure set default.region auto
|
||||
|
||||
- name: Sync existing repo metadata from R2
|
||||
env:
|
||||
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
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
|
||||
|
||||
- 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:
|
||||
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:
|
||||
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}"
|
||||
BRANCH="chore/update-flake-${VERSION}"
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git checkout -b "$BRANCH"
|
||||
git add flake.nix
|
||||
if git diff --cached --quiet; then
|
||||
echo "No flake changes needed"
|
||||
exit 0
|
||||
fi
|
||||
git commit -m "chore: update flake.nix for v${VERSION} [skip ci]"
|
||||
git push origin "$BRANCH"
|
||||
gh pr create \
|
||||
--title "chore: update flake.nix for v${VERSION}" \
|
||||
--body "Automated update of flake.nix with new AppImage hashes for v${VERSION}." \
|
||||
--base main \
|
||||
--head "$BRANCH"
|
||||
gh pr merge "$BRANCH" --squash --admin
|
||||
|
||||
@@ -17,8 +17,9 @@ 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
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@c51854704019a247608d928f370c98740469d4b5" # v2.3.5
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -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
|
||||
@@ -98,10 +104,10 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -124,7 +130,7 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev libxdo-dev pkg-config xdg-utils
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
@@ -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 }}"
|
||||
@@ -229,6 +235,34 @@ jobs:
|
||||
prerelease: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
- name: Create portable Windows ZIP
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: bash
|
||||
run: |
|
||||
PORTABLE_DIR="Donut-Portable"
|
||||
mkdir -p "$PORTABLE_DIR"
|
||||
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/Donut.exe" "$PORTABLE_DIR/"
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-proxy.exe" "$PORTABLE_DIR/"
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/donut-daemon.exe" "$PORTABLE_DIR/"
|
||||
|
||||
if [ -f "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" ]; then
|
||||
cp "src-tauri/target/${{ matrix.target }}/release/WebView2Loader.dll" "$PORTABLE_DIR/"
|
||||
fi
|
||||
|
||||
touch "$PORTABLE_DIR/.portable"
|
||||
|
||||
7z a "Donut_x64-portable.zip" "$PORTABLE_DIR"
|
||||
|
||||
- name: Upload portable ZIP to release
|
||||
if: matrix.platform == 'windows-latest'
|
||||
shell: bash
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
NIGHTLY_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
run: |
|
||||
gh release upload "$NIGHTLY_TAG" "Donut_x64-portable.zip" --clobber
|
||||
|
||||
- name: Clean up Apple certificate
|
||||
if: matrix.platform == 'macos-latest' && always()
|
||||
run: |
|
||||
@@ -236,12 +270,13 @@ 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:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
|
||||
- name: Generate nightly tag
|
||||
id: tag
|
||||
@@ -250,6 +285,57 @@ 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"
|
||||
true
|
||||
} >> /tmp/nightly-notes.md
|
||||
|
||||
- name: Update rolling nightly release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
@@ -284,5 +370,46 @@ 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
|
||||
|
||||
deploy-website:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
needs: [update-nightly-release]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Trigger Cloudflare Pages deployment
|
||||
run: curl -fsSL -X POST "${{ secrets.CLOUDFLARE_WEB_DEPLOYMENT_HOOK }}"
|
||||
|
||||
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"
|
||||
COMMIT_URL="https://github.com/${GITHUB_REPOSITORY}/commit/${GITHUB_SHA}"
|
||||
|
||||
PAYLOAD=$(jq -n \
|
||||
--arg title "Donut Browser Nightly (${COMMIT_SHORT})" \
|
||||
--arg url "$RELEASE_URL" \
|
||||
--arg commit_url "$COMMIT_URL" \
|
||||
--arg commit_short "$COMMIT_SHORT" \
|
||||
'{
|
||||
embeds: [{
|
||||
title: $title,
|
||||
url: $url,
|
||||
color: 16752128,
|
||||
fields: [
|
||||
{ name: "Commit", value: ("[" + $commit_short + "](" + $commit_url + ")"), inline: true },
|
||||
{ name: "Download", value: ("[Nightly Release](" + $url + ")"), inline: true }
|
||||
],
|
||||
footer: { text: "donutbrowser.com" }
|
||||
}]
|
||||
}')
|
||||
|
||||
curl -fsSL -H "Content-Type: application/json" -d "$PAYLOAD" "$DISCORD_WEBHOOK_URL"
|
||||
|
||||
@@ -21,6 +21,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd #v6.0.2
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d #v1.44.0
|
||||
|
||||
@@ -6,6 +6,7 @@ on:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
if: github.repository == 'zhom/donutbrowser'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
@@ -15,7 +16,9 @@ jobs:
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: "This issue has been inactive for 60 days. Please respond to keep it open."
|
||||
stale-pr-message: "This pull request has been inactive for 60 days. Please respond to keep it open."
|
||||
stale-issue-message: "This issue has been inactive for 30 days. Please respond to keep it open."
|
||||
stale-pr-message: "This pull request has been inactive for 30 days. Please respond to keep it open."
|
||||
stale-issue-label: "stale"
|
||||
stale-pr-label: "stale"
|
||||
days-before-stale: 30
|
||||
days-before-close: 7
|
||||
|
||||
@@ -32,10 +32,10 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
@@ -51,7 +51,7 @@ jobs:
|
||||
toolchain: stable
|
||||
|
||||
- name: Cache Rust dependencies
|
||||
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
|
||||
uses: swatinem/rust-cache@c19371144df3bb44fab255c43d04cbc2ab54d1c4 #v2.9.1
|
||||
with:
|
||||
workspaces: "src-tauri"
|
||||
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v6.0.2
|
||||
|
||||
- name: Start MinIO
|
||||
run: |
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
done
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
uses: pnpm/action-setup@fc06bc1257f339d1d5d8b3a19a8cae5388b55320 #v4.4.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
|
||||
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
+73
@@ -10,11 +10,15 @@
|
||||
"appindicator",
|
||||
"applescript",
|
||||
"asyncio",
|
||||
"autocheckpoint",
|
||||
"autoconfig",
|
||||
"autologin",
|
||||
"bintools",
|
||||
"biomejs",
|
||||
"boringtun",
|
||||
"breezedark",
|
||||
"browserforge",
|
||||
"Buildx",
|
||||
"busctl",
|
||||
"CAMOU",
|
||||
"camoufox",
|
||||
@@ -33,6 +37,7 @@
|
||||
"codesign",
|
||||
"codesigning",
|
||||
"commitish",
|
||||
"coreutils",
|
||||
"Crashpad",
|
||||
"CTYPE",
|
||||
"daijro",
|
||||
@@ -40,49 +45,68 @@
|
||||
"datareporting",
|
||||
"datas",
|
||||
"DBAPI",
|
||||
"dbus",
|
||||
"dconf",
|
||||
"debuginfo",
|
||||
"desynced",
|
||||
"devedition",
|
||||
"direnv",
|
||||
"diskutil",
|
||||
"distro",
|
||||
"dists",
|
||||
"DMABUF",
|
||||
"DOCKERHUB",
|
||||
"doctest",
|
||||
"doesn",
|
||||
"domcontentloaded",
|
||||
"dont",
|
||||
"donutbrowser",
|
||||
"doorhanger",
|
||||
"dpkg",
|
||||
"dtolnay",
|
||||
"dyld",
|
||||
"elif",
|
||||
"erasevolume",
|
||||
"errorlevel",
|
||||
"esac",
|
||||
"esbuild",
|
||||
"etree",
|
||||
"fetchurl",
|
||||
"findutils",
|
||||
"firstrun",
|
||||
"flate",
|
||||
"fontconfig",
|
||||
"freetype",
|
||||
"fribidi",
|
||||
"frontmost",
|
||||
"fsprogs",
|
||||
"geoip",
|
||||
"getcwd",
|
||||
"gettimezone",
|
||||
"gifs",
|
||||
"globset",
|
||||
"gnugrep",
|
||||
"gnumake",
|
||||
"gnused",
|
||||
"GOPATH",
|
||||
"gsettings",
|
||||
"harfbuzz",
|
||||
"healthreport",
|
||||
"hiddenimports",
|
||||
"hkcu",
|
||||
"hooksconfig",
|
||||
"hookspath",
|
||||
"hostable",
|
||||
"Hoverable",
|
||||
"icns",
|
||||
"idlelib",
|
||||
"idletime",
|
||||
"idna",
|
||||
"imdisk",
|
||||
"infobars",
|
||||
"inkey",
|
||||
"Inno",
|
||||
"isps",
|
||||
"kdeglobals",
|
||||
"keras",
|
||||
"KHTML",
|
||||
@@ -92,18 +116,39 @@
|
||||
"langpack",
|
||||
"launchservices",
|
||||
"letterboxing",
|
||||
"leveldb",
|
||||
"libappindicator",
|
||||
"libatk",
|
||||
"libayatana",
|
||||
"libc",
|
||||
"libcairo",
|
||||
"libdrm",
|
||||
"libfuse",
|
||||
"libgbm",
|
||||
"libgdk",
|
||||
"libglib",
|
||||
"libglvnd",
|
||||
"libgpg",
|
||||
"libpango",
|
||||
"librsvg",
|
||||
"libsoup",
|
||||
"libwebkit",
|
||||
"libx",
|
||||
"libxcb",
|
||||
"libxcomposite",
|
||||
"libxcursor",
|
||||
"libxdamage",
|
||||
"libxdo",
|
||||
"libxext",
|
||||
"libxfixes",
|
||||
"libxi",
|
||||
"libxinerama",
|
||||
"libxkbcommon",
|
||||
"libxrandr",
|
||||
"libxrender",
|
||||
"libxscrnsaver",
|
||||
"libxshmfence",
|
||||
"libxtst",
|
||||
"localtime",
|
||||
"lpdw",
|
||||
"lxml",
|
||||
@@ -111,6 +156,7 @@
|
||||
"macchiato",
|
||||
"Matchalk",
|
||||
"maxminddb",
|
||||
"minidumps",
|
||||
"minioadmin",
|
||||
"mmdb",
|
||||
"mountpoint",
|
||||
@@ -120,24 +166,33 @@
|
||||
"msys",
|
||||
"muda",
|
||||
"mypy",
|
||||
"nixos",
|
||||
"nixpkgs",
|
||||
"noarchive",
|
||||
"nobrowse",
|
||||
"noconfirm",
|
||||
"nodecar",
|
||||
"NODELAY",
|
||||
"nodemon",
|
||||
"nomount",
|
||||
"norestart",
|
||||
"NSIS",
|
||||
"nspr",
|
||||
"ntfs",
|
||||
"ntlm",
|
||||
"numpy",
|
||||
"numtide",
|
||||
"objc",
|
||||
"oneshot",
|
||||
"opencode",
|
||||
"OPENROUTER",
|
||||
"orhun",
|
||||
"orjson",
|
||||
"osascript",
|
||||
"oscpu",
|
||||
"outpath",
|
||||
"OVPN",
|
||||
"pango",
|
||||
"passout",
|
||||
"patchelf",
|
||||
"pathex",
|
||||
@@ -145,12 +200,16 @@
|
||||
"peerconnection",
|
||||
"PHANDLER",
|
||||
"pids",
|
||||
"pipefail",
|
||||
"pixbuf",
|
||||
"pkexec",
|
||||
"pkgs",
|
||||
"pkill",
|
||||
"plasmohq",
|
||||
"platformdirs",
|
||||
"pname",
|
||||
"prefs",
|
||||
"presign",
|
||||
"PRIO",
|
||||
"propertylist",
|
||||
"psutil",
|
||||
@@ -163,15 +222,22 @@
|
||||
"pyyaml",
|
||||
"quic",
|
||||
"ralt",
|
||||
"ramdisk",
|
||||
"rawfile",
|
||||
"repodata",
|
||||
"repogen",
|
||||
"reportingpolicy",
|
||||
"reqwest",
|
||||
"resvg",
|
||||
"ridedott",
|
||||
"rlib",
|
||||
"rsplit",
|
||||
"rusqlite",
|
||||
"rustc",
|
||||
"rwxr",
|
||||
"safebrowsing",
|
||||
"SARIF",
|
||||
"sarifv",
|
||||
"scipy",
|
||||
"screeninfo",
|
||||
"selectables",
|
||||
@@ -188,11 +254,13 @@
|
||||
"signon",
|
||||
"signum",
|
||||
"sklearn",
|
||||
"smoltcp",
|
||||
"SMTO",
|
||||
"sonner",
|
||||
"splitn",
|
||||
"sspi",
|
||||
"staticlib",
|
||||
"stdenv",
|
||||
"stefanzweifel",
|
||||
"subdirs",
|
||||
"subkey",
|
||||
@@ -208,14 +276,19 @@
|
||||
"TERX",
|
||||
"testpass",
|
||||
"testuser",
|
||||
"thiserror",
|
||||
"timedatectl",
|
||||
"titlebar",
|
||||
"tkinter",
|
||||
"tmpfs",
|
||||
"tombstoned",
|
||||
"tqdm",
|
||||
"trackingprotection",
|
||||
"trailhead",
|
||||
"tungstenite",
|
||||
"turbopack",
|
||||
"turtledemo",
|
||||
"typer",
|
||||
"udeps",
|
||||
"unlisten",
|
||||
"unminimize",
|
||||
|
||||
@@ -1,9 +1,88 @@
|
||||
# Instructions for AI Agents
|
||||
# Project Guidelines
|
||||
|
||||
- After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test". It means that you first format the code, then lint it, then test it, so that no part is broken after your changes.
|
||||
- Don't leave comments that don't add value.
|
||||
- Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times.
|
||||
- Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
|
||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless I have explicitly specified in the request otherwise.
|
||||
- If you are modifying the UI, do not add random colors that are not controlled by src/lib/themes.ts file.
|
||||
> **NOTE**: CLAUDE.md is a symlink to AGENTS.md — editing either file updates both.
|
||||
> After significant changes (new modules, renamed files, new directories), re-evaluate the Repository Structure below and update it if needed.
|
||||
|
||||
## Repository Structure
|
||||
|
||||
```
|
||||
donutbrowser/
|
||||
├── src/ # Next.js frontend
|
||||
│ ├── app/ # App router (page.tsx, layout.tsx)
|
||||
│ ├── components/ # 50+ React components (dialogs, tables, UI)
|
||||
│ ├── hooks/ # Event-driven React hooks
|
||||
│ ├── i18n/locales/ # Translations (en, es, fr, ja, pt, ru, zh)
|
||||
│ ├── lib/ # Utilities (themes, toast, browser-utils)
|
||||
│ └── types.ts # Shared TypeScript interfaces
|
||||
├── src-tauri/ # Rust backend (Tauri)
|
||||
│ ├── src/
|
||||
│ │ ├── lib.rs # Tauri command registration (100+ commands)
|
||||
│ │ ├── browser_runner.rs # Profile launch/kill orchestration
|
||||
│ │ ├── browser.rs # Browser trait & launch logic
|
||||
│ │ ├── profile/ # Profile CRUD (manager.rs, types.rs)
|
||||
│ │ ├── proxy_manager.rs # Proxy lifecycle & connection testing
|
||||
│ │ ├── proxy_server.rs # Local proxy binary (donut-proxy)
|
||||
│ │ ├── proxy_storage.rs # Proxy config persistence (JSON files)
|
||||
│ │ ├── api_server.rs # REST API (utoipa + axum)
|
||||
│ │ ├── mcp_server.rs # MCP protocol server
|
||||
│ │ ├── sync/ # Cloud sync (engine, encryption, manifest, scheduler)
|
||||
│ │ ├── vpn/ # WireGuard & OpenVPN tunnels
|
||||
│ │ ├── camoufox/ # Camoufox fingerprint engine (Bayesian network)
|
||||
│ │ ├── wayfern_manager.rs # Wayfern (Chromium) browser management
|
||||
│ │ ├── camoufox_manager.rs # Camoufox (Firefox) browser management
|
||||
│ │ ├── downloader.rs # Browser binary downloader
|
||||
│ │ ├── extraction.rs # Archive extraction (zip, tar, dmg, msi)
|
||||
│ │ ├── settings_manager.rs # App settings persistence
|
||||
│ │ ├── cookie_manager.rs # Cookie import/export
|
||||
│ │ ├── extension_manager.rs # Browser extension management
|
||||
│ │ ├── group_manager.rs # Profile group management
|
||||
│ │ ├── synchronizer.rs # Real-time profile synchronizer
|
||||
│ │ ├── daemon/ # Background daemon + tray icon (currently disabled)
|
||||
│ │ └── cloud_auth.rs # Cloud authentication
|
||||
│ ├── tests/ # Integration tests
|
||||
│ └── Cargo.toml # Rust dependencies
|
||||
├── donut-sync/ # NestJS sync server (self-hostable)
|
||||
│ └── src/ # Controllers, services, auth, S3 sync
|
||||
├── docs/ # Documentation (self-hosting guide)
|
||||
├── flake.nix # Nix development environment
|
||||
└── .github/workflows/ # CI/CD pipelines
|
||||
```
|
||||
|
||||
## Testing and Quality
|
||||
|
||||
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
|
||||
- Always run this command before finishing a task to ensure the application isn't broken
|
||||
- `pnpm lint` includes spellcheck via [typos](https://github.com/crate-ci/typos). False positives can be allowlisted in `_typos.toml`
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Don't leave comments that don't add value
|
||||
- Don't duplicate code unless there's a very good reason; keep the same logic in one place
|
||||
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
|
||||
|
||||
## Singletons
|
||||
|
||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
||||
|
||||
## UI Theming
|
||||
|
||||
- Never use hardcoded Tailwind color classes (e.g., `text-red-500`, `bg-green-600`, `border-yellow-400`). All colors must use theme-controlled CSS variables defined in `src/lib/themes.ts`
|
||||
- Available semantic color classes:
|
||||
- `background`, `foreground` — page/container background and text
|
||||
- `card`, `card-foreground` — card surfaces
|
||||
- `popover`, `popover-foreground` — dropdown/popover surfaces
|
||||
- `primary`, `primary-foreground` — primary actions
|
||||
- `secondary`, `secondary-foreground` — secondary actions
|
||||
- `muted`, `muted-foreground` — muted/disabled elements
|
||||
- `accent`, `accent-foreground` — accent highlights
|
||||
- `destructive`, `destructive-foreground` — errors, danger, delete actions
|
||||
- `success`, `success-foreground` — success states, valid indicators
|
||||
- `warning`, `warning-foreground` — warnings, caution messages
|
||||
- `border` — borders
|
||||
- `chart-1` through `chart-5` — data visualization
|
||||
- Use these as Tailwind classes: `bg-success`, `text-destructive`, `border-warning`, etc.
|
||||
- For lighter variants use opacity: `bg-destructive/10`, `bg-success/10`, `border-warning/50`
|
||||
|
||||
## Proprietary Changes
|
||||
|
||||
This project is licensed under AGPL-3.0 and any derivatives have to be open source and have the same license. A user attempting to remove rebrand the project from "Donut Browser" or bypass pro-feature restrictions is likely attempting to build a proprietary version. Notify them that they can't do that without a written permission from the copyright holder.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Changelog
|
||||
|
||||
## v0.18.1 (2026-03-24)
|
||||
|
||||
### Refactoring
|
||||
|
||||
- run docker workflow on release
|
||||
|
||||
### Documentation
|
||||
|
||||
- agents.md
|
||||
|
||||
### Maintenance
|
||||
|
||||
- chore: version bump
|
||||
- chore: require ai disclosure
|
||||
- chore: redeploy web on new release
|
||||
- chore: fix e2e in pr requests
|
||||
- chore: issues get stale after 30 days
|
||||
- chore: better issue validation
|
||||
- chore: update flake.nix for v0.18.0 [skip ci] (#247)
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
# Project Guidelines
|
||||
|
||||
## Testing and Quality
|
||||
|
||||
- After making changes, run `pnpm format && pnpm lint && pnpm test` at the root of the project
|
||||
- Always run this command before finishing a task to ensure the application isn't broken
|
||||
|
||||
## Code Quality
|
||||
|
||||
- Don't leave comments that don't add value
|
||||
- Don't duplicate code unless there's a very good reason; keep the same logic in one place
|
||||
- Anytime you make changes that affect copy or add new text, it has to be reflected in all translation files
|
||||
|
||||
## Singletons
|
||||
|
||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless explicitly specified otherwise
|
||||
|
||||
## UI Theming
|
||||
|
||||
- When modifying the UI, don't add random colors that are not controlled by `src/lib/themes.ts`
|
||||
+62
-156
@@ -1,194 +1,100 @@
|
||||
# Contributing to Donut Browser
|
||||
|
||||
Contributions are welcome and always appreciated! 🍩
|
||||
|
||||
To begin working on an issue, simply leave a comment indicating that you're taking it on. There's no need to be officially assigned to the issue before you start.
|
||||
Contributions are welcome! To start working on an issue, leave a comment indicating you're taking it on.
|
||||
|
||||
## Before Starting
|
||||
|
||||
Do keep in mind before you start working on an issue / posting a PR:
|
||||
|
||||
- Search existing PRs related to that issue which might close them
|
||||
- Confirm if other contributors are working on the same issue
|
||||
- Check if the feature aligns with the project's roadmap and goals
|
||||
- Search existing PRs related to that issue
|
||||
- Confirm no other contributors are working on the same issue
|
||||
- Check if the feature aligns with the project's goals
|
||||
|
||||
## Contributor License Agreement
|
||||
|
||||
By contributing to Donut Browser, you agree that your contributions will be licensed under the same terms as the project. You must agree to the [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md) before your contributions can be accepted. This agreement ensures that:
|
||||
|
||||
- Your contributions can be used in the open source version of Donut Browser (licensed under AGPL-3.0)
|
||||
- Donut Browser can offer commercial licenses for the software, including your contributions
|
||||
- You retain all rights to use your contributions for any other purpose
|
||||
|
||||
When you submit your first pull request, you acknowledge that you agree to the terms of the Contributor License Agreement.
|
||||
|
||||
## Tips & Things to Consider
|
||||
|
||||
- PRs with tests are highly appreciated
|
||||
- Avoid adding third party libraries, whenever possible
|
||||
- Unless you are helping out by updating dependencies, you should not be uploading your lock files or updating any dependencies in your PR
|
||||
- If you are unsure where to start, open a discussion to get pointed to a good first issue
|
||||
By contributing, you agree your contributions will be licensed under the same terms as the project. See [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md). This ensures contributions can be used in the open source version (AGPL-3.0) and commercially licensed. You retain all rights to use your contributions elsewhere.
|
||||
|
||||
## Development Setup
|
||||
|
||||
### Using Nix
|
||||
|
||||
If you have [Nix](https://nixos.org/) installed, you can skip the manual setup below and simply run:
|
||||
### Using Nix (recommended)
|
||||
|
||||
```bash
|
||||
nix develop
|
||||
# or if you use direnv
|
||||
direnv allow
|
||||
nix run .#setup # Install dependencies
|
||||
nix run .#tauri-dev # Start development server
|
||||
nix run .#test # Run all checks
|
||||
```
|
||||
|
||||
This will provide Node.js, Rust, and all necessary system libraries.
|
||||
Or enter the dev shell: `nix develop`
|
||||
|
||||
### Manual Setup
|
||||
|
||||
Ensure you have the following dependencies installed:
|
||||
Requirements:
|
||||
|
||||
- Node.js (see `.node-version` for exact version)
|
||||
- pnpm package manager
|
||||
- Latest Rust and Cargo toolchain
|
||||
- [Tauri prerequisites guide](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
## Run Locally
|
||||
|
||||
After having the above dependencies installed, proceed through the following steps to setup the codebase locally:
|
||||
|
||||
1. **Fork the project** & [clone](https://docs.github.com/en/repositories/creating-and-managing-repositories/cloning-a-repository) it locally.
|
||||
|
||||
2. **Create a new separate branch.**
|
||||
|
||||
```bash
|
||||
git checkout -b feature/my-feature-name
|
||||
```
|
||||
|
||||
3. **Install frontend dependencies**
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Start the development server**
|
||||
|
||||
```bash
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
This will start the app for local development with live reloading.
|
||||
|
||||
## Code Style & Quality
|
||||
|
||||
The project uses several tools to maintain code quality:
|
||||
|
||||
- **Biome** for JavaScript/TypeScript linting and formatting
|
||||
- **Clippy** for Rust linting
|
||||
- **rustfmt** for Rust formatting
|
||||
|
||||
### Before Committing
|
||||
|
||||
Run these commands to ensure your code meets the project's standards:
|
||||
- Node.js (see `.node-version`)
|
||||
- pnpm
|
||||
- Rust + Cargo (latest stable)
|
||||
- [Tauri v2 prerequisites](https://v2.tauri.app/start/prerequisites/)
|
||||
|
||||
```bash
|
||||
# Format and lint frontend code
|
||||
pnpm format:js
|
||||
|
||||
# Format and lint Rust code
|
||||
pnpm format:rust
|
||||
|
||||
# Run all linting
|
||||
pnpm lint
|
||||
git checkout -b feature/my-feature-name
|
||||
pnpm install
|
||||
pnpm tauri dev
|
||||
```
|
||||
|
||||
## Building
|
||||
## Quality Checks
|
||||
|
||||
It is crucial to test your code before submitting a pull request. Please ensure that you can make a complete production build before you submit your code for merging.
|
||||
Run before every commit:
|
||||
|
||||
```bash
|
||||
# Build the frontend
|
||||
pnpm build
|
||||
|
||||
# Build the backend
|
||||
cd src-tauri && cargo build
|
||||
|
||||
# Build the Tauri application
|
||||
pnpm tauri build
|
||||
pnpm format && pnpm lint && pnpm test
|
||||
```
|
||||
|
||||
Make sure the build completes successfully without errors.
|
||||
This runs:
|
||||
|
||||
## Testing
|
||||
- **Biome** — JS/TS linting and formatting
|
||||
- **Clippy + rustfmt** — Rust linting and formatting
|
||||
- **typos** — Spellcheck (allowlist in `_typos.toml`)
|
||||
- **CodeQL** — Security analysis (JS, Actions, Rust) — runs in CI
|
||||
- **Unit tests** — 330+ Rust tests
|
||||
- **Integration tests** — proxy, sync e2e
|
||||
|
||||
- Always test your changes on the target platform
|
||||
- Verify that existing functionality still works
|
||||
- Add tests for new features when possible
|
||||
### Running CodeQL locally
|
||||
|
||||
```bash
|
||||
# Install: brew install codeql
|
||||
codeql pack download codeql/javascript-queries codeql/rust-queries
|
||||
|
||||
# JavaScript
|
||||
codeql database create /tmp/codeql-js --language=javascript --source-root=.
|
||||
codeql database analyze /tmp/codeql-js --format=sarifv2.1.0 --output=/tmp/js.sarif codeql/javascript-queries
|
||||
|
||||
# Rust
|
||||
codeql database create /tmp/codeql-rust --language=rust --source-root=.
|
||||
codeql database analyze /tmp/codeql-rust --format=sarifv2.1.0 --output=/tmp/rust.sarif codeql/rust-queries
|
||||
```
|
||||
|
||||
## Key Rules
|
||||
|
||||
- **Translations**: Any UI text changes must be reflected in all 7 locale files (`src/i18n/locales/`)
|
||||
- **Tauri commands**: If you modify Tauri commands, the `test_no_unused_tauri_commands` test will catch unused ones
|
||||
- **No hardcoded colors**: Use theme CSS variables (see `src/lib/themes.ts`), never Tailwind color classes like `text-red-500`
|
||||
- **No lock file changes**: Don't update `pnpm-lock.yaml` or `Cargo.lock` unless updating dependencies is the purpose of the PR
|
||||
- **AGPL-3.0**: This project is AGPL-licensed. Derivatives must be open source with the same license
|
||||
|
||||
## Pull Request Guidelines
|
||||
|
||||
🎉 Now that you're ready to submit your code for merging, there are some points to keep in mind:
|
||||
- Fill the PR description template
|
||||
- Reference related issues (`Fixes #123` or `Refs #123`)
|
||||
- Include screenshots/videos for UI changes
|
||||
- Ensure "Allow edits from maintainers" is checked
|
||||
|
||||
### PR Description
|
||||
## Architecture
|
||||
|
||||
- Fill your PR description template accordingly
|
||||
- Have an appropriate title and description
|
||||
- Include relevant screenshots for UI changes. If you can include video/gifs, it is even better.
|
||||
- Reference related issues
|
||||
|
||||
### Linking Issues
|
||||
|
||||
If your PR fixes an issue, add this line **in the body** of the Pull Request description:
|
||||
|
||||
```text
|
||||
Fixes #00000
|
||||
```
|
||||
|
||||
If your PR is referencing an issue:
|
||||
|
||||
```text
|
||||
Refs #00000
|
||||
```
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] Code follows the project's style guidelines
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published
|
||||
|
||||
### Options
|
||||
|
||||
- Ensure that "Allow edits from maintainers" option is checked
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Donut Browser is built with:
|
||||
|
||||
- **Frontend**: Next.js React application
|
||||
- **Backend**: Tauri (Rust) for native functionality
|
||||
- **Node.js Sidecar**: `nodecar` binary for access to JavaScript ecosystem
|
||||
- **Build System**: GitHub Actions for CI/CD
|
||||
|
||||
Understanding this architecture will help you contribute more effectively.
|
||||
- **Frontend**: Next.js (React) — `src/`
|
||||
- **Backend**: Tauri (Rust) — `src-tauri/src/`
|
||||
- **Proxy Worker**: Detached process for proxy tunneling — `src-tauri/src/bin/proxy_server.rs`
|
||||
- **Sync**: Cloud sync via S3-compatible storage — `src-tauri/src/sync/`, `donut-sync/`
|
||||
- **Browsers**: Camoufox (Firefox-based) and Wayfern (Chromium-based)
|
||||
|
||||
## Getting Help
|
||||
|
||||
- **Issues**: Use for bug reports and feature requests
|
||||
- **Discussions**: Use for questions and general discussion
|
||||
- **Pull Requests**: Use for code contributions
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please note that this project is released with a [Contributor Code of Conduct](CODE_OF_CONDUCT.md). By participating in this project you agree to abide by its terms.
|
||||
|
||||
## Recognition
|
||||
|
||||
All contributors will be recognized! The project uses the all-contributors specification to acknowledge everyone who contributes.
|
||||
|
||||
---
|
||||
|
||||
Thank you for contributing to Donut Browser! 🍩✨
|
||||
- **Issues**: Bug reports and feature requests
|
||||
- **Discussions**: Questions and general discussion
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<div align="center">
|
||||
<img src="assets/logo.png" alt="Donut Browser Logo" width="150">
|
||||
<h1>Donut Browser</h1>
|
||||
<strong>A powerful anti-detect browser that puts you in control of your browsing experience. 🍩</strong>
|
||||
<strong>Open Source Anti-Detect Browser</strong>
|
||||
<br>
|
||||
<a href="https://donutbrowser.com">donutbrowser.com</a>
|
||||
</div>
|
||||
<br>
|
||||
|
||||
@@ -15,13 +17,16 @@
|
||||
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
|
||||
</a>
|
||||
<a href="https://app.codacy.com/gh/zhom/donutbrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade">
|
||||
<img src="https://app.codacy.com/project/badge/Grade/b9c9beafc92d4bc8bc7c5b42c6c4ba81"/>
|
||||
<img src="https://app.codacy.com/project/badge/Grade/b9c9beafc92d4bc8bc7c5b42c6c4ba81" alt="Codacy Grade"/>
|
||||
</a>
|
||||
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
|
||||
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security"/>
|
||||
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security" alt="FOSSA Security Status"/>
|
||||
</a>
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/stargazers" target="_blank">
|
||||
<img src="https://img.shields.io/github/stars/zhom/donutbrowser?style=social" alt="GitHub stars">
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/network/members" target="_blank">
|
||||
<img src="https://img.shields.io/github/forks/zhom/donutbrowser?style=social" alt="GitHub forks">
|
||||
</a>
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/releases" target="_blank">
|
||||
<img src="https://img.shields.io/github/downloads/zhom/donutbrowser/total" alt="Downloads">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
@@ -29,21 +34,55 @@
|
||||
|
||||
## Features
|
||||
|
||||
- Create unlimited number of local browser profiles completely isolated from each other
|
||||
- Safely use multiple accounts on one device by using anti-detect browser profiles, powered by [Camoufox](https://camoufox.com)
|
||||
- Proxy support with basic auth for all browsers
|
||||
- Import profiles from your existing browsers
|
||||
- Automatic updates for browsers
|
||||
- Set Donut Browser as your default browser to control in which profile to open links
|
||||
- **Unlimited browser profiles** — each fully isolated with its own fingerprint, cookies, extensions, and data
|
||||
- **Chromium & Firefox engines** — Chromium powered by [Wayfern](https://wayfern.com), Firefox powered by [Camoufox](https://camoufox.com), both with advanced fingerprint spoofing
|
||||
- **Proxy support** — HTTP, HTTPS, SOCKS4, SOCKS5 per profile, with dynamic proxy URLs
|
||||
- **VPN support** — WireGuard and OpenVPN configs per profile
|
||||
- **Local API & MCP** — REST API and [Model Context Protocol](https://modelcontextprotocol.io) server for integration with Claude, automation tools, and custom workflows
|
||||
- **Profile groups** — organize profiles and apply bulk settings
|
||||
- **Import profiles** — migrate from Chrome, Firefox, Edge, Brave, or other Chromium browsers
|
||||
- **Cookie & extension management** — import/export cookies, manage extensions per profile
|
||||
- **Default browser** — set Donut as your default browser and choose which profile opens each link
|
||||
- **Cloud sync** — sync profiles, proxies, and groups across devices (self-hostable)
|
||||
- **E2E encryption** — optional end-to-end encrypted sync with a password only you know
|
||||
- **Zero telemetry** — no tracking or device fingerprinting
|
||||
|
||||
## Download
|
||||
## Install
|
||||
|
||||
> For Linux, .deb and .rpm packages are available as well as standalone .AppImage files.
|
||||
<!-- install-links-start -->
|
||||
### macOS
|
||||
|
||||
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
|
||||
| | Apple Silicon | Intel |
|
||||
|---|---|---|
|
||||
| **DMG** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.dmg) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_x64.dmg) |
|
||||
|
||||
Or install via Homebrew:
|
||||
|
||||
```bash
|
||||
brew install --cask donut
|
||||
```
|
||||
|
||||
### Windows
|
||||
|
||||
[Download Windows Installer (x64)](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_x64-setup.exe)
|
||||
|
||||
### Linux
|
||||
|
||||
| Format | x86_64 | ARM64 |
|
||||
|---|---|---|
|
||||
| **deb** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.deb) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_arm64.deb) |
|
||||
| **rpm** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut-0.18.1-1.x86_64.rpm) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut-0.18.1-1.aarch64.rpm) |
|
||||
| **AppImage** | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.AppImage) | [Download](https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.AppImage) |
|
||||
<!-- install-links-end -->
|
||||
|
||||
Or install via package manager:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://donutbrowser.com/install.sh | sh
|
||||
```
|
||||
|
||||
<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,40 +94,32 @@ If that gives an EGL display error, try adding `WEBKIT_DISABLE_DMABUF_RENDERER=1
|
||||
|
||||
</details>
|
||||
|
||||
<!-- ## Supported Platforms
|
||||
### Nix
|
||||
|
||||
- ✅ **macOS** (Apple Silicon)
|
||||
- ✅ **Linux** (x64)
|
||||
- ✅ **Windows** (x64) -->
|
||||
|
||||
## Development
|
||||
|
||||
### Contributing
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Issues
|
||||
|
||||
If you face any problems while using the application, please [open an issue](https://github.com/zhom/donutbrowser/issues).
|
||||
```bash
|
||||
nix run github:zhom/donutbrowser#release-start
|
||||
```
|
||||
|
||||
## Self-Hosting Sync
|
||||
|
||||
Donut Browser supports syncing profiles, proxies, and groups across devices via a self-hosted sync server. See the [Self-Hosting Guide](docs/self-hosting-donut-sync.md) for Docker-based setup instructions.
|
||||
|
||||
## Community
|
||||
## Development
|
||||
|
||||
Have questions or want to contribute? The team would love to hear from you!
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Community
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/#zhom/donutbrowser&Date">
|
||||
<a href="https://www.star-history.com/?repos=zhom%2Fdonutbrowser&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/image?repos=zhom/donutbrowser&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
@@ -112,6 +143,20 @@ 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/yb403">
|
||||
<img src="https://avatars.githubusercontent.com/u/87396571?v=4" width="100;" alt="yb403"/>
|
||||
<br />
|
||||
<sub><b>yb403</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 +171,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).
|
||||
|
||||
## 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:
|
||||
|
||||
|
||||
@@ -9,3 +9,4 @@ extend-exclude = [
|
||||
|
||||
[default.extend-words]
|
||||
DBE = "DBE"
|
||||
nd = "nd"
|
||||
|
||||
+13
-4
@@ -1,12 +1,21 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /build
|
||||
COPY donut-sync/package.json donut-sync/tsconfig.json donut-sync/tsconfig.build.json ./
|
||||
COPY donut-sync/src/ src/
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
FROM node:22-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json .
|
||||
COPY dist/ dist/
|
||||
COPY node_modules/ node_modules/
|
||||
COPY --from=builder /build/package.json .
|
||||
COPY --from=builder /build/dist/ dist/
|
||||
COPY --from=builder /build/node_modules/ node_modules/
|
||||
|
||||
ENV NODE_ENV=production
|
||||
EXPOSE 12342
|
||||
|
||||
USER node
|
||||
CMD ["node", "dist/main"]
|
||||
|
||||
+9
-11
@@ -2,8 +2,6 @@
|
||||
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
|
||||
</p>
|
||||
|
||||
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
|
||||
[circleci-url]: https://circleci.com/gh/nestjs/nest
|
||||
|
||||
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p>
|
||||
<p align="center">
|
||||
@@ -28,33 +26,33 @@
|
||||
## Project setup
|
||||
|
||||
```bash
|
||||
$ pnpm install
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## Compile and run the project
|
||||
|
||||
```bash
|
||||
# development
|
||||
$ pnpm run start
|
||||
pnpm run start
|
||||
|
||||
# watch mode
|
||||
$ pnpm run start:dev
|
||||
pnpm run start:dev
|
||||
|
||||
# production mode
|
||||
$ pnpm run start:prod
|
||||
pnpm run start:prod
|
||||
```
|
||||
|
||||
## Run tests
|
||||
|
||||
```bash
|
||||
# unit tests
|
||||
$ pnpm run test
|
||||
pnpm run test
|
||||
|
||||
# e2e tests
|
||||
$ pnpm run test:e2e
|
||||
pnpm run test:e2e
|
||||
|
||||
# test coverage
|
||||
$ pnpm run test:cov
|
||||
pnpm run test:cov
|
||||
```
|
||||
|
||||
## Deployment
|
||||
@@ -64,8 +62,8 @@ When you're ready to deploy your NestJS application to production, there are som
|
||||
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
|
||||
|
||||
```bash
|
||||
$ pnpm install -g @nestjs/mau
|
||||
$ mau deploy
|
||||
pnpm install -g @nestjs/mau
|
||||
mau deploy
|
||||
```
|
||||
|
||||
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
|
||||
|
||||
@@ -15,15 +15,15 @@
|
||||
"test:watch": "jest --watch",
|
||||
"test:cov": "jest --coverage",
|
||||
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
"test:e2e": "NODE_OPTIONS='--experimental-vm-modules' jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.1004.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1004.0",
|
||||
"@nestjs/common": "^11.1.16",
|
||||
"@aws-sdk/client-s3": "^3.1019.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1019.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,13 +31,13 @@
|
||||
"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",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"jest": "^30.2.0",
|
||||
"jest": "^30.3.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.2.2",
|
||||
"ts-jest": "^29.4.6",
|
||||
|
||||
@@ -27,7 +27,7 @@ export class AuthGuard implements CanActivate {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
const authHeader = request.headers.authorization;
|
||||
|
||||
if (!authHeader || !authHeader.startsWith("Bearer ")) {
|
||||
if (!authHeader?.startsWith("Bearer ")) {
|
||||
throw new UnauthorizedException(
|
||||
"Missing or invalid authorization header",
|
||||
);
|
||||
@@ -38,7 +38,7 @@ export class AuthGuard implements CanActivate {
|
||||
// Try SYNC_TOKEN first (self-hosted mode)
|
||||
const expectedToken = this.configService.get<string>("SYNC_TOKEN");
|
||||
if (expectedToken && token === expectedToken) {
|
||||
(request as any).user = {
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "self-hosted",
|
||||
prefix: "",
|
||||
teamPrefix: null,
|
||||
@@ -55,7 +55,7 @@ export class AuthGuard implements CanActivate {
|
||||
algorithms: ["RS256"],
|
||||
}) as jwt.JwtPayload;
|
||||
|
||||
(request as any).user = {
|
||||
(request as unknown as Record<string, unknown>).user = {
|
||||
mode: "cloud",
|
||||
prefix: decoded.prefix || `users/${decoded.sub}/`,
|
||||
teamPrefix: decoded.teamPrefix || null,
|
||||
|
||||
@@ -39,7 +39,7 @@ export class SyncController {
|
||||
constructor(private readonly syncService: SyncService) {}
|
||||
|
||||
private getUserContext(req: Request): UserContext {
|
||||
return (req as any).user as UserContext;
|
||||
return (req as unknown as Record<string, unknown>).user as UserContext;
|
||||
}
|
||||
|
||||
@Post("stat")
|
||||
|
||||
@@ -13,10 +13,11 @@
|
||||
"target": "ES2023",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"strictPropertyInitialization": false,
|
||||
"types": ["jest", "node"],
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noImplicitAny": false,
|
||||
"strictBindCallApply": false,
|
||||
|
||||
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.18.1";
|
||||
releaseAppImage =
|
||||
if system == "x86_64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_amd64.AppImage";
|
||||
hash = "sha256-+twOKfcM5qdV3+415/PecdQUgTTe+9xwL7/qu4kCxQI=";
|
||||
}
|
||||
else if system == "aarch64-linux" then
|
||||
pkgs.fetchurl {
|
||||
url = "https://github.com/zhom/donutbrowser/releases/download/v0.18.1/Donut_0.18.1_aarch64.AppImage";
|
||||
hash = "sha256-/Fj2euuxKzP6DxcV7sqShsNr6sy7Ck1iERtYcMt2hZQ=";
|
||||
}
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
+21
-17
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.16.1",
|
||||
"version": "0.18.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
@@ -12,9 +12,10 @@
|
||||
"test:rust": "cd src-tauri && cargo test",
|
||||
"test:rust:unit": "cd src-tauri && cargo test --lib && cargo test --test donut_proxy_integration",
|
||||
"test:sync-e2e": "node scripts/sync-test-harness.mjs",
|
||||
"lint": "pnpm lint:js && pnpm lint:rust",
|
||||
"lint": "pnpm lint:js && pnpm lint:rust && pnpm lint:spell",
|
||||
"lint:js": "biome check src/ && tsc --noEmit && cd donut-sync && biome check src/ && tsc --noEmit",
|
||||
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||
"lint:spell": "typos .",
|
||||
"tauri": "tauri",
|
||||
"shadcn:add": "pnpm dlx shadcn@latest add",
|
||||
"prepare": "husky && husky install",
|
||||
@@ -50,42 +51,42 @@
|
||||
"@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.14",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.35.0",
|
||||
"next": "^16.1.6",
|
||||
"i18next": "^26.0.0",
|
||||
"lucide-react": "^1.7.0",
|
||||
"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.6",
|
||||
"react-i18next": "^17.0.0",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "3.8.0",
|
||||
"recharts": "3.8.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.6",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@biomejs/biome": "2.4.9",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tauri-apps/cli": "~2.10.1",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/color": "^4.2.1",
|
||||
"@types/node": "^25.5.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.3.2",
|
||||
"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"
|
||||
"typescript": "~6.0.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.1",
|
||||
"lint-staged": {
|
||||
@@ -96,6 +97,9 @@
|
||||
"bash -c 'cd src-tauri && cargo fmt --all'",
|
||||
"bash -c 'cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all'",
|
||||
"bash -c 'cd src-tauri && cargo test --lib'"
|
||||
],
|
||||
"**/*.{rs,ts,tsx,js,jsx,md}": [
|
||||
"typos"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1548
-1593
File diff suppressed because it is too large
Load Diff
Generated
+717
-312
File diff suppressed because it is too large
Load Diff
+14
-11
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.16.1"
|
||||
version = "0.18.1"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -30,7 +30,7 @@ path = "src/bin/donut_daemon.rs"
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
resvg = "0.46"
|
||||
resvg = "0.47"
|
||||
|
||||
[dependencies]
|
||||
serde_json = "1"
|
||||
@@ -57,14 +57,14 @@ base64 = "0.22"
|
||||
libc = "0.2"
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
zip = { version = "7", default-features = false, features = ["deflate-flate2"] }
|
||||
zip = { version = "8", default-features = false, features = ["deflate-flate2"] }
|
||||
tar = "0"
|
||||
bzip2 = "0"
|
||||
flate2 = "1"
|
||||
lzma-rs = "0"
|
||||
msi-extract = "0"
|
||||
|
||||
uuid = { version = "1.20", features = ["v4", "serde"] }
|
||||
uuid = { version = "1.23", features = ["v4", "serde"] }
|
||||
url = "2.5"
|
||||
blake3 = "1"
|
||||
globset = "0.4"
|
||||
@@ -76,11 +76,15 @@ chrono-tz = "0.10"
|
||||
axum = { version = "0.8.8", features = ["ws"] }
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
rand = "0.9.2"
|
||||
rand = "0.10.0"
|
||||
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
|
||||
utoipa-axum = "0.2"
|
||||
argon2 = "0.5"
|
||||
aes-gcm = "0.10"
|
||||
aes = "0.8"
|
||||
cbc = "0.1"
|
||||
pbkdf2 = "0.12"
|
||||
sha1 = "0.10"
|
||||
hyper = { version = "1.8", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
@@ -91,8 +95,8 @@ 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"] }
|
||||
rusqlite = { version = "0.38", features = ["bundled"] }
|
||||
tokio-tungstenite = { version = "0.29", features = ["native-tls"] }
|
||||
rusqlite = { version = "0.39", features = ["bundled"] }
|
||||
serde_yaml = "0.9"
|
||||
thiserror = "2.0"
|
||||
regex-lite = "0.1"
|
||||
@@ -101,14 +105,13 @@ maxminddb = "0.27"
|
||||
quick-xml = { version = "0.39", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
lz4_flex = "0.11"
|
||||
boringtun = "0.7"
|
||||
smoltcp = { version = "0.11", 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"
|
||||
muda = "0.17"
|
||||
tao = "0.34"
|
||||
tao = "0.35"
|
||||
image = "0.25"
|
||||
dirs = "6"
|
||||
crossbeam-channel = "0.5"
|
||||
@@ -123,7 +126,7 @@ objc2 = "0.6.3"
|
||||
objc2-app-kit = { version = "0.3.2", features = ["NSWindow", "NSApplication", "NSRunningApplication"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.55"
|
||||
winreg = "0.56"
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_ProcessStatus",
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 745 B After Width: | Height: | Size: 487 B |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.7 KiB After Width: | Height: | Size: 1.2 KiB |
+62
-24
@@ -111,13 +111,17 @@ struct ApiProxyResponse {
|
||||
name: String,
|
||||
#[schema(value_type = Object)]
|
||||
proxy_settings: ProxySettings,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
struct CreateProxyRequest {
|
||||
name: String,
|
||||
#[schema(value_type = Object)]
|
||||
proxy_settings: ProxySettings,
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
@@ -125,6 +129,8 @@ struct UpdateProxyRequest {
|
||||
name: Option<String>,
|
||||
#[schema(value_type = Object)]
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, ToSchema)]
|
||||
@@ -1028,6 +1034,8 @@ async fn get_proxies(
|
||||
.map(|p| ApiProxyResponse {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
dynamic_proxy_url: p.dynamic_proxy_url,
|
||||
dynamic_proxy_format: p.dynamic_proxy_format,
|
||||
proxy_settings: p.proxy_settings,
|
||||
})
|
||||
.collect(),
|
||||
@@ -1061,6 +1069,8 @@ async fn get_proxy(
|
||||
id: proxy.id,
|
||||
name: proxy.name,
|
||||
proxy_settings: proxy.proxy_settings,
|
||||
dynamic_proxy_url: proxy.dynamic_proxy_url,
|
||||
dynamic_proxy_format: proxy.dynamic_proxy_format,
|
||||
}))
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
@@ -1086,14 +1096,27 @@ async fn create_proxy(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
match PROXY_MANAGER.create_stored_proxy(
|
||||
&state.app_handle,
|
||||
request.name.clone(),
|
||||
request.proxy_settings,
|
||||
) {
|
||||
let result = if let (Some(url), Some(format)) =
|
||||
(&request.dynamic_proxy_url, &request.dynamic_proxy_format)
|
||||
{
|
||||
PROXY_MANAGER.create_dynamic_proxy(
|
||||
&state.app_handle,
|
||||
request.name.clone(),
|
||||
url.clone(),
|
||||
format.clone(),
|
||||
)
|
||||
} else if let Some(settings) = request.proxy_settings {
|
||||
PROXY_MANAGER.create_stored_proxy(&state.app_handle, request.name.clone(), settings)
|
||||
} else {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(proxy) => Ok(Json(ApiProxyResponse {
|
||||
id: proxy.id,
|
||||
name: proxy.name,
|
||||
dynamic_proxy_url: proxy.dynamic_proxy_url,
|
||||
dynamic_proxy_format: proxy.dynamic_proxy_format,
|
||||
proxy_settings: proxy.proxy_settings,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
@@ -1124,28 +1147,29 @@ async fn update_proxy(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
let proxies = PROXY_MANAGER.get_stored_proxies();
|
||||
if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
|
||||
let new_name = request.name.unwrap_or(proxy.name.clone());
|
||||
let new_proxy_settings = request
|
||||
.proxy_settings
|
||||
.unwrap_or(proxy.proxy_settings.clone());
|
||||
let is_dynamic = PROXY_MANAGER.is_dynamic_proxy(&id) || request.dynamic_proxy_url.is_some();
|
||||
|
||||
match PROXY_MANAGER.update_stored_proxy(
|
||||
let result = if is_dynamic {
|
||||
PROXY_MANAGER.update_dynamic_proxy(
|
||||
&state.app_handle,
|
||||
&id,
|
||||
Some(new_name.clone()),
|
||||
Some(new_proxy_settings.clone()),
|
||||
) {
|
||||
Ok(_) => Ok(Json(ApiProxyResponse {
|
||||
id,
|
||||
name: new_name,
|
||||
proxy_settings: new_proxy_settings,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
request.name,
|
||||
request.dynamic_proxy_url,
|
||||
request.dynamic_proxy_format,
|
||||
)
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
PROXY_MANAGER.update_stored_proxy(&state.app_handle, &id, request.name, request.proxy_settings)
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(proxy) => Ok(Json(ApiProxyResponse {
|
||||
id: proxy.id,
|
||||
name: proxy.name,
|
||||
dynamic_proxy_url: proxy.dynamic_proxy_url,
|
||||
dynamic_proxy_format: proxy.dynamic_proxy_format,
|
||||
proxy_settings: proxy.proxy_settings,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::NOT_FOUND),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1289,6 +1313,13 @@ async fn run_profile(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<RunProfileRequest>,
|
||||
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let headless = request.headless.unwrap_or(false);
|
||||
let url = request.url;
|
||||
|
||||
@@ -1357,6 +1388,13 @@ async fn open_url_in_profile(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<OpenUrlRequest>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err(StatusCode::PAYMENT_REQUIRED);
|
||||
}
|
||||
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
|
||||
browser_runner
|
||||
|
||||
@@ -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)
|
||||
@@ -1602,6 +1604,10 @@ rm "{}"
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
|
||||
if crate::app_dirs::is_portable() {
|
||||
log::info!("App auto-updates disabled in portable mode");
|
||||
return Ok(None);
|
||||
}
|
||||
// The disable_auto_updates setting controls app self-updates only
|
||||
let disabled = crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
|
||||
@@ -3,11 +3,29 @@ use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static BASE_DIRS: OnceLock<BaseDirs> = OnceLock::new();
|
||||
static PORTABLE_DIR: OnceLock<Option<PathBuf>> = OnceLock::new();
|
||||
|
||||
fn base_dirs() -> &'static BaseDirs {
|
||||
BASE_DIRS.get_or_init(|| BaseDirs::new().expect("Failed to get base directories"))
|
||||
}
|
||||
|
||||
/// Returns the portable base directory if a `.portable` marker exists next to the executable.
|
||||
fn portable_dir() -> Option<&'static PathBuf> {
|
||||
PORTABLE_DIR
|
||||
.get_or_init(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|p| p.to_path_buf()))
|
||||
.filter(|dir| dir.join(".portable").exists())
|
||||
})
|
||||
.as_ref()
|
||||
}
|
||||
|
||||
/// Returns true if the app is running in portable mode.
|
||||
pub fn is_portable() -> bool {
|
||||
portable_dir().is_some()
|
||||
}
|
||||
|
||||
pub fn app_name() -> &'static str {
|
||||
if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
@@ -28,6 +46,10 @@ pub fn data_dir() -> PathBuf {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
|
||||
if let Some(dir) = portable_dir() {
|
||||
return dir.join("data");
|
||||
}
|
||||
|
||||
base_dirs().data_local_dir().join(app_name())
|
||||
}
|
||||
|
||||
@@ -43,6 +65,10 @@ pub fn cache_dir() -> PathBuf {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
|
||||
if let Some(dir) = portable_dir() {
|
||||
return dir.join("cache");
|
||||
}
|
||||
|
||||
base_dirs().cache_dir().join(app_name())
|
||||
}
|
||||
|
||||
|
||||
@@ -106,35 +106,7 @@ impl AutoUpdater {
|
||||
// Check each profile for updates
|
||||
for profile in profiles {
|
||||
if let Some(update) = self.check_profile_update(&profile, &versions)? {
|
||||
// Apply chromium threshold logic
|
||||
if browser == "chromium" {
|
||||
// For chromium, only show notifications if there's a significant version jump
|
||||
// Compare the major version component (first number before the dot)
|
||||
let current_major: u32 = profile
|
||||
.version
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let new_major: u32 = update
|
||||
.new_version
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let result = new_major.saturating_sub(current_major);
|
||||
log::info!(
|
||||
"Current major version: {current_major}, New major version: {new_major}, Diff: {result}"
|
||||
);
|
||||
if result > 0 {
|
||||
notifications.push(update);
|
||||
} else {
|
||||
log::info!("Skipping chromium update notification: same major version");
|
||||
}
|
||||
} else {
|
||||
notifications.push(update);
|
||||
}
|
||||
notifications.push(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -173,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");
|
||||
|
||||
|
||||
@@ -195,6 +195,15 @@ async fn main() {
|
||||
)
|
||||
.arg(Arg::new("action").required(true).help("Action (start)")),
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("mcp-bridge")
|
||||
.about("Bridge stdio MCP to a local HTTP MCP server")
|
||||
.arg(
|
||||
Arg::new("url")
|
||||
.required(true)
|
||||
.help("HTTP MCP server URL (e.g. http://127.0.0.1:51080/mcp/TOKEN)"),
|
||||
),
|
||||
)
|
||||
.get_matches();
|
||||
|
||||
if let Some(proxy_matches) = matches.subcommand_matches("proxy") {
|
||||
@@ -461,6 +470,81 @@ async fn main() {
|
||||
log::error!("Invalid action for vpn-worker. Use 'start'");
|
||||
process::exit(1);
|
||||
}
|
||||
} else if let Some(bridge_matches) = matches.subcommand_matches("mcp-bridge") {
|
||||
let url = bridge_matches
|
||||
.get_one::<String>("url")
|
||||
.expect("url is required")
|
||||
.clone();
|
||||
|
||||
// Suppress debug logging for bridge mode — stderr noise confuses MCP clients
|
||||
log::set_max_level(log::LevelFilter::Warn);
|
||||
|
||||
// stdio↔HTTP MCP bridge: translates stdio JSON-RPC to Streamable HTTP transport
|
||||
let client = reqwest::Client::new();
|
||||
let stdin = tokio::io::stdin();
|
||||
let reader = tokio::io::BufReader::new(stdin);
|
||||
let mut session_id: Option<String> = None;
|
||||
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt};
|
||||
let mut lines = reader.lines();
|
||||
let mut stdout = tokio::io::stdout();
|
||||
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is a notification (no "id" field) to handle 202 responses
|
||||
let is_notification = serde_json::from_str::<serde_json::Value>(&line)
|
||||
.ok()
|
||||
.map(|v| v.get("id").is_none() || v["id"].is_null())
|
||||
.unwrap_or(false);
|
||||
|
||||
let mut req = client
|
||||
.post(&url)
|
||||
.header("Content-Type", "application/json")
|
||||
.header("Accept", "application/json");
|
||||
|
||||
if let Some(sid) = &session_id {
|
||||
req = req.header("mcp-session-id", sid);
|
||||
}
|
||||
|
||||
match req.body(line).send().await {
|
||||
Ok(resp) => {
|
||||
// Capture session ID from initialize response
|
||||
if let Some(sid) = resp.headers().get("mcp-session-id") {
|
||||
if let Ok(s) = sid.to_str() {
|
||||
session_id = Some(s.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Notifications return 202 with no body — don't write anything
|
||||
if is_notification {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Ok(body) = resp.text().await {
|
||||
if !body.is_empty() {
|
||||
let _ = stdout.write_all(body.as_bytes()).await;
|
||||
let _ = stdout.write_all(b"\n").await;
|
||||
let _ = stdout.flush().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
if !is_notification {
|
||||
let err = serde_json::json!({
|
||||
"jsonrpc": "2.0",
|
||||
"id": null,
|
||||
"error": {"code": -32000, "message": format!("HTTP error: {e}")},
|
||||
});
|
||||
let _ = stdout.write_all(err.to_string().as_bytes()).await;
|
||||
let _ = stdout.write_all(b"\n").await;
|
||||
let _ = stdout.flush().await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("No command specified");
|
||||
process::exit(1);
|
||||
|
||||
+138
-555
File diff suppressed because it is too large
Load Diff
+46
-420
@@ -1,4 +1,4 @@
|
||||
use crate::browser::{create_browser, BrowserType, ProxySettings};
|
||||
use crate::browser::ProxySettings;
|
||||
use crate::camoufox_manager::{CamoufoxConfig, CamoufoxManager};
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
@@ -40,12 +40,25 @@ impl BrowserRunner {
|
||||
|
||||
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
|
||||
/// then resolve the proxy settings with profile-specific sid for sticky sessions.
|
||||
/// Resolve proxy settings for a profile, returning an error for dynamic proxy failures.
|
||||
/// Returns Ok(None) when no proxy is configured, Ok(Some) on success, Err on dynamic fetch failure.
|
||||
async fn resolve_proxy_with_refresh(
|
||||
&self,
|
||||
proxy_id: Option<&String>,
|
||||
profile_id: Option<&str>,
|
||||
) -> Option<ProxySettings> {
|
||||
let proxy_id = proxy_id?;
|
||||
) -> Result<Option<ProxySettings>, String> {
|
||||
let proxy_id = match proxy_id {
|
||||
Some(id) => id,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
// Handle dynamic proxies: fetch from URL at launch time
|
||||
if PROXY_MANAGER.is_dynamic_proxy(proxy_id) {
|
||||
log::info!("Fetching dynamic proxy settings for proxy {proxy_id}");
|
||||
let settings = PROXY_MANAGER.resolve_dynamic_proxy(proxy_id).await?;
|
||||
return Ok(Some(settings));
|
||||
}
|
||||
|
||||
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
|
||||
log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}");
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
@@ -53,10 +66,10 @@ impl BrowserRunner {
|
||||
// For cloud-derived proxies, inject profile-specific sid for sticky sessions
|
||||
if let Some(pid) = profile_id {
|
||||
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
|
||||
return PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid);
|
||||
return Ok(PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid));
|
||||
}
|
||||
}
|
||||
PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)
|
||||
Ok(PROXY_MANAGER.get_proxy_settings_by_id(proxy_id))
|
||||
}
|
||||
|
||||
/// Get the executable path for a browser profile
|
||||
@@ -98,9 +111,9 @@ impl BrowserRunner {
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
url: Option<String>,
|
||||
local_proxy_settings: Option<&ProxySettings>,
|
||||
_local_proxy_settings: Option<&ProxySettings>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
_headless: bool,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Handle Camoufox profiles using CamoufoxManager
|
||||
if profile.browser == "camoufox" {
|
||||
@@ -117,7 +130,8 @@ impl BrowserRunner {
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let mut upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
@@ -375,7 +389,8 @@ impl BrowserRunner {
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let mut upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
@@ -613,248 +628,12 @@ impl BrowserRunner {
|
||||
return Ok(updated_profile);
|
||||
}
|
||||
|
||||
// Create browser instance
|
||||
let browser_type = BrowserType::from_str(&profile.browser)
|
||||
.map_err(|_| format!("Invalid browser type: {}", profile.browser))?;
|
||||
let browser = create_browser(browser_type.clone());
|
||||
|
||||
// Get executable path using common helper
|
||||
let executable_path = self
|
||||
.get_browser_executable_path(profile)
|
||||
.expect("Failed to get executable path");
|
||||
|
||||
log::info!("Executable path: {executable_path:?}");
|
||||
|
||||
// Prepare the executable (set permissions, etc.)
|
||||
if let Err(e) = browser.prepare_executable(&executable_path) {
|
||||
log::warn!("Warning: Failed to prepare executable: {e}");
|
||||
// Continue anyway, the error might not be critical
|
||||
}
|
||||
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let _stored_proxy_settings = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await;
|
||||
|
||||
// Use provided local proxy for Chromium-based browsers launch arguments
|
||||
let proxy_for_launch_args: Option<&ProxySettings> = local_proxy_settings;
|
||||
|
||||
// Get profile data path and launch arguments
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let browser_args = browser
|
||||
.create_launch_args(
|
||||
&profile_data_path.to_string_lossy(),
|
||||
proxy_for_launch_args,
|
||||
url,
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
)
|
||||
.expect("Failed to create launch arguments");
|
||||
|
||||
// Launch browser using platform-specific method
|
||||
let child = {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
platform_browser::macos::launch_browser_process(&executable_path, &browser_args).await?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
platform_browser::windows::launch_browser_process(&executable_path, &browser_args).await?
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
platform_browser::linux::launch_browser_process(&executable_path, &browser_args).await?
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
return Err("Unsupported platform for browser launching".into());
|
||||
}
|
||||
};
|
||||
|
||||
let launcher_pid = child.id();
|
||||
|
||||
log::info!(
|
||||
"Launched browser with launcher PID: {} for profile: {} (ID: {})",
|
||||
launcher_pid,
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
|
||||
// On macOS, when launching via `open -a`, the child PID is the `open` helper.
|
||||
// Resolve and store the actual browser PID for all browser types.
|
||||
let actual_pid = {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Give the browser a moment to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
|
||||
|
||||
let system = System::new_all();
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
let mut resolved_pid = launcher_pid;
|
||||
|
||||
for (pid, process) in system.processes() {
|
||||
let cmd = process.cmd();
|
||||
if cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine if this process matches the intended browser type
|
||||
let exe_name_lower = process.name().to_string_lossy().to_lowercase();
|
||||
let is_correct_browser = match profile.browser.as_str() {
|
||||
"firefox" => {
|
||||
exe_name_lower.contains("firefox")
|
||||
&& !exe_name_lower.contains("developer")
|
||||
&& !exe_name_lower.contains("camoufox")
|
||||
}
|
||||
"firefox-developer" => {
|
||||
// More flexible detection for Firefox Developer Edition
|
||||
(exe_name_lower.contains("firefox") && exe_name_lower.contains("developer"))
|
||||
|| (exe_name_lower.contains("firefox")
|
||||
&& cmd.iter().any(|arg| {
|
||||
let arg_str = arg.to_str().unwrap_or("");
|
||||
arg_str.contains("Developer")
|
||||
|| arg_str.contains("developer")
|
||||
|| arg_str.contains("FirefoxDeveloperEdition")
|
||||
|| arg_str.contains("firefox-developer")
|
||||
}))
|
||||
|| exe_name_lower == "firefox" // Firefox Developer might just show as "firefox"
|
||||
}
|
||||
"zen" => exe_name_lower.contains("zen"),
|
||||
"chromium" => exe_name_lower.contains("chromium") || exe_name_lower.contains("chrome"),
|
||||
"brave" => exe_name_lower.contains("brave") || exe_name_lower.contains("Brave"),
|
||||
_ => false,
|
||||
};
|
||||
|
||||
if !is_correct_browser {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check for profile path match
|
||||
let profile_path_match = if matches!(
|
||||
profile.browser.as_str(),
|
||||
"firefox" | "firefox-developer" | "zen"
|
||||
) {
|
||||
// Firefox-based browsers: look for -profile argument followed by path
|
||||
let mut found_profile_arg = false;
|
||||
for (i, arg) in cmd.iter().enumerate() {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
if arg_str == "-profile" && i + 1 < cmd.len() {
|
||||
if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) {
|
||||
if next_arg == profile_data_path_str {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
// Also check for combined -profile=path format
|
||||
if arg_str == format!("-profile={profile_data_path_str}") {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
// Check if the argument is the profile path directly
|
||||
if arg_str == profile_data_path_str {
|
||||
found_profile_arg = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found_profile_arg
|
||||
} else {
|
||||
// Chromium-based browsers: look for --user-data-dir argument
|
||||
cmd.iter().any(|s| {
|
||||
if let Some(arg) = s.to_str() {
|
||||
arg == format!("--user-data-dir={profile_data_path_str}")
|
||||
|| arg == profile_data_path_str
|
||||
} else {
|
||||
false
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
if profile_path_match {
|
||||
let pid_u32 = pid.as_u32();
|
||||
if pid_u32 != launcher_pid {
|
||||
resolved_pid = pid_u32;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resolved_pid
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
launcher_pid
|
||||
}
|
||||
};
|
||||
|
||||
// Update profile with process info and save
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.process_id = Some(actual_pid);
|
||||
updated_profile.last_launch = Some(SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs());
|
||||
|
||||
self.save_process_info(&updated_profile)?;
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.profile_manager.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
// Apply proxy settings if needed (for Firefox-based browsers)
|
||||
if profile.proxy_id.is_some()
|
||||
&& matches!(
|
||||
browser_type,
|
||||
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen
|
||||
)
|
||||
{
|
||||
// Proxy settings for Firefox-based browsers are applied via user.js file
|
||||
// which is already handled in the profile creation process
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Emitting profile events for successful launch: {} (ID: {})",
|
||||
updated_profile.name,
|
||||
updated_profile.id
|
||||
);
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = events::emit("profile-updated", &updated_profile) {
|
||||
log::warn!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
|
||||
// Emit minimal running changed event to frontend with a small delay to ensure UI consistency
|
||||
#[derive(Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
let payload = RunningChangedPayload {
|
||||
id: updated_profile.id.to_string(),
|
||||
is_running: updated_profile.process_id.is_some(),
|
||||
};
|
||||
|
||||
if let Err(e) = events::emit("profile-running-changed", &payload) {
|
||||
log::warn!("Warning: Failed to emit profile running changed event: {e}");
|
||||
} else {
|
||||
log::info!(
|
||||
"Successfully emitted profile-running-changed event for {}: running={}",
|
||||
updated_profile.name,
|
||||
payload.is_running
|
||||
);
|
||||
}
|
||||
|
||||
Ok(updated_profile)
|
||||
Err(format!("Unsupported browser type: {}", profile.browser).into())
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
_app_handle: tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
_internal_proxy_settings: Option<&ProxySettings>,
|
||||
@@ -948,134 +727,7 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// Use the comprehensive browser status check for non-camoufox/wayfern browsers
|
||||
let is_running = self
|
||||
.check_browser_status(app_handle.clone(), profile)
|
||||
.await?;
|
||||
|
||||
if !is_running {
|
||||
return Err("Browser is not running".into());
|
||||
}
|
||||
|
||||
// Get the updated profile with current PID
|
||||
let profiles = self
|
||||
.profile_manager
|
||||
.list_profiles()
|
||||
.expect("Failed to list profiles");
|
||||
let updated_profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile.id)
|
||||
.unwrap_or_else(|| profile.clone());
|
||||
|
||||
// Ensure we have a valid process ID
|
||||
if updated_profile.process_id.is_none() {
|
||||
return Err("No valid process ID found for the browser".into());
|
||||
}
|
||||
|
||||
let browser_type = BrowserType::from_str(&updated_profile.browser)
|
||||
.map_err(|_| format!("Invalid browser type: {}", updated_profile.browser))?;
|
||||
|
||||
// Get browser directory for all platforms - path structure: binaries/<browser>/<version>/
|
||||
let mut browser_dir = self.get_binaries_dir();
|
||||
browser_dir.push(&updated_profile.browser);
|
||||
browser_dir.push(&updated_profile.version);
|
||||
|
||||
match browser_type {
|
||||
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
return platform_browser::macos::open_url_in_existing_browser_firefox_like(
|
||||
&updated_profile,
|
||||
url,
|
||||
browser_type,
|
||||
&browser_dir,
|
||||
&profiles_dir,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
return platform_browser::windows::open_url_in_existing_browser_firefox_like(
|
||||
&updated_profile,
|
||||
url,
|
||||
browser_type,
|
||||
&browser_dir,
|
||||
&profiles_dir,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
return platform_browser::linux::open_url_in_existing_browser_firefox_like(
|
||||
&updated_profile,
|
||||
url,
|
||||
browser_type,
|
||||
&browser_dir,
|
||||
&profiles_dir,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
return Err("Unsupported platform".into());
|
||||
}
|
||||
BrowserType::Camoufox => {
|
||||
// Camoufox URL opening is handled differently
|
||||
Err("URL opening in existing Camoufox instance is not supported".into())
|
||||
}
|
||||
BrowserType::Wayfern => {
|
||||
// Wayfern URL opening is handled differently
|
||||
Err("URL opening in existing Wayfern instance is not supported".into())
|
||||
}
|
||||
BrowserType::Chromium | BrowserType::Brave => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
return platform_browser::macos::open_url_in_existing_browser_chromium(
|
||||
&updated_profile,
|
||||
url,
|
||||
browser_type,
|
||||
&browser_dir,
|
||||
&profiles_dir,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
return platform_browser::windows::open_url_in_existing_browser_chromium(
|
||||
&updated_profile,
|
||||
url,
|
||||
browser_type,
|
||||
&browser_dir,
|
||||
&profiles_dir,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
return platform_browser::linux::open_url_in_existing_browser_chromium(
|
||||
&updated_profile,
|
||||
url,
|
||||
browser_type,
|
||||
&browser_dir,
|
||||
&profiles_dir,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
return Err("Unsupported platform".into());
|
||||
}
|
||||
}
|
||||
Err(format!("Unsupported browser type: {}", profile.browser).into())
|
||||
}
|
||||
|
||||
pub async fn launch_browser_with_debugging(
|
||||
@@ -1091,7 +743,8 @@ impl BrowserRunner {
|
||||
// Refresh cloud proxy credentials before resolving
|
||||
let upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await;
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
|
||||
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
|
||||
let temp_pid = 1u32;
|
||||
@@ -1115,32 +768,6 @@ impl BrowserRunner {
|
||||
|
||||
let internal_proxy_settings = Some(internal_proxy.clone());
|
||||
|
||||
// Configure Firefox profiles to use local proxy
|
||||
{
|
||||
// For Firefox-based browsers, apply PAC/user.js to point to the local proxy
|
||||
if matches!(
|
||||
profile.browser.as_str(),
|
||||
"firefox" | "firefox-developer" | "zen"
|
||||
) {
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
|
||||
// Provide a dummy upstream (ignored when internal proxy is provided)
|
||||
let dummy_upstream = ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: internal_proxy.port,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
self
|
||||
.profile_manager
|
||||
.apply_proxy_settings_to_profile(&profile_path, &dummy_upstream, Some(&internal_proxy))
|
||||
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
let result = self
|
||||
.launch_browser_internal(
|
||||
app_handle.clone(),
|
||||
@@ -2532,9 +2159,9 @@ impl BrowserRunner {
|
||||
|
||||
if profile.is_cross_os() {
|
||||
return Err(format!(
|
||||
"Cannot open URL with profile '{}': it was created on {} and is not supported on this system",
|
||||
"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("unknown")
|
||||
profile.host_os.as_deref().unwrap_or("another OS"),
|
||||
));
|
||||
}
|
||||
|
||||
@@ -2568,20 +2195,22 @@ pub async fn launch_browser_profile(
|
||||
|
||||
if profile.is_cross_os() {
|
||||
return Err(format!(
|
||||
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
|
||||
"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("unknown")
|
||||
profile.host_os.as_deref().unwrap_or("another OS"),
|
||||
));
|
||||
}
|
||||
|
||||
// Team lock check: if profile is sync-enabled and user is on a team, acquire lock
|
||||
crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
|
||||
// Notify sync scheduler that profile is now running
|
||||
// Notify sync scheduler that profile is now running and queue sync for when it stops
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
scheduler
|
||||
.mark_profile_running(&profile.id.to_string())
|
||||
.await;
|
||||
let pid = profile.id.to_string();
|
||||
scheduler.mark_profile_running(&pid).await;
|
||||
if profile.is_sync_enabled() {
|
||||
scheduler.queue_profile_sync(pid).await;
|
||||
}
|
||||
}
|
||||
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
@@ -2620,7 +2249,7 @@ pub async fn launch_browser_profile(
|
||||
profile_for_launch.proxy_id.as_ref(),
|
||||
Some(&profile_for_launch.id.to_string()),
|
||||
)
|
||||
.await;
|
||||
.await?;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
@@ -2794,14 +2423,11 @@ pub async fn kill_browser_profile(
|
||||
// Release team lock if applicable
|
||||
crate::team_lock::release_team_lock_if_needed(&profile).await;
|
||||
|
||||
// Notify sync scheduler that profile stopped and queue sync
|
||||
// Notify sync scheduler that profile stopped (sync was queued at launch)
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let pid = profile.id.to_string();
|
||||
scheduler.mark_profile_stopped(&pid).await;
|
||||
if profile.is_sync_enabled() {
|
||||
log::info!("Profile '{}' killed, queuing sync", profile.name);
|
||||
scheduler.queue_profile_sync(pid).await;
|
||||
}
|
||||
scheduler
|
||||
.mark_profile_stopped(&profile.id.to_string())
|
||||
.await;
|
||||
}
|
||||
|
||||
// Auto-update non-running profiles and cleanup unused binaries
|
||||
@@ -2885,9 +2511,9 @@ pub async fn launch_browser_profile_with_debugging(
|
||||
) -> Result<BrowserProfile, String> {
|
||||
if profile.is_cross_os() {
|
||||
return Err(format!(
|
||||
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
|
||||
"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("unknown")
|
||||
profile.host_os.as_deref().unwrap_or("another OS"),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Converts fingerprints to Camoufox configuration format and builds launch options.
|
||||
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
use serde_yaml;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Implements weighted random sampling from conditional probability distributions.
|
||||
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ use directories::BaseDirs;
|
||||
use maxminddb::{geoip2, Reader};
|
||||
use quick_xml::events::Event;
|
||||
use quick_xml::Reader as XmlReader;
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
//!
|
||||
//! Samples realistic WebGL configurations based on OS-specific probability distributions.
|
||||
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
use rusqlite::{Connection, Result as SqliteResult};
|
||||
use std::collections::HashMap;
|
||||
use std::io::Write;
|
||||
|
||||
@@ -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,
|
||||
@@ -56,6 +54,7 @@ pub struct CamoufoxLaunchResult {
|
||||
#[serde(alias = "profile_path")]
|
||||
pub profilePath: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub cdp_port: Option<u16>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -65,6 +64,7 @@ struct CamoufoxInstance {
|
||||
process_id: Option<u32>,
|
||||
profile_path: Option<String>,
|
||||
url: Option<String>,
|
||||
cdp_port: Option<u16>,
|
||||
}
|
||||
|
||||
struct CamoufoxManagerInner {
|
||||
@@ -88,6 +88,33 @@ impl CamoufoxManager {
|
||||
&CAMOUFOX_LAUNCHER
|
||||
}
|
||||
|
||||
async fn find_free_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?;
|
||||
let port = listener.local_addr()?.port();
|
||||
drop(listener);
|
||||
Ok(port)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn get_cdp_port(&self, profile_path: &str) -> Option<u16> {
|
||||
let inner = self.inner.lock().await;
|
||||
let target_path = std::path::Path::new(profile_path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
||||
|
||||
for instance in inner.instances.values() {
|
||||
if let Some(path) = &instance.profile_path {
|
||||
let instance_path = std::path::Path::new(path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
|
||||
if instance_path == target_path {
|
||||
return instance.cdp_port;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn get_profiles_dir(&self) -> PathBuf {
|
||||
crate::app_dirs::profiles_dir()
|
||||
}
|
||||
@@ -100,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()
|
||||
@@ -201,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> =
|
||||
@@ -239,6 +242,9 @@ impl CamoufoxManager {
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let cdp_port = Self::find_free_port().await?;
|
||||
args.push(format!("--remote-debugging-port={cdp_port}"));
|
||||
|
||||
// Add URL if provided
|
||||
if let Some(url) = url {
|
||||
args.push("-new-tab".to_string());
|
||||
@@ -294,6 +300,7 @@ impl CamoufoxManager {
|
||||
process_id,
|
||||
profile_path: Some(profile_path.to_string()),
|
||||
url: url.map(String::from),
|
||||
cdp_port: Some(cdp_port),
|
||||
};
|
||||
|
||||
let launch_result = CamoufoxLaunchResult {
|
||||
@@ -301,6 +308,7 @@ impl CamoufoxManager {
|
||||
processId: process_id,
|
||||
profilePath: Some(profile_path.to_string()),
|
||||
url: url.map(String::from),
|
||||
cdp_port: Some(cdp_port),
|
||||
};
|
||||
|
||||
{
|
||||
@@ -418,6 +426,7 @@ impl CamoufoxManager {
|
||||
processId: instance.process_id,
|
||||
profilePath: instance.profile_path.clone(),
|
||||
url: instance.url.clone(),
|
||||
cdp_port: instance.cdp_port,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -428,7 +437,9 @@ impl CamoufoxManager {
|
||||
|
||||
// If not found in in-memory instances, scan system processes
|
||||
// This handles the case where the app was restarted but Camoufox is still running
|
||||
if let Some((pid, found_profile_path)) = self.find_camoufox_process_by_profile(&target_path) {
|
||||
if let Some((pid, found_profile_path, cdp_port)) =
|
||||
self.find_camoufox_process_by_profile(&target_path)
|
||||
{
|
||||
log::info!(
|
||||
"Found running Camoufox process (PID: {}) for profile path via system scan",
|
||||
pid
|
||||
@@ -444,6 +455,7 @@ impl CamoufoxManager {
|
||||
process_id: Some(pid),
|
||||
profile_path: Some(found_profile_path.clone()),
|
||||
url: None,
|
||||
cdp_port,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -452,6 +464,7 @@ impl CamoufoxManager {
|
||||
processId: Some(pid),
|
||||
profilePath: Some(found_profile_path),
|
||||
url: None,
|
||||
cdp_port,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -462,7 +475,7 @@ impl CamoufoxManager {
|
||||
fn find_camoufox_process_by_profile(
|
||||
&self,
|
||||
target_path: &std::path::Path,
|
||||
) -> Option<(u32, String)> {
|
||||
) -> Option<(u32, String, Option<u16>)> {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
let system = System::new_with_specifics(
|
||||
@@ -487,6 +500,10 @@ impl CamoufoxManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut matched = false;
|
||||
let mut found_profile_path = None;
|
||||
let mut cdp_port: Option<u16> = None;
|
||||
|
||||
// Check if the command line contains our profile path
|
||||
for (i, arg) in cmd.iter().enumerate() {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
@@ -498,15 +515,27 @@ impl CamoufoxManager {
|
||||
.unwrap_or_else(|_| std::path::Path::new(next_arg).to_path_buf());
|
||||
|
||||
if cmd_path == target_path {
|
||||
return Some((pid.as_u32(), next_arg.to_string()));
|
||||
matched = true;
|
||||
found_profile_path = Some(next_arg.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also check if the argument contains the profile path directly
|
||||
if arg_str.contains(&*target_path_str) {
|
||||
return Some((pid.as_u32(), target_path_str.to_string()));
|
||||
if !matched && arg_str.contains(&*target_path_str) {
|
||||
matched = true;
|
||||
found_profile_path = Some(target_path_str.to_string());
|
||||
}
|
||||
|
||||
if let Some(port_val) = arg_str.strip_prefix("--remote-debugging-port=") {
|
||||
cdp_port = port_val.parse().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matched {
|
||||
if let Some(profile_path) = found_profile_path {
|
||||
return Some((pid.as_u32(), profile_path, cdp_port));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -630,9 +659,6 @@ impl CamoufoxManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Write search.json.mozlz4 with default search engines (DuckDuckGo + Google)
|
||||
write_default_search_config(&profile_path);
|
||||
|
||||
self
|
||||
.launch_camoufox(
|
||||
&app_handle,
|
||||
@@ -646,77 +672,6 @@ impl CamoufoxManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_default_search_config(profile_path: &std::path::Path) {
|
||||
let search_file = profile_path.join("search.json.mozlz4");
|
||||
if search_file.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let json = serde_json::json!({
|
||||
"version": 6,
|
||||
"engines": [
|
||||
{
|
||||
"_name": "DuckDuckGo",
|
||||
"_isAppProvided": false,
|
||||
"_metaData": { "order": 1 },
|
||||
"_urls": [
|
||||
{
|
||||
"template": "https://duckduckgo.com/?q={searchTerms}",
|
||||
"type": "text/html",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"template": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
|
||||
"type": "application/x-suggestions+json",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"_iconURL": "https://duckduckgo.com/favicon.ico"
|
||||
},
|
||||
{
|
||||
"_name": "Google",
|
||||
"_isAppProvided": false,
|
||||
"_metaData": { "order": 2 },
|
||||
"_urls": [
|
||||
{
|
||||
"template": "https://www.google.com/search?q={searchTerms}",
|
||||
"type": "text/html",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"template": "https://www.google.com/complete/search?client=firefox&q={searchTerms}",
|
||||
"type": "application/x-suggestions+json",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"_iconURL": "https://www.google.com/favicon.ico"
|
||||
}
|
||||
],
|
||||
"metaData": {
|
||||
"useSavedOrder": false,
|
||||
"defaultEngineId": "DuckDuckGo"
|
||||
}
|
||||
});
|
||||
|
||||
let json_bytes = match serde_json::to_vec(&json) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to serialize search config: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let magic = b"mozLz40\0";
|
||||
let compressed = lz4_flex::block::compress_prepend_size(&json_bytes);
|
||||
let mut output = Vec::with_capacity(magic.len() + compressed.len());
|
||||
output.extend_from_slice(magic);
|
||||
output.extend_from_slice(&compressed);
|
||||
|
||||
if let Err(e) = std::fs::write(&search_file, &output) {
|
||||
log::warn!("Failed to write search.json.mozlz4: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+11
-13
@@ -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,18 +1070,18 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync cloud proxy credentials
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
|
||||
// Refresh wayfern token every 12 hours (72 iterations of 10-minute loop)
|
||||
if wayfern_refresh_counter >= 72 {
|
||||
// Refresh wayfern token every 10 hours (60 iterations of 10-minute loop)
|
||||
if wayfern_refresh_counter >= 60 {
|
||||
wayfern_refresh_counter = 0;
|
||||
if CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if let Err(e) = CLOUD_AUTH.request_wayfern_token().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");
|
||||
@@ -1390,7 +1388,7 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping missing profile check: {}", e);
|
||||
log::warn!("Sync not configured, skipping missing profile check: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+186
-25
@@ -7,6 +7,112 @@ use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
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.
|
||||
pub mod chrome_decrypt {
|
||||
use aes::cipher::{block_padding::Pkcs7, BlockDecryptMut, BlockEncryptMut, KeyIvInit};
|
||||
use std::path::Path;
|
||||
|
||||
type Aes128CbcDec = cbc::Decryptor<aes::Aes128>;
|
||||
type Aes128CbcEnc = cbc::Encryptor<aes::Aes128>;
|
||||
|
||||
const PBKDF2_ITERATIONS: u32 = 1;
|
||||
const KEY_LEN: usize = 16; // AES-128
|
||||
const SALT: &[u8] = b"saltysalt";
|
||||
const IV: [u8; 16] = [b' '; 16]; // 16 spaces
|
||||
|
||||
fn derive_key(password: &[u8]) -> [u8; KEY_LEN] {
|
||||
let mut key = [0u8; KEY_LEN];
|
||||
pbkdf2::pbkdf2_hmac::<sha1::Sha1>(password, SALT, PBKDF2_ITERATIONS, &mut key);
|
||||
key
|
||||
}
|
||||
|
||||
/// Get the encryption key for Chrome cookies.
|
||||
/// Wayfern stores os_crypt_key as a file inside the profile's user-data-dir on all platforms.
|
||||
/// On macOS/Linux the key is a base64 string used as PBKDF2 password.
|
||||
/// On Windows the key is raw bytes (32 bytes) used directly.
|
||||
pub fn get_encryption_key(profile_data_path: &Path) -> Option<[u8; KEY_LEN]> {
|
||||
let key_file = profile_data_path.join("os_crypt_key");
|
||||
if let Ok(contents) = std::fs::read_to_string(&key_file) {
|
||||
let contents = contents.trim();
|
||||
if !contents.is_empty() {
|
||||
return Some(derive_key(contents.as_bytes()));
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for macOS: try system Keychain (for profiles created before file-based keys)
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let output = std::process::Command::new("security")
|
||||
.args([
|
||||
"find-generic-password",
|
||||
"-w",
|
||||
"-s",
|
||||
"Chromium Safe Storage",
|
||||
"-a",
|
||||
"Chromium",
|
||||
])
|
||||
.output()
|
||||
.ok()?;
|
||||
if output.status.success() {
|
||||
let password = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !password.is_empty() {
|
||||
return Some(derive_key(password.as_bytes()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Decrypt a Chrome encrypted cookie value.
|
||||
/// Chromium prefixes encrypted values with "v10" (macOS) or "v11" (Linux).
|
||||
pub fn decrypt(encrypted: &[u8], key: &[u8; KEY_LEN]) -> Option<String> {
|
||||
if encrypted.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
// Check for v10/v11 prefix
|
||||
let prefix = &encrypted[..3];
|
||||
if prefix != b"v10" && prefix != b"v11" {
|
||||
return None;
|
||||
}
|
||||
let ciphertext = &encrypted[3..];
|
||||
if ciphertext.is_empty() {
|
||||
return Some(String::new());
|
||||
}
|
||||
|
||||
let mut buf = ciphertext.to_vec();
|
||||
let decrypted = Aes128CbcDec::new(key.into(), &IV.into())
|
||||
.decrypt_padded_mut::<Pkcs7>(&mut buf)
|
||||
.ok()?;
|
||||
|
||||
String::from_utf8(decrypted.to_vec()).ok()
|
||||
}
|
||||
|
||||
/// Encrypt a cookie value in Chrome format (v10/v11 prefix + AES-128-CBC).
|
||||
pub fn encrypt(plaintext: &str, key: &[u8; KEY_LEN]) -> Vec<u8> {
|
||||
let pt = plaintext.as_bytes();
|
||||
let block_size = 16usize;
|
||||
// Allocate buffer with space for PKCS7 padding (up to one extra block)
|
||||
let padded_len = pt.len() + (block_size - pt.len() % block_size);
|
||||
let mut buf = vec![0u8; padded_len];
|
||||
buf[..pt.len()].copy_from_slice(pt);
|
||||
|
||||
let encrypted = Aes128CbcEnc::new(key.into(), &IV.into())
|
||||
.encrypt_padded_mut::<Pkcs7>(&mut buf, pt.len())
|
||||
.expect("encryption buffer too small");
|
||||
|
||||
let mut result = Vec::with_capacity(3 + encrypted.len());
|
||||
#[cfg(target_os = "macos")]
|
||||
result.extend_from_slice(b"v10");
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
result.extend_from_slice(b"v11");
|
||||
result.extend_from_slice(encrypted);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified cookie representation that works across both browser types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UnifiedCookie {
|
||||
@@ -77,6 +183,12 @@ impl CookieManager {
|
||||
/// Windows epoch offset: seconds between 1601-01-01 and 1970-01-01
|
||||
const WINDOWS_EPOCH_DIFF: i64 = 11644473600;
|
||||
|
||||
/// Get the Chrome cookie encryption key for a Wayfern profile
|
||||
fn get_chrome_encryption_key(profile: &BrowserProfile, profiles_dir: &Path) -> Option<[u8; 16]> {
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
chrome_decrypt::get_encryption_key(&profile_data_path)
|
||||
}
|
||||
|
||||
/// Get the cookie database path for a profile
|
||||
fn get_cookie_db_path(profile: &BrowserProfile, profiles_dir: &Path) -> Result<PathBuf, String> {
|
||||
let profile_data_path = profile.get_profile_data_path(profiles_dir);
|
||||
@@ -155,31 +267,58 @@ impl CookieManager {
|
||||
Ok(cookies)
|
||||
}
|
||||
|
||||
/// Read cookies from a Chrome/Wayfern profile
|
||||
fn read_chrome_cookies(db_path: &Path) -> Result<Vec<UnifiedCookie>, String> {
|
||||
/// Read cookies from a Chrome/Wayfern profile.
|
||||
/// Handles encrypted cookies by decrypting encrypted_value using the profile's encryption key.
|
||||
fn read_chrome_cookies(
|
||||
db_path: &Path,
|
||||
encryption_key: Option<&[u8; 16]>,
|
||||
) -> Result<Vec<UnifiedCookie>, String> {
|
||||
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
|
||||
|
||||
let mut stmt = conn
|
||||
.prepare(
|
||||
"SELECT name, value, host_key, path, expires_utc, is_secure,
|
||||
is_httponly, samesite, creation_utc, last_access_utc
|
||||
FROM cookies",
|
||||
is_httponly, samesite, creation_utc, last_access_utc, encrypted_value
|
||||
FROM cookies",
|
||||
)
|
||||
.map_err(|e| format!("Failed to prepare statement: {e}"))?;
|
||||
|
||||
let cookies = stmt
|
||||
.query_map([], |row| {
|
||||
let name: String = row.get(0)?;
|
||||
let plaintext_value: String = row.get(1)?;
|
||||
let domain: String = row.get(2)?;
|
||||
let path: String = row.get(3)?;
|
||||
let expires_utc: i64 = row.get(4)?;
|
||||
let is_secure: i32 = row.get(5)?;
|
||||
let is_httponly: i32 = row.get(6)?;
|
||||
let samesite: i32 = row.get(7)?;
|
||||
let creation_utc: i64 = row.get(8)?;
|
||||
let last_access_utc: i64 = row.get(9)?;
|
||||
let encrypted_value: Vec<u8> = row.get(10)?;
|
||||
|
||||
// Use plaintext value if available, otherwise decrypt encrypted_value
|
||||
let value = if !plaintext_value.is_empty() {
|
||||
plaintext_value
|
||||
} else if !encrypted_value.is_empty() {
|
||||
encryption_key
|
||||
.and_then(|key| chrome_decrypt::decrypt(&encrypted_value, key))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
Ok(UnifiedCookie {
|
||||
name: row.get(0)?,
|
||||
value: row.get(1)?,
|
||||
domain: row.get(2)?,
|
||||
path: row.get(3)?,
|
||||
expires: Self::chrome_time_to_unix(row.get(4)?),
|
||||
is_secure: row.get::<_, i32>(5)? != 0,
|
||||
is_http_only: row.get::<_, i32>(6)? != 0,
|
||||
same_site: row.get(7)?,
|
||||
creation_time: Self::chrome_time_to_unix(row.get(8)?),
|
||||
last_accessed: Self::chrome_time_to_unix(row.get(9)?),
|
||||
name,
|
||||
value,
|
||||
domain,
|
||||
path,
|
||||
expires: Self::chrome_time_to_unix(expires_utc),
|
||||
is_secure: is_secure != 0,
|
||||
is_http_only: is_httponly != 0,
|
||||
same_site: samesite,
|
||||
creation_time: Self::chrome_time_to_unix(creation_utc),
|
||||
last_accessed: Self::chrome_time_to_unix(last_access_utc),
|
||||
})
|
||||
})
|
||||
.map_err(|e| format!("Failed to query cookies: {e}"))?
|
||||
@@ -256,10 +395,12 @@ impl CookieManager {
|
||||
Ok((copied, replaced))
|
||||
}
|
||||
|
||||
/// Write cookies to a Chrome/Wayfern profile
|
||||
/// Write cookies to a Chrome/Wayfern profile.
|
||||
/// If an encryption key is available, stores cookies encrypted in encrypted_value.
|
||||
fn write_chrome_cookies(
|
||||
db_path: &Path,
|
||||
cookies: &[UnifiedCookie],
|
||||
encryption_key: Option<&[u8; 16]>,
|
||||
) -> Result<(usize, usize), String> {
|
||||
let conn = Connection::open(db_path).map_err(|e| format!("Failed to open database: {e}"))?;
|
||||
|
||||
@@ -272,6 +413,12 @@ impl CookieManager {
|
||||
.as_secs() as i64;
|
||||
|
||||
for cookie in cookies {
|
||||
// Prepare value/encrypted_value based on whether we have an encryption key
|
||||
let (value_str, encrypted_bytes): (&str, Vec<u8>) = match encryption_key {
|
||||
Some(key) => ("", chrome_decrypt::encrypt(&cookie.value, key)),
|
||||
None => (cookie.value.as_str(), Vec::new()),
|
||||
};
|
||||
|
||||
let existing: Option<i64> = conn
|
||||
.query_row(
|
||||
"SELECT rowid FROM cookies WHERE host_key = ?1 AND name = ?2 AND path = ?3",
|
||||
@@ -283,11 +430,12 @@ impl CookieManager {
|
||||
if existing.is_some() {
|
||||
conn
|
||||
.execute(
|
||||
"UPDATE cookies SET value = ?1, expires_utc = ?2, is_secure = ?3,
|
||||
is_httponly = ?4, samesite = ?5, last_access_utc = ?6, last_update_utc = ?7
|
||||
WHERE host_key = ?8 AND name = ?9 AND path = ?10",
|
||||
"UPDATE cookies SET value = ?1, encrypted_value = ?2, expires_utc = ?3, is_secure = ?4,
|
||||
is_httponly = ?5, samesite = ?6, last_access_utc = ?7, last_update_utc = ?8
|
||||
WHERE host_key = ?9 AND name = ?10 AND path = ?11",
|
||||
params![
|
||||
&cookie.value,
|
||||
value_str,
|
||||
encrypted_bytes,
|
||||
Self::unix_to_chrome_time(cookie.expires),
|
||||
cookie.is_secure as i32,
|
||||
cookie.is_http_only as i32,
|
||||
@@ -308,12 +456,13 @@ impl CookieManager {
|
||||
path, expires_utc, is_secure, is_httponly, last_access_utc, has_expires,
|
||||
is_persistent, priority, samesite, source_scheme, source_port, source_type,
|
||||
has_cross_site_ancestor, last_update_utc)
|
||||
VALUES (?1, ?2, '', ?3, ?4, X'', ?5, ?6, ?7, ?8, ?9, 1, 1, 1, ?10, 2, -1, 0, 0, ?11)",
|
||||
VALUES (?1, ?2, '', ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, 1, 1, 1, ?11, 2, -1, 0, 0, ?12)",
|
||||
params![
|
||||
Self::unix_to_chrome_time(cookie.creation_time),
|
||||
&cookie.domain,
|
||||
&cookie.name,
|
||||
&cookie.value,
|
||||
value_str,
|
||||
encrypted_bytes,
|
||||
&cookie.path,
|
||||
Self::unix_to_chrome_time(cookie.expires),
|
||||
cookie.is_secure as i32,
|
||||
@@ -348,7 +497,10 @@ impl CookieManager {
|
||||
|
||||
let cookies = match profile.browser.as_str() {
|
||||
"camoufox" => Self::read_firefox_cookies(&db_path)?,
|
||||
"wayfern" => Self::read_chrome_cookies(&db_path)?,
|
||||
"wayfern" => {
|
||||
let key = Self::get_chrome_encryption_key(profile, &profiles_dir);
|
||||
Self::read_chrome_cookies(&db_path, key.as_ref())?
|
||||
}
|
||||
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
|
||||
};
|
||||
|
||||
@@ -401,7 +553,10 @@ impl CookieManager {
|
||||
let source_db_path = Self::get_cookie_db_path(source, &profiles_dir)?;
|
||||
let all_cookies = match source.browser.as_str() {
|
||||
"camoufox" => Self::read_firefox_cookies(&source_db_path)?,
|
||||
"wayfern" => Self::read_chrome_cookies(&source_db_path)?,
|
||||
"wayfern" => {
|
||||
let key = Self::get_chrome_encryption_key(source, &profiles_dir);
|
||||
Self::read_chrome_cookies(&source_db_path, key.as_ref())?
|
||||
}
|
||||
_ => return Err(format!("Unsupported browser type: {}", source.browser)),
|
||||
};
|
||||
|
||||
@@ -468,7 +623,10 @@ impl CookieManager {
|
||||
|
||||
let write_result = match target.browser.as_str() {
|
||||
"camoufox" => Self::write_firefox_cookies(&target_db_path, &cookies_to_copy),
|
||||
"wayfern" => Self::write_chrome_cookies(&target_db_path, &cookies_to_copy),
|
||||
"wayfern" => {
|
||||
let key = Self::get_chrome_encryption_key(target, &profiles_dir);
|
||||
Self::write_chrome_cookies(&target_db_path, &cookies_to_copy, key.as_ref())
|
||||
}
|
||||
_ => {
|
||||
results.push(CookieCopyResult {
|
||||
target_profile_id: target_id.clone(),
|
||||
@@ -733,7 +891,10 @@ impl CookieManager {
|
||||
|
||||
let write_result = match profile.browser.as_str() {
|
||||
"camoufox" => Self::write_firefox_cookies(&db_path, &cookies),
|
||||
"wayfern" => Self::write_chrome_cookies(&db_path, &cookies),
|
||||
"wayfern" => {
|
||||
let key = Self::get_chrome_encryption_key(profile, &profiles_dir);
|
||||
Self::write_chrome_cookies(&db_path, &cookies, key.as_ref())
|
||||
}
|
||||
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
|
||||
};
|
||||
|
||||
|
||||
@@ -340,6 +340,9 @@ pub fn is_autostart_enabled() -> bool {
|
||||
}
|
||||
|
||||
pub fn get_data_dir() -> Option<PathBuf> {
|
||||
if crate::app_dirs::is_portable() {
|
||||
return Some(crate::app_dirs::data_dir());
|
||||
}
|
||||
if let Some(proj_dirs) = ProjectDirs::from("com", "donutbrowser", "Donut Browser") {
|
||||
Some(proj_dirs.data_dir().to_path_buf())
|
||||
} else {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// Daemon Spawn - Start the daemon from the GUI
|
||||
// Currently disabled; will be re-enabled in the future
|
||||
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+161
-527
@@ -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(),
|
||||
@@ -56,7 +59,7 @@ impl Downloader {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_with_api_client(_api_client: ApiClient) -> Self {
|
||||
pub fn new_for_test() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_client: ApiClient::instance(),
|
||||
@@ -67,87 +70,53 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub async fn download_file(
|
||||
&self,
|
||||
download_url: &str,
|
||||
dest_path: &Path,
|
||||
filename: &str,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let file_path = dest_path.join(filename);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(download_url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let mut file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.write(true)
|
||||
.open(&file_path)?;
|
||||
|
||||
let mut stream = response.bytes_stream();
|
||||
use futures_util::StreamExt;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
io::copy(&mut chunk.as_ref(), &mut file)?;
|
||||
}
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// Resolve the actual download URL for browsers that need dynamic asset resolution
|
||||
pub async fn resolve_download_url(
|
||||
&self,
|
||||
browser_type: BrowserType,
|
||||
version: &str,
|
||||
download_info: &DownloadInfo,
|
||||
_download_info: &DownloadInfo,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
match browser_type {
|
||||
BrowserType::Brave => {
|
||||
// For Brave, we need to find the actual platform-specific asset
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_brave_releases_with_caching(true)
|
||||
.await?;
|
||||
|
||||
// Find the release with the matching version
|
||||
let release = releases
|
||||
.iter()
|
||||
.find(|r| {
|
||||
r.tag_name == version || r.tag_name == format!("v{}", version.trim_start_matches('v'))
|
||||
})
|
||||
.ok_or(format!("Brave version {version} not found"))?;
|
||||
|
||||
// Get platform and architecture info
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
|
||||
// Find the appropriate asset based on platform and architecture
|
||||
let asset_url = self
|
||||
.find_brave_asset(&release.assets, &os, &arch)
|
||||
.ok_or(format!(
|
||||
"No compatible asset found for Brave version {version} on {os}/{arch}"
|
||||
))?;
|
||||
|
||||
Ok(asset_url)
|
||||
}
|
||||
BrowserType::Zen => {
|
||||
// For Zen, verify the asset exists and handle different naming patterns
|
||||
let releases = match self.api_client.fetch_zen_releases_with_caching(true).await {
|
||||
Ok(releases) => releases,
|
||||
Err(e) => {
|
||||
log::error!("Failed to fetch Zen releases: {e}");
|
||||
return Err(format!("Failed to fetch Zen releases from GitHub API: {e}. This might be due to GitHub API rate limiting or network issues. Please try again later.").into());
|
||||
}
|
||||
};
|
||||
|
||||
let release = releases
|
||||
.iter()
|
||||
.find(|r| r.tag_name == version)
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Zen version {} not found. Available versions: {}",
|
||||
version,
|
||||
releases
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|r| r.tag_name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get platform and architecture info
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
|
||||
// Find the appropriate asset
|
||||
let asset_url = self
|
||||
.find_zen_asset(&release.assets, &os, &arch)
|
||||
.ok_or_else(|| {
|
||||
let available_assets: Vec<&str> =
|
||||
release.assets.iter().map(|a| a.name.as_str()).collect();
|
||||
format!(
|
||||
"No compatible asset found for Zen version {} on {}/{}. Available assets: {}",
|
||||
version,
|
||||
os,
|
||||
arch,
|
||||
available_assets.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(asset_url)
|
||||
}
|
||||
BrowserType::Camoufox => {
|
||||
// For Camoufox, verify the asset exists and find the correct download URL
|
||||
let releases = self
|
||||
@@ -209,10 +178,6 @@ impl Downloader {
|
||||
|
||||
Ok(download_url)
|
||||
}
|
||||
_ => {
|
||||
// For other browsers, use the provided URL
|
||||
Ok(download_info.url.clone())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -239,110 +204,6 @@ impl Downloader {
|
||||
(os.to_string(), arch.to_string())
|
||||
}
|
||||
|
||||
/// Find the appropriate Brave asset for the current platform and architecture
|
||||
fn find_brave_asset(
|
||||
&self,
|
||||
assets: &[crate::browser::GithubAsset],
|
||||
os: &str,
|
||||
arch: &str,
|
||||
) -> Option<String> {
|
||||
// Brave asset naming patterns:
|
||||
// Windows: BraveBrowserStandaloneNightlySetup.exe, BraveBrowserStandaloneSilentNightlySetup.exe
|
||||
// macOS: Brave-Browser-Nightly-universal.dmg, Brave-Browser-Nightly-universal.pkg
|
||||
// Linux: brave-browser-1.79.119-linux-arm64.zip, brave-browser-1.79.119-linux-amd64.zip
|
||||
|
||||
let asset = match os {
|
||||
"windows" => {
|
||||
// For Windows, look for standalone setup EXE (not the auto-updater one)
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent")
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to any EXE if standalone not found
|
||||
assets.iter().find(|asset| asset.name.ends_with(".exe"))
|
||||
})
|
||||
}
|
||||
"macos" => {
|
||||
// For macOS, prefer universal DMG
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("universal") && name.ends_with(".dmg")
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to any DMG
|
||||
assets.iter().find(|asset| asset.name.ends_with(".dmg"))
|
||||
})
|
||||
}
|
||||
"linux" => {
|
||||
// For Linux, be strict about architecture matching - same logic as has_compatible_brave_asset
|
||||
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
|
||||
|
||||
assets.iter().find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
asset.map(|a| a.browser_download_url.clone())
|
||||
}
|
||||
|
||||
/// Find the appropriate Zen asset for the current platform and architecture
|
||||
fn find_zen_asset(
|
||||
&self,
|
||||
assets: &[crate::browser::GithubAsset],
|
||||
os: &str,
|
||||
arch: &str,
|
||||
) -> Option<String> {
|
||||
// Zen asset naming patterns:
|
||||
// Windows: zen.installer.exe, zen.installer-arm64.exe
|
||||
// macOS: zen.macos-universal.dmg
|
||||
// Linux: zen.linux-x86_64.tar.xz, zen.linux-aarch64.tar.xz, zen-x86_64.AppImage, zen-aarch64.AppImage
|
||||
|
||||
let asset = match (os, arch) {
|
||||
("windows", "x64") => assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.installer.exe"),
|
||||
("windows", "arm64") => assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.installer-arm64.exe"),
|
||||
("macos", _) => assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.macos-universal.dmg"),
|
||||
("linux", "x64") => {
|
||||
// Prefer tar.xz, fallback to AppImage
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.linux-x86_64.tar.xz")
|
||||
.or_else(|| {
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen-x86_64.AppImage")
|
||||
})
|
||||
}
|
||||
("linux", "arm64") => {
|
||||
// Prefer tar.xz, fallback to AppImage
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.linux-aarch64.tar.xz")
|
||||
.or_else(|| {
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen-aarch64.AppImage")
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
asset.map(|a| a.browser_download_url.clone())
|
||||
}
|
||||
|
||||
/// Find the appropriate Camoufox asset for the current platform and architecture
|
||||
fn find_camoufox_asset(
|
||||
&self,
|
||||
@@ -434,13 +295,6 @@ impl Downloader {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn configure_camoufox_search_engine(
|
||||
&self,
|
||||
browser_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
configure_camoufox_search_engine(browser_dir)
|
||||
}
|
||||
|
||||
pub async fn download_browser<R: tauri::Runtime>(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle<R>,
|
||||
@@ -453,13 +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?;
|
||||
|
||||
// Check if this is a twilight release for special handling
|
||||
let is_twilight =
|
||||
browser_type == BrowserType::Zen && version.to_lowercase().contains("twilight");
|
||||
log::info!("Download URL resolved: {}", download_url);
|
||||
|
||||
// Determine if we have a partial file to resume
|
||||
let mut existing_size: u64 = 0;
|
||||
@@ -467,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)
|
||||
@@ -482,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) {
|
||||
@@ -540,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;
|
||||
@@ -555,11 +442,7 @@ impl Downloader {
|
||||
0.0
|
||||
};
|
||||
|
||||
let initial_stage = if is_twilight {
|
||||
"downloading (twilight rolling release)".to_string()
|
||||
} else {
|
||||
"downloading".to_string()
|
||||
};
|
||||
let initial_stage = "downloading".to_string();
|
||||
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_type.as_str().to_string(),
|
||||
@@ -574,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;
|
||||
@@ -592,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();
|
||||
@@ -621,11 +508,7 @@ impl Downloader {
|
||||
None
|
||||
};
|
||||
|
||||
let stage_description = if is_twilight {
|
||||
"downloading (twilight rolling release)".to_string()
|
||||
} else {
|
||||
"downloading".to_string()
|
||||
};
|
||||
let stage_description = "downloading".to_string();
|
||||
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_type.as_str().to_string(),
|
||||
@@ -643,6 +526,9 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
// Flush remaining buffered data to disk
|
||||
file.flush()?;
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
@@ -844,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);
|
||||
@@ -857,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());
|
||||
}
|
||||
}
|
||||
@@ -1004,10 +909,6 @@ impl Downloader {
|
||||
{
|
||||
log::warn!("Failed to create version.json for Camoufox: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = self.configure_camoufox_search_engine(&browser_dir) {
|
||||
log::warn!("Failed to configure Camoufox search engine: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Emit completion
|
||||
@@ -1071,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,
|
||||
@@ -1102,250 +1010,24 @@ pub async fn cancel_download(browser_str: String, version: String) -> Result<(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Find all candidate `distribution/` directories inside the Camoufox browser dir.
|
||||
/// On macOS: `<browser_dir>/<app>.app/Contents/Resources/distribution/`
|
||||
/// On Linux: `<browser_dir>/camoufox/distribution/`
|
||||
/// On Windows: `<browser_dir>/distribution/`
|
||||
/// Also includes `<browser_dir>/distribution/` as a fallback for all platforms.
|
||||
#[allow(clippy::vec_init_then_push)]
|
||||
fn find_camoufox_distribution_dirs(browser_dir: &Path) -> Vec<std::path::PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(entries) = std::fs::read_dir(browser_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.path().extension().is_some_and(|ext| ext == "app") {
|
||||
dirs.push(
|
||||
entry
|
||||
.path()
|
||||
.join("Contents")
|
||||
.join("Resources")
|
||||
.join("distribution"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
dirs.push(browser_dir.join("camoufox").join("distribution"));
|
||||
}
|
||||
|
||||
// Fallback for all platforms
|
||||
dirs.push(browser_dir.join("distribution"));
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Set DuckDuckGo as the default search engine in Camoufox.
|
||||
/// Creates or updates distribution/policies.json with a proper DuckDuckGo engine definition.
|
||||
/// Called both at download time and at launch time to cover existing installations.
|
||||
pub fn configure_camoufox_search_engine(
|
||||
browser_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let distribution_dirs = find_camoufox_distribution_dirs(browser_dir);
|
||||
|
||||
// Find an existing policies.json, or pick the first candidate dir to create one
|
||||
let (policies_path, mut policies) = {
|
||||
let mut found = None;
|
||||
for dir in &distribution_dirs {
|
||||
let path = dir.join("policies.json");
|
||||
if path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
found = Some((path, val));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match found {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
// Pick the first candidate directory that exists (or can be created)
|
||||
let target_dir = distribution_dirs
|
||||
.iter()
|
||||
.find(|d| d.parent().is_some_and(|p| p.exists()))
|
||||
.or(distribution_dirs.first())
|
||||
.ok_or("No suitable distribution directory found")?;
|
||||
std::fs::create_dir_all(target_dir)?;
|
||||
(
|
||||
target_dir.join("policies.json"),
|
||||
serde_json::json!({"policies": {}}),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Check if already configured
|
||||
let has_ddg_default = policies
|
||||
.get("policies")
|
||||
.and_then(|p| p.get("SearchEngines"))
|
||||
.and_then(|se| se.get("Default"))
|
||||
.and_then(|d| d.as_str())
|
||||
== Some("DuckDuckGo");
|
||||
|
||||
let has_ddg_engine = policies
|
||||
.get("policies")
|
||||
.and_then(|p| p.get("SearchEngines"))
|
||||
.and_then(|se| se.get("Add"))
|
||||
.and_then(|a| a.as_array())
|
||||
.is_some_and(|arr| {
|
||||
arr
|
||||
.iter()
|
||||
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
|
||||
});
|
||||
|
||||
if has_ddg_default && has_ddg_engine {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let ddg_engine = serde_json::json!({
|
||||
"Name": "DuckDuckGo",
|
||||
"URLTemplate": "https://duckduckgo.com/?q={searchTerms}",
|
||||
"SuggestURLTemplate": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
|
||||
"Method": "GET",
|
||||
"IconURL": "https://duckduckgo.com/favicon.ico",
|
||||
"Alias": "ddg"
|
||||
});
|
||||
|
||||
// Ensure policies.SearchEngines exists
|
||||
let policies_obj = policies
|
||||
.as_object_mut()
|
||||
.ok_or("Invalid policies.json")?
|
||||
.entry("policies")
|
||||
.or_insert(serde_json::json!({}));
|
||||
let se = policies_obj
|
||||
.as_object_mut()
|
||||
.ok_or("Invalid policies object")?
|
||||
.entry("SearchEngines")
|
||||
.or_insert(serde_json::json!({}));
|
||||
|
||||
if let Some(se_obj) = se.as_object_mut() {
|
||||
// Set DuckDuckGo as default
|
||||
se_obj.insert(
|
||||
"Default".to_string(),
|
||||
serde_json::Value::String("DuckDuckGo".to_string()),
|
||||
);
|
||||
|
||||
// Add DuckDuckGo engine definition if not present
|
||||
let add_arr = se_obj
|
||||
.entry("Add")
|
||||
.or_insert(serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("SearchEngines.Add is not an array")?;
|
||||
|
||||
// Remove fake "None" engine
|
||||
add_arr.retain(|entry| entry.get("Name").and_then(|n| n.as_str()) != Some("None"));
|
||||
|
||||
// Add DuckDuckGo if not already present
|
||||
if !add_arr
|
||||
.iter()
|
||||
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
|
||||
{
|
||||
add_arr.push(ddg_engine);
|
||||
}
|
||||
|
||||
// Ensure DuckDuckGo is not in the Remove list
|
||||
if let Some(remove_arr) = se_obj.get_mut("Remove").and_then(|r| r.as_array_mut()) {
|
||||
remove_arr.retain(|v| v.as_str() != Some("DuckDuckGo"));
|
||||
}
|
||||
}
|
||||
|
||||
let updated = serde_json::to_string_pretty(&policies)?;
|
||||
std::fs::write(&policies_path, updated)?;
|
||||
log::info!(
|
||||
"Configured DuckDuckGo search engine in {}",
|
||||
policies_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::browser::BrowserType;
|
||||
use crate::browser_version_manager::DownloadInfo;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
async fn setup_mock_server() -> MockServer {
|
||||
MockServer::start().await
|
||||
}
|
||||
|
||||
fn create_test_api_client(server: &MockServer) -> ApiClient {
|
||||
let base_url = server.uri();
|
||||
ApiClient::new_with_base_urls(
|
||||
base_url.clone(), // firefox_api_base
|
||||
base_url.clone(), // firefox_dev_api_base
|
||||
base_url.clone(), // github_api_base
|
||||
base_url.clone(), // chromium_api_base
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_firefox_download_url() {
|
||||
let server = setup_mock_server().await;
|
||||
async fn test_download_file_with_progress() {
|
||||
let server = MockServer::start().await;
|
||||
let downloader = Downloader::new_for_test();
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "https://download.mozilla.org/?product=firefox-139.0&os=osx&lang=en-US".to_string(),
|
||||
filename: "firefox-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Firefox, "139.0", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, download_info.url);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_chromium_download_url() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "https://commondatastorage.googleapis.com/chromium-browser-snapshots/Mac/1465660/chrome-mac.zip".to_string(),
|
||||
filename: "chromium-test.zip".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Chromium, "1465660", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, download_info.url);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_with_progress() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
// Create a temporary directory for the test
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let dest_path = temp_dir.path();
|
||||
|
||||
// Create test file content (simulating a small download)
|
||||
let test_content = b"This is a test file content for download simulation";
|
||||
|
||||
// Mock the download endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/test-download"))
|
||||
.respond_with(
|
||||
@@ -1357,85 +1039,51 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: format!("{}/test-download", server.uri()),
|
||||
filename: "test-file.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
// Create a mock app handle for testing
|
||||
let app = tauri::test::mock_app();
|
||||
let app_handle = app.handle().clone();
|
||||
let download_url = format!("{}/test-download", server.uri());
|
||||
|
||||
let result = downloader
|
||||
.download_browser(
|
||||
&app_handle,
|
||||
BrowserType::Firefox,
|
||||
"139.0",
|
||||
&download_info,
|
||||
dest_path,
|
||||
None,
|
||||
)
|
||||
.download_file(&download_url, dest_path, "test-file.dmg")
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let downloaded_file = result.unwrap();
|
||||
assert!(downloaded_file.exists());
|
||||
|
||||
// Verify file content
|
||||
let downloaded_content = std::fs::read(&downloaded_file).unwrap();
|
||||
assert_eq!(downloaded_content, test_content);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_network_error() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
async fn test_download_file_network_error() {
|
||||
let server = MockServer::start().await;
|
||||
let downloader = Downloader::new_for_test();
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let dest_path = temp_dir.path();
|
||||
|
||||
// Mock a 404 response
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/missing-file"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: format!("{}/missing-file", server.uri()),
|
||||
filename: "missing-file.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let app = tauri::test::mock_app();
|
||||
let app_handle = app.handle().clone();
|
||||
let download_url = format!("{}/missing-file", server.uri());
|
||||
|
||||
let result = downloader
|
||||
.download_browser(
|
||||
&app_handle,
|
||||
BrowserType::Firefox,
|
||||
"139.0",
|
||||
&download_info,
|
||||
dest_path,
|
||||
None,
|
||||
)
|
||||
.download_file(&download_url, dest_path, "missing-file.dmg")
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_chunked_response() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
async fn test_download_file_chunked_response() {
|
||||
let server = MockServer::start().await;
|
||||
let downloader = Downloader::new_for_test();
|
||||
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let dest_path = temp_dir.path();
|
||||
|
||||
// Create larger test content to simulate chunked transfer
|
||||
let test_content = vec![42u8; 1024]; // 1KB of data
|
||||
|
||||
Mock::given(method("GET"))
|
||||
@@ -1449,24 +1097,10 @@ mod tests {
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: format!("{}/chunked-download", server.uri()),
|
||||
filename: "chunked-file.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let app = tauri::test::mock_app();
|
||||
let app_handle = app.handle().clone();
|
||||
let download_url = format!("{}/chunked-download", server.uri());
|
||||
|
||||
let result = downloader
|
||||
.download_browser(
|
||||
&app_handle,
|
||||
BrowserType::Chromium,
|
||||
"1465660",
|
||||
&download_info,
|
||||
dest_path,
|
||||
None,
|
||||
)
|
||||
.download_file(&download_url, dest_path, "chunked-file.dmg")
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
|
||||
@@ -281,7 +281,11 @@ 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();
|
||||
|
||||
|
||||
@@ -829,8 +829,8 @@ impl ExtensionManager {
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let group = self.get_group(group_id)?;
|
||||
let browser_type = match browser {
|
||||
"camoufox" | "firefox" | "firefox-developer" | "zen" => "firefox",
|
||||
"wayfern" | "chromium" | "brave" => "chromium",
|
||||
"camoufox" => "firefox",
|
||||
"wayfern" => "chromium",
|
||||
_ => return Err(format!("Extensions are not supported for browser '{browser}'").into()),
|
||||
};
|
||||
|
||||
@@ -871,8 +871,8 @@ impl ExtensionManager {
|
||||
}
|
||||
|
||||
let browser_type = match profile.browser.as_str() {
|
||||
"camoufox" | "firefox" | "firefox-developer" | "zen" => "firefox",
|
||||
"wayfern" | "chromium" | "brave" => "chromium",
|
||||
"camoufox" => "firefox",
|
||||
"wayfern" => "chromium",
|
||||
_ => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
+55
-101
@@ -6,8 +6,8 @@ use crate::browser::BrowserType;
|
||||
use crate::downloader::DownloadProgress;
|
||||
use crate::events;
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
use std::process::Command;
|
||||
#[cfg(target_os = "macos")]
|
||||
use tokio::process::Command;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
use std::fs::create_dir_all;
|
||||
@@ -38,12 +38,7 @@ impl Extractor {
|
||||
"camoufox"
|
||||
} else if dest_dir.to_string_lossy().contains("wayfern") {
|
||||
"wayfern"
|
||||
} else if dest_dir.to_string_lossy().contains("firefox") {
|
||||
"firefox"
|
||||
} else if dest_dir.to_string_lossy().contains("zen") {
|
||||
"zen"
|
||||
} else {
|
||||
// For other browsers, assume the structure is already correct
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
@@ -212,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(),
|
||||
@@ -237,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())
|
||||
@@ -362,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);
|
||||
@@ -387,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());
|
||||
@@ -412,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());
|
||||
@@ -432,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);
|
||||
@@ -739,57 +757,19 @@ impl Extractor {
|
||||
dest_dir: &Path,
|
||||
browser_type: BrowserType,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
match browser_type {
|
||||
BrowserType::Zen => {
|
||||
// Zen installer EXE needs to be run to install
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
self.install_zen_windows(exe_path, dest_dir).await
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Err("Zen EXE installation is only supported on Windows".into())
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// For other browsers (Firefox, TOR, etc.), the EXE is typically just copied
|
||||
let exe_name = exe_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("browser.exe");
|
||||
{
|
||||
let _ = browser_type;
|
||||
let exe_name = exe_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or("browser.exe");
|
||||
|
||||
let dest_path = dest_dir.join(exe_name);
|
||||
fs::copy(exe_path, &dest_path)?;
|
||||
Ok(dest_path)
|
||||
}
|
||||
let dest_path = dest_dir.join(exe_name);
|
||||
fs::copy(exe_path, &dest_path)?;
|
||||
Ok(dest_path)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
async fn install_zen_windows(
|
||||
&self,
|
||||
installer_path: &Path,
|
||||
dest_dir: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// For Zen installer, we need to run it silently
|
||||
let output = Command::new(installer_path)
|
||||
.args(["/S", &format!("/D={}", dest_dir.display())])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to install Zen: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Find the installed executable
|
||||
self.find_extracted_executable(dest_dir).await
|
||||
}
|
||||
|
||||
fn flatten_single_directory_archive(
|
||||
&self,
|
||||
dest_dir: &Path,
|
||||
@@ -954,8 +934,6 @@ impl Extractor {
|
||||
"firefox.exe",
|
||||
"chrome.exe",
|
||||
"chromium.exe",
|
||||
"zen.exe",
|
||||
"brave.exe",
|
||||
"camoufox.exe",
|
||||
"wayfern.exe",
|
||||
];
|
||||
@@ -1023,8 +1001,6 @@ impl Extractor {
|
||||
if file_name.contains("firefox")
|
||||
|| file_name.contains("chrome")
|
||||
|| file_name.contains("chromium")
|
||||
|| file_name.contains("zen")
|
||||
|| file_name.contains("brave")
|
||||
|| file_name.contains("browser")
|
||||
|| file_name.contains("camoufox")
|
||||
|| file_name.contains("wayfern")
|
||||
@@ -1075,31 +1051,14 @@ impl Extractor {
|
||||
|
||||
// Enhanced list of common browser executable names
|
||||
let exe_names = [
|
||||
// Firefox variants
|
||||
// Firefox variants (used by Camoufox)
|
||||
"firefox",
|
||||
"firefox-bin",
|
||||
"firefox-esr",
|
||||
"firefox-trunk",
|
||||
// Chrome/Chromium variants
|
||||
// Chrome/Chromium variants (used by Wayfern)
|
||||
"chrome",
|
||||
"google-chrome",
|
||||
"google-chrome-stable",
|
||||
"google-chrome-beta",
|
||||
"google-chrome-unstable",
|
||||
"chromium",
|
||||
"chromium-browser",
|
||||
"chromium-bin",
|
||||
// Zen Browser
|
||||
"zen",
|
||||
"zen-browser",
|
||||
"zen-bin",
|
||||
// Brave variants
|
||||
"brave",
|
||||
"brave-browser",
|
||||
"brave-browser-stable",
|
||||
"brave-browser-beta",
|
||||
"brave-browser-dev",
|
||||
"brave-bin",
|
||||
// Camoufox variants
|
||||
"camoufox",
|
||||
"camoufox-bin",
|
||||
@@ -1130,17 +1089,12 @@ impl Extractor {
|
||||
"firefox",
|
||||
"chrome",
|
||||
"chromium",
|
||||
"brave",
|
||||
"zen",
|
||||
"camoufox",
|
||||
"wayfern",
|
||||
".",
|
||||
"./",
|
||||
"firefox",
|
||||
"Browser",
|
||||
"browser",
|
||||
"opt/google/chrome",
|
||||
"opt/brave.com/brave",
|
||||
"opt/camoufox",
|
||||
"usr/lib/firefox",
|
||||
"usr/lib/chromium",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,492 @@
|
||||
use rand::{Rng, RngExt};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
const PROB_ERROR: f64 = 0.04;
|
||||
const PROB_SWAP_ERROR: f64 = 0.015;
|
||||
const PROB_NOTICE_ERROR: f64 = 0.85;
|
||||
const SPEED_BOOST_COMMON_WORD: f64 = 0.6;
|
||||
const SPEED_PENALTY_COMPLEX_WORD: f64 = 1.3;
|
||||
const SPEED_BOOST_CLOSE_KEYS: f64 = 0.5;
|
||||
const SPEED_BOOST_BIGRAM: f64 = 0.4;
|
||||
const TIME_KEYSTROKE_STD: f64 = 0.03;
|
||||
const TIME_BACKSPACE_MEAN: f64 = 0.12;
|
||||
const TIME_BACKSPACE_STD: f64 = 0.02;
|
||||
const TIME_REACTION_MEAN: f64 = 0.35;
|
||||
const TIME_REACTION_STD: f64 = 0.1;
|
||||
const TIME_UPPERCASE_PENALTY: f64 = 0.2;
|
||||
const TIME_SPACE_PAUSE_MEAN: f64 = 0.25;
|
||||
const TIME_SPACE_PAUSE_STD: f64 = 0.05;
|
||||
const FATIGUE_FACTOR: f64 = 1.0005;
|
||||
const AVG_WORD_LENGTH: f64 = 5.0;
|
||||
const WPM_STD: f64 = 10.0;
|
||||
const DEFAULT_WPM: f64 = 80.0;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum TypingAction {
|
||||
Char(char),
|
||||
Backspace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct TypingEvent {
|
||||
pub time: f64,
|
||||
pub action: TypingAction,
|
||||
}
|
||||
|
||||
struct KeyboardLayout {
|
||||
pos_map: HashMap<char, (usize, usize)>,
|
||||
grid: Vec<Vec<char>>,
|
||||
}
|
||||
|
||||
impl KeyboardLayout {
|
||||
fn new() -> Self {
|
||||
let grid: Vec<Vec<char>> = vec![
|
||||
"`1234567890-=".chars().collect(),
|
||||
"qwertyuiop[]\\".chars().collect(),
|
||||
"asdfghjkl;'".chars().collect(),
|
||||
"zxcvbnm,./".chars().collect(),
|
||||
];
|
||||
let mut pos_map = HashMap::new();
|
||||
for (r, row) in grid.iter().enumerate() {
|
||||
for (c, &ch) in row.iter().enumerate() {
|
||||
pos_map.insert(ch, (r, c));
|
||||
}
|
||||
}
|
||||
KeyboardLayout { pos_map, grid }
|
||||
}
|
||||
|
||||
fn has_key(&self, ch: char) -> bool {
|
||||
self.pos_map.contains_key(&ch.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
fn get_neighbor_keys(&self, ch: char) -> Vec<char> {
|
||||
let ch = ch.to_ascii_lowercase();
|
||||
let (r, c) = match self.pos_map.get(&ch) {
|
||||
Some(&pos) => pos,
|
||||
None => return vec![],
|
||||
};
|
||||
let deltas: [(i32, i32); 8] = [
|
||||
(-1, -1),
|
||||
(-1, 0),
|
||||
(-1, 1),
|
||||
(0, -1),
|
||||
(0, 1),
|
||||
(1, -1),
|
||||
(1, 0),
|
||||
(1, 1),
|
||||
];
|
||||
let mut neighbors = Vec::new();
|
||||
for (dr, dc) in &deltas {
|
||||
let nr = r as i32 + dr;
|
||||
let nc = c as i32 + dc;
|
||||
if nr >= 0 && (nr as usize) < self.grid.len() {
|
||||
let row = &self.grid[nr as usize];
|
||||
if nc >= 0 && (nc as usize) < row.len() {
|
||||
neighbors.push(row[nc as usize]);
|
||||
}
|
||||
}
|
||||
}
|
||||
neighbors
|
||||
}
|
||||
|
||||
fn get_distance(&self, c1: char, c2: char) -> f64 {
|
||||
let c1 = c1.to_ascii_lowercase();
|
||||
let c2 = c2.to_ascii_lowercase();
|
||||
match (self.pos_map.get(&c1), self.pos_map.get(&c2)) {
|
||||
(Some(&(r1, c1p)), Some(&(r2, c2p))) => {
|
||||
let dr = r1 as f64 - r2 as f64;
|
||||
let dc = c1p as f64 - c2p as f64;
|
||||
(dr * dr + dc * dc).sqrt()
|
||||
}
|
||||
_ => 4.0,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_random_neighbor(&self, ch: char, rng: &mut impl Rng) -> char {
|
||||
let neighbors = self.get_neighbor_keys(ch);
|
||||
if neighbors.is_empty() {
|
||||
let flat: Vec<char> = self.grid.iter().flat_map(|r| r.iter().copied()).collect();
|
||||
flat[rng.random_range(0..flat.len())]
|
||||
} else {
|
||||
neighbors[rng.random_range(0..neighbors.len())]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normal_sample(rng: &mut impl Rng, mean: f64, std_dev: f64) -> f64 {
|
||||
// Box-Muller transform
|
||||
let u1: f64 = rng.random::<f64>().max(1e-10);
|
||||
let u2: f64 = rng.random::<f64>();
|
||||
let z = (-2.0_f64 * u1.ln()).sqrt() * (2.0_f64 * std::f64::consts::PI * u2).cos();
|
||||
mean + std_dev * z
|
||||
}
|
||||
|
||||
static COMMON_WORDS: &[&str] = &[
|
||||
"the", "be", "to", "of", "and", "a", "in", "that", "have", "it", "for", "not", "on", "with",
|
||||
"he", "as", "you", "do", "at", "this", "but", "his", "by", "from", "they", "we", "say", "her",
|
||||
"she", "or", "an", "will", "my", "one", "all", "would", "there", "their", "what", "so", "up",
|
||||
"out", "if", "about", "who", "get", "which", "go", "me", "when", "make", "can", "like", "time",
|
||||
"no", "just", "him", "know", "take", "people", "into", "year", "your", "good", "some", "could",
|
||||
"them", "see", "other", "than", "then", "now", "look", "only", "come", "its", "over", "think",
|
||||
"also", "back", "after", "use", "two", "how", "our", "work", "first", "well", "way", "even",
|
||||
"new", "want", "because",
|
||||
];
|
||||
|
||||
static COMMON_BIGRAMS: &[&str] = &[
|
||||
"th", "he", "in", "er", "an", "re", "on", "at", "en", "nd", "ti", "es", "or", "te", "of", "ed",
|
||||
"is", "it", "al", "ar", "st", "to", "nt", "ng", "se", "ha", "as", "ou", "io", "le", "ve", "co",
|
||||
"me", "de", "hi", "ri", "ro", "ic", "ne", "ea", "ra", "ce",
|
||||
];
|
||||
|
||||
fn get_word_difficulty(word: &str) -> &'static str {
|
||||
let lower = word.to_lowercase();
|
||||
let trimmed = lower.trim_matches(|c: char| matches!(c, '.' | ',' | '!' | '?' | ';' | ':'));
|
||||
let common_set: HashSet<&str> = COMMON_WORDS.iter().copied().collect();
|
||||
if common_set.contains(trimmed) {
|
||||
return "common";
|
||||
}
|
||||
let is_long = trimmed.len() > 8;
|
||||
let has_complex = trimmed.chars().any(|c| matches!(c, 'z' | 'x' | 'q' | 'j'));
|
||||
if is_long || has_complex {
|
||||
return "complex";
|
||||
}
|
||||
"normal"
|
||||
}
|
||||
|
||||
fn is_common_bigram(c1: char, c2: char) -> bool {
|
||||
let bigram = format!("{}{}", c1.to_ascii_lowercase(), c2.to_ascii_lowercase());
|
||||
let bigram_set: HashSet<&str> = COMMON_BIGRAMS.iter().copied().collect();
|
||||
bigram_set.contains(bigram.as_str())
|
||||
}
|
||||
|
||||
pub struct MarkovTyper {
|
||||
target: Vec<char>,
|
||||
current: Vec<char>,
|
||||
keyboard: KeyboardLayout,
|
||||
base_keystroke_time: f64,
|
||||
fatigue_multiplier: f64,
|
||||
mental_cursor_pos: usize,
|
||||
last_char_typed: Option<char>,
|
||||
total_time: f64,
|
||||
last_was_backspace: bool,
|
||||
rng: rand::rngs::ThreadRng,
|
||||
}
|
||||
|
||||
impl MarkovTyper {
|
||||
pub fn new(text: &str, wpm: Option<f64>) -> Self {
|
||||
let mut rng = rand::rng();
|
||||
let target_wpm = wpm.unwrap_or(DEFAULT_WPM);
|
||||
let session_wpm = normal_sample(&mut rng, target_wpm, WPM_STD).max(10.0);
|
||||
let base_keystroke_time = 60.0 / (session_wpm * AVG_WORD_LENGTH);
|
||||
|
||||
MarkovTyper {
|
||||
target: text.chars().collect(),
|
||||
current: Vec::new(),
|
||||
keyboard: KeyboardLayout::new(),
|
||||
base_keystroke_time,
|
||||
fatigue_multiplier: 1.0,
|
||||
mental_cursor_pos: 0,
|
||||
last_char_typed: None,
|
||||
total_time: 0.0,
|
||||
last_was_backspace: false,
|
||||
rng,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_current_word(&self) -> Option<String> {
|
||||
if self.mental_cursor_pos >= self.target.len() {
|
||||
return None;
|
||||
}
|
||||
let mut start = self.mental_cursor_pos;
|
||||
while start > 0 && self.target[start - 1] != ' ' {
|
||||
start -= 1;
|
||||
}
|
||||
let mut end = self.mental_cursor_pos;
|
||||
while end < self.target.len() && self.target[end] != ' ' {
|
||||
end += 1;
|
||||
}
|
||||
Some(self.target[start..end].iter().collect())
|
||||
}
|
||||
|
||||
fn calculate_keystroke_time(&mut self, ch: char) -> f64 {
|
||||
let mut time = self.base_keystroke_time * self.fatigue_multiplier;
|
||||
|
||||
if let Some(word) = self.get_current_word() {
|
||||
match get_word_difficulty(&word) {
|
||||
"common" => time *= SPEED_BOOST_COMMON_WORD,
|
||||
"complex" => time *= SPEED_PENALTY_COMPLEX_WORD,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(last) = self.last_char_typed {
|
||||
if is_common_bigram(last, ch) {
|
||||
time *= SPEED_BOOST_BIGRAM;
|
||||
} else {
|
||||
let dist = self.keyboard.get_distance(last, ch);
|
||||
if dist > 0.0 && dist < 2.0 {
|
||||
time *= SPEED_BOOST_CLOSE_KEYS;
|
||||
} else if dist > 4.0 {
|
||||
time *= 1.2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ch == ' ' {
|
||||
time += normal_sample(&mut self.rng, TIME_SPACE_PAUSE_MEAN, TIME_SPACE_PAUSE_STD);
|
||||
} else if ch.is_uppercase() {
|
||||
time += TIME_UPPERCASE_PENALTY;
|
||||
}
|
||||
|
||||
let dt = normal_sample(&mut self.rng, time, TIME_KEYSTROKE_STD);
|
||||
dt.max(0.02)
|
||||
}
|
||||
|
||||
fn step(&mut self) -> Option<TypingEvent> {
|
||||
if self.current == self.target {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Find first error position
|
||||
let mut first_error_pos = self.target.len();
|
||||
let min_len = self.current.len().min(self.target.len());
|
||||
for i in 0..min_len {
|
||||
if self.current[i] != self.target[i] {
|
||||
first_error_pos = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if self.current.len() > self.target.len() && first_error_pos == self.target.len() {
|
||||
first_error_pos = self.target.len();
|
||||
}
|
||||
|
||||
// Error correction
|
||||
if first_error_pos < self.current.len() {
|
||||
let mut should_correct = false;
|
||||
|
||||
if self.last_was_backspace || self.mental_cursor_pos >= self.target.len() {
|
||||
should_correct = true;
|
||||
} else if !self.current.is_empty() {
|
||||
let last_char = *self.current.last().unwrap();
|
||||
let distance = self.current.len() - first_error_pos;
|
||||
|
||||
if " \n\t.,;!?:()[]{}\"'<>".contains(last_char) {
|
||||
should_correct = true;
|
||||
} else if distance >= 2 {
|
||||
if self.rng.random::<f64>() < 0.8 {
|
||||
should_correct = true;
|
||||
}
|
||||
} else if distance == 1 && self.rng.random::<f64>() < PROB_NOTICE_ERROR {
|
||||
should_correct = true;
|
||||
}
|
||||
}
|
||||
|
||||
if should_correct {
|
||||
if !self.last_was_backspace {
|
||||
let dt = normal_sample(&mut self.rng, TIME_REACTION_MEAN, TIME_REACTION_STD).max(0.1);
|
||||
self.total_time += dt;
|
||||
}
|
||||
|
||||
let dt = normal_sample(&mut self.rng, TIME_BACKSPACE_MEAN, TIME_BACKSPACE_STD);
|
||||
self.total_time += dt;
|
||||
self.current.pop();
|
||||
self.mental_cursor_pos = self.current.len();
|
||||
self.last_was_backspace = true;
|
||||
|
||||
return Some(TypingEvent {
|
||||
time: self.total_time,
|
||||
action: TypingAction::Backspace,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
self.last_was_backspace = false;
|
||||
|
||||
if self.mental_cursor_pos > self.current.len() {
|
||||
self.mental_cursor_pos = self.current.len();
|
||||
}
|
||||
if self.mental_cursor_pos >= self.target.len() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let char_intended = self.target[self.mental_cursor_pos];
|
||||
self.fatigue_multiplier *= FATIGUE_FACTOR;
|
||||
|
||||
// Non-QWERTY characters (CJK, Cyrillic, etc.) are composed via IME —
|
||||
// skip error simulation entirely, just apply realistic timing.
|
||||
let on_keyboard = self.keyboard.has_key(char_intended);
|
||||
|
||||
// Swap error (only for characters on the physical keyboard)
|
||||
if on_keyboard && self.mental_cursor_pos + 1 < self.target.len() {
|
||||
let char_after = self.target[self.mental_cursor_pos + 1];
|
||||
if char_after != ' '
|
||||
&& char_after != char_intended
|
||||
&& self.keyboard.has_key(char_after)
|
||||
&& self.rng.random::<f64>() < PROB_SWAP_ERROR
|
||||
{
|
||||
let dt = self.calculate_keystroke_time(char_after);
|
||||
self.total_time += dt;
|
||||
self.current.push(char_after);
|
||||
self.last_char_typed = Some(char_after);
|
||||
self.mental_cursor_pos += 1;
|
||||
return Some(TypingEvent {
|
||||
time: self.total_time,
|
||||
action: TypingAction::Char(char_after),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Normal typing with possible error (errors only for QWERTY characters)
|
||||
let typed_char = if on_keyboard {
|
||||
let mut current_prob_error = PROB_ERROR;
|
||||
if let Some(word) = self.get_current_word() {
|
||||
match get_word_difficulty(&word) {
|
||||
"complex" => current_prob_error *= 1.5,
|
||||
"common" => current_prob_error *= 0.5,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if self.rng.random::<f64>() < current_prob_error {
|
||||
self
|
||||
.keyboard
|
||||
.get_random_neighbor(char_intended, &mut self.rng)
|
||||
} else {
|
||||
char_intended
|
||||
}
|
||||
} else {
|
||||
char_intended
|
||||
};
|
||||
|
||||
let dt = self.calculate_keystroke_time(typed_char);
|
||||
self.total_time += dt;
|
||||
self.current.push(typed_char);
|
||||
self.last_char_typed = Some(typed_char);
|
||||
self.mental_cursor_pos += 1;
|
||||
|
||||
Some(TypingEvent {
|
||||
time: self.total_time,
|
||||
action: TypingAction::Char(typed_char),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run(mut self) -> Vec<TypingEvent> {
|
||||
let max_steps = self.target.len() * 10;
|
||||
let mut events = Vec::new();
|
||||
let mut steps = 0;
|
||||
while let Some(event) = self.step() {
|
||||
events.push(event);
|
||||
steps += 1;
|
||||
if steps > max_steps {
|
||||
break;
|
||||
}
|
||||
}
|
||||
events
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_generates_events() {
|
||||
let typer = MarkovTyper::new("hello", Some(60.0));
|
||||
let events = typer.run();
|
||||
assert!(!events.is_empty());
|
||||
// Final text should be "hello" — verify by replaying
|
||||
let mut text = String::new();
|
||||
for event in &events {
|
||||
match &event.action {
|
||||
TypingAction::Char(c) => text.push(*c),
|
||||
TypingAction::Backspace => {
|
||||
text.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(text, "hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_timing_increases() {
|
||||
let typer = MarkovTyper::new("test", Some(60.0));
|
||||
let events = typer.run();
|
||||
for window in events.windows(2) {
|
||||
assert!(window[1].time >= window[0].time);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_text() {
|
||||
let typer = MarkovTyper::new("", Some(60.0));
|
||||
let events = typer.run();
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chinese_text() {
|
||||
let input = "你好世界";
|
||||
let typer = MarkovTyper::new(input, Some(60.0));
|
||||
let events = typer.run();
|
||||
let mut text = String::new();
|
||||
for event in &events {
|
||||
match &event.action {
|
||||
TypingAction::Char(c) => text.push(*c),
|
||||
TypingAction::Backspace => {
|
||||
text.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(text, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_russian_text() {
|
||||
let input = "Привет мир";
|
||||
let typer = MarkovTyper::new(input, Some(60.0));
|
||||
let events = typer.run();
|
||||
let mut text = String::new();
|
||||
for event in &events {
|
||||
match &event.action {
|
||||
TypingAction::Char(c) => text.push(*c),
|
||||
TypingAction::Backspace => {
|
||||
text.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(text, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_japanese_text() {
|
||||
let input = "東京タワー";
|
||||
let typer = MarkovTyper::new(input, Some(60.0));
|
||||
let events = typer.run();
|
||||
let mut text = String::new();
|
||||
for event in &events {
|
||||
match &event.action {
|
||||
TypingAction::Char(c) => text.push(*c),
|
||||
TypingAction::Backspace => {
|
||||
text.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(text, input);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mixed_latin_and_cjk() {
|
||||
let input = "Hello 你好 world";
|
||||
let typer = MarkovTyper::new(input, Some(60.0));
|
||||
let events = typer.run();
|
||||
let mut text = String::new();
|
||||
for event in &events {
|
||||
match &event.action {
|
||||
TypingAction::Char(c) => text.push(*c),
|
||||
TypingAction::Backspace => {
|
||||
text.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
assert_eq!(text, input);
|
||||
}
|
||||
}
|
||||
+374
-113
@@ -26,6 +26,7 @@ mod extension_manager;
|
||||
mod extraction;
|
||||
mod geoip_downloader;
|
||||
mod group_manager;
|
||||
mod human_typing;
|
||||
mod ip_utils;
|
||||
mod platform_browser;
|
||||
mod profile;
|
||||
@@ -36,6 +37,7 @@ pub mod proxy_server;
|
||||
pub mod proxy_storage;
|
||||
mod settings_manager;
|
||||
pub mod sync;
|
||||
mod synchronizer;
|
||||
pub mod traffic_stats;
|
||||
mod wayfern_manager;
|
||||
mod wayfern_terms;
|
||||
@@ -45,6 +47,7 @@ mod commercial_license;
|
||||
mod cookie_manager;
|
||||
pub mod daemon;
|
||||
pub mod daemon_client;
|
||||
#[allow(dead_code)]
|
||||
mod daemon_spawn;
|
||||
pub mod daemon_ws;
|
||||
pub mod events;
|
||||
@@ -82,7 +85,7 @@ use downloader::{cancel_download, download_browser};
|
||||
|
||||
use settings_manager::{
|
||||
decline_launch_on_login, dismiss_window_resize_warning, enable_launch_on_login, get_app_settings,
|
||||
get_sync_settings, get_system_language, get_table_sorting_settings,
|
||||
get_sync_settings, get_system_info, get_system_language, get_table_sorting_settings,
|
||||
get_window_resize_warning_dismissed, save_app_settings, save_sync_settings,
|
||||
save_table_sorting_settings, should_show_launch_on_login_prompt,
|
||||
};
|
||||
@@ -207,11 +210,21 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
async fn create_stored_proxy(
|
||||
app_handle: tauri::AppHandle,
|
||||
name: String,
|
||||
proxy_settings: crate::browser::ProxySettings,
|
||||
proxy_settings: Option<crate::browser::ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_stored_proxy(&app_handle, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to create stored proxy: {e}"))
|
||||
if let (Some(url), Some(format)) = (&dynamic_proxy_url, &dynamic_proxy_format) {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_dynamic_proxy(&app_handle, name, url.clone(), format.clone())
|
||||
.map_err(|e| format!("Failed to create dynamic proxy: {e}"))
|
||||
} else if let Some(settings) = proxy_settings {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_stored_proxy(&app_handle, name, settings)
|
||||
.map_err(|e| format!("Failed to create stored proxy: {e}"))
|
||||
} else {
|
||||
Err("Either proxy_settings or dynamic proxy URL and format are required".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -225,10 +238,26 @@ async fn update_stored_proxy(
|
||||
proxy_id: String,
|
||||
name: Option<String>,
|
||||
proxy_settings: Option<crate::browser::ProxySettings>,
|
||||
dynamic_proxy_url: Option<String>,
|
||||
dynamic_proxy_format: Option<String>,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
||||
// Check if this is a dynamic proxy update
|
||||
let is_dynamic = crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id);
|
||||
if is_dynamic || dynamic_proxy_url.is_some() {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_dynamic_proxy(
|
||||
&app_handle,
|
||||
&proxy_id,
|
||||
name,
|
||||
dynamic_proxy_url,
|
||||
dynamic_proxy_format,
|
||||
)
|
||||
.map_err(|e| format!("Failed to update dynamic proxy: {e}"))
|
||||
} else {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -241,13 +270,43 @@ async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) ->
|
||||
#[tauri::command]
|
||||
async fn check_proxy_validity(
|
||||
proxy_id: String,
|
||||
proxy_settings: crate::browser::ProxySettings,
|
||||
proxy_settings: Option<crate::browser::ProxySettings>,
|
||||
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
|
||||
// For dynamic proxies, fetch settings first
|
||||
let settings = if let Some(s) = proxy_settings {
|
||||
s
|
||||
} else if crate::proxy_manager::PROXY_MANAGER.is_dynamic_proxy(&proxy_id) {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.resolve_dynamic_proxy(&proxy_id)
|
||||
.await?
|
||||
} else {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.get_proxy_settings_by_id(&proxy_id)
|
||||
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?
|
||||
};
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.check_proxy_validity(&proxy_id, &proxy_settings)
|
||||
.check_proxy_validity(&proxy_id, &settings)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn fetch_dynamic_proxy(
|
||||
url: String,
|
||||
format: String,
|
||||
) -> Result<crate::browser::ProxySettings, String> {
|
||||
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]
|
||||
fn get_cached_proxy_check(proxy_id: String) -> Option<crate::proxy_manager::ProxyCheckResult> {
|
||||
crate::proxy_manager::PROXY_MANAGER.get_cached_proxy_check(&proxy_id)
|
||||
@@ -298,12 +357,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?;
|
||||
|
||||
@@ -339,12 +392,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?;
|
||||
|
||||
@@ -368,12 +415,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)
|
||||
}
|
||||
|
||||
@@ -434,7 +475,6 @@ fn get_mcp_server_status() -> bool {
|
||||
struct McpConfig {
|
||||
port: u16,
|
||||
token: String,
|
||||
config_json: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -455,23 +495,283 @@ async fn get_mcp_config(app_handle: tauri::AppHandle) -> Result<Option<McpConfig
|
||||
.map_err(|e| format!("Failed to get MCP token: {e}"))?
|
||||
.ok_or("MCP token not found")?;
|
||||
|
||||
let config_json = serde_json::json!({
|
||||
"mcpServers": {
|
||||
"donut-browser": {
|
||||
"url": format!("http://127.0.0.1:{}/mcp", port),
|
||||
"headers": {
|
||||
"Authorization": format!("Bearer {}", token)
|
||||
}
|
||||
Ok(Some(McpConfig { port, token }))
|
||||
}
|
||||
|
||||
fn claude_desktop_extension_dir() -> Option<std::path::PathBuf> {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
dirs::home_dir().map(|h| {
|
||||
h.join("Library")
|
||||
.join("Application Support")
|
||||
.join("Claude")
|
||||
.join("Claude Extensions")
|
||||
.join("local.mcpb.donut-browser.donut-browser")
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
std::env::var("APPDATA").ok().map(|appdata| {
|
||||
std::path::PathBuf::from(appdata)
|
||||
.join("Claude")
|
||||
.join("Claude Extensions")
|
||||
.join("local.mcpb.donut-browser.donut-browser")
|
||||
})
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
dirs::config_dir().map(|c| {
|
||||
c.join("Claude")
|
||||
.join("Claude Extensions")
|
||||
.join("local.mcpb.donut-browser.donut-browser")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn is_mcp_in_claude_desktop() -> Result<bool, String> {
|
||||
let dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
|
||||
Ok(dir.join("manifest.json").exists())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_mcp_to_claude_desktop(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let mcp_server = mcp_server::McpServer::instance();
|
||||
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
|
||||
|
||||
let settings_manager = settings_manager::SettingsManager::instance();
|
||||
let token = settings_manager
|
||||
.get_mcp_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get MCP token: {e}"))?
|
||||
.ok_or("MCP token not found")?;
|
||||
|
||||
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
|
||||
let server_dir = ext_dir.join("server");
|
||||
std::fs::create_dir_all(&server_dir)
|
||||
.map_err(|e| format!("Failed to create extension directory: {e}"))?;
|
||||
|
||||
let mcp_url = format!("http://127.0.0.1:{port}/mcp/{token}");
|
||||
|
||||
let manifest = serde_json::json!({
|
||||
"manifest_version": "0.3",
|
||||
"name": "donut-browser",
|
||||
"display_name": "Donut Browser",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"description": "Control Donut Browser profiles, proxies, and automation via MCP",
|
||||
"author": { "name": "Donut Browser" },
|
||||
"tools_generated": true,
|
||||
"server": {
|
||||
"type": "node",
|
||||
"entry_point": "server/index.js",
|
||||
"mcp_config": {
|
||||
"command": "node",
|
||||
"args": ["${__dirname}/server/index.js"],
|
||||
"env": {}
|
||||
}
|
||||
},
|
||||
"license": "AGPL-3.0"
|
||||
});
|
||||
std::fs::write(
|
||||
ext_dir.join("manifest.json"),
|
||||
serde_json::to_string_pretty(&manifest)
|
||||
.map_err(|e| format!("Failed to serialize manifest: {e}"))?,
|
||||
)
|
||||
.map_err(|e| format!("Failed to write manifest: {e}"))?;
|
||||
|
||||
let bridge_js = format!(
|
||||
r#"#!/usr/bin/env node
|
||||
const http = require("http");
|
||||
const readline = require("readline");
|
||||
const MCP_URL = "{mcp_url}";
|
||||
let sid = null;
|
||||
function post(line) {{
|
||||
return new Promise((resolve, reject) => {{
|
||||
const u = new URL(MCP_URL);
|
||||
const o = {{
|
||||
hostname: u.hostname, port: u.port, path: u.pathname, method: "POST",
|
||||
headers: {{ "Content-Type": "application/json", Accept: "application/json" }},
|
||||
}};
|
||||
if (sid) o.headers["mcp-session-id"] = sid;
|
||||
const r = http.request(o, (res) => {{
|
||||
const s = res.headers["mcp-session-id"];
|
||||
if (s) sid = s;
|
||||
let b = "";
|
||||
res.on("data", (c) => (b += c));
|
||||
res.on("end", () => resolve(b));
|
||||
}});
|
||||
r.on("error", reject);
|
||||
r.write(line);
|
||||
r.end();
|
||||
}});
|
||||
}}
|
||||
const rl = readline.createInterface({{ input: process.stdin, crlfDelay: Infinity }});
|
||||
rl.on("line", (line) => {{
|
||||
if (!line.trim()) return;
|
||||
let notif = false;
|
||||
try {{ notif = JSON.parse(line).id == null; }} catch {{}}
|
||||
post(line).then((b) => {{
|
||||
if (!notif && b.trim()) process.stdout.write(b.trim() + "\n");
|
||||
}}).catch((e) => {{
|
||||
if (!notif) process.stdout.write(JSON.stringify({{
|
||||
jsonrpc: "2.0", id: null, error: {{ code: -32000, message: "HTTP error: " + e.message }}
|
||||
}}) + "\n");
|
||||
}});
|
||||
}});
|
||||
rl.on("close", () => setTimeout(() => process.exit(0), 500));
|
||||
"#
|
||||
);
|
||||
std::fs::write(server_dir.join("index.js"), bridge_js)
|
||||
.map_err(|e| format!("Failed to write bridge script: {e}"))?;
|
||||
|
||||
// Update the extensions-installations.json registry so Claude Desktop picks it up
|
||||
update_claude_extensions_registry("local.mcpb.donut-browser.donut-browser", Some(manifest))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remove_mcp_from_claude_desktop() -> Result<(), String> {
|
||||
let ext_dir = claude_desktop_extension_dir().ok_or("Unsupported platform")?;
|
||||
if ext_dir.exists() {
|
||||
std::fs::remove_dir_all(&ext_dir).map_err(|e| format!("Failed to remove extension: {e}"))?;
|
||||
}
|
||||
update_claude_extensions_registry("local.mcpb.donut-browser.donut-browser", None)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn update_claude_extensions_registry(
|
||||
ext_id: &str,
|
||||
manifest: Option<serde_json::Value>,
|
||||
) -> Result<(), String> {
|
||||
let registry_path = claude_desktop_extension_dir()
|
||||
.ok_or("Unsupported platform")?
|
||||
.parent()
|
||||
.and_then(|p| p.parent())
|
||||
.map(|p| p.join("extensions-installations.json"))
|
||||
.ok_or("Failed to resolve registry path")?;
|
||||
|
||||
let mut registry: serde_json::Value = if registry_path.exists() {
|
||||
let content = std::fs::read_to_string(®istry_path)
|
||||
.map_err(|e| format!("Failed to read registry: {e}"))?;
|
||||
serde_json::from_str(&content).unwrap_or(serde_json::json!({"extensions": {}}))
|
||||
} else {
|
||||
serde_json::json!({"extensions": {}})
|
||||
};
|
||||
|
||||
if registry.get("extensions").is_none() {
|
||||
registry["extensions"] = serde_json::json!({});
|
||||
}
|
||||
|
||||
match manifest {
|
||||
Some(m) => {
|
||||
registry["extensions"][ext_id] = serde_json::json!({
|
||||
"id": ext_id,
|
||||
"version": m.get("version").and_then(|v| v.as_str()).unwrap_or("0.0.0"),
|
||||
"hash": "",
|
||||
"installedAt": chrono::Utc::now().to_rfc3339(),
|
||||
"manifest": m,
|
||||
"signatureInfo": { "status": "unsigned" },
|
||||
"source": "local"
|
||||
});
|
||||
}
|
||||
None => {
|
||||
if let Some(exts) = registry
|
||||
.get_mut("extensions")
|
||||
.and_then(|e| e.as_object_mut())
|
||||
{
|
||||
exts.remove(ext_id);
|
||||
}
|
||||
}
|
||||
})
|
||||
.to_string();
|
||||
}
|
||||
|
||||
Ok(Some(McpConfig {
|
||||
port,
|
||||
token,
|
||||
config_json,
|
||||
}))
|
||||
let output =
|
||||
serde_json::to_string(®istry).map_err(|e| format!("Failed to serialize registry: {e}"))?;
|
||||
let tmp = registry_path.with_extension("json.tmp");
|
||||
std::fs::write(&tmp, &output).map_err(|e| format!("Failed to write registry: {e}"))?;
|
||||
std::fs::rename(&tmp, ®istry_path).map_err(|e| format!("Failed to save registry: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_claude_cli() -> Option<std::path::PathBuf> {
|
||||
let mut candidates: Vec<std::path::PathBuf> = vec![
|
||||
std::path::PathBuf::from("/usr/local/bin/claude"),
|
||||
std::path::PathBuf::from("/opt/homebrew/bin/claude"),
|
||||
];
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
candidates.insert(0, home.join(".local/bin/claude"));
|
||||
candidates.push(home.join(".claude/local/claude"));
|
||||
}
|
||||
#[cfg(windows)]
|
||||
if let Ok(appdata) = std::env::var("APPDATA") {
|
||||
candidates.insert(
|
||||
0,
|
||||
std::path::PathBuf::from(appdata).join("Claude/claude.exe"),
|
||||
);
|
||||
}
|
||||
for p in &candidates {
|
||||
if p.exists() {
|
||||
return Some(p.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn is_mcp_in_claude_code() -> Result<bool, String> {
|
||||
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
||||
let output = std::process::Command::new(&cli)
|
||||
.args(["mcp", "list"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
Ok(stdout.contains("donut-browser"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn add_mcp_to_claude_code(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
||||
|
||||
let mcp_server = mcp_server::McpServer::instance();
|
||||
let port = mcp_server.get_port().ok_or("MCP server is not running")?;
|
||||
|
||||
let settings_manager = settings_manager::SettingsManager::instance();
|
||||
let token = settings_manager
|
||||
.get_mcp_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to get MCP token: {e}"))?
|
||||
.ok_or("MCP token not found")?;
|
||||
|
||||
let url = format!("http://127.0.0.1:{port}/mcp/{token}");
|
||||
|
||||
let _ = std::process::Command::new(&cli)
|
||||
.args(["mcp", "remove", "donut-browser"])
|
||||
.output();
|
||||
|
||||
let output = std::process::Command::new(&cli)
|
||||
.args(["mcp", "add", "--transport", "http", "donut-browser", &url])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to add MCP to Claude Code: {stderr}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn remove_mcp_from_claude_code() -> Result<(), String> {
|
||||
let cli = find_claude_cli().ok_or("Claude Code CLI not found")?;
|
||||
let output = std::process::Command::new(&cli)
|
||||
.args(["mcp", "remove", "donut-browser"])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to run claude: {e}"))?;
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to remove MCP from Claude Code: {stderr}"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -925,38 +1225,13 @@ pub fn run() {
|
||||
mgr.ensure_icons_extracted();
|
||||
}
|
||||
|
||||
// Start the daemon for tray icon
|
||||
if let Err(e) = daemon_spawn::ensure_daemon_running() {
|
||||
log::warn!("Failed to start daemon: {e}");
|
||||
}
|
||||
|
||||
// Register this GUI's PID in daemon state so the daemon can kill us directly
|
||||
daemon_spawn::register_gui_pid();
|
||||
|
||||
// Monitor daemon health - quit GUI if daemon dies
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Give the daemon time to fully start
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let is_running = tokio::task::spawn_blocking(daemon_spawn::is_daemon_running)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_running {
|
||||
log::warn!("Daemon is no longer running, quitting GUI immediately");
|
||||
// Use process::exit for immediate termination. Tauri's exit()
|
||||
// triggers a slow graceful shutdown that can take over a minute
|
||||
// waiting for async tasks (sync, version updater, etc.) to finish.
|
||||
std::process::exit(0);
|
||||
}
|
||||
// Daemon (tray icon) is currently disabled — clean up any existing autostart
|
||||
if daemon::autostart::is_autostart_enabled() {
|
||||
log::info!("Removing daemon autostart (daemon is disabled)");
|
||||
if let Err(e) = daemon::autostart::disable_autostart() {
|
||||
log::warn!("Failed to remove daemon autostart: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Create the main window programmatically
|
||||
#[allow(unused_variables)]
|
||||
@@ -1068,41 +1343,18 @@ pub fn run() {
|
||||
version_updater::VersionUpdater::run_background_task().await;
|
||||
});
|
||||
|
||||
// TODO(v0.17+): Remove this migration block after a few releases.
|
||||
// Migrate proxy/VPN worker configs from old proxies/ dir to new proxy_workers/ cache dir.
|
||||
// Before v0.16, ephemeral worker configs (proxy_*, vpnw_*) lived alongside persistent
|
||||
// StoredProxy files in proxies/. Now they live in cache_dir/proxy_workers/.
|
||||
// Auto-start MCP server if it was previously enabled
|
||||
{
|
||||
let old_dir = crate::app_dirs::proxies_dir();
|
||||
let new_dir = crate::app_dirs::proxy_workers_dir();
|
||||
if old_dir.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(&new_dir) {
|
||||
log::error!("Failed to create proxy_workers dir: {e}");
|
||||
} else if let Ok(entries) = std::fs::read_dir(&old_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if (name.starts_with("proxy_") || name.starts_with("vpnw_"))
|
||||
&& name.ends_with(".json")
|
||||
{
|
||||
let dest = new_dir.join(name);
|
||||
match std::fs::rename(&path, &dest) {
|
||||
Ok(()) => log::info!("Migrated worker config {name} to proxy_workers/"),
|
||||
Err(e) => {
|
||||
// rename fails across filesystems, fall back to copy+delete
|
||||
if let Ok(content) = std::fs::read(&path) {
|
||||
if std::fs::write(&dest, &content).is_ok() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
log::info!("Migrated worker config {name} to proxy_workers/ (copy)");
|
||||
}
|
||||
} else {
|
||||
log::warn!("Failed to migrate worker config {name}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
let mcp_handle = app.handle().clone();
|
||||
let settings_mgr = settings_manager::SettingsManager::instance();
|
||||
if let Ok(settings) = settings_mgr.load_settings() {
|
||||
if settings.mcp_enabled {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match mcp_server::McpServer::instance().start(mcp_handle).await {
|
||||
Ok(port) => log::info!("MCP server auto-started on port {port}"),
|
||||
Err(e) => log::warn!("Failed to auto-start MCP server: {e}"),
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1386,12 +1638,8 @@ pub fn run() {
|
||||
if is_running {
|
||||
scheduler.mark_profile_running(&profile_id).await;
|
||||
} else {
|
||||
// Sync was queued at launch; mark_profile_stopped triggers it
|
||||
scheduler.mark_profile_stopped(&profile_id).await;
|
||||
// Queue sync after profile stops (if sync is enabled)
|
||||
if profile.is_sync_enabled() {
|
||||
log::info!("Profile '{}' stopped, queuing sync", profile.name);
|
||||
scheduler.queue_profile_sync(profile_id.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1499,7 +1747,7 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping missing profile check: {}", e);
|
||||
log::warn!("Sync not configured, skipping missing profile check: {}", e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1568,6 +1816,7 @@ pub fn run() {
|
||||
get_table_sorting_settings,
|
||||
save_table_sorting_settings,
|
||||
get_system_language,
|
||||
get_system_info,
|
||||
dismiss_window_resize_warning,
|
||||
get_window_resize_warning_dismissed,
|
||||
clear_all_version_cache_and_refetch,
|
||||
@@ -1594,6 +1843,7 @@ pub fn run() {
|
||||
update_stored_proxy,
|
||||
delete_stored_proxy,
|
||||
check_proxy_validity,
|
||||
fetch_dynamic_proxy,
|
||||
get_cached_proxy_check,
|
||||
export_proxies,
|
||||
import_proxies_json,
|
||||
@@ -1661,6 +1911,12 @@ pub fn run() {
|
||||
stop_mcp_server,
|
||||
get_mcp_server_status,
|
||||
get_mcp_config,
|
||||
is_mcp_in_claude_desktop,
|
||||
add_mcp_to_claude_desktop,
|
||||
remove_mcp_from_claude_desktop,
|
||||
is_mcp_in_claude_code,
|
||||
add_mcp_to_claude_code,
|
||||
remove_mcp_from_claude_code,
|
||||
// VPN commands
|
||||
import_vpn_config,
|
||||
list_vpn_configs,
|
||||
@@ -1691,6 +1947,11 @@ pub fn run() {
|
||||
// Team lock commands
|
||||
team_lock::get_team_locks,
|
||||
team_lock::get_team_lock_status,
|
||||
// Synchronizer commands
|
||||
synchronizer::start_sync_session,
|
||||
synchronizer::stop_sync_session,
|
||||
synchronizer::remove_sync_follower,
|
||||
synchronizer::get_sync_sessions,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
|
||||
+2033
-103
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ use std::process::Command;
|
||||
|
||||
// Platform-specific modules
|
||||
#[cfg(target_os = "macos")]
|
||||
#[allow(dead_code)]
|
||||
pub mod macos {
|
||||
use super::*;
|
||||
use sysinfo::{Pid, System};
|
||||
@@ -468,6 +469,7 @@ end try
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[allow(dead_code)]
|
||||
pub mod windows {
|
||||
use super::*;
|
||||
|
||||
@@ -680,6 +682,7 @@ pub mod windows {
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
#[allow(dead_code)]
|
||||
pub mod linux {
|
||||
use super::*;
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -1242,10 +1233,7 @@ impl ProfileManager {
|
||||
let profile_path_match = cmd.iter().any(|s| {
|
||||
let arg = s.to_str().unwrap_or("");
|
||||
// For Firefox-based browsers, check for exact profile path match
|
||||
if profile.browser == "firefox"
|
||||
|| profile.browser == "firefox-developer"
|
||||
|| profile.browser == "zen"
|
||||
{
|
||||
if profile.browser == "camoufox" {
|
||||
arg == profile_data_path_str
|
||||
|| arg == format!("-profile={profile_data_path_str}")
|
||||
|| (arg == "-profile"
|
||||
@@ -1253,7 +1241,7 @@ impl ProfileManager {
|
||||
.iter()
|
||||
.any(|s2| s2.to_str().unwrap_or("") == profile_data_path_str))
|
||||
} else {
|
||||
// For Chromium-based browsers, check for user-data-dir
|
||||
// For Chromium-based browsers (Wayfern), check for user-data-dir
|
||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||
|| arg == profile_data_path_str
|
||||
}
|
||||
@@ -1262,7 +1250,6 @@ impl ProfileManager {
|
||||
if profile_path_match {
|
||||
is_running = true;
|
||||
found_pid = Some(pid);
|
||||
// Found existing browser process
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1275,16 +1262,12 @@ impl ProfileManager {
|
||||
// Check if this is the right browser executable first
|
||||
let exe_name = process.name().to_string_lossy().to_lowercase();
|
||||
let is_correct_browser = match profile.browser.as_str() {
|
||||
"firefox" => {
|
||||
exe_name.contains("firefox")
|
||||
&& !exe_name.contains("developer")
|
||||
&& !exe_name.contains("camoufox")
|
||||
"camoufox" => exe_name.contains("camoufox") || exe_name.contains("firefox"),
|
||||
"wayfern" => {
|
||||
exe_name.contains("wayfern")
|
||||
|| exe_name.contains("chromium")
|
||||
|| exe_name.contains("chrome")
|
||||
}
|
||||
"firefox-developer" => exe_name.contains("firefox") && exe_name.contains("developer"),
|
||||
"zen" => exe_name.contains("zen"),
|
||||
"chromium" => exe_name.contains("chromium"),
|
||||
"brave" => exe_name.contains("brave"),
|
||||
// Camoufox is handled via CamoufoxManager, not PID-based checking
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -1300,13 +1283,6 @@ impl ProfileManager {
|
||||
let arg = s.to_str().unwrap_or("");
|
||||
// For Firefox-based browsers, check for exact profile path match
|
||||
if profile.browser == "camoufox" {
|
||||
// Camoufox uses user_data_dir like Chromium browsers
|
||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||
|| arg == profile_data_path_str
|
||||
} else if profile.browser == "firefox"
|
||||
|| profile.browser == "firefox-developer"
|
||||
|| profile.browser == "zen"
|
||||
{
|
||||
arg == profile_data_path_str
|
||||
|| arg == format!("-profile={profile_data_path_str}")
|
||||
|| (arg == "-profile"
|
||||
@@ -1314,7 +1290,7 @@ impl ProfileManager {
|
||||
.iter()
|
||||
.any(|s2| s2.to_str().unwrap_or("") == profile_data_path_str))
|
||||
} else {
|
||||
// For Chromium-based browsers, check for user-data-dir
|
||||
// For Chromium-based browsers (Wayfern), check for user-data-dir
|
||||
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|
||||
|| arg == profile_data_path_str
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ pub enum SyncMode {
|
||||
Encrypted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
pub struct BrowserProfile {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: String,
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,22 +4,38 @@ use std::collections::HashSet;
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::browser::BrowserType;
|
||||
use crate::camoufox_manager::CamoufoxConfig;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
|
||||
use crate::profile::ProfileManager;
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::wayfern_manager::WayfernConfig;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DetectedProfile {
|
||||
pub browser: String,
|
||||
pub mapped_browser: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
fn map_browser_type(browser: &str) -> &str {
|
||||
match browser {
|
||||
"firefox" | "firefox-developer" | "zen" => "camoufox",
|
||||
"chromium" | "brave" => "wayfern",
|
||||
"camoufox" => "camoufox",
|
||||
"wayfern" => "wayfern",
|
||||
_ => "wayfern",
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProfileImporter {
|
||||
base_dirs: BaseDirs,
|
||||
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||
profile_manager: &'static ProfileManager,
|
||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
||||
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
||||
}
|
||||
|
||||
impl ProfileImporter {
|
||||
@@ -28,6 +44,8 @@ impl ProfileImporter {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
|
||||
profile_manager: ProfileManager::instance(),
|
||||
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
|
||||
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,31 +53,18 @@ impl ProfileImporter {
|
||||
&PROFILE_IMPORTER
|
||||
}
|
||||
|
||||
/// Detect existing browser profiles on the system
|
||||
pub fn detect_existing_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut detected_profiles = Vec::new();
|
||||
|
||||
// Detect Firefox profiles
|
||||
detected_profiles.extend(self.detect_firefox_profiles()?);
|
||||
|
||||
// Detect Chrome profiles
|
||||
detected_profiles.extend(self.detect_chrome_profiles()?);
|
||||
|
||||
// Detect Brave profiles
|
||||
detected_profiles.extend(self.detect_brave_profiles()?);
|
||||
|
||||
// Detect Firefox Developer Edition profiles
|
||||
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
|
||||
|
||||
// Detect Chromium profiles
|
||||
detected_profiles.extend(self.detect_chromium_profiles()?);
|
||||
|
||||
// Detect Zen Browser profiles
|
||||
detected_profiles.extend(self.detect_zen_browser_profiles()?);
|
||||
|
||||
// Remove duplicates based on path
|
||||
let mut seen_paths = HashSet::new();
|
||||
let unique_profiles: Vec<DetectedProfile> = detected_profiles
|
||||
.into_iter()
|
||||
@@ -69,7 +74,6 @@ impl ProfileImporter {
|
||||
Ok(unique_profiles)
|
||||
}
|
||||
|
||||
/// Detect Firefox profiles
|
||||
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
@@ -84,12 +88,10 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Primary location in AppData\Roaming
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
|
||||
// Also check AppData\Local for portable installations
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
|
||||
if firefox_local_dir.exists() {
|
||||
@@ -106,7 +108,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Firefox Developer Edition profiles
|
||||
fn detect_firefox_developer_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
@@ -114,13 +115,11 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Firefox Developer Edition on macOS uses separate profile directories
|
||||
let firefox_dev_alt_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Firefox Developer Edition/Profiles");
|
||||
|
||||
// Only scan the dedicated dev edition directory if it exists, otherwise skip to avoid duplicates
|
||||
if firefox_dev_alt_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
|
||||
}
|
||||
@@ -129,7 +128,6 @@ impl ProfileImporter {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
// Firefox Developer Edition on Windows typically uses separate directories
|
||||
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
@@ -138,7 +136,6 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Firefox Developer Edition on Linux uses separate directories
|
||||
let firefox_dev_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
@@ -151,7 +148,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Chrome profiles
|
||||
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
@@ -180,7 +176,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Chromium profiles
|
||||
fn detect_chromium_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
@@ -209,7 +204,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Brave profiles
|
||||
fn detect_brave_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
@@ -241,7 +235,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Zen Browser profiles
|
||||
fn detect_zen_browser_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
@@ -272,7 +265,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Scan Firefox-style profiles directory
|
||||
fn scan_firefox_profiles_dir(
|
||||
&self,
|
||||
profiles_dir: &Path,
|
||||
@@ -284,7 +276,6 @@ impl ProfileImporter {
|
||||
return Ok(profiles);
|
||||
}
|
||||
|
||||
// Read profiles.ini file if it exists
|
||||
let profiles_ini = profiles_dir
|
||||
.parent()
|
||||
.unwrap_or(profiles_dir)
|
||||
@@ -295,7 +286,6 @@ impl ProfileImporter {
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan directory for any profile folders not in profiles.ini
|
||||
if let Ok(entries) = fs::read_dir(profiles_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
@@ -307,11 +297,11 @@ impl ProfileImporter {
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Unknown Profile");
|
||||
|
||||
// Check if this profile was already found in profiles.ini
|
||||
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
|
||||
if !already_added {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: format!(
|
||||
"{} Profile - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
@@ -329,7 +319,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Parse Firefox profiles.ini file
|
||||
fn parse_firefox_profiles_ini(
|
||||
&self,
|
||||
content: &str,
|
||||
@@ -346,7 +335,6 @@ impl ProfileImporter {
|
||||
let line = line.trim();
|
||||
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
// Save previous profile if complete
|
||||
if !current_section.is_empty()
|
||||
&& current_section.starts_with("Profile")
|
||||
&& !profile_path.is_empty()
|
||||
@@ -370,6 +358,7 @@ impl ProfileImporter {
|
||||
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: display_name,
|
||||
path: full_path.to_string_lossy().to_string(),
|
||||
description: format!("Profile: {profile_name}"),
|
||||
@@ -377,7 +366,6 @@ impl ProfileImporter {
|
||||
}
|
||||
}
|
||||
|
||||
// Start new section
|
||||
current_section = line[1..line.len() - 1].to_string();
|
||||
profile_name.clear();
|
||||
profile_path.clear();
|
||||
@@ -398,7 +386,6 @@ impl ProfileImporter {
|
||||
}
|
||||
}
|
||||
|
||||
// Handle last profile
|
||||
if !current_section.is_empty()
|
||||
&& current_section.starts_with("Profile")
|
||||
&& !profile_path.is_empty()
|
||||
@@ -422,6 +409,7 @@ impl ProfileImporter {
|
||||
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: display_name,
|
||||
path: full_path.to_string_lossy().to_string(),
|
||||
description: format!("Profile: {profile_name}"),
|
||||
@@ -432,7 +420,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Scan Chrome-style profiles directory
|
||||
fn scan_chrome_profiles_dir(
|
||||
&self,
|
||||
browser_dir: &Path,
|
||||
@@ -444,11 +431,11 @@ impl ProfileImporter {
|
||||
return Ok(profiles);
|
||||
}
|
||||
|
||||
// Check for Default profile
|
||||
let default_profile = browser_dir.join("Default");
|
||||
if default_profile.exists() && default_profile.join("Preferences").exists() {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: format!(
|
||||
"{} - Default Profile",
|
||||
self.get_browser_display_name(browser_type)
|
||||
@@ -458,7 +445,6 @@ impl ProfileImporter {
|
||||
});
|
||||
}
|
||||
|
||||
// Check for Profile X directories
|
||||
if let Ok(entries) = fs::read_dir(browser_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
@@ -466,9 +452,10 @@ impl ProfileImporter {
|
||||
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
|
||||
if dir_name.starts_with("Profile ") && path.join("Preferences").exists() {
|
||||
let profile_number = &dir_name[8..]; // Remove "Profile " prefix
|
||||
let profile_number = &dir_name[8..];
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
mapped_browser: map_browser_type(browser_type).to_string(),
|
||||
name: format!(
|
||||
"{} - Profile {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
@@ -485,7 +472,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Get browser display name
|
||||
fn get_browser_display_name(&self, browser_type: &str) -> &str {
|
||||
match browser_type {
|
||||
"firefox" => "Firefox",
|
||||
@@ -493,28 +479,36 @@ impl ProfileImporter {
|
||||
"chromium" => "Chrome/Chromium",
|
||||
"brave" => "Brave",
|
||||
"zen" => "Zen Browser",
|
||||
"camoufox" => "Camoufox",
|
||||
"wayfern" => "Wayfern",
|
||||
_ => "Unknown Browser",
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a profile from an existing browser profile
|
||||
pub fn import_profile(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn import_profile(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
source_path: &str,
|
||||
browser_type: &str,
|
||||
new_profile_name: &str,
|
||||
proxy_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Validate that source path exists
|
||||
let source_path = Path::new(source_path);
|
||||
if !source_path.exists() {
|
||||
return Err("Source profile path does not exist".into());
|
||||
}
|
||||
|
||||
// Validate browser type
|
||||
let _browser_type = BrowserType::from_str(browser_type)
|
||||
.map_err(|_| format!("Invalid browser type: {browser_type}"))?;
|
||||
let mapped = map_browser_type(browser_type);
|
||||
|
||||
if let Some(ref pid) = proxy_id {
|
||||
if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
|
||||
crate::cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a profile with this name already exists
|
||||
let existing_profiles = self.profile_manager.list_profiles()?;
|
||||
if existing_profiles
|
||||
.iter()
|
||||
@@ -523,7 +517,6 @@ impl ProfileImporter {
|
||||
return Err(format!("Profile with name '{new_profile_name}' already exists").into());
|
||||
}
|
||||
|
||||
// Generate UUID for new profile and create the directory structure
|
||||
let profile_id = uuid::Uuid::new_v4();
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let new_profile_uuid_dir = profiles_dir.join(profile_id.to_string());
|
||||
@@ -532,32 +525,185 @@ impl ProfileImporter {
|
||||
create_dir_all(&new_profile_uuid_dir)?;
|
||||
create_dir_all(&new_profile_data_dir)?;
|
||||
|
||||
// Copy all files from source to destination profile subdirectory
|
||||
Self::copy_directory_recursive(source_path, &new_profile_data_dir)?;
|
||||
|
||||
// Create the profile metadata without overwriting the imported data
|
||||
// We need to find a suitable version for this browser type
|
||||
let available_versions = self.get_default_version_for_browser(browser_type)?;
|
||||
let version = self.get_default_version_for_browser(mapped)?;
|
||||
|
||||
let profile = crate::profile::BrowserProfile {
|
||||
let final_camoufox_config = if mapped == "camoufox" {
|
||||
let mut config = camoufox_config.unwrap_or_default();
|
||||
|
||||
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)) =
|
||||
(&proxy_settings.username, &proxy_settings.password)
|
||||
{
|
||||
format!(
|
||||
"{}://{}:{}@{}:{}",
|
||||
proxy_settings.proxy_type.to_lowercase(),
|
||||
username,
|
||||
password,
|
||||
proxy_settings.host,
|
||||
proxy_settings.port
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}://{}:{}",
|
||||
proxy_settings.proxy_type.to_lowercase(),
|
||||
proxy_settings.host,
|
||||
proxy_settings.port
|
||||
)
|
||||
};
|
||||
config.proxy = Some(proxy_url);
|
||||
}
|
||||
}
|
||||
|
||||
if config.fingerprint.is_none() {
|
||||
let temp_profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: new_profile_name.to_string(),
|
||||
browser: mapped.to_string(),
|
||||
version: version.clone(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
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: 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,
|
||||
};
|
||||
|
||||
match self
|
||||
.camoufox_manager
|
||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
||||
.await
|
||||
{
|
||||
Ok(fp) => config.fingerprint = Some(fp),
|
||||
Err(e) => {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.proxy = None;
|
||||
Some(config)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let final_wayfern_config = if mapped == "wayfern" {
|
||||
let mut config = wayfern_config.unwrap_or_default();
|
||||
|
||||
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)) =
|
||||
(&proxy_settings.username, &proxy_settings.password)
|
||||
{
|
||||
format!(
|
||||
"{}://{}:{}@{}:{}",
|
||||
proxy_settings.proxy_type.to_lowercase(),
|
||||
username,
|
||||
password,
|
||||
proxy_settings.host,
|
||||
proxy_settings.port
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"{}://{}:{}",
|
||||
proxy_settings.proxy_type.to_lowercase(),
|
||||
proxy_settings.host,
|
||||
proxy_settings.port
|
||||
)
|
||||
};
|
||||
config.proxy = Some(proxy_url);
|
||||
}
|
||||
}
|
||||
|
||||
if config.fingerprint.is_none() {
|
||||
let temp_profile = BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: new_profile_name.to_string(),
|
||||
browser: mapped.to_string(),
|
||||
version: version.clone(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
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: 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,
|
||||
};
|
||||
|
||||
match self
|
||||
.wayfern_manager
|
||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
||||
.await
|
||||
{
|
||||
Ok(fp) => config.fingerprint = Some(fp),
|
||||
Err(e) => {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to generate fingerprint for imported profile '{new_profile_name}': {e}"
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
config.proxy = None;
|
||||
Some(config)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let profile = BrowserProfile {
|
||||
id: profile_id,
|
||||
name: new_profile_name.to_string(),
|
||||
browser: browser_type.to_string(),
|
||||
version: available_versions,
|
||||
proxy_id: None,
|
||||
browser: mapped.to_string(),
|
||||
version,
|
||||
proxy_id,
|
||||
vpn_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
wayfern_config: None,
|
||||
camoufox_config: final_camoufox_config,
|
||||
wayfern_config: final_wayfern_config,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_mode: crate::profile::types::SyncMode::Disabled,
|
||||
sync_mode: SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: Some(crate::profile::types::get_host_os()),
|
||||
host_os: Some(get_host_os()),
|
||||
ephemeral: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
@@ -565,7 +711,6 @@ impl ProfileImporter {
|
||||
created_by_email: None,
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
self.profile_manager.save_profile(&profile)?;
|
||||
|
||||
log::info!(
|
||||
@@ -577,12 +722,10 @@ impl ProfileImporter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a default version for a browser type
|
||||
fn get_default_version_for_browser(
|
||||
&self,
|
||||
browser_type: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Check if any version of the browser is downloaded
|
||||
let downloaded_versions = self
|
||||
.downloaded_browsers_registry
|
||||
.get_downloaded_versions(browser_type);
|
||||
@@ -591,15 +734,16 @@ impl ProfileImporter {
|
||||
return Ok(version.clone());
|
||||
}
|
||||
|
||||
// If no downloaded versions found, return an error
|
||||
Err(format!(
|
||||
"No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.",
|
||||
browser_type,
|
||||
self.get_browser_display_name(browser_type)
|
||||
).into())
|
||||
Err(
|
||||
format!(
|
||||
"No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.",
|
||||
browser_type,
|
||||
self.get_browser_display_name(browser_type)
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Recursively copy directory contents
|
||||
pub fn copy_directory_recursive(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
@@ -624,7 +768,6 @@ impl ProfileImporter {
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
#[tauri::command]
|
||||
pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String> {
|
||||
let importer = ProfileImporter::instance();
|
||||
@@ -635,17 +778,41 @@ pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String>
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_browser_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
source_path: String,
|
||||
browser_type: String,
|
||||
new_profile_name: String,
|
||||
proxy_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
) -> Result<(), String> {
|
||||
let fingerprint_os = camoufox_config
|
||||
.as_ref()
|
||||
.and_then(|c| c.os.as_deref())
|
||||
.or_else(|| wayfern_config.as_ref().and_then(|c| c.os.as_deref()));
|
||||
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.is_fingerprint_os_allowed(fingerprint_os)
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
|
||||
}
|
||||
|
||||
let importer = ProfileImporter::instance();
|
||||
importer
|
||||
.import_profile(&source_path, &browser_type, &new_profile_name)
|
||||
.import_profile(
|
||||
&app_handle,
|
||||
&source_path,
|
||||
&browser_type,
|
||||
&new_profile_name,
|
||||
proxy_id,
|
||||
camoufox_config,
|
||||
wayfern_config,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to import profile: {e}"))
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref PROFILE_IMPORTER: ProfileImporter = ProfileImporter::new();
|
||||
}
|
||||
@@ -658,10 +825,7 @@ mod tests {
|
||||
|
||||
fn create_test_profile_importer() -> (ProfileImporter, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
// Set up a temporary home directory for testing
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
|
||||
let importer = ProfileImporter::new();
|
||||
(importer, temp_dir)
|
||||
}
|
||||
@@ -669,7 +833,6 @@ mod tests {
|
||||
#[test]
|
||||
fn test_profile_importer_creation() {
|
||||
let (_importer, _temp_dir) = create_test_profile_importer();
|
||||
// Test passes if no panic occurs
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -693,19 +856,25 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_map_browser_type() {
|
||||
assert_eq!(map_browser_type("firefox"), "camoufox");
|
||||
assert_eq!(map_browser_type("firefox-developer"), "camoufox");
|
||||
assert_eq!(map_browser_type("zen"), "camoufox");
|
||||
assert_eq!(map_browser_type("chromium"), "wayfern");
|
||||
assert_eq!(map_browser_type("brave"), "wayfern");
|
||||
assert_eq!(map_browser_type("camoufox"), "camoufox");
|
||||
assert_eq!(map_browser_type("wayfern"), "wayfern");
|
||||
assert_eq!(map_browser_type("something_else"), "wayfern");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_existing_profiles_no_panic() {
|
||||
let (importer, _temp_dir) = create_test_profile_importer();
|
||||
|
||||
// This should not panic even if no browser profiles exist
|
||||
let result = importer.detect_existing_profiles();
|
||||
assert!(result.is_ok(), "detect_existing_profiles should not fail");
|
||||
|
||||
let _profiles = result.unwrap();
|
||||
// We can't assert specific profiles since they depend on the system
|
||||
// but we can verify the result is a valid Vec
|
||||
// We can't assert specific profiles since they depend on the system
|
||||
// but we can verify the result is a valid Vec (length check is always true for Vec, but shows intent)
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -764,12 +933,10 @@ mod tests {
|
||||
fn test_parse_firefox_profiles_ini_valid() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
|
||||
// Create a mock profile directory
|
||||
let profiles_dir = temp_dir.path().join("profiles");
|
||||
let profile_dir = profiles_dir.join("test.profile");
|
||||
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
|
||||
|
||||
// Create a prefs.js file to make it look like a valid profile
|
||||
let prefs_file = profile_dir.join("prefs.js");
|
||||
fs::write(&prefs_file, "// Firefox preferences").expect("Should create prefs.js");
|
||||
|
||||
@@ -788,31 +955,27 @@ Path=test.profile
|
||||
assert_eq!(profiles.len(), 1, "Should find one profile");
|
||||
assert_eq!(profiles[0].name, "Firefox - Test Profile");
|
||||
assert_eq!(profiles[0].browser, "firefox");
|
||||
assert_eq!(profiles[0].mapped_browser, "camoufox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_directory_recursive() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
// Create source directory structure
|
||||
let source_dir = temp_dir.path().join("source");
|
||||
let source_subdir = source_dir.join("subdir");
|
||||
fs::create_dir_all(&source_subdir).expect("Should create source directories");
|
||||
|
||||
// Create some test files
|
||||
let source_file1 = source_dir.join("file1.txt");
|
||||
let source_file2 = source_subdir.join("file2.txt");
|
||||
fs::write(&source_file1, "content1").expect("Should create file1");
|
||||
fs::write(&source_file2, "content2").expect("Should create file2");
|
||||
|
||||
// Create destination directory
|
||||
let dest_dir = temp_dir.path().join("dest");
|
||||
|
||||
// Copy recursively
|
||||
let result = ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir);
|
||||
assert!(result.is_ok(), "Should copy directory successfully");
|
||||
|
||||
// Verify files were copied
|
||||
let dest_file1 = dest_dir.join("file1.txt");
|
||||
let dest_file2 = dest_dir.join("subdir").join("file2.txt");
|
||||
|
||||
@@ -830,8 +993,9 @@ Path=test.profile
|
||||
fn test_get_default_version_for_browser_no_versions() {
|
||||
let (importer, _temp_dir) = create_test_profile_importer();
|
||||
|
||||
// This should fail since no versions are downloaded in test environment
|
||||
let result = importer.get_default_version_for_browser("firefox");
|
||||
// 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"
|
||||
|
||||
+619
-10
@@ -117,6 +117,10 @@ pub struct StoredProxy {
|
||||
pub geo_city: Option<String>,
|
||||
#[serde(default)]
|
||||
pub geo_isp: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dynamic_proxy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub dynamic_proxy_format: Option<String>,
|
||||
}
|
||||
|
||||
impl StoredProxy {
|
||||
@@ -135,9 +139,15 @@ impl StoredProxy {
|
||||
geo_region: None,
|
||||
geo_city: None,
|
||||
geo_isp: None,
|
||||
dynamic_proxy_url: None,
|
||||
dynamic_proxy_format: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_dynamic(&self) -> bool {
|
||||
self.dynamic_proxy_url.is_some()
|
||||
}
|
||||
|
||||
/// Migrate legacy geo_state to geo_region
|
||||
pub fn migrate_geo_fields(&mut self) {
|
||||
if self.geo_region.is_none() && self.geo_state.is_some() {
|
||||
@@ -450,6 +460,8 @@ impl ProxyManager {
|
||||
geo_region: None,
|
||||
geo_city: None,
|
||||
geo_isp: None,
|
||||
dynamic_proxy_url: None,
|
||||
dynamic_proxy_format: None,
|
||||
};
|
||||
stored_proxies.insert(CLOUD_PROXY_ID.to_string(), cloud_proxy.clone());
|
||||
drop(stored_proxies);
|
||||
@@ -639,6 +651,8 @@ impl ProxyManager {
|
||||
geo_region: region,
|
||||
geo_city: city,
|
||||
geo_isp: isp,
|
||||
dynamic_proxy_url: None,
|
||||
dynamic_proxy_format: None,
|
||||
};
|
||||
|
||||
{
|
||||
@@ -893,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);
|
||||
@@ -914,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,
|
||||
@@ -936,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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -965,6 +1065,280 @@ impl ProxyManager {
|
||||
self.load_proxy_check_cache(proxy_id)
|
||||
}
|
||||
|
||||
// Check if a stored proxy is dynamic
|
||||
pub fn is_dynamic_proxy(&self, proxy_id: &str) -> bool {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.get(proxy_id).is_some_and(|p| p.is_dynamic())
|
||||
}
|
||||
|
||||
// Fetch proxy settings from a dynamic proxy URL
|
||||
pub async fn fetch_dynamic_proxy(
|
||||
&self,
|
||||
url: &str,
|
||||
format: &str,
|
||||
) -> Result<ProxySettings, String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(15))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
|
||||
let response = client
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch dynamic proxy: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Dynamic proxy URL returned status {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
let body = response
|
||||
.text()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to read dynamic proxy response: {e}"))?;
|
||||
|
||||
let body = body.trim();
|
||||
if body.is_empty() {
|
||||
return Err("Dynamic proxy URL returned empty response".to_string());
|
||||
}
|
||||
|
||||
match format {
|
||||
"json" => Self::parse_dynamic_proxy_json(body),
|
||||
"text" => Self::parse_dynamic_proxy_text(body),
|
||||
_ => Err(format!("Unsupported dynamic proxy format: {format}")),
|
||||
}
|
||||
}
|
||||
|
||||
// Parse JSON format: { "ip"/"host": "...", "port": ..., "username": "...", "password": "..." }
|
||||
fn parse_dynamic_proxy_json(body: &str) -> Result<ProxySettings, String> {
|
||||
let json: serde_json::Value =
|
||||
serde_json::from_str(body).map_err(|e| format!("Invalid JSON response: {e}"))?;
|
||||
|
||||
let obj = json
|
||||
.as_object()
|
||||
.ok_or_else(|| "JSON response is not an object".to_string())?;
|
||||
|
||||
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())?;
|
||||
|
||||
// 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")
|
||||
.and_then(|v| {
|
||||
v.as_u64()
|
||||
.or_else(|| v.as_str().and_then(|s| s.parse().ok()))
|
||||
})
|
||||
.ok_or_else(|| "Missing or invalid 'port' field in JSON response".to_string())?
|
||||
as u16;
|
||||
|
||||
let proxy_type = obj
|
||||
.get("type")
|
||||
.or_else(|| obj.get("proxy_type"))
|
||||
.or_else(|| obj.get("protocol"))
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_lowercase())
|
||||
.or(protocol_from_host)
|
||||
.unwrap_or_else(|| "http".to_string());
|
||||
|
||||
let username = obj
|
||||
.get("username")
|
||||
.or_else(|| obj.get("user"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
let password = obj
|
||||
.get("password")
|
||||
.or_else(|| obj.get("pass"))
|
||||
.and_then(|v| v.as_str())
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
Ok(ProxySettings {
|
||||
proxy_type,
|
||||
host,
|
||||
port,
|
||||
username,
|
||||
password,
|
||||
})
|
||||
}
|
||||
|
||||
// Parse text format using the same logic as proxy import
|
||||
fn parse_dynamic_proxy_text(body: &str) -> Result<ProxySettings, String> {
|
||||
let line = body
|
||||
.lines()
|
||||
.find(|l| !l.trim().is_empty())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
if line.is_empty() {
|
||||
return Err("Empty text response".to_string());
|
||||
}
|
||||
|
||||
match Self::parse_single_proxy_line(line) {
|
||||
ProxyParseResult::Parsed(parsed) => Ok(ProxySettings {
|
||||
proxy_type: parsed.proxy_type,
|
||||
host: parsed.host,
|
||||
port: parsed.port,
|
||||
username: parsed.username,
|
||||
password: parsed.password,
|
||||
}),
|
||||
ProxyParseResult::Ambiguous {
|
||||
possible_formats, ..
|
||||
} => Err(format!(
|
||||
"Ambiguous proxy format. Could be: {}",
|
||||
possible_formats.join(" or ")
|
||||
)),
|
||||
ProxyParseResult::Invalid { reason, .. } => {
|
||||
Err(format!("Failed to parse proxy response: {reason}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve dynamic proxy: fetch from URL and return settings
|
||||
pub async fn resolve_dynamic_proxy(&self, proxy_id: &str) -> Result<ProxySettings, String> {
|
||||
let (url, format) = {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
let proxy = stored_proxies
|
||||
.get(proxy_id)
|
||||
.ok_or_else(|| format!("Proxy '{proxy_id}' not found"))?;
|
||||
|
||||
match (&proxy.dynamic_proxy_url, &proxy.dynamic_proxy_format) {
|
||||
(Some(url), Some(format)) => (url.clone(), format.clone()),
|
||||
_ => return Err("Proxy is not a dynamic proxy".to_string()),
|
||||
}
|
||||
};
|
||||
|
||||
self.fetch_dynamic_proxy(&url, &format).await
|
||||
}
|
||||
|
||||
// Create a dynamic stored proxy
|
||||
pub fn create_dynamic_proxy(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
name: String,
|
||||
url: String,
|
||||
format: String,
|
||||
) -> Result<StoredProxy, String> {
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
if stored_proxies.values().any(|p| p.name == name) {
|
||||
return Err(format!("Proxy with name '{name}' already exists"));
|
||||
}
|
||||
}
|
||||
|
||||
let placeholder_settings = ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "dynamic".to_string(),
|
||||
port: 0,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
let mut stored_proxy = StoredProxy::new(name, placeholder_settings);
|
||||
stored_proxy.dynamic_proxy_url = Some(url);
|
||||
stored_proxy.dynamic_proxy_format = Some(format);
|
||||
|
||||
{
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone());
|
||||
}
|
||||
|
||||
if let Err(e) = self.save_proxy(&stored_proxy) {
|
||||
log::warn!("Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = events::emit_empty("proxies-changed") {
|
||||
log::error!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
if stored_proxy.sync_enabled {
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let id = stored_proxy.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_proxy_sync(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(stored_proxy)
|
||||
}
|
||||
|
||||
// Update a dynamic proxy's URL and format
|
||||
pub fn update_dynamic_proxy(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
proxy_id: &str,
|
||||
name: Option<String>,
|
||||
url: Option<String>,
|
||||
format: Option<String>,
|
||||
) -> Result<StoredProxy, String> {
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
if !stored_proxies.contains_key(proxy_id) {
|
||||
return Err(format!("Proxy with ID '{proxy_id}' not found"));
|
||||
}
|
||||
if let Some(ref new_name) = name {
|
||||
if stored_proxies
|
||||
.values()
|
||||
.any(|p| p.id != proxy_id && p.name == *new_name)
|
||||
{
|
||||
return Err(format!("Proxy with name '{new_name}' already exists"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let updated_proxy = {
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
let stored_proxy = stored_proxies.get_mut(proxy_id).unwrap();
|
||||
|
||||
if let Some(new_name) = name {
|
||||
stored_proxy.update_name(new_name);
|
||||
}
|
||||
if let Some(new_url) = url {
|
||||
stored_proxy.dynamic_proxy_url = Some(new_url);
|
||||
}
|
||||
if let Some(new_format) = format {
|
||||
stored_proxy.dynamic_proxy_format = Some(new_format);
|
||||
}
|
||||
|
||||
stored_proxy.clone()
|
||||
};
|
||||
|
||||
if let Err(e) = self.save_proxy(&updated_proxy) {
|
||||
log::warn!("Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = events::emit_empty("proxies-changed") {
|
||||
log::error!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
if updated_proxy.sync_enabled {
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let id = updated_proxy.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_proxy_sync(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updated_proxy)
|
||||
}
|
||||
|
||||
// Export all proxies as JSON
|
||||
pub fn export_proxies_json(&self) -> Result<String, String> {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
@@ -2439,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();
|
||||
|
||||
@@ -2835,6 +3211,8 @@ mod tests {
|
||||
geo_region: None,
|
||||
geo_city: None,
|
||||
geo_isp: None,
|
||||
dynamic_proxy_url: None,
|
||||
dynamic_proxy_format: None,
|
||||
};
|
||||
|
||||
// Before migration
|
||||
@@ -3112,4 +3490,235 @@ mod tests {
|
||||
|
||||
delete_proxy_config(&id);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_standard_format() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "user1", "password": "pass1"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.host, "1.2.3.4");
|
||||
assert_eq!(result.port, 8080);
|
||||
assert_eq!(result.proxy_type, "http");
|
||||
assert_eq!(result.username.as_deref(), Some("user1"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_host_alias() {
|
||||
let body = r#"{"host": "proxy.example.com", "port": 3128}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 3128);
|
||||
assert!(result.username.is_none());
|
||||
assert!(result.password.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_user_pass_aliases() {
|
||||
let body = r#"{"ip": "10.0.0.1", "port": 1080, "user": "u", "pass": "p"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.username.as_deref(), Some("u"));
|
||||
assert_eq!(result.password.as_deref(), Some("p"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_port_as_string() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": "9090"}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert_eq!(result.port, 9090);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_with_proxy_type() {
|
||||
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": 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]
|
||||
fn test_parse_dynamic_proxy_json_empty_credentials_treated_as_none() {
|
||||
let body = r#"{"ip": "1.2.3.4", "port": 8080, "username": "", "password": ""}"#;
|
||||
let result = ProxyManager::parse_dynamic_proxy_json(body).unwrap();
|
||||
assert!(result.username.is_none());
|
||||
assert!(result.password.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_missing_ip() {
|
||||
let body = r#"{"port": 8080}"#;
|
||||
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
|
||||
assert!(err.contains("ip") || err.contains("host"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_missing_port() {
|
||||
let body = r#"{"ip": "1.2.3.4"}"#;
|
||||
let err = ProxyManager::parse_dynamic_proxy_json(body).unwrap_err();
|
||||
assert!(err.contains("port"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_invalid_json() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_json("not json").unwrap_err();
|
||||
assert!(err.contains("Invalid JSON"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_json_not_object() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_json("[1,2,3]").unwrap_err();
|
||||
assert!(err.contains("not an object"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_host_port_user_pass() {
|
||||
let body = "proxy.example.com:8080:user1:pass1";
|
||||
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 8080);
|
||||
assert_eq!(result.username.as_deref(), Some("user1"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_protocol_url_format() {
|
||||
let body = "http://user:pass@proxy.example.com:3128";
|
||||
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 3128);
|
||||
assert_eq!(result.proxy_type, "http");
|
||||
assert_eq!(result.username.as_deref(), Some("user"));
|
||||
assert_eq!(result.password.as_deref(), Some("pass"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_with_whitespace() {
|
||||
let body = " \n proxy.example.com:8080:user:pass \n ";
|
||||
let result = ProxyManager::parse_dynamic_proxy_text(body).unwrap();
|
||||
assert_eq!(result.host, "proxy.example.com");
|
||||
assert_eq!(result.port, 8080);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_empty() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_text("").unwrap_err();
|
||||
assert!(err.contains("Empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_dynamic_proxy_text_whitespace_only() {
|
||||
let err = ProxyManager::parse_dynamic_proxy_text(" \n \n ").unwrap_err();
|
||||
assert!(err.contains("Empty"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_stored_proxy_is_dynamic() {
|
||||
let mut proxy = StoredProxy::new(
|
||||
"test".to_string(),
|
||||
ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "h.com".to_string(),
|
||||
port: 80,
|
||||
username: None,
|
||||
password: None,
|
||||
},
|
||||
);
|
||||
assert!(!proxy.is_dynamic());
|
||||
|
||||
proxy.dynamic_proxy_url = Some("https://api.example.com/proxy".to_string());
|
||||
assert!(proxy.is_dynamic());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_dynamic_proxy_via_manager() {
|
||||
let pm = ProxyManager::new();
|
||||
|
||||
let mut proxy = StoredProxy::new(
|
||||
"DynTest".to_string(),
|
||||
ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "dynamic".to_string(),
|
||||
port: 0,
|
||||
username: None,
|
||||
password: None,
|
||||
},
|
||||
);
|
||||
proxy.dynamic_proxy_url = Some("https://api.example.com/proxy".to_string());
|
||||
proxy.dynamic_proxy_format = Some("json".to_string());
|
||||
|
||||
let id = proxy.id.clone();
|
||||
pm.stored_proxies.lock().unwrap().insert(id.clone(), proxy);
|
||||
|
||||
assert!(pm.is_dynamic_proxy(&id));
|
||||
assert!(!pm.is_dynamic_proxy("nonexistent"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_dynamic_proxy_not_dynamic() {
|
||||
let pm = ProxyManager::new();
|
||||
|
||||
let proxy = StoredProxy::new(
|
||||
"Regular".to_string(),
|
||||
ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "1.2.3.4".to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
},
|
||||
);
|
||||
let id = proxy.id.clone();
|
||||
pm.stored_proxies.lock().unwrap().insert(id.clone(), proxy);
|
||||
|
||||
let err = pm.resolve_dynamic_proxy(&id).await.unwrap_err();
|
||||
assert!(err.contains("not a dynamic proxy"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_dynamic_proxy_not_found() {
|
||||
let pm = ProxyManager::new();
|
||||
|
||||
let err = pm.resolve_dynamic_proxy("nonexistent").await.unwrap_err();
|
||||
assert!(err.contains("not found"));
|
||||
}
|
||||
}
|
||||
|
||||
+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) => {
|
||||
|
||||
@@ -178,10 +178,8 @@ impl SettingsManager {
|
||||
}
|
||||
|
||||
pub fn should_show_launch_on_login_prompt(&self) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let settings = self.load_settings()?;
|
||||
// Show if: user has NOT declined AND autostart is NOT enabled
|
||||
let autostart_enabled = crate::daemon::autostart::is_autostart_enabled();
|
||||
Ok(!settings.launch_on_login_declined && !autostart_enabled)
|
||||
// Daemon is currently disabled, never show this prompt
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub fn decline_launch_on_login(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -200,7 +198,7 @@ impl SettingsManager {
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Generate a secure random token (base64 encoded for URL safety)
|
||||
let token_bytes: [u8; 32] = {
|
||||
use rand::RngCore;
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
let mut bytes = [0u8; 32];
|
||||
rng.fill_bytes(&mut bytes);
|
||||
@@ -390,7 +388,7 @@ impl SettingsManager {
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let token_bytes: [u8; 32] = {
|
||||
use rand::RngCore;
|
||||
use rand::Rng;
|
||||
let mut rng = rand::rng();
|
||||
let mut bytes = [0u8; 32];
|
||||
rng.fill_bytes(&mut bytes);
|
||||
@@ -734,11 +732,17 @@ pub async fn save_app_settings(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to store API token: {e}"))?;
|
||||
} else {
|
||||
let token = manager
|
||||
.generate_api_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to generate API token: {e}"))?;
|
||||
settings.api_token = Some(token);
|
||||
// Check if a token already exists on disk before generating a new one
|
||||
let existing = manager.get_api_token(&app_handle).await.ok().flatten();
|
||||
if let Some(t) = existing {
|
||||
settings.api_token = Some(t);
|
||||
} else {
|
||||
let token = manager
|
||||
.generate_api_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to generate API token: {e}"))?;
|
||||
settings.api_token = Some(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -758,11 +762,17 @@ pub async fn save_app_settings(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to store MCP token: {e}"))?;
|
||||
} else {
|
||||
let token = manager
|
||||
.generate_mcp_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to generate MCP token: {e}"))?;
|
||||
settings.mcp_token = Some(token);
|
||||
// Check if a token already exists on disk before generating a new one
|
||||
let existing = manager.get_mcp_token(&app_handle).await.ok().flatten();
|
||||
if let Some(t) = existing {
|
||||
settings.mcp_token = Some(t);
|
||||
} else {
|
||||
let token = manager
|
||||
.generate_mcp_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to generate MCP token: {e}"))?;
|
||||
settings.mcp_token = Some(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -935,6 +945,42 @@ pub fn get_system_language() -> String {
|
||||
.unwrap_or_else(|| "en".to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Clone)]
|
||||
pub struct SystemInfo {
|
||||
pub app_version: String,
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub portable: bool,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_system_info() -> SystemInfo {
|
||||
let os = if cfg!(target_os = "macos") {
|
||||
"macOS"
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"Windows"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"Linux"
|
||||
} else {
|
||||
"Unknown"
|
||||
};
|
||||
|
||||
let arch = if cfg!(target_arch = "x86_64") {
|
||||
"x86_64"
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
"aarch64"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
SystemInfo {
|
||||
app_version: crate::app_auto_updater::AppAutoUpdater::get_current_version(),
|
||||
os: os.to_string(),
|
||||
arch: arch.to_string(),
|
||||
portable: crate::app_dirs::is_portable(),
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref SETTINGS_MANAGER: SettingsManager = SettingsManager::new();
|
||||
|
||||
@@ -127,6 +127,14 @@ impl SyncClient {
|
||||
}
|
||||
|
||||
pub async fn list(&self, prefix: &str) -> SyncResult<ListResponse> {
|
||||
self.list_page(prefix, None).await
|
||||
}
|
||||
|
||||
async fn list_page(
|
||||
&self,
|
||||
prefix: &str,
|
||||
continuation_token: Option<String>,
|
||||
) -> SyncResult<ListResponse> {
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url("list"))
|
||||
@@ -134,7 +142,7 @@ impl SyncClient {
|
||||
.json(&ListRequest {
|
||||
prefix: prefix.to_string(),
|
||||
max_keys: Some(1000),
|
||||
continuation_token: None,
|
||||
continuation_token,
|
||||
})
|
||||
.send()
|
||||
.await
|
||||
@@ -152,6 +160,27 @@ impl SyncClient {
|
||||
.map_err(|e| SyncError::SerializationError(e.to_string()))
|
||||
}
|
||||
|
||||
/// List all objects under a prefix, paginating through all results
|
||||
pub async fn list_all(&self, prefix: &str) -> SyncResult<Vec<ListObject>> {
|
||||
let mut all_objects = Vec::new();
|
||||
let mut continuation_token: Option<String> = None;
|
||||
|
||||
loop {
|
||||
let response = self.list_page(prefix, continuation_token).await?;
|
||||
all_objects.extend(response.objects);
|
||||
|
||||
if !response.is_truncated {
|
||||
break;
|
||||
}
|
||||
continuation_token = response.next_continuation_token;
|
||||
if continuation_token.is_none() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_objects)
|
||||
}
|
||||
|
||||
pub async fn upload_bytes(
|
||||
&self,
|
||||
presigned_url: &str,
|
||||
|
||||
+474
-45
@@ -9,7 +9,7 @@ use crate::settings_manager::SettingsManager;
|
||||
use chrono::{DateTime, Utc};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::Instant;
|
||||
@@ -49,6 +49,70 @@ fn is_critical_file(path: &str) -> bool {
|
||||
.any(|pattern| path.contains(pattern))
|
||||
}
|
||||
|
||||
/// Checkpoint all SQLite WAL files in a profile directory.
|
||||
///
|
||||
/// When a browser crashes or is killed, SQLite WAL files may contain
|
||||
/// uncommitted data (e.g. cookies, login data). Since WAL files are
|
||||
/// excluded from sync, we must checkpoint them into the main database
|
||||
/// files before generating the manifest to avoid data loss.
|
||||
fn checkpoint_sqlite_wal_files(profile_dir: &Path) {
|
||||
fn find_wal_files(dir: &Path, wal_files: &mut Vec<PathBuf>) {
|
||||
let Ok(entries) = fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
find_wal_files(&path, wal_files);
|
||||
} else if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name.ends_with("-wal") {
|
||||
wal_files.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut wal_files = Vec::new();
|
||||
find_wal_files(profile_dir, &mut wal_files);
|
||||
|
||||
for wal_path in &wal_files {
|
||||
// Only checkpoint non-empty WAL files
|
||||
let is_non_empty = fs::metadata(wal_path).map(|m| m.len() > 0).unwrap_or(false);
|
||||
if !is_non_empty {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Derive the main database path by stripping the "-wal" suffix
|
||||
let db_path_str = wal_path.to_string_lossy();
|
||||
let db_path = PathBuf::from(db_path_str.strip_suffix("-wal").unwrap());
|
||||
|
||||
if !db_path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match rusqlite::Connection::open(&db_path) {
|
||||
Ok(conn) => match conn.pragma_update(None, "wal_checkpoint", "TRUNCATE") {
|
||||
Ok(_) => {
|
||||
log::info!(
|
||||
"Checkpointed WAL for: {}",
|
||||
db_path.file_name().unwrap_or_default().to_string_lossy()
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to checkpoint WAL for {}: {}", db_path.display(), e);
|
||||
}
|
||||
},
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
"Failed to open DB for WAL checkpoint {}: {}",
|
||||
db_path.display(),
|
||||
e
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resume state persisted to disk so interrupted syncs can continue
|
||||
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||
struct SyncResumeState {
|
||||
@@ -268,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
|
||||
@@ -362,6 +426,10 @@ impl SyncEngine {
|
||||
))
|
||||
})?;
|
||||
|
||||
// Checkpoint any SQLite WAL files to ensure all data is in the main DB
|
||||
// before we generate the manifest (WAL files are excluded from sync)
|
||||
checkpoint_sqlite_wal_files(&profile_dir);
|
||||
|
||||
// Load or create hash cache
|
||||
let cache_path = get_cache_path(&profile_dir);
|
||||
let mut hash_cache = HashCache::load(&cache_path);
|
||||
@@ -392,7 +460,9 @@ impl SyncEngine {
|
||||
|
||||
// Try to download remote manifest
|
||||
let remote_manifest_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id);
|
||||
let remote_manifest = self.download_manifest(&remote_manifest_key).await?;
|
||||
let remote_manifest = self
|
||||
.download_manifest(&remote_manifest_key, encryption_key.as_ref())
|
||||
.await?;
|
||||
|
||||
// Compute diff
|
||||
let diff = compute_diff(&local_manifest, remote_manifest.as_ref());
|
||||
@@ -488,11 +558,29 @@ impl SyncEngine {
|
||||
.upload_profile_metadata(&profile_id, profile, &key_prefix)
|
||||
.await?;
|
||||
|
||||
// If we recovered from an empty local state (downloaded everything from remote),
|
||||
// regenerate the manifest from the actual files now on disk so we don't
|
||||
// overwrite the remote manifest with an empty one.
|
||||
let final_manifest = if local_manifest.files.is_empty() && !diff.files_to_download.is_empty() {
|
||||
let mut new_cache = HashCache::load(&cache_path);
|
||||
let mut regenerated = generate_manifest(&profile_id, &profile_dir, &mut new_cache)?;
|
||||
new_cache.save(&cache_path)?;
|
||||
regenerated.encrypted = encryption_key.is_some();
|
||||
regenerated
|
||||
} else {
|
||||
let mut m = local_manifest;
|
||||
m.encrypted = encryption_key.is_some();
|
||||
m
|
||||
};
|
||||
|
||||
// Upload manifest.json last for atomicity
|
||||
let mut final_manifest = local_manifest;
|
||||
final_manifest.encrypted = encryption_key.is_some();
|
||||
self
|
||||
.upload_manifest(&profile_id, &final_manifest, &key_prefix)
|
||||
.upload_manifest(
|
||||
&profile_id,
|
||||
&final_manifest,
|
||||
encryption_key.as_ref(),
|
||||
&key_prefix,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Sync completed successfully — clean up resume state
|
||||
@@ -509,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(
|
||||
@@ -533,7 +641,11 @@ impl SyncEngine {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn download_manifest(&self, key: &str) -> SyncResult<Option<SyncManifest>> {
|
||||
async fn download_manifest(
|
||||
&self,
|
||||
key: &str,
|
||||
encryption_key: Option<&[u8; 32]>,
|
||||
) -> SyncResult<Option<SyncManifest>> {
|
||||
let stat = self.client.stat(key).await?;
|
||||
if !stat.exists {
|
||||
return Ok(None);
|
||||
@@ -542,35 +654,136 @@ impl SyncEngine {
|
||||
let presign = self.client.presign_download(key).await?;
|
||||
let data = self.client.download_bytes(&presign.url).await?;
|
||||
|
||||
let manifest: SyncManifest = serde_json::from_slice(&data)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to parse manifest: {e}")))?;
|
||||
// Try parsing as plaintext JSON first (unencrypted or backwards-compatible)
|
||||
if let Ok(manifest) = serde_json::from_slice::<SyncManifest>(&data) {
|
||||
return Ok(Some(manifest));
|
||||
}
|
||||
|
||||
Ok(Some(manifest))
|
||||
// If plaintext parse failed and we have an encryption key, try decrypting
|
||||
if let Some(key) = encryption_key {
|
||||
let decrypted = encryption::decrypt_bytes(key, &data)
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to decrypt manifest: {e}")))?;
|
||||
let manifest: SyncManifest = serde_json::from_slice(&decrypted).map_err(|e| {
|
||||
SyncError::SerializationError(format!("Failed to parse decrypted manifest: {e}"))
|
||||
})?;
|
||||
return Ok(Some(manifest));
|
||||
}
|
||||
|
||||
Err(SyncError::SerializationError(
|
||||
"Failed to parse manifest (not valid JSON and no encryption key available)".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn upload_manifest(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
manifest: &SyncManifest,
|
||||
encryption_key: Option<&[u8; 32]>,
|
||||
key_prefix: &str,
|
||||
) -> SyncResult<()> {
|
||||
let json = serde_json::to_string_pretty(manifest)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize manifest: {e}")))?;
|
||||
|
||||
let upload_data = if let Some(key) = encryption_key {
|
||||
encryption::encrypt_bytes(key, json.as_bytes())
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to encrypt manifest: {e}")))?
|
||||
} else {
|
||||
json.into_bytes()
|
||||
};
|
||||
|
||||
let content_type = if encryption_key.is_some() {
|
||||
"application/octet-stream"
|
||||
} else {
|
||||
"application/json"
|
||||
};
|
||||
|
||||
let remote_key = format!("{}profiles/{}/manifest.json", key_prefix, profile_id);
|
||||
let presign = self
|
||||
.client
|
||||
.presign_upload(&remote_key, Some("application/json"))
|
||||
.presign_upload(&remote_key, Some(content_type))
|
||||
.await?;
|
||||
|
||||
self
|
||||
.client
|
||||
.upload_bytes(&presign.url, json.as_bytes(), Some("application/json"))
|
||||
.upload_bytes(&presign.url, &upload_data, Some(content_type))
|
||||
.await?;
|
||||
|
||||
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,
|
||||
@@ -580,6 +793,7 @@ impl SyncEngine {
|
||||
let mut sanitized = profile.clone();
|
||||
sanitized.process_id = None;
|
||||
sanitized.last_launch = None;
|
||||
sanitized.last_sync = None; // Avoid triggering sync loop on timestamp change
|
||||
|
||||
let json = serde_json::to_string_pretty(&sanitized)
|
||||
.map_err(|e| SyncError::SerializationError(format!("Failed to serialize profile: {e}")))?;
|
||||
@@ -2059,16 +2273,9 @@ impl SyncEngine {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
// Download manifest
|
||||
let manifest = self.download_manifest(&manifest_key).await?;
|
||||
let Some(manifest) = manifest else {
|
||||
return Err(SyncError::InvalidData(
|
||||
"Remote manifest not found".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// If remote manifest is encrypted, we need the E2E password
|
||||
let encryption_key = if manifest.encrypted {
|
||||
// Derive encryption key before downloading manifest if profile uses encrypted sync.
|
||||
// The manifest itself may be encrypted (new behavior) or plaintext (backwards compat).
|
||||
let encryption_key = if profile.is_encrypted_sync() {
|
||||
let password = encryption::load_e2e_password()
|
||||
.map_err(|e| SyncError::InvalidData(format!("Failed to load E2E password: {e}")))?
|
||||
.ok_or_else(|| {
|
||||
@@ -2087,6 +2294,16 @@ impl SyncEngine {
|
||||
None
|
||||
};
|
||||
|
||||
// Download manifest (may be encrypted for e2e profiles)
|
||||
let manifest = self
|
||||
.download_manifest(&manifest_key, encryption_key.as_ref())
|
||||
.await?;
|
||||
let Some(manifest) = manifest else {
|
||||
return Err(SyncError::InvalidData(
|
||||
"Remote manifest not found".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
// Ensure profile directory exists
|
||||
fs::create_dir_all(&profile_dir).map_err(|e| {
|
||||
SyncError::IoError(format!(
|
||||
@@ -2125,6 +2342,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 {
|
||||
@@ -2165,14 +2418,14 @@ impl SyncEngine {
|
||||
) -> SyncResult<Vec<String>> {
|
||||
log::info!("Checking for missing synced profiles...");
|
||||
|
||||
// List personal profiles from S3
|
||||
let list_response = self.client.list("profiles/").await?;
|
||||
// List all personal profiles from S3 (paginated)
|
||||
let all_objects = self.client.list_all("profiles/").await?;
|
||||
|
||||
let mut downloaded: Vec<String> = Vec::new();
|
||||
|
||||
// Extract unique profile IDs with their key prefix
|
||||
let mut profiles_to_check: HashMap<String, String> = HashMap::new();
|
||||
for obj in list_response.objects {
|
||||
for obj in all_objects {
|
||||
if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") {
|
||||
if let Some(profile_id) = obj
|
||||
.key
|
||||
@@ -2189,8 +2442,8 @@ impl SyncEngine {
|
||||
if let Some(team_id) = &auth.user.team_id {
|
||||
let team_prefix = format!("teams/{}/", team_id);
|
||||
let team_list_key = format!("{}profiles/", team_prefix);
|
||||
if let Ok(team_list) = self.client.list(&team_list_key).await {
|
||||
for obj in team_list.objects {
|
||||
if let Ok(team_objects) = self.client.list_all(&team_list_key).await {
|
||||
for obj in team_objects {
|
||||
if obj.key.starts_with("profiles/") && obj.key.ends_with("/manifest.json") {
|
||||
if let Some(profile_id) = obj
|
||||
.key
|
||||
@@ -2238,6 +2491,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
|
||||
@@ -2608,15 +2909,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());
|
||||
}
|
||||
@@ -3341,3 +3635,138 @@ pub async fn set_extension_group_sync_enabled(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_sqlite_wal_files() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
|
||||
// Create a SQLite database in WAL mode and insert data.
|
||||
// Use std::mem::forget to prevent the connection destructor from running,
|
||||
// which simulates a browser crash where WAL is not checkpointed.
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
||||
conn.pragma_update(None, "wal_autocheckpoint", "0").unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"CREATE TABLE cookies (id INTEGER PRIMARY KEY, value TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies (value) VALUES ('session_token_123')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
// Leak the connection to prevent auto-checkpoint on drop
|
||||
std::mem::forget(conn);
|
||||
}
|
||||
|
||||
// Verify WAL file exists and has data
|
||||
let wal_path = temp_dir.path().join("test.db-wal");
|
||||
assert!(wal_path.exists(), "WAL file should exist");
|
||||
let wal_size = fs::metadata(&wal_path).unwrap().len();
|
||||
assert!(wal_size > 0, "WAL file should be non-empty");
|
||||
|
||||
// Run checkpoint
|
||||
checkpoint_sqlite_wal_files(temp_dir.path());
|
||||
|
||||
// After checkpoint, WAL should be truncated (empty)
|
||||
let wal_size_after = fs::metadata(&wal_path).map(|m| m.len()).unwrap_or(0);
|
||||
assert_eq!(
|
||||
wal_size_after, 0,
|
||||
"WAL should be truncated after checkpoint"
|
||||
);
|
||||
|
||||
// Verify data is still accessible from the main database
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
let value: String = conn
|
||||
.query_row("SELECT value FROM cookies WHERE id = 1", [], |row| {
|
||||
row.get(0)
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(value, "session_token_123");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_handles_missing_db() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
|
||||
// Create a WAL file without a corresponding database
|
||||
let wal_path = temp_dir.path().join("missing.db-wal");
|
||||
fs::write(&wal_path, b"fake wal data").unwrap();
|
||||
|
||||
// Should not panic
|
||||
checkpoint_sqlite_wal_files(temp_dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_skips_empty_wal() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let db_path = temp_dir.path().join("test.db");
|
||||
|
||||
// Create a database and checkpoint immediately (WAL is empty)
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
||||
conn
|
||||
.execute("CREATE TABLE t (id INTEGER PRIMARY KEY)", [])
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
// Create an empty WAL file
|
||||
let wal_path = temp_dir.path().join("test.db-wal");
|
||||
fs::write(&wal_path, b"").unwrap();
|
||||
|
||||
// Should skip empty WAL without error
|
||||
checkpoint_sqlite_wal_files(temp_dir.path());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_checkpoint_nested_directories() {
|
||||
let temp_dir = tempfile::TempDir::new().unwrap();
|
||||
let nested_dir = temp_dir.path().join("profile").join("Default");
|
||||
fs::create_dir_all(&nested_dir).unwrap();
|
||||
|
||||
let db_path = nested_dir.join("Cookies");
|
||||
|
||||
// Create a database with WAL data, leak connection to simulate crash
|
||||
{
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
conn.pragma_update(None, "journal_mode", "WAL").unwrap();
|
||||
conn.pragma_update(None, "wal_autocheckpoint", "0").unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"CREATE TABLE cookies (host_key TEXT, name TEXT, value TEXT)",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
conn
|
||||
.execute(
|
||||
"INSERT INTO cookies VALUES ('.example.com', 'session', 'abc')",
|
||||
[],
|
||||
)
|
||||
.unwrap();
|
||||
std::mem::forget(conn);
|
||||
}
|
||||
|
||||
let wal_path = nested_dir.join("Cookies-wal");
|
||||
assert!(wal_path.exists());
|
||||
|
||||
// Checkpoint from the top-level directory
|
||||
checkpoint_sqlite_wal_files(temp_dir.path());
|
||||
|
||||
// Verify data is in the main database
|
||||
let conn = rusqlite::Connection::open(&db_path).unwrap();
|
||||
let count: i64 = conn
|
||||
.query_row("SELECT COUNT(*) FROM cookies", [], |row| row.get(0))
|
||||
.unwrap();
|
||||
assert_eq!(count, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,12 @@ use std::path::Path;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use super::types::{SyncError, SyncResult};
|
||||
use crate::profile::types::BrowserProfile;
|
||||
|
||||
/// Default exclude patterns for volatile browser profile files.
|
||||
/// 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 +23,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 +31,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/**",
|
||||
];
|
||||
|
||||
@@ -206,6 +210,39 @@ fn hash_file(path: &Path) -> Result<Option<String>, SyncError> {
|
||||
Ok(Some(hasher.finalize().to_hex().to_string()))
|
||||
}
|
||||
|
||||
/// Compute blake3 hash of metadata.json after sanitizing volatile fields.
|
||||
/// This prevents infinite sync loops where updating last_sync triggers a new sync.
|
||||
fn hash_sanitized_metadata(path: &Path) -> Result<Option<String>, SyncError> {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => {
|
||||
return Err(SyncError::IoError(format!(
|
||||
"Failed to read metadata at {}: {e}",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
};
|
||||
|
||||
let mut profile: BrowserProfile = serde_json::from_str(&content).map_err(|e| {
|
||||
SyncError::SerializationError(format!("Failed to parse metadata for hashing: {e}"))
|
||||
})?;
|
||||
|
||||
// Sanitize volatile fields that should not trigger a re-sync
|
||||
profile.last_sync = None;
|
||||
profile.process_id = None;
|
||||
profile.last_launch = None;
|
||||
|
||||
let sanitized_json = serde_json::to_string(&profile).map_err(|e| {
|
||||
SyncError::SerializationError(format!("Failed to serialize sanitized metadata: {e}"))
|
||||
})?;
|
||||
|
||||
let mut hasher = blake3::Hasher::new();
|
||||
hasher.update(sanitized_json.as_bytes());
|
||||
|
||||
Ok(Some(hasher.finalize().to_hex().to_string()))
|
||||
}
|
||||
|
||||
/// Get mtime as unix timestamp
|
||||
/// Returns None if the file doesn't exist (was deleted)
|
||||
fn get_mtime(path: &Path) -> Result<Option<i64>, SyncError> {
|
||||
@@ -321,7 +358,19 @@ pub fn generate_manifest(
|
||||
*max_mtime = (*max_mtime).max(mtime);
|
||||
|
||||
// Check cache for existing hash
|
||||
let hash = if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
|
||||
let hash = if relative_path == "metadata.json" {
|
||||
// Special case: sanitize metadata.json before hashing to prevent sync loops
|
||||
match hash_sanitized_metadata(&path)? {
|
||||
Some(computed_hash) => computed_hash,
|
||||
None => {
|
||||
log::debug!(
|
||||
"File disappeared during manifest generation, skipping: {}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else if let Some(cached_hash) = cache.get(&relative_path, size, mtime) {
|
||||
cached_hash.to_string()
|
||||
} else {
|
||||
match hash_file(&path)? {
|
||||
@@ -408,6 +457,19 @@ pub fn compute_diff(local: &SyncManifest, remote: Option<&SyncManifest>) -> Mani
|
||||
let remote_files: HashMap<&str, &ManifestFileEntry> =
|
||||
remote.files.iter().map(|f| (f.path.as_str(), f)).collect();
|
||||
|
||||
// Safety: if local is empty but remote has files, always download from remote.
|
||||
// This prevents data loss when profile data files are deleted but metadata
|
||||
// survives — the newly generated manifest would have updated_at=NOW, which
|
||||
// would appear "newer" and cause all remote files to be deleted.
|
||||
if local.files.is_empty() && !remote.files.is_empty() {
|
||||
log::info!(
|
||||
"Local manifest is empty but remote has {} files — downloading from remote to recover",
|
||||
remote.files.len()
|
||||
);
|
||||
diff.files_to_download = remote.files.clone();
|
||||
return diff;
|
||||
}
|
||||
|
||||
// Compare timestamps to determine direction
|
||||
let local_updated = local.updated_at_datetime();
|
||||
let remote_updated = remote.updated_at_datetime();
|
||||
@@ -576,7 +638,12 @@ mod tests {
|
||||
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
|
||||
|
||||
// metadata.json at root
|
||||
fs::write(profile_dir.join("metadata.json"), "keep").unwrap();
|
||||
let profile = BrowserProfile::default();
|
||||
fs::write(
|
||||
profile_dir.join("metadata.json"),
|
||||
serde_json::to_string(&profile).unwrap(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let mut cache = HashCache::default();
|
||||
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
|
||||
@@ -738,4 +805,131 @@ mod tests {
|
||||
let deserialized: SyncManifest = serde_json::from_str(&serialized).unwrap();
|
||||
assert!(deserialized.encrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_diff_empty_local_downloads_from_remote() {
|
||||
// When local has no files but remote does, always download from remote.
|
||||
// This prevents data loss when profile data is deleted but metadata survives.
|
||||
let local = SyncManifest {
|
||||
version: 1,
|
||||
profile_id: "test".to_string(),
|
||||
generated_at: Utc::now().to_rfc3339(),
|
||||
updated_at: Utc::now().to_rfc3339(), // NOW — appears newer than remote
|
||||
exclude_globs: vec![],
|
||||
files: vec![],
|
||||
encrypted: false,
|
||||
};
|
||||
|
||||
let remote = SyncManifest {
|
||||
version: 1,
|
||||
profile_id: "test".to_string(),
|
||||
generated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
exclude_globs: vec![],
|
||||
files: vec![
|
||||
ManifestFileEntry {
|
||||
path: "Cookies".to_string(),
|
||||
size: 100,
|
||||
mtime: 1000,
|
||||
hash: "abc".to_string(),
|
||||
},
|
||||
ManifestFileEntry {
|
||||
path: "Local State".to_string(),
|
||||
size: 200,
|
||||
mtime: 1000,
|
||||
hash: "def".to_string(),
|
||||
},
|
||||
],
|
||||
encrypted: false,
|
||||
};
|
||||
|
||||
let diff = compute_diff(&local, Some(&remote));
|
||||
|
||||
// Must download all remote files, NOT delete them
|
||||
assert_eq!(diff.files_to_download.len(), 2);
|
||||
assert!(diff.files_to_upload.is_empty());
|
||||
assert!(diff.files_to_delete_remote.is_empty());
|
||||
assert!(diff.files_to_delete_local.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_manifest_sanitizes_metadata() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let profile_dir = temp_dir.path().join("profile");
|
||||
fs::create_dir_all(&profile_dir).unwrap();
|
||||
|
||||
let profile_id = uuid::Uuid::new_v4();
|
||||
let metadata_path = profile_dir.join("metadata.json");
|
||||
|
||||
let profile = BrowserProfile {
|
||||
id: profile_id,
|
||||
name: "test-profile".to_string(),
|
||||
last_sync: Some(100),
|
||||
process_id: Some(1234),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
fs::write(&metadata_path, serde_json::to_string(&profile).unwrap()).unwrap();
|
||||
|
||||
let mut cache = HashCache::default();
|
||||
let manifest1 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
|
||||
let hash1 = manifest1
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.path == "metadata.json")
|
||||
.unwrap()
|
||||
.hash
|
||||
.clone();
|
||||
|
||||
// Update volatile fields
|
||||
let profile2 = BrowserProfile {
|
||||
id: profile_id,
|
||||
name: "test-profile".to_string(),
|
||||
last_sync: Some(200),
|
||||
process_id: Some(5678),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
fs::write(&metadata_path, serde_json::to_string(&profile2).unwrap()).unwrap();
|
||||
|
||||
let manifest2 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
|
||||
let hash2 = manifest2
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.path == "metadata.json")
|
||||
.unwrap()
|
||||
.hash
|
||||
.clone();
|
||||
|
||||
// Hash should be identical because volatile fields are sanitized
|
||||
assert_eq!(
|
||||
hash1, hash2,
|
||||
"Metadata hash should be stable across last_sync/process_id updates"
|
||||
);
|
||||
|
||||
// Change a non-volatile field
|
||||
let profile3 = BrowserProfile {
|
||||
id: profile_id,
|
||||
name: "changed-name".to_string(),
|
||||
last_sync: Some(200),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
fs::write(&metadata_path, serde_json::to_string(&profile3).unwrap()).unwrap();
|
||||
|
||||
let manifest3 = generate_manifest(&profile_id.to_string(), &profile_dir, &mut cache).unwrap();
|
||||
let hash3 = manifest3
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.path == "metadata.json")
|
||||
.unwrap()
|
||||
.hash
|
||||
.clone();
|
||||
|
||||
// Hash should be different because name changed
|
||||
assert_ne!(
|
||||
hash1, hash3,
|
||||
"Metadata hash should change when non-volatile fields change"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+87
-150
@@ -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() {
|
||||
@@ -396,96 +386,92 @@ impl SyncScheduler {
|
||||
ready
|
||||
};
|
||||
|
||||
// Mark all profiles as in-flight and filter out duplicates
|
||||
let mut to_sync = Vec::new();
|
||||
for profile_id in profiles_to_sync {
|
||||
// Mark as in-flight to prevent duplicate syncs
|
||||
{
|
||||
let mut in_flight = self.in_flight_profiles.lock().await;
|
||||
if in_flight.contains(&profile_id) {
|
||||
log::debug!("Profile {} already in-flight, skipping", profile_id);
|
||||
continue;
|
||||
}
|
||||
in_flight.insert(profile_id.clone());
|
||||
}
|
||||
|
||||
log::info!("Executing queued sync for profile {}", profile_id);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "syncing"
|
||||
}),
|
||||
);
|
||||
|
||||
let profile_to_sync = {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
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())
|
||||
})
|
||||
};
|
||||
|
||||
let Some(profile) = profile_to_sync else {
|
||||
// Remove from in-flight
|
||||
let mut in_flight = self.in_flight_profiles.lock().await;
|
||||
in_flight.remove(&profile_id);
|
||||
let mut in_flight = self.in_flight_profiles.lock().await;
|
||||
if in_flight.contains(&profile_id) {
|
||||
log::debug!("Profile {} already in-flight, skipping", profile_id);
|
||||
continue;
|
||||
};
|
||||
|
||||
let result = match SyncEngine::create_from_settings(app_handle).await {
|
||||
Ok(engine) => engine.sync_profile(app_handle, &profile).await,
|
||||
Err(e) => {
|
||||
log::error!("Failed to create sync engine: {}", e);
|
||||
Err(super::types::SyncError::NotConfigured)
|
||||
}
|
||||
};
|
||||
|
||||
// Remove from in-flight and check if sync just completed
|
||||
let sync_just_completed = {
|
||||
let mut in_flight = self.in_flight_profiles.lock().await;
|
||||
in_flight.remove(&profile_id);
|
||||
// If this was the last in-flight profile and there are no pending profiles, sync just completed
|
||||
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()
|
||||
};
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
log::info!("Profile {} synced successfully", profile_id);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "synced"
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to sync profile {}: {}", profile_id, e);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "error",
|
||||
"error": e.to_string()
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
in_flight.insert(profile_id.clone());
|
||||
to_sync.push(profile_id);
|
||||
}
|
||||
|
||||
// Trigger cleanup after sync completes if this was the last profile
|
||||
if sync_just_completed {
|
||||
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");
|
||||
// Sync all profiles in parallel
|
||||
let mut sync_set = tokio::task::JoinSet::new();
|
||||
for profile_id in to_sync {
|
||||
let app = app_handle.clone();
|
||||
let in_flight = self.in_flight_profiles.clone();
|
||||
sync_set.spawn(async move {
|
||||
log::info!("Executing queued sync for profile {}", profile_id);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "syncing"
|
||||
}),
|
||||
);
|
||||
|
||||
let profile_to_sync = {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager.list_profiles().ok().and_then(|profiles| {
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled())
|
||||
})
|
||||
};
|
||||
|
||||
let Some(profile) = profile_to_sync else {
|
||||
let mut inf = in_flight.lock().await;
|
||||
inf.remove(&profile_id);
|
||||
return;
|
||||
};
|
||||
|
||||
let result = match SyncEngine::create_from_settings(&app).await {
|
||||
Ok(engine) => engine.sync_profile(&app, &profile).await,
|
||||
Err(e) => {
|
||||
log::error!("Failed to create sync engine: {}", e);
|
||||
Err(super::types::SyncError::NotConfigured)
|
||||
}
|
||||
};
|
||||
|
||||
{
|
||||
let mut inf = in_flight.lock().await;
|
||||
inf.remove(&profile_id);
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(()) => {
|
||||
log::info!("Profile {} synced successfully", profile_id);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "synced"
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to sync profile {}: {}", profile_id, e);
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": "error",
|
||||
"error": e.to_string()
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -541,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);
|
||||
@@ -608,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);
|
||||
@@ -670,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);
|
||||
@@ -710,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);
|
||||
@@ -748,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/")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+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)
|
||||
}
|
||||
|
||||
@@ -143,12 +143,7 @@ impl VersionUpdater {
|
||||
pub async fn check_and_run_startup_update(
|
||||
&self,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Only run if an update is actually needed
|
||||
if !Self::should_run_background_update() {
|
||||
log::debug!("No startup version update needed");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Always check for updates on launch
|
||||
if let Some(ref app_handle) = self.app_handle {
|
||||
log::info!("Running startup version update...");
|
||||
|
||||
|
||||
@@ -73,11 +73,11 @@ struct WgRxToken {
|
||||
}
|
||||
|
||||
impl RxToken for WgRxToken {
|
||||
fn consume<R, F>(mut self, f: F) -> R
|
||||
fn consume<R, F>(self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut [u8]) -> R,
|
||||
F: FnOnce(&[u8]) -> R,
|
||||
{
|
||||
f(&mut self.data)
|
||||
f(&self.data)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,7 +173,7 @@ fn parse_cidr_address(addr: &str) -> Result<(IpCidr, IpAddress), VpnError> {
|
||||
))
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let smol_ip = smoltcp::wire::Ipv6Address::from_bytes(&v6.octets());
|
||||
let smol_ip = smoltcp::wire::Ipv6Address::from(v6.octets());
|
||||
Ok((
|
||||
IpCidr::new(IpAddress::Ipv6(smol_ip), prefix),
|
||||
IpAddress::Ipv6(smol_ip),
|
||||
@@ -331,7 +331,7 @@ impl WireGuardSocks5Server {
|
||||
// Set default gateway
|
||||
match local_ip {
|
||||
IpAddress::Ipv4(v4) => {
|
||||
let octets = v4.as_bytes();
|
||||
let octets = v4.octets();
|
||||
let gw = Ipv4Address::new(octets[0], octets[1], octets[2], 1);
|
||||
iface
|
||||
.routes_mut()
|
||||
@@ -523,7 +523,7 @@ impl WireGuardSocks5Server {
|
||||
IpAddress::Ipv4(Ipv4Address::new(o[0], o[1], o[2], o[3]))
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
IpAddress::Ipv6(smoltcp::wire::Ipv6Address::from_bytes(&v6.octets()))
|
||||
IpAddress::Ipv6(smoltcp::wire::Ipv6Address::from(v6.octets()))
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use aes_gcm::{
|
||||
Aes256Gcm, Nonce,
|
||||
};
|
||||
use chrono::Utc;
|
||||
use rand::Rng;
|
||||
use rand::RngExt;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -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>,
|
||||
}
|
||||
@@ -138,8 +136,10 @@ impl WayfernManager {
|
||||
port: u16,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("http://127.0.0.1:{port}/json/version");
|
||||
let max_attempts = 50;
|
||||
let delay = Duration::from_millis(100);
|
||||
// On first launch, macOS Gatekeeper verifies the binary which can take 30+ seconds.
|
||||
// Use a generous timeout (60s) to handle this.
|
||||
let max_attempts = 120;
|
||||
let delay = Duration::from_millis(500);
|
||||
|
||||
for attempt in 0..max_attempts {
|
||||
match self.http_client.get(&url).send().await {
|
||||
@@ -212,21 +212,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 +235,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 +450,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 +460,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 +552,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}"));
|
||||
}
|
||||
@@ -514,6 +580,15 @@ impl WayfernManager {
|
||||
args.push(format!("--load-extension={}", extension_paths.join(",")));
|
||||
}
|
||||
|
||||
// Pass wayfern token as CLI flag so the browser can gate CDP features
|
||||
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
if let Some(ref token) = wayfern_token {
|
||||
args.push(format!("--wayfern-token={token}"));
|
||||
log::info!("Wayfern token passed as CLI flag (length: {})", token.len());
|
||||
} else {
|
||||
log::warn!("No wayfern token available — CDP gated methods will be blocked");
|
||||
}
|
||||
|
||||
// Don't add URL to args - we'll navigate via CDP after setting fingerprint
|
||||
// This ensures fingerprint is applied at navigation commit time
|
||||
|
||||
@@ -674,25 +749,6 @@ impl WayfernManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Close the debugging port to prevent localhost port-scan detection.
|
||||
// Reopen on a random high port after 5s so we can still manage the browser.
|
||||
let reopen_port = port; // Reopen on same port for find_wayfern_by_profile recovery
|
||||
if let Some(target) = page_targets.first() {
|
||||
if let Some(ws_url) = &target.websocket_debugger_url {
|
||||
match self
|
||||
.send_cdp_command(
|
||||
ws_url,
|
||||
"Wayfern.closeDebuggingPort",
|
||||
json!({ "reopenPort": reopen_port, "reopenDelayMs": 30000 }),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => log::info!("Closed debugging port, will reopen on {reopen_port} after 30s"),
|
||||
Err(e) => log::warn!("Failed to close debugging port: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let instance = WayfernInstance {
|
||||
id: id.clone(),
|
||||
@@ -783,6 +839,25 @@ impl WayfernManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_cdp_port(&self, profile_path: &str) -> Option<u16> {
|
||||
let inner = self.inner.lock().await;
|
||||
let target_path = std::path::Path::new(profile_path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
||||
|
||||
for instance in inner.instances.values() {
|
||||
if let Some(path) = &instance.profile_path {
|
||||
let instance_path = std::path::Path::new(path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(path).to_path_buf());
|
||||
if instance_path == target_path {
|
||||
return instance.cdp_port;
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn find_wayfern_by_profile(&self, profile_path: &str) -> Option<WayfernLaunchResult> {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.16.1",
|
||||
"version": "0.18.1",
|
||||
"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(())
|
||||
}
|
||||
|
||||
@@ -841,3 +841,210 @@ async fn test_profile_bypass_rules_sync() {
|
||||
client.delete(&test_key, None).await.unwrap();
|
||||
client.delete(&empty_key, None).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_encrypted_profile_sync() {
|
||||
use donutbrowser_lib::sync::encryption::{
|
||||
decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt,
|
||||
};
|
||||
|
||||
ensure_sync_server_available().await;
|
||||
let client = TestClient::new();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let profile_id = uuid::Uuid::new_v4().to_string();
|
||||
let test_key = format!("profiles/{}.tar.gz.enc", profile_id);
|
||||
|
||||
let bundle = create_test_profile_bundle(temp_dir.path());
|
||||
|
||||
let salt = generate_salt();
|
||||
let password = "test-e2e-encryption-password";
|
||||
let key = derive_profile_key(password, &salt).unwrap();
|
||||
|
||||
let encrypted = encrypt_bytes(&key, &bundle).unwrap();
|
||||
assert_ne!(
|
||||
encrypted, bundle,
|
||||
"Encrypted data should differ from plaintext"
|
||||
);
|
||||
assert!(
|
||||
encrypted.len() > bundle.len(),
|
||||
"Encrypted data includes nonce + auth tag overhead"
|
||||
);
|
||||
|
||||
let presign = client
|
||||
.presign_upload(&test_key, "application/octet-stream")
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.upload_bytes(&presign.url, &encrypted, "application/octet-stream")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stat = client.stat(&test_key).await.unwrap();
|
||||
assert!(stat.exists);
|
||||
assert_eq!(stat.size, Some(encrypted.len() as u64));
|
||||
|
||||
let download_presign = client.presign_download(&test_key).await.unwrap();
|
||||
let downloaded = client.download_bytes(&download_presign.url).await.unwrap();
|
||||
assert_eq!(downloaded.len(), encrypted.len());
|
||||
|
||||
let decrypted = decrypt_bytes(&key, &downloaded).unwrap();
|
||||
assert_eq!(
|
||||
decrypted, bundle,
|
||||
"Decrypted content should match original bundle"
|
||||
);
|
||||
|
||||
let extract_dir = temp_dir.path().join("extracted");
|
||||
fs::create_dir_all(&extract_dir).unwrap();
|
||||
let metadata = extract_bundle(&decrypted, &extract_dir);
|
||||
|
||||
assert_eq!(metadata["id"], "test-profile-id");
|
||||
assert_eq!(metadata["name"], "Test Profile");
|
||||
assert_eq!(metadata["browser"], "chromium");
|
||||
assert_eq!(metadata["version"], "120.0.0");
|
||||
assert!(metadata["sync_enabled"].as_bool().unwrap());
|
||||
let tags = metadata["tags"].as_array().unwrap();
|
||||
assert_eq!(tags.len(), 2);
|
||||
assert_eq!(tags[0], "test");
|
||||
assert_eq!(tags[1], "e2e");
|
||||
|
||||
let test_file = extract_dir.join("profile").join("test_file.txt");
|
||||
assert!(test_file.exists());
|
||||
assert_eq!(fs::read_to_string(test_file).unwrap(), "test content");
|
||||
|
||||
let wrong_key = derive_profile_key("wrong-password", &salt).unwrap();
|
||||
assert!(
|
||||
decrypt_bytes(&wrong_key, &downloaded).is_err(),
|
||||
"Decryption with wrong key should fail"
|
||||
);
|
||||
|
||||
let different_salt = generate_salt();
|
||||
let wrong_salt_key = derive_profile_key(password, &different_salt).unwrap();
|
||||
assert!(
|
||||
decrypt_bytes(&wrong_salt_key, &downloaded).is_err(),
|
||||
"Decryption with key derived from wrong salt should fail"
|
||||
);
|
||||
|
||||
client.delete(&test_key, None).await.unwrap();
|
||||
let final_stat = client.stat(&test_key).await.unwrap();
|
||||
assert!(!final_stat.exists);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_encrypted_delta_sync() {
|
||||
use donutbrowser_lib::sync::encryption::{
|
||||
decrypt_bytes, derive_profile_key, encrypt_bytes, generate_salt,
|
||||
};
|
||||
|
||||
ensure_sync_server_available().await;
|
||||
let client = TestClient::new();
|
||||
let profile_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
let salt = generate_salt();
|
||||
let password = "delta-sync-test-password";
|
||||
let key = derive_profile_key(password, &salt).unwrap();
|
||||
|
||||
let file1_key = format!("profiles/{}/files/file1.txt.enc", profile_id);
|
||||
let file2_key = format!("profiles/{}/files/file2.txt.enc", profile_id);
|
||||
let file3_key = format!("profiles/{}/files/file3.txt.enc", profile_id);
|
||||
|
||||
let content1 = b"file one content";
|
||||
let content2 = b"file two content";
|
||||
let content3 = b"file three content";
|
||||
|
||||
let encrypted1 = encrypt_bytes(&key, content1).unwrap();
|
||||
let encrypted2 = encrypt_bytes(&key, content2).unwrap();
|
||||
let encrypted3 = encrypt_bytes(&key, content3).unwrap();
|
||||
|
||||
let presign1 = client
|
||||
.presign_upload(&file1_key, "application/octet-stream")
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.upload_bytes(&presign1.url, &encrypted1, "application/octet-stream")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let presign2 = client
|
||||
.presign_upload(&file2_key, "application/octet-stream")
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.upload_bytes(&presign2.url, &encrypted2, "application/octet-stream")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let presign3 = client
|
||||
.presign_upload(&file3_key, "application/octet-stream")
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.upload_bytes(&presign3.url, &encrypted3, "application/octet-stream")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
for (file_key, expected_content) in [
|
||||
(&file1_key, content1.as_slice()),
|
||||
(&file2_key, content2.as_slice()),
|
||||
(&file3_key, content3.as_slice()),
|
||||
] {
|
||||
let dl_presign = client.presign_download(file_key).await.unwrap();
|
||||
let downloaded = client.download_bytes(&dl_presign.url).await.unwrap();
|
||||
let decrypted = decrypt_bytes(&key, &downloaded).unwrap();
|
||||
assert_eq!(
|
||||
decrypted, expected_content,
|
||||
"Decrypted content mismatch for {file_key}"
|
||||
);
|
||||
}
|
||||
|
||||
let stat1_before = client.stat(&file1_key).await.unwrap();
|
||||
let stat2_before = client.stat(&file2_key).await.unwrap();
|
||||
let stat3_before = client.stat(&file3_key).await.unwrap();
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||
|
||||
let updated_content2 = b"file two content -- updated with new data";
|
||||
let encrypted2_updated = encrypt_bytes(&key, updated_content2).unwrap();
|
||||
|
||||
let presign2_update = client
|
||||
.presign_upload(&file2_key, "application/octet-stream")
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.upload_bytes(
|
||||
&presign2_update.url,
|
||||
&encrypted2_updated,
|
||||
"application/octet-stream",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stat2_after = client.stat(&file2_key).await.unwrap();
|
||||
assert_ne!(
|
||||
stat2_before.size, stat2_after.size,
|
||||
"File2 size should have changed after update"
|
||||
);
|
||||
|
||||
let stat1_after = client.stat(&file1_key).await.unwrap();
|
||||
let stat3_after = client.stat(&file3_key).await.unwrap();
|
||||
assert_eq!(
|
||||
stat1_before.size, stat1_after.size,
|
||||
"File1 should be unchanged"
|
||||
);
|
||||
assert_eq!(
|
||||
stat3_before.size, stat3_after.size,
|
||||
"File3 should be unchanged"
|
||||
);
|
||||
|
||||
let dl_presign2 = client.presign_download(&file2_key).await.unwrap();
|
||||
let downloaded2 = client.download_bytes(&dl_presign2.url).await.unwrap();
|
||||
let decrypted2 = decrypt_bytes(&key, &downloaded2).unwrap();
|
||||
assert_eq!(
|
||||
decrypted2,
|
||||
updated_content2.to_vec(),
|
||||
"Updated file2 should decrypt to new content"
|
||||
);
|
||||
|
||||
client.delete(&file1_key, None).await.unwrap();
|
||||
client.delete(&file2_key, None).await.unwrap();
|
||||
client.delete(&file3_key, None).await.unwrap();
|
||||
}
|
||||
|
||||
+67
-71
@@ -29,6 +29,7 @@ import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { SyncAllDialog } from "@/components/sync-all-dialog";
|
||||
import { SyncConfigDialog } from "@/components/sync-config-dialog";
|
||||
import { SyncFollowerDialog } from "@/components/sync-follower-dialog";
|
||||
import { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
|
||||
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
@@ -39,6 +40,7 @@ import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useSyncSessions } from "@/hooks/use-sync-session";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
@@ -57,14 +59,7 @@ import type {
|
||||
WayfernConfig,
|
||||
} from "@/types";
|
||||
|
||||
type BrowserTypeString =
|
||||
| "firefox"
|
||||
| "firefox-developer"
|
||||
| "chromium"
|
||||
| "brave"
|
||||
| "zen"
|
||||
| "camoufox"
|
||||
| "wayfern";
|
||||
type BrowserTypeString = "camoufox" | "wayfern";
|
||||
|
||||
interface PendingUrl {
|
||||
id: string;
|
||||
@@ -97,6 +92,11 @@ export default function Home() {
|
||||
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
|
||||
// Synchronizer sessions
|
||||
const { getProfileSyncInfo } = useSyncSessions();
|
||||
const [syncLeaderProfile, setSyncLeaderProfile] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
|
||||
// Wayfern terms and commercial trial hooks
|
||||
const {
|
||||
termsAccepted,
|
||||
@@ -280,7 +280,7 @@ export default function Home() {
|
||||
const [processingUrls, setProcessingUrls] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleUrlOpen = useCallback(
|
||||
async (url: string) => {
|
||||
(url: string) => {
|
||||
// Prevent duplicate processing of the same URL
|
||||
if (processingUrls.has(url)) {
|
||||
console.log("URL already being processed:", url);
|
||||
@@ -324,7 +324,7 @@ export default function Home() {
|
||||
const currentUrl = await getCurrent();
|
||||
if (currentUrl && currentUrl.length > 0) {
|
||||
console.log("Startup URL detected:", currentUrl[0]);
|
||||
void handleUrlOpen(currentUrl[0]);
|
||||
handleUrlOpen(currentUrl[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check current URL:", error);
|
||||
@@ -372,7 +372,7 @@ export default function Home() {
|
||||
}
|
||||
}, [proxiesError]);
|
||||
|
||||
const checkAllPermissions = useCallback(async () => {
|
||||
const checkAllPermissions = useCallback(() => {
|
||||
try {
|
||||
// Wait for permissions to be initialized before checking
|
||||
if (!isInitialized) {
|
||||
@@ -413,13 +413,13 @@ export default function Home() {
|
||||
// Listen for URL open events from the deep link handler (when app is already running)
|
||||
await listen<string>("url-open-request", (event) => {
|
||||
console.log("Received URL open request:", event.payload);
|
||||
void handleUrlOpen(event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
|
||||
// Listen for show profile selector events
|
||||
await listen<string>("show-profile-selector", (event) => {
|
||||
console.log("Received show profile selector request:", event.payload);
|
||||
void handleUrlOpen(event.payload);
|
||||
handleUrlOpen(event.payload);
|
||||
});
|
||||
|
||||
// Listen for show create profile dialog events
|
||||
@@ -437,7 +437,7 @@ export default function Home() {
|
||||
// Listen for custom logo click events
|
||||
const handleLogoUrlEvent = (event: CustomEvent) => {
|
||||
console.log("Received logo URL event:", event.detail);
|
||||
void handleUrlOpen(event.detail);
|
||||
handleUrlOpen(event.detail);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
@@ -529,7 +529,7 @@ export default function Home() {
|
||||
camoufoxConfig: profileData.camoufoxConfig,
|
||||
wayfernConfig: profileData.wayfernConfig,
|
||||
groupId:
|
||||
profileData.groupId ||
|
||||
profileData.groupId ??
|
||||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
|
||||
ephemeral: profileData.ephemeral,
|
||||
},
|
||||
@@ -764,13 +764,13 @@ export default function Home() {
|
||||
setCookieManagementDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleGroupAssignmentComplete = useCallback(async () => {
|
||||
const handleGroupAssignmentComplete = useCallback(() => {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setGroupAssignmentDialogOpen(false);
|
||||
setSelectedProfilesForGroup([]);
|
||||
}, []);
|
||||
|
||||
const handleProxyAssignmentComplete = useCallback(async () => {
|
||||
const handleProxyAssignmentComplete = useCallback(() => {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setProxyAssignmentDialogOpen(false);
|
||||
setSelectedProfilesForProxy([]);
|
||||
@@ -809,7 +809,8 @@ export default function Home() {
|
||||
useEffect(() => {
|
||||
let unlistenStatus: (() => void) | undefined;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
(async () => {
|
||||
const profilesWithTransfer = new Set<string>();
|
||||
void (async () => {
|
||||
try {
|
||||
unlistenStatus = await listen<{
|
||||
profile_id: string;
|
||||
@@ -822,19 +823,15 @@ export default function Home() {
|
||||
const profile = profiles.find((p) => p.id === profile_id);
|
||||
const name = profile_name || profile?.name || "Unknown";
|
||||
|
||||
if (status === "syncing") {
|
||||
showToast({
|
||||
type: "loading",
|
||||
title: `Syncing profile '${name}'...`,
|
||||
id: toastId,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
onCancel: () => dismissToast(toastId),
|
||||
});
|
||||
} else if (status === "synced") {
|
||||
if (status === "synced") {
|
||||
dismissToast(toastId);
|
||||
showSuccessToast(`Profile '${name}' synced successfully`);
|
||||
if (profilesWithTransfer.has(profile_id)) {
|
||||
profilesWithTransfer.delete(profile_id);
|
||||
showSuccessToast(`Profile '${name}' synced successfully`);
|
||||
}
|
||||
} else if (status === "error") {
|
||||
dismissToast(toastId);
|
||||
profilesWithTransfer.delete(profile_id);
|
||||
showErrorToast(
|
||||
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
|
||||
);
|
||||
@@ -863,6 +860,7 @@ export default function Home() {
|
||||
payload.phase === "uploading" ||
|
||||
payload.phase === "downloading"
|
||||
) {
|
||||
profilesWithTransfer.add(payload.profile_id);
|
||||
showSyncProgressToast(
|
||||
name,
|
||||
{
|
||||
@@ -900,7 +898,7 @@ export default function Home() {
|
||||
};
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
setupListeners().then((cleanupFn) => {
|
||||
void setupListeners().then((cleanupFn) => {
|
||||
cleanup = cleanupFn;
|
||||
});
|
||||
|
||||
@@ -943,37 +941,6 @@ export default function Home() {
|
||||
profiles.length,
|
||||
]);
|
||||
|
||||
// Show deprecation warning for unsupported profiles (with names)
|
||||
useEffect(() => {
|
||||
if (profiles.length === 0) return;
|
||||
|
||||
const deprecatedProfiles = profiles.filter(
|
||||
(p) => p.release_type === "nightly" && p.browser !== "firefox-developer",
|
||||
);
|
||||
|
||||
if (deprecatedProfiles.length > 0) {
|
||||
const deprecatedNames = deprecatedProfiles.map((p) => p.name).join(", ");
|
||||
|
||||
// Use a stable id to avoid duplicate toasts on re-renders
|
||||
showToast({
|
||||
id: "deprecated-profiles-warning",
|
||||
type: "error",
|
||||
title: "Some profiles will be deprecated soon",
|
||||
description: `The following profiles will be deprecated soon: ${deprecatedNames}. Nightly profiles (except Firefox Developers Edition) will be removed in upcoming versions. Please check GitHub for migration instructions.`,
|
||||
duration: 15000,
|
||||
action: {
|
||||
label: "Learn more",
|
||||
onClick: () => {
|
||||
const event = new CustomEvent("url-open-request", {
|
||||
detail: "https://github.com/zhom/donutbrowser/discussions/66",
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [profiles]);
|
||||
|
||||
// Show warning for non-wayfern/camoufox profiles (support ending March 15, 2026)
|
||||
useEffect(() => {
|
||||
if (profiles.length === 0) return;
|
||||
@@ -1028,7 +995,7 @@ export default function Home() {
|
||||
// Check permissions when they are initialized
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
void checkAllPermissions();
|
||||
checkAllPermissions();
|
||||
}
|
||||
}, [isInitialized, checkAllPermissions]);
|
||||
|
||||
@@ -1089,7 +1056,6 @@ export default function Home() {
|
||||
onExtensionManagementDialogOpen={setExtensionManagementDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full mt-2.5">
|
||||
@@ -1126,6 +1092,10 @@ export default function Home() {
|
||||
onToggleProfileSync={handleToggleProfileSync}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
syncUnlocked={syncUnlocked}
|
||||
getProfileSyncInfo={getProfileSyncInfo}
|
||||
onLaunchWithSync={(profile) => {
|
||||
setSyncLeaderProfile(profile);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
@@ -1163,6 +1133,7 @@ export default function Home() {
|
||||
onClose={() => {
|
||||
setImportProfileDialogOpen(false);
|
||||
}}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
|
||||
<ProxyManagementDialog
|
||||
@@ -1198,7 +1169,9 @@ export default function Home() {
|
||||
|
||||
<CloneProfileDialog
|
||||
isOpen={!!cloneProfile}
|
||||
onClose={() => setCloneProfile(null)}
|
||||
onClose={() => {
|
||||
setCloneProfile(null);
|
||||
}}
|
||||
profile={cloneProfile}
|
||||
/>
|
||||
|
||||
@@ -1228,7 +1201,9 @@ export default function Home() {
|
||||
|
||||
<ExtensionManagementDialog
|
||||
isOpen={extensionManagementDialogOpen}
|
||||
onClose={() => setExtensionManagementDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setExtensionManagementDialogOpen(false);
|
||||
}}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
|
||||
@@ -1273,7 +1248,9 @@ export default function Home() {
|
||||
selectedProfiles={selectedProfilesForCookies}
|
||||
profiles={profiles}
|
||||
runningProfiles={runningProfiles}
|
||||
onCopyComplete={() => setSelectedProfilesForCookies([])}
|
||||
onCopyComplete={() => {
|
||||
setSelectedProfilesForCookies([]);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CookieManagementDialog
|
||||
@@ -1287,7 +1264,9 @@ export default function Home() {
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => setShowBulkDeleteConfirmation(false)}
|
||||
onClose={() => {
|
||||
setShowBulkDeleteConfirmation(false);
|
||||
}}
|
||||
onConfirm={confirmBulkDelete}
|
||||
title="Delete Selected Profiles"
|
||||
description={`This action cannot be undone. This will permanently delete ${selectedProfiles.length} profile${selectedProfiles.length !== 1 ? "s" : ""} and all associated data.`}
|
||||
@@ -1310,7 +1289,9 @@ export default function Home() {
|
||||
|
||||
<SyncAllDialog
|
||||
isOpen={syncAllDialogOpen}
|
||||
onClose={() => setSyncAllDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setSyncAllDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<ProfileSyncDialog
|
||||
@@ -1320,7 +1301,9 @@ export default function Home() {
|
||||
setCurrentProfileForSync(null);
|
||||
}}
|
||||
profile={currentProfileForSync}
|
||||
onSyncConfigOpen={() => setSyncConfigDialogOpen(true)}
|
||||
onSyncConfigOpen={() => {
|
||||
setSyncConfigDialogOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Wayfern Terms and Conditions Dialog - shown if terms not accepted */}
|
||||
@@ -1329,13 +1312,14 @@ export default function Home() {
|
||||
onAccepted={checkTerms}
|
||||
/>
|
||||
|
||||
{/* Commercial Trial Modal - shown once when trial expires */}
|
||||
{/* Commercial Trial Modal - shown once when trial expires (skip for paid users) */}
|
||||
<CommercialTrialModal
|
||||
isOpen={
|
||||
!termsLoading &&
|
||||
termsAccepted === true &&
|
||||
trialStatus?.type === "Expired" &&
|
||||
!trialAcknowledged
|
||||
!trialAcknowledged &&
|
||||
!crossOsUnlocked
|
||||
}
|
||||
onClose={checkTrialStatus}
|
||||
/>
|
||||
@@ -1343,7 +1327,9 @@ export default function Home() {
|
||||
{/* Launch on Login Dialog - shown on every startup until enabled or declined */}
|
||||
<LaunchOnLoginDialog
|
||||
isOpen={launchOnLoginDialogOpen}
|
||||
onClose={() => setLaunchOnLoginDialogOpen(false)}
|
||||
onClose={() => {
|
||||
setLaunchOnLoginDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<WindowResizeWarningDialog
|
||||
@@ -1355,6 +1341,16 @@ export default function Home() {
|
||||
windowResizeWarningResolver.current = null;
|
||||
}}
|
||||
/>
|
||||
|
||||
<SyncFollowerDialog
|
||||
isOpen={syncLeaderProfile !== null}
|
||||
onClose={() => {
|
||||
setSyncLeaderProfile(null);
|
||||
}}
|
||||
leaderProfile={syncLeaderProfile}
|
||||
allProfiles={profiles}
|
||||
runningProfiles={runningProfiles}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -46,12 +46,6 @@ export function BandwidthMiniChart({
|
||||
return result;
|
||||
}, [data]);
|
||||
|
||||
// Find max value for scaling
|
||||
const _maxBandwidth = React.useMemo(() => {
|
||||
const max = Math.max(...chartData.map((d) => d.bandwidth), 1);
|
||||
return max;
|
||||
}, [chartData]);
|
||||
|
||||
// Use external bandwidth if provided, otherwise calculate from last data point
|
||||
const currentBandwidth =
|
||||
externalBandwidth ?? chartData[chartData.length - 1]?.bandwidth ?? 0;
|
||||
|
||||
@@ -69,7 +69,12 @@ export function CloneProfileDialog({
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) onClose();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("profileInfo.clone.title")}</DialogTitle>
|
||||
@@ -80,7 +85,9 @@ export function CloneProfileDialog({
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setName(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleClone();
|
||||
}}
|
||||
|
||||
@@ -44,9 +44,15 @@ export function CommercialTrialModal({
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent
|
||||
className="sm:max-w-md"
|
||||
onEscapeKeyDown={(e) => e.preventDefault()}
|
||||
onPointerDownOutside={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
onEscapeKeyDown={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onPointerDownOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Commercial Trial Expired</DialogTitle>
|
||||
|
||||
@@ -50,12 +50,13 @@ interface CookieCopyDialogProps {
|
||||
onCopyComplete?: () => void;
|
||||
}
|
||||
|
||||
type SelectionState = {
|
||||
[domain: string]: {
|
||||
type SelectionState = Record<
|
||||
string,
|
||||
{
|
||||
allSelected: boolean;
|
||||
cookies: Set<string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
export function CookieCopyDialog({
|
||||
isOpen,
|
||||
@@ -109,7 +110,7 @@ export function CookieCopyDialog({
|
||||
const domainSelection = selection[domain];
|
||||
if (domainSelection.allSelected) {
|
||||
const domainData = cookieData?.domains.find((d) => d.domain === domain);
|
||||
count += domainData?.cookie_count || 0;
|
||||
count += domainData?.cookie_count ?? 0;
|
||||
} else {
|
||||
count += domainSelection.cookies.size;
|
||||
}
|
||||
@@ -148,7 +149,7 @@ export function CookieCopyDialog({
|
||||
(domain: string, cookies: UnifiedCookie[]) => {
|
||||
setSelection((prev) => {
|
||||
const current = prev[domain];
|
||||
const allSelected = current?.allSelected || false;
|
||||
const allSelected = current.allSelected;
|
||||
|
||||
if (allSelected) {
|
||||
const newSelection = { ...prev };
|
||||
@@ -171,7 +172,7 @@ export function CookieCopyDialog({
|
||||
const toggleCookie = useCallback(
|
||||
(domain: string, cookieName: string, totalCookies: number) => {
|
||||
setSelection((prev) => {
|
||||
const current = prev[domain] || {
|
||||
const current = prev[domain] ?? {
|
||||
allSelected: false,
|
||||
cookies: new Set<string>(),
|
||||
};
|
||||
@@ -412,7 +413,9 @@ export function CookieCopyDialog({
|
||||
<Input
|
||||
placeholder="Search domains or cookies..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setSearchQuery(e.target.value);
|
||||
}}
|
||||
className="pl-8"
|
||||
/>
|
||||
</div>
|
||||
@@ -501,8 +504,8 @@ function DomainRow({
|
||||
onToggleExpand,
|
||||
}: DomainRowProps) {
|
||||
const domainSelection = selection[domain.domain];
|
||||
const isAllSelected = domainSelection?.allSelected || false;
|
||||
const selectedCount = domainSelection?.cookies.size || 0;
|
||||
const isAllSelected = domainSelection.allSelected;
|
||||
const selectedCount = domainSelection.cookies.size;
|
||||
const isPartial =
|
||||
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
|
||||
|
||||
@@ -511,13 +514,17 @@ function DomainRow({
|
||||
<div className="flex items-center gap-2 p-2 hover:bg-accent/50 rounded">
|
||||
<Checkbox
|
||||
checked={isAllSelected || isPartial}
|
||||
onCheckedChange={() => onToggleDomain(domain.domain, domain.cookies)}
|
||||
onCheckedChange={() => {
|
||||
onToggleDomain(domain.domain, domain.cookies);
|
||||
}}
|
||||
className={isPartial ? "opacity-70" : ""}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 flex-1 text-left bg-transparent border-none cursor-pointer"
|
||||
onClick={() => onToggleExpand(domain.domain)}
|
||||
onClick={() => {
|
||||
onToggleExpand(domain.domain);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<LuChevronDown className="w-4 h-4" />
|
||||
@@ -533,8 +540,7 @@ function DomainRow({
|
||||
{isExpanded && (
|
||||
<div className="ml-8 pl-2 border-l space-y-1">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) || false;
|
||||
const isSelected = domainSelection.cookies.has(cookie.name);
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
@@ -542,13 +548,13 @@ function DomainRow({
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
onCheckedChange={() =>
|
||||
onCheckedChange={() => {
|
||||
onToggleCookie(
|
||||
domain.domain,
|
||||
cookie.name,
|
||||
domain.cookie_count,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{cookie.name}</span>
|
||||
</div>
|
||||
|
||||
@@ -45,12 +45,13 @@ interface CookieManagementDialogProps {
|
||||
initialTab?: "import" | "export";
|
||||
}
|
||||
|
||||
type SelectionState = {
|
||||
[domain: string]: {
|
||||
type SelectionState = Record<
|
||||
string,
|
||||
{
|
||||
allSelected: boolean;
|
||||
cookies: Set<string>;
|
||||
};
|
||||
};
|
||||
}
|
||||
>;
|
||||
|
||||
const countCookies = (content: string): number => {
|
||||
const trimmed = content.trim();
|
||||
@@ -150,7 +151,7 @@ export function CookieManagementDialog({
|
||||
const domainData = exportCookieData?.domains.find(
|
||||
(d) => d.domain === domain,
|
||||
);
|
||||
count += domainData?.cookie_count || 0;
|
||||
count += domainData?.cookie_count ?? 0;
|
||||
} else {
|
||||
count += ds.cookies.size;
|
||||
}
|
||||
@@ -309,7 +310,7 @@ export function CookieManagementDialog({
|
||||
(domain: string, cookies: UnifiedCookie[]) => {
|
||||
setExportSelection((prev) => {
|
||||
const current = prev[domain];
|
||||
if (current?.allSelected) {
|
||||
if (current.allSelected) {
|
||||
const next = { ...prev };
|
||||
delete next[domain];
|
||||
return next;
|
||||
@@ -329,7 +330,7 @@ export function CookieManagementDialog({
|
||||
const toggleCookie = useCallback(
|
||||
(domain: string, cookieName: string, totalCookies: number) => {
|
||||
setExportSelection((prev) => {
|
||||
const current = prev[domain] || {
|
||||
const current = prev[domain] ?? {
|
||||
allSelected: false,
|
||||
cookies: new Set<string>(),
|
||||
};
|
||||
@@ -462,8 +463,8 @@ export function CookieManagementDialog({
|
||||
|
||||
{importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-green-500/10">
|
||||
<div className="font-medium text-green-600 dark:text-green-400">
|
||||
<div className="p-4 rounded-lg bg-success/10">
|
||||
<div className="font-medium text-success">
|
||||
Successfully imported {importResult.cookies_imported}{" "}
|
||||
cookies ({importResult.cookies_replaced} replaced)
|
||||
</div>
|
||||
@@ -485,7 +486,9 @@ export function CookieManagementDialog({
|
||||
<Label>Format</Label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => setFormat(v as "netscape" | "json")}
|
||||
onValueChange={(v) => {
|
||||
setFormat(v as "netscape" | "json");
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
@@ -589,8 +592,8 @@ function ExportDomainRow({
|
||||
onToggleExpand,
|
||||
}: ExportDomainRowProps) {
|
||||
const domainSelection = selection[domain.domain];
|
||||
const isAllSelected = domainSelection?.allSelected || false;
|
||||
const selectedCount = domainSelection?.cookies.size || 0;
|
||||
const isAllSelected = domainSelection.allSelected;
|
||||
const selectedCount = domainSelection.cookies.size;
|
||||
const isPartial =
|
||||
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
|
||||
|
||||
@@ -599,13 +602,17 @@ function ExportDomainRow({
|
||||
<div className="flex items-center gap-2 p-1.5 hover:bg-accent/50 rounded">
|
||||
<Checkbox
|
||||
checked={isAllSelected || isPartial}
|
||||
onCheckedChange={() => onToggleDomain(domain.domain, domain.cookies)}
|
||||
onCheckedChange={() => {
|
||||
onToggleDomain(domain.domain, domain.cookies);
|
||||
}}
|
||||
className={isPartial ? "opacity-70" : ""}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 flex-1 text-left text-sm bg-transparent border-none cursor-pointer"
|
||||
onClick={() => onToggleExpand(domain.domain)}
|
||||
onClick={() => {
|
||||
onToggleExpand(domain.domain);
|
||||
}}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<LuChevronDown className="w-3.5 h-3.5" />
|
||||
@@ -621,8 +628,7 @@ function ExportDomainRow({
|
||||
{isExpanded && (
|
||||
<div className="ml-7 pl-2 border-l space-y-0.5">
|
||||
{domain.cookies.map((cookie) => {
|
||||
const isSelected =
|
||||
domainSelection?.cookies.has(cookie.name) || false;
|
||||
const isSelected = domainSelection.cookies.has(cookie.name);
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
@@ -630,13 +636,13 @@ function ExportDomainRow({
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
onCheckedChange={() =>
|
||||
onCheckedChange={() => {
|
||||
onToggleCookie(
|
||||
domain.domain,
|
||||
cookie.name,
|
||||
domain.cookie_count,
|
||||
)
|
||||
}
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{cookie.name}</span>
|
||||
</div>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user