mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-24 23:49:55 +02:00
Compare commits
51 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 704bcb2b28 | |||
| 08559eef13 | |||
| 0ff0570321 | |||
| 7eb56a2296 | |||
| e2fa6f2c5f | |||
| 8b83ece7be | |||
| 4fed80cf3c | |||
| c1fb1e3c4b | |||
| 7e367325be | |||
| e6cb4e6082 | |||
| 21d80fde56 | |||
| 3732d3a6e1 | |||
| 2e193987df | |||
| ddc2657165 | |||
| 98798b83df | |||
| ed82f74932 | |||
| cc5379f957 | |||
| 8b9ad44ebc | |||
| 206be3ff12 | |||
| 1afc2ca5ff | |||
| c61b3d3188 | |||
| 97da1ca288 | |||
| 6484656de0 | |||
| 961e3f2185 | |||
| f515a4f327 | |||
| 4ba2c5ec24 | |||
| f378f0fbde | |||
| c816fee184 | |||
| 4872dcc8ad | |||
| 8bc1ea500b | |||
| 7ed19f3a8f | |||
| e5663515a7 | |||
| 0f579cb97d | |||
| de896f895c | |||
| 3d57a622b1 | |||
| 5dfe7cb216 | |||
| dea0181009 | |||
| 4983f622d0 | |||
| 6654ab9fdc | |||
| d490ad3612 | |||
| e31de5ac99 | |||
| 7cd3e922f5 | |||
| 547bd89de9 | |||
| edabfd0831 | |||
| 127912c68c | |||
| af2aa36ac6 | |||
| d52493b7e4 | |||
| dfc94c10ff | |||
| a008e11504 | |||
| 6f28ed3a47 | |||
| c30a44a13d |
@@ -0,0 +1,76 @@
|
||||
messages:
|
||||
- role: system
|
||||
content: |-
|
||||
You are an issue validation assistant for Donut Browser, an anti-detect browser.
|
||||
|
||||
Analyze the provided issue content and determine if it contains sufficient information based on these requirements:
|
||||
|
||||
For Bug Reports, the issue should include:
|
||||
1. Clear description of the problem
|
||||
2. Steps to reproduce the issue (numbered list preferred)
|
||||
3. Expected vs actual behavior
|
||||
4. Environment information (OS, browser version, etc.)
|
||||
5. Error messages, stack traces, or screenshots if applicable
|
||||
|
||||
For Feature Requests, the issue should include:
|
||||
1. Clear description of the requested feature
|
||||
2. Use case or problem it solves
|
||||
3. Proposed solution or how it should work
|
||||
4. Priority level or importance
|
||||
|
||||
General Requirements for all issues:
|
||||
1. Descriptive title
|
||||
2. Sufficient detail to understand and act upon
|
||||
3. Professional tone and clear communication
|
||||
|
||||
Constraints:
|
||||
- Maximum 3 items in missing_info array
|
||||
- Maximum 3 items in suggestions array
|
||||
- Each array item must be under 80 characters
|
||||
- overall_assessment must be under 100 characters
|
||||
- role: user
|
||||
content: |-
|
||||
## Issue Content to Analyze:
|
||||
|
||||
**Title:** {{issue_title}}
|
||||
|
||||
**Body:**
|
||||
{{issue_body}}
|
||||
|
||||
**Labels:** {{issue_labels}}
|
||||
model: openai/gpt-4.1
|
||||
responseFormat: json_schema
|
||||
jsonSchema: |-
|
||||
{
|
||||
"name": "issue_validation",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"is_valid": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the issue contains sufficient information"
|
||||
},
|
||||
"issue_type": {
|
||||
"type": "string",
|
||||
"enum": ["bug_report", "feature_request", "other"]
|
||||
},
|
||||
"missing_info": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Missing information items (max 3, each under 80 characters)"
|
||||
},
|
||||
"suggestions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Suggestions for improvement (max 3, each under 80 characters)"
|
||||
},
|
||||
"overall_assessment": {
|
||||
"type": "string",
|
||||
"description": "One sentence assessment under 100 characters"
|
||||
}
|
||||
},
|
||||
"required": ["is_valid", "issue_type", "missing_info", "suggestions", "overall_assessment"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
messages:
|
||||
- role: system
|
||||
content: |-
|
||||
You are a code review assistant for Donut Browser, an open-source anti-detect browser built with Tauri, Next.js, and Rust.
|
||||
|
||||
Review the provided pull request and provide constructive feedback. Focus on:
|
||||
1. Code quality and best practices
|
||||
2. Potential bugs or issues
|
||||
3. Security concerns (especially important for an anti-detect browser)
|
||||
4. Performance implications
|
||||
5. Consistency with the project's patterns
|
||||
|
||||
Constraints:
|
||||
- Maximum 4 items in feedback array
|
||||
- Maximum 3 items in suggestions array
|
||||
- Maximum 2 items in security_notes array
|
||||
- Each array item must be under 150 characters
|
||||
- summary must be under 200 characters
|
||||
- Be constructive and helpful, not harsh
|
||||
- role: user
|
||||
content: |-
|
||||
## Pull Request to Review:
|
||||
|
||||
**Title:** {{pr_title}}
|
||||
|
||||
**Description:**
|
||||
{{pr_body}}
|
||||
|
||||
**Diff:**
|
||||
{{pr_diff}}
|
||||
model: openai/gpt-4.1
|
||||
responseFormat: json_schema
|
||||
jsonSchema: |-
|
||||
{
|
||||
"name": "pr_review",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "Brief 1-2 sentence summary under 200 characters"
|
||||
},
|
||||
"quality_score": {
|
||||
"type": "string",
|
||||
"enum": ["good", "needs_work", "critical_issues"]
|
||||
},
|
||||
"feedback": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Feedback points (max 4, each under 150 characters)"
|
||||
},
|
||||
"suggestions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Suggestions (max 3, each under 150 characters)"
|
||||
},
|
||||
"security_notes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Security notes if any (max 2, each under 150 characters)"
|
||||
}
|
||||
},
|
||||
"required": ["summary", "quality_score", "feedback", "suggestions", "security_notes"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
messages:
|
||||
- role: system
|
||||
content: |-
|
||||
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful anti-detect browser desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
|
||||
|
||||
Guidelines:
|
||||
- Use clear, user-friendly language
|
||||
- Group related commits logically
|
||||
- Omit minor commits like formatting, typos unless significant
|
||||
- Focus on user-facing changes
|
||||
- Use emojis sparingly and consistently
|
||||
- Keep descriptions concise but informative
|
||||
- If commits are unclear, infer the purpose from the context
|
||||
- Only include sections that have relevant changes
|
||||
- role: user
|
||||
content: |-
|
||||
Generate release notes for version {{version}} based on these commits:
|
||||
|
||||
{{commits}}
|
||||
|
||||
Use this format:
|
||||
|
||||
## What's New in {{version}}
|
||||
|
||||
[Brief 1-2 sentence overview]
|
||||
|
||||
### New Features
|
||||
### Bug Fixes
|
||||
### Improvements
|
||||
### Documentation
|
||||
### Dependencies
|
||||
### Developer Experience
|
||||
model: openai/gpt-4.1
|
||||
@@ -26,81 +26,22 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
|
||||
- name: Get issue templates
|
||||
id: get-templates
|
||||
run: |
|
||||
if [ -f ".github/ISSUE_TEMPLATE/01-bug-report.md" ]; then
|
||||
echo "bug-template-exists=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ -f ".github/ISSUE_TEMPLATE/02-feature-request.md" ]; then
|
||||
echo "feature-template-exists=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create issue analysis prompt
|
||||
id: create-prompt
|
||||
- name: Save issue body to file
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }}
|
||||
run: |
|
||||
cat > issue_analysis.txt << EOF
|
||||
## Issue Content to Analyze:
|
||||
|
||||
**Title:** $ISSUE_TITLE
|
||||
|
||||
**Body:**
|
||||
$ISSUE_BODY
|
||||
|
||||
**Labels:** $ISSUE_LABELS
|
||||
EOF
|
||||
run: printf '%s' "${ISSUE_BODY:-}" > issue_body.txt
|
||||
|
||||
- name: Validate issue with AI
|
||||
id: validate
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
prompt-file: issue_analysis.txt
|
||||
system-prompt: |
|
||||
You are an issue validation assistant for Donut Browser, an anti-detect browser.
|
||||
|
||||
Analyze the provided issue content and determine if it contains sufficient information based on these requirements:
|
||||
|
||||
**For Bug Reports, the issue should include:**
|
||||
1. Clear description of the problem
|
||||
2. Steps to reproduce the issue (numbered list preferred)
|
||||
3. Expected vs actual behavior
|
||||
4. Environment information (OS, browser version, etc.)
|
||||
5. Error messages, stack traces, or screenshots if applicable
|
||||
|
||||
**For Feature Requests, the issue should include:**
|
||||
1. Clear description of the requested feature
|
||||
2. Use case or problem it solves
|
||||
3. Proposed solution or how it should work
|
||||
4. Priority level or importance
|
||||
|
||||
**General Requirements for all issues:**
|
||||
1. Descriptive title
|
||||
2. Sufficient detail to understand and act upon
|
||||
3. Professional tone and clear communication
|
||||
|
||||
Respond ONLY with valid JSON (no markdown fences). Keep responses concise.
|
||||
|
||||
JSON structure:
|
||||
{
|
||||
"is_valid": true|false,
|
||||
"issue_type": "bug_report"|"feature_request"|"other",
|
||||
"missing_info": ["item1", "item2"],
|
||||
"suggestions": ["suggestion1", "suggestion2"],
|
||||
"overall_assessment": "One sentence assessment"
|
||||
}
|
||||
|
||||
IMPORTANT CONSTRAINTS:
|
||||
- Maximum 3 items in missing_info array
|
||||
- Maximum 3 items in suggestions array
|
||||
- Each array item must be under 80 characters
|
||||
- overall_assessment must be under 100 characters
|
||||
- Output ONLY the JSON object, nothing else
|
||||
model: gpt-5-mini
|
||||
prompt-file: .github/prompts/issue-validation.prompt.yml
|
||||
input: |
|
||||
issue_title: ${{ github.event.issue.title }}
|
||||
issue_labels: ${{ join(github.event.issue.labels.*.name, ', ') }}
|
||||
file_input: |
|
||||
issue_body: ./issue_body.txt
|
||||
max-tokens: 1024
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -203,14 +144,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run opencode analysis
|
||||
uses: anomalyco/opencode/github@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
|
||||
uses: anomalyco/opencode/github@296250f1b7e1ec992a3a33bee999f5e09a1697d0 #v1.2.10
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
with:
|
||||
model: zai-coding-plan/glm-4.7
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -f issue_analysis.txt comment.md
|
||||
run: rm -f issue_body.txt comment.md
|
||||
|
||||
handle-pr:
|
||||
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
|
||||
@@ -245,54 +186,22 @@ jobs:
|
||||
gh pr diff ${{ github.event.pull_request.number }} > pr_diff.txt
|
||||
head -c 10000 pr_diff.txt > pr_diff_truncated.txt
|
||||
|
||||
- name: Create PR analysis prompt
|
||||
- name: Save PR body to file
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
{
|
||||
printf "## Pull Request to Review:\n\n"
|
||||
printf "**Title:** %s\n\n" "$PR_TITLE"
|
||||
printf "**Description:**\n%s\n\n" "$PR_BODY"
|
||||
printf "**Diff:**\n"
|
||||
cat pr_diff_truncated.txt
|
||||
} > pr_analysis.txt
|
||||
run: printf '%s' "${PR_BODY:-No description provided}" > pr_body.txt
|
||||
|
||||
- name: Analyze PR with AI
|
||||
id: analyze
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
prompt-file: pr_analysis.txt
|
||||
system-prompt: |
|
||||
You are a code review assistant for Donut Browser, an open-source anti-detect browser built with Tauri, Next.js, and Rust.
|
||||
|
||||
Review the provided pull request and provide constructive feedback. Focus on:
|
||||
1. Code quality and best practices
|
||||
2. Potential bugs or issues
|
||||
3. Security concerns (especially important for an anti-detect browser)
|
||||
4. Performance implications
|
||||
5. Consistency with the project's patterns
|
||||
|
||||
Respond ONLY with valid JSON (no markdown fences).
|
||||
|
||||
JSON structure:
|
||||
{
|
||||
"summary": "Brief 1-2 sentence summary of what this PR does",
|
||||
"quality_score": "good"|"needs_work"|"critical_issues",
|
||||
"feedback": ["feedback point 1", "feedback point 2"],
|
||||
"suggestions": ["suggestion 1", "suggestion 2"],
|
||||
"security_notes": ["security note if any"] or []
|
||||
}
|
||||
|
||||
IMPORTANT CONSTRAINTS:
|
||||
- Maximum 4 items in feedback array
|
||||
- Maximum 3 items in suggestions array
|
||||
- Maximum 2 items in security_notes array
|
||||
- Each array item must be under 150 characters
|
||||
- summary must be under 200 characters
|
||||
- Be constructive and helpful, not harsh
|
||||
- Output ONLY the JSON object, nothing else
|
||||
model: gpt-5-mini
|
||||
prompt-file: .github/prompts/pr-review.prompt.yml
|
||||
input: |
|
||||
pr_title: ${{ github.event.pull_request.title }}
|
||||
file_input: |
|
||||
pr_body: ./pr_body.txt
|
||||
pr_diff: ./pr_diff_truncated.txt
|
||||
max-tokens: 1024
|
||||
|
||||
- name: Post PR feedback comment
|
||||
env:
|
||||
@@ -364,14 +273,14 @@ jobs:
|
||||
gh pr comment ${{ github.event.pull_request.number }} --body-file comment.md
|
||||
|
||||
- name: Run opencode analysis
|
||||
uses: anomalyco/opencode/github@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
|
||||
uses: anomalyco/opencode/github@296250f1b7e1ec992a3a33bee999f5e09a1697d0 #v1.2.10
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
with:
|
||||
model: zai-coding-plan/glm-4.7
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -f pr_diff.txt pr_diff_truncated.txt pr_analysis.txt comment.md
|
||||
run: rm -f pr_diff.txt pr_diff_truncated.txt pr_body.txt comment.md
|
||||
|
||||
opencode-command:
|
||||
if: |
|
||||
@@ -386,7 +295,7 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
|
||||
uses: anomalyco/opencode/github@296250f1b7e1ec992a3a33bee999f5e09a1697d0 #v1.2.10
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
with:
|
||||
|
||||
@@ -29,6 +29,7 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-22.04, windows-latest]
|
||||
|
||||
|
||||
@@ -84,45 +84,12 @@ jobs:
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
prompt-file: commits.txt
|
||||
system-prompt: |
|
||||
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful anti-detect browser.
|
||||
|
||||
Analyze the provided commit messages and generate well-structured release notes following this format:
|
||||
|
||||
## What's New in ${{ steps.get-previous-tag.outputs.current-tag }}
|
||||
|
||||
[Brief 1-2 sentence overview of the release]
|
||||
|
||||
### ✨ New Features
|
||||
[List new features with brief descriptions]
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
[List bug fixes]
|
||||
|
||||
### 🔧 Improvements
|
||||
[List improvements and enhancements]
|
||||
|
||||
### 📚 Documentation
|
||||
[List documentation updates if any]
|
||||
|
||||
### 🔄 Dependencies
|
||||
[List dependency updates if any]
|
||||
|
||||
### 🛠️ Developer Experience
|
||||
[List development-related changes if any]
|
||||
|
||||
Guidelines:
|
||||
- Use clear, user-friendly language
|
||||
- Group related commits logically
|
||||
- Omit minor commits like formatting, typos unless significant
|
||||
- Focus on user-facing changes
|
||||
- Use emojis sparingly and consistently
|
||||
- Keep descriptions concise but informative
|
||||
- If commits are unclear, infer the purpose from the context
|
||||
|
||||
The application is a desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
|
||||
model: gpt-5-mini
|
||||
prompt-file: .github/prompts/release-notes.prompt.yml
|
||||
input: |
|
||||
version: ${{ steps.get-previous-tag.outputs.current-tag }}
|
||||
file_input: |
|
||||
commits: ./commits.txt
|
||||
max-tokens: 4096
|
||||
|
||||
- name: Update release with generated notes
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
|
||||
@@ -230,3 +230,103 @@ jobs:
|
||||
# with:
|
||||
# branch: main
|
||||
# commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
|
||||
|
||||
publish-repos:
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Download Linux packages from release
|
||||
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@40f1582b2485089dde7abd97c1529aa768e1baff #v5.6.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: ${{ 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: ${{ 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: ${{ 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}"
|
||||
|
||||
bump-homebrew-cask:
|
||||
needs: [release]
|
||||
runs-on: macos-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Bump Homebrew cask
|
||||
env:
|
||||
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
brew bump-cask-pr --version "$VERSION" --no-browse donut
|
||||
|
||||
@@ -234,3 +234,55 @@ jobs:
|
||||
run: |
|
||||
security delete-keychain $RUNNER_TEMP/app-signing.keychain-db || true
|
||||
rm -f $RUNNER_TEMP/build_certificate.p12 || true
|
||||
|
||||
update-nightly-release:
|
||||
needs: [rolling-release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
|
||||
- name: Generate nightly tag
|
||||
id: tag
|
||||
run: |
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d")
|
||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
echo "nightly_tag=nightly-${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Update rolling nightly release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
NIGHTLY_TAG="${{ steps.tag.outputs.nightly_tag }}"
|
||||
ASSETS_DIR="/tmp/nightly-assets"
|
||||
|
||||
# Download all assets from the per-commit nightly release
|
||||
mkdir -p "$ASSETS_DIR"
|
||||
gh release download "$NIGHTLY_TAG" --dir "$ASSETS_DIR" --clobber
|
||||
|
||||
# Rename versioned filenames to stable nightly names
|
||||
cd "$ASSETS_DIR"
|
||||
for f in Donut_*_aarch64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.dmg; done
|
||||
for f in Donut_*_x64.dmg; do [ -f "$f" ] && mv "$f" Donut_nightly_x64.dmg; done
|
||||
for f in Donut_*_x64-setup.exe; do [ -f "$f" ] && mv "$f" Donut_nightly_x64-setup.exe; done
|
||||
for f in Donut_*_aarch64.AppImage; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.AppImage; done
|
||||
for f in Donut_*_amd64.AppImage; do [ -f "$f" ] && mv "$f" Donut_nightly_amd64.AppImage; done
|
||||
for f in Donut_*_amd64.deb; do [ -f "$f" ] && mv "$f" Donut_nightly_amd64.deb; done
|
||||
for f in Donut_*_arm64.deb; do [ -f "$f" ] && mv "$f" Donut_nightly_arm64.deb; done
|
||||
for f in Donut-*.x86_64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_x86_64.rpm; done
|
||||
for f in Donut-*.aarch64.rpm; do [ -f "$f" ] && mv "$f" Donut_nightly_aarch64.rpm; done
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
|
||||
# Delete existing rolling nightly release and tag
|
||||
gh release delete nightly --yes 2>/dev/null || true
|
||||
git push --delete origin nightly 2>/dev/null || true
|
||||
|
||||
# Create new rolling nightly release with all assets
|
||||
gh release create nightly \
|
||||
"$ASSETS_DIR"/Donut_nightly_* \
|
||||
"$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}" \
|
||||
--prerelease
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@78bc6fb2c0d734235d57a2d6b9de923cc325ebdd #v1.43.4
|
||||
uses: crate-ci/typos@57b11c6b7e54c402ccd9cda953f1072ec4f78e33 #v1.43.5
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
- 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."
|
||||
|
||||
@@ -24,6 +24,7 @@ jobs:
|
||||
rust-sync-e2e:
|
||||
name: Rust Sync E2E Tests
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-22.04]
|
||||
|
||||
|
||||
Vendored
+13
@@ -31,6 +31,7 @@
|
||||
"cmdk",
|
||||
"codegen",
|
||||
"codesign",
|
||||
"codesigning",
|
||||
"commitish",
|
||||
"Crashpad",
|
||||
"CTYPE",
|
||||
@@ -44,6 +45,7 @@
|
||||
"devedition",
|
||||
"direnv",
|
||||
"distro",
|
||||
"dists",
|
||||
"doctest",
|
||||
"doesn",
|
||||
"domcontentloaded",
|
||||
@@ -65,6 +67,7 @@
|
||||
"gettimezone",
|
||||
"gifs",
|
||||
"globset",
|
||||
"GOPATH",
|
||||
"gsettings",
|
||||
"healthreport",
|
||||
"hiddenimports",
|
||||
@@ -77,6 +80,7 @@
|
||||
"idletime",
|
||||
"idna",
|
||||
"infobars",
|
||||
"inkey",
|
||||
"Inno",
|
||||
"kdeglobals",
|
||||
"keras",
|
||||
@@ -131,10 +135,13 @@
|
||||
"osascript",
|
||||
"oscpu",
|
||||
"outpath",
|
||||
"OVPN",
|
||||
"passout",
|
||||
"patchelf",
|
||||
"pathex",
|
||||
"pathlib",
|
||||
"peerconnection",
|
||||
"PHANDLER",
|
||||
"pids",
|
||||
"pixbuf",
|
||||
"pkexec",
|
||||
@@ -153,6 +160,9 @@
|
||||
"pytest",
|
||||
"pyyaml",
|
||||
"quic",
|
||||
"ralt",
|
||||
"repodata",
|
||||
"repogen",
|
||||
"reportingpolicy",
|
||||
"reqwest",
|
||||
"ridedott",
|
||||
@@ -172,6 +182,7 @@
|
||||
"shadcn",
|
||||
"showcursor",
|
||||
"shutil",
|
||||
"sighandler",
|
||||
"signon",
|
||||
"signum",
|
||||
"sklearn",
|
||||
@@ -183,6 +194,7 @@
|
||||
"stefanzweifel",
|
||||
"subdirs",
|
||||
"subkey",
|
||||
"subsec",
|
||||
"SUPPRESSMSGBOXES",
|
||||
"swatinem",
|
||||
"sysinfo",
|
||||
@@ -212,6 +224,7 @@
|
||||
"venv",
|
||||
"vercel",
|
||||
"VERYSILENT",
|
||||
"vpns",
|
||||
"wayfern",
|
||||
"webgl",
|
||||
"webrtc",
|
||||
|
||||
+1
-1
@@ -23,6 +23,6 @@ Examples of unacceptable behavior by participants include:
|
||||
|
||||
## Enforcement
|
||||
|
||||
Violations of the Code of Conduct may be reported to contact at donutbrowser dot com. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
|
||||
Violations of the Code of Conduct may be reported to [contact@donutbrowser.com](mailto:contact@donutbrowser.com). All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
We hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
@@ -25,11 +25,7 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/preview-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/preview.png" />
|
||||
<img alt="Preview" src="assets/preview.png" />
|
||||
</picture>
|
||||
<img alt="Donut Browser Preview" src="assets/donut-preview.png" />
|
||||
|
||||
## Features
|
||||
|
||||
@@ -117,7 +113,7 @@ Have questions or want to contribute? We'd love to hear from you!
|
||||
|
||||
## Contact
|
||||
|
||||
Have an urgent question or want to report a security vulnerability? Send an email to contact at donutbrowser dot com and we'll get back to you as fast as possible.
|
||||
Have an urgent question or want to report a security vulnerability? Send an email to [contact@donutbrowser.com](mailto:contact@donutbrowser.com) and we'll get back to you as fast as possible.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+2
-2
@@ -8,7 +8,7 @@ We take the security of Donut Browser seriously. If you believe you have found a
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
|
||||
|
||||
Instead, please send an email to **contact at donutbrowser dot com** with the subject line "Security Vulnerability Report".
|
||||
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:
|
||||
|
||||
@@ -32,7 +32,7 @@ This information will help us triage your report more quickly.
|
||||
|
||||
## Contact
|
||||
|
||||
For urgent security matters, please contact us at **contact at donutbrowser dot com**.
|
||||
For urgent security matters, please contact us at **[contact@donutbrowser.com](mailto:contact@donutbrowser.com)**.
|
||||
|
||||
For general questions about this security policy, you can also reach out through:
|
||||
|
||||
|
||||
@@ -6,3 +6,6 @@ extend-exclude = [
|
||||
"src-tauri/build.rs",
|
||||
"src-tauri/tests/fixtures/test.ovpn",
|
||||
]
|
||||
|
||||
[default.extend-words]
|
||||
DBE = "DBE"
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 623 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 114 KiB |
@@ -18,12 +18,12 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.990.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.990.0",
|
||||
"@nestjs/common": "^11.1.13",
|
||||
"@aws-sdk/client-s3": "^3.996.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.996.0",
|
||||
"@nestjs/common": "^11.1.14",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.13",
|
||||
"@nestjs/platform-express": "^11.1.13",
|
||||
"@nestjs/core": "^11.1.14",
|
||||
"@nestjs/platform-express": "^11.1.14",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
@@ -31,11 +31,11 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@nestjs/testing": "^11.1.13",
|
||||
"@nestjs/testing": "^11.1.14",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"jest": "^30.2.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
|
||||
+15
-15
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.14.2",
|
||||
"version": "0.15.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
@@ -44,7 +44,7 @@
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "~2.9.0",
|
||||
"@tauri-apps/api": "~2.10.1",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.7",
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-fs": "~2.4.5",
|
||||
@@ -56,9 +56,9 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"i18next": "^25.8.7",
|
||||
"lucide-react": "^0.564.0",
|
||||
"motion": "^12.34.0",
|
||||
"i18next": "^25.8.13",
|
||||
"lucide-react": "^0.575.0",
|
||||
"motion": "^12.34.3",
|
||||
"next": "^16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -68,34 +68,34 @@
|
||||
"react-icons": "^5.5.0",
|
||||
"recharts": "3.7.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.3.15",
|
||||
"@tailwindcss/postcss": "^4.1.18",
|
||||
"@tauri-apps/cli": "~2.9.0",
|
||||
"@biomejs/biome": "2.4.4",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tauri-apps/cli": "~2.10.0",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/node": "^25.2.3",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^5.1.4",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.2.7",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.30.1",
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||
"biome check --fix"
|
||||
],
|
||||
"src-tauri/**/*.rs": [
|
||||
"cd src-tauri && cargo fmt --all",
|
||||
"cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all",
|
||||
"cd src-tauri && cargo test"
|
||||
"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'"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1226
-1228
File diff suppressed because it is too large
Load Diff
Generated
+597
-339
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.14.2"
|
||||
version = "0.15.0"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -40,6 +40,7 @@ tauri-plugin-opener = "2"
|
||||
tauri-plugin-fs = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
tauri-plugin-single-instance = "2"
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
tauri-plugin-log = "2"
|
||||
@@ -100,20 +101,17 @@ quick-xml = { version = "0.39", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
boringtun = "0.7"
|
||||
smoltcp = { version = "0.11", 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"
|
||||
single-instance = "0.3"
|
||||
image = "0.25"
|
||||
dirs = "6"
|
||||
crossbeam-channel = "0.5"
|
||||
sys-locale = "0.3"
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
nix = { version = "0.31", features = ["signal", "process"] }
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
<string>Donut</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.donutbrowser</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.donutbrowser</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"deep-link:allow-get-current",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-write-text-file",
|
||||
"macos-permissions:default",
|
||||
"macos-permissions:allow-request-microphone-permission",
|
||||
"macos-permissions:allow-request-camera-permission",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -464,13 +463,7 @@ impl ApiClient {
|
||||
}
|
||||
|
||||
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let app_name = if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
};
|
||||
let cache_dir = base_dirs.cache_dir().join(app_name).join("version_cache");
|
||||
let cache_dir = crate::app_dirs::cache_dir().join("version_cache");
|
||||
fs::create_dir_all(&cache_dir)?;
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
@@ -601,9 +601,11 @@ async fn create_profile(
|
||||
&request.version,
|
||||
request.release_type.as_deref().unwrap_or("stable"),
|
||||
request.proxy_id.clone(),
|
||||
None, // vpn_id
|
||||
camoufox_config,
|
||||
wayfern_config,
|
||||
request.group_id.clone(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -1161,6 +1163,7 @@ async fn delete_proxy(
|
||||
request_body = RunProfileRequest,
|
||||
responses(
|
||||
(status = 200, description = "Profile launched successfully", body = RunProfileResponse),
|
||||
(status = 400, description = "Cannot launch cross-OS profile"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Profile not found"),
|
||||
(status = 500, description = "Internal server error")
|
||||
@@ -1188,6 +1191,10 @@ async fn run_profile(
|
||||
.find(|p| p.id.to_string() == id)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
if profile.is_cross_os() {
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Generate a random port for remote debugging
|
||||
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
|
||||
|
||||
|
||||
@@ -506,7 +506,8 @@ impl AppAutoUpdater {
|
||||
&& (asset.name.contains(&format!("_{arch}.dmg"))
|
||||
|| asset.name.contains(&format!("-{arch}.dmg"))
|
||||
|| asset.name.contains(&format!("_{arch}_"))
|
||||
|| asset.name.contains(&format!("-{arch}-")))
|
||||
|| asset.name.contains(&format!("-{arch}-"))
|
||||
|| asset.name.contains(&format!("_{arch}-")))
|
||||
{
|
||||
log::info!("Found exact architecture match: {}", asset.name);
|
||||
return Some(asset.browser_download_url.clone());
|
||||
@@ -564,7 +565,8 @@ impl AppAutoUpdater {
|
||||
&& (asset.name.contains(&format!("_{arch}.{ext}"))
|
||||
|| asset.name.contains(&format!("-{arch}.{ext}"))
|
||||
|| asset.name.contains(&format!("_{arch}_"))
|
||||
|| asset.name.contains(&format!("-{arch}-")))
|
||||
|| asset.name.contains(&format!("-{arch}-"))
|
||||
|| asset.name.contains(&format!("_{arch}-")))
|
||||
{
|
||||
log::info!("Found Windows {ext} with exact arch match: {}", asset.name);
|
||||
return Some(asset.browser_download_url.clone());
|
||||
@@ -627,7 +629,8 @@ impl AppAutoUpdater {
|
||||
&& (asset.name.contains(&format!("_{arch}.{ext}"))
|
||||
|| asset.name.contains(&format!("-{arch}.{ext}"))
|
||||
|| asset.name.contains(&format!("_{arch}_"))
|
||||
|| asset.name.contains(&format!("-{arch}-")))
|
||||
|| asset.name.contains(&format!("-{arch}-"))
|
||||
|| asset.name.contains(&format!("_{arch}-")))
|
||||
{
|
||||
log::info!("Found Linux {ext} with exact arch match: {}", asset.name);
|
||||
return Some(asset.browser_download_url.clone());
|
||||
@@ -1698,15 +1701,10 @@ mod tests {
|
||||
browser_download_url: "https://example.com/x64.dmg".to_string(),
|
||||
size: 12345,
|
||||
},
|
||||
// Windows assets
|
||||
// Windows assets (NSIS naming: _ARCH-setup.exe)
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser_0.1.0_x64.msi".to_string(),
|
||||
browser_download_url: "https://example.com/x64.msi".to_string(),
|
||||
size: 12345,
|
||||
},
|
||||
AppReleaseAsset {
|
||||
name: "Donut.Browser_0.1.0_x64.exe".to_string(),
|
||||
browser_download_url: "https://example.com/x64.exe".to_string(),
|
||||
name: "Donut_0.1.0_x64-setup.exe".to_string(),
|
||||
browser_download_url: "https://example.com/x64-setup.exe".to_string(),
|
||||
size: 12345,
|
||||
},
|
||||
// Linux assets
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
use directories::BaseDirs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static BASE_DIRS: OnceLock<BaseDirs> = OnceLock::new();
|
||||
|
||||
fn base_dirs() -> &'static BaseDirs {
|
||||
BASE_DIRS.get_or_init(|| BaseDirs::new().expect("Failed to get base directories"))
|
||||
}
|
||||
|
||||
pub fn app_name() -> &'static str {
|
||||
if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_dir() -> PathBuf {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if let Some(dir) = TEST_DATA_DIR.with(|cell| cell.borrow().clone()) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(dir) = std::env::var("DONUTBROWSER_DATA_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
|
||||
base_dirs().data_local_dir().join(app_name())
|
||||
}
|
||||
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if let Some(dir) = TEST_CACHE_DIR.with(|cell| cell.borrow().clone()) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(dir) = std::env::var("DONUTBROWSER_CACHE_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
|
||||
base_dirs().cache_dir().join(app_name())
|
||||
}
|
||||
|
||||
pub fn profiles_dir() -> PathBuf {
|
||||
data_dir().join("profiles")
|
||||
}
|
||||
|
||||
pub fn binaries_dir() -> PathBuf {
|
||||
data_dir().join("binaries")
|
||||
}
|
||||
|
||||
pub fn data_subdir() -> PathBuf {
|
||||
data_dir().join("data")
|
||||
}
|
||||
|
||||
pub fn settings_dir() -> PathBuf {
|
||||
data_dir().join("settings")
|
||||
}
|
||||
|
||||
pub fn proxies_dir() -> PathBuf {
|
||||
data_dir().join("proxies")
|
||||
}
|
||||
|
||||
pub fn vpn_dir() -> PathBuf {
|
||||
data_dir().join("vpn")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
static TEST_CACHE_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub struct TestDirGuard {
|
||||
kind: TestDirKind,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
enum TestDirKind {
|
||||
Data,
|
||||
Cache,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for TestDirGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.kind {
|
||||
TestDirKind::Data => TEST_DATA_DIR.with(|cell| *cell.borrow_mut() = None),
|
||||
TestDirKind::Cache => TEST_CACHE_DIR.with(|cell| *cell.borrow_mut() = None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_test_data_dir(dir: PathBuf) -> TestDirGuard {
|
||||
TEST_DATA_DIR.with(|cell| *cell.borrow_mut() = Some(dir));
|
||||
TestDirGuard {
|
||||
kind: TestDirKind::Data,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_test_cache_dir(dir: PathBuf) -> TestDirGuard {
|
||||
TEST_CACHE_DIR.with(|cell| *cell.borrow_mut() = Some(dir));
|
||||
TestDirGuard {
|
||||
kind: TestDirKind::Cache,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_app_name() {
|
||||
let name = app_name();
|
||||
assert!(
|
||||
name == "DonutBrowser" || name == "DonutBrowserDev",
|
||||
"app_name should be DonutBrowser or DonutBrowserDev, got: {name}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_dir_returns_path() {
|
||||
let dir = data_dir();
|
||||
assert!(
|
||||
dir.to_string_lossy().contains(app_name()),
|
||||
"data_dir should contain app_name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_dir_returns_path() {
|
||||
let dir = cache_dir();
|
||||
assert!(
|
||||
dir.to_string_lossy().contains(app_name()),
|
||||
"cache_dir should contain app_name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subdirectory_helpers() {
|
||||
assert!(profiles_dir().ends_with("profiles"));
|
||||
assert!(binaries_dir().ends_with("binaries"));
|
||||
assert!(data_subdir().ends_with("data"));
|
||||
assert!(settings_dir().ends_with("settings"));
|
||||
assert!(proxies_dir().ends_with("proxies"));
|
||||
assert!(vpn_dir().ends_with("vpn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_test_data_dir() {
|
||||
let tmp = PathBuf::from("/tmp/test-donut-data");
|
||||
let _guard = set_test_data_dir(tmp.clone());
|
||||
assert_eq!(data_dir(), tmp);
|
||||
assert_eq!(profiles_dir(), tmp.join("profiles"));
|
||||
assert_eq!(binaries_dir(), tmp.join("binaries"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_test_cache_dir() {
|
||||
let tmp = PathBuf::from("/tmp/test-donut-cache");
|
||||
let _guard = set_test_cache_dir(tmp.clone());
|
||||
assert_eq!(cache_dir(), tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_guard_cleanup() {
|
||||
let original_data = data_dir();
|
||||
let original_cache = cache_dir();
|
||||
|
||||
{
|
||||
let _guard = set_test_data_dir(PathBuf::from("/tmp/test-cleanup-data"));
|
||||
assert_eq!(data_dir(), PathBuf::from("/tmp/test-cleanup-data"));
|
||||
}
|
||||
assert_eq!(data_dir(), original_data);
|
||||
|
||||
{
|
||||
let _guard = set_test_cache_dir(PathBuf::from("/tmp/test-cleanup-cache"));
|
||||
assert_eq!(cache_dir(), PathBuf::from("/tmp/test-cleanup-cache"));
|
||||
}
|
||||
assert_eq!(cache_dir(), original_cache);
|
||||
}
|
||||
}
|
||||
@@ -362,15 +362,6 @@ impl AutoUpdater {
|
||||
Ok(updated_profiles)
|
||||
}
|
||||
|
||||
/// Check if browser is disabled due to ongoing update
|
||||
pub fn is_browser_disabled(
|
||||
&self,
|
||||
browser: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let state = self.load_auto_update_state()?;
|
||||
Ok(state.disabled_browsers.contains(browser))
|
||||
}
|
||||
|
||||
/// Dismiss update notification
|
||||
pub fn dismiss_update_notification(
|
||||
&self,
|
||||
@@ -511,6 +502,7 @@ mod tests {
|
||||
version: version.to_string(),
|
||||
process_id: None,
|
||||
proxy_id: None,
|
||||
vpn_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
@@ -518,8 +510,11 @@ mod tests {
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_enabled: false,
|
||||
sync_mode: crate::profile::types::SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -16,12 +16,32 @@ use serde::{Deserialize, Serialize};
|
||||
use tao::event::{Event, StartCause};
|
||||
use tao::event_loop::{ControlFlow, EventLoopBuilder};
|
||||
use tokio::runtime::Runtime;
|
||||
use tray_icon::{MouseButton, TrayIcon, TrayIconEvent};
|
||||
use tray_icon::TrayIcon;
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
use tray_icon::{MouseButton, TrayIconEvent};
|
||||
|
||||
use donutbrowser_lib::daemon::{autostart, services, tray};
|
||||
|
||||
static SHOULD_QUIT: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
#[cfg(windows)]
|
||||
fn win_process_exists(pid: u32) -> bool {
|
||||
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
|
||||
|
||||
extern "system" {
|
||||
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
|
||||
fn CloseHandle(hObject: *mut ()) -> i32;
|
||||
}
|
||||
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
false
|
||||
} else {
|
||||
unsafe { CloseHandle(handle) };
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
enum ServiceStatus {
|
||||
Ready {
|
||||
api_port: Option<u16>,
|
||||
@@ -173,6 +193,41 @@ fn run_daemon() {
|
||||
// Store tray icon in Option - created after event loop starts
|
||||
let mut tray_icon: Option<TrayIcon> = None;
|
||||
|
||||
// Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
extern "C" fn signal_handler(_sig: libc::c_int) {
|
||||
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
libc::signal(
|
||||
libc::SIGTERM,
|
||||
signal_handler as *const () as libc::sighandler_t,
|
||||
);
|
||||
libc::signal(
|
||||
libc::SIGINT,
|
||||
signal_handler as *const () as libc::sighandler_t,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
extern "system" {
|
||||
fn SetConsoleCtrlHandler(
|
||||
handler: Option<unsafe extern "system" fn(u32) -> i32>,
|
||||
add: i32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 {
|
||||
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
1 // TRUE
|
||||
}
|
||||
|
||||
unsafe {
|
||||
SetConsoleCtrlHandler(Some(ctrl_handler), 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the event loop
|
||||
event_loop.run(move |event, _, control_flow| {
|
||||
// Use WaitUntil to check for menu events periodically while staying low on CPU
|
||||
@@ -222,15 +277,15 @@ fn run_daemon() {
|
||||
|
||||
// Process menu events
|
||||
while let Ok(event) = menu_channel.try_recv() {
|
||||
if event.id == tray_menu.open_item.id() {
|
||||
tray::open_gui();
|
||||
} else if event.id == tray_menu.quit_item.id() {
|
||||
if event.id == tray_menu.quit_item.id() {
|
||||
log::info!("[daemon] Quit requested");
|
||||
SHOULD_QUIT.store(true, Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle tray icon click (left-click opens the app)
|
||||
// On macOS, left-click already shows the menu, so don't also launch the GUI.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
|
||||
if let TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
@@ -243,14 +298,25 @@ fn run_daemon() {
|
||||
|
||||
// Use swap to only run cleanup once
|
||||
if SHOULD_QUIT.swap(false, Ordering::SeqCst) {
|
||||
// Cleanup
|
||||
// Remove tray icon from status bar immediately so the UI feels responsive
|
||||
tray_icon = None;
|
||||
|
||||
tray::quit_gui();
|
||||
|
||||
let mut state = read_state();
|
||||
state.daemon_pid = None;
|
||||
let _ = write_state(&state);
|
||||
log::info!("[daemon] Exiting");
|
||||
*control_flow = ControlFlow::Exit;
|
||||
|
||||
// Use process::exit for immediate termination instead of ControlFlow::Exit.
|
||||
// ControlFlow::Exit can delay because tao's macOS event loop defers exit,
|
||||
// and dropping the tokio runtime blocks until all spawned tasks finish.
|
||||
process::exit(0);
|
||||
}
|
||||
}
|
||||
Event::Reopen { .. } => {
|
||||
tray::open_gui();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -266,6 +332,32 @@ fn stop_daemon() {
|
||||
let state = read_state();
|
||||
|
||||
if let Some(pid) = state.daemon_pid {
|
||||
// On Windows, taskkill /F kills instantly with no handler, so kill GUI first
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
|
||||
let state_path = get_state_path();
|
||||
if let Ok(content) = fs::read_to_string(&state_path) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(gui_pid) = val.get("gui_pid").and_then(|v| v.as_u64()) {
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &gui_pid.to_string(), "/F"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
eprintln!("Sent stop signal to daemon (PID {})", pid);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
@@ -273,15 +365,6 @@ fn stop_daemon() {
|
||||
}
|
||||
eprintln!("Sent stop signal to daemon (PID {})", pid);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::process::Command;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output();
|
||||
eprintln!("Sent stop signal to daemon (PID {})", pid);
|
||||
}
|
||||
} else {
|
||||
eprintln!("Daemon is not running");
|
||||
}
|
||||
@@ -295,15 +378,7 @@ fn show_status() {
|
||||
let is_running = unsafe { libc::kill(pid as i32, 0) == 0 };
|
||||
|
||||
#[cfg(windows)]
|
||||
let is_running = {
|
||||
use std::process::Command;
|
||||
let output = Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {}", pid)])
|
||||
.output();
|
||||
output
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()))
|
||||
.unwrap_or(false)
|
||||
};
|
||||
let is_running = win_process_exists(pid);
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
let is_running = false;
|
||||
@@ -357,10 +432,6 @@ fn main() {
|
||||
|
||||
match args[1].as_str() {
|
||||
"start" => {
|
||||
// "start" is now an alias for "run"
|
||||
// On macOS, the daemon should be started via launchctl (see daemon_spawn.rs)
|
||||
// This command is kept for backward compatibility
|
||||
eprintln!("Starting daemon...");
|
||||
run_daemon();
|
||||
}
|
||||
"stop" => {
|
||||
|
||||
@@ -172,6 +172,24 @@ async fn main() {
|
||||
)
|
||||
.arg(Arg::new("action").required(true).help("Action (start)")),
|
||||
)
|
||||
.subcommand(
|
||||
Command::new("vpn-worker")
|
||||
.about("Run a VPN worker process (internal use)")
|
||||
.arg(
|
||||
Arg::new("id")
|
||||
.long("id")
|
||||
.required(true)
|
||||
.help("VPN worker configuration ID"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("port")
|
||||
.long("port")
|
||||
.value_parser(clap::value_parser!(u16))
|
||||
.required(true)
|
||||
.help("Local SOCKS5 port"),
|
||||
)
|
||||
.arg(Arg::new("action").required(true).help("Action (start)")),
|
||||
)
|
||||
.get_matches();
|
||||
|
||||
if let Some(proxy_matches) = matches.subcommand_matches("proxy") {
|
||||
@@ -333,6 +351,107 @@ async fn main() {
|
||||
log::error!("Invalid action for proxy-worker. Use 'start'");
|
||||
process::exit(1);
|
||||
}
|
||||
} else if let Some(vpn_matches) = matches.subcommand_matches("vpn-worker") {
|
||||
let id = vpn_matches.get_one::<String>("id").expect("id is required");
|
||||
let action = vpn_matches
|
||||
.get_one::<String>("action")
|
||||
.expect("action is required");
|
||||
let port = *vpn_matches
|
||||
.get_one::<u16>("port")
|
||||
.expect("port is required");
|
||||
|
||||
if action == "start" {
|
||||
set_high_priority();
|
||||
|
||||
log::info!("VPN worker starting, config id: {}", id);
|
||||
log::info!("Process PID: {}", std::process::id());
|
||||
|
||||
// Retry config loading to handle file system race condition
|
||||
let config = {
|
||||
let mut attempts = 0;
|
||||
loop {
|
||||
if let Some(config) = donutbrowser_lib::vpn_worker_storage::get_vpn_worker_config(id) {
|
||||
log::info!(
|
||||
"Found VPN worker config: id={}, vpn_type={}, vpn_id={}",
|
||||
config.id,
|
||||
config.vpn_type,
|
||||
config.vpn_id
|
||||
);
|
||||
break config;
|
||||
}
|
||||
attempts += 1;
|
||||
if attempts >= 10 {
|
||||
log::error!(
|
||||
"VPN worker configuration {} not found after {} attempts",
|
||||
id,
|
||||
attempts
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
log::info!(
|
||||
"VPN worker config {} not found yet, retrying ({}/10)...",
|
||||
id,
|
||||
attempts
|
||||
);
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
};
|
||||
|
||||
// Read the decrypted VPN config from the temp file
|
||||
let vpn_config_data = match std::fs::read_to_string(&config.config_file_path) {
|
||||
Ok(data) => data,
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to read VPN config file {}: {}",
|
||||
config.config_file_path,
|
||||
e
|
||||
);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
match config.vpn_type.as_str() {
|
||||
"wireguard" => {
|
||||
let wg_config = match donutbrowser_lib::vpn::parse_wireguard_config(&vpn_config_data) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse WireGuard config: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let server =
|
||||
donutbrowser_lib::vpn::socks5_server::WireGuardSocks5Server::new(wg_config, port);
|
||||
if let Err(e) = server.run(id.clone()).await {
|
||||
log::error!("VPN worker failed: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
"openvpn" => {
|
||||
let ovpn_config = match donutbrowser_lib::vpn::parse_openvpn_config(&vpn_config_data) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
log::error!("Failed to parse OpenVPN config: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
let server =
|
||||
donutbrowser_lib::vpn::openvpn_socks5::OpenVpnSocks5Server::new(ovpn_config, port);
|
||||
if let Err(e) = server.run(id.clone()).await {
|
||||
log::error!("VPN worker failed: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
other => {
|
||||
log::error!("Unknown VPN type: {}", other);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
log::error!("Invalid action for vpn-worker. Use 'start'");
|
||||
process::exit(1);
|
||||
}
|
||||
} else {
|
||||
log::error!("No command specified");
|
||||
process::exit(1);
|
||||
|
||||
@@ -612,7 +612,7 @@ mod windows {
|
||||
if let Ok(entries) = std::fs::read_dir(install_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.extension().is_some_and(|ext| ext == "exe") {
|
||||
if path.extension().is_some_and(|ext| ext == "exe") && is_pe_executable(&path) {
|
||||
let name = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
@@ -716,7 +716,7 @@ mod windows {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().is_some_and(|ext| ext == "exe") {
|
||||
if path.extension().is_some_and(|ext| ext == "exe") && is_pe_executable(&path) {
|
||||
let name = path
|
||||
.file_stem()
|
||||
.unwrap_or_default()
|
||||
@@ -1185,6 +1185,22 @@ impl BrowserFactory {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a file is a valid PE executable by reading its magic bytes (MZ).
|
||||
/// Returns false for archive files (.zip starts with PK, etc.) that were
|
||||
/// incorrectly named with a .exe extension.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn is_pe_executable(path: &Path) -> bool {
|
||||
use std::io::Read;
|
||||
let Ok(mut file) = std::fs::File::open(path) else {
|
||||
return false;
|
||||
};
|
||||
let mut magic = [0u8; 2];
|
||||
if file.read_exact(&mut magic).is_err() {
|
||||
return false;
|
||||
}
|
||||
magic == [0x4D, 0x5A] // MZ
|
||||
}
|
||||
|
||||
// Factory function to create browser instances (kept for backward compatibility)
|
||||
pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
|
||||
BrowserFactory::instance().create_browser(browser_type)
|
||||
|
||||
+140
-30
@@ -6,13 +6,11 @@ use crate::platform_browser;
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
|
||||
use directories::BaseDirs;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use sysinfo::System;
|
||||
pub struct BrowserRunner {
|
||||
base_dirs: BaseDirs,
|
||||
pub profile_manager: &'static ProfileManager,
|
||||
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||
auto_updater: &'static crate::auto_updater::AutoUpdater,
|
||||
@@ -23,7 +21,6 @@ pub struct BrowserRunner {
|
||||
impl BrowserRunner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
profile_manager: ProfileManager::instance(),
|
||||
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
|
||||
auto_updater: crate::auto_updater::AutoUpdater::instance(),
|
||||
@@ -37,14 +34,7 @@ impl BrowserRunner {
|
||||
}
|
||||
|
||||
pub fn get_binaries_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
/// Get the executable path for a browser profile
|
||||
@@ -90,17 +80,6 @@ impl BrowserRunner {
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Check if browser is disabled due to ongoing update
|
||||
if self.auto_updater.is_browser_disabled(&profile.browser)? {
|
||||
return Err(
|
||||
format!(
|
||||
"{} is currently being updated. Please wait for the update to complete.",
|
||||
profile.browser
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Handle Camoufox profiles using CamoufoxManager
|
||||
if profile.browser == "camoufox" {
|
||||
// Get or create camoufox config
|
||||
@@ -113,11 +92,34 @@ impl BrowserRunner {
|
||||
});
|
||||
|
||||
// Always start a local proxy for Camoufox (for traffic monitoring and geoip support)
|
||||
let upstream_proxy = profile
|
||||
let mut upstream_proxy = profile
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
if let Some(ref vpn_id) = profile.vpn_id {
|
||||
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
|
||||
Ok(vpn_worker) => {
|
||||
if let Some(port) = vpn_worker.local_port {
|
||||
upstream_proxy = Some(ProxySettings {
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port,
|
||||
username: None,
|
||||
password: None,
|
||||
});
|
||||
log::info!("VPN worker started for Camoufox profile on port {}", port);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to start VPN worker: {e}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Starting local proxy for Camoufox profile: {} (upstream: {})",
|
||||
profile.name,
|
||||
@@ -208,6 +210,23 @@ impl BrowserRunner {
|
||||
);
|
||||
}
|
||||
|
||||
// Ensure DuckDuckGo is set as default search engine for Camoufox
|
||||
let mut browser_dir = self.get_binaries_dir();
|
||||
browser_dir.push(&profile.browser);
|
||||
browser_dir.push(&profile.version);
|
||||
if let Err(e) = crate::downloader::configure_camoufox_search_engine(&browser_dir) {
|
||||
log::warn!("Failed to configure Camoufox search engine: {e}");
|
||||
}
|
||||
|
||||
// Create ephemeral dir for ephemeral profiles
|
||||
let override_profile_path = if profile.ephemeral {
|
||||
let dir = crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
Some(dir)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Launch Camoufox browser
|
||||
log::info!("Launching Camoufox for profile: {}", profile.name);
|
||||
let camoufox_result = self
|
||||
@@ -217,6 +236,7 @@ impl BrowserRunner {
|
||||
updated_profile.clone(),
|
||||
camoufox_config,
|
||||
url,
|
||||
override_profile_path,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
@@ -312,11 +332,34 @@ impl BrowserRunner {
|
||||
});
|
||||
|
||||
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
|
||||
let upstream_proxy = profile
|
||||
let mut upstream_proxy = profile
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
if let Some(ref vpn_id) = profile.vpn_id {
|
||||
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
|
||||
Ok(vpn_worker) => {
|
||||
if let Some(port) = vpn_worker.local_port {
|
||||
upstream_proxy = Some(ProxySettings {
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port,
|
||||
username: None,
|
||||
password: None,
|
||||
});
|
||||
log::info!("VPN worker started for Wayfern profile on port {}", port);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to start VPN worker: {e}").into());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Starting local proxy for Wayfern profile: {} (upstream: {})",
|
||||
profile.name,
|
||||
@@ -397,12 +440,19 @@ impl BrowserRunner {
|
||||
);
|
||||
}
|
||||
|
||||
// Create ephemeral dir for ephemeral profiles
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
}
|
||||
|
||||
// Launch Wayfern browser
|
||||
log::info!("Launching Wayfern for profile: {}", profile.name);
|
||||
|
||||
// Get profile path for Wayfern
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = updated_profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy().to_string();
|
||||
|
||||
// Get proxy URL from config
|
||||
@@ -417,6 +467,7 @@ impl BrowserRunner {
|
||||
&wayfern_config,
|
||||
url.as_deref(),
|
||||
proxy_url,
|
||||
profile.ephemeral,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
@@ -749,7 +800,8 @@ impl BrowserRunner {
|
||||
if profile.browser == "camoufox" {
|
||||
// Get the profile path based on the UUID
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
// Check if the process is running
|
||||
@@ -803,7 +855,8 @@ impl BrowserRunner {
|
||||
// Handle Wayfern profiles using WayfernManager
|
||||
if profile.browser == "wayfern" {
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
// Check if the process is running
|
||||
@@ -1201,7 +1254,8 @@ impl BrowserRunner {
|
||||
if profile.browser == "camoufox" {
|
||||
// Search by profile path to find the running Camoufox instance
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
log::info!(
|
||||
@@ -1619,6 +1673,10 @@ impl BrowserRunner {
|
||||
);
|
||||
}
|
||||
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Camoufox process cleanup completed for profile: {} (ID: {})",
|
||||
profile.name,
|
||||
@@ -1644,7 +1702,8 @@ impl BrowserRunner {
|
||||
// Handle Wayfern profiles using WayfernManager
|
||||
if profile.browser == "wayfern" {
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
log::info!(
|
||||
@@ -1937,6 +1996,10 @@ impl BrowserRunner {
|
||||
);
|
||||
}
|
||||
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Wayfern process cleanup completed for profile: {} (ID: {})",
|
||||
profile.name,
|
||||
@@ -2355,6 +2418,14 @@ impl BrowserRunner {
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| format!("Profile '{profile_id}' not found"))?;
|
||||
|
||||
if profile.is_cross_os() {
|
||||
return Err(format!(
|
||||
"Cannot open URL with profile '{}': it was created on {} and is not supported on this system",
|
||||
profile.name,
|
||||
profile.host_os.as_deref().unwrap_or("unknown")
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("Opening URL '{url}' with profile '{profile_id}'");
|
||||
|
||||
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
|
||||
@@ -2383,6 +2454,14 @@ pub async fn launch_browser_profile(
|
||||
profile.id
|
||||
);
|
||||
|
||||
if profile.is_cross_os() {
|
||||
return Err(format!(
|
||||
"Cannot launch profile '{}': it was created on {} and is not supported on this system",
|
||||
profile.name,
|
||||
profile.host_os.as_deref().unwrap_or("unknown")
|
||||
));
|
||||
}
|
||||
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
|
||||
// Store the internal proxy settings for passing to launch_browser
|
||||
@@ -2413,11 +2492,34 @@ pub async fn launch_browser_profile(
|
||||
// This ensures all traffic goes through the local proxy for monitoring and future features
|
||||
if profile.browser != "camoufox" && profile.browser != "wayfern" {
|
||||
// Determine upstream proxy if configured; otherwise use DIRECT (no upstream)
|
||||
let upstream_proxy = profile_for_launch
|
||||
let mut upstream_proxy = profile_for_launch
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
if let Some(ref vpn_id) = profile_for_launch.vpn_id {
|
||||
match crate::vpn_worker_runner::start_vpn_worker(vpn_id).await {
|
||||
Ok(vpn_worker) => {
|
||||
if let Some(port) = vpn_worker.local_port {
|
||||
upstream_proxy = Some(ProxySettings {
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port,
|
||||
username: None,
|
||||
password: None,
|
||||
});
|
||||
log::info!("VPN worker started for profile on port {}", port);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("Failed to start VPN worker: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
|
||||
let temp_pid = 1u32;
|
||||
let profile_id_str = profile.id.to_string();
|
||||
@@ -2595,6 +2697,14 @@ pub async fn launch_browser_profile_with_debugging(
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
) -> 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",
|
||||
profile.name,
|
||||
profile.host_os.as_deref().unwrap_or("unknown")
|
||||
));
|
||||
}
|
||||
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
browser_runner
|
||||
.launch_browser_with_debugging(app_handle, &profile, url, remote_debugging_port, headless)
|
||||
|
||||
@@ -685,7 +685,7 @@ impl BrowserVersionManager {
|
||||
"macos-arm64" | "macos-x64" => (format!("wayfern-{version}-{platform_key}.dmg"), true),
|
||||
"linux-x64" | "linux-arm64" => (format!("wayfern-{version}-{platform_key}.tar.xz"), true),
|
||||
"windows-x64" | "windows-arm64" => {
|
||||
(format!("wayfern-{version}-{platform_key}.exe"), false)
|
||||
(format!("wayfern-{version}-{platform_key}.zip"), true)
|
||||
}
|
||||
_ => {
|
||||
return Err(
|
||||
|
||||
@@ -267,6 +267,7 @@ impl Default for LocaleSelector {
|
||||
}
|
||||
|
||||
/// Normalize a locale string to standard format.
|
||||
/// Handles formats like "en-US", "zh-Hant-US", "zh-Hans-CN".
|
||||
fn normalize_locale(locale: &str) -> Locale {
|
||||
let parts: Vec<&str> = locale.split('-').collect();
|
||||
|
||||
@@ -275,23 +276,33 @@ fn normalize_locale(locale: &str) -> Locale {
|
||||
.map(|s| s.to_lowercase())
|
||||
.unwrap_or_else(|| "en".to_string());
|
||||
|
||||
let region = parts.get(1).map(|s| s.to_uppercase());
|
||||
// A 4-letter part is a script subtag (e.g. "Hant", "Hans", "Cyrl").
|
||||
// A 2-letter or 3-digit part is a region subtag (e.g. "US", "CN").
|
||||
let mut explicit_script: Option<String> = None;
|
||||
let mut region: Option<String> = None;
|
||||
|
||||
// Determine script based on language if needed
|
||||
let script = match language.as_str() {
|
||||
"zh" => {
|
||||
// Chinese - Traditional for TW/HK, Simplified otherwise
|
||||
if region.as_deref() == Some("TW") || region.as_deref() == Some("HK") {
|
||||
Some("Hant".to_string())
|
||||
} else {
|
||||
Some("Hans".to_string())
|
||||
for part in parts.iter().skip(1) {
|
||||
if part.len() == 4 && part.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
explicit_script = Some(part[..1].to_uppercase() + &part[1..].to_lowercase());
|
||||
} else {
|
||||
region = Some(part.to_uppercase());
|
||||
}
|
||||
}
|
||||
|
||||
let script = if explicit_script.is_some() {
|
||||
explicit_script
|
||||
} else {
|
||||
match language.as_str() {
|
||||
"zh" => {
|
||||
if region.as_deref() == Some("TW") || region.as_deref() == Some("HK") {
|
||||
Some("Hant".to_string())
|
||||
} else {
|
||||
Some("Hans".to_string())
|
||||
}
|
||||
}
|
||||
"sr" => Some("Cyrl".to_string()),
|
||||
_ => None,
|
||||
}
|
||||
"sr" => {
|
||||
// Serbian - can be Cyrillic or Latin
|
||||
Some("Cyrl".to_string())
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
Locale {
|
||||
@@ -442,5 +453,16 @@ mod tests {
|
||||
|
||||
let zh_cn = normalize_locale("zh-CN");
|
||||
assert_eq!(zh_cn.script, Some("Hans".to_string()));
|
||||
|
||||
// 3-part locale: language-script-region
|
||||
let zh_hant_us = normalize_locale("zh-Hant-US");
|
||||
assert_eq!(zh_hant_us.language, "zh");
|
||||
assert_eq!(zh_hant_us.region, Some("US".to_string()));
|
||||
assert_eq!(zh_hant_us.script, Some("Hant".to_string()));
|
||||
|
||||
let zh_hans_us = normalize_locale("zh-Hans-US");
|
||||
assert_eq!(zh_hans_us.language, "zh");
|
||||
assert_eq!(zh_hans_us.region, Some("US".to_string()));
|
||||
assert_eq!(zh_hans_us.script, Some("Hans".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
use crate::camoufox::{CamoufoxConfigBuilder, GeoIPOption, ScreenConstraints};
|
||||
use crate::profile::BrowserProfile;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -74,7 +73,6 @@ struct CamoufoxManagerInner {
|
||||
|
||||
pub struct CamoufoxManager {
|
||||
inner: Arc<AsyncMutex<CamoufoxManagerInner>>,
|
||||
base_dirs: BaseDirs,
|
||||
}
|
||||
|
||||
impl CamoufoxManager {
|
||||
@@ -83,7 +81,6 @@ impl CamoufoxManager {
|
||||
inner: Arc::new(AsyncMutex::new(CamoufoxManagerInner {
|
||||
instances: HashMap::new(),
|
||||
})),
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,14 +89,7 @@ impl CamoufoxManager {
|
||||
}
|
||||
|
||||
pub fn get_profiles_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("profiles");
|
||||
path
|
||||
crate::app_dirs::profiles_dir()
|
||||
}
|
||||
|
||||
/// Generate Camoufox fingerprint configuration during profile creation
|
||||
@@ -111,7 +101,15 @@ impl CamoufoxManager {
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get executable path
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
PathBuf::from(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)
|
||||
@@ -204,7 +202,15 @@ impl CamoufoxManager {
|
||||
|
||||
// Get executable path
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
PathBuf::from(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)
|
||||
@@ -360,8 +366,11 @@ impl CamoufoxManager {
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let result = std::process::Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/T"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.status();
|
||||
|
||||
match result {
|
||||
@@ -576,10 +585,15 @@ impl CamoufoxManager {
|
||||
profile: BrowserProfile,
|
||||
config: CamoufoxConfig,
|
||||
url: Option<String>,
|
||||
override_profile_path: Option<std::path::PathBuf>,
|
||||
) -> Result<CamoufoxLaunchResult, String> {
|
||||
// Get profile path
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_path = if let Some(ref override_path) = override_profile_path {
|
||||
override_path.clone()
|
||||
} else {
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
profile.get_profile_data_path(&profiles_dir)
|
||||
};
|
||||
let profile_path_str = profile_path.to_string_lossy();
|
||||
|
||||
// Check if there's already a running instance for this profile
|
||||
@@ -591,6 +605,29 @@ impl CamoufoxManager {
|
||||
// Clean up any dead instances before launching
|
||||
let _ = self.cleanup_dead_instances().await;
|
||||
|
||||
// For ephemeral profiles, write Firefox prefs to minimize disk writes
|
||||
if override_profile_path.is_some() {
|
||||
let user_js_path = profile_path.join("user.js");
|
||||
let prefs = concat!(
|
||||
"user_pref(\"browser.cache.disk.enable\", false);\n",
|
||||
"user_pref(\"browser.cache.memory.enable\", true);\n",
|
||||
"user_pref(\"browser.sessionstore.resume_from_crash\", false);\n",
|
||||
"user_pref(\"browser.sessionstore.max_tabs_undo\", 0);\n",
|
||||
"user_pref(\"browser.sessionstore.max_windows_undo\", 0);\n",
|
||||
"user_pref(\"places.history.enabled\", false);\n",
|
||||
"user_pref(\"browser.formfill.enable\", false);\n",
|
||||
"user_pref(\"signon.rememberSignons\", false);\n",
|
||||
"user_pref(\"browser.bookmarks.max_backups\", 0);\n",
|
||||
"user_pref(\"browser.shell.checkDefaultBrowser\", false);\n",
|
||||
"user_pref(\"toolkit.crashreporter.enabled\", false);\n",
|
||||
"user_pref(\"browser.pagethumbnails.capturing_disabled\", true);\n",
|
||||
"user_pref(\"browser.download.manager.addToRecentDocs\", false);\n",
|
||||
);
|
||||
if let Err(e) = std::fs::write(&user_js_path, prefs) {
|
||||
log::warn!("Failed to write ephemeral user.js: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
.launch_camoufox(
|
||||
&app_handle,
|
||||
|
||||
@@ -602,11 +602,39 @@ impl CloudAuthManager {
|
||||
pub async fn has_active_paid_subscription(&self) -> bool {
|
||||
let state = self.state.lock().await;
|
||||
match &*state {
|
||||
Some(auth) => auth.user.plan != "free" && auth.user.subscription_status == "active",
|
||||
Some(auth) => {
|
||||
auth.user.plan != "free"
|
||||
&& (auth.user.subscription_status == "active"
|
||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-async version that uses try_lock, defaults to false if lock can't be acquired.
|
||||
pub fn has_active_paid_subscription_sync(&self) -> bool {
|
||||
match self.state.try_lock() {
|
||||
Ok(state) => match &*state {
|
||||
Some(auth) => {
|
||||
auth.user.plan != "free"
|
||||
&& (auth.user.subscription_status == "active"
|
||||
|| auth.user.plan_period.as_deref() == Some("lifetime"))
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_fingerprint_os_allowed(&self, fingerprint_os: Option<&str>) -> bool {
|
||||
let host_os = crate::profile::types::get_host_os();
|
||||
match fingerprint_os {
|
||||
None => true,
|
||||
Some(os) if os == host_os => true,
|
||||
Some(_) => self.has_active_paid_subscription().await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_user(&self) -> Option<CloudAuthState> {
|
||||
let state = self.state.lock().await;
|
||||
state.clone()
|
||||
@@ -1071,6 +1099,12 @@ pub async fn restart_sync_service(app_handle: tauri::AppHandle) -> Result<(), St
|
||||
{
|
||||
log::warn!("Failed to check for missing profiles: {}", e);
|
||||
}
|
||||
if let Err(e) = engine
|
||||
.check_for_missing_synced_entities(&app_handle_sync)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to check for missing entities: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping missing profile check: {}", e);
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::profile::manager::ProfileManager;
|
||||
use crate::profile::BrowserProfile;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::AppHandle;
|
||||
@@ -62,6 +63,14 @@ pub struct CookieCopyResult {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of a cookie import operation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CookieImportResult {
|
||||
pub cookies_imported: usize,
|
||||
pub cookies_replaced: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct CookieManager;
|
||||
|
||||
impl CookieManager {
|
||||
@@ -493,4 +502,499 @@ impl CookieManager {
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Parse Netscape format cookies from text content
|
||||
fn parse_netscape_cookies(content: &str) -> (Vec<UnifiedCookie>, Vec<String>) {
|
||||
let mut cookies = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fields: Vec<&str> = line.split('\t').collect();
|
||||
if fields.len() < 7 {
|
||||
errors.push(format!(
|
||||
"Line {}: expected 7 tab-separated fields, got {}",
|
||||
i + 1,
|
||||
fields.len()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let domain = fields[0].to_string();
|
||||
let path = fields[2].to_string();
|
||||
let is_secure = fields[3].eq_ignore_ascii_case("TRUE");
|
||||
let expires = fields[4].parse::<i64>().unwrap_or(0);
|
||||
let name = fields[5].to_string();
|
||||
let value = fields[6].to_string();
|
||||
|
||||
cookies.push(UnifiedCookie {
|
||||
name,
|
||||
value,
|
||||
domain,
|
||||
path,
|
||||
expires,
|
||||
is_secure,
|
||||
is_http_only: false,
|
||||
same_site: 0,
|
||||
creation_time: now,
|
||||
last_accessed: now,
|
||||
});
|
||||
}
|
||||
|
||||
(cookies, errors)
|
||||
}
|
||||
|
||||
/// Parse JSON format cookies (array of cookie objects, e.g. from browser extensions)
|
||||
fn parse_json_cookies(content: &str) -> (Vec<UnifiedCookie>, Vec<String>) {
|
||||
let mut cookies = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
let arr: Vec<Value> = match serde_json::from_str(content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
errors.push(format!("Failed to parse JSON: {e}"));
|
||||
return (cookies, errors);
|
||||
}
|
||||
};
|
||||
|
||||
for (i, obj) in arr.iter().enumerate() {
|
||||
let name = match obj.get("name").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
errors.push(format!("Cookie {}: missing 'name' field", i + 1));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let value = obj
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let domain = match obj.get("domain").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
errors.push(format!("Cookie {}: missing 'domain' field", i + 1));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = obj
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("/")
|
||||
.to_string();
|
||||
let is_secure = obj.get("secure").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let is_http_only = obj
|
||||
.get("httpOnly")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_session = obj
|
||||
.get("session")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let expires = if is_session {
|
||||
0
|
||||
} else {
|
||||
obj
|
||||
.get("expirationDate")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|f| f as i64)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let same_site = obj
|
||||
.get("sameSite")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| match s {
|
||||
"lax" => 1,
|
||||
"strict" => 2,
|
||||
_ => 0, // "no_restriction" or unrecognized
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
cookies.push(UnifiedCookie {
|
||||
name,
|
||||
value,
|
||||
domain,
|
||||
path,
|
||||
expires,
|
||||
is_secure,
|
||||
is_http_only,
|
||||
same_site,
|
||||
creation_time: now,
|
||||
last_accessed: now,
|
||||
});
|
||||
}
|
||||
|
||||
(cookies, errors)
|
||||
}
|
||||
|
||||
/// Auto-detect cookie format and parse
|
||||
fn parse_cookies(content: &str) -> (Vec<UnifiedCookie>, Vec<String>) {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.starts_with('[') && serde_json::from_str::<Vec<Value>>(trimmed).is_ok() {
|
||||
return Self::parse_json_cookies(trimmed);
|
||||
}
|
||||
Self::parse_netscape_cookies(content)
|
||||
}
|
||||
|
||||
/// Format cookies as Netscape TXT
|
||||
pub fn format_netscape_cookies(cookies: &[UnifiedCookie]) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push("# Netscape HTTP Cookie File".to_string());
|
||||
for cookie in cookies {
|
||||
let flag = if cookie.domain.starts_with('.') {
|
||||
"TRUE"
|
||||
} else {
|
||||
"FALSE"
|
||||
};
|
||||
let secure = if cookie.is_secure { "TRUE" } else { "FALSE" };
|
||||
lines.push(format!(
|
||||
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
|
||||
cookie.domain, flag, cookie.path, secure, cookie.expires, cookie.name, cookie.value
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Format cookies as JSON
|
||||
pub fn format_json_cookies(cookies: &[UnifiedCookie]) -> String {
|
||||
let arr: Vec<Value> = cookies
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let same_site_str = match c.same_site {
|
||||
1 => "lax",
|
||||
2 => "strict",
|
||||
_ => "no_restriction",
|
||||
};
|
||||
serde_json::json!({
|
||||
"name": c.name,
|
||||
"value": c.value,
|
||||
"domain": c.domain,
|
||||
"path": c.path,
|
||||
"secure": c.is_secure,
|
||||
"httpOnly": c.is_http_only,
|
||||
"sameSite": same_site_str,
|
||||
"expirationDate": c.expires,
|
||||
"session": c.expires == 0,
|
||||
"hostOnly": !c.domain.starts_with('.'),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
/// Public API: Import cookies with auto-format detection
|
||||
pub async fn import_cookies(
|
||||
app_handle: &AppHandle,
|
||||
profile_id: &str,
|
||||
content: &str,
|
||||
) -> Result<CookieImportResult, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles_dir = profile_manager.get_profiles_dir();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| format!("Profile not found: {profile_id}"))?;
|
||||
|
||||
let is_running = profile_manager
|
||||
.check_browser_status(app_handle.clone(), profile)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_running {
|
||||
return Err(format!(
|
||||
"Cannot import cookies while browser is running for profile: {}",
|
||||
profile.name
|
||||
));
|
||||
}
|
||||
|
||||
let (cookies, parse_errors) = Self::parse_cookies(content);
|
||||
|
||||
if cookies.is_empty() {
|
||||
return Err("No valid cookies found in the file".to_string());
|
||||
}
|
||||
|
||||
let db_path = Self::get_cookie_db_path(profile, &profiles_dir)?;
|
||||
|
||||
let write_result = match profile.browser.as_str() {
|
||||
"camoufox" => Self::write_firefox_cookies(&db_path, &cookies),
|
||||
"wayfern" => Self::write_chrome_cookies(&db_path, &cookies),
|
||||
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
|
||||
};
|
||||
|
||||
match write_result {
|
||||
Ok((imported, replaced)) => Ok(CookieImportResult {
|
||||
cookies_imported: imported,
|
||||
cookies_replaced: replaced,
|
||||
errors: parse_errors,
|
||||
}),
|
||||
Err(e) => Err(format!("Failed to write cookies: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Public API: Export cookies from a profile in the specified format
|
||||
pub fn export_cookies(profile_id: &str, format: &str) -> Result<String, String> {
|
||||
let result = Self::read_cookies(profile_id)?;
|
||||
let all_cookies: Vec<UnifiedCookie> =
|
||||
result.domains.into_iter().flat_map(|d| d.cookies).collect();
|
||||
|
||||
match format {
|
||||
"json" => Ok(Self::format_json_cookies(&all_cookies)),
|
||||
"netscape" => Ok(Self::format_netscape_cookies(&all_cookies)),
|
||||
_ => Err(format!("Unsupported export format: {format}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_netscape_cookies_valid() {
|
||||
let content = "# Netscape HTTP Cookie File\n\
|
||||
.example.com\tTRUE\t/\tTRUE\t1700000000\tsession_id\tabc123\n\
|
||||
example.com\tFALSE\t/path\tFALSE\t0\ttoken\txyz";
|
||||
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
|
||||
assert_eq!(cookies.len(), 2);
|
||||
assert!(errors.is_empty());
|
||||
|
||||
assert_eq!(cookies[0].domain, ".example.com");
|
||||
assert_eq!(cookies[0].name, "session_id");
|
||||
assert_eq!(cookies[0].value, "abc123");
|
||||
assert_eq!(cookies[0].path, "/");
|
||||
assert!(cookies[0].is_secure);
|
||||
assert_eq!(cookies[0].expires, 1700000000);
|
||||
|
||||
assert_eq!(cookies[1].domain, "example.com");
|
||||
assert!(!cookies[1].is_secure);
|
||||
assert_eq!(cookies[1].expires, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_netscape_cookies_skips_comments_and_blanks() {
|
||||
let content = "# Comment line\n\n \n# Another comment\n\
|
||||
.test.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n";
|
||||
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert!(errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_netscape_cookies_malformed_lines() {
|
||||
let content = "not\tenough\tfields\n\
|
||||
.ok.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n";
|
||||
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert_eq!(errors.len(), 1);
|
||||
assert!(errors[0].contains("expected 7 tab-separated fields"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_cookies_valid() {
|
||||
let content = r#"[
|
||||
{
|
||||
"name": "sid",
|
||||
"value": "abc",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"sameSite": "lax",
|
||||
"expirationDate": 1700000000,
|
||||
"session": false
|
||||
}
|
||||
]"#;
|
||||
let (cookies, errors) = CookieManager::parse_json_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(cookies[0].name, "sid");
|
||||
assert_eq!(cookies[0].domain, ".example.com");
|
||||
assert!(cookies[0].is_secure);
|
||||
assert!(cookies[0].is_http_only);
|
||||
assert_eq!(cookies[0].same_site, 1);
|
||||
assert_eq!(cookies[0].expires, 1700000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_cookies_session() {
|
||||
let content = r#"[{"name": "s", "value": "v", "domain": ".d.com", "session": true, "expirationDate": 9999}]"#;
|
||||
let (cookies, errors) = CookieManager::parse_json_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(cookies[0].expires, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_cookies_same_site_mapping() {
|
||||
let content = r#"[
|
||||
{"name": "a", "value": "", "domain": ".d.com", "sameSite": "no_restriction"},
|
||||
{"name": "b", "value": "", "domain": ".d.com", "sameSite": "lax"},
|
||||
{"name": "c", "value": "", "domain": ".d.com", "sameSite": "strict"}
|
||||
]"#;
|
||||
let (cookies, _) = CookieManager::parse_json_cookies(content);
|
||||
assert_eq!(cookies[0].same_site, 0);
|
||||
assert_eq!(cookies[1].same_site, 1);
|
||||
assert_eq!(cookies[2].same_site, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cookies_auto_detect_json() {
|
||||
let content = r#"[{"name": "x", "value": "y", "domain": ".test.com"}]"#;
|
||||
let (cookies, _) = CookieManager::parse_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert_eq!(cookies[0].name, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cookies_auto_detect_netscape() {
|
||||
let content = ".test.com\tTRUE\t/\tFALSE\t0\tname\tvalue";
|
||||
let (cookies, _) = CookieManager::parse_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert_eq!(cookies[0].name, "name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_netscape_cookies() {
|
||||
let cookies = vec![UnifiedCookie {
|
||||
name: "sid".to_string(),
|
||||
value: "abc".to_string(),
|
||||
domain: ".example.com".to_string(),
|
||||
path: "/".to_string(),
|
||||
expires: 1700000000,
|
||||
is_secure: true,
|
||||
is_http_only: false,
|
||||
same_site: 0,
|
||||
creation_time: 0,
|
||||
last_accessed: 0,
|
||||
}];
|
||||
let output = CookieManager::format_netscape_cookies(&cookies);
|
||||
assert!(output.contains("# Netscape HTTP Cookie File"));
|
||||
assert!(output.contains(".example.com\tTRUE\t/\tTRUE\t1700000000\tsid\tabc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_json_cookies() {
|
||||
let cookies = vec![UnifiedCookie {
|
||||
name: "sid".to_string(),
|
||||
value: "abc".to_string(),
|
||||
domain: ".example.com".to_string(),
|
||||
path: "/".to_string(),
|
||||
expires: 1700000000,
|
||||
is_secure: true,
|
||||
is_http_only: true,
|
||||
same_site: 1,
|
||||
creation_time: 0,
|
||||
last_accessed: 0,
|
||||
}];
|
||||
let output = CookieManager::format_json_cookies(&cookies);
|
||||
let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0]["name"], "sid");
|
||||
assert_eq!(parsed[0]["sameSite"], "lax");
|
||||
assert_eq!(parsed[0]["session"], false);
|
||||
assert_eq!(parsed[0]["hostOnly"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_netscape_roundtrip() {
|
||||
let cookies = vec![
|
||||
UnifiedCookie {
|
||||
name: "a".to_string(),
|
||||
value: "1".to_string(),
|
||||
domain: ".d.com".to_string(),
|
||||
path: "/".to_string(),
|
||||
expires: 1700000000,
|
||||
is_secure: true,
|
||||
is_http_only: false,
|
||||
same_site: 0,
|
||||
creation_time: 0,
|
||||
last_accessed: 0,
|
||||
},
|
||||
UnifiedCookie {
|
||||
name: "b".to_string(),
|
||||
value: "2".to_string(),
|
||||
domain: "d.com".to_string(),
|
||||
path: "/p".to_string(),
|
||||
expires: 0,
|
||||
is_secure: false,
|
||||
is_http_only: false,
|
||||
same_site: 0,
|
||||
creation_time: 0,
|
||||
last_accessed: 0,
|
||||
},
|
||||
];
|
||||
let formatted = CookieManager::format_netscape_cookies(&cookies);
|
||||
let (parsed, errors) = CookieManager::parse_netscape_cookies(&formatted);
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert_eq!(parsed[0].name, "a");
|
||||
assert_eq!(parsed[0].domain, ".d.com");
|
||||
assert!(parsed[0].is_secure);
|
||||
assert_eq!(parsed[1].name, "b");
|
||||
assert_eq!(parsed[1].domain, "d.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_roundtrip() {
|
||||
let cookies = vec![UnifiedCookie {
|
||||
name: "tok".to_string(),
|
||||
value: "xyz".to_string(),
|
||||
domain: ".site.org".to_string(),
|
||||
path: "/app".to_string(),
|
||||
expires: 1700000000,
|
||||
is_secure: false,
|
||||
is_http_only: true,
|
||||
same_site: 2,
|
||||
creation_time: 0,
|
||||
last_accessed: 0,
|
||||
}];
|
||||
let formatted = CookieManager::format_json_cookies(&cookies);
|
||||
let (parsed, errors) = CookieManager::parse_json_cookies(&formatted);
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].name, "tok");
|
||||
assert_eq!(parsed[0].domain, ".site.org");
|
||||
assert_eq!(parsed[0].path, "/app");
|
||||
assert!(!parsed[0].is_secure);
|
||||
assert!(parsed[0].is_http_only);
|
||||
assert_eq!(parsed[0].same_site, 2);
|
||||
assert_eq!(parsed[0].expires, 1700000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_time_to_unix() {
|
||||
assert_eq!(CookieManager::chrome_time_to_unix(0), 0);
|
||||
let chrome_time: i64 = (1700000000 + CookieManager::WINDOWS_EPOCH_DIFF) * 1_000_000;
|
||||
assert_eq!(CookieManager::chrome_time_to_unix(chrome_time), 1700000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unix_to_chrome_time() {
|
||||
assert_eq!(CookieManager::unix_to_chrome_time(0), 0);
|
||||
let expected = (1700000000 + CookieManager::WINDOWS_EPOCH_DIFF) * 1_000_000;
|
||||
assert_eq!(CookieManager::unix_to_chrome_time(1700000000), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_time_roundtrip() {
|
||||
let unix = 1700000000_i64;
|
||||
let chrome = CookieManager::unix_to_chrome_time(unix);
|
||||
assert_eq!(CookieManager::chrome_time_to_unix(chrome), unix);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,11 +103,6 @@ pub fn enable_autostart() -> io::Result<()> {
|
||||
<true/>
|
||||
<key>LimitLoadToSessionType</key>
|
||||
<string>Aqua</string>
|
||||
<key>KeepAlive</key>
|
||||
<dict>
|
||||
<key>SuccessfulExit</key>
|
||||
<false/>
|
||||
</dict>
|
||||
<key>ProcessType</key>
|
||||
<string>Interactive</string>
|
||||
<key>StandardOutPath</key>
|
||||
@@ -188,6 +183,26 @@ pub fn load_launch_agent() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn start_launch_agent() -> io::Result<()> {
|
||||
use std::process::Command;
|
||||
|
||||
let output = Command::new("launchctl")
|
||||
.args(["start", "com.donutbrowser.daemon"])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(io::Error::other(format!(
|
||||
"launchctl start failed: {}",
|
||||
stderr
|
||||
)));
|
||||
}
|
||||
|
||||
log::info!("Started launch agent via launchctl");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn unload_launch_agent() -> io::Result<()> {
|
||||
use std::process::Command;
|
||||
@@ -229,16 +244,22 @@ pub fn enable_autostart() -> io::Result<()> {
|
||||
|
||||
let desktop_path = autostart_dir.join("donut-daemon.desktop");
|
||||
|
||||
let escaped_daemon_path = daemon_path
|
||||
.display()
|
||||
.to_string()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('`', "\\`")
|
||||
.replace('$', "\\$");
|
||||
let desktop_content = format!(
|
||||
r#"[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Donut Browser Daemon
|
||||
Exec={} start
|
||||
Exec="{escaped_daemon_path}" run
|
||||
Hidden=false
|
||||
NoDisplay=true
|
||||
X-GNOME-Autostart-enabled=true
|
||||
"#,
|
||||
daemon_path.display()
|
||||
);
|
||||
|
||||
fs::write(&desktop_path, desktop_content)?;
|
||||
@@ -281,7 +302,7 @@ pub fn enable_autostart() -> io::Result<()> {
|
||||
|
||||
key.set_value(
|
||||
"DonutBrowserDaemon",
|
||||
&format!("\"{}\" start", daemon_path.display()),
|
||||
&format!("\"{}\" run", daemon_path.display()),
|
||||
)?;
|
||||
|
||||
log::info!("Added registry autostart entry");
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
use muda::{Menu, MenuItem, PredefinedMenuItem};
|
||||
use muda::{Menu, MenuItem};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tray_icon::{Icon, TrayIcon, TrayIconBuilder};
|
||||
|
||||
static GUI_RUNNING: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub fn load_icon() -> Icon {
|
||||
// On Windows, use the full-color icon so it renders well on dark taskbars.
|
||||
// On macOS/Linux, use the template icon (black with alpha) for system light/dark handling.
|
||||
@@ -25,7 +22,6 @@ pub fn load_icon() -> Icon {
|
||||
|
||||
pub struct TrayMenu {
|
||||
pub menu: Menu,
|
||||
pub open_item: MenuItem,
|
||||
pub quit_item: MenuItem,
|
||||
}
|
||||
|
||||
@@ -39,19 +35,11 @@ impl TrayMenu {
|
||||
pub fn new() -> Self {
|
||||
let menu = Menu::new();
|
||||
|
||||
let open_item = MenuItem::new("Open Donut Browser", true, None);
|
||||
let separator = PredefinedMenuItem::separator();
|
||||
let quit_item = MenuItem::new("Quit Donut Browser", true, None);
|
||||
|
||||
menu.append(&open_item).unwrap();
|
||||
menu.append(&separator).unwrap();
|
||||
menu.append(&quit_item).unwrap();
|
||||
|
||||
Self {
|
||||
menu,
|
||||
open_item,
|
||||
quit_item,
|
||||
}
|
||||
Self { menu, quit_item }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,25 +56,45 @@ pub fn create_tray_icon(icon: Icon, menu: &Menu) -> TrayIcon {
|
||||
builder.build().expect("Failed to create tray icon")
|
||||
}
|
||||
|
||||
pub fn open_gui() {
|
||||
if GUI_RUNNING.load(Ordering::SeqCst) {
|
||||
log::info!("GUI already running, activating...");
|
||||
activate_gui();
|
||||
return;
|
||||
/// Resolve the .app bundle path from the current daemon executable.
|
||||
/// In production the daemon is at `Donut.app/Contents/MacOS/donut-daemon`.
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_app_bundle_path() -> Option<std::path::PathBuf> {
|
||||
let exe = std::env::current_exe().ok()?;
|
||||
let macos_dir = exe.parent()?;
|
||||
let contents_dir = macos_dir.parent()?;
|
||||
let app_dir = contents_dir.parent()?;
|
||||
if app_dir.extension().and_then(|e| e.to_str()) == Some("app") {
|
||||
Some(app_dir.to_path_buf())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn open_gui() {
|
||||
log::info!("Opening GUI...");
|
||||
|
||||
// On macOS, use `open` WITHOUT `-n`. The daemon runs with Accessory
|
||||
// activation policy so macOS won't confuse it with the GUI process.
|
||||
// `open` will either activate the existing GUI or launch a new one.
|
||||
// Using `-n` would bypass the single-instance plugin entirely.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = Command::new("open").arg("-a").arg("Donut Browser").spawn();
|
||||
// Use `open -n` to force launching a new process. Without `-n`, macOS
|
||||
// re-activates the daemon (the existing process from the bundle) instead
|
||||
// of launching the GUI binary. The single-instance Tauri plugin in the
|
||||
// GUI handles deduplication if a GUI instance is already running.
|
||||
if let Some(app_bundle) = get_app_bundle_path() {
|
||||
let _ = Command::new("open").args(["-n"]).arg(&app_bundle).spawn();
|
||||
} else {
|
||||
let _ = Command::new("open").args(["-n", "-a", "Donut"]).spawn();
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::path::PathBuf;
|
||||
|
||||
// In dev mode, find the main exe next to the daemon binary
|
||||
if let Ok(current_exe) = std::env::current_exe() {
|
||||
if let Some(exe_dir) = current_exe.parent() {
|
||||
let app_path = exe_dir.join("donutbrowser.exe");
|
||||
@@ -118,15 +126,77 @@ pub fn open_gui() {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn activate_gui() {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn read_gui_pid() -> Option<u32> {
|
||||
let path = super::autostart::get_data_dir()?.join("daemon-state.json");
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
val.get("gui_pid")?.as_u64().map(|p| p as u32)
|
||||
}
|
||||
|
||||
fn kill_gui_by_pid() -> bool {
|
||||
let Some(pid) = read_gui_pid() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let _ = Command::new("osascript")
|
||||
.args(["-e", "tell application \"Donut Browser\" to activate"])
|
||||
.spawn();
|
||||
let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
|
||||
ret == 0
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_gui_running(running: bool) {
|
||||
GUI_RUNNING.store(running, Ordering::SeqCst);
|
||||
pub fn quit_gui() {
|
||||
log::info!("[daemon] Quitting GUI...");
|
||||
|
||||
if kill_gui_by_pid() {
|
||||
log::info!("[daemon] GUI killed by PID");
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("[daemon] PID-based kill failed, falling back to name-based kill");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Use spawn() instead of output() to avoid blocking the event loop.
|
||||
// AppleScript has a ~2 minute default timeout that would freeze the tray icon.
|
||||
let _ = Command::new("osascript")
|
||||
.args(["-e", "tell application \"Donut\" to quit"])
|
||||
.spawn();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/IM", "Donut.exe", "/F"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn();
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/IM", "donutbrowser.exe", "/F"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.spawn();
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = Command::new("pkill").args(["-x", "donutbrowser"]).spawn();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,26 @@ use std::time::Duration;
|
||||
|
||||
use crate::daemon::autostart;
|
||||
|
||||
/// Check if a process with the given PID exists using the Windows API.
|
||||
/// This avoids spawning tasklist.exe which causes a visible conhost window flash.
|
||||
#[cfg(windows)]
|
||||
fn win_process_exists(pid: u32) -> bool {
|
||||
const PROCESS_QUERY_LIMITED_INFORMATION: u32 = 0x1000;
|
||||
|
||||
extern "system" {
|
||||
fn OpenProcess(dwDesiredAccess: u32, bInheritHandles: i32, dwProcessId: u32) -> *mut ();
|
||||
fn CloseHandle(hObject: *mut ()) -> i32;
|
||||
}
|
||||
|
||||
let handle = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, 0, pid) };
|
||||
if handle.is_null() {
|
||||
false
|
||||
} else {
|
||||
unsafe { CloseHandle(handle) };
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Default)]
|
||||
struct DaemonState {
|
||||
daemon_pid: Option<u32>,
|
||||
@@ -32,7 +52,7 @@ fn read_state() -> DaemonState {
|
||||
DaemonState::default()
|
||||
}
|
||||
|
||||
fn is_daemon_running() -> bool {
|
||||
pub fn is_daemon_running() -> bool {
|
||||
let state = read_state();
|
||||
|
||||
if let Some(pid) = state.daemon_pid {
|
||||
@@ -43,12 +63,7 @@ fn is_daemon_running() -> bool {
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
let output = Command::new("tasklist")
|
||||
.args(["/FI", &format!("PID eq {}", pid)])
|
||||
.output();
|
||||
output
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).contains(&pid.to_string()))
|
||||
.unwrap_or(false)
|
||||
win_process_exists(pid)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
@@ -113,7 +128,13 @@ fn get_daemon_path() -> Option<PathBuf> {
|
||||
// Try to find it in PATH
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(output) = Command::new("where").arg("donut-daemon").output() {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
if let Ok(output) = Command::new("where")
|
||||
.arg("donut-daemon")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout);
|
||||
let path = path.lines().next()?.trim();
|
||||
@@ -243,6 +264,11 @@ fn spawn_daemon_macos() -> Result<(), String> {
|
||||
autostart::load_launch_agent().map_err(|e| format!("Failed to load LaunchAgent: {}", e))?;
|
||||
log::info!("launchctl load completed");
|
||||
|
||||
// Also explicitly start the agent in case it was already loaded but stopped
|
||||
if let Err(e) = autostart::start_launch_agent() {
|
||||
log::debug!("launchctl start note (non-fatal): {}", e);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -308,3 +334,26 @@ pub fn ensure_daemon_running() -> Result<(), String> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_gui_pid() {
|
||||
let path = get_state_path();
|
||||
let mut val: serde_json::Value = if path.exists() {
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|c| serde_json::from_str(&c).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}))
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.insert(
|
||||
"gui_pid".to_string(),
|
||||
serde_json::Value::Number(std::process::id().into()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(content) = serde_json::to_string_pretty(&val) {
|
||||
let _ = fs::write(&path, content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ mod windows {
|
||||
.map_err(|e| format!("Failed to set ApplicationDescription: {}", e))?;
|
||||
|
||||
app_key
|
||||
.set_value("ApplicationIcon", &format!("{},0", exe_path))
|
||||
.set_value("ApplicationIcon", &format!("\"{}\",0", exe_path))
|
||||
.map_err(|e| format!("Failed to set ApplicationIcon: {}", e))?;
|
||||
|
||||
// Create Capabilities key
|
||||
@@ -273,7 +273,7 @@ mod windows {
|
||||
.map_err(|e| format!("Failed to create DefaultIcon key: {}", e))?;
|
||||
|
||||
icon_key
|
||||
.set_value("", &format!("{},0", exe_path))
|
||||
.set_value("", &format!("\"{}\",0", exe_path))
|
||||
.map_err(|e| format!("Failed to set default icon: {}", e))?;
|
||||
|
||||
// Create shell\open\command key
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
@@ -71,16 +70,7 @@ impl DownloadedBrowsersRegistry {
|
||||
}
|
||||
|
||||
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("data");
|
||||
path.push("downloaded_browsers.json");
|
||||
Ok(path)
|
||||
Ok(crate::app_dirs::data_subdir().join("downloaded_browsers.json"))
|
||||
}
|
||||
|
||||
pub fn add_browser(&self, info: DownloadedBrowserInfo) {
|
||||
@@ -128,19 +118,7 @@ impl DownloadedBrowsersRegistry {
|
||||
};
|
||||
let browser_instance = create_browser(browser_type.clone());
|
||||
|
||||
// Get binaries directory
|
||||
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||
|
||||
let files_exist = browser_instance.is_version_downloaded(version, &binaries_dir);
|
||||
|
||||
@@ -312,6 +290,30 @@ impl DownloadedBrowsersRegistry {
|
||||
}
|
||||
}
|
||||
|
||||
// Filter out versions that would leave a browser with zero versions in the registry
|
||||
{
|
||||
let data = self.data.lock().unwrap();
|
||||
let mut removal_counts: std::collections::HashMap<String, usize> =
|
||||
std::collections::HashMap::new();
|
||||
for (browser, _) in &to_remove {
|
||||
*removal_counts.entry(browser.clone()).or_insert(0) += 1;
|
||||
}
|
||||
to_remove.retain(|(browser, version)| {
|
||||
let total = data
|
||||
.browsers
|
||||
.get(browser.as_str())
|
||||
.map(|v| v.len())
|
||||
.unwrap_or(0);
|
||||
let removing = *removal_counts.get(browser.as_str()).unwrap_or(&0);
|
||||
if removing >= total {
|
||||
log::info!("Keeping last available version: {browser} {version}");
|
||||
*removal_counts.get_mut(browser.as_str()).unwrap() -= 1;
|
||||
return false;
|
||||
}
|
||||
true
|
||||
});
|
||||
}
|
||||
|
||||
// Remove unused binaries and their version folders
|
||||
for (browser, version) in to_remove {
|
||||
if let Err(e) = self.cleanup_failed_download(&browser, &version) {
|
||||
@@ -511,15 +513,7 @@ impl DownloadedBrowsersRegistry {
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get binaries directory path
|
||||
let base_dirs = directories::BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let mut binaries_dir = base_dirs.data_local_dir().to_path_buf();
|
||||
binaries_dir.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
binaries_dir.push("binaries");
|
||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||
|
||||
let version_dir = binaries_dir.join(browser).join(version);
|
||||
|
||||
@@ -806,19 +800,7 @@ impl DownloadedBrowsersRegistry {
|
||||
|
||||
let browser = create_browser(browser_type.clone());
|
||||
|
||||
// Get binaries directory
|
||||
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
} else {
|
||||
return Err("Failed to get base directories".into());
|
||||
};
|
||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||
|
||||
log::info!(
|
||||
"binaries_dir: {binaries_dir:?} for profile: {}",
|
||||
@@ -1164,6 +1146,58 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_last_version_kept_during_cleanup() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
// Add a single version for "firefox"
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/firefox/139.0"),
|
||||
});
|
||||
|
||||
// Add two versions for "chromium"
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "chromium".to_string(),
|
||||
version: "120.0".to_string(),
|
||||
file_path: PathBuf::from("/test/chromium/120.0"),
|
||||
});
|
||||
registry.add_browser(DownloadedBrowserInfo {
|
||||
browser: "chromium".to_string(),
|
||||
version: "121.0".to_string(),
|
||||
file_path: PathBuf::from("/test/chromium/121.0"),
|
||||
});
|
||||
|
||||
// No active or running profiles
|
||||
let result = registry
|
||||
.cleanup_unused_binaries_internal(&[], &[])
|
||||
.expect("cleanup should succeed");
|
||||
|
||||
// firefox 139.0 should be kept (last version), chromium should lose one but keep one
|
||||
// The exact one kept depends on iteration order, but at least one must remain
|
||||
assert!(
|
||||
!result.contains(&"firefox 139.0".to_string()),
|
||||
"Last version of firefox should not be cleaned up"
|
||||
);
|
||||
// At most one chromium version should have been cleaned up
|
||||
let chromium_cleaned: Vec<_> = result
|
||||
.iter()
|
||||
.filter(|r| r.starts_with("chromium"))
|
||||
.collect();
|
||||
assert!(
|
||||
chromium_cleaned.len() <= 1,
|
||||
"At most one chromium version should be cleaned up, got: {:?}",
|
||||
chromium_cleaned
|
||||
);
|
||||
|
||||
// Verify firefox is still registered
|
||||
assert!(
|
||||
registry.is_browser_registered("firefox", "139.0"),
|
||||
"Last firefox version should still be registered"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_browser_registered_vs_downloaded() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
+67
-16
@@ -434,6 +434,13 @@ 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>,
|
||||
@@ -674,21 +681,7 @@ impl Downloader {
|
||||
|
||||
// Use injected registry instance
|
||||
|
||||
// Get binaries directory - we need to get it from somewhere
|
||||
// This is a bit tricky since we don't have access to BrowserRunner's get_binaries_dir
|
||||
// We'll need to replicate this logic
|
||||
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
} else {
|
||||
return Err("Failed to get base directories".into());
|
||||
};
|
||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||
|
||||
// Check if registry thinks it's downloaded, but also verify files actually exist
|
||||
if self.registry.is_browser_downloaded(&browser_str, &version) {
|
||||
@@ -975,7 +968,10 @@ impl Downloader {
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to create version.json for Camoufox: {e}");
|
||||
// Don't fail the download if version.json creation fails
|
||||
}
|
||||
|
||||
if let Err(e) = self.configure_camoufox_search_engine(&browser_dir) {
|
||||
log::warn!("Failed to configure Camoufox search engine: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1037,6 +1033,61 @@ pub async fn cancel_download(browser_str: String, version: String) -> Result<(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Set DuckDuckGo as the default search engine in Camoufox policies.json.
|
||||
/// Removes the fake "None" search engine and explicitly sets DuckDuckGo as default.
|
||||
/// 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 policies_path = browser_dir.join("distribution").join("policies.json");
|
||||
|
||||
if !policies_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&policies_path)?;
|
||||
let mut policies: serde_json::Value = serde_json::from_str(&content)?;
|
||||
|
||||
let current_default = policies
|
||||
.get("policies")
|
||||
.and_then(|p| p.get("SearchEngines"))
|
||||
.and_then(|se| se.get("Default"))
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if current_default == "DuckDuckGo" {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Some(policies_obj) = policies.get_mut("policies") {
|
||||
if let Some(se) = policies_obj.get_mut("SearchEngines") {
|
||||
// Set DuckDuckGo as the explicit default
|
||||
if let Some(obj) = se.as_object_mut() {
|
||||
obj.insert(
|
||||
"Default".to_string(),
|
||||
serde_json::Value::String("DuckDuckGo".to_string()),
|
||||
);
|
||||
}
|
||||
|
||||
// Remove the fake "None" search engine entry from Add
|
||||
if let Some(add_arr) = se.get_mut("Add").and_then(|a| a.as_array_mut()) {
|
||||
add_arr.retain(|entry| entry.get("Name").and_then(|n| n.as_str()) != Some("None"));
|
||||
}
|
||||
|
||||
// Ensure DuckDuckGo is not in the Remove list
|
||||
if let Some(remove_arr) = se.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!("Set DuckDuckGo as default search engine in Camoufox policies.json");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::profile::BrowserProfile;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EPHEMERAL_DIRS: Mutex<HashMap<String, PathBuf>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
/// Get or create the RAM-backed base directory for ephemeral profiles.
|
||||
/// Linux: /dev/shm (always tmpfs). macOS: RAM disk via hdiutil. Windows: imdisk RAM disk.
|
||||
fn get_ephemeral_base_dir() -> Result<PathBuf, String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let base = PathBuf::from("/dev/shm/donut-ephemeral");
|
||||
std::fs::create_dir_all(&base)
|
||||
.map_err(|e| format!("Failed to create ephemeral base in /dev/shm: {e}"))?;
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(mount) = get_or_create_macos_ramdisk() {
|
||||
return Ok(mount);
|
||||
}
|
||||
log::warn!("Failed to create macOS RAM disk, ephemeral profiles may use disk");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Ok(mount) = get_or_create_windows_ramdisk() {
|
||||
return Ok(mount);
|
||||
}
|
||||
log::warn!("Failed to create Windows RAM disk, ephemeral profiles may use disk");
|
||||
}
|
||||
|
||||
// Fallback
|
||||
let base = std::env::temp_dir().join("donut-ephemeral");
|
||||
std::fs::create_dir_all(&base)
|
||||
.map_err(|e| format!("Failed to create ephemeral base dir: {e}"))?;
|
||||
Ok(base)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn get_or_create_macos_ramdisk() -> Result<PathBuf, String> {
|
||||
let mount_point = PathBuf::from("/Volumes/DonutEphemeral");
|
||||
|
||||
// Reuse existing RAM disk from a previous session
|
||||
if mount_point.exists() && mount_point.is_dir() {
|
||||
return Ok(mount_point);
|
||||
}
|
||||
|
||||
// 256 MB in 512-byte sectors
|
||||
let sectors = 256 * 2048;
|
||||
let output = std::process::Command::new("hdiutil")
|
||||
.args(["attach", "-nomount", &format!("ram://{sectors}")])
|
||||
.output()
|
||||
.map_err(|e| format!("hdiutil attach failed: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(format!(
|
||||
"hdiutil attach failed: {}",
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
let dev = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
|
||||
let fmt = std::process::Command::new("diskutil")
|
||||
.args(["erasevolume", "HFS+", "DonutEphemeral", &dev])
|
||||
.output()
|
||||
.map_err(|e| format!("diskutil erasevolume failed: {e}"))?;
|
||||
|
||||
if !fmt.status.success() {
|
||||
let _ = std::process::Command::new("hdiutil")
|
||||
.args(["detach", &dev])
|
||||
.output();
|
||||
return Err(format!(
|
||||
"diskutil erasevolume failed: {}",
|
||||
String::from_utf8_lossy(&fmt.stderr)
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("Created macOS RAM disk at {}", mount_point.display());
|
||||
Ok(mount_point)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn get_or_create_windows_ramdisk() -> Result<PathBuf, String> {
|
||||
// Check if a previous RAM disk with our directory already exists
|
||||
for letter in ['R', 'Q', 'P', 'O'] {
|
||||
let base = PathBuf::from(format!("{}:\\DonutEphemeral", letter));
|
||||
if base.exists() && base.is_dir() {
|
||||
return Ok(base);
|
||||
}
|
||||
}
|
||||
|
||||
// Try to create a RAM disk using imdisk (open-source RAM disk driver)
|
||||
for letter in ['R', 'Q', 'P', 'O'] {
|
||||
let drive = format!("{}:", letter);
|
||||
if PathBuf::from(format!("{}\\", drive)).exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let output = std::process::Command::new("imdisk")
|
||||
.args(["-a", "-s", "256M", "-m", &drive, "-p", "/fs:ntfs /q /y"])
|
||||
.output();
|
||||
|
||||
match output {
|
||||
Ok(out) if out.status.success() => {
|
||||
let base = PathBuf::from(format!("{}\\DonutEphemeral", drive));
|
||||
std::fs::create_dir_all(&base)
|
||||
.map_err(|e| format!("Failed to create dir on RAM disk: {e}"))?;
|
||||
log::info!("Created Windows RAM disk at {}", base.display());
|
||||
return Ok(base);
|
||||
}
|
||||
Ok(out) => {
|
||||
log::debug!(
|
||||
"imdisk failed for drive {}: {}",
|
||||
drive,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(format!("imdisk not available: {e}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not create Windows RAM disk".to_string())
|
||||
}
|
||||
|
||||
pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
|
||||
let base = get_ephemeral_base_dir()?;
|
||||
let dir_path = base.join(profile_id);
|
||||
|
||||
std::fs::create_dir_all(&dir_path).map_err(|e| format!("Failed to create ephemeral dir: {e}"))?;
|
||||
|
||||
EPHEMERAL_DIRS
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock ephemeral dirs: {e}"))?
|
||||
.insert(profile_id.to_string(), dir_path.clone());
|
||||
|
||||
log::info!(
|
||||
"Created ephemeral dir for profile {}: {}",
|
||||
profile_id,
|
||||
dir_path.display()
|
||||
);
|
||||
|
||||
Ok(dir_path)
|
||||
}
|
||||
|
||||
pub fn get_ephemeral_dir(profile_id: &str) -> Option<PathBuf> {
|
||||
EPHEMERAL_DIRS.lock().ok()?.get(profile_id).cloned()
|
||||
}
|
||||
|
||||
pub fn remove_ephemeral_dir(profile_id: &str) {
|
||||
let dir = EPHEMERAL_DIRS
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut map| map.remove(profile_id));
|
||||
|
||||
if let Some(dir_path) = dir {
|
||||
if dir_path.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&dir_path) {
|
||||
log::warn!("Failed to remove ephemeral dir {}: {e}", dir_path.display());
|
||||
} else {
|
||||
log::info!(
|
||||
"Removed ephemeral dir for profile {}: {}",
|
||||
profile_id,
|
||||
dir_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Recover ephemeral dir mappings on startup by scanning the RAM-backed base dir.
|
||||
/// Dir names are profile UUIDs, so we re-populate the in-memory HashMap.
|
||||
/// Also cleans up old disk-based dirs from previous versions.
|
||||
pub fn recover_ephemeral_dirs() {
|
||||
cleanup_legacy_dirs();
|
||||
|
||||
let base = match get_ephemeral_base_dir() {
|
||||
Ok(base) => base,
|
||||
Err(e) => {
|
||||
log::warn!("Cannot recover ephemeral dirs: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let entries = match std::fs::read_dir(&base) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
let mut dirs = match EPHEMERAL_DIRS.lock() {
|
||||
Ok(dirs) => dirs,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if entry.path().is_dir() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
if uuid::Uuid::parse_str(name).is_ok() {
|
||||
dirs.insert(name.to_string(), entry.path());
|
||||
log::info!("Recovered ephemeral dir for profile {}", name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove old-format ephemeral dirs from /tmp (pre-tmpfs migration).
|
||||
fn cleanup_legacy_dirs() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let entries = match std::fs::read_dir(&temp_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(_) => return,
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
if name.starts_with("donut-ephemeral-") && entry.path().is_dir() {
|
||||
if let Err(e) = std::fs::remove_dir_all(entry.path()) {
|
||||
log::warn!("Failed to clean up legacy ephemeral dir: {e}");
|
||||
} else {
|
||||
log::info!(
|
||||
"Cleaned up legacy ephemeral dir: {}",
|
||||
entry.path().display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_effective_profile_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf {
|
||||
if profile.ephemeral {
|
||||
if let Some(dir) = get_ephemeral_dir(&profile.id.to_string()) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
profile.get_profile_data_path(profiles_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_test_profile(id: uuid::Uuid, ephemeral: bool) -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
id,
|
||||
name: "test".to_string(),
|
||||
browser: "camoufox".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
vpn_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
wayfern_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_mode: crate::profile::types::SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ephemeral_dir_lifecycle() {
|
||||
let profile_id = uuid::Uuid::new_v4();
|
||||
let id_str = profile_id.to_string();
|
||||
|
||||
let dir = create_ephemeral_dir(&id_str).unwrap();
|
||||
assert!(dir.is_dir());
|
||||
assert_eq!(get_ephemeral_dir(&id_str), Some(dir.clone()));
|
||||
|
||||
let ephemeral_profile = make_test_profile(profile_id, true);
|
||||
let profiles_dir = std::env::temp_dir().join("test_profiles_ephemeral");
|
||||
assert_eq!(
|
||||
get_effective_profile_path(&ephemeral_profile, &profiles_dir),
|
||||
dir
|
||||
);
|
||||
|
||||
remove_ephemeral_dir(&id_str);
|
||||
assert!(!dir.exists());
|
||||
assert!(get_ephemeral_dir(&id_str).is_none());
|
||||
|
||||
let persistent_profile = make_test_profile(uuid::Uuid::new_v4(), false);
|
||||
let expected = persistent_profile.get_profile_data_path(&profiles_dir);
|
||||
assert_eq!(
|
||||
get_effective_profile_path(&persistent_profile, &profiles_dir),
|
||||
expected
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_recover_ephemeral_dirs() {
|
||||
let base = get_ephemeral_base_dir().unwrap();
|
||||
let test_id = uuid::Uuid::new_v4().to_string();
|
||||
let test_dir = base.join(&test_id);
|
||||
std::fs::create_dir_all(&test_dir).unwrap();
|
||||
|
||||
// Clear the HashMap so recovery has something to find
|
||||
EPHEMERAL_DIRS.lock().unwrap().remove(&test_id);
|
||||
assert!(get_ephemeral_dir(&test_id).is_none());
|
||||
|
||||
recover_ephemeral_dirs();
|
||||
assert_eq!(get_ephemeral_dir(&test_id), Some(test_dir.clone()));
|
||||
|
||||
// Clean up
|
||||
remove_ephemeral_dir(&test_id);
|
||||
}
|
||||
}
|
||||
@@ -593,7 +593,11 @@ impl Extractor {
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("ZIP extraction completed. Searching for executable...");
|
||||
log::info!("ZIP extraction completed.");
|
||||
|
||||
self.flatten_single_directory_archive(dest_dir)?;
|
||||
|
||||
log::info!("Searching for executable...");
|
||||
self
|
||||
.find_extracted_executable(dest_dir)
|
||||
.await
|
||||
@@ -617,7 +621,9 @@ impl Extractor {
|
||||
// Set executable permissions for extracted files
|
||||
self.set_executable_permissions_recursive(dest_dir).await?;
|
||||
|
||||
log::info!("tar.gz extraction completed. Searching for executable...");
|
||||
log::info!("tar.gz extraction completed.");
|
||||
self.flatten_single_directory_archive(dest_dir)?;
|
||||
log::info!("Searching for executable...");
|
||||
self.find_extracted_executable(dest_dir).await
|
||||
}
|
||||
|
||||
@@ -638,7 +644,9 @@ impl Extractor {
|
||||
// Set executable permissions for extracted files
|
||||
self.set_executable_permissions_recursive(dest_dir).await?;
|
||||
|
||||
log::info!("tar.bz2 extraction completed. Searching for executable...");
|
||||
log::info!("tar.bz2 extraction completed.");
|
||||
self.flatten_single_directory_archive(dest_dir)?;
|
||||
log::info!("Searching for executable...");
|
||||
self.find_extracted_executable(dest_dir).await
|
||||
}
|
||||
|
||||
@@ -673,7 +681,9 @@ impl Extractor {
|
||||
// Set executable permissions for extracted files
|
||||
self.set_executable_permissions_recursive(dest_dir).await?;
|
||||
|
||||
log::info!("tar.xz extraction completed. Searching for executable...");
|
||||
log::info!("tar.xz extraction completed.");
|
||||
self.flatten_single_directory_archive(dest_dir)?;
|
||||
log::info!("Searching for executable...");
|
||||
self.find_extracted_executable(dest_dir).await
|
||||
}
|
||||
|
||||
@@ -691,7 +701,9 @@ impl Extractor {
|
||||
extractor.to(dest_dir);
|
||||
}
|
||||
|
||||
log::info!("MSI extraction completed. Searching for executable...");
|
||||
log::info!("MSI extraction completed.");
|
||||
self.flatten_single_directory_archive(dest_dir)?;
|
||||
log::info!("Searching for executable...");
|
||||
self.find_extracted_executable(dest_dir).await
|
||||
}
|
||||
|
||||
@@ -778,6 +790,80 @@ impl Extractor {
|
||||
self.find_extracted_executable(dest_dir).await
|
||||
}
|
||||
|
||||
fn flatten_single_directory_archive(
|
||||
&self,
|
||||
dest_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let entries: Vec<_> = fs::read_dir(dest_dir)?.filter_map(|e| e.ok()).collect();
|
||||
|
||||
let archive_extensions = ["zip", "tar", "xz", "gz", "bz2", "dmg", "msi", "exe"];
|
||||
|
||||
let mut dirs = Vec::new();
|
||||
let mut has_non_archive_files = false;
|
||||
|
||||
for entry in &entries {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
dirs.push(path);
|
||||
} else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
|
||||
if !archive_extensions.contains(&ext.to_lowercase().as_str()) {
|
||||
has_non_archive_files = true;
|
||||
}
|
||||
} else {
|
||||
has_non_archive_files = true;
|
||||
}
|
||||
}
|
||||
|
||||
if dirs.len() == 1 && !has_non_archive_files {
|
||||
let single_dir = &dirs[0];
|
||||
|
||||
if single_dir.extension().is_some_and(|ext| ext == "app") {
|
||||
log::info!(
|
||||
"Skipping flatten: {} is a macOS app bundle",
|
||||
single_dir.display()
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Flattening single-directory archive: moving contents of {} to {}",
|
||||
single_dir.display(),
|
||||
dest_dir.display()
|
||||
);
|
||||
|
||||
let inner_entries: Vec<_> = fs::read_dir(single_dir)?.filter_map(|e| e.ok()).collect();
|
||||
|
||||
for entry in inner_entries {
|
||||
let source = entry.path();
|
||||
let file_name = match source.file_name() {
|
||||
Some(name) => name.to_owned(),
|
||||
None => continue,
|
||||
};
|
||||
let target = dest_dir.join(&file_name);
|
||||
fs::rename(&source, &target).map_err(|e| {
|
||||
format!(
|
||||
"Failed to move {} to {}: {}",
|
||||
source.display(),
|
||||
target.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
fs::remove_dir(single_dir).map_err(|e| {
|
||||
format!(
|
||||
"Failed to remove empty directory {}: {}",
|
||||
single_dir.display(),
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
log::info!("Successfully flattened archive directory structure");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn find_extracted_executable(
|
||||
&self,
|
||||
dest_dir: &Path,
|
||||
|
||||
@@ -76,21 +76,39 @@ impl GeoIPDownloader {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Check if GeoIP database is missing for Camoufox profiles
|
||||
|
||||
fn get_timestamp_path() -> PathBuf {
|
||||
crate::app_dirs::cache_dir().join("geoip_last_download")
|
||||
}
|
||||
|
||||
fn is_geoip_stale() -> bool {
|
||||
let timestamp_path = Self::get_timestamp_path();
|
||||
let Ok(content) = std::fs::read_to_string(×tamp_path) else {
|
||||
return true;
|
||||
};
|
||||
let Ok(timestamp) = content.trim().parse::<u64>() else {
|
||||
return true;
|
||||
};
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
const SEVEN_DAYS: u64 = 7 * 24 * 60 * 60;
|
||||
now.saturating_sub(timestamp) > SEVEN_DAYS
|
||||
}
|
||||
|
||||
/// Check if GeoIP database is missing or stale for Camoufox profiles
|
||||
pub fn check_missing_geoip_database(
|
||||
&self,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get all profiles
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
// Check if there are any Camoufox profiles
|
||||
let has_camoufox_profiles = profiles.iter().any(|profile| profile.browser == "camoufox");
|
||||
|
||||
if has_camoufox_profiles {
|
||||
// Check if GeoIP database is available
|
||||
return Ok(!Self::is_geoip_database_available());
|
||||
return Ok(!Self::is_geoip_database_available() || Self::is_geoip_stale());
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
@@ -201,6 +219,17 @@ impl GeoIPDownloader {
|
||||
|
||||
file.flush().await?;
|
||||
|
||||
// Write download timestamp
|
||||
let timestamp_path = Self::get_timestamp_path();
|
||||
if let Some(parent) = timestamp_path.parent() {
|
||||
let _ = fs::create_dir_all(parent).await;
|
||||
}
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let _ = fs::write(×tamp_path, now.to_string()).await;
|
||||
|
||||
// Emit completion
|
||||
let _ = events::emit(
|
||||
"geoip-download-progress",
|
||||
@@ -362,6 +391,31 @@ mod tests {
|
||||
assert!(path.to_string_lossy().ends_with("GeoLite2-City.mmdb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_geoip_stale() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_cache_dir(tmp.path().to_path_buf());
|
||||
|
||||
// No timestamp file → stale
|
||||
assert!(GeoIPDownloader::is_geoip_stale());
|
||||
|
||||
let timestamp_path = GeoIPDownloader::get_timestamp_path();
|
||||
std::fs::create_dir_all(timestamp_path.parent().unwrap()).unwrap();
|
||||
|
||||
// Recent timestamp → not stale
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
std::fs::write(×tamp_path, now.to_string()).unwrap();
|
||||
assert!(!GeoIPDownloader::is_geoip_stale());
|
||||
|
||||
// 8 days ago → stale
|
||||
let eight_days_ago = now - 8 * 24 * 60 * 60;
|
||||
std::fs::write(×tamp_path, eight_days_ago.to_string()).unwrap();
|
||||
assert!(GeoIPDownloader::is_geoip_stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_geoip_database_available() {
|
||||
// Test that the function works correctly regardless of file system state.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::events;
|
||||
@@ -33,48 +31,15 @@ struct GroupsData {
|
||||
groups: Vec<ProfileGroup>,
|
||||
}
|
||||
|
||||
pub struct GroupManager {
|
||||
base_dirs: BaseDirs,
|
||||
data_dir_override: Option<PathBuf>,
|
||||
}
|
||||
pub struct GroupManager;
|
||||
|
||||
impl GroupManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
|
||||
.ok()
|
||||
.map(PathBuf::from),
|
||||
}
|
||||
Self
|
||||
}
|
||||
|
||||
// Helper for tests to override data directory without global env var
|
||||
#[allow(dead_code)]
|
||||
pub fn with_data_dir_override(dir: &Path) -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: Some(dir.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_groups_file_path(&self) -> PathBuf {
|
||||
if let Some(dir) = &self.data_dir_override {
|
||||
let mut override_path = dir.clone();
|
||||
// Ensure the directory exists before returning the path
|
||||
let _ = fs::create_dir_all(&override_path);
|
||||
override_path.push("groups.json");
|
||||
return override_path;
|
||||
}
|
||||
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("data");
|
||||
path.push("groups.json");
|
||||
path
|
||||
fn get_groups_file_path(&self) -> std::path::PathBuf {
|
||||
crate::app_dirs::data_subdir().join("groups.json")
|
||||
}
|
||||
|
||||
fn load_groups_data(&self) -> Result<GroupsData, Box<dyn std::error::Error>> {
|
||||
@@ -119,10 +84,11 @@ impl GroupManager {
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
}
|
||||
|
||||
let sync_enabled = crate::sync::is_sync_configured();
|
||||
let group = ProfileGroup {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
sync_enabled: false,
|
||||
sync_enabled,
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
@@ -134,6 +100,15 @@ impl GroupManager {
|
||||
log::error!("Failed to emit groups-changed event: {e}");
|
||||
}
|
||||
|
||||
if group.sync_enabled {
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let id = group.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_group_sync(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
@@ -170,6 +145,15 @@ impl GroupManager {
|
||||
log::error!("Failed to emit groups-changed event: {e}");
|
||||
}
|
||||
|
||||
if updated_group.sync_enabled {
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let id = updated_group.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_group_sync(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(updated_group)
|
||||
}
|
||||
|
||||
@@ -207,6 +191,17 @@ impl GroupManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_group_internal(&self, id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
let initial_len = groups_data.groups.len();
|
||||
groups_data.groups.retain(|g| g.id != id);
|
||||
if groups_data.groups.len() == initial_len {
|
||||
return Err(format!("Group with id '{id}' not found").into());
|
||||
}
|
||||
self.save_groups_data(&groups_data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_group(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
|
||||
+308
-82
@@ -11,6 +11,7 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
mod api_client;
|
||||
mod api_server;
|
||||
mod app_auto_updater;
|
||||
pub mod app_dirs;
|
||||
mod auto_updater;
|
||||
mod browser;
|
||||
mod browser_runner;
|
||||
@@ -20,6 +21,7 @@ mod camoufox_manager;
|
||||
mod default_browser;
|
||||
mod downloaded_browsers_registry;
|
||||
mod downloader;
|
||||
mod ephemeral_dirs;
|
||||
mod extraction;
|
||||
mod geoip_downloader;
|
||||
mod group_manager;
|
||||
@@ -49,6 +51,8 @@ mod mcp_server;
|
||||
mod tag_manager;
|
||||
mod version_updater;
|
||||
pub mod vpn;
|
||||
pub mod vpn_worker_runner;
|
||||
pub mod vpn_worker_storage;
|
||||
|
||||
use browser_runner::{
|
||||
check_browser_exists, kill_browser_profile, launch_browser_profile, open_url_with_profile,
|
||||
@@ -57,7 +61,7 @@ use browser_runner::{
|
||||
use profile::manager::{
|
||||
check_browser_status, clone_profile, create_browser_profile_new, delete_profile,
|
||||
list_browser_profiles, rename_profile, update_camoufox_config, update_profile_note,
|
||||
update_profile_proxy, update_profile_tags, update_wayfern_config,
|
||||
update_profile_proxy, update_profile_tags, update_profile_vpn, update_wayfern_config,
|
||||
};
|
||||
|
||||
use browser_version_manager::{
|
||||
@@ -74,14 +78,17 @@ use downloaded_browsers_registry::{
|
||||
use downloader::{cancel_download, download_browser};
|
||||
|
||||
use settings_manager::{
|
||||
decline_launch_on_login, enable_launch_on_login, get_app_settings, get_sync_settings,
|
||||
get_system_language, get_table_sorting_settings, save_app_settings, save_sync_settings,
|
||||
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_window_resize_warning_dismissed, save_app_settings, save_sync_settings,
|
||||
save_table_sorting_settings, should_show_launch_on_login_prompt,
|
||||
};
|
||||
|
||||
use sync::{
|
||||
is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile, request_profile_sync,
|
||||
set_group_sync_enabled, set_profile_sync_enabled, set_proxy_sync_enabled,
|
||||
check_has_e2e_password, delete_e2e_password, enable_sync_for_all_entities,
|
||||
get_unsynced_entity_counts, is_group_in_use_by_synced_profile, is_proxy_in_use_by_synced_profile,
|
||||
is_vpn_in_use_by_synced_profile, request_profile_sync, set_e2e_password, set_group_sync_enabled,
|
||||
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
|
||||
};
|
||||
|
||||
use tag_manager::get_all_tags;
|
||||
@@ -280,9 +287,41 @@ 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());
|
||||
}
|
||||
cookie_manager::CookieManager::copy_cookies(&app_handle, request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_cookies_from_file(
|
||||
app_handle: tauri::AppHandle,
|
||||
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());
|
||||
}
|
||||
cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await
|
||||
}
|
||||
|
||||
#[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)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_wayfern_terms_accepted() -> bool {
|
||||
wayfern_terms::WayfernTermsManager::instance().is_terms_accepted()
|
||||
@@ -429,13 +468,23 @@ async fn import_vpn_config(
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
|
||||
match storage.import_config(&content, &filename, name.clone()) {
|
||||
Ok(config) => Ok(vpn::VpnImportResult {
|
||||
success: true,
|
||||
vpn_id: Some(config.id),
|
||||
vpn_type: Some(config.vpn_type),
|
||||
name: config.name,
|
||||
error: None,
|
||||
}),
|
||||
Ok(config) => {
|
||||
if config.sync_enabled {
|
||||
if let Some(scheduler) = sync::get_global_scheduler() {
|
||||
let id = config.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_vpn_sync(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(vpn::VpnImportResult {
|
||||
success: true,
|
||||
vpn_id: Some(config.id),
|
||||
vpn_type: Some(config.vpn_type),
|
||||
name: config.name,
|
||||
error: None,
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(vpn::VpnImportResult {
|
||||
success: false,
|
||||
vpn_id: None,
|
||||
@@ -469,69 +518,168 @@ async fn get_vpn_config(vpn_id: String) -> Result<vpn::VpnConfig, String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_vpn_config(vpn_id: String) -> Result<(), String> {
|
||||
// First disconnect if connected
|
||||
async fn delete_vpn_config(app_handle: tauri::AppHandle, vpn_id: String) -> Result<(), String> {
|
||||
// First disconnect if connected (stop VPN worker)
|
||||
let _ = vpn_worker_runner::stop_vpn_worker_by_vpn_id(&vpn_id).await;
|
||||
|
||||
// Check if sync was enabled before deleting
|
||||
let was_sync_enabled = {
|
||||
let storage = vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
storage
|
||||
.load_config(&vpn_id)
|
||||
.map(|c| c.sync_enabled)
|
||||
.unwrap_or(false)
|
||||
};
|
||||
|
||||
// Delete from storage
|
||||
{
|
||||
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
|
||||
if manager.is_tunnel_active(&vpn_id) {
|
||||
if let Some(tunnel) = manager.get_tunnel_mut(&vpn_id) {
|
||||
let _ = tunnel.disconnect().await;
|
||||
}
|
||||
manager.remove_tunnel(&vpn_id);
|
||||
}
|
||||
let storage = vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
|
||||
storage
|
||||
.delete_config(&vpn_id)
|
||||
.map_err(|e| format!("Failed to delete VPN config: {e}"))?;
|
||||
}
|
||||
|
||||
// Then delete from storage
|
||||
let storage = vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
// If sync was enabled, also delete from remote
|
||||
if was_sync_enabled {
|
||||
let vpn_id_clone = vpn_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match sync::SyncEngine::create_from_settings(&app_handle_clone).await {
|
||||
Ok(engine) => {
|
||||
if let Err(e) = engine.delete_vpn(&vpn_id_clone).await {
|
||||
log::warn!("Failed to delete VPN {} from sync: {}", vpn_id_clone, e);
|
||||
} else {
|
||||
log::info!("VPN {} deleted from sync storage", vpn_id_clone);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping remote VPN deletion: {}", e);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
storage
|
||||
.delete_config(&vpn_id)
|
||||
.map_err(|e| format!("Failed to delete VPN config: {e}"))
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
|
||||
// Load config from storage
|
||||
async fn create_vpn_config_manual(
|
||||
name: String,
|
||||
vpn_type: vpn::VpnType,
|
||||
config_data: String,
|
||||
) -> Result<vpn::VpnConfig, String> {
|
||||
let config = {
|
||||
let storage = vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
|
||||
storage
|
||||
.load_config(&vpn_id)
|
||||
.map_err(|e| format!("Failed to load VPN config: {e}"))?
|
||||
.create_config_manual(&name, vpn_type, &config_data)
|
||||
.map_err(|e| format!("Failed to create VPN config: {e}"))?
|
||||
};
|
||||
|
||||
// Create and connect the appropriate tunnel
|
||||
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
|
||||
|
||||
// Check if already connected
|
||||
if manager.is_tunnel_active(&vpn_id) {
|
||||
return Ok(());
|
||||
if config.sync_enabled {
|
||||
if let Some(scheduler) = sync::get_global_scheduler() {
|
||||
let id = config.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_vpn_sync(id).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let mut tunnel: Box<dyn vpn::VpnTunnel> = match config.vpn_type {
|
||||
vpn::VpnType::WireGuard => {
|
||||
let wg_config = vpn::parse_wireguard_config(&config.config_data)
|
||||
.map_err(|e| format!("Invalid WireGuard config: {e}"))?;
|
||||
Box::new(vpn::WireGuardTunnel::new(vpn_id.clone(), wg_config))
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_vpn_config(vpn_id: String, name: String) -> Result<vpn::VpnConfig, String> {
|
||||
let config = {
|
||||
let storage = vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
|
||||
storage
|
||||
.update_config_name(&vpn_id, &name)
|
||||
.map_err(|e| format!("Failed to update VPN config: {e}"))?
|
||||
};
|
||||
|
||||
if config.sync_enabled {
|
||||
if let Some(scheduler) = sync::get_global_scheduler() {
|
||||
let id = config.id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_vpn_sync(id).await;
|
||||
});
|
||||
}
|
||||
vpn::VpnType::OpenVPN => {
|
||||
let ovpn_config = vpn::parse_openvpn_config(&config.config_data)
|
||||
.map_err(|e| format!("Invalid OpenVPN config: {e}"))?;
|
||||
Box::new(vpn::OpenVpnTunnel::new(vpn_id.clone(), ovpn_config))
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_vpn_validity(
|
||||
vpn_id: String,
|
||||
) -> Result<crate::proxy_manager::ProxyCheckResult, String> {
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
|
||||
// Start a temporary VPN worker to send real traffic
|
||||
let vpn_worker = vpn_worker_runner::start_vpn_worker(&vpn_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start VPN worker: {e}"))?;
|
||||
|
||||
let socks_url = format!("socks5://127.0.0.1:{}", vpn_worker.local_port.unwrap_or(0));
|
||||
|
||||
// Fetch public IP through the VPN SOCKS5 proxy
|
||||
let result = match ip_utils::fetch_public_ip(Some(&socks_url)).await {
|
||||
Ok(ip) => {
|
||||
let (city, country, country_code) =
|
||||
crate::proxy_manager::ProxyManager::get_ip_geolocation(&ip)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
crate::proxy_manager::ProxyCheckResult {
|
||||
ip,
|
||||
city,
|
||||
country,
|
||||
country_code,
|
||||
timestamp: now,
|
||||
is_valid: true,
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("VPN check failed to fetch public IP: {e}");
|
||||
crate::proxy_manager::ProxyCheckResult {
|
||||
ip: String::new(),
|
||||
city: None,
|
||||
country: None,
|
||||
country_code: None,
|
||||
timestamp: now,
|
||||
is_valid: false,
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
tunnel
|
||||
.connect()
|
||||
// Stop the temporary VPN worker
|
||||
let _ = vpn_worker_runner::stop_vpn_worker(&vpn_worker.id).await;
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn connect_vpn(vpn_id: String) -> Result<(), String> {
|
||||
// Start VPN worker process (detached, survives GUI shutdown)
|
||||
vpn_worker_runner::start_vpn_worker(&vpn_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to connect VPN: {e}"))?;
|
||||
|
||||
manager.register_tunnel(vpn_id.clone(), tunnel);
|
||||
|
||||
// Update last_used timestamp
|
||||
{
|
||||
let storage = vpn::VPN_STORAGE
|
||||
@@ -545,27 +693,27 @@ async fn connect_vpn(vpn_id: String) -> Result<(), String> {
|
||||
|
||||
#[tauri::command]
|
||||
async fn disconnect_vpn(vpn_id: String) -> Result<(), String> {
|
||||
let mut manager = vpn::TUNNEL_MANAGER.lock().await;
|
||||
|
||||
if let Some(tunnel) = manager.get_tunnel_mut(&vpn_id) {
|
||||
tunnel
|
||||
.disconnect()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to disconnect VPN: {e}"))?;
|
||||
}
|
||||
|
||||
manager.remove_tunnel(&vpn_id);
|
||||
vpn_worker_runner::stop_vpn_worker_by_vpn_id(&vpn_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to disconnect VPN: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_vpn_status(vpn_id: String) -> Result<vpn::VpnStatus, String> {
|
||||
let manager = vpn::TUNNEL_MANAGER.lock().await;
|
||||
use crate::proxy_storage::is_process_running;
|
||||
|
||||
if let Some(tunnel) = manager.get_tunnel(&vpn_id) {
|
||||
Ok(tunnel.get_status())
|
||||
if let Some(worker) = vpn_worker_storage::find_vpn_worker_by_vpn_id(&vpn_id) {
|
||||
let connected = worker.pid.map(is_process_running).unwrap_or(false);
|
||||
Ok(vpn::VpnStatus {
|
||||
connected,
|
||||
vpn_id,
|
||||
connected_at: None,
|
||||
bytes_sent: None,
|
||||
bytes_received: None,
|
||||
last_handshake: None,
|
||||
})
|
||||
} else {
|
||||
// Not connected
|
||||
Ok(vpn::VpnStatus {
|
||||
connected: false,
|
||||
vpn_id,
|
||||
@@ -579,8 +727,23 @@ async fn get_vpn_status(vpn_id: String) -> Result<vpn::VpnStatus, String> {
|
||||
|
||||
#[tauri::command]
|
||||
async fn list_active_vpn_connections() -> Result<Vec<vpn::VpnStatus>, String> {
|
||||
let manager = vpn::TUNNEL_MANAGER.lock().await;
|
||||
Ok(manager.get_all_statuses())
|
||||
use crate::proxy_storage::is_process_running;
|
||||
|
||||
let workers = vpn_worker_storage::list_vpn_worker_configs();
|
||||
Ok(
|
||||
workers
|
||||
.into_iter()
|
||||
.filter(|w| w.pid.map(is_process_running).unwrap_or(false))
|
||||
.map(|w| vpn::VpnStatus {
|
||||
connected: true,
|
||||
vpn_id: w.vpn_id,
|
||||
connected_at: None,
|
||||
bytes_sent: None,
|
||||
bytes_received: None,
|
||||
last_handshake: None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -594,12 +757,7 @@ pub fn run() {
|
||||
pending.push(url.clone());
|
||||
}
|
||||
|
||||
// Configure logging plugin with separate logs for dev and production
|
||||
let log_file_name = if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
};
|
||||
let log_file_name = app_dirs::app_name();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(
|
||||
@@ -630,9 +788,16 @@ pub fn run() {
|
||||
})
|
||||
.build(),
|
||||
)
|
||||
.plugin(tauri_plugin_single_instance::init(|_, args, _cwd| {
|
||||
log::info!("Single instance triggered with args: {args:?}");
|
||||
}))
|
||||
.plugin(tauri_plugin_single_instance::init(
|
||||
|app_handle, args, _cwd| {
|
||||
log::info!("Single instance triggered with args: {args:?}");
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
},
|
||||
))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
@@ -640,11 +805,42 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.setup(|app| {
|
||||
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
||||
ephemeral_dirs::recover_ephemeral_dirs();
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Create the main window programmatically
|
||||
#[allow(unused_variables)]
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
@@ -1075,6 +1271,12 @@ pub fn run() {
|
||||
{
|
||||
log::warn!("Failed to check for missing profiles: {}", e);
|
||||
}
|
||||
if let Err(e) = engine
|
||||
.check_for_missing_synced_entities(&app_handle_sync)
|
||||
.await
|
||||
{
|
||||
log::warn!("Failed to check for missing entities: {}", e);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping missing profile check: {}", e);
|
||||
@@ -1124,6 +1326,7 @@ pub fn run() {
|
||||
get_all_tags,
|
||||
get_browser_release_types,
|
||||
update_profile_proxy,
|
||||
update_profile_vpn,
|
||||
update_profile_tags,
|
||||
update_profile_note,
|
||||
check_browser_status,
|
||||
@@ -1137,6 +1340,8 @@ pub fn run() {
|
||||
get_table_sorting_settings,
|
||||
save_table_sorting_settings,
|
||||
get_system_language,
|
||||
dismiss_window_resize_warning,
|
||||
get_window_resize_warning_dismissed,
|
||||
clear_all_version_cache_and_refetch,
|
||||
is_default_browser,
|
||||
open_url_with_profile,
|
||||
@@ -1185,14 +1390,23 @@ pub fn run() {
|
||||
get_traffic_stats_for_period,
|
||||
get_sync_settings,
|
||||
save_sync_settings,
|
||||
set_profile_sync_enabled,
|
||||
set_profile_sync_mode,
|
||||
request_profile_sync,
|
||||
set_proxy_sync_enabled,
|
||||
set_group_sync_enabled,
|
||||
is_proxy_in_use_by_synced_profile,
|
||||
is_group_in_use_by_synced_profile,
|
||||
set_vpn_sync_enabled,
|
||||
is_vpn_in_use_by_synced_profile,
|
||||
get_unsynced_entity_counts,
|
||||
enable_sync_for_all_entities,
|
||||
set_e2e_password,
|
||||
check_has_e2e_password,
|
||||
delete_e2e_password,
|
||||
read_profile_cookies,
|
||||
copy_profile_cookies,
|
||||
import_cookies_from_file,
|
||||
export_profile_cookies,
|
||||
check_wayfern_terms_accepted,
|
||||
check_wayfern_downloaded,
|
||||
accept_wayfern_terms,
|
||||
@@ -1208,6 +1422,9 @@ pub fn run() {
|
||||
list_vpn_configs,
|
||||
get_vpn_config,
|
||||
delete_vpn_config,
|
||||
create_vpn_config_manual,
|
||||
update_vpn_config,
|
||||
check_vpn_validity,
|
||||
connect_vpn,
|
||||
disconnect_vpn,
|
||||
get_vpn_status,
|
||||
@@ -1225,8 +1442,18 @@ pub fn run() {
|
||||
cloud_auth::create_cloud_location_proxy,
|
||||
cloud_auth::restart_sync_service
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|_app_handle, _event| {
|
||||
#[cfg(target_os = "macos")]
|
||||
if let tauri::RunEvent::Reopen { .. } = _event {
|
||||
if let Some(window) = _app_handle.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1247,13 +1474,12 @@ mod tests {
|
||||
// Commands that are intentionally not used in the frontend
|
||||
// but are used via MCP server or other programmatic APIs
|
||||
let mcp_only_commands = [
|
||||
"list_vpn_configs",
|
||||
"get_vpn_config",
|
||||
"delete_vpn_config",
|
||||
"connect_vpn",
|
||||
"disconnect_vpn",
|
||||
"get_vpn_status",
|
||||
"get_vpn_config",
|
||||
"list_active_vpn_connections",
|
||||
"export_profile_cookies",
|
||||
];
|
||||
|
||||
// Extract command names from the generate_handler! macro in this file
|
||||
|
||||
+273
-86
@@ -18,6 +18,7 @@ use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
use crate::browser::ProxySettings;
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::group_manager::GROUP_MANAGER;
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
@@ -679,6 +680,51 @@ impl McpServer {
|
||||
"required": ["vpn_id"]
|
||||
}),
|
||||
},
|
||||
// Fingerprint management tools
|
||||
McpTool {
|
||||
name: "get_profile_fingerprint".to_string(),
|
||||
description: "Get the fingerprint configuration for a Wayfern or Camoufox profile"
|
||||
.to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the profile"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "update_profile_fingerprint".to_string(),
|
||||
description:
|
||||
"Update the fingerprint configuration for a Wayfern or Camoufox profile. Requires an active Pro subscription."
|
||||
.to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the profile to update"
|
||||
},
|
||||
"fingerprint": {
|
||||
"type": "string",
|
||||
"description": "JSON string of the fingerprint configuration, or null to clear"
|
||||
},
|
||||
"os": {
|
||||
"type": "string",
|
||||
"enum": ["windows", "macos", "linux"],
|
||||
"description": "Operating system for fingerprint generation"
|
||||
},
|
||||
"randomize_fingerprint_on_launch": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to generate a new fingerprint on every launch"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -777,6 +823,9 @@ impl McpServer {
|
||||
"connect_vpn" => self.handle_connect_vpn(&arguments).await,
|
||||
"disconnect_vpn" => self.handle_disconnect_vpn(&arguments).await,
|
||||
"get_vpn_status" => self.handle_get_vpn_status(&arguments).await,
|
||||
// Fingerprint management
|
||||
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(&arguments).await,
|
||||
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(&arguments).await,
|
||||
_ => Err(McpError {
|
||||
code: -32602,
|
||||
message: format!("Unknown tool: {tool_name}"),
|
||||
@@ -1702,16 +1751,8 @@ impl McpServer {
|
||||
message: "Missing vpn_id".to_string(),
|
||||
})?;
|
||||
|
||||
// First disconnect if connected
|
||||
{
|
||||
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
|
||||
if manager.is_tunnel_active(vpn_id) {
|
||||
if let Some(tunnel) = manager.get_tunnel_mut(vpn_id) {
|
||||
let _ = tunnel.disconnect().await;
|
||||
}
|
||||
manager.remove_tunnel(vpn_id);
|
||||
}
|
||||
}
|
||||
// First disconnect if connected (stop VPN worker)
|
||||
let _ = crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(vpn_id).await;
|
||||
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
|
||||
code: -32000,
|
||||
@@ -1743,63 +1784,14 @@ impl McpServer {
|
||||
message: "Missing vpn_id".to_string(),
|
||||
})?;
|
||||
|
||||
// Load config from storage
|
||||
let config = {
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
|
||||
// Start VPN worker process
|
||||
crate::vpn_worker_runner::start_vpn_worker(vpn_id)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to lock VPN storage: {e}"),
|
||||
message: format!("Failed to connect VPN: {e}"),
|
||||
})?;
|
||||
|
||||
storage.load_config(vpn_id).map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to load VPN config: {e}"),
|
||||
})?
|
||||
};
|
||||
|
||||
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
|
||||
|
||||
// Check if already connected
|
||||
if manager.is_tunnel_active(vpn_id) {
|
||||
return Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("VPN '{}' is already connected", config.name)
|
||||
}]
|
||||
}));
|
||||
}
|
||||
|
||||
let mut tunnel: Box<dyn crate::vpn::VpnTunnel> = match config.vpn_type {
|
||||
crate::vpn::VpnType::WireGuard => {
|
||||
let wg_config =
|
||||
crate::vpn::parse_wireguard_config(&config.config_data).map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Invalid WireGuard config: {e}"),
|
||||
})?;
|
||||
Box::new(crate::vpn::WireGuardTunnel::new(
|
||||
vpn_id.to_string(),
|
||||
wg_config,
|
||||
))
|
||||
}
|
||||
crate::vpn::VpnType::OpenVPN => {
|
||||
let ovpn_config =
|
||||
crate::vpn::parse_openvpn_config(&config.config_data).map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Invalid OpenVPN config: {e}"),
|
||||
})?;
|
||||
Box::new(crate::vpn::OpenVpnTunnel::new(
|
||||
vpn_id.to_string(),
|
||||
ovpn_config,
|
||||
))
|
||||
}
|
||||
};
|
||||
|
||||
tunnel.connect().await.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to connect VPN: {e}"),
|
||||
})?;
|
||||
|
||||
manager.register_tunnel(vpn_id.to_string(), tunnel);
|
||||
|
||||
// Update last_used timestamp
|
||||
{
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().map_err(|e| McpError {
|
||||
@@ -1812,7 +1804,7 @@ impl McpServer {
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("VPN '{}' connected successfully", config.name)
|
||||
"text": format!("VPN '{}' connected successfully", vpn_id)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
@@ -1829,16 +1821,12 @@ impl McpServer {
|
||||
message: "Missing vpn_id".to_string(),
|
||||
})?;
|
||||
|
||||
let mut manager = crate::vpn::TUNNEL_MANAGER.lock().await;
|
||||
|
||||
if let Some(tunnel) = manager.get_tunnel_mut(vpn_id) {
|
||||
tunnel.disconnect().await.map_err(|e| McpError {
|
||||
crate::vpn_worker_runner::stop_vpn_worker_by_vpn_id(vpn_id)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to disconnect VPN: {e}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
manager.remove_tunnel(vpn_id);
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
@@ -1860,19 +1848,23 @@ impl McpServer {
|
||||
message: "Missing vpn_id".to_string(),
|
||||
})?;
|
||||
|
||||
let manager = crate::vpn::TUNNEL_MANAGER.lock().await;
|
||||
let connected =
|
||||
if let Some(worker) = crate::vpn_worker_storage::find_vpn_worker_by_vpn_id(vpn_id) {
|
||||
worker
|
||||
.pid
|
||||
.map(crate::proxy_storage::is_process_running)
|
||||
.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
let status = if let Some(tunnel) = manager.get_tunnel(vpn_id) {
|
||||
tunnel.get_status()
|
||||
} else {
|
||||
crate::vpn::VpnStatus {
|
||||
connected: false,
|
||||
vpn_id: vpn_id.to_string(),
|
||||
connected_at: None,
|
||||
bytes_sent: None,
|
||||
bytes_received: None,
|
||||
last_handshake: None,
|
||||
}
|
||||
let status = crate::vpn::VpnStatus {
|
||||
connected,
|
||||
vpn_id: vpn_id.to_string(),
|
||||
connected_at: None,
|
||||
bytes_sent: None,
|
||||
bytes_received: None,
|
||||
last_handshake: None,
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
@@ -1882,6 +1874,198 @@ impl McpServer {
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
// Fingerprint management handlers
|
||||
async fn handle_get_profile_fingerprint(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list profiles: {e}"),
|
||||
})?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: format!("Profile not found: {profile_id}"),
|
||||
})?;
|
||||
|
||||
let fingerprint_info = match profile.browser.as_str() {
|
||||
"camoufox" => {
|
||||
let config = profile
|
||||
.camoufox_config
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"browser": "camoufox",
|
||||
"fingerprint": config.fingerprint,
|
||||
"os": config.os,
|
||||
"randomize_fingerprint_on_launch": config.randomize_fingerprint_on_launch,
|
||||
"screen_max_width": config.screen_max_width,
|
||||
"screen_max_height": config.screen_max_height,
|
||||
"screen_min_width": config.screen_min_width,
|
||||
"screen_min_height": config.screen_min_height,
|
||||
})
|
||||
}
|
||||
"wayfern" => {
|
||||
let config = profile.wayfern_config.as_ref().cloned().unwrap_or_default();
|
||||
serde_json::json!({
|
||||
"browser": "wayfern",
|
||||
"fingerprint": config.fingerprint,
|
||||
"os": config.os,
|
||||
"randomize_fingerprint_on_launch": config.randomize_fingerprint_on_launch,
|
||||
"screen_max_width": config.screen_max_width,
|
||||
"screen_max_height": config.screen_max_height,
|
||||
"screen_min_width": config.screen_min_width,
|
||||
"screen_min_height": config.screen_min_height,
|
||||
})
|
||||
}
|
||||
_ => {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "MCP only supports Wayfern and Camoufox profiles".to_string(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&fingerprint_info).unwrap_or_default()
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_update_profile_fingerprint(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Fingerprint editing requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let profile_id = arguments
|
||||
.get("profile_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing profile_id".to_string(),
|
||||
})?;
|
||||
|
||||
let fingerprint = arguments.get("fingerprint").and_then(|v| v.as_str());
|
||||
let os = arguments.get("os").and_then(|v| v.as_str());
|
||||
let randomize = arguments
|
||||
.get("randomize_fingerprint_on_launch")
|
||||
.and_then(|v| v.as_bool());
|
||||
|
||||
if let Some(os_val) = os {
|
||||
if !CLOUD_AUTH.is_fingerprint_os_allowed(Some(os_val)).await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: format!(
|
||||
"OS spoofing to '{}' requires an active Pro subscription",
|
||||
os_val
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list profiles: {e}"),
|
||||
})?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: format!("Profile not found: {profile_id}"),
|
||||
})?;
|
||||
|
||||
let inner = self.inner.lock().await;
|
||||
let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError {
|
||||
code: -32000,
|
||||
message: "MCP server not properly initialized".to_string(),
|
||||
})?;
|
||||
|
||||
match profile.browser.as_str() {
|
||||
"camoufox" => {
|
||||
let mut config = profile
|
||||
.camoufox_config
|
||||
.as_ref()
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
if let Some(fp) = fingerprint {
|
||||
config.fingerprint = Some(fp.to_string());
|
||||
}
|
||||
if let Some(os_val) = os {
|
||||
config.os = Some(os_val.to_string());
|
||||
}
|
||||
if let Some(r) = randomize {
|
||||
config.randomize_fingerprint_on_launch = Some(r);
|
||||
}
|
||||
ProfileManager::instance()
|
||||
.update_camoufox_config(app_handle.clone(), profile_id, config)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update camoufox config: {e}"),
|
||||
})?;
|
||||
}
|
||||
"wayfern" => {
|
||||
let mut config = profile.wayfern_config.as_ref().cloned().unwrap_or_default();
|
||||
if let Some(fp) = fingerprint {
|
||||
config.fingerprint = Some(fp.to_string());
|
||||
}
|
||||
if let Some(os_val) = os {
|
||||
config.os = Some(os_val.to_string());
|
||||
}
|
||||
if let Some(r) = randomize {
|
||||
config.randomize_fingerprint_on_launch = Some(r);
|
||||
}
|
||||
ProfileManager::instance()
|
||||
.update_wayfern_config(app_handle.clone(), profile_id, config)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update wayfern config: {e}"),
|
||||
})?;
|
||||
}
|
||||
_ => {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "MCP only supports Wayfern and Camoufox profiles".to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!("Fingerprint configuration updated for profile '{}'", profile.name)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -1897,8 +2081,8 @@ mod tests {
|
||||
let server = McpServer::new();
|
||||
let tools = server.get_tools();
|
||||
|
||||
// Should have at least 24 tools (18 + 6 VPN tools)
|
||||
assert!(tools.len() >= 24);
|
||||
// Should have at least 26 tools (24 + 2 fingerprint tools)
|
||||
assert!(tools.len() >= 26);
|
||||
|
||||
// Check tool names
|
||||
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
|
||||
@@ -1931,6 +2115,9 @@ mod tests {
|
||||
assert!(tool_names.contains(&"connect_vpn"));
|
||||
assert!(tool_names.contains(&"disconnect_vpn"));
|
||||
assert!(tool_names.contains(&"get_vpn_status"));
|
||||
// Fingerprint tools
|
||||
assert!(tool_names.contains(&"get_profile_fingerprint"));
|
||||
assert!(tool_names.contains(&"update_profile_fingerprint"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -651,8 +651,11 @@ pub mod windows {
|
||||
use std::process::Command;
|
||||
|
||||
// Try taskkill command as fallback
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let output = Command::new("taskkill")
|
||||
.args(["/F", "/PID", &pid.to_string()])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
|
||||
match output {
|
||||
|
||||
@@ -3,16 +3,14 @@ use crate::browser::{create_browser, BrowserType, ProxySettings};
|
||||
use crate::camoufox_manager::CamoufoxConfig;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::events;
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::wayfern_manager::WayfernConfig;
|
||||
use directories::BaseDirs;
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
use sysinfo::{Pid, System};
|
||||
|
||||
pub struct ProfileManager {
|
||||
base_dirs: BaseDirs,
|
||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
||||
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
||||
}
|
||||
@@ -20,7 +18,6 @@ pub struct ProfileManager {
|
||||
impl ProfileManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
|
||||
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
|
||||
}
|
||||
@@ -31,25 +28,11 @@ impl ProfileManager {
|
||||
}
|
||||
|
||||
pub fn get_profiles_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("profiles");
|
||||
path
|
||||
crate::app_dirs::profiles_dir()
|
||||
}
|
||||
|
||||
pub fn get_binaries_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -61,10 +44,15 @@ impl ProfileManager {
|
||||
version: &str,
|
||||
release_type: &str,
|
||||
proxy_id: Option<String>,
|
||||
vpn_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: bool,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
if proxy_id.is_some() && vpn_id.is_some() {
|
||||
return Err("Cannot set both proxy_id and vpn_id".into());
|
||||
}
|
||||
log::info!("Attempting to create profile: {name}");
|
||||
|
||||
// Check if a profile with this name already exists (case insensitive)
|
||||
@@ -85,7 +73,9 @@ impl ProfileManager {
|
||||
|
||||
// Create profile directory with UUID and profile subdirectory
|
||||
create_dir_all(&profile_uuid_dir)?;
|
||||
create_dir_all(&profile_data_dir)?;
|
||||
if !ephemeral {
|
||||
create_dir_all(&profile_data_dir)?;
|
||||
}
|
||||
|
||||
// For Camoufox profiles, generate fingerprint during creation
|
||||
let final_camoufox_config = if browser == "camoufox" {
|
||||
@@ -163,6 +153,7 @@ impl ProfileManager {
|
||||
browser: browser.to_string(),
|
||||
version: version.to_string(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: release_type.to_string(),
|
||||
@@ -171,8 +162,11 @@ impl ProfileManager {
|
||||
group_id: group_id.clone(),
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_enabled: false,
|
||||
sync_mode: SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -276,6 +270,7 @@ impl ProfileManager {
|
||||
browser: browser.to_string(),
|
||||
version: version.to_string(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: release_type.to_string(),
|
||||
@@ -284,8 +279,11 @@ impl ProfileManager {
|
||||
group_id: group_id.clone(),
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_enabled: false,
|
||||
sync_mode: SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -321,6 +319,7 @@ impl ProfileManager {
|
||||
browser: browser.to_string(),
|
||||
version: version.to_string(),
|
||||
proxy_id: proxy_id.clone(),
|
||||
vpn_id: vpn_id.clone(),
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: release_type.to_string(),
|
||||
@@ -329,8 +328,11 @@ impl ProfileManager {
|
||||
group_id: group_id.clone(),
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_enabled: false,
|
||||
sync_mode: SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: Some(get_host_os()),
|
||||
ephemeral,
|
||||
};
|
||||
|
||||
// Save profile info
|
||||
@@ -344,16 +346,19 @@ impl ProfileManager {
|
||||
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
|
||||
|
||||
// Create user.js with common Firefox preferences and apply proxy settings if provided
|
||||
if let Some(proxy_id_ref) = &proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
|
||||
// Skip for ephemeral profiles since the data dir is created at launch time
|
||||
if !ephemeral {
|
||||
if let Some(proxy_id_ref) = &proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
|
||||
} else {
|
||||
// Proxy ID provided but not found, disable proxy
|
||||
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
|
||||
}
|
||||
} else {
|
||||
// Proxy ID provided but not found, disable proxy
|
||||
// Create user.js with common Firefox preferences but no proxy
|
||||
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
|
||||
}
|
||||
} else {
|
||||
// Create user.js with common Firefox preferences but no proxy
|
||||
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
|
||||
}
|
||||
|
||||
// Emit profile creation event
|
||||
@@ -466,15 +471,15 @@ impl ProfileManager {
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
// Check if browser is running
|
||||
if profile.process_id.is_some() {
|
||||
// Check if browser is running (cross-OS profiles can't be running locally)
|
||||
if profile.process_id.is_some() && !profile.is_cross_os() {
|
||||
return Err(
|
||||
"Cannot delete profile while browser is running. Please stop the browser first.".into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Remember sync_enabled before deleting local files
|
||||
let was_sync_enabled = profile.sync_enabled;
|
||||
// Remember sync mode before deleting local files
|
||||
let was_sync_enabled = profile.is_sync_enabled();
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
@@ -620,7 +625,7 @@ impl ProfileManager {
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
// Auto-enable sync for new group if profile has sync enabled
|
||||
if profile.sync_enabled {
|
||||
if profile.is_sync_enabled() {
|
||||
if let Some(ref new_group_id) = group_id {
|
||||
let group_id_clone = new_group_id.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
@@ -733,8 +738,8 @@ impl ProfileManager {
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
// Check if browser is running
|
||||
if profile.process_id.is_some() {
|
||||
// Check if browser is running (cross-OS profiles can't be running locally)
|
||||
if profile.process_id.is_some() && !profile.is_cross_os() {
|
||||
return Err(
|
||||
format!(
|
||||
"Cannot delete profile '{}' while browser is running. Please stop the browser first.",
|
||||
@@ -745,7 +750,7 @@ impl ProfileManager {
|
||||
}
|
||||
|
||||
// Track sync-enabled profiles for remote deletion
|
||||
if profile.sync_enabled {
|
||||
if profile.is_sync_enabled() {
|
||||
sync_enabled_ids.push(profile_id.clone());
|
||||
}
|
||||
|
||||
@@ -837,6 +842,7 @@ impl ProfileManager {
|
||||
browser: source.browser,
|
||||
version: source.version,
|
||||
proxy_id: source.proxy_id,
|
||||
vpn_id: source.vpn_id,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: source.release_type,
|
||||
@@ -845,8 +851,11 @@ impl ProfileManager {
|
||||
group_id: source.group_id,
|
||||
tags: source.tags,
|
||||
note: source.note,
|
||||
sync_enabled: false,
|
||||
sync_mode: SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: Some(get_host_os()),
|
||||
ephemeral: false,
|
||||
};
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
@@ -1007,8 +1016,9 @@ impl ProfileManager {
|
||||
// Remember old proxy_id for cleanup (not used yet, but may be needed for cleanup)
|
||||
let _old_proxy_id = profile.proxy_id.clone();
|
||||
|
||||
// Update proxy settings
|
||||
// Update proxy settings and clear VPN (mutual exclusion)
|
||||
profile.proxy_id = proxy_id.clone();
|
||||
profile.vpn_id = None;
|
||||
|
||||
// Save the updated profile
|
||||
self
|
||||
@@ -1018,7 +1028,7 @@ impl ProfileManager {
|
||||
})?;
|
||||
|
||||
// Auto-enable sync for new proxy if profile has sync enabled
|
||||
if profile.sync_enabled {
|
||||
if profile.is_sync_enabled() {
|
||||
if let Some(ref new_proxy_id) = proxy_id {
|
||||
let _ = crate::sync::enable_proxy_sync_if_needed(new_proxy_id, &app_handle).await;
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
@@ -1071,6 +1081,52 @@ impl ProfileManager {
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub async fn update_profile_vpn(
|
||||
&self,
|
||||
_app_handle: tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
vpn_id: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|
||||
|_| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Invalid profile ID: {profile_id}").into()
|
||||
},
|
||||
)?;
|
||||
let profiles =
|
||||
self
|
||||
.list_profiles()
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to list profiles: {e}").into()
|
||||
})?;
|
||||
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Profile with ID '{profile_id}' not found").into()
|
||||
})?;
|
||||
|
||||
// Update VPN and clear proxy (mutual exclusion)
|
||||
profile.vpn_id = vpn_id;
|
||||
profile.proxy_id = None;
|
||||
|
||||
self
|
||||
.save_profile(&profile)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to save profile: {e}").into()
|
||||
})?;
|
||||
|
||||
if let Err(e) = events::emit("profile-updated", &profile) {
|
||||
log::warn!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub async fn check_browser_status(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1248,7 +1304,8 @@ impl ProfileManager {
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let launcher = self.camoufox_manager;
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
// Check if there's a running Camoufox instance for this profile
|
||||
@@ -1292,6 +1349,10 @@ impl ProfileManager {
|
||||
}
|
||||
Ok(None) => {
|
||||
// No running instance found, clear process ID if set and stop proxy
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
@@ -1322,6 +1383,10 @@ impl ProfileManager {
|
||||
Err(e) => {
|
||||
// Error checking status, assume not running and clear process ID
|
||||
log::warn!("Warning: Failed to check Camoufox status: {e}");
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
@@ -1363,7 +1428,8 @@ impl ProfileManager {
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let manager = self.wayfern_manager;
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
// Check if there's a running Wayfern instance for this profile
|
||||
@@ -1407,6 +1473,10 @@ impl ProfileManager {
|
||||
}
|
||||
None => {
|
||||
// No running instance found, clear process ID if set
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
@@ -1623,9 +1693,11 @@ impl ProfileManager {
|
||||
let pac_content = "function FindProxyForURL(url, host) { return 'DIRECT'; }";
|
||||
let pac_path = uuid_dir.join("proxy.pac");
|
||||
fs::write(&pac_path, pac_content)?;
|
||||
let pac_url =
|
||||
url::Url::from_file_path(&pac_path).map_err(|_| "Failed to convert PAC path to file URL")?;
|
||||
preferences.push(format!(
|
||||
"user_pref(\"network.proxy.autoconfig_url\", \"file://{}\");",
|
||||
pac_path.to_string_lossy()
|
||||
"user_pref(\"network.proxy.autoconfig_url\", \"{}\");",
|
||||
pac_url.as_str()
|
||||
));
|
||||
|
||||
fs::write(user_js_path, preferences.join("\n"))?;
|
||||
@@ -1784,6 +1856,33 @@ mod tests {
|
||||
"Should set SSL proxy port"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pac_url_encodes_spaces_in_path() {
|
||||
let (manager, temp_dir) = create_test_profile_manager();
|
||||
|
||||
let uuid_dir = temp_dir.path().join("path with spaces");
|
||||
let profile_dir = uuid_dir.join("profile");
|
||||
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
|
||||
|
||||
let result = manager.disable_proxy_settings_in_profile(&profile_dir);
|
||||
assert!(result.is_ok(), "Should handle paths with spaces");
|
||||
|
||||
let user_js = fs::read_to_string(profile_dir.join("user.js")).unwrap();
|
||||
let pac_line = user_js
|
||||
.lines()
|
||||
.find(|l| l.contains("autoconfig_url"))
|
||||
.expect("Should have autoconfig_url preference");
|
||||
|
||||
assert!(
|
||||
!pac_line.contains("path with spaces"),
|
||||
"PAC URL should not contain raw spaces: {pac_line}"
|
||||
);
|
||||
assert!(
|
||||
pac_line.contains("path%20with%20spaces"),
|
||||
"PAC URL should percent-encode spaces: {pac_line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1795,9 +1894,11 @@ pub async fn create_browser_profile_with_group(
|
||||
version: String,
|
||||
release_type: String,
|
||||
proxy_id: Option<String>,
|
||||
vpn_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: bool,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
@@ -1808,9 +1909,11 @@ pub async fn create_browser_profile_with_group(
|
||||
&version,
|
||||
&release_type,
|
||||
proxy_id,
|
||||
vpn_id,
|
||||
camoufox_config,
|
||||
wayfern_config,
|
||||
group_id,
|
||||
ephemeral,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create profile: {e}"))
|
||||
@@ -1837,6 +1940,19 @@ pub async fn update_profile_proxy(
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_profile_vpn(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
vpn_id: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_profile_vpn(app_handle, &profile_id, vpn_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update profile VPN: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_tags(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1894,10 +2010,24 @@ pub async fn create_browser_profile_new(
|
||||
version: String,
|
||||
release_type: String,
|
||||
proxy_id: Option<String>,
|
||||
vpn_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: Option<bool>,
|
||||
) -> Result<BrowserProfile, 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 browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
create_browser_profile_with_group(
|
||||
@@ -1907,9 +2037,11 @@ pub async fn create_browser_profile_new(
|
||||
version,
|
||||
release_type,
|
||||
proxy_id,
|
||||
vpn_id,
|
||||
camoufox_config,
|
||||
wayfern_config,
|
||||
group_id,
|
||||
ephemeral.unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -1920,6 +2052,21 @@ pub async fn update_camoufox_config(
|
||||
profile_id: String,
|
||||
config: CamoufoxConfig,
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint editing requires an active Pro subscription".to_string());
|
||||
}
|
||||
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.is_fingerprint_os_allowed(config.os.as_deref())
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_camoufox_config(app_handle, &profile_id, config)
|
||||
@@ -1933,6 +2080,21 @@ pub async fn update_wayfern_config(
|
||||
profile_id: String,
|
||||
config: WayfernConfig,
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint editing requires an active Pro subscription".to_string());
|
||||
}
|
||||
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.is_fingerprint_os_allowed(config.os.as_deref())
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint OS spoofing requires an active Pro subscription".to_string());
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_wayfern_config(app_handle, &profile_id, config)
|
||||
|
||||
@@ -13,6 +13,14 @@ pub enum SyncStatus {
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum SyncMode {
|
||||
#[default]
|
||||
Disabled,
|
||||
Regular,
|
||||
Encrypted,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BrowserProfile {
|
||||
pub id: uuid::Uuid,
|
||||
@@ -22,6 +30,8 @@ pub struct BrowserProfile {
|
||||
#[serde(default)]
|
||||
pub proxy_id: Option<String>, // Reference to stored proxy
|
||||
#[serde(default)]
|
||||
pub vpn_id: Option<String>, // Reference to stored VPN config
|
||||
#[serde(default)]
|
||||
pub process_id: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub last_launch: Option<u64>,
|
||||
@@ -38,18 +48,53 @@ pub struct BrowserProfile {
|
||||
#[serde(default)]
|
||||
pub note: Option<String>, // User note
|
||||
#[serde(default)]
|
||||
pub sync_enabled: bool, // Whether sync is enabled for this profile
|
||||
pub sync_mode: SyncMode,
|
||||
#[serde(default)]
|
||||
pub encryption_salt: Option<String>,
|
||||
#[serde(default)]
|
||||
pub last_sync: Option<u64>, // Timestamp of last successful sync (epoch seconds)
|
||||
#[serde(default)]
|
||||
pub host_os: Option<String>, // OS where profile was created ("macos", "windows", "linux")
|
||||
#[serde(default)]
|
||||
pub ephemeral: bool,
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
"stable".to_string()
|
||||
}
|
||||
|
||||
pub fn get_host_os() -> String {
|
||||
if cfg!(target_os = "macos") {
|
||||
"macos".to_string()
|
||||
} else if cfg!(target_os = "windows") {
|
||||
"windows".to_string()
|
||||
} else {
|
||||
"linux".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserProfile {
|
||||
/// Get the path to the profile data directory (profiles/{uuid}/profile)
|
||||
pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf {
|
||||
profiles_dir.join(self.id.to_string()).join("profile")
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn is_cross_os(&self) -> bool {
|
||||
match &self.host_os {
|
||||
Some(host_os) => host_os != &get_host_os(),
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true if sync is enabled (either Regular or Encrypted mode).
|
||||
pub fn is_sync_enabled(&self) -> bool {
|
||||
self.sync_mode != SyncMode::Disabled
|
||||
}
|
||||
|
||||
/// Returns true if sync uses E2E encryption.
|
||||
pub fn is_encrypted_sync(&self) -> bool {
|
||||
self.sync_mode == SyncMode::Encrypted
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,6 +545,7 @@ impl ProfileImporter {
|
||||
browser: browser_type.to_string(),
|
||||
version: available_versions,
|
||||
proxy_id: None,
|
||||
vpn_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
@@ -553,8 +554,11 @@ impl ProfileImporter {
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_enabled: false,
|
||||
sync_mode: crate::profile::types::SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: Some(crate::profile::types::get_host_os()),
|
||||
ephemeral: false,
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use chrono::Utc;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
@@ -117,11 +116,12 @@ pub struct StoredProxy {
|
||||
|
||||
impl StoredProxy {
|
||||
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
|
||||
let sync_enabled = crate::sync::is_sync_configured();
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
proxy_settings,
|
||||
sync_enabled: false,
|
||||
sync_enabled,
|
||||
last_sync: None,
|
||||
is_cloud_managed: false,
|
||||
is_cloud_derived: false,
|
||||
@@ -148,18 +148,15 @@ pub struct ProxyManager {
|
||||
// Track active proxy IDs by profile name for targeted cleanup
|
||||
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
|
||||
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
|
||||
base_dirs: BaseDirs,
|
||||
}
|
||||
|
||||
impl ProxyManager {
|
||||
pub fn new() -> Self {
|
||||
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
|
||||
let manager = Self {
|
||||
active_proxies: Mutex::new(HashMap::new()),
|
||||
profile_proxies: Mutex::new(HashMap::new()),
|
||||
profile_active_proxy_ids: Mutex::new(HashMap::new()),
|
||||
stored_proxies: Mutex::new(HashMap::new()),
|
||||
base_dirs,
|
||||
};
|
||||
|
||||
// Load stored proxies on initialization
|
||||
@@ -170,27 +167,12 @@ impl ProxyManager {
|
||||
manager
|
||||
}
|
||||
|
||||
// Get the path to the proxies directory
|
||||
fn get_proxies_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("proxies");
|
||||
path
|
||||
crate::app_dirs::proxies_dir()
|
||||
}
|
||||
|
||||
// Get the path to the proxy check cache directory
|
||||
fn get_proxy_check_cache_dir(&self) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let mut path = self.base_dirs.cache_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("proxy_checks");
|
||||
let path = crate::app_dirs::cache_dir().join("proxy_checks");
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
@@ -243,8 +225,7 @@ impl ProxyManager {
|
||||
.as_secs()
|
||||
}
|
||||
|
||||
// Get geolocation for an IP address
|
||||
async fn get_ip_geolocation(
|
||||
pub async fn get_ip_geolocation(
|
||||
ip: &str,
|
||||
) -> Result<(Option<String>, Option<String>, Option<String>), String> {
|
||||
// Use ip-api.com (free, no API key required)
|
||||
@@ -409,6 +390,15 @@ impl ProxyManager {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -478,15 +468,16 @@ impl ProxyManager {
|
||||
}
|
||||
|
||||
// Build a geo-targeted username from base username and location parts
|
||||
// LP format: username-zone-lightning-region-{country}-st-{state}-city-{city}
|
||||
fn build_geo_username(
|
||||
base_username: &str,
|
||||
country: &str,
|
||||
state: &Option<String>,
|
||||
city: &Option<String>,
|
||||
) -> String {
|
||||
let mut username = format!("{}-country-{}", base_username, country);
|
||||
let mut username = format!("{}-zone-lightning-region-{}", base_username, country);
|
||||
if let Some(state) = state {
|
||||
username = format!("{}-state-{}", username, state);
|
||||
username = format!("{}-st-{}", username, state);
|
||||
}
|
||||
if let Some(city) = city {
|
||||
username = format!("{}-city-{}", username, city);
|
||||
@@ -626,6 +617,11 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_from_memory(&self, proxy_id: &str) {
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.remove(proxy_id);
|
||||
}
|
||||
|
||||
// Get all stored proxies
|
||||
pub fn get_stored_proxies(&self) -> Vec<StoredProxy> {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
@@ -698,6 +694,15 @@ impl ProxyManager {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -1625,6 +1630,27 @@ impl ProxyManager {
|
||||
delete_proxy_config(&config.id);
|
||||
}
|
||||
|
||||
// Clean up orphaned VPN worker configs where the worker process is dead
|
||||
{
|
||||
use crate::proxy_storage::is_process_running;
|
||||
use crate::vpn_worker_storage::{delete_vpn_worker_config, list_vpn_worker_configs};
|
||||
|
||||
let vpn_workers = list_vpn_worker_configs();
|
||||
for worker in vpn_workers {
|
||||
if let Some(pid) = worker.pid {
|
||||
if !is_process_running(pid) {
|
||||
log::info!(
|
||||
"Cleaning up orphaned VPN worker config: {} (process PID {} is dead)",
|
||||
worker.id,
|
||||
pid
|
||||
);
|
||||
let _ = std::fs::remove_file(&worker.config_file_path);
|
||||
delete_vpn_worker_config(&worker.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = events::emit_empty("proxies-changed") {
|
||||
log::error!("Failed to emit proxies-changed event: {e}");
|
||||
|
||||
@@ -254,9 +254,12 @@ pub async fn stop_proxy_process(id: &str) -> Result<bool, Box<dyn std::error::Er
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/F", "/PID", &pid.to_string()])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -35,15 +34,7 @@ impl ProxyConfig {
|
||||
}
|
||||
|
||||
pub fn get_storage_dir() -> PathBuf {
|
||||
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("proxies");
|
||||
path
|
||||
crate::app_dirs::proxies_dir()
|
||||
}
|
||||
|
||||
pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::PathBuf;
|
||||
@@ -54,6 +53,8 @@ pub struct AppSettings {
|
||||
pub launch_on_login_declined: bool, // User permanently declined the launch-on-login prompt
|
||||
#[serde(default)]
|
||||
pub language: Option<String>, // ISO 639-1: "en", "es", "pt", "fr", "zh", "ja", "ru", or None for system default
|
||||
#[serde(default)]
|
||||
pub window_resize_warning_dismissed: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
@@ -87,31 +88,16 @@ impl Default for AppSettings {
|
||||
mcp_token: None,
|
||||
launch_on_login_declined: false,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SettingsManager {
|
||||
base_dirs: BaseDirs,
|
||||
data_dir_override: Option<PathBuf>,
|
||||
}
|
||||
pub struct SettingsManager;
|
||||
|
||||
impl SettingsManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
|
||||
.ok()
|
||||
.map(PathBuf::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_data_dir_override(dir: &std::path::Path) -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: Some(dir.to_path_buf()),
|
||||
}
|
||||
pub(crate) fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static SettingsManager {
|
||||
@@ -119,18 +105,7 @@ impl SettingsManager {
|
||||
}
|
||||
|
||||
pub fn get_settings_dir(&self) -> PathBuf {
|
||||
if let Some(dir) = &self.data_dir_override {
|
||||
return dir.join("settings");
|
||||
}
|
||||
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("settings");
|
||||
path
|
||||
crate::app_dirs::settings_dir()
|
||||
}
|
||||
|
||||
pub fn get_settings_file(&self) -> PathBuf {
|
||||
@@ -809,6 +784,15 @@ pub async fn save_app_settings(
|
||||
settings.mcp_token = None;
|
||||
}
|
||||
|
||||
// Preserve server-managed flags that the frontend may not have up-to-date.
|
||||
// Read directly from file to avoid load_settings' save-on-load behavior.
|
||||
if let Ok(content) = std::fs::read_to_string(manager.get_settings_file()) {
|
||||
if let Ok(current) = serde_json::from_str::<AppSettings>(&content) {
|
||||
settings.window_resize_warning_dismissed = current.window_resize_warning_dismissed;
|
||||
settings.launch_on_login_declined = current.launch_on_login_declined;
|
||||
}
|
||||
}
|
||||
|
||||
let mut persist_settings = settings.clone();
|
||||
persist_settings.api_token = None;
|
||||
persist_settings.mcp_token = None;
|
||||
@@ -926,6 +910,27 @@ pub async fn save_sync_settings(
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dismiss_window_resize_warning() -> Result<(), String> {
|
||||
let manager = SettingsManager::instance();
|
||||
let mut settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
settings.window_resize_warning_dismissed = true;
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_window_resize_warning_dismissed() -> Result<bool, String> {
|
||||
let manager = SettingsManager::instance();
|
||||
let settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
Ok(settings.window_resize_warning_dismissed)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_system_language() -> String {
|
||||
sys_locale::get_locale()
|
||||
@@ -950,16 +955,16 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_settings_manager() -> (SettingsManager, TempDir) {
|
||||
fn create_test_settings_manager() -> (SettingsManager, TempDir, crate::app_dirs::TestDirGuard) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
let manager = SettingsManager::with_data_dir_override(temp_dir.path());
|
||||
(manager, temp_dir)
|
||||
let guard = crate::app_dirs::set_test_data_dir(temp_dir.path().to_path_buf());
|
||||
let manager = SettingsManager::new();
|
||||
(manager, temp_dir, guard)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_manager_creation() {
|
||||
let (_manager, _temp_dir) = create_test_settings_manager();
|
||||
// Test passes if no panic occurs
|
||||
let (_manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -992,7 +997,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_load_settings_nonexistent_file() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let result = manager.load_settings();
|
||||
assert!(
|
||||
@@ -1010,7 +1015,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_settings() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let test_settings = AppSettings {
|
||||
set_as_default_browser: true,
|
||||
@@ -1027,13 +1032,12 @@ mod tests {
|
||||
mcp_token: None,
|
||||
launch_on_login_declined: false,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
};
|
||||
|
||||
// Save settings
|
||||
let save_result = manager.save_settings(&test_settings);
|
||||
assert!(save_result.is_ok(), "Should save settings successfully");
|
||||
|
||||
// Load settings back
|
||||
let load_result = manager.load_settings();
|
||||
assert!(load_result.is_ok(), "Should load settings successfully");
|
||||
|
||||
@@ -1050,7 +1054,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_load_table_sorting_nonexistent_file() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let result = manager.load_table_sorting();
|
||||
assert!(
|
||||
@@ -1065,18 +1069,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_table_sorting() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let test_sorting = TableSortingSettings {
|
||||
column: "browser".to_string(),
|
||||
direction: "desc".to_string(),
|
||||
};
|
||||
|
||||
// Save sorting
|
||||
let save_result = manager.save_table_sorting(&test_sorting);
|
||||
assert!(save_result.is_ok(), "Should save sorting successfully");
|
||||
|
||||
// Load sorting back
|
||||
let load_result = manager.load_table_sorting();
|
||||
assert!(load_result.is_ok(), "Should load sorting successfully");
|
||||
|
||||
@@ -1093,45 +1095,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_should_show_launch_on_login_prompt() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let result = manager.should_show_launch_on_login_prompt();
|
||||
assert!(result.is_ok(), "Should not fail");
|
||||
|
||||
// By default, should show prompt (not declined, autostart not enabled)
|
||||
let _should_show = result.unwrap();
|
||||
// Note: The actual value depends on system autostart state, so we just test it doesn't fail
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decline_launch_on_login() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
// Initially not declined
|
||||
let settings = manager.load_settings().unwrap();
|
||||
assert!(!settings.launch_on_login_declined);
|
||||
|
||||
// Decline
|
||||
manager.decline_launch_on_login().unwrap();
|
||||
|
||||
// Should be declined now
|
||||
let settings = manager.load_settings().unwrap();
|
||||
assert!(settings.launch_on_login_declined);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_corrupted_settings_file() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
// Create settings directory
|
||||
let settings_dir = manager.get_settings_dir();
|
||||
fs::create_dir_all(&settings_dir).expect("Should create settings directory");
|
||||
|
||||
// Write corrupted JSON
|
||||
let settings_file = manager.get_settings_file();
|
||||
fs::write(&settings_file, "{ invalid json }").expect("Should write corrupted file");
|
||||
|
||||
// Should handle corrupted file gracefully
|
||||
let result = manager.load_settings();
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
@@ -1151,7 +1145,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_settings_file_paths() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let settings_dir = manager.get_settings_dir();
|
||||
let settings_file = manager.get_settings_file();
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
use aes_gcm::{
|
||||
aead::{Aead, AeadCore, KeyInit, OsRng},
|
||||
Aes256Gcm, Key,
|
||||
};
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
|
||||
|
||||
const E2E_FILE_HEADER: &[u8] = b"DBE2E";
|
||||
const E2E_FILE_VERSION: u8 = 1;
|
||||
|
||||
fn get_e2e_password_path() -> std::path::PathBuf {
|
||||
crate::app_dirs::settings_dir().join("e2e_password.dat")
|
||||
}
|
||||
|
||||
fn get_vault_password() -> String {
|
||||
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
|
||||
}
|
||||
|
||||
pub fn store_e2e_password(password: &str) -> Result<(), String> {
|
||||
let file_path = get_e2e_password_path();
|
||||
|
||||
if let Some(parent) = file_path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| format!("Failed to create directory: {e}"))?;
|
||||
}
|
||||
|
||||
let vault_password = get_vault_password();
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(vault_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
|
||||
let key_bytes: [u8; 32] = hash_bytes[..32]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid key length")?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, password.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {e}"))?;
|
||||
|
||||
let mut file_data = Vec::new();
|
||||
file_data.extend_from_slice(E2E_FILE_HEADER);
|
||||
file_data.push(E2E_FILE_VERSION);
|
||||
|
||||
let salt_str = salt.as_str();
|
||||
file_data.push(salt_str.len() as u8);
|
||||
file_data.extend_from_slice(salt_str.as_bytes());
|
||||
file_data.extend_from_slice(&nonce);
|
||||
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
|
||||
file_data.extend_from_slice(&ciphertext);
|
||||
|
||||
std::fs::write(&file_path, file_data)
|
||||
.map_err(|e| format!("Failed to write e2e password file: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load_e2e_password() -> Result<Option<String>, String> {
|
||||
let file_path = get_e2e_password_path();
|
||||
if !file_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let file_data =
|
||||
std::fs::read(&file_path).map_err(|e| format!("Failed to read e2e password file: {e}"))?;
|
||||
|
||||
if file_data.len() < E2E_FILE_HEADER.len() + 1 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if &file_data[..E2E_FILE_HEADER.len()] != E2E_FILE_HEADER {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let version = file_data[E2E_FILE_HEADER.len()];
|
||||
if version != E2E_FILE_VERSION {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut offset = E2E_FILE_HEADER.len() + 1;
|
||||
|
||||
if offset >= file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_len = file_data[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
if offset + salt_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_str = std::str::from_utf8(&file_data[offset..offset + salt_len])
|
||||
.map_err(|_| "Invalid salt encoding")?;
|
||||
offset += salt_len;
|
||||
|
||||
let salt = SaltString::from_b64(salt_str).map_err(|e| format!("Invalid salt: {e}"))?;
|
||||
|
||||
if offset + 12 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nonce_bytes: [u8; 12] = file_data[offset..offset + 12]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid nonce")?;
|
||||
let nonce = aes_gcm::Nonce::from(nonce_bytes);
|
||||
offset += 12;
|
||||
|
||||
if offset + 4 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext_len =
|
||||
u32::from_le_bytes(file_data[offset..offset + 4].try_into().unwrap()) as usize;
|
||||
offset += 4;
|
||||
|
||||
if offset + ciphertext_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext = &file_data[offset..offset + ciphertext_len];
|
||||
|
||||
let vault_password = get_vault_password();
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(vault_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
|
||||
let key_bytes: [u8; 32] = hash_bytes[..32]
|
||||
.try_into()
|
||||
.map_err(|_| "Invalid key length")?;
|
||||
let key = Key::<Aes256Gcm>::from(key_bytes);
|
||||
let cipher = Aes256Gcm::new(&key);
|
||||
|
||||
let plaintext = cipher
|
||||
.decrypt(&nonce, ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {e}"))?;
|
||||
|
||||
let password =
|
||||
String::from_utf8(plaintext).map_err(|e| format!("Invalid UTF-8 in password: {e}"))?;
|
||||
|
||||
Ok(Some(password))
|
||||
}
|
||||
|
||||
pub fn has_e2e_password() -> bool {
|
||||
get_e2e_password_path().exists()
|
||||
}
|
||||
|
||||
pub fn remove_e2e_password() -> Result<(), String> {
|
||||
let file_path = get_e2e_password_path();
|
||||
if file_path.exists() {
|
||||
std::fs::remove_file(&file_path)
|
||||
.map_err(|e| format!("Failed to remove e2e password file: {e}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Derive a per-profile encryption key using Argon2id
|
||||
pub fn derive_profile_key(user_password: &str, profile_salt: &str) -> Result<[u8; 32], String> {
|
||||
let salt_bytes = BASE64
|
||||
.decode(profile_salt)
|
||||
.map_err(|e| format!("Invalid salt encoding: {e}"))?;
|
||||
|
||||
let salt = SaltString::encode_b64(&salt_bytes)
|
||||
.map_err(|e| format!("Failed to create salt string: {e}"))?;
|
||||
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(user_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
|
||||
let mut key = [0u8; 32];
|
||||
key.copy_from_slice(&hash_bytes[..32]);
|
||||
Ok(key)
|
||||
}
|
||||
|
||||
/// Generate a random 16-byte salt, base64-encoded
|
||||
pub fn generate_salt() -> String {
|
||||
let mut salt = [0u8; 16];
|
||||
use aes_gcm::aead::rand_core::RngCore;
|
||||
OsRng.fill_bytes(&mut salt);
|
||||
BASE64.encode(salt)
|
||||
}
|
||||
|
||||
/// Encrypt bytes with AES-256-GCM. Output format: [nonce 12B][ciphertext]
|
||||
pub fn encrypt_bytes(key: &[u8; 32], plaintext: &[u8]) -> Result<Vec<u8>, String> {
|
||||
let aes_key = Key::<Aes256Gcm>::from(*key);
|
||||
let cipher = Aes256Gcm::new(&aes_key);
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, plaintext)
|
||||
.map_err(|e| format!("Encryption failed: {e}"))?;
|
||||
|
||||
let mut output = Vec::with_capacity(12 + ciphertext.len());
|
||||
output.extend_from_slice(&nonce);
|
||||
output.extend_from_slice(&ciphertext);
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Decrypt bytes encrypted with encrypt_bytes. Input format: [nonce 12B][ciphertext]
|
||||
pub fn decrypt_bytes(key: &[u8; 32], encrypted: &[u8]) -> Result<Vec<u8>, String> {
|
||||
if encrypted.len() < 12 {
|
||||
return Err("Encrypted data too short".to_string());
|
||||
}
|
||||
|
||||
let nonce_bytes: [u8; 12] = encrypted[..12].try_into().map_err(|_| "Invalid nonce")?;
|
||||
let nonce = aes_gcm::Nonce::from(nonce_bytes);
|
||||
let ciphertext = &encrypted[12..];
|
||||
|
||||
let aes_key = Key::<Aes256Gcm>::from(*key);
|
||||
let cipher = Aes256Gcm::new(&aes_key);
|
||||
|
||||
cipher
|
||||
.decrypt(&nonce, ciphertext)
|
||||
.map_err(|e| format!("Decryption failed: {e}"))
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_e2e_password(password: String) -> Result<(), String> {
|
||||
if password.len() < 8 {
|
||||
return Err("Password must be at least 8 characters".to_string());
|
||||
}
|
||||
store_e2e_password(&password)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_has_e2e_password() -> bool {
|
||||
has_e2e_password()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_e2e_password() -> Result<(), String> {
|
||||
remove_e2e_password()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_roundtrip() {
|
||||
let key = [42u8; 32];
|
||||
let plaintext = b"Hello, World!";
|
||||
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_empty_data() {
|
||||
let key = [1u8; 32];
|
||||
let plaintext = b"";
|
||||
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
|
||||
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext.to_vec());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_encrypt_decrypt_large_data() {
|
||||
let key = [7u8; 32];
|
||||
let plaintext = vec![0xABu8; 1_048_576]; // 1MB
|
||||
let encrypted = encrypt_bytes(&key, &plaintext).unwrap();
|
||||
let decrypted = decrypt_bytes(&key, &encrypted).unwrap();
|
||||
assert_eq!(decrypted, plaintext);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_different_keys_different_ciphertext() {
|
||||
let key1 = [1u8; 32];
|
||||
let key2 = [2u8; 32];
|
||||
let plaintext = b"same data";
|
||||
let encrypted1 = encrypt_bytes(&key1, plaintext).unwrap();
|
||||
let encrypted2 = encrypt_bytes(&key2, plaintext).unwrap();
|
||||
// Nonces are random so ciphertexts will differ regardless,
|
||||
// but decrypting with wrong key should fail
|
||||
assert!(decrypt_bytes(&key2, &encrypted1).is_err());
|
||||
assert!(decrypt_bytes(&key1, &encrypted2).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_nonce_uniqueness() {
|
||||
let key = [5u8; 32];
|
||||
let plaintext = b"same data encrypted twice";
|
||||
let encrypted1 = encrypt_bytes(&key, plaintext).unwrap();
|
||||
let encrypted2 = encrypt_bytes(&key, plaintext).unwrap();
|
||||
// Different nonces should produce different ciphertext
|
||||
assert_ne!(encrypted1, encrypted2);
|
||||
// But both should decrypt to the same plaintext
|
||||
assert_eq!(
|
||||
decrypt_bytes(&key, &encrypted1).unwrap(),
|
||||
decrypt_bytes(&key, &encrypted2).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_wrong_key_fails() {
|
||||
let key = [10u8; 32];
|
||||
let wrong_key = [20u8; 32];
|
||||
let plaintext = b"secret data";
|
||||
let encrypted = encrypt_bytes(&key, plaintext).unwrap();
|
||||
assert!(decrypt_bytes(&wrong_key, &encrypted).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_derivation_deterministic() {
|
||||
let salt = generate_salt();
|
||||
let key1 = derive_profile_key("my_password", &salt).unwrap();
|
||||
let key2 = derive_profile_key("my_password", &salt).unwrap();
|
||||
assert_eq!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_key_derivation_different_salts() {
|
||||
let salt1 = generate_salt();
|
||||
let salt2 = generate_salt();
|
||||
let key1 = derive_profile_key("my_password", &salt1).unwrap();
|
||||
let key2 = derive_profile_key("my_password", &salt2).unwrap();
|
||||
assert_ne!(key1, key2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_salt_generation_unique() {
|
||||
let salt1 = generate_salt();
|
||||
let salt2 = generate_salt();
|
||||
assert_ne!(salt1, salt2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_password_storage_roundtrip() {
|
||||
let password = "test_password_12345";
|
||||
store_e2e_password(password).unwrap();
|
||||
assert!(has_e2e_password());
|
||||
let loaded = load_e2e_password().unwrap();
|
||||
assert_eq!(loaded, Some(password.to_string()));
|
||||
remove_e2e_password().unwrap();
|
||||
assert!(!has_e2e_password());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decrypt_too_short_data() {
|
||||
let key = [1u8; 32];
|
||||
assert!(decrypt_bytes(&key, &[0u8; 5]).is_err());
|
||||
}
|
||||
}
|
||||
+779
-27
File diff suppressed because it is too large
Load Diff
@@ -52,6 +52,8 @@ pub struct SyncManifest {
|
||||
#[serde(rename = "excludeGlobs")]
|
||||
pub exclude_globs: Vec<String>,
|
||||
pub files: Vec<ManifestFileEntry>,
|
||||
#[serde(default)]
|
||||
pub encrypted: bool,
|
||||
}
|
||||
|
||||
impl SyncManifest {
|
||||
@@ -64,6 +66,7 @@ impl SyncManifest {
|
||||
updated_at: now,
|
||||
exclude_globs,
|
||||
files: Vec::new(),
|
||||
encrypted: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -547,6 +550,7 @@ mod tests {
|
||||
hash: "def".to_string(),
|
||||
},
|
||||
],
|
||||
encrypted: false,
|
||||
};
|
||||
|
||||
let diff = compute_diff(&local, None);
|
||||
@@ -588,6 +592,7 @@ mod tests {
|
||||
hash: "new".to_string(),
|
||||
},
|
||||
],
|
||||
encrypted: false,
|
||||
};
|
||||
|
||||
let remote = SyncManifest {
|
||||
@@ -616,6 +621,7 @@ mod tests {
|
||||
hash: "gone".to_string(),
|
||||
},
|
||||
],
|
||||
encrypted: false,
|
||||
};
|
||||
|
||||
let diff = compute_diff(&local, Some(&remote));
|
||||
@@ -634,4 +640,22 @@ mod tests {
|
||||
.files_to_delete_remote
|
||||
.contains(&"deleted.txt".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_encrypted_flag_default() {
|
||||
let json = r#"{"version":1,"profileId":"test","generatedAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","excludeGlobs":[],"files":[]}"#;
|
||||
let manifest: SyncManifest = serde_json::from_str(json).unwrap();
|
||||
assert!(!manifest.encrypted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_manifest_with_encrypted_flag() {
|
||||
let json = r#"{"version":1,"profileId":"test","generatedAt":"2024-01-01T00:00:00Z","updatedAt":"2024-01-01T00:00:00Z","excludeGlobs":[],"files":[],"encrypted":true}"#;
|
||||
let manifest: SyncManifest = serde_json::from_str(json).unwrap();
|
||||
assert!(manifest.encrypted);
|
||||
|
||||
let serialized = serde_json::to_string(&manifest).unwrap();
|
||||
let deserialized: SyncManifest = serde_json::from_str(&serialized).unwrap();
|
||||
assert!(deserialized.encrypted);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod client;
|
||||
pub mod encryption;
|
||||
mod engine;
|
||||
pub mod manifest;
|
||||
pub mod scheduler;
|
||||
@@ -6,12 +7,15 @@ pub mod subscription;
|
||||
pub mod types;
|
||||
|
||||
pub use client::SyncClient;
|
||||
pub use encryption::{check_has_e2e_password, delete_e2e_password, set_e2e_password};
|
||||
pub use engine::{
|
||||
enable_group_sync_if_needed, enable_proxy_sync_if_needed, is_group_in_use_by_synced_profile,
|
||||
enable_group_sync_if_needed, enable_proxy_sync_if_needed, enable_sync_for_all_entities,
|
||||
enable_vpn_sync_if_needed, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
|
||||
is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile,
|
||||
is_proxy_used_by_synced_profile, request_profile_sync, set_group_sync_enabled,
|
||||
set_profile_sync_enabled, set_proxy_sync_enabled, sync_profile, trigger_sync_for_profile,
|
||||
SyncEngine,
|
||||
is_proxy_used_by_synced_profile, is_sync_configured, is_vpn_in_use_by_synced_profile,
|
||||
is_vpn_used_by_synced_profile, request_profile_sync, set_group_sync_enabled,
|
||||
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile,
|
||||
trigger_sync_for_profile, SyncEngine,
|
||||
};
|
||||
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
|
||||
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
|
||||
|
||||
+131
-43
@@ -34,6 +34,7 @@ pub struct SyncScheduler {
|
||||
pending_profiles: Arc<Mutex<HashMap<String, ProfileStopTime>>>,
|
||||
pending_proxies: Arc<Mutex<HashSet<String>>>,
|
||||
pending_groups: Arc<Mutex<HashSet<String>>>,
|
||||
pending_vpns: Arc<Mutex<HashSet<String>>>,
|
||||
pending_tombstones: Arc<Mutex<Vec<(String, String)>>>,
|
||||
running_profiles: Arc<Mutex<HashSet<String>>>,
|
||||
in_flight_profiles: Arc<Mutex<HashSet<String>>>,
|
||||
@@ -52,6 +53,7 @@ impl SyncScheduler {
|
||||
pending_profiles: Arc::new(Mutex::new(HashMap::new())),
|
||||
pending_proxies: Arc::new(Mutex::new(HashSet::new())),
|
||||
pending_groups: Arc::new(Mutex::new(HashSet::new())),
|
||||
pending_vpns: Arc::new(Mutex::new(HashSet::new())),
|
||||
pending_tombstones: Arc::new(Mutex::new(Vec::new())),
|
||||
running_profiles: Arc::new(Mutex::new(HashSet::new())),
|
||||
in_flight_profiles: Arc::new(Mutex::new(HashSet::new())),
|
||||
@@ -92,6 +94,12 @@ impl SyncScheduler {
|
||||
}
|
||||
drop(pending_groups);
|
||||
|
||||
let pending_vpns = self.pending_vpns.lock().await;
|
||||
if !pending_vpns.is_empty() {
|
||||
return true;
|
||||
}
|
||||
drop(pending_vpns);
|
||||
|
||||
let pending_tombstones = self.pending_tombstones.lock().await;
|
||||
if !pending_tombstones.is_empty() {
|
||||
return true;
|
||||
@@ -190,6 +198,11 @@ impl SyncScheduler {
|
||||
pending.insert(proxy_id);
|
||||
}
|
||||
|
||||
pub async fn queue_vpn_sync(&self, vpn_id: String) {
|
||||
let mut pending = self.pending_vpns.lock().await;
|
||||
pending.insert(vpn_id);
|
||||
}
|
||||
|
||||
pub async fn queue_group_sync(&self, group_id: String) {
|
||||
let mut pending = self.pending_groups.lock().await;
|
||||
pending.insert(group_id);
|
||||
@@ -219,7 +232,10 @@ impl SyncScheduler {
|
||||
}
|
||||
};
|
||||
|
||||
let sync_enabled_profiles: Vec<_> = profiles.into_iter().filter(|p| p.sync_enabled).collect();
|
||||
let sync_enabled_profiles: Vec<_> = profiles
|
||||
.into_iter()
|
||||
.filter(|p| p.is_sync_enabled())
|
||||
.collect();
|
||||
|
||||
if sync_enabled_profiles.is_empty() {
|
||||
log::debug!("No sync-enabled profiles found");
|
||||
@@ -269,6 +285,7 @@ impl SyncScheduler {
|
||||
SyncWorkItem::Profile(id) => scheduler.queue_profile_sync(id).await,
|
||||
SyncWorkItem::Proxy(id) => scheduler.queue_proxy_sync(id).await,
|
||||
SyncWorkItem::Group(id) => scheduler.queue_group_sync(id).await,
|
||||
SyncWorkItem::Vpn(id) => scheduler.queue_vpn_sync(id).await,
|
||||
SyncWorkItem::Tombstone(entity_type, entity_id) => {
|
||||
scheduler.queue_tombstone(entity_type, entity_id).await
|
||||
}
|
||||
@@ -288,6 +305,7 @@ impl SyncScheduler {
|
||||
self.process_pending_profiles(app_handle).await;
|
||||
self.process_pending_proxies(app_handle).await;
|
||||
self.process_pending_groups(app_handle).await;
|
||||
self.process_pending_vpns(app_handle).await;
|
||||
self.process_pending_tombstones(app_handle).await;
|
||||
}
|
||||
|
||||
@@ -338,7 +356,7 @@ impl SyncScheduler {
|
||||
profile_manager.list_profiles().ok().and_then(|profiles| {
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id && p.sync_enabled)
|
||||
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled())
|
||||
})
|
||||
};
|
||||
|
||||
@@ -366,6 +384,7 @@ impl SyncScheduler {
|
||||
&& 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()
|
||||
};
|
||||
|
||||
match result {
|
||||
@@ -537,7 +556,69 @@ impl SyncScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_pending_tombstones(&self, app_handle: &tauri::AppHandle) {
|
||||
async fn process_pending_vpns(&self, app_handle: &tauri::AppHandle) {
|
||||
let vpns_to_sync: Vec<String> = {
|
||||
let mut pending = self.pending_vpns.lock().await;
|
||||
let list: Vec<String> = pending.drain().collect();
|
||||
list
|
||||
};
|
||||
|
||||
if vpns_to_sync.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match SyncEngine::create_from_settings(app_handle).await {
|
||||
Ok(engine) => {
|
||||
for vpn_id in vpns_to_sync {
|
||||
log::info!("Syncing VPN {}", vpn_id);
|
||||
let _ = events::emit(
|
||||
"vpn-sync-status",
|
||||
serde_json::json!({
|
||||
"id": vpn_id,
|
||||
"status": "syncing"
|
||||
}),
|
||||
);
|
||||
match engine.sync_vpn_by_id_with_handle(&vpn_id, app_handle).await {
|
||||
Ok(()) => {
|
||||
let _ = events::emit(
|
||||
"vpn-sync-status",
|
||||
serde_json::json!({
|
||||
"id": vpn_id,
|
||||
"status": "synced"
|
||||
}),
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to sync VPN {}: {}", vpn_id, e);
|
||||
let _ = events::emit(
|
||||
"vpn-sync-status",
|
||||
serde_json::json!({
|
||||
"id": vpn_id,
|
||||
"status": "error"
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_pending_tombstones(&self, _app_handle: &tauri::AppHandle) {
|
||||
let tombstones: Vec<(String, String)> = {
|
||||
let mut pending = self.pending_tombstones.lock().await;
|
||||
std::mem::take(&mut *pending)
|
||||
@@ -551,61 +632,68 @@ impl SyncScheduler {
|
||||
log::info!("Processing tombstone for {} {}", entity_type, entity_id);
|
||||
match entity_type.as_str() {
|
||||
"profile" => {
|
||||
let exists_locally = {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profile_to_delete = {
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
let profile_uuid = uuid::Uuid::parse_str(&entity_id).ok();
|
||||
profile_uuid
|
||||
.as_ref()
|
||||
.map(|uuid| profiles.iter().any(|p| p.id == *uuid))
|
||||
.unwrap_or(false)
|
||||
profile_uuid.and_then(|uuid| profiles.into_iter().find(|p| p.id == uuid))
|
||||
} else {
|
||||
false
|
||||
None
|
||||
}
|
||||
};
|
||||
|
||||
if exists_locally {
|
||||
// Profile exists locally but was deleted remotely - delete locally
|
||||
if let Some(mut profile) = profile_to_delete {
|
||||
log::info!(
|
||||
"Profile {} exists locally, deleting due to remote tombstone",
|
||||
"Profile {} was deleted remotely, disabling sync locally",
|
||||
entity_id
|
||||
);
|
||||
// Note: We don't actually delete here to avoid data loss.
|
||||
// The user should be notified or we could add a confirmation step.
|
||||
// For now, just log it.
|
||||
} else {
|
||||
// Profile doesn't exist locally - check if it still exists remotely
|
||||
// (tombstone might have been created but profile files still exist)
|
||||
// Try to download it
|
||||
match SyncEngine::create_from_settings(app_handle).await {
|
||||
Ok(engine) => {
|
||||
if let Ok(true) = engine
|
||||
.download_profile_if_missing(app_handle, &entity_id)
|
||||
.await
|
||||
{
|
||||
log::info!(
|
||||
"Downloaded missing profile {} from remote storage",
|
||||
entity_id
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::debug!("Sync not configured, skipping profile download: {}", e);
|
||||
}
|
||||
profile.sync_mode = crate::profile::types::SyncMode::Disabled;
|
||||
if let Err(e) = profile_manager.save_profile(&profile) {
|
||||
log::warn!("Failed to disable sync for profile {}: {}", entity_id, e);
|
||||
} else {
|
||||
log::info!(
|
||||
"Profile {} sync disabled due to remote tombstone (local copy kept)",
|
||||
entity_id
|
||||
);
|
||||
let _ = events::emit("profiles-changed", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
"proxy" => {
|
||||
log::debug!(
|
||||
"Proxy tombstone for {} - local deletion not implemented",
|
||||
entity_id
|
||||
);
|
||||
let proxy_manager = &crate::proxy_manager::PROXY_MANAGER;
|
||||
let proxies = proxy_manager.get_stored_proxies();
|
||||
if let Some(proxy) = proxies.iter().find(|p| p.id == entity_id) {
|
||||
if proxy.sync_enabled {
|
||||
log::info!("Proxy {} was deleted remotely, deleting locally", entity_id);
|
||||
let proxy_file = proxy_manager.get_proxy_file_path(&entity_id);
|
||||
if proxy_file.exists() {
|
||||
let _ = std::fs::remove_file(&proxy_file);
|
||||
}
|
||||
proxy_manager.remove_from_memory(&entity_id);
|
||||
let _ = events::emit("stored-proxies-changed", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
"group" => {
|
||||
log::debug!(
|
||||
"Group tombstone for {} - local deletion not implemented",
|
||||
entity_id
|
||||
);
|
||||
let group_manager = crate::group_manager::GROUP_MANAGER.lock().unwrap();
|
||||
let groups = group_manager.get_all_groups().unwrap_or_default();
|
||||
if let Some(group) = groups.iter().find(|g| g.id == entity_id) {
|
||||
if group.sync_enabled {
|
||||
log::info!("Group {} was deleted remotely, deleting locally", entity_id);
|
||||
let _ = group_manager.delete_group_internal(&entity_id);
|
||||
let _ = events::emit("groups-changed", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
"vpn" => {
|
||||
let storage = crate::vpn::VPN_STORAGE.lock().unwrap();
|
||||
if let Ok(vpn) = storage.load_config(&entity_id) {
|
||||
if vpn.sync_enabled {
|
||||
log::info!("VPN {} was deleted remotely, deleting locally", entity_id);
|
||||
let _ = storage.delete_config(&entity_id);
|
||||
let _ = events::emit("vpn-configs-changed", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ pub enum SyncWorkItem {
|
||||
Profile(String),
|
||||
Proxy(String),
|
||||
Group(String),
|
||||
Vpn(String),
|
||||
Tombstone(String, String),
|
||||
}
|
||||
|
||||
@@ -229,6 +230,11 @@ impl SyncSubscription {
|
||||
.strip_prefix("groups/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
.map(|s| SyncWorkItem::Group(s.to_string()))
|
||||
} else if key.starts_with("vpns/") {
|
||||
key
|
||||
.strip_prefix("vpns/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
.map(|s| SyncWorkItem::Vpn(s.to_string()))
|
||||
} else if key.starts_with("tombstones/") {
|
||||
key.strip_prefix("tombstones/").and_then(|rest| {
|
||||
if rest.starts_with("profiles/") {
|
||||
@@ -246,6 +252,11 @@ impl SyncSubscription {
|
||||
.strip_prefix("groups/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
.map(|id| SyncWorkItem::Tombstone("group".to_string(), id.to_string()))
|
||||
} else if rest.starts_with("vpns/") {
|
||||
rest
|
||||
.strip_prefix("vpns/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
.map(|id| SyncWorkItem::Tombstone("vpn".to_string(), id.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -1,56 +1,22 @@
|
||||
use crate::profile::BrowserProfile;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
struct TagsData {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct TagManager {
|
||||
base_dirs: BaseDirs,
|
||||
data_dir_override: Option<PathBuf>,
|
||||
}
|
||||
pub struct TagManager;
|
||||
|
||||
impl TagManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
|
||||
.ok()
|
||||
.map(PathBuf::from),
|
||||
}
|
||||
Self
|
||||
}
|
||||
|
||||
// Helper for tests to override data directory without global env var
|
||||
#[allow(dead_code)]
|
||||
pub fn with_data_dir_override(dir: &Path) -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: Some(dir.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_tags_file_path(&self) -> PathBuf {
|
||||
if let Some(dir) = &self.data_dir_override {
|
||||
let mut override_path = dir.clone();
|
||||
let _ = fs::create_dir_all(&override_path);
|
||||
override_path.push("tags.json");
|
||||
return override_path;
|
||||
}
|
||||
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("data");
|
||||
path.push("tags.json");
|
||||
path
|
||||
fn get_tags_file_path(&self) -> std::path::PathBuf {
|
||||
crate::app_dirs::data_subdir().join("tags.json")
|
||||
}
|
||||
|
||||
fn load_tags_data(&self) -> Result<TagsData, Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
@@ -328,17 +327,8 @@ fn acquire_file_lock(lock_path: &PathBuf) -> Result<FileLockGuard, Box<dyn std::
|
||||
Ok(FileLockGuard { _file: file })
|
||||
}
|
||||
|
||||
/// Get the traffic stats storage directory
|
||||
pub fn get_traffic_stats_dir() -> PathBuf {
|
||||
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
|
||||
let mut path = base_dirs.cache_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("traffic_stats");
|
||||
path
|
||||
crate::app_dirs::cache_dir().join("traffic_stats")
|
||||
}
|
||||
|
||||
/// Get the storage key for traffic stats (profile_id if available, otherwise proxy_id)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -67,13 +66,7 @@ impl VersionUpdater {
|
||||
}
|
||||
|
||||
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let app_name = if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
};
|
||||
let cache_dir = base_dirs.cache_dir().join(app_name).join("version_cache");
|
||||
let cache_dir = crate::app_dirs::cache_dir().join("version_cache");
|
||||
fs::create_dir_all(&cache_dir)?;
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,10 @@ pub struct VpnConfig {
|
||||
pub config_data: String, // Raw config content (encrypted at rest)
|
||||
pub created_at: i64,
|
||||
pub last_used: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub sync_enabled: bool,
|
||||
#[serde(default)]
|
||||
pub last_sync: Option<u64>,
|
||||
}
|
||||
|
||||
/// Parsed WireGuard configuration
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
|
||||
mod config;
|
||||
mod openvpn;
|
||||
pub mod openvpn_socks5;
|
||||
pub mod socks5_server;
|
||||
mod storage;
|
||||
mod tunnel;
|
||||
mod wireguard;
|
||||
@@ -25,7 +27,3 @@ use std::sync::Mutex;
|
||||
|
||||
/// Global VPN storage instance
|
||||
pub static VPN_STORAGE: Lazy<Mutex<VpnStorage>> = Lazy::new(|| Mutex::new(VpnStorage::new()));
|
||||
|
||||
/// Global tunnel manager instance
|
||||
pub static TUNNEL_MANAGER: Lazy<tokio::sync::Mutex<TunnelManager>> =
|
||||
Lazy::new(|| tokio::sync::Mutex::new(TunnelManager::new()));
|
||||
|
||||
@@ -73,7 +73,13 @@ impl OpenVpnTunnel {
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
if let Ok(output) = Command::new("where").arg("openvpn").output() {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
if let Ok(output) = Command::new("where")
|
||||
.arg("openvpn")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
|
||||
@@ -0,0 +1,239 @@
|
||||
use super::config::{OpenVpnConfig, VpnError};
|
||||
use std::path::PathBuf;
|
||||
use std::process::{Command, Stdio};
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
pub struct OpenVpnSocks5Server {
|
||||
config: OpenVpnConfig,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl OpenVpnSocks5Server {
|
||||
pub fn new(config: OpenVpnConfig, port: u16) -> Self {
|
||||
Self { config, port }
|
||||
}
|
||||
|
||||
fn find_openvpn_binary() -> Result<PathBuf, VpnError> {
|
||||
let locations = [
|
||||
"/usr/sbin/openvpn",
|
||||
"/usr/local/sbin/openvpn",
|
||||
"/opt/homebrew/bin/openvpn",
|
||||
"/usr/bin/openvpn",
|
||||
"C:\\Program Files\\OpenVPN\\bin\\openvpn.exe",
|
||||
"C:\\Program Files (x86)\\OpenVPN\\bin\\openvpn.exe",
|
||||
];
|
||||
|
||||
for loc in &locations {
|
||||
let path = PathBuf::from(loc);
|
||||
if path.exists() {
|
||||
return Ok(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
if let Ok(output) = Command::new("which").arg("openvpn").output() {
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
if let Ok(output) = Command::new("where")
|
||||
.arg("openvpn")
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let path = String::from_utf8_lossy(&output.stdout)
|
||||
.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !path.is_empty() {
|
||||
return Ok(PathBuf::from(path));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(VpnError::Connection(
|
||||
"OpenVPN binary not found. Please install OpenVPN.".to_string(),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
|
||||
let openvpn_bin = Self::find_openvpn_binary()?;
|
||||
|
||||
// Write config to temp file
|
||||
let config_path = std::env::temp_dir().join(format!("openvpn_{}.ovpn", config_id));
|
||||
std::fs::write(&config_path, &self.config.raw_config).map_err(VpnError::Io)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
// Find a management port
|
||||
let mgmt_listener = std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to bind management port: {e}")))?;
|
||||
let mgmt_port = mgmt_listener
|
||||
.local_addr()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get management port: {e}")))?
|
||||
.port();
|
||||
drop(mgmt_listener);
|
||||
|
||||
// Start OpenVPN with SOCKS proxy mode
|
||||
let mut cmd = Command::new(&openvpn_bin);
|
||||
cmd
|
||||
.arg("--config")
|
||||
.arg(&config_path)
|
||||
.arg("--management")
|
||||
.arg("127.0.0.1")
|
||||
.arg(mgmt_port.to_string())
|
||||
.arg("--socks-proxy")
|
||||
.arg("127.0.0.1")
|
||||
.arg(self.port.to_string())
|
||||
.arg("--verb")
|
||||
.arg("3")
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped());
|
||||
|
||||
let mut child = cmd
|
||||
.spawn()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to start OpenVPN: {e}")))?;
|
||||
|
||||
// Wait for OpenVPN to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => {
|
||||
let _ = std::fs::remove_file(&config_path);
|
||||
return Err(VpnError::Connection(format!(
|
||||
"OpenVPN exited early with status: {status}. OpenVPN requires elevated privileges (sudo/admin)."
|
||||
)));
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
let _ = std::fs::remove_file(&config_path);
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Failed to check OpenVPN status: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// Start a basic SOCKS5 proxy that tunnels through the OpenVPN TUN interface
|
||||
let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port))
|
||||
.await
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5: {e}")))?;
|
||||
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
|
||||
.port();
|
||||
|
||||
if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
|
||||
wc.local_port = Some(actual_port);
|
||||
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
|
||||
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[vpn-worker] OpenVPN SOCKS5 server listening on 127.0.0.1:{}",
|
||||
actual_port
|
||||
);
|
||||
|
||||
loop {
|
||||
match listener.accept().await {
|
||||
Ok((client, _)) => {
|
||||
tokio::spawn(Self::handle_socks5_client(client));
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("[vpn-worker] Accept error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_socks5_client(
|
||||
mut client: TcpStream,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// SOCKS5 greeting
|
||||
let mut buf = [0u8; 256];
|
||||
let n = client.read(&mut buf).await?;
|
||||
if n < 3 || buf[0] != 0x05 {
|
||||
return Ok(());
|
||||
}
|
||||
client.write_all(&[0x05, 0x00]).await?;
|
||||
|
||||
// SOCKS5 connect request
|
||||
let n = client.read(&mut buf).await?;
|
||||
if n < 10 || buf[0] != 0x05 || buf[1] != 0x01 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let dest_addr = match buf[3] {
|
||||
0x01 => {
|
||||
let ip = std::net::Ipv4Addr::new(buf[4], buf[5], buf[6], buf[7]);
|
||||
let port = u16::from_be_bytes([buf[8], buf[9]]);
|
||||
format!("{}:{}", ip, port)
|
||||
}
|
||||
0x03 => {
|
||||
let domain_len = buf[4] as usize;
|
||||
let domain = String::from_utf8_lossy(&buf[5..5 + domain_len]).to_string();
|
||||
let port_start = 5 + domain_len;
|
||||
let port = u16::from_be_bytes([buf[port_start], buf[port_start + 1]]);
|
||||
format!("{}:{}", domain, port)
|
||||
}
|
||||
_ => return Ok(()),
|
||||
};
|
||||
|
||||
// Connect to destination through OpenVPN tunnel (OS routing handles it)
|
||||
match TcpStream::connect(&dest_addr).await {
|
||||
Ok(upstream) => {
|
||||
client
|
||||
.write_all(&[0x05, 0x00, 0x00, 0x01, 127, 0, 0, 1, 0, 0])
|
||||
.await?;
|
||||
|
||||
let (mut cr, mut cw) = client.into_split();
|
||||
let (mut ur, mut uw) = upstream.into_split();
|
||||
|
||||
let c2u = tokio::io::copy(&mut cr, &mut uw);
|
||||
let u2c = tokio::io::copy(&mut ur, &mut cw);
|
||||
|
||||
let _ = tokio::try_join!(c2u, u2c);
|
||||
}
|
||||
Err(_) => {
|
||||
client
|
||||
.write_all(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0])
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_find_openvpn_binary_format() {
|
||||
let result = OpenVpnSocks5Server::find_openvpn_binary();
|
||||
match result {
|
||||
Ok(path) => assert!(!path.as_os_str().is_empty()),
|
||||
Err(e) => assert!(e.to_string().contains("not found")),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,656 @@
|
||||
use super::config::{VpnError, WireGuardConfig};
|
||||
use boringtun::noise::{Tunn, TunnResult};
|
||||
use boringtun::x25519::{PublicKey, StaticSecret};
|
||||
use smoltcp::iface::{Config as IfaceConfig, Interface, SocketHandle, SocketSet};
|
||||
use smoltcp::phy::{Device, DeviceCapabilities, Medium, RxToken, TxToken};
|
||||
use smoltcp::socket::tcp::{Socket as TcpSocket, SocketBuffer};
|
||||
use smoltcp::time::Instant as SmolInstant;
|
||||
use smoltcp::wire::{HardwareAddress, IpAddress, IpCidr, Ipv4Address};
|
||||
use std::collections::VecDeque;
|
||||
use std::net::{SocketAddr, ToSocketAddrs, UdpSocket};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
const SMOLTCP_TCP_RX_BUF: usize = 65536;
|
||||
const SMOLTCP_TCP_TX_BUF: usize = 65536;
|
||||
|
||||
struct WgDevice {
|
||||
tunn: Arc<Mutex<Box<Tunn>>>,
|
||||
udp_socket: Arc<UdpSocket>,
|
||||
peer_addr: SocketAddr,
|
||||
rx_queue: VecDeque<Vec<u8>>,
|
||||
tx_queue: VecDeque<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl WgDevice {
|
||||
fn pump_wg_to_rx(&mut self) {
|
||||
let mut recv_buf = vec![0u8; 2048];
|
||||
loop {
|
||||
match self.udp_socket.recv_from(&mut recv_buf) {
|
||||
Ok((len, _)) => {
|
||||
let mut dst = vec![0u8; 2048];
|
||||
let mut tunn = self.tunn.lock().unwrap();
|
||||
let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst);
|
||||
match result {
|
||||
TunnResult::WriteToTunnelV4(data, _) | TunnResult::WriteToTunnelV6(data, _) => {
|
||||
self.rx_queue.push_back(data.to_vec());
|
||||
}
|
||||
TunnResult::WriteToNetwork(response) => {
|
||||
let _ = self.udp_socket.send_to(response, self.peer_addr);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => break,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn flush_tx_queue(&mut self) {
|
||||
while let Some(ip_packet) = self.tx_queue.pop_front() {
|
||||
let mut dst = vec![0u8; ip_packet.len() + 256];
|
||||
let mut tunn = self.tunn.lock().unwrap();
|
||||
let result = tunn.encapsulate(&ip_packet, &mut dst);
|
||||
if let TunnResult::WriteToNetwork(packet) = result {
|
||||
let _ = self.udp_socket.send_to(packet, self.peer_addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn tick_timers(&mut self) {
|
||||
let mut dst = vec![0u8; 2048];
|
||||
let mut tunn = self.tunn.lock().unwrap();
|
||||
let result = tunn.update_timers(&mut dst);
|
||||
if let TunnResult::WriteToNetwork(packet) = result {
|
||||
let _ = self.udp_socket.send_to(packet, self.peer_addr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct WgRxToken {
|
||||
data: Vec<u8>,
|
||||
}
|
||||
|
||||
impl RxToken for WgRxToken {
|
||||
fn consume<R, F>(mut self, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut [u8]) -> R,
|
||||
{
|
||||
f(&mut self.data)
|
||||
}
|
||||
}
|
||||
|
||||
struct WgTxToken<'a> {
|
||||
tx_queue: &'a mut VecDeque<Vec<u8>>,
|
||||
}
|
||||
|
||||
impl<'a> TxToken for WgTxToken<'a> {
|
||||
fn consume<R, F>(self, len: usize, f: F) -> R
|
||||
where
|
||||
F: FnOnce(&mut [u8]) -> R,
|
||||
{
|
||||
let mut buf = vec![0u8; len];
|
||||
let result = f(&mut buf);
|
||||
self.tx_queue.push_back(buf);
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl Device for WgDevice {
|
||||
type RxToken<'a> = WgRxToken;
|
||||
type TxToken<'a> = WgTxToken<'a>;
|
||||
|
||||
fn receive(&mut self, _timestamp: SmolInstant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
|
||||
if let Some(data) = self.rx_queue.pop_front() {
|
||||
Some((
|
||||
WgRxToken { data },
|
||||
WgTxToken {
|
||||
tx_queue: &mut self.tx_queue,
|
||||
},
|
||||
))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn transmit(&mut self, _timestamp: SmolInstant) -> Option<Self::TxToken<'_>> {
|
||||
Some(WgTxToken {
|
||||
tx_queue: &mut self.tx_queue,
|
||||
})
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> DeviceCapabilities {
|
||||
let mut caps = DeviceCapabilities::default();
|
||||
caps.medium = Medium::Ip;
|
||||
caps.max_transmission_unit = 1420;
|
||||
caps
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_key(key: &str) -> Result<[u8; 32], VpnError> {
|
||||
let decoded = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, key)
|
||||
.map_err(|e| VpnError::InvalidWireGuard(format!("Invalid key encoding: {e}")))?;
|
||||
if decoded.len() != 32 {
|
||||
return Err(VpnError::InvalidWireGuard(format!(
|
||||
"Invalid key length: {} (expected 32)",
|
||||
decoded.len()
|
||||
)));
|
||||
}
|
||||
let mut key_bytes = [0u8; 32];
|
||||
key_bytes.copy_from_slice(&decoded);
|
||||
Ok(key_bytes)
|
||||
}
|
||||
|
||||
fn parse_cidr_address(addr: &str) -> Result<(IpCidr, IpAddress), VpnError> {
|
||||
let first_addr = addr.split(',').next().unwrap_or(addr).trim();
|
||||
|
||||
let parts: Vec<&str> = first_addr.split('/').collect();
|
||||
let ip_str = parts[0];
|
||||
let prefix = if parts.len() > 1 {
|
||||
parts[1]
|
||||
.parse::<u8>()
|
||||
.map_err(|_| VpnError::InvalidWireGuard(format!("Invalid prefix length: {}", parts[1])))?
|
||||
} else {
|
||||
32
|
||||
};
|
||||
|
||||
let ip: std::net::IpAddr = ip_str
|
||||
.parse()
|
||||
.map_err(|_| VpnError::InvalidWireGuard(format!("Invalid IP address: {ip_str}")))?;
|
||||
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let smol_ip = Ipv4Address::new(
|
||||
v4.octets()[0],
|
||||
v4.octets()[1],
|
||||
v4.octets()[2],
|
||||
v4.octets()[3],
|
||||
);
|
||||
Ok((
|
||||
IpCidr::new(IpAddress::Ipv4(smol_ip), prefix),
|
||||
IpAddress::Ipv4(smol_ip),
|
||||
))
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
let smol_ip = smoltcp::wire::Ipv6Address::from_bytes(&v6.octets());
|
||||
Ok((
|
||||
IpCidr::new(IpAddress::Ipv6(smol_ip), prefix),
|
||||
IpAddress::Ipv6(smol_ip),
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct WireGuardSocks5Server {
|
||||
config: WireGuardConfig,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl WireGuardSocks5Server {
|
||||
pub fn new(config: WireGuardConfig, port: u16) -> Self {
|
||||
Self { config, port }
|
||||
}
|
||||
|
||||
fn create_tunnel(&self) -> Result<Box<Tunn>, VpnError> {
|
||||
let private_key_bytes = parse_key(&self.config.private_key)?;
|
||||
let static_private = StaticSecret::from(private_key_bytes);
|
||||
|
||||
let peer_public_bytes = parse_key(&self.config.peer_public_key)?;
|
||||
let peer_public = PublicKey::from(peer_public_bytes);
|
||||
|
||||
let preshared_key = if let Some(ref psk) = self.config.preshared_key {
|
||||
Some(parse_key(psk)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(Box::new(Tunn::new(
|
||||
static_private,
|
||||
peer_public,
|
||||
preshared_key,
|
||||
self.config.persistent_keepalive,
|
||||
0,
|
||||
None,
|
||||
)))
|
||||
}
|
||||
|
||||
fn resolve_endpoint(&self) -> Result<SocketAddr, VpnError> {
|
||||
self
|
||||
.config
|
||||
.peer_endpoint
|
||||
.to_socket_addrs()
|
||||
.map_err(|e| {
|
||||
VpnError::Connection(format!(
|
||||
"Failed to resolve endpoint '{}': {e}",
|
||||
self.config.peer_endpoint
|
||||
))
|
||||
})?
|
||||
.next()
|
||||
.ok_or_else(|| {
|
||||
VpnError::Connection(format!(
|
||||
"No addresses found for endpoint: {}",
|
||||
self.config.peer_endpoint
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
fn do_handshake(
|
||||
tunn: &mut Tunn,
|
||||
socket: &UdpSocket,
|
||||
peer_addr: SocketAddr,
|
||||
) -> Result<(), VpnError> {
|
||||
let mut dst = vec![0u8; 2048];
|
||||
let result = tunn.format_handshake_initiation(&mut dst, false);
|
||||
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(packet) => {
|
||||
socket
|
||||
.send_to(packet, peer_addr)
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to send handshake: {e}")))?;
|
||||
}
|
||||
TunnResult::Err(e) => {
|
||||
return Err(VpnError::Tunnel(format!(
|
||||
"Handshake initiation failed: {e:?}"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
socket
|
||||
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to set timeout: {e}")))?;
|
||||
|
||||
let mut recv_buf = vec![0u8; 2048];
|
||||
match socket.recv_from(&mut recv_buf) {
|
||||
Ok((len, _)) => {
|
||||
let result = tunn.decapsulate(None, &recv_buf[..len], &mut dst);
|
||||
match result {
|
||||
TunnResult::WriteToNetwork(response) => {
|
||||
socket
|
||||
.send_to(response, peer_addr)
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to send response: {e}")))?;
|
||||
}
|
||||
TunnResult::Done => {}
|
||||
TunnResult::Err(e) => {
|
||||
return Err(VpnError::Tunnel(format!(
|
||||
"Handshake response failed: {e:?}"
|
||||
)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
return Err(VpnError::Connection(format!(
|
||||
"Handshake timeout or error: {e}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
socket
|
||||
.set_read_timeout(None)
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to clear timeout: {e}")))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run(self, config_id: String) -> Result<(), VpnError> {
|
||||
let peer_addr = self.resolve_endpoint()?;
|
||||
let mut tunn = self.create_tunnel()?;
|
||||
|
||||
let udp_socket = UdpSocket::bind("0.0.0.0:0")
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to create UDP socket: {e}")))?;
|
||||
|
||||
Self::do_handshake(&mut tunn, &udp_socket, peer_addr)?;
|
||||
|
||||
udp_socket
|
||||
.set_nonblocking(true)
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to set non-blocking: {e}")))?;
|
||||
|
||||
log::info!("[vpn-worker] WireGuard handshake completed");
|
||||
|
||||
let (cidr, local_ip) = parse_cidr_address(&self.config.address)?;
|
||||
|
||||
let tunn_arc = Arc::new(Mutex::new(tunn));
|
||||
let udp_arc = Arc::new(udp_socket);
|
||||
|
||||
let mut device = WgDevice {
|
||||
tunn: tunn_arc.clone(),
|
||||
udp_socket: udp_arc.clone(),
|
||||
peer_addr,
|
||||
rx_queue: VecDeque::new(),
|
||||
tx_queue: VecDeque::new(),
|
||||
};
|
||||
|
||||
let iface_config = IfaceConfig::new(HardwareAddress::Ip);
|
||||
let mut iface = Interface::new(iface_config, &mut device, SmolInstant::now());
|
||||
iface.update_ip_addrs(|addrs| {
|
||||
let _ = addrs.push(cidr);
|
||||
});
|
||||
|
||||
// Set default gateway
|
||||
match local_ip {
|
||||
IpAddress::Ipv4(v4) => {
|
||||
let octets = v4.as_bytes();
|
||||
let gw = Ipv4Address::new(octets[0], octets[1], octets[2], 1);
|
||||
iface
|
||||
.routes_mut()
|
||||
.add_default_ipv4_route(gw)
|
||||
.map_err(|e| VpnError::Tunnel(format!("Failed to add default route: {e}")))?;
|
||||
}
|
||||
IpAddress::Ipv6(_) => {
|
||||
// IPv6 routing not yet implemented
|
||||
}
|
||||
}
|
||||
|
||||
let listener = TcpListener::bind(format!("127.0.0.1:{}", self.port))
|
||||
.await
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to bind SOCKS5 listener: {e}")))?;
|
||||
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| VpnError::Connection(format!("Failed to get local addr: {e}")))?
|
||||
.port();
|
||||
|
||||
// Update config with actual port and local_url
|
||||
if let Some(mut wc) = crate::vpn_worker_storage::get_vpn_worker_config(&config_id) {
|
||||
wc.local_port = Some(actual_port);
|
||||
wc.local_url = Some(format!("socks5://127.0.0.1:{}", actual_port));
|
||||
let _ = crate::vpn_worker_storage::save_vpn_worker_config(&wc);
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"[vpn-worker] SOCKS5 server listening on 127.0.0.1:{}",
|
||||
actual_port
|
||||
);
|
||||
|
||||
let mut sockets = SocketSet::new(vec![]);
|
||||
|
||||
struct Connection {
|
||||
smol_handle: SocketHandle,
|
||||
tcp_stream: TcpStream,
|
||||
socks_done: bool,
|
||||
read_buf: Vec<u8>,
|
||||
dest_addr: Option<SocketAddr>,
|
||||
}
|
||||
|
||||
let mut connections: Vec<Connection> = Vec::new();
|
||||
let mut timer_counter: u64 = 0;
|
||||
|
||||
loop {
|
||||
// Accept new SOCKS5 connections (non-blocking via short timeout)
|
||||
if let Ok(Ok((stream, _addr))) =
|
||||
tokio::time::timeout(tokio::time::Duration::from_millis(1), listener.accept()).await
|
||||
{
|
||||
let tcp_rx = SocketBuffer::new(vec![0u8; SMOLTCP_TCP_RX_BUF]);
|
||||
let tcp_tx = SocketBuffer::new(vec![0u8; SMOLTCP_TCP_TX_BUF]);
|
||||
let tcp_socket = TcpSocket::new(tcp_rx, tcp_tx);
|
||||
let handle = sockets.add(tcp_socket);
|
||||
|
||||
connections.push(Connection {
|
||||
smol_handle: handle,
|
||||
tcp_stream: stream,
|
||||
socks_done: false,
|
||||
read_buf: Vec::new(),
|
||||
dest_addr: None,
|
||||
});
|
||||
}
|
||||
|
||||
// Pump WireGuard packets into smoltcp rx queue
|
||||
device.pump_wg_to_rx();
|
||||
|
||||
// Poll the smoltcp interface
|
||||
let timestamp = SmolInstant::now();
|
||||
let _changed = iface.poll(timestamp, &mut device, &mut sockets);
|
||||
|
||||
// Flush encrypted packets out through WireGuard
|
||||
device.flush_tx_queue();
|
||||
|
||||
// Process each connection
|
||||
let mut completed = Vec::new();
|
||||
for (idx, conn) in connections.iter_mut().enumerate() {
|
||||
if !conn.socks_done {
|
||||
// Handle SOCKS5 handshake
|
||||
let mut buf = [0u8; 512];
|
||||
match conn.tcp_stream.try_read(&mut buf) {
|
||||
Ok(0) => {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
Ok(n) => {
|
||||
conn.read_buf.extend_from_slice(&buf[..n]);
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(_) => {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if conn.dest_addr.is_none() && conn.read_buf.len() >= 3 {
|
||||
// SOCKS5 greeting: version, nmethods, methods
|
||||
if conn.read_buf[0] != 0x05 {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
// Reply: no auth required
|
||||
let _ = conn.tcp_stream.try_write(&[0x05, 0x00]);
|
||||
let nmethods = conn.read_buf[1] as usize;
|
||||
conn.read_buf.drain(..2 + nmethods);
|
||||
}
|
||||
|
||||
if conn.dest_addr.is_none() && conn.read_buf.len() >= 10 {
|
||||
// SOCKS5 connect request
|
||||
if conn.read_buf[0] != 0x05 || conn.read_buf[1] != 0x01 {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
|
||||
let (addr, addr_len) = match conn.read_buf[3] {
|
||||
0x01 => {
|
||||
// IPv4
|
||||
if conn.read_buf.len() < 10 {
|
||||
continue;
|
||||
}
|
||||
let ip = std::net::Ipv4Addr::new(
|
||||
conn.read_buf[4],
|
||||
conn.read_buf[5],
|
||||
conn.read_buf[6],
|
||||
conn.read_buf[7],
|
||||
);
|
||||
let port = u16::from_be_bytes([conn.read_buf[8], conn.read_buf[9]]);
|
||||
(SocketAddr::new(std::net::IpAddr::V4(ip), port), 10)
|
||||
}
|
||||
0x03 => {
|
||||
// Domain name
|
||||
let domain_len = conn.read_buf[4] as usize;
|
||||
let needed = 4 + 1 + domain_len + 2;
|
||||
if conn.read_buf.len() < needed {
|
||||
continue;
|
||||
}
|
||||
let domain = String::from_utf8_lossy(&conn.read_buf[5..5 + domain_len]).to_string();
|
||||
let port_start = 5 + domain_len;
|
||||
let port =
|
||||
u16::from_be_bytes([conn.read_buf[port_start], conn.read_buf[port_start + 1]]);
|
||||
// Resolve domain
|
||||
match format!("{}:{}", domain, port).to_socket_addrs() {
|
||||
Ok(mut addrs) => {
|
||||
if let Some(addr) = addrs.next() {
|
||||
(addr, needed)
|
||||
} else {
|
||||
// Send SOCKS5 error: host unreachable
|
||||
let _ = conn
|
||||
.tcp_stream
|
||||
.try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
let _ = conn
|
||||
.tcp_stream
|
||||
.try_write(&[0x05, 0x04, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
0x04 => {
|
||||
// IPv6
|
||||
if conn.read_buf.len() < 22 {
|
||||
continue;
|
||||
}
|
||||
let mut octets = [0u8; 16];
|
||||
octets.copy_from_slice(&conn.read_buf[4..20]);
|
||||
let ip = std::net::Ipv6Addr::from(octets);
|
||||
let port = u16::from_be_bytes([conn.read_buf[20], conn.read_buf[21]]);
|
||||
(SocketAddr::new(std::net::IpAddr::V6(ip), port), 22)
|
||||
}
|
||||
_ => {
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
conn.read_buf.drain(..addr_len);
|
||||
conn.dest_addr = Some(addr);
|
||||
|
||||
// Open smoltcp TCP socket to the destination
|
||||
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
|
||||
let smol_addr = match addr.ip() {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
let o = v4.octets();
|
||||
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()))
|
||||
}
|
||||
};
|
||||
|
||||
let local_port = 10000 + (rand::random::<u16>() % 50000);
|
||||
if socket
|
||||
.connect(iface.context(), (smol_addr, addr.port()), local_port)
|
||||
.is_err()
|
||||
{
|
||||
let _ = conn
|
||||
.tcp_stream
|
||||
.try_write(&[0x05, 0x05, 0x00, 0x01, 0, 0, 0, 0, 0, 0]);
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Send SOCKS5 success reply
|
||||
let _ = conn.tcp_stream.try_write(&[
|
||||
0x05,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
127,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
(actual_port >> 8) as u8,
|
||||
(actual_port & 0xff) as u8,
|
||||
]);
|
||||
conn.socks_done = true;
|
||||
}
|
||||
} else {
|
||||
// Data relay between SOCKS5 client and smoltcp socket
|
||||
let socket = sockets.get_mut::<TcpSocket>(conn.smol_handle);
|
||||
|
||||
// Client → smoltcp
|
||||
let mut buf = [0u8; 4096];
|
||||
match conn.tcp_stream.try_read(&mut buf) {
|
||||
Ok(0) => {
|
||||
socket.close();
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
Ok(n) => {
|
||||
if socket.can_send() {
|
||||
let _ = socket.send_slice(&buf[..n]);
|
||||
}
|
||||
}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {}
|
||||
Err(_) => {
|
||||
socket.close();
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// smoltcp → Client
|
||||
if socket.can_recv() {
|
||||
match socket.recv(|data| (data.len(), data.to_vec())) {
|
||||
Ok(data) if !data.is_empty() => {
|
||||
if conn.tcp_stream.try_write(&data).is_err() {
|
||||
socket.close();
|
||||
completed.push(idx);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if smoltcp socket closed
|
||||
if !socket.is_open() && !socket.is_active() {
|
||||
completed.push(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove completed connections (in reverse order)
|
||||
completed.sort_unstable();
|
||||
completed.dedup();
|
||||
for idx in completed.into_iter().rev() {
|
||||
let conn = connections.remove(idx);
|
||||
sockets.remove(conn.smol_handle);
|
||||
}
|
||||
|
||||
// Timer ticks for WireGuard keepalives
|
||||
timer_counter += 1;
|
||||
if timer_counter.is_multiple_of(500) {
|
||||
device.tick_timers();
|
||||
}
|
||||
|
||||
// Small sleep to avoid busy-spinning
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_cidr_ipv4() {
|
||||
let (cidr, ip) = parse_cidr_address("10.0.0.2/24").unwrap();
|
||||
assert_eq!(cidr.prefix_len(), 24);
|
||||
assert_eq!(ip, IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cidr_no_prefix() {
|
||||
let (cidr, _) = parse_cidr_address("10.0.0.2").unwrap();
|
||||
assert_eq!(cidr.prefix_len(), 32);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cidr_multi_address() {
|
||||
let (_, ip) = parse_cidr_address("10.0.0.2/24, fd00::2/128").unwrap();
|
||||
assert_eq!(ip, IpAddress::Ipv4(Ipv4Address::new(10, 0, 0, 2)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_valid() {
|
||||
let key = "YEocP0e2o1WT5GlvBvQzVF7EeR6z9aCk+ZdZ5NKEuXA=";
|
||||
assert!(parse_key(key).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_key_invalid() {
|
||||
assert!(parse_key("not-valid").is_err());
|
||||
}
|
||||
}
|
||||
+106
-13
@@ -32,6 +32,10 @@ struct StoredVpnConfig {
|
||||
nonce: String, // Base64 encoded nonce
|
||||
created_at: i64,
|
||||
last_used: Option<i64>,
|
||||
#[serde(default)]
|
||||
sync_enabled: bool,
|
||||
#[serde(default)]
|
||||
last_sync: Option<u64>,
|
||||
}
|
||||
|
||||
/// VPN storage manager with encryption
|
||||
@@ -93,22 +97,17 @@ impl VpnStorage {
|
||||
|
||||
/// Get the storage file path
|
||||
fn get_storage_path() -> PathBuf {
|
||||
let data_dir = directories::ProjectDirs::from("com", "donut", "donutbrowser")
|
||||
.map(|dirs| dirs.data_local_dir().to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
if !data_dir.exists() {
|
||||
let _ = fs::create_dir_all(&data_dir);
|
||||
let vpn_dir = crate::app_dirs::vpn_dir();
|
||||
if !vpn_dir.exists() {
|
||||
let _ = fs::create_dir_all(&vpn_dir);
|
||||
}
|
||||
|
||||
data_dir.join("vpn_configs.json")
|
||||
Self::migrate_from_old_location(&vpn_dir);
|
||||
vpn_dir.join("vpn_configs.json")
|
||||
}
|
||||
|
||||
/// Get or create the encryption key
|
||||
fn get_or_create_key() -> [u8; 32] {
|
||||
let key_path = directories::ProjectDirs::from("com", "donut", "donutbrowser")
|
||||
.map(|dirs| dirs.data_local_dir().join(".vpn_key"))
|
||||
.unwrap_or_else(|| PathBuf::from(".vpn_key"));
|
||||
let key_path = crate::app_dirs::vpn_dir().join(".vpn_key");
|
||||
|
||||
if key_path.exists() {
|
||||
if let Ok(key_data) = fs::read(&key_path) {
|
||||
@@ -134,6 +133,22 @@ impl VpnStorage {
|
||||
key
|
||||
}
|
||||
|
||||
/// Migrate VPN configs from the old ProjectDirs location to the new app_dirs location.
|
||||
fn migrate_from_old_location(new_dir: &std::path::Path) {
|
||||
let old_dir = match directories::ProjectDirs::from("com", "donut", "donutbrowser") {
|
||||
Some(dirs) => dirs.data_local_dir().to_path_buf(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
for filename in &["vpn_configs.json", ".vpn_key"] {
|
||||
let old_path = old_dir.join(filename);
|
||||
let new_path = new_dir.join(filename);
|
||||
if old_path.exists() && !new_path.exists() {
|
||||
let _ = fs::copy(&old_path, &new_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load storage data from disk
|
||||
fn load_storage(&self) -> Result<VpnStorageData, VpnError> {
|
||||
if !self.storage_path.exists() {
|
||||
@@ -220,6 +235,8 @@ impl VpnStorage {
|
||||
nonce,
|
||||
created_at: config.created_at,
|
||||
last_used: config.last_used,
|
||||
sync_enabled: config.sync_enabled,
|
||||
last_sync: config.last_sync,
|
||||
};
|
||||
|
||||
// Update existing or add new
|
||||
@@ -251,6 +268,8 @@ impl VpnStorage {
|
||||
config_data,
|
||||
created_at: stored.created_at,
|
||||
last_used: stored.last_used,
|
||||
sync_enabled: stored.sync_enabled,
|
||||
last_sync: stored.last_sync,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -269,6 +288,8 @@ impl VpnStorage {
|
||||
config_data: String::new(), // Don't include config data in list
|
||||
created_at: stored.created_at,
|
||||
last_used: stored.last_used,
|
||||
sync_enabled: stored.sync_enabled,
|
||||
last_sync: stored.last_sync,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
@@ -300,6 +321,68 @@ impl VpnStorage {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a VPN config manually from validated data
|
||||
pub fn create_config_manual(
|
||||
&self,
|
||||
name: &str,
|
||||
vpn_type: VpnType,
|
||||
config_data: &str,
|
||||
) -> Result<VpnConfig, VpnError> {
|
||||
// Validate the config by parsing it
|
||||
match vpn_type {
|
||||
VpnType::WireGuard => {
|
||||
super::parse_wireguard_config(config_data)?;
|
||||
}
|
||||
VpnType::OpenVPN => {
|
||||
super::parse_openvpn_config(config_data)?;
|
||||
}
|
||||
}
|
||||
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let sync_enabled = crate::sync::is_sync_configured();
|
||||
|
||||
let config = VpnConfig {
|
||||
id,
|
||||
name: name.to_string(),
|
||||
vpn_type,
|
||||
config_data: config_data.to_string(),
|
||||
created_at: Utc::now().timestamp(),
|
||||
last_used: None,
|
||||
sync_enabled,
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
self.save_config(&config)?;
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Update the name of an existing VPN config
|
||||
pub fn update_config_name(&self, id: &str, new_name: &str) -> Result<VpnConfig, VpnError> {
|
||||
let mut config = self.load_config(id)?;
|
||||
config.name = new_name.to_string();
|
||||
self.save_config(&config)?;
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Update sync fields on a VPN config
|
||||
pub fn update_sync_fields(
|
||||
&self,
|
||||
id: &str,
|
||||
sync_enabled: bool,
|
||||
last_sync: Option<u64>,
|
||||
) -> Result<(), VpnError> {
|
||||
let mut storage = self.load_storage()?;
|
||||
|
||||
if let Some(config) = storage.configs.iter_mut().find(|c| c.id == id) {
|
||||
config.sync_enabled = sync_enabled;
|
||||
config.last_sync = last_sync;
|
||||
self.save_storage(&storage)
|
||||
} else {
|
||||
Err(VpnError::NotFound(id.to_string()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a VPN config from raw content
|
||||
pub fn import_config(
|
||||
&self,
|
||||
@@ -325,6 +408,7 @@ impl VpnStorage {
|
||||
let base = filename.trim_end_matches(".conf").trim_end_matches(".ovpn");
|
||||
format!("{} ({})", base, vpn_type)
|
||||
});
|
||||
let sync_enabled = crate::sync::is_sync_configured();
|
||||
|
||||
let config = VpnConfig {
|
||||
id,
|
||||
@@ -333,6 +417,8 @@ impl VpnStorage {
|
||||
config_data: content.to_string(),
|
||||
created_at: Utc::now().timestamp(),
|
||||
last_used: None,
|
||||
sync_enabled,
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
self.save_config(&config)?;
|
||||
@@ -348,8 +434,7 @@ mod tests {
|
||||
|
||||
fn create_test_storage() -> (VpnStorage, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut storage = VpnStorage::new();
|
||||
storage.storage_path = temp_dir.path().join("test_vpn_configs.json");
|
||||
let storage = VpnStorage::with_dir(temp_dir.path());
|
||||
(storage, temp_dir)
|
||||
}
|
||||
|
||||
@@ -375,6 +460,8 @@ mod tests {
|
||||
config_data: "[Interface]\nPrivateKey = test\n[Peer]\nPublicKey = peer".to_string(),
|
||||
created_at: 1234567890,
|
||||
last_used: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
storage.save_config(&config).unwrap();
|
||||
@@ -397,6 +484,8 @@ mod tests {
|
||||
config_data: "secret1".to_string(),
|
||||
created_at: 1000,
|
||||
last_used: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
let config2 = VpnConfig {
|
||||
@@ -406,6 +495,8 @@ mod tests {
|
||||
config_data: "secret2".to_string(),
|
||||
created_at: 2000,
|
||||
last_used: Some(3000),
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
storage.save_config(&config1).unwrap();
|
||||
@@ -430,6 +521,8 @@ mod tests {
|
||||
config_data: "data".to_string(),
|
||||
created_at: 1000,
|
||||
last_used: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
storage.save_config(&config).unwrap();
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
use crate::proxy_storage::is_process_running;
|
||||
use crate::vpn_worker_storage::{
|
||||
delete_vpn_worker_config, find_vpn_worker_by_vpn_id, generate_vpn_worker_id,
|
||||
get_vpn_worker_config, list_vpn_worker_configs, save_vpn_worker_config, VpnWorkerConfig,
|
||||
};
|
||||
use std::process::Stdio;
|
||||
|
||||
pub async fn start_vpn_worker(vpn_id: &str) -> Result<VpnWorkerConfig, Box<dyn std::error::Error>> {
|
||||
// Check if a VPN worker for this vpn_id already exists and is running
|
||||
if let Some(existing) = find_vpn_worker_by_vpn_id(vpn_id) {
|
||||
if let Some(pid) = existing.pid {
|
||||
if is_process_running(pid) {
|
||||
return Ok(existing);
|
||||
}
|
||||
}
|
||||
// Worker config exists but process is dead, clean up
|
||||
delete_vpn_worker_config(&existing.id);
|
||||
}
|
||||
|
||||
// Load VPN config from storage to determine type
|
||||
let vpn_config = {
|
||||
let storage = crate::vpn::VPN_STORAGE
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock VPN storage: {e}"))?;
|
||||
storage
|
||||
.load_config(vpn_id)
|
||||
.map_err(|e| format!("Failed to load VPN config: {e}"))?
|
||||
};
|
||||
|
||||
let vpn_type_str = match vpn_config.vpn_type {
|
||||
crate::vpn::VpnType::WireGuard => "wireguard",
|
||||
crate::vpn::VpnType::OpenVPN => "openvpn",
|
||||
};
|
||||
|
||||
// Write decrypted config to a temp file
|
||||
let config_file_path = std::env::temp_dir()
|
||||
.join(format!("donut_vpn_{}.conf", vpn_id))
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
|
||||
std::fs::write(&config_file_path, &vpn_config.config_data)?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&config_file_path, std::fs::Permissions::from_mode(0o600));
|
||||
}
|
||||
|
||||
let id = generate_vpn_worker_id();
|
||||
|
||||
// Find an available port
|
||||
let local_port = {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||
listener.local_addr()?.port()
|
||||
};
|
||||
|
||||
let config = VpnWorkerConfig::new(
|
||||
id.clone(),
|
||||
vpn_id.to_string(),
|
||||
vpn_type_str.to_string(),
|
||||
config_file_path,
|
||||
);
|
||||
save_vpn_worker_config(&config)?;
|
||||
|
||||
// Spawn detached VPN worker process
|
||||
let exe = std::env::current_exe()?;
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
let mut cmd = StdCommand::new(&exe);
|
||||
cmd.arg("vpn-worker");
|
||||
cmd.arg("start");
|
||||
cmd.arg("--id");
|
||||
cmd.arg(&id);
|
||||
cmd.arg("--port");
|
||||
cmd.arg(local_port.to_string());
|
||||
|
||||
cmd.stdin(Stdio::null());
|
||||
cmd.stdout(Stdio::null());
|
||||
|
||||
let log_path = std::env::temp_dir().join(format!("donut-vpn-{}.log", id));
|
||||
if let Ok(file) = std::fs::File::create(&log_path) {
|
||||
log::info!("VPN worker stderr will be logged to: {:?}", log_path);
|
||||
cmd.stderr(Stdio::from(file));
|
||||
} else {
|
||||
cmd.stderr(Stdio::null());
|
||||
}
|
||||
|
||||
unsafe {
|
||||
cmd.pre_exec(|| {
|
||||
libc::setsid();
|
||||
if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 {
|
||||
let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
let pid = child.id();
|
||||
|
||||
let mut config_with_pid = config.clone();
|
||||
config_with_pid.pid = Some(pid);
|
||||
config_with_pid.local_port = Some(local_port);
|
||||
save_vpn_worker_config(&config_with_pid)?;
|
||||
|
||||
drop(child);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command as StdCommand;
|
||||
|
||||
let mut cmd = StdCommand::new(&exe);
|
||||
cmd.arg("vpn-worker");
|
||||
cmd.arg("start");
|
||||
cmd.arg("--id");
|
||||
cmd.arg(&id);
|
||||
cmd.arg("--port");
|
||||
cmd.arg(local_port.to_string());
|
||||
|
||||
cmd.stdin(Stdio::null());
|
||||
cmd.stdout(Stdio::null());
|
||||
|
||||
let log_path = std::env::temp_dir().join(format!("donut-vpn-{}.log", id));
|
||||
if let Ok(file) = std::fs::File::create(&log_path) {
|
||||
log::info!("VPN worker stderr will be logged to: {:?}", log_path);
|
||||
cmd.stderr(Stdio::from(file));
|
||||
} else {
|
||||
cmd.stderr(Stdio::null());
|
||||
}
|
||||
|
||||
const DETACHED_PROCESS: u32 = 0x00000008;
|
||||
const CREATE_NEW_PROCESS_GROUP: u32 = 0x00000200;
|
||||
cmd.creation_flags(DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP);
|
||||
|
||||
let child = cmd.spawn()?;
|
||||
let pid = child.id();
|
||||
|
||||
let mut config_with_pid = config.clone();
|
||||
config_with_pid.pid = Some(pid);
|
||||
config_with_pid.local_port = Some(local_port);
|
||||
save_vpn_worker_config(&config_with_pid)?;
|
||||
|
||||
drop(child);
|
||||
}
|
||||
|
||||
// Wait for the worker to update config with local_url
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
let mut attempts = 0;
|
||||
let max_attempts = 100; // 10 seconds max
|
||||
|
||||
loop {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
|
||||
if let Some(updated_config) = get_vpn_worker_config(&id) {
|
||||
if let Some(ref local_url) = updated_config.local_url {
|
||||
if !local_url.is_empty() {
|
||||
if let Some(port) = updated_config.local_port {
|
||||
if let Ok(Ok(_)) = tokio::time::timeout(
|
||||
tokio::time::Duration::from_millis(100),
|
||||
tokio::net::TcpStream::connect(("127.0.0.1", port)),
|
||||
)
|
||||
.await
|
||||
{
|
||||
return Ok(updated_config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
attempts += 1;
|
||||
if attempts >= max_attempts {
|
||||
if let Some(config) = get_vpn_worker_config(&id) {
|
||||
let process_running = config.pid.map(is_process_running).unwrap_or(false);
|
||||
// Clean up on failure
|
||||
delete_vpn_worker_config(&id);
|
||||
return Err(
|
||||
format!(
|
||||
"VPN worker failed to start in time. pid={:?}, process_running={}, local_url={:?}",
|
||||
config.pid, process_running, config.local_url
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
delete_vpn_worker_config(&id);
|
||||
return Err("VPN worker config not found after spawn".into());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop_vpn_worker(id: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let config = get_vpn_worker_config(id);
|
||||
|
||||
if let Some(config) = config {
|
||||
if let Some(pid) = config.pid {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::process::Command;
|
||||
let _ = Command::new("kill")
|
||||
.arg("-TERM")
|
||||
.arg(pid.to_string())
|
||||
.output();
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::process::Command;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/F", "/PID", &pid.to_string()])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// Clean up temp config file
|
||||
let _ = std::fs::remove_file(&config.config_file_path);
|
||||
|
||||
delete_vpn_worker_config(id);
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn stop_vpn_worker_by_vpn_id(vpn_id: &str) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
if let Some(config) = find_vpn_worker_by_vpn_id(vpn_id) {
|
||||
return stop_vpn_worker(&config.id).await;
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
pub async fn stop_all_vpn_workers() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let configs = list_vpn_worker_configs();
|
||||
for config in configs {
|
||||
let _ = stop_vpn_worker(&config.id).await;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
use crate::proxy_storage::get_storage_dir;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct VpnWorkerConfig {
|
||||
pub id: String,
|
||||
pub vpn_id: String,
|
||||
pub vpn_type: String,
|
||||
pub config_file_path: String,
|
||||
pub local_port: Option<u16>,
|
||||
pub local_url: Option<String>,
|
||||
pub pid: Option<u32>,
|
||||
}
|
||||
|
||||
impl VpnWorkerConfig {
|
||||
pub fn new(id: String, vpn_id: String, vpn_type: String, config_file_path: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
vpn_id,
|
||||
vpn_type,
|
||||
config_file_path,
|
||||
local_port: None,
|
||||
local_url: None,
|
||||
pid: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn save_vpn_worker_config(config: &VpnWorkerConfig) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let storage_dir = get_storage_dir();
|
||||
fs::create_dir_all(&storage_dir)?;
|
||||
|
||||
let file_path = storage_dir.join(format!("vpn_worker_{}.json", config.id));
|
||||
let content = serde_json::to_string_pretty(config)?;
|
||||
fs::write(&file_path, content)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_vpn_worker_config(id: &str) -> Option<VpnWorkerConfig> {
|
||||
let storage_dir = get_storage_dir();
|
||||
let file_path = storage_dir.join(format!("vpn_worker_{}.json", id));
|
||||
|
||||
if !file_path.exists() {
|
||||
return None;
|
||||
}
|
||||
|
||||
match fs::read_to_string(&file_path) {
|
||||
Ok(content) => serde_json::from_str(&content).ok(),
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn delete_vpn_worker_config(id: &str) -> bool {
|
||||
let storage_dir = get_storage_dir();
|
||||
let file_path = storage_dir.join(format!("vpn_worker_{}.json", id));
|
||||
|
||||
if !file_path.exists() {
|
||||
return false;
|
||||
}
|
||||
|
||||
fs::remove_file(&file_path).is_ok()
|
||||
}
|
||||
|
||||
pub fn list_vpn_worker_configs() -> Vec<VpnWorkerConfig> {
|
||||
let storage_dir = get_storage_dir();
|
||||
|
||||
if !storage_dir.exists() {
|
||||
return Vec::new();
|
||||
}
|
||||
|
||||
let mut configs = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&storage_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("vpn_worker_") && name.ends_with(".json") {
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
if let Ok(config) = serde_json::from_str::<VpnWorkerConfig>(&content) {
|
||||
configs.push(config);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
configs
|
||||
}
|
||||
|
||||
pub fn find_vpn_worker_by_vpn_id(vpn_id: &str) -> Option<VpnWorkerConfig> {
|
||||
list_vpn_worker_configs()
|
||||
.into_iter()
|
||||
.find(|c| c.vpn_id == vpn_id)
|
||||
}
|
||||
|
||||
pub fn generate_vpn_worker_id() -> String {
|
||||
format!(
|
||||
"vpnw_{}_{}",
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs(),
|
||||
rand::random::<u32>()
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
use crate::profile::BrowserProfile;
|
||||
use directories::BaseDirs;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -72,8 +71,6 @@ struct WayfernManagerInner {
|
||||
|
||||
pub struct WayfernManager {
|
||||
inner: Arc<AsyncMutex<WayfernManagerInner>>,
|
||||
#[allow(dead_code)]
|
||||
base_dirs: BaseDirs,
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
@@ -91,7 +88,6 @@ impl WayfernManager {
|
||||
inner: Arc::new(AsyncMutex::new(WayfernManagerInner {
|
||||
instances: HashMap::new(),
|
||||
})),
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
http_client: Client::new(),
|
||||
}
|
||||
}
|
||||
@@ -102,26 +98,12 @@ impl WayfernManager {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_profiles_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("profiles");
|
||||
path
|
||||
crate::app_dirs::profiles_dir()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn get_binaries_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
async fn find_free_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -231,7 +213,15 @@ impl WayfernManager {
|
||||
config: &WayfernConfig,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
PathBuf::from(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)
|
||||
@@ -255,6 +245,9 @@ impl WayfernManager {
|
||||
.arg("--no-first-run")
|
||||
.arg("--no-default-browser-check")
|
||||
.arg("--disable-background-mode")
|
||||
.arg("--use-mock-keychain")
|
||||
.arg("--password-store=basic")
|
||||
.arg("--disable-features=DialMediaRouteProvider")
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
|
||||
@@ -271,8 +264,11 @@ impl WayfernManager {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/PID", &id.to_string(), "/F"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
}
|
||||
}
|
||||
@@ -390,6 +386,7 @@ impl WayfernManager {
|
||||
Ok(fingerprint_json)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn launch_wayfern(
|
||||
&self,
|
||||
_app_handle: &AppHandle,
|
||||
@@ -398,9 +395,18 @@ impl WayfernManager {
|
||||
config: &WayfernConfig,
|
||||
url: Option<&str>,
|
||||
proxy_url: Option<&str>,
|
||||
ephemeral: bool,
|
||||
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
PathBuf::from(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)
|
||||
@@ -434,6 +440,14 @@ impl WayfernManager {
|
||||
args.push(format!("--proxy-server={proxy}"));
|
||||
}
|
||||
|
||||
if ephemeral {
|
||||
args.push("--disk-cache-size=1".to_string());
|
||||
args.push("--disable-breakpad".to_string());
|
||||
args.push("--disable-crash-reporter".to_string());
|
||||
args.push("--no-service-autorun".to_string());
|
||||
args.push("--disable-sync".to_string());
|
||||
}
|
||||
|
||||
// Don't add URL to args - we'll navigate via CDP after setting fingerprint
|
||||
// This ensures fingerprint is applied at navigation commit time
|
||||
|
||||
@@ -586,8 +600,11 @@ impl WayfernManager {
|
||||
}
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let _ = std::process::Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.creation_flags(CREATE_NO_WINDOW)
|
||||
.output();
|
||||
}
|
||||
log::info!("Stopped Wayfern instance {id} (PID: {pid})");
|
||||
@@ -641,11 +658,19 @@ impl WayfernManager {
|
||||
|
||||
let mut inner = self.inner.lock().await;
|
||||
|
||||
// Canonicalize the target path for comparison
|
||||
let target_path = std::path::Path::new(profile_path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
||||
|
||||
// Find the instance with the matching profile path
|
||||
let mut found_id: Option<String> = None;
|
||||
for (id, instance) in &inner.instances {
|
||||
if let Some(path) = &instance.profile_path {
|
||||
if path == 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 {
|
||||
found_id = Some(id.clone());
|
||||
break;
|
||||
}
|
||||
@@ -662,7 +687,6 @@ impl WayfernManager {
|
||||
let sysinfo_pid = sysinfo::Pid::from_u32(pid);
|
||||
|
||||
if system.process(sysinfo_pid).is_some() {
|
||||
// Process is still running
|
||||
return Some(WayfernLaunchResult {
|
||||
id: id.clone(),
|
||||
processId: instance.process_id,
|
||||
@@ -671,7 +695,6 @@ impl WayfernManager {
|
||||
cdp_port: instance.cdp_port,
|
||||
});
|
||||
} else {
|
||||
// Process has died (e.g., Cmd+Q), remove from instances
|
||||
log::info!(
|
||||
"Wayfern process {} for profile {} is no longer running, cleaning up",
|
||||
pid,
|
||||
@@ -684,6 +707,101 @@ impl WayfernManager {
|
||||
}
|
||||
}
|
||||
|
||||
// If not found in in-memory instances, scan system processes.
|
||||
// This handles the case where the GUI was restarted but Wayfern is still running.
|
||||
if let Some((pid, found_profile_path, cdp_port)) =
|
||||
Self::find_wayfern_process_by_profile(&target_path)
|
||||
{
|
||||
log::info!(
|
||||
"Found running Wayfern process (PID: {}) for profile path via system scan",
|
||||
pid
|
||||
);
|
||||
|
||||
let instance_id = format!("recovered_{}", pid);
|
||||
inner.instances.insert(
|
||||
instance_id.clone(),
|
||||
WayfernInstance {
|
||||
id: instance_id.clone(),
|
||||
process_id: Some(pid),
|
||||
profile_path: Some(found_profile_path.clone()),
|
||||
url: None,
|
||||
cdp_port,
|
||||
},
|
||||
);
|
||||
|
||||
return Some(WayfernLaunchResult {
|
||||
id: instance_id,
|
||||
processId: Some(pid),
|
||||
profilePath: Some(found_profile_path),
|
||||
url: None,
|
||||
cdp_port,
|
||||
});
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
/// Scan system processes to find a Wayfern/Chromium process using a specific profile path
|
||||
fn find_wayfern_process_by_profile(
|
||||
target_path: &std::path::Path,
|
||||
) -> Option<(u32, String, Option<u16>)> {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
|
||||
let target_path_str = target_path.to_string_lossy();
|
||||
|
||||
for (pid, process) in system.processes() {
|
||||
let cmd = process.cmd();
|
||||
if cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let exe_name = process.name().to_string_lossy().to_lowercase();
|
||||
let is_chromium_like = exe_name.contains("wayfern")
|
||||
|| exe_name.contains("chromium")
|
||||
|| exe_name.contains("chrome");
|
||||
|
||||
if !is_chromium_like {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip child processes (renderer, GPU, utility, zygote, etc.)
|
||||
// Only the main browser process lacks a --type= argument
|
||||
let is_child = cmd
|
||||
.iter()
|
||||
.any(|a| a.to_str().is_some_and(|s| s.starts_with("--type=")));
|
||||
if is_child {
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut matched = false;
|
||||
let mut cdp_port: Option<u16> = None;
|
||||
|
||||
for arg in cmd.iter() {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
if let Some(dir_val) = arg_str.strip_prefix("--user-data-dir=") {
|
||||
let cmd_path = std::path::Path::new(dir_val)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(dir_val).to_path_buf());
|
||||
if cmd_path == target_path {
|
||||
matched = true;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(port_val) = arg_str.strip_prefix("--remote-debugging-port=") {
|
||||
cdp_port = port_val.parse().ok();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if matched {
|
||||
return Some((pid.as_u32(), target_path_str.to_string(), cdp_port));
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
@@ -715,6 +833,7 @@ impl WayfernManager {
|
||||
config,
|
||||
url,
|
||||
proxy_url,
|
||||
profile.ephemeral,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.14.2",
|
||||
"version": "0.15.0",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
@@ -32,7 +32,7 @@
|
||||
"frameworks": [],
|
||||
"minimumSystemVersion": "10.13",
|
||||
"exceptionDomain": "",
|
||||
"signingIdentity": "-",
|
||||
"signingIdentity": null,
|
||||
"providerShortName": null,
|
||||
"entitlements": "entitlements.plist",
|
||||
"files": {
|
||||
|
||||
@@ -516,13 +516,7 @@ async fn test_traffic_tracking() -> Result<(), Box<dyn std::error::Error + Send
|
||||
// Wait for traffic stats to be flushed (happens every second)
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Verify traffic was tracked by checking traffic stats file exists
|
||||
// Note: Traffic stats are stored in the cache directory
|
||||
let cache_dir = directories::BaseDirs::new()
|
||||
.expect("Failed to get base directories")
|
||||
.cache_dir()
|
||||
.to_path_buf();
|
||||
let traffic_stats_dir = cache_dir.join("DonutBrowserDev").join("traffic_stats");
|
||||
let traffic_stats_dir = donutbrowser_lib::app_dirs::cache_dir().join("traffic_stats");
|
||||
let stats_file = traffic_stats_dir.join(format!("{}.json", proxy_id));
|
||||
|
||||
if stats_file.exists() {
|
||||
|
||||
@@ -188,6 +188,8 @@ fn test_vpn_storage_save_and_load() {
|
||||
config_data: "[Interface]\nPrivateKey=key\n[Peer]\nPublicKey=peer".to_string(),
|
||||
created_at: 1234567890,
|
||||
last_used: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
let save_result = storage.save_config(&config);
|
||||
@@ -230,6 +232,8 @@ fn test_vpn_storage_list() {
|
||||
config_data: "secret data".to_string(),
|
||||
created_at: 1000 * i as i64,
|
||||
last_used: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
};
|
||||
storage.save_config(&config).unwrap();
|
||||
}
|
||||
@@ -256,6 +260,8 @@ fn test_vpn_storage_delete() {
|
||||
config_data: "data".to_string(),
|
||||
created_at: 1000,
|
||||
last_used: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
};
|
||||
|
||||
storage.save_config(&config).unwrap();
|
||||
|
||||
+174
-16
@@ -3,10 +3,11 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieManagementDialog } from "@/components/cookie-management-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
||||
@@ -23,8 +24,10 @@ import { ProfileSyncDialog } from "@/components/profile-sync-dialog";
|
||||
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
|
||||
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 { WayfernTermsDialog } from "@/components/wayfern-terms-dialog";
|
||||
import { WindowResizeWarningDialog } from "@/components/window-resize-warning-dialog";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
|
||||
@@ -35,9 +38,20 @@ import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { useWayfernTerms } from "@/hooks/use-wayfern-terms";
|
||||
import { showErrorToast, showSuccessToast, showToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, CamoufoxConfig, WayfernConfig } from "@/types";
|
||||
import {
|
||||
dismissToast,
|
||||
showErrorToast,
|
||||
showSuccessToast,
|
||||
showToast,
|
||||
} from "@/lib/toast-utils";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
CamoufoxConfig,
|
||||
SyncSettings,
|
||||
WayfernConfig,
|
||||
} from "@/types";
|
||||
|
||||
type BrowserTypeString =
|
||||
| "firefox"
|
||||
@@ -77,6 +91,8 @@ export default function Home() {
|
||||
error: proxiesError,
|
||||
} = useProxyEvents();
|
||||
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
|
||||
// Wayfern terms and commercial trial hooks
|
||||
const {
|
||||
termsAccepted,
|
||||
@@ -92,7 +108,26 @@ export default function Home() {
|
||||
// Cloud auth for cross-OS unlock
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const crossOsUnlocked =
|
||||
cloudUser?.plan !== "free" && cloudUser?.subscriptionStatus === "active";
|
||||
cloudUser?.plan !== "free" &&
|
||||
(cloudUser?.subscriptionStatus === "active" ||
|
||||
cloudUser?.planPeriod === "lifetime");
|
||||
|
||||
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
||||
useState(false);
|
||||
|
||||
const checkSelfHostedSync = useCallback(async () => {
|
||||
try {
|
||||
const settings = await invoke<SyncSettings>("get_sync_settings");
|
||||
const hasConfig = Boolean(
|
||||
settings.sync_server_url && settings.sync_token,
|
||||
);
|
||||
setSelfHostedSyncConfigured(hasConfig && !cloudUser);
|
||||
} catch {
|
||||
setSelfHostedSyncConfigured(false);
|
||||
}
|
||||
}, [cloudUser]);
|
||||
|
||||
const syncUnlocked = crossOsUnlocked || selfHostedSyncConfigured;
|
||||
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
@@ -109,6 +144,12 @@ export default function Home() {
|
||||
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
|
||||
useState(false);
|
||||
const [cookieCopyDialogOpen, setCookieCopyDialogOpen] = useState(false);
|
||||
const [cookieManagementDialogOpen, setCookieManagementDialogOpen] =
|
||||
useState(false);
|
||||
const [
|
||||
currentProfileForCookieManagement,
|
||||
setCurrentProfileForCookieManagement,
|
||||
] = useState<BrowserProfile | null>(null);
|
||||
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
@@ -126,6 +167,10 @@ export default function Home() {
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
||||
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
|
||||
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
|
||||
const windowResizeWarningResolver = useRef<
|
||||
((proceed: boolean) => void) | null
|
||||
>(null);
|
||||
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
|
||||
const [currentPermissionType, setCurrentPermissionType] =
|
||||
useState<PermissionType>("microphone");
|
||||
@@ -133,12 +178,15 @@ export default function Home() {
|
||||
useState(false);
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const [syncConfigDialogOpen, setSyncConfigDialogOpen] = useState(false);
|
||||
const [syncAllDialogOpen, setSyncAllDialogOpen] = useState(false);
|
||||
const [profileSyncDialogOpen, setProfileSyncDialogOpen] = useState(false);
|
||||
const [currentProfileForSync, setCurrentProfileForSync] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
const userInitiatedSyncIds = useRef<Set<string>>(new Set());
|
||||
|
||||
const handleSelectGroup = useCallback((groupId: string) => {
|
||||
setSelectedGroupId(groupId);
|
||||
setSelectedProfiles([]);
|
||||
@@ -446,9 +494,11 @@ export default function Home() {
|
||||
version: string;
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
vpnId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
ephemeral?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
await invoke<BrowserProfile>("create_browser_profile_new", {
|
||||
@@ -457,11 +507,13 @@ export default function Home() {
|
||||
version: profileData.version,
|
||||
releaseType: profileData.releaseType,
|
||||
proxyId: profileData.proxyId,
|
||||
vpnId: profileData.vpnId,
|
||||
camoufoxConfig: profileData.camoufoxConfig,
|
||||
wayfernConfig: profileData.wayfernConfig,
|
||||
groupId:
|
||||
profileData.groupId ||
|
||||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
|
||||
ephemeral: profileData.ephemeral,
|
||||
});
|
||||
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
@@ -480,6 +532,26 @@ export default function Home() {
|
||||
const launchProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
console.log("Starting launch for profile:", profile.name);
|
||||
|
||||
// Show one-time warning about window resizing for fingerprinted browsers
|
||||
if (profile.browser === "camoufox" || profile.browser === "wayfern") {
|
||||
try {
|
||||
const dismissed = await invoke<boolean>(
|
||||
"get_window_resize_warning_dismissed",
|
||||
);
|
||||
if (!dismissed) {
|
||||
const proceed = await new Promise<boolean>((resolve) => {
|
||||
windowResizeWarningResolver.current = resolve;
|
||||
setWindowResizeWarningOpen(true);
|
||||
});
|
||||
if (!proceed) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check window resize warning:", error);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await invoke<BrowserProfile>("launch_browser_profile", {
|
||||
profile,
|
||||
@@ -489,7 +561,6 @@ export default function Home() {
|
||||
console.error("Failed to launch browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to launch browser: ${errorMessage}`);
|
||||
// Re-throw the error so the table component can handle loading state cleanup
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
@@ -650,6 +721,11 @@ export default function Home() {
|
||||
setCookieCopyDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleOpenCookieManagement = useCallback((profile: BrowserProfile) => {
|
||||
setCurrentProfileForCookieManagement(profile);
|
||||
setCookieManagementDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleGroupAssignmentComplete = useCallback(async () => {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setGroupAssignmentDialogOpen(false);
|
||||
@@ -674,18 +750,19 @@ export default function Home() {
|
||||
const handleToggleProfileSync = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
try {
|
||||
await invoke("set_profile_sync_enabled", {
|
||||
const enabling = !profile.sync_mode || profile.sync_mode === "Disabled";
|
||||
await invoke("set_profile_sync_mode", {
|
||||
profileId: profile.id,
|
||||
enabled: !profile.sync_enabled,
|
||||
syncMode: enabling ? "Regular" : "Disabled",
|
||||
});
|
||||
if (enabling) {
|
||||
userInitiatedSyncIds.current.add(profile.id);
|
||||
}
|
||||
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", {
|
||||
description: enabling
|
||||
? "Profile sync has been enabled"
|
||||
: "Profile sync has been disabled",
|
||||
});
|
||||
showSuccessToast(
|
||||
profile.sync_enabled ? "Sync disabled" : "Sync enabled",
|
||||
{
|
||||
description: profile.sync_enabled
|
||||
? "Profile sync has been disabled"
|
||||
: "Profile sync has been enabled",
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast("Failed to update sync settings");
|
||||
@@ -694,6 +771,50 @@ export default function Home() {
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
unlisten = await listen<{
|
||||
profile_id: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>("profile-sync-status", (event) => {
|
||||
const { profile_id, status, error } = event.payload;
|
||||
if (!userInitiatedSyncIds.current.has(profile_id)) return;
|
||||
|
||||
const toastId = `sync-${profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === profile_id);
|
||||
const name = profile?.name ?? "Unknown";
|
||||
|
||||
if (status === "syncing") {
|
||||
showToast({
|
||||
type: "loading",
|
||||
title: `Syncing profile '${name}'...`,
|
||||
id: toastId,
|
||||
duration: 30000,
|
||||
});
|
||||
} else if (status === "synced") {
|
||||
dismissToast(toastId);
|
||||
showSuccessToast(`Profile '${name}' synced successfully`);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
} else if (status === "error") {
|
||||
dismissToast(toastId);
|
||||
showErrorToast(
|
||||
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
|
||||
);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync status events:", error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [profiles]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for startup default browser prompt
|
||||
void checkStartupPrompt();
|
||||
@@ -837,6 +958,11 @@ export default function Home() {
|
||||
}
|
||||
}, [isInitialized, checkAllPermissions]);
|
||||
|
||||
// Check self-hosted sync config on mount and when cloud user changes
|
||||
useEffect(() => {
|
||||
void checkSelfHostedSync();
|
||||
}, [checkSelfHostedSync]);
|
||||
|
||||
// Filter data by selected group and search query
|
||||
const filteredProfiles = useMemo(() => {
|
||||
let filtered = profiles;
|
||||
@@ -906,6 +1032,7 @@ export default function Home() {
|
||||
onRenameProfile={handleRenameProfile}
|
||||
onConfigureCamoufox={handleConfigureCamoufox}
|
||||
onCopyCookiesToProfile={handleCopyCookiesToProfile}
|
||||
onOpenCookieManagement={handleOpenCookieManagement}
|
||||
runningProfiles={runningProfiles}
|
||||
isUpdating={isUpdating}
|
||||
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
|
||||
@@ -920,6 +1047,7 @@ export default function Home() {
|
||||
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
|
||||
onToggleProfileSync={handleToggleProfileSync}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
syncUnlocked={syncUnlocked}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
@@ -1033,6 +1161,7 @@ export default function Home() {
|
||||
onAssignmentComplete={handleProxyAssignmentComplete}
|
||||
profiles={profiles}
|
||||
storedProxies={storedProxies}
|
||||
vpnConfigs={vpnConfigs}
|
||||
/>
|
||||
|
||||
<CookieCopyDialog
|
||||
@@ -1047,6 +1176,15 @@ export default function Home() {
|
||||
onCopyComplete={() => setSelectedProfilesForCookies([])}
|
||||
/>
|
||||
|
||||
<CookieManagementDialog
|
||||
isOpen={cookieManagementDialogOpen}
|
||||
onClose={() => {
|
||||
setCookieManagementDialogOpen(false);
|
||||
setCurrentProfileForCookieManagement(null);
|
||||
}}
|
||||
profile={currentProfileForCookieManagement}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => setShowBulkDeleteConfirmation(false)}
|
||||
@@ -1061,7 +1199,18 @@ export default function Home() {
|
||||
|
||||
<SyncConfigDialog
|
||||
isOpen={syncConfigDialogOpen}
|
||||
onClose={() => setSyncConfigDialogOpen(false)}
|
||||
onClose={(loginOccurred) => {
|
||||
setSyncConfigDialogOpen(false);
|
||||
void checkSelfHostedSync();
|
||||
if (loginOccurred) {
|
||||
setSyncAllDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<SyncAllDialog
|
||||
isOpen={syncAllDialogOpen}
|
||||
onClose={() => setSyncAllDialogOpen(false)}
|
||||
/>
|
||||
|
||||
<ProfileSyncDialog
|
||||
@@ -1096,6 +1245,15 @@ export default function Home() {
|
||||
isOpen={launchOnLoginDialogOpen}
|
||||
onClose={() => setLaunchOnLoginDialogOpen(false)}
|
||||
/>
|
||||
|
||||
<WindowResizeWarningDialog
|
||||
isOpen={windowResizeWarningOpen}
|
||||
onResult={(proceed) => {
|
||||
setWindowResizeWarningOpen(false);
|
||||
windowResizeWarningResolver.current?.(proceed);
|
||||
windowResizeWarningResolver.current = null;
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,69 +1,24 @@
|
||||
"use client";
|
||||
|
||||
import { FaDownload, FaExternalLinkAlt, FaTimes } from "react-icons/fa";
|
||||
import { LuCheckCheck, LuCog, LuRefreshCw } from "react-icons/lu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FaExternalLinkAlt, FaTimes } from "react-icons/fa";
|
||||
import { LuCheckCheck } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
|
||||
import type { AppUpdateInfo } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface AppUpdateToastProps {
|
||||
updateInfo: AppUpdateInfo;
|
||||
onUpdate: (updateInfo: AppUpdateInfo) => Promise<void>;
|
||||
onRestart: () => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
isUpdating?: boolean;
|
||||
updateProgress?: AppUpdateProgress | null;
|
||||
updateReady?: boolean;
|
||||
}
|
||||
|
||||
function getStageIcon(stage?: string, isUpdating?: boolean) {
|
||||
if (!isUpdating) {
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5" />;
|
||||
}
|
||||
|
||||
switch (stage) {
|
||||
case "downloading":
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5" />;
|
||||
case "extracting":
|
||||
return <LuRefreshCw className="flex-shrink-0 w-5 h-5 animate-spin" />;
|
||||
case "installing":
|
||||
return <LuCog className="flex-shrink-0 w-5 h-5 animate-spin" />;
|
||||
case "completed":
|
||||
return <LuCheckCheck className="flex-shrink-0 w-5 h-5" />;
|
||||
default:
|
||||
return <LuRefreshCw className="flex-shrink-0 w-5 h-5 animate-spin" />;
|
||||
}
|
||||
}
|
||||
|
||||
function getStageDisplayName(stage?: string) {
|
||||
switch (stage) {
|
||||
case "downloading":
|
||||
return "Downloading";
|
||||
case "extracting":
|
||||
return "Extracting";
|
||||
case "installing":
|
||||
return "Installing";
|
||||
case "completed":
|
||||
return "Completed";
|
||||
default:
|
||||
return "Updating";
|
||||
}
|
||||
}
|
||||
|
||||
export function AppUpdateToast({
|
||||
updateInfo,
|
||||
onUpdate,
|
||||
onRestart,
|
||||
onDismiss,
|
||||
isUpdating = false,
|
||||
updateProgress,
|
||||
updateReady = false,
|
||||
}: AppUpdateToastProps) {
|
||||
const handleUpdateClick = async () => {
|
||||
await onUpdate(updateInfo);
|
||||
};
|
||||
|
||||
const handleRestartClick = async () => {
|
||||
await onRestart();
|
||||
};
|
||||
@@ -77,115 +32,37 @@ export function AppUpdateToast({
|
||||
}
|
||||
};
|
||||
|
||||
const showDownloadProgress =
|
||||
isUpdating &&
|
||||
updateProgress?.stage === "downloading" &&
|
||||
updateProgress.percentage !== undefined;
|
||||
|
||||
const showOtherStageProgress =
|
||||
isUpdating &&
|
||||
updateProgress &&
|
||||
(updateProgress.stage === "extracting" ||
|
||||
updateProgress.stage === "installing" ||
|
||||
updateProgress.stage === "completed");
|
||||
|
||||
return (
|
||||
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="mr-3 mt-0.5">
|
||||
{updateReady ? (
|
||||
<LuCheckCheck className="flex-shrink-0 w-5 h-5 text-green-500" />
|
||||
) : (
|
||||
getStageIcon(updateProgress?.stage, isUpdating)
|
||||
)}
|
||||
<LuCheckCheck className="flex-shrink-0 w-5 h-5" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex gap-2 justify-between items-start">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{updateReady
|
||||
? "The update is ready, restart app"
|
||||
: isUpdating
|
||||
? `${getStageDisplayName(updateProgress?.stage)} Donut Browser Update`
|
||||
: "Donut Browser Update Available"}
|
||||
</span>
|
||||
{!updateReady && (
|
||||
<Badge
|
||||
variant={updateInfo.is_nightly ? "secondary" : "default"}
|
||||
className="text-xs"
|
||||
>
|
||||
{updateInfo.is_nightly ? "Nightly" : "Stable"}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{updateReady
|
||||
? "Update ready, restart to apply"
|
||||
: "Manual download required"}
|
||||
</span>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{updateInfo.current_version} → {updateInfo.new_version}
|
||||
</div>
|
||||
{!updateReady && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{isUpdating ? (
|
||||
updateProgress?.message || "Updating..."
|
||||
) : (
|
||||
<>
|
||||
Update from {updateInfo.current_version} to{" "}
|
||||
<span className="font-medium">
|
||||
{updateInfo.new_version}
|
||||
</span>
|
||||
{updateInfo.manual_update_required && (
|
||||
<span className="block mt-1 text-muted-foreground/80">
|
||||
Manual download required on Linux
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!isUpdating && !updateReady && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="p-0 w-6 h-6 shrink-0"
|
||||
>
|
||||
<FaTimes className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="p-0 w-6 h-6 shrink-0"
|
||||
>
|
||||
<FaTimes className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!updateReady && showDownloadProgress && updateProgress && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
|
||||
{updateProgress.percentage?.toFixed(1)}%
|
||||
{updateProgress.speed && ` • ${updateProgress.speed} MB/s`}
|
||||
{updateProgress.eta && ` • ${updateProgress.eta} remaining`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-primary h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${updateProgress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!updateReady && showOtherStageProgress && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all duration-500 ${
|
||||
updateProgress.stage === "completed"
|
||||
? "bg-green-500 w-full"
|
||||
: "bg-primary w-full animate-pulse"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateReady ? (
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
{updateReady ? (
|
||||
<RippleButton
|
||||
onClick={() => void handleRestartClick()}
|
||||
size="sm"
|
||||
@@ -194,40 +71,27 @@ export function AppUpdateToast({
|
||||
<LuCheckCheck className="w-3 h-3" />
|
||||
Restart Now
|
||||
</RippleButton>
|
||||
</div>
|
||||
) : (
|
||||
!isUpdating && (
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
{updateInfo.manual_update_required ? (
|
||||
<RippleButton
|
||||
onClick={handleViewRelease}
|
||||
size="sm"
|
||||
className="flex gap-2 items-center text-xs"
|
||||
>
|
||||
<FaExternalLinkAlt className="w-3 h-3" />
|
||||
View Release
|
||||
</RippleButton>
|
||||
) : (
|
||||
<RippleButton
|
||||
onClick={() => void handleUpdateClick()}
|
||||
size="sm"
|
||||
className="flex gap-2 items-center text-xs"
|
||||
>
|
||||
<FaDownload className="w-3 h-3" />
|
||||
Download Update
|
||||
</RippleButton>
|
||||
)}
|
||||
) : (
|
||||
updateInfo.manual_update_required && (
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onDismiss}
|
||||
onClick={handleViewRelease}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
className="flex gap-2 items-center text-xs"
|
||||
>
|
||||
Later
|
||||
<FaExternalLinkAlt className="w-3 h-3" />
|
||||
View Release
|
||||
</RippleButton>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
)
|
||||
)}
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onDismiss}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Later
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -163,6 +163,7 @@ export function CamoufoxConfigDialog({
|
||||
forceAdvanced={true}
|
||||
readOnly={isRunning}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
) : (
|
||||
<SharedCamoufoxConfigForm
|
||||
@@ -172,6 +173,7 @@ export function CamoufoxConfigDialog({
|
||||
readOnly={isRunning}
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,649 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { LuChevronDown, LuChevronRight, LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
CookieReadResult,
|
||||
DomainCookies,
|
||||
UnifiedCookie,
|
||||
} from "@/types";
|
||||
|
||||
interface CookieImportResult {
|
||||
cookies_imported: number;
|
||||
cookies_replaced: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface CookieManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
initialTab?: "import" | "export";
|
||||
}
|
||||
|
||||
type SelectionState = {
|
||||
[domain: string]: {
|
||||
allSelected: boolean;
|
||||
cookies: Set<string>;
|
||||
};
|
||||
};
|
||||
|
||||
const countCookies = (content: string): number => {
|
||||
const trimmed = content.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const arr = JSON.parse(trimmed);
|
||||
if (Array.isArray(arr)) return arr.length;
|
||||
} catch {
|
||||
// Fall through to Netscape counting
|
||||
}
|
||||
}
|
||||
return content.split("\n").filter((line) => {
|
||||
const l = line.trim();
|
||||
return l && !l.startsWith("#");
|
||||
}).length;
|
||||
};
|
||||
|
||||
function formatJsonCookies(cookies: UnifiedCookie[]): string {
|
||||
const arr = cookies.map((c) => {
|
||||
const sameSite =
|
||||
c.same_site === 1
|
||||
? "lax"
|
||||
: c.same_site === 2
|
||||
? "strict"
|
||||
: "no_restriction";
|
||||
return {
|
||||
name: c.name,
|
||||
value: c.value,
|
||||
domain: c.domain,
|
||||
path: c.path,
|
||||
secure: c.is_secure,
|
||||
httpOnly: c.is_http_only,
|
||||
sameSite,
|
||||
expirationDate: c.expires,
|
||||
session: c.expires === 0,
|
||||
hostOnly: !c.domain.startsWith("."),
|
||||
};
|
||||
});
|
||||
return JSON.stringify(arr, null, 2);
|
||||
}
|
||||
|
||||
function formatNetscapeCookies(cookies: UnifiedCookie[]): string {
|
||||
const lines = ["# Netscape HTTP Cookie File"];
|
||||
for (const c of cookies) {
|
||||
const flag = c.domain.startsWith(".") ? "TRUE" : "FALSE";
|
||||
const secure = c.is_secure ? "TRUE" : "FALSE";
|
||||
lines.push(
|
||||
`${c.domain}\t${flag}\t${c.path}\t${secure}\t${c.expires}\t${c.name}\t${c.value}`,
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function initSelectionFromCookieData(data: CookieReadResult): SelectionState {
|
||||
const sel: SelectionState = {};
|
||||
for (const d of data.domains) {
|
||||
sel[d.domain] = {
|
||||
allSelected: true,
|
||||
cookies: new Set(d.cookies.map((c) => c.name)),
|
||||
};
|
||||
}
|
||||
return sel;
|
||||
}
|
||||
|
||||
export function CookieManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
initialTab = "import",
|
||||
}: CookieManagementDialogProps) {
|
||||
// Import state
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [cookieCount, setCookieCount] = useState(0);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [importResult, setImportResult] = useState<CookieImportResult | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
// Export state
|
||||
const [format, setFormat] = useState<"netscape" | "json">("json");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
const [exportCookieData, setExportCookieData] =
|
||||
useState<CookieReadResult | null>(null);
|
||||
const [isLoadingExportCookies, setIsLoadingExportCookies] = useState(false);
|
||||
const [exportSelection, setExportSelection] = useState<SelectionState>({});
|
||||
const [expandedDomains, setExpandedDomains] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [activeTab, setActiveTab] = useState<string>(initialTab);
|
||||
|
||||
const selectedExportCount = useMemo(() => {
|
||||
let count = 0;
|
||||
for (const domain of Object.keys(exportSelection)) {
|
||||
const ds = exportSelection[domain];
|
||||
if (ds.allSelected) {
|
||||
const domainData = exportCookieData?.domains.find(
|
||||
(d) => d.domain === domain,
|
||||
);
|
||||
count += domainData?.cookie_count || 0;
|
||||
} else {
|
||||
count += ds.cookies.size;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}, [exportSelection, exportCookieData]);
|
||||
|
||||
const loadExportCookies = useCallback(
|
||||
async (profileId: string) => {
|
||||
if (exportCookieData) return;
|
||||
setIsLoadingExportCookies(true);
|
||||
try {
|
||||
const result = await invoke<CookieReadResult>("read_profile_cookies", {
|
||||
profileId,
|
||||
});
|
||||
setExportCookieData(result);
|
||||
setExportSelection(initSelectionFromCookieData(result));
|
||||
} catch (err) {
|
||||
toast.error(
|
||||
`Failed to load cookies: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoadingExportCookies(false);
|
||||
}
|
||||
},
|
||||
[exportCookieData],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === "export" && profile && !exportCookieData) {
|
||||
void loadExportCookies(profile.id);
|
||||
}
|
||||
}, [activeTab, profile, exportCookieData, loadExportCookies]);
|
||||
|
||||
const resetImportState = useCallback(() => {
|
||||
setFileContent(null);
|
||||
setFileName(null);
|
||||
setCookieCount(0);
|
||||
setIsImporting(false);
|
||||
setImportResult(null);
|
||||
}, []);
|
||||
|
||||
const resetExportState = useCallback(() => {
|
||||
setFormat("json");
|
||||
setIsExporting(false);
|
||||
setExportCookieData(null);
|
||||
setExportSelection({});
|
||||
setExpandedDomains(new Set());
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetImportState();
|
||||
resetExportState();
|
||||
setActiveTab(initialTab);
|
||||
onClose();
|
||||
}, [resetImportState, resetExportState, onClose, initialTab]);
|
||||
|
||||
const handleTabChange = useCallback(
|
||||
(tab: string) => {
|
||||
setActiveTab(tab);
|
||||
resetImportState();
|
||||
if (tab !== "export") {
|
||||
resetExportState();
|
||||
}
|
||||
},
|
||||
[resetImportState, resetExportState],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setFileContent(content);
|
||||
setFileName(file.name);
|
||||
setCookieCount(countCookies(content));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, []);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!fileContent || !profile) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const result = await invoke<CookieImportResult>(
|
||||
"import_cookies_from_file",
|
||||
{
|
||||
profileId: profile.id,
|
||||
content: fileContent,
|
||||
},
|
||||
);
|
||||
setImportResult(result);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [fileContent, profile]);
|
||||
|
||||
const getSelectedCookies = useCallback((): UnifiedCookie[] => {
|
||||
if (!exportCookieData) return [];
|
||||
const result: UnifiedCookie[] = [];
|
||||
for (const domain of exportCookieData.domains) {
|
||||
const ds = exportSelection[domain.domain];
|
||||
if (!ds) continue;
|
||||
if (ds.allSelected) {
|
||||
result.push(...domain.cookies);
|
||||
} else {
|
||||
result.push(...domain.cookies.filter((c) => ds.cookies.has(c.name)));
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [exportCookieData, exportSelection]);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!profile) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const cookies = getSelectedCookies();
|
||||
const content =
|
||||
format === "json"
|
||||
? formatJsonCookies(cookies)
|
||||
: formatNetscapeCookies(cookies);
|
||||
|
||||
const ext = format === "json" ? "json" : "txt";
|
||||
const defaultName = `${profile.name}_cookies.${ext}`;
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: defaultName,
|
||||
filters: [
|
||||
{
|
||||
name: format === "json" ? "JSON" : "Text",
|
||||
extensions: [ext],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
setIsExporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await writeTextFile(filePath, content);
|
||||
toast.success("Cookies exported successfully");
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [profile, format, getSelectedCookies, handleClose]);
|
||||
|
||||
const toggleDomain = useCallback(
|
||||
(domain: string, cookies: UnifiedCookie[]) => {
|
||||
setExportSelection((prev) => {
|
||||
const current = prev[domain];
|
||||
if (current?.allSelected) {
|
||||
const next = { ...prev };
|
||||
delete next[domain];
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[domain]: {
|
||||
allSelected: true,
|
||||
cookies: new Set(cookies.map((c) => c.name)),
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleCookie = useCallback(
|
||||
(domain: string, cookieName: string, totalCookies: number) => {
|
||||
setExportSelection((prev) => {
|
||||
const current = prev[domain] || {
|
||||
allSelected: false,
|
||||
cookies: new Set<string>(),
|
||||
};
|
||||
const newCookies = new Set(current.cookies);
|
||||
if (newCookies.has(cookieName)) {
|
||||
newCookies.delete(cookieName);
|
||||
} else {
|
||||
newCookies.add(cookieName);
|
||||
}
|
||||
if (newCookies.size === 0) {
|
||||
const next = { ...prev };
|
||||
delete next[domain];
|
||||
return next;
|
||||
}
|
||||
return {
|
||||
...prev,
|
||||
[domain]: {
|
||||
allSelected: newCookies.size === totalCookies,
|
||||
cookies: newCookies,
|
||||
},
|
||||
};
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const toggleExpand = useCallback((domain: string) => {
|
||||
setExpandedDomains((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(domain)) {
|
||||
next.delete(domain);
|
||||
} else {
|
||||
next.add(domain);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSelectAll = useCallback(() => {
|
||||
if (!exportCookieData) return;
|
||||
if (selectedExportCount === exportCookieData.total_count) {
|
||||
setExportSelection({});
|
||||
} else {
|
||||
setExportSelection(initSelectionFromCookieData(exportCookieData));
|
||||
}
|
||||
}, [exportCookieData, selectedExportCount]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Cookie Management</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
defaultValue={initialTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="w-full"
|
||||
>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="import">Import</TabsTrigger>
|
||||
<TabsTrigger value="export">Export</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="import" className="space-y-4 mt-4">
|
||||
{!fileContent && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Import cookies from a Netscape or JSON format file.
|
||||
</p>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 transition-colors cursor-pointer border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||
onClick={() =>
|
||||
document.getElementById("cookie-file-input")?.click()
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById("cookie-file-input")?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to choose a cookie file
|
||||
<br />
|
||||
<span className="text-xs">(.txt, .cookies, or .json)</span>
|
||||
</p>
|
||||
<input
|
||||
id="cookie-file-input"
|
||||
type="file"
|
||||
accept=".txt,.cookies,.json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileRead(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileContent && !importResult && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{cookieCount} cookies found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton variant="outline" onClick={resetImportState}>
|
||||
Back
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={cookieCount === 0}
|
||||
>
|
||||
Import
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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">
|
||||
Successfully imported {importResult.cookies_imported}{" "}
|
||||
cookies ({importResult.cookies_replaced} replaced)
|
||||
</div>
|
||||
{importResult.errors.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{importResult.errors.length} line(s) skipped
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="export" className="space-y-3 mt-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Format</Label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => setFormat(v as "netscape" | "json")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="netscape">Netscape TXT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>
|
||||
Cookies{" "}
|
||||
{exportCookieData && (
|
||||
<span className="text-muted-foreground font-normal">
|
||||
({selectedExportCount} of {exportCookieData.total_count}{" "}
|
||||
selected)
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{exportCookieData && exportCookieData.total_count > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={toggleSelectAll}
|
||||
>
|
||||
{selectedExportCount === exportCookieData.total_count
|
||||
? "Deselect all"
|
||||
: "Select all"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isLoadingExportCookies ? (
|
||||
<div className="flex items-center justify-center h-24">
|
||||
<div className="animate-spin h-5 w-5 border-2 border-primary border-t-transparent rounded-full" />
|
||||
</div>
|
||||
) : !exportCookieData || exportCookieData.domains.length === 0 ? (
|
||||
<div className="p-4 text-center text-sm text-muted-foreground border rounded-md">
|
||||
No cookies found in this profile
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-[200px] border rounded-md">
|
||||
<div className="p-2 space-y-1">
|
||||
{exportCookieData.domains.map((domain) => (
|
||||
<ExportDomainRow
|
||||
key={domain.domain}
|
||||
domain={domain}
|
||||
selection={exportSelection}
|
||||
isExpanded={expandedDomains.has(domain.domain)}
|
||||
onToggleDomain={toggleDomain}
|
||||
onToggleCookie={toggleCookie}
|
||||
onToggleExpand={toggleExpand}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isExporting}
|
||||
onClick={() => void handleExport()}
|
||||
disabled={selectedExportCount === 0}
|
||||
>
|
||||
Export
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ExportDomainRowProps {
|
||||
domain: DomainCookies;
|
||||
selection: SelectionState;
|
||||
isExpanded: boolean;
|
||||
onToggleDomain: (domain: string, cookies: UnifiedCookie[]) => void;
|
||||
onToggleCookie: (
|
||||
domain: string,
|
||||
cookieName: string,
|
||||
totalCookies: number,
|
||||
) => void;
|
||||
onToggleExpand: (domain: string) => void;
|
||||
}
|
||||
|
||||
function ExportDomainRow({
|
||||
domain,
|
||||
selection,
|
||||
isExpanded,
|
||||
onToggleDomain,
|
||||
onToggleCookie,
|
||||
onToggleExpand,
|
||||
}: ExportDomainRowProps) {
|
||||
const domainSelection = selection[domain.domain];
|
||||
const isAllSelected = domainSelection?.allSelected || false;
|
||||
const selectedCount = domainSelection?.cookies.size || 0;
|
||||
const isPartial =
|
||||
selectedCount > 0 && selectedCount < domain.cookie_count && !isAllSelected;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<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)}
|
||||
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)}
|
||||
>
|
||||
{isExpanded ? (
|
||||
<LuChevronDown className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<LuChevronRight className="w-3.5 h-3.5" />
|
||||
)}
|
||||
<span className="font-medium truncate">{domain.domain}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
({domain.cookie_count})
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
{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;
|
||||
return (
|
||||
<div
|
||||
key={`${domain.domain}-${cookie.name}`}
|
||||
className="flex items-center gap-2 p-1 text-sm hover:bg-accent/30 rounded"
|
||||
>
|
||||
<Checkbox
|
||||
checked={isSelected || isAllSelected}
|
||||
onCheckedChange={() =>
|
||||
onToggleCookie(
|
||||
domain.domain,
|
||||
cookie.name,
|
||||
domain.cookie_count,
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span className="truncate">{cookie.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,13 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -20,15 +22,17 @@ import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
import { WayfernConfigForm } from "@/components/wayfern-config-form";
|
||||
|
||||
import { useBrowserDownload } from "@/hooks/use-browser-download";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type {
|
||||
BrowserReleaseTypes,
|
||||
@@ -66,9 +70,11 @@ interface CreateProfileDialogProps {
|
||||
version: string;
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
vpnId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
ephemeral?: boolean;
|
||||
}) => Promise<void>;
|
||||
selectedGroupId?: string;
|
||||
crossOsUnlocked?: boolean;
|
||||
@@ -109,6 +115,7 @@ export function CreateProfileDialog({
|
||||
selectedGroupId,
|
||||
crossOsUnlocked = false,
|
||||
}: CreateProfileDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [profileName, setProfileName] = useState("");
|
||||
const [currentStep, setCurrentStep] = useState<
|
||||
"browser-selection" | "browser-config"
|
||||
@@ -155,8 +162,10 @@ export function CreateProfileDialog({
|
||||
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
const { storedProxies } = useProxyEvents();
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
|
||||
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
|
||||
const [releaseTypesError, setReleaseTypesError] = useState<string | null>(
|
||||
@@ -170,6 +179,7 @@ export function CreateProfileDialog({
|
||||
downloadBrowser,
|
||||
loadDownloadedVersions,
|
||||
isVersionDownloaded,
|
||||
downloadedVersions,
|
||||
} = useBrowserDownload();
|
||||
|
||||
const loadSupportedBrowsers = useCallback(async () => {
|
||||
@@ -328,6 +338,26 @@ export function CreateProfileDialog({
|
||||
[releaseTypes],
|
||||
);
|
||||
|
||||
const getCreatableVersion = useCallback(
|
||||
(browserType?: string) => {
|
||||
const bestVersion = getBestAvailableVersion(browserType);
|
||||
if (bestVersion && isVersionDownloaded(bestVersion.version)) {
|
||||
return bestVersion;
|
||||
}
|
||||
if (downloadedVersions.length > 0) {
|
||||
const fallbackVersion = downloadedVersions[0];
|
||||
const releaseType =
|
||||
browserType === "firefox-developer" ? "nightly" : "stable";
|
||||
return {
|
||||
version: fallbackVersion,
|
||||
releaseType: releaseType as "stable" | "nightly",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[getBestAvailableVersion, isVersionDownloaded, downloadedVersions],
|
||||
);
|
||||
|
||||
const handleDownload = async (browserStr: string) => {
|
||||
const bestVersion = getBestAvailableVersion(browserStr);
|
||||
|
||||
@@ -347,11 +377,16 @@ export function CreateProfileDialog({
|
||||
if (!profileName.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
|
||||
const isVpnSelection = selectedProxyId?.startsWith("vpn-") ?? false;
|
||||
const resolvedProxyId = isVpnSelection ? undefined : selectedProxyId;
|
||||
const resolvedVpnId =
|
||||
isVpnSelection && selectedProxyId ? selectedProxyId.slice(4) : undefined;
|
||||
try {
|
||||
if (activeTab === "anti-detect") {
|
||||
// Anti-detect browser - check if Wayfern or Camoufox is selected
|
||||
if (selectedBrowser === "wayfern") {
|
||||
const bestWayfernVersion = getBestAvailableVersion("wayfern");
|
||||
const bestWayfernVersion = getCreatableVersion("wayfern");
|
||||
if (!bestWayfernVersion) {
|
||||
console.error("No Wayfern version available");
|
||||
return;
|
||||
@@ -365,14 +400,16 @@ export function CreateProfileDialog({
|
||||
browserStr: "wayfern" as BrowserTypeString,
|
||||
version: bestWayfernVersion.version,
|
||||
releaseType: bestWayfernVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
wayfernConfig: finalWayfernConfig,
|
||||
groupId:
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
ephemeral,
|
||||
});
|
||||
} else {
|
||||
// Default to Camoufox
|
||||
const bestCamoufoxVersion = getBestAvailableVersion("camoufox");
|
||||
const bestCamoufoxVersion = getCreatableVersion("camoufox");
|
||||
if (!bestCamoufoxVersion) {
|
||||
console.error("No Camoufox version available");
|
||||
return;
|
||||
@@ -387,10 +424,12 @@ export function CreateProfileDialog({
|
||||
browserStr: "camoufox" as BrowserTypeString,
|
||||
version: bestCamoufoxVersion.version,
|
||||
releaseType: bestCamoufoxVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
proxyId: resolvedProxyId,
|
||||
vpnId: resolvedVpnId,
|
||||
camoufoxConfig: finalCamoufoxConfig,
|
||||
groupId:
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
ephemeral,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -401,7 +440,7 @@ export function CreateProfileDialog({
|
||||
}
|
||||
|
||||
// Use the best available version (stable preferred, nightly as fallback)
|
||||
const bestVersion = getBestAvailableVersion(selectedBrowser);
|
||||
const bestVersion = getCreatableVersion(selectedBrowser);
|
||||
if (!bestVersion) {
|
||||
console.error("No version available");
|
||||
return;
|
||||
@@ -445,6 +484,7 @@ export function CreateProfileDialog({
|
||||
setWayfernConfig({
|
||||
os: getCurrentOS() as WayfernOS, // Reset to current OS
|
||||
});
|
||||
setEphemeral(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -477,14 +517,14 @@ export function CreateProfileDialog({
|
||||
if (!profileName.trim()) return true;
|
||||
if (!selectedBrowser) return true;
|
||||
if (isBrowserCurrentlyDownloading(selectedBrowser)) return true;
|
||||
if (!isBrowserVersionAvailable(selectedBrowser)) return true;
|
||||
if (!getCreatableVersion(selectedBrowser)) return true;
|
||||
|
||||
return false;
|
||||
}, [
|
||||
profileName,
|
||||
selectedBrowser,
|
||||
isBrowserCurrentlyDownloading,
|
||||
isBrowserVersionAvailable,
|
||||
getCreatableVersion,
|
||||
]);
|
||||
|
||||
// Filter supported browsers for regular browsers
|
||||
@@ -543,9 +583,7 @@ export function CreateProfileDialog({
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
Chromium (Wayfern)
|
||||
</div>
|
||||
<div className="font-medium">Wayfern</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Anti-Detect Browser
|
||||
</div>
|
||||
@@ -568,9 +606,7 @@ export function CreateProfileDialog({
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
Firefox (Camoufox)
|
||||
</div>
|
||||
<div className="font-medium">Camoufox</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Anti-Detect Browser
|
||||
</div>
|
||||
@@ -650,6 +686,28 @@ export function CreateProfileDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ephemeral Option */}
|
||||
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="ephemeral"
|
||||
checked={ephemeral}
|
||||
onCheckedChange={(checked) =>
|
||||
setEphemeral(checked === true)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="ephemeral" className="font-medium">
|
||||
{t("profiles.ephemeral")}
|
||||
</Label>
|
||||
<span className="px-1 py-0.5 text-[10px] leading-none rounded bg-muted text-muted-foreground font-medium">
|
||||
{t("profiles.ephemeralAlpha")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground ml-6">
|
||||
{t("profiles.ephemeralDescription")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{selectedBrowser === "wayfern" ? (
|
||||
// Wayfern Configuration
|
||||
<div className="space-y-6">
|
||||
@@ -745,6 +803,7 @@ export function CreateProfileDialog({
|
||||
onConfigChange={updateWayfernConfig}
|
||||
isCreating
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
) : selectedBrowser === "camoufox" ? (
|
||||
@@ -843,6 +902,7 @@ export function CreateProfileDialog({
|
||||
isCreating
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -946,10 +1006,10 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proxy Selection - Always visible */}
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy</Label>
|
||||
<Label>Proxy / VPN</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -959,7 +1019,7 @@ export function CreateProfileDialog({
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 ? (
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
@@ -969,21 +1029,47 @@ export function CreateProfileDialog({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
<SelectValue placeholder="No proxy / VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="none">
|
||||
No proxy / VPN
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Proxies</SelectLabel>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem
|
||||
key={proxy.id}
|
||||
value={proxy.id}
|
||||
>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{vpnConfigs.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>VPNs</SelectLabel>
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<SelectItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.id}`}
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}{" "}
|
||||
— {vpn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies available. Add one to route this
|
||||
profile's traffic.
|
||||
No proxies or VPNs available. Add one to route
|
||||
this profile's traffic.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -1107,10 +1193,10 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Proxy Selection - Always visible */}
|
||||
{/* Proxy / VPN Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy</Label>
|
||||
<Label>Proxy / VPN</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
@@ -1120,7 +1206,7 @@ export function CreateProfileDialog({
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 ? (
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
@@ -1130,21 +1216,47 @@ export function CreateProfileDialog({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
<SelectValue placeholder="No proxy / VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="none">
|
||||
No proxy / VPN
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Proxies</SelectLabel>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem
|
||||
key={proxy.id}
|
||||
value={proxy.id}
|
||||
>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{vpnConfigs.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>VPNs</SelectLabel>
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<SelectItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.id}`}
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}{" "}
|
||||
— {vpn.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies available. Add one to route this
|
||||
profile's traffic.
|
||||
No proxies or VPNs available. Add one to route
|
||||
this profile's traffic.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@ export const ZenBrowser = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<title>Zen Browser</title>
|
||||
<path
|
||||
d="M12 8.15c-2.12 0-3.85 1.72-3.85 3.85s1.72 3.85 3.85 3.85 3.85-1.72 3.85-3.85S14.13 8.15 12 8.15m0 6.92c-1.7 0-3.08-1.38-3.08-3.08S10.3 8.91 12 8.91s3.08 1.38 3.08 3.08-1.38 3.08-3.08 3.08"
|
||||
className="b"
|
||||
|
||||
@@ -13,6 +13,8 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit, listen } from "@tauri-apps/api/event";
|
||||
import type { Dispatch, SetStateAction } from "react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { IoEllipsisHorizontal } from "react-icons/io5";
|
||||
import {
|
||||
@@ -20,7 +22,6 @@ import {
|
||||
LuChevronDown,
|
||||
LuChevronUp,
|
||||
LuCookie,
|
||||
LuLock,
|
||||
LuTrash2,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
@@ -47,6 +48,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
@@ -64,10 +66,13 @@ import {
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useTableSorting } from "@/hooks/use-table-sorting";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import {
|
||||
getBrowserDisplayName,
|
||||
getBrowserIcon,
|
||||
getCurrentOS,
|
||||
getOSDisplayName,
|
||||
getProfileIcon,
|
||||
isCrossOsProfile,
|
||||
} from "@/lib/browser-utils";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { trimName } from "@/lib/name-utils";
|
||||
@@ -78,6 +83,7 @@ import type {
|
||||
ProxyCheckResult,
|
||||
StoredProxy,
|
||||
TrafficSnapshot,
|
||||
VpnConfig,
|
||||
} from "@/types";
|
||||
import { BandwidthMiniChart } from "./bandwidth-mini-chart";
|
||||
import {
|
||||
@@ -94,6 +100,7 @@ import { RippleButton } from "./ui/ripple";
|
||||
// Stable table meta type to pass volatile state/handlers into TanStack Table without
|
||||
// causing column definitions to be recreated on every render.
|
||||
type TableMeta = {
|
||||
t: (key: string, options?: Record<string, unknown>) => string;
|
||||
selectedProfiles: string[];
|
||||
selectableCount: number;
|
||||
showCheckboxes: boolean;
|
||||
@@ -134,6 +141,14 @@ type TableMeta = {
|
||||
checkingProfileId: string | null;
|
||||
proxyCheckResults: Record<string, ProxyCheckResult>;
|
||||
|
||||
// VPN selector state
|
||||
vpnConfigs: VpnConfig[];
|
||||
vpnOverrides: Record<string, string | null>;
|
||||
handleVpnSelection: (
|
||||
profileId: string,
|
||||
vpnId: string | null,
|
||||
) => void | Promise<void>;
|
||||
|
||||
// Selection helpers
|
||||
isProfileSelected: (id: string) => boolean;
|
||||
handleToggleAll: (checked: boolean) => void;
|
||||
@@ -163,16 +178,18 @@ type TableMeta = {
|
||||
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
||||
onOpenCookieManagement?: (profile: BrowserProfile) => void;
|
||||
|
||||
// Traffic snapshots (lightweight real-time data)
|
||||
trafficSnapshots: Record<string, TrafficSnapshot>;
|
||||
onOpenTrafficDialog?: (profileId: string) => void;
|
||||
|
||||
// Sync
|
||||
syncStatuses: Record<string, string>;
|
||||
syncStatuses: Record<string, { status: string; error?: string }>;
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
syncUnlocked?: boolean;
|
||||
|
||||
// Country proxy creation (inline in proxy dropdown)
|
||||
countries: LocationItem[];
|
||||
@@ -184,6 +201,62 @@ type TableMeta = {
|
||||
) => Promise<void>;
|
||||
};
|
||||
|
||||
type SyncStatusDot = { color: string; tooltip: string; animate: boolean };
|
||||
|
||||
function getProfileSyncStatusDot(
|
||||
profile: BrowserProfile,
|
||||
liveStatus:
|
||||
| "syncing"
|
||||
| "waiting"
|
||||
| "synced"
|
||||
| "error"
|
||||
| "disabled"
|
||||
| undefined,
|
||||
errorMessage?: string,
|
||||
): SyncStatusDot | null {
|
||||
const status =
|
||||
liveStatus ??
|
||||
(profile.sync_mode && profile.sync_mode !== "Disabled"
|
||||
? "synced"
|
||||
: "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-yellow-500",
|
||||
tooltip: "Waiting to sync",
|
||||
animate: false,
|
||||
};
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-green-500",
|
||||
tooltip: profile.last_sync
|
||||
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-red-500",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
animate: false,
|
||||
};
|
||||
case "disabled":
|
||||
if (profile.last_sync) {
|
||||
return {
|
||||
color: "bg-gray-400",
|
||||
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`,
|
||||
animate: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const TagsCell = React.memo<{
|
||||
profile: BrowserProfile;
|
||||
isDisabled: boolean;
|
||||
@@ -690,6 +763,7 @@ interface ProfilesDataTableProps {
|
||||
onRenameProfile: (profileId: string, newName: string) => Promise<void>;
|
||||
onConfigureCamoufox: (profile: BrowserProfile) => void;
|
||||
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
||||
onOpenCookieManagement?: (profile: BrowserProfile) => void;
|
||||
runningProfiles: Set<string>;
|
||||
isUpdating: (browser: string) => boolean;
|
||||
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
|
||||
@@ -704,6 +778,7 @@ interface ProfilesDataTableProps {
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
syncUnlocked?: boolean;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -715,6 +790,7 @@ export function ProfilesDataTable({
|
||||
onRenameProfile,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onOpenCookieManagement,
|
||||
runningProfiles,
|
||||
isUpdating,
|
||||
onAssignProfilesToGroup,
|
||||
@@ -727,7 +803,9 @@ export function ProfilesDataTable({
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
crossOsUnlocked = false,
|
||||
syncUnlocked = false,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { t } = useTranslation();
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
|
||||
@@ -798,10 +876,14 @@ export function ProfilesDataTable({
|
||||
);
|
||||
|
||||
const { storedProxies } = useProxyEvents();
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
|
||||
const [proxyOverrides, setProxyOverrides] = React.useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
const [vpnOverrides, setVpnOverrides] = React.useState<
|
||||
Record<string, string | null>
|
||||
>({});
|
||||
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
|
||||
const [tagsOverrides, setTagsOverrides] = React.useState<
|
||||
Record<string, string[]>
|
||||
@@ -833,7 +915,7 @@ export function ProfilesDataTable({
|
||||
name?: string;
|
||||
} | null>(null);
|
||||
const [syncStatuses, setSyncStatuses] = React.useState<
|
||||
Record<string, string>
|
||||
Record<string, { status: string; error?: string }>
|
||||
>({});
|
||||
|
||||
// Country proxy creation state (for inline proxy creation in dropdown)
|
||||
@@ -900,7 +982,7 @@ export function ProfilesDataTable({
|
||||
proxyId,
|
||||
});
|
||||
setProxyOverrides((prev) => ({ ...prev, [profileId]: proxyId }));
|
||||
// Notify other parts of the app so usage counts and lists refresh
|
||||
setVpnOverrides((prev) => ({ ...prev, [profileId]: null }));
|
||||
await emit("profile-updated");
|
||||
} catch (error) {
|
||||
console.error("Failed to update proxy settings:", error);
|
||||
@@ -911,6 +993,25 @@ export function ProfilesDataTable({
|
||||
[],
|
||||
);
|
||||
|
||||
const handleVpnSelection = React.useCallback(
|
||||
async (profileId: string, vpnId: string | null) => {
|
||||
try {
|
||||
await invoke("update_profile_vpn", {
|
||||
profileId,
|
||||
vpnId,
|
||||
});
|
||||
setVpnOverrides((prev) => ({ ...prev, [profileId]: vpnId }));
|
||||
setProxyOverrides((prev) => ({ ...prev, [profileId]: null }));
|
||||
await emit("profile-updated");
|
||||
} catch (error) {
|
||||
console.error("Failed to update VPN settings:", error);
|
||||
} finally {
|
||||
setOpenProxySelectorFor(null);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreateCountryProxy = React.useCallback(
|
||||
async (profileId: string, country: LocationItem) => {
|
||||
try {
|
||||
@@ -955,13 +1056,17 @@ export function ProfilesDataTable({
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
unlisten = await listen<{ profile_id: string; status: string }>(
|
||||
"profile-sync-status",
|
||||
(event) => {
|
||||
const { profile_id, status } = event.payload;
|
||||
setSyncStatuses((prev) => ({ ...prev, [profile_id]: status }));
|
||||
},
|
||||
);
|
||||
unlisten = await listen<{
|
||||
profile_id: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>("profile-sync-status", (event) => {
|
||||
const { profile_id, status, error } = event.payload;
|
||||
setSyncStatuses((prev) => ({
|
||||
...prev,
|
||||
[profile_id]: { status, error },
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync status events:", error);
|
||||
}
|
||||
@@ -1100,9 +1205,8 @@ export function ProfilesDataTable({
|
||||
browserState.isClient && runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
if (isRunning || isLaunching || isStopping || isBrowserUpdating) {
|
||||
if (isRunning || isLaunching || isStopping) {
|
||||
newSet.delete(profileId);
|
||||
hasChanges = true;
|
||||
}
|
||||
@@ -1117,7 +1221,6 @@ export function ProfilesDataTable({
|
||||
runningProfiles,
|
||||
launchingProfiles,
|
||||
stoppingProfiles,
|
||||
isUpdating,
|
||||
browserState.isClient,
|
||||
onSelectedProfilesChange,
|
||||
selectedProfiles,
|
||||
@@ -1263,13 +1366,7 @@ export function ProfilesDataTable({
|
||||
browserState.isClient && runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
return (
|
||||
!isRunning &&
|
||||
!isLaunching &&
|
||||
!isStopping &&
|
||||
!isBrowserUpdating
|
||||
);
|
||||
return !isRunning && !isLaunching && !isStopping;
|
||||
})
|
||||
.map((profile) => profile.id),
|
||||
)
|
||||
@@ -1285,7 +1382,6 @@ export function ProfilesDataTable({
|
||||
runningProfiles,
|
||||
launchingProfiles,
|
||||
stoppingProfiles,
|
||||
isUpdating,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1296,8 +1392,7 @@ export function ProfilesDataTable({
|
||||
browserState.isClient && runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating;
|
||||
return !isRunning && !isLaunching && !isStopping;
|
||||
});
|
||||
}, [
|
||||
profiles,
|
||||
@@ -1305,12 +1400,12 @@ export function ProfilesDataTable({
|
||||
runningProfiles,
|
||||
launchingProfiles,
|
||||
stoppingProfiles,
|
||||
isUpdating,
|
||||
]);
|
||||
|
||||
// Build table meta from volatile state so columns can stay stable
|
||||
const tableMeta = React.useMemo<TableMeta>(
|
||||
() => ({
|
||||
t,
|
||||
selectedProfiles,
|
||||
selectableCount: selectableProfiles.length,
|
||||
showCheckboxes,
|
||||
@@ -1344,6 +1439,11 @@ export function ProfilesDataTable({
|
||||
checkingProfileId,
|
||||
proxyCheckResults,
|
||||
|
||||
// VPN selector state
|
||||
vpnConfigs,
|
||||
vpnOverrides,
|
||||
handleVpnSelection,
|
||||
|
||||
// Selection helpers
|
||||
isProfileSelected: (id: string) => selectedProfiles.includes(id),
|
||||
handleToggleAll,
|
||||
@@ -1371,6 +1471,7 @@ export function ProfilesDataTable({
|
||||
onCloneProfile,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onOpenCookieManagement,
|
||||
|
||||
// Traffic snapshots (lightweight real-time data)
|
||||
trafficSnapshots,
|
||||
@@ -1384,6 +1485,7 @@ export function ProfilesDataTable({
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
crossOsUnlocked,
|
||||
syncUnlocked,
|
||||
|
||||
// Country proxy creation
|
||||
countries,
|
||||
@@ -1392,6 +1494,7 @@ export function ProfilesDataTable({
|
||||
handleCreateCountryProxy,
|
||||
}),
|
||||
[
|
||||
t,
|
||||
selectedProfiles,
|
||||
selectableProfiles.length,
|
||||
showCheckboxes,
|
||||
@@ -1412,6 +1515,9 @@ export function ProfilesDataTable({
|
||||
handleProxySelection,
|
||||
checkingProfileId,
|
||||
proxyCheckResults,
|
||||
vpnConfigs,
|
||||
vpnOverrides,
|
||||
handleVpnSelection,
|
||||
handleToggleAll,
|
||||
handleCheckboxChange,
|
||||
handleIconClick,
|
||||
@@ -1428,10 +1534,12 @@ export function ProfilesDataTable({
|
||||
onCloneProfile,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onOpenCookieManagement,
|
||||
syncStatuses,
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
crossOsUnlocked,
|
||||
syncUnlocked,
|
||||
countries,
|
||||
canCreateLocationProxy,
|
||||
loadCountries,
|
||||
@@ -1463,16 +1571,83 @@ export function ProfilesDataTable({
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const browser = profile.browser;
|
||||
const IconComponent = getBrowserIcon(browser);
|
||||
const IconComponent = getProfileIcon(profile);
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
|
||||
const isSelected = meta.isProfileSelected(profile.id);
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = meta.isUpdating(browser);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
const isDisabled = isRunning || isLaunching || isStopping;
|
||||
|
||||
// Cross-OS profiles: show OS icon when checkboxes aren't visible, show checkbox when they are
|
||||
if (isCrossOs && !meta.showCheckboxes && !isSelected) {
|
||||
const osName = profile.host_os
|
||||
? getOSDisplayName(profile.host_os)
|
||||
: "another OS";
|
||||
const OsIcon =
|
||||
profile.host_os === "macos"
|
||||
? FaApple
|
||||
: profile.host_os === "windows"
|
||||
? FaWindows
|
||||
: FaLinux;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center w-4 h-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
||||
onClick={() => meta.handleIconClick(profile.id)}
|
||||
aria-label="Select profile"
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
<OsIcon className="w-4 h-4 text-muted-foreground group-hover:hidden" />
|
||||
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
This profile was created on {osName} and is not supported on
|
||||
this system
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
// Cross-OS profiles with checkboxes visible: show checkbox (selectable for bulk delete)
|
||||
if (isCrossOs && (meta.showCheckboxes || isSelected)) {
|
||||
const osName = profile.host_os
|
||||
? getOSDisplayName(profile.host_os)
|
||||
: "another OS";
|
||||
return (
|
||||
<NonHoverableTooltip
|
||||
content={
|
||||
<p>
|
||||
This profile was created on {osName} and is not supported on
|
||||
this system
|
||||
</p>
|
||||
}
|
||||
sideOffset={4}
|
||||
horizontalOffset={8}
|
||||
>
|
||||
<span className="flex justify-center items-center w-4 h-4">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(value) =>
|
||||
meta.handleCheckboxChange(profile.id, !!value)
|
||||
}
|
||||
aria-label="Select row"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</span>
|
||||
</NonHoverableTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
if (isDisabled) {
|
||||
const tooltipMessage = isRunning
|
||||
@@ -1718,13 +1893,13 @@ export function ProfilesDataTable({
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = meta.isUpdating(profile.browser);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
isRunning || isLaunching || isStopping || isCrossOs;
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -1762,13 +1937,13 @@ export function ProfilesDataTable({
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = meta.isUpdating(profile.browser);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
isRunning || isLaunching || isStopping || isCrossOs;
|
||||
|
||||
return (
|
||||
<TagsCell
|
||||
@@ -1790,13 +1965,13 @@ export function ProfilesDataTable({
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = meta.isUpdating(profile.browser);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
isRunning || isLaunching || isStopping || isCrossOs;
|
||||
|
||||
return (
|
||||
<NoteCell
|
||||
@@ -1812,40 +1987,56 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "proxy",
|
||||
header: "Proxy",
|
||||
header: "Proxy / VPN",
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = meta.isUpdating(profile.browser);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
isRunning || isLaunching || isStopping || isCrossOs;
|
||||
|
||||
const hasOverride = Object.hasOwn(meta.proxyOverrides, profile.id);
|
||||
const effectiveProxyId = hasOverride
|
||||
const hasProxyOverride = Object.hasOwn(
|
||||
meta.proxyOverrides,
|
||||
profile.id,
|
||||
);
|
||||
const effectiveProxyId = hasProxyOverride
|
||||
? meta.proxyOverrides[profile.id]
|
||||
: (profile.proxy_id ?? null);
|
||||
const effectiveProxy = effectiveProxyId
|
||||
? (meta.storedProxies.find((p) => p.id === effectiveProxyId) ??
|
||||
null)
|
||||
: null;
|
||||
const displayName = effectiveProxy
|
||||
? effectiveProxy.name
|
||||
: "Not Selected";
|
||||
const profileHasProxy = Boolean(effectiveProxy);
|
||||
const tooltipText =
|
||||
profileHasProxy && effectiveProxy ? effectiveProxy.name : null;
|
||||
|
||||
const hasVpnOverride = Object.hasOwn(meta.vpnOverrides, profile.id);
|
||||
const effectiveVpnId = hasVpnOverride
|
||||
? meta.vpnOverrides[profile.id]
|
||||
: (profile.vpn_id ?? null);
|
||||
const effectiveVpn = effectiveVpnId
|
||||
? (meta.vpnConfigs.find((v) => v.id === effectiveVpnId) ?? null)
|
||||
: null;
|
||||
|
||||
const hasAssignment = Boolean(effectiveProxy || effectiveVpn);
|
||||
const displayName = effectiveVpn
|
||||
? effectiveVpn.name
|
||||
: effectiveProxy
|
||||
? effectiveProxy.name
|
||||
: "Not Selected";
|
||||
const vpnBadge = effectiveVpn
|
||||
? effectiveVpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"
|
||||
: null;
|
||||
const tooltipText = hasAssignment ? displayName : null;
|
||||
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
||||
const selectedId = effectiveVpnId ?? effectiveProxyId ?? null;
|
||||
|
||||
// When profile is running, show bandwidth chart instead of proxy selector
|
||||
if (isRunning && meta.trafficSnapshots) {
|
||||
// Find the traffic snapshot for this profile by matching profile_id
|
||||
const snapshot = meta.trafficSnapshots[profile.id];
|
||||
// Only use recent_bandwidth (last 60 seconds) - minimal data needed for mini chart
|
||||
// Create a new array reference to ensure React detects changes
|
||||
const bandwidthData = snapshot?.recent_bandwidth
|
||||
? [...snapshot.recent_bandwidth]
|
||||
: [];
|
||||
@@ -1882,13 +2073,21 @@ export function ProfilesDataTable({
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
>
|
||||
{vpnBadge && (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight"
|
||||
>
|
||||
{vpnBadge}
|
||||
</Badge>
|
||||
)}
|
||||
<span
|
||||
className={cn(
|
||||
"text-sm",
|
||||
!profileHasProxy && "text-muted-foreground",
|
||||
!hasAssignment && "text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{profileHasProxy
|
||||
{hasAssignment
|
||||
? trimName(displayName, 10)
|
||||
: displayName}
|
||||
</span>
|
||||
@@ -1910,8 +2109,8 @@ export function ProfilesDataTable({
|
||||
<CommandInput
|
||||
placeholder={
|
||||
meta.canCreateLocationProxy
|
||||
? "Search proxies or countries..."
|
||||
: "Search proxies..."
|
||||
? "Search proxies, VPNs, or countries..."
|
||||
: "Search proxies or VPNs..."
|
||||
}
|
||||
onFocus={() => {
|
||||
if (meta.canCreateLocationProxy)
|
||||
@@ -1919,7 +2118,7 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
/>
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies found.</CommandEmpty>
|
||||
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
@@ -1930,12 +2129,12 @@ export function ProfilesDataTable({
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveProxyId === null
|
||||
selectedId === null
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
No Proxy
|
||||
None
|
||||
</CommandItem>
|
||||
{meta.storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
@@ -1951,7 +2150,7 @@ export function ProfilesDataTable({
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveProxyId === proxy.id
|
||||
effectiveProxyId === proxy.id && !effectiveVpn
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
@@ -1960,6 +2159,38 @@ export function ProfilesDataTable({
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{meta.vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
{meta.vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.name}`}
|
||||
onSelect={() =>
|
||||
void meta.handleVpnSelection(
|
||||
profile.id,
|
||||
vpn.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
effectiveVpnId === vpn.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight mr-1"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
)}
|
||||
{meta.canCreateLocationProxy &&
|
||||
meta.countries.length > 0 && (
|
||||
<CommandGroup heading="Create by country">
|
||||
@@ -1994,7 +2225,7 @@ export function ProfilesDataTable({
|
||||
</PopoverContent>
|
||||
)}
|
||||
</Popover>
|
||||
{profileHasProxy && effectiveProxy && !isDisabled && (
|
||||
{effectiveProxy && !effectiveVpn && !isDisabled && (
|
||||
<ProxyCheckButton
|
||||
proxy={effectiveProxy}
|
||||
profileId={profile.id}
|
||||
@@ -2023,26 +2254,37 @@ export function ProfilesDataTable({
|
||||
id: "sync",
|
||||
header: "",
|
||||
size: 24,
|
||||
cell: ({ row }) => {
|
||||
cell: ({ row, table }) => {
|
||||
const profile = row.original;
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const syncEntry = meta.syncStatuses[profile.id];
|
||||
const liveStatus = syncEntry?.status as
|
||||
| "syncing"
|
||||
| "waiting"
|
||||
| "synced"
|
||||
| "error"
|
||||
| "disabled"
|
||||
| undefined;
|
||||
|
||||
if (!profile.sync_enabled && profile.last_sync) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center w-3 h-3">
|
||||
<span className="w-2 h-2 rounded-full bg-orange-500" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Sync is disabled, last sync{" "}
|
||||
{formatRelativeTime(profile.last_sync)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
const dot = getProfileSyncStatusDot(
|
||||
profile,
|
||||
liveStatus,
|
||||
syncEntry?.error,
|
||||
);
|
||||
if (!dot) return null;
|
||||
|
||||
return null;
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex justify-center items-center w-3 h-3">
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
|
||||
/>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{dot.tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2050,14 +2292,14 @@ export function ProfilesDataTable({
|
||||
cell: ({ row, table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const profile = row.original;
|
||||
const isCrossOs = isCrossOsProfile(profile);
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isBrowserUpdating =
|
||||
meta.isClient && meta.isUpdating(profile.browser);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
isRunning || isLaunching || isStopping || isCrossOs;
|
||||
const isDeleteDisabled = isRunning || isLaunching || isStopping;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center">
|
||||
@@ -2077,31 +2319,27 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
meta.onOpenTrafficDialog?.(profile.id);
|
||||
}}
|
||||
disabled={isCrossOs}
|
||||
>
|
||||
View Network
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onToggleProfileSync?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={!meta.crossOsUnlocked}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{profile.sync_enabled ? "Disable Sync" : "Enable Sync"}
|
||||
{!meta.crossOsUnlocked && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
</span>
|
||||
{meta.t("profiles.actions.viewNetwork")}
|
||||
</DropdownMenuItem>
|
||||
{!profile.ephemeral && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onOpenProfileSyncDialog?.(profile);
|
||||
}}
|
||||
disabled={isCrossOs}
|
||||
>
|
||||
{meta.t("profiles.actions.syncSettings")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onAssignProfilesToGroup?.([profile.id]);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Assign to Group
|
||||
{meta.t("profiles.actions.assignToGroup")}
|
||||
</DropdownMenuItem>
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
@@ -2110,36 +2348,64 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
meta.onConfigureCamoufox?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Change Fingerprint
|
||||
{meta.t("profiles.actions.changeFingerprint")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
!profile.ephemeral &&
|
||||
meta.onCopyCookiesToProfile && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onCopyCookiesToProfile?.(profile);
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onCopyCookiesToProfile?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled || !meta.crossOsUnlocked}
|
||||
>
|
||||
Copy Cookies to Profile
|
||||
<span className="flex items-center gap-2">
|
||||
{meta.t("profiles.actions.copyCookiesToProfile")}
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onCloneProfile?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Clone Profile
|
||||
</DropdownMenuItem>
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
!profile.ephemeral &&
|
||||
meta.onOpenCookieManagement && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onOpenCookieManagement?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled || !meta.crossOsUnlocked}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{meta.t("cookies.management.menuItem")}
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!profile.ephemeral && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onCloneProfile?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{meta.t("profiles.actions.clone")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setProfileToDelete(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
disabled={isDeleteDisabled}
|
||||
>
|
||||
Delete
|
||||
{meta.t("profiles.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -2166,8 +2432,7 @@ export function ProfilesDataTable({
|
||||
browserState.isClient && runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
return !isRunning && !isLaunching && !isStopping && !isBrowserUpdating;
|
||||
return !isRunning && !isLaunching && !isStopping;
|
||||
},
|
||||
getSortedRowModel: getSortedRowModel(),
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
@@ -2210,7 +2475,10 @@ export function ProfilesDataTable({
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="overflow-visible hover:bg-accent/50"
|
||||
className={cn(
|
||||
"overflow-visible hover:bg-accent/50",
|
||||
isCrossOsProfile(row.original) && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="overflow-visible">
|
||||
@@ -2266,9 +2534,10 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkCopyCookies && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Copy Cookies"
|
||||
tooltip={crossOsUnlocked ? "Copy Cookies" : "Copy Cookies (Pro)"}
|
||||
onClick={onBulkCopyCookies}
|
||||
size="icon"
|
||||
disabled={!crossOsUnlocked}
|
||||
>
|
||||
<LuCookie />
|
||||
</DataTableActionBarAction>
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,8 +15,10 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, SyncSettings } from "@/types";
|
||||
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
|
||||
import { isSyncEnabled } from "@/types";
|
||||
|
||||
interface ProfileSyncDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -31,12 +33,14 @@ export function ProfileSyncDialog({
|
||||
profile,
|
||||
onSyncConfigOpen,
|
||||
}: ProfileSyncDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncEnabled, setSyncEnabled] = useState(
|
||||
profile?.sync_enabled ?? false,
|
||||
const [syncMode, setSyncMode] = useState<SyncMode>(
|
||||
profile?.sync_mode ?? "Disabled",
|
||||
);
|
||||
const [hasConfig, setHasConfig] = useState(false);
|
||||
const [hasE2ePassword, setHasE2ePassword] = useState(false);
|
||||
const [isCheckingConfig, setIsCheckingConfig] = useState(false);
|
||||
|
||||
const checkSyncConfig = useCallback(async () => {
|
||||
@@ -44,6 +48,8 @@ export function ProfileSyncDialog({
|
||||
try {
|
||||
const settings = await invoke<SyncSettings>("get_sync_settings");
|
||||
setHasConfig(Boolean(settings.sync_server_url && settings.sync_token));
|
||||
const hasPassword = await invoke<boolean>("check_has_e2e_password");
|
||||
setHasE2ePassword(hasPassword);
|
||||
} catch {
|
||||
setHasConfig(false);
|
||||
} finally {
|
||||
@@ -54,7 +60,7 @@ export function ProfileSyncDialog({
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open && profile) {
|
||||
setSyncEnabled(profile.sync_enabled ?? false);
|
||||
setSyncMode(profile.sync_mode ?? "Disabled");
|
||||
void checkSyncConfig();
|
||||
}
|
||||
if (!open) {
|
||||
@@ -64,39 +70,49 @@ export function ProfileSyncDialog({
|
||||
[profile, onClose, checkSyncConfig],
|
||||
);
|
||||
|
||||
const handleToggleSync = useCallback(async () => {
|
||||
if (!profile) return;
|
||||
const handleModeChange = useCallback(
|
||||
async (newMode: string) => {
|
||||
if (!profile) return;
|
||||
|
||||
if (!hasConfig) {
|
||||
showErrorToast("Please configure sync service first");
|
||||
onSyncConfigOpen();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (!hasConfig) {
|
||||
showErrorToast(t("sync.mode.noPasswordWarning"));
|
||||
onSyncConfigOpen();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke("set_profile_sync_enabled", {
|
||||
profileId: profile.id,
|
||||
enabled: !syncEnabled,
|
||||
});
|
||||
setSyncEnabled(!syncEnabled);
|
||||
showSuccessToast(
|
||||
!syncEnabled ? "Sync enabled - syncing now..." : "Sync disabled",
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle sync:", error);
|
||||
showErrorToast("Failed to update sync settings");
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [profile, syncEnabled, hasConfig, onSyncConfigOpen, onClose]);
|
||||
if (newMode === "Encrypted" && !hasE2ePassword) {
|
||||
showErrorToast(t("sync.mode.passwordRequired"));
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke("set_profile_sync_mode", {
|
||||
profileId: profile.id,
|
||||
syncMode: newMode,
|
||||
});
|
||||
setSyncMode(newMode as SyncMode);
|
||||
showSuccessToast(
|
||||
newMode !== "Disabled"
|
||||
? t("sync.mode.enabledToast")
|
||||
: t("sync.mode.disabledToast"),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to set sync mode:", error);
|
||||
showErrorToast(String(error));
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[profile, hasConfig, hasE2ePassword, onSyncConfigOpen, onClose, t],
|
||||
);
|
||||
|
||||
const handleSyncNow = useCallback(async () => {
|
||||
if (!profile) return;
|
||||
|
||||
if (!hasConfig) {
|
||||
showErrorToast("Please configure sync service first");
|
||||
showErrorToast(t("sync.mode.noPasswordWarning"));
|
||||
onSyncConfigOpen();
|
||||
onClose();
|
||||
return;
|
||||
@@ -105,17 +121,17 @@ export function ProfileSyncDialog({
|
||||
setIsSyncing(true);
|
||||
try {
|
||||
await invoke("request_profile_sync", { profileId: profile.id });
|
||||
showSuccessToast("Sync queued");
|
||||
showSuccessToast(t("sync.mode.syncQueued"));
|
||||
} catch (error) {
|
||||
console.error("Failed to queue sync:", error);
|
||||
showErrorToast("Failed to queue sync");
|
||||
showErrorToast(String(error));
|
||||
} finally {
|
||||
setIsSyncing(false);
|
||||
}
|
||||
}, [profile, hasConfig, onSyncConfigOpen, onClose]);
|
||||
}, [profile, hasConfig, onSyncConfigOpen, onClose, t]);
|
||||
|
||||
const formatLastSync = (timestamp?: number) => {
|
||||
if (!timestamp) return "Never";
|
||||
if (!timestamp) return t("common.labels.never", "Never");
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString();
|
||||
};
|
||||
@@ -126,9 +142,12 @@ export function ProfileSyncDialog({
|
||||
<Dialog open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Profile Sync</DialogTitle>
|
||||
<DialogTitle>{t("sync.mode.title", "Profile Sync")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage sync settings for "{profile.name}"
|
||||
{t("sync.mode.description", {
|
||||
name: profile.name,
|
||||
defaultValue: `Manage sync settings for "${profile.name}"`,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -140,7 +159,9 @@ export function ProfileSyncDialog({
|
||||
<div className="grid gap-4 py-4">
|
||||
{!hasConfig && (
|
||||
<div className="p-3 text-sm rounded-md bg-muted">
|
||||
<p className="mb-2">Sync service not configured.</p>
|
||||
<p className="mb-2">
|
||||
{t("sync.mode.notConfigured", "Sync service not configured.")}
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
@@ -149,39 +170,87 @@ export function ProfileSyncDialog({
|
||||
onClose();
|
||||
}}
|
||||
>
|
||||
Configure Sync Service
|
||||
{t("sync.mode.configureService", "Configure Sync Service")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hasConfig && (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="sync-enabled">Sync Enabled</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Sync this profile across devices
|
||||
</p>
|
||||
<RadioGroup
|
||||
value={syncMode}
|
||||
onValueChange={handleModeChange}
|
||||
disabled={isSaving}
|
||||
className="grid gap-3"
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="Disabled" id="sync-disabled" />
|
||||
<Label htmlFor="sync-disabled" className="cursor-pointer">
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.disabled", "Disabled")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sync.mode.disabledDescription",
|
||||
"No sync for this profile",
|
||||
)}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
<Checkbox
|
||||
id="sync-enabled"
|
||||
checked={syncEnabled}
|
||||
onCheckedChange={handleToggleSync}
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="Regular" id="sync-regular" />
|
||||
<Label htmlFor="sync-regular" className="cursor-pointer">
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.regular", "Regular Sync")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sync.mode.regularDescription",
|
||||
"Fast sync, unencrypted",
|
||||
)}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="Encrypted" id="sync-encrypted" />
|
||||
<Label htmlFor="sync-encrypted" className="cursor-pointer">
|
||||
<span className="font-medium">
|
||||
{t("sync.mode.encrypted", "E2E Encrypted Sync")}
|
||||
</span>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"sync.mode.encryptedDescription",
|
||||
"Encrypted before upload. Server never sees plaintext data.",
|
||||
)}
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
|
||||
{syncMode === "Encrypted" && !hasE2ePassword && (
|
||||
<div className="p-3 text-sm rounded-md bg-destructive/10 text-destructive">
|
||||
{t(
|
||||
"sync.mode.noPasswordWarning",
|
||||
"E2E password not set. Please set a password in Settings.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Last Synced</Label>
|
||||
<Label>{t("sync.mode.lastSynced", "Last Synced")}</Label>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Badge variant="outline">
|
||||
{formatLastSync(profile.last_sync)}
|
||||
</Badge>
|
||||
{syncEnabled && (
|
||||
{isSyncEnabled(profile) && (
|
||||
<Badge
|
||||
variant={profile.last_sync ? "default" : "secondary"}
|
||||
>
|
||||
{profile.last_sync ? "Synced" : "Pending"}
|
||||
{profile.last_sync
|
||||
? t("common.status.synced")
|
||||
: t("common.status.pending")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
@@ -193,11 +262,11 @@ export function ProfileSyncDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
{t("common.buttons.close")}
|
||||
</Button>
|
||||
{hasConfig && syncEnabled && (
|
||||
{hasConfig && isSyncEnabled(profile) && (
|
||||
<LoadingButton onClick={handleSyncNow} isLoading={isSyncing}>
|
||||
Sync Now
|
||||
{t("sync.mode.syncNow", "Sync Now")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,11 +18,13 @@ import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BrowserProfile, StoredProxy } from "@/types";
|
||||
import type { BrowserProfile, StoredProxy, VpnConfig } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProxyAssignmentDialogProps {
|
||||
@@ -31,6 +34,7 @@ interface ProxyAssignmentDialogProps {
|
||||
onAssignmentComplete: () => void;
|
||||
profiles?: BrowserProfile[];
|
||||
storedProxies?: StoredProxy[];
|
||||
vpnConfigs?: VpnConfig[];
|
||||
}
|
||||
|
||||
export function ProxyAssignmentDialog({
|
||||
@@ -40,11 +44,28 @@ export function ProxyAssignmentDialog({
|
||||
onAssignmentComplete,
|
||||
profiles = [],
|
||||
storedProxies = [],
|
||||
vpnConfigs = [],
|
||||
}: ProxyAssignmentDialogProps) {
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(null);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectionType, setSelectionType] = useState<"none" | "proxy" | "vpn">(
|
||||
"none",
|
||||
);
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleValueChange = useCallback((value: string) => {
|
||||
if (value === "none") {
|
||||
setSelectedId(null);
|
||||
setSelectionType("none");
|
||||
} else if (value.startsWith("vpn-")) {
|
||||
setSelectedId(value.slice(4));
|
||||
setSelectionType("vpn");
|
||||
} else {
|
||||
setSelectedId(value);
|
||||
setSelectionType("proxy");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAssign = useCallback(async () => {
|
||||
setIsAssigning(true);
|
||||
setError(null);
|
||||
@@ -60,24 +81,29 @@ export function ProxyAssignmentDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Update each profile's proxy sequentially to avoid file locking issues
|
||||
for (const profileId of validProfiles) {
|
||||
await invoke("update_profile_proxy", {
|
||||
profileId,
|
||||
proxyId: selectedProxyId,
|
||||
});
|
||||
if (selectionType === "vpn") {
|
||||
await invoke("update_profile_vpn", {
|
||||
profileId,
|
||||
vpnId: selectedId,
|
||||
});
|
||||
} else {
|
||||
await invoke("update_profile_proxy", {
|
||||
profileId,
|
||||
proxyId: selectionType === "proxy" ? selectedId : null,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Notify other parts of the app so usage counts and lists refresh
|
||||
await emit("profile-updated");
|
||||
onAssignmentComplete();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to assign proxies to profiles:", err);
|
||||
console.error("Failed to assign proxy/VPN to profiles:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to assign proxies to profiles";
|
||||
: "Failed to assign proxy/VPN to profiles";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
@@ -85,7 +111,8 @@ export function ProxyAssignmentDialog({
|
||||
}
|
||||
}, [
|
||||
selectedProfiles,
|
||||
selectedProxyId,
|
||||
selectedId,
|
||||
selectionType,
|
||||
profiles,
|
||||
onAssignmentComplete,
|
||||
onClose,
|
||||
@@ -93,18 +120,27 @@ export function ProxyAssignmentDialog({
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setSelectedProxyId(null);
|
||||
setSelectedId(null);
|
||||
setSelectionType("none");
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const selectValue =
|
||||
selectionType === "none"
|
||||
? "none"
|
||||
: selectionType === "vpn"
|
||||
? `vpn-${selectedId}`
|
||||
: (selectedId ?? "none");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign Proxy</DialogTitle>
|
||||
<DialogTitle>Assign Proxy / VPN</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign a proxy to {selectedProfiles.length} selected profile(s).
|
||||
Assign a proxy or VPN to {selectedProfiles.length} selected
|
||||
profile(s).
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -120,7 +156,7 @@ export function ProxyAssignmentDialog({
|
||||
const displayName = profile ? profile.name : profileId;
|
||||
return (
|
||||
<li key={profileId} className="truncate">
|
||||
• {displayName}
|
||||
• {displayName}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
@@ -129,24 +165,42 @@ export function ProxyAssignmentDialog({
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxy-select">Assign Proxy:</Label>
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) => {
|
||||
setSelectedProxyId(value === "none" ? null : value);
|
||||
}}
|
||||
>
|
||||
<Label htmlFor="proxy-vpn-select">Assign Proxy / VPN:</Label>
|
||||
<Select value={selectValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a proxy" />
|
||||
<SelectValue placeholder="Select a proxy or VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No Proxy</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
{proxy.is_cloud_managed ? " (Included)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="none">None</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Proxies</SelectLabel>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
{proxy.is_cloud_managed ? " (Included)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
{vpnConfigs.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>VPNs</SelectLabel>
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<SelectItem key={vpn.id} value={`vpn-${vpn.id}`}>
|
||||
<span className="flex items-center gap-1">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] px-1 py-0 leading-tight"
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LuShield, LuUpload } from "react-icons/lu";
|
||||
import { LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
@@ -22,8 +22,6 @@ import type {
|
||||
ParsedProxyLine,
|
||||
ProxyImportResult,
|
||||
ProxyParseResult,
|
||||
VpnImportResult,
|
||||
VpnType,
|
||||
} from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -32,13 +30,7 @@ interface ProxyImportDialogProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
type ImportStep =
|
||||
| "dropzone"
|
||||
| "preview"
|
||||
| "ambiguous"
|
||||
| "result"
|
||||
| "vpn-preview"
|
||||
| "vpn-result";
|
||||
type ImportStep = "dropzone" | "preview" | "ambiguous" | "result";
|
||||
|
||||
interface AmbiguousProxy {
|
||||
line: string;
|
||||
@@ -46,13 +38,6 @@ interface AmbiguousProxy {
|
||||
selectedFormat?: string;
|
||||
}
|
||||
|
||||
interface VpnPreviewData {
|
||||
content: string;
|
||||
filename: string;
|
||||
detectedType: VpnType | null;
|
||||
endpoint: string | null;
|
||||
}
|
||||
|
||||
export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
const [step, setStep] = useState<ImportStep>("dropzone");
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
@@ -68,11 +53,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [namePrefix, setNamePrefix] = useState("Imported");
|
||||
// VPN import state
|
||||
const [vpnPreview, setVpnPreview] = useState<VpnPreviewData | null>(null);
|
||||
const [vpnName, setVpnName] = useState("");
|
||||
const [vpnImportResult, setVpnImportResult] =
|
||||
useState<VpnImportResult | null>(null);
|
||||
|
||||
const os = getCurrentOS();
|
||||
const modKey = os === "macos" ? "⌘" : "Ctrl";
|
||||
@@ -86,76 +66,11 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
setImportResult(null);
|
||||
setIsImporting(false);
|
||||
setNamePrefix("Imported");
|
||||
// Reset VPN state
|
||||
setVpnPreview(null);
|
||||
setVpnName("");
|
||||
setVpnImportResult(null);
|
||||
}, []);
|
||||
|
||||
// Detect VPN type from content
|
||||
const detectVpnType = useCallback(
|
||||
(
|
||||
content: string,
|
||||
filename: string,
|
||||
): { isVpn: boolean; type: VpnType | null; endpoint: string | null } => {
|
||||
const lowerFilename = filename.toLowerCase();
|
||||
|
||||
// Check for WireGuard config
|
||||
if (
|
||||
lowerFilename.endsWith(".conf") &&
|
||||
content.includes("[Interface]") &&
|
||||
content.includes("[Peer]")
|
||||
) {
|
||||
// Extract endpoint from WireGuard config
|
||||
const endpointMatch = content.match(/Endpoint\s*=\s*([^\s\n]+)/i);
|
||||
return {
|
||||
isVpn: true,
|
||||
type: "WireGuard",
|
||||
endpoint: endpointMatch ? endpointMatch[1] : null,
|
||||
};
|
||||
}
|
||||
|
||||
// Check for OpenVPN config
|
||||
if (
|
||||
lowerFilename.endsWith(".ovpn") ||
|
||||
(content.includes("remote ") &&
|
||||
(content.includes("client") || content.includes("dev tun")))
|
||||
) {
|
||||
// Extract remote from OpenVPN config
|
||||
const remoteMatch = content.match(/remote\s+(\S+)(?:\s+(\d+))?/i);
|
||||
const endpoint = remoteMatch
|
||||
? `${remoteMatch[1]}${remoteMatch[2] ? `:${remoteMatch[2]}` : ""}`
|
||||
: null;
|
||||
return { isVpn: true, type: "OpenVPN", endpoint };
|
||||
}
|
||||
|
||||
return { isVpn: false, type: null, endpoint: null };
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const processContent = useCallback(
|
||||
async (content: string, isJson: boolean, filename: string = "") => {
|
||||
async (content: string, isJson: boolean, _filename: string = "") => {
|
||||
try {
|
||||
// Check if it's a VPN config
|
||||
const vpnDetection = detectVpnType(content, filename);
|
||||
if (vpnDetection.isVpn) {
|
||||
setVpnPreview({
|
||||
content,
|
||||
filename,
|
||||
detectedType: vpnDetection.type,
|
||||
endpoint: vpnDetection.endpoint,
|
||||
});
|
||||
// Generate default name from filename
|
||||
const baseName = filename
|
||||
.replace(/\.(conf|ovpn)$/i, "")
|
||||
.replace(/_/g, " ")
|
||||
.replace(/-/g, " ");
|
||||
setVpnName(baseName || `${vpnDetection.type} VPN`);
|
||||
setStep("vpn-preview");
|
||||
return;
|
||||
}
|
||||
|
||||
if (isJson) {
|
||||
setIsImporting(true);
|
||||
const result = await invoke<ProxyImportResult>(
|
||||
@@ -213,7 +128,7 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
setIsImporting(false);
|
||||
}
|
||||
},
|
||||
[detectVpnType],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleFileRead = useCallback(
|
||||
@@ -239,17 +154,13 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const validFile = files.find(
|
||||
(f) =>
|
||||
f.name.endsWith(".json") ||
|
||||
f.name.endsWith(".txt") ||
|
||||
f.name.endsWith(".conf") || // WireGuard
|
||||
f.name.endsWith(".ovpn"), // OpenVPN
|
||||
(f) => f.name.endsWith(".json") || f.name.endsWith(".txt"),
|
||||
);
|
||||
|
||||
if (validFile) {
|
||||
handleFileRead(validFile);
|
||||
} else {
|
||||
toast.error("Please drop a .json, .txt, .conf, or .ovpn file");
|
||||
toast.error("Please drop a .json or .txt file");
|
||||
}
|
||||
},
|
||||
[handleFileRead],
|
||||
@@ -311,33 +222,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
}
|
||||
}, [parsedProxies, namePrefix]);
|
||||
|
||||
const handleVpnImport = useCallback(async () => {
|
||||
if (!vpnPreview) return;
|
||||
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const result = await invoke<VpnImportResult>("import_vpn_config", {
|
||||
content: vpnPreview.content,
|
||||
filename: vpnPreview.filename,
|
||||
name: vpnName.trim() || null,
|
||||
});
|
||||
|
||||
setVpnImportResult(result);
|
||||
setStep("vpn-result");
|
||||
|
||||
if (result.success) {
|
||||
await emit("vpn-configs-changed");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to import VPN config:", error);
|
||||
toast.error(
|
||||
error instanceof Error ? error.message : "Failed to import VPN config",
|
||||
);
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [vpnPreview, vpnName]);
|
||||
|
||||
const handleAmbiguousFormatSelect = useCallback(
|
||||
(index: number, format: string) => {
|
||||
setAmbiguousProxies((prev) =>
|
||||
@@ -389,20 +273,13 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{step === "vpn-preview" || step === "vpn-result"
|
||||
? "Import VPN Config"
|
||||
: "Import Proxies"}
|
||||
</DialogTitle>
|
||||
<DialogTitle>Import Proxies</DialogTitle>
|
||||
<DialogDescription>
|
||||
{step === "dropzone" &&
|
||||
"Import proxies from a JSON or TXT file, or VPN configs (.conf/.ovpn)"}
|
||||
{step === "dropzone" && "Import proxies from a JSON or TXT file"}
|
||||
{step === "preview" && "Review the proxies to import"}
|
||||
{step === "ambiguous" &&
|
||||
"Some proxies have ambiguous formats. Please select the correct format."}
|
||||
{step === "result" && "Import completed"}
|
||||
{step === "vpn-preview" && "Review the VPN configuration to import"}
|
||||
{step === "vpn-result" && "VPN import completed"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -432,14 +309,14 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Drop a proxy or VPN config file
|
||||
Drop a proxy config file
|
||||
<br />
|
||||
<span className="text-xs">(.json, .txt, .conf, .ovpn)</span>
|
||||
<span className="text-xs">(.json, .txt)</span>
|
||||
</p>
|
||||
<input
|
||||
id="proxy-file-input"
|
||||
type="file"
|
||||
accept=".json,.txt,.conf,.ovpn"
|
||||
accept=".json,.txt"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
@@ -594,75 +471,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && vpnPreview && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
|
||||
<LuShield className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{vpnPreview.detectedType} Configuration
|
||||
</div>
|
||||
{vpnPreview.endpoint && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Endpoint: {vpnPreview.endpoint}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="vpn-name">VPN Name</Label>
|
||||
<Input
|
||||
id="vpn-name"
|
||||
placeholder="My VPN"
|
||||
value={vpnName}
|
||||
onChange={(e) => setVpnName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>Config Preview</Label>
|
||||
<ScrollArea className="h-[150px] border rounded-md">
|
||||
<pre className="p-2 text-xs font-mono whitespace-pre-wrap break-all">
|
||||
{vpnPreview.content.slice(0, 1000)}
|
||||
{vpnPreview.content.length > 1000 && "..."}
|
||||
</pre>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && vpnImportResult && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
className={`p-4 rounded-lg ${vpnImportResult.success ? "bg-green-500/10" : "bg-red-500/10"}`}
|
||||
>
|
||||
{vpnImportResult.success ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<LuShield className="w-8 h-8 text-green-600 dark:text-green-400" />
|
||||
<div>
|
||||
<div className="font-medium text-green-600 dark:text-green-400">
|
||||
VPN Imported Successfully
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{vpnImportResult.name} ({vpnImportResult.vpn_type})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium text-red-600 dark:text-red-400">
|
||||
Import Failed
|
||||
</div>
|
||||
<div className="text-sm text-red-600 dark:text-red-400">
|
||||
{vpnImportResult.error}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{step === "dropzone" && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
@@ -702,24 +510,6 @@ export function ProxyImportDialog({ isOpen, onClose }: ProxyImportDialogProps) {
|
||||
{step === "result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
)}
|
||||
|
||||
{step === "vpn-preview" && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleVpnImport()}
|
||||
>
|
||||
Import VPN
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{step === "vpn-result" && (
|
||||
<RippleButton onClick={handleClose}>Done</RippleButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -30,26 +30,31 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { ProxyCheckResult, StoredProxy } from "@/types";
|
||||
import type { ProxyCheckResult, StoredProxy, VpnConfig } from "@/types";
|
||||
import { FlagIcon } from "./flag-icon";
|
||||
import { LocationProxyDialog } from "./location-proxy-dialog";
|
||||
import { ProxyCheckButton } from "./proxy-check-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import { VpnCheckButton } from "./vpn-check-button";
|
||||
import { VpnFormDialog } from "./vpn-form-dialog";
|
||||
import { VpnImportDialog } from "./vpn-import-dialog";
|
||||
|
||||
type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
|
||||
function getSyncStatusDot(
|
||||
proxy: StoredProxy,
|
||||
item: { sync_enabled?: boolean; last_sync?: number },
|
||||
liveStatus: SyncStatus | undefined,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (proxy.sync_enabled ? "synced" : "disabled");
|
||||
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
@@ -57,8 +62,8 @@ function getSyncStatusDot(
|
||||
case "synced":
|
||||
return {
|
||||
color: "bg-green-500",
|
||||
tooltip: proxy.last_sync
|
||||
? `Synced ${new Date(proxy.last_sync * 1000).toLocaleString()}`
|
||||
tooltip: item.last_sync
|
||||
? `Synced ${new Date(item.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
animate: false,
|
||||
};
|
||||
@@ -84,6 +89,7 @@ export function ProxyManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ProxyManagementDialogProps) {
|
||||
// Proxy state
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [showImportDialog, setShowImportDialog] = useState(false);
|
||||
const [showExportDialog, setShowExportDialog] = useState(false);
|
||||
@@ -103,7 +109,23 @@ export function ProxyManagementDialog({
|
||||
{},
|
||||
);
|
||||
|
||||
// VPN state
|
||||
const [showVpnForm, setShowVpnForm] = useState(false);
|
||||
const [showVpnImportDialog, setShowVpnImportDialog] = useState(false);
|
||||
const [editingVpn, setEditingVpn] = useState<VpnConfig | null>(null);
|
||||
const [vpnToDelete, setVpnToDelete] = useState<VpnConfig | null>(null);
|
||||
const [isDeletingVpn, setIsDeletingVpn] = useState(false);
|
||||
const [checkingVpnId, setCheckingVpnId] = useState<string | null>(null);
|
||||
const [vpnSyncStatus, setVpnSyncStatus] = useState<
|
||||
Record<string, SyncStatus>
|
||||
>({});
|
||||
const [vpnInUse, setVpnInUse] = useState<Record<string, boolean>>({});
|
||||
const [isTogglingVpnSync, setIsTogglingVpnSync] = useState<
|
||||
Record<string, boolean>
|
||||
>({});
|
||||
|
||||
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
|
||||
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
|
||||
const [cloudProxyUsage, setCloudProxyUsage] = useState<{
|
||||
used_mb: number;
|
||||
limit_mb: number;
|
||||
@@ -158,6 +180,29 @@ export function ProxyManagementDialog({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Listen for VPN sync status events
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ id: string; status: string }>(
|
||||
"vpn-sync-status",
|
||||
(event) => {
|
||||
const { id, status } = event.payload;
|
||||
setVpnSyncStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: status as SyncStatus,
|
||||
}));
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
void setupListener();
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Load cached check results on mount and when proxies change
|
||||
useEffect(() => {
|
||||
const loadCachedResults = async () => {
|
||||
@@ -190,8 +235,30 @@ export function ProxyManagementDialog({
|
||||
}
|
||||
}, [storedProxies]);
|
||||
|
||||
// Load VPN in-use status
|
||||
useEffect(() => {
|
||||
const loadVpnInUse = async () => {
|
||||
const inUse: Record<string, boolean> = {};
|
||||
for (const vpn of vpnConfigs) {
|
||||
try {
|
||||
const inUseBySynced = await invoke<boolean>(
|
||||
"is_vpn_in_use_by_synced_profile",
|
||||
{ vpnId: vpn.id },
|
||||
);
|
||||
inUse[vpn.id] = inUseBySynced;
|
||||
} catch (_error) {
|
||||
// Ignore errors
|
||||
}
|
||||
}
|
||||
setVpnInUse(inUse);
|
||||
};
|
||||
if (vpnConfigs.length > 0) {
|
||||
void loadVpnInUse();
|
||||
}
|
||||
}, [vpnConfigs]);
|
||||
|
||||
// Proxy handlers
|
||||
const handleDeleteProxy = useCallback((proxy: StoredProxy) => {
|
||||
// Open in-app confirmation dialog
|
||||
setProxyToDelete(proxy);
|
||||
}, []);
|
||||
|
||||
@@ -245,106 +312,377 @@ export function ProxyManagementDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
// VPN handlers
|
||||
const handleDeleteVpn = useCallback((vpn: VpnConfig) => {
|
||||
setVpnToDelete(vpn);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDeleteVpn = useCallback(async () => {
|
||||
if (!vpnToDelete) return;
|
||||
setIsDeletingVpn(true);
|
||||
try {
|
||||
await invoke("delete_vpn_config", { vpnId: vpnToDelete.id });
|
||||
toast.success("VPN deleted successfully");
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete VPN:", error);
|
||||
toast.error("Failed to delete VPN");
|
||||
} finally {
|
||||
setIsDeletingVpn(false);
|
||||
setVpnToDelete(null);
|
||||
}
|
||||
}, [vpnToDelete]);
|
||||
|
||||
const handleCreateVpn = useCallback(() => {
|
||||
setEditingVpn(null);
|
||||
setShowVpnForm(true);
|
||||
}, []);
|
||||
|
||||
const handleEditVpn = useCallback((vpn: VpnConfig) => {
|
||||
setEditingVpn(vpn);
|
||||
setShowVpnForm(true);
|
||||
}, []);
|
||||
|
||||
const handleVpnFormClose = useCallback(() => {
|
||||
setShowVpnForm(false);
|
||||
setEditingVpn(null);
|
||||
}, []);
|
||||
|
||||
const handleToggleVpnSync = useCallback(async (vpn: VpnConfig) => {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: true }));
|
||||
try {
|
||||
await invoke("set_vpn_sync_enabled", {
|
||||
vpnId: vpn.id,
|
||||
enabled: !vpn.sync_enabled,
|
||||
});
|
||||
showSuccessToast(vpn.sync_enabled ? "Sync disabled" : "Sync enabled");
|
||||
await emit("vpn-configs-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to toggle VPN sync:", error);
|
||||
showErrorToast(
|
||||
error instanceof Error ? error.message : "Failed to update sync",
|
||||
);
|
||||
} finally {
|
||||
setIsTogglingVpnSync((prev) => ({ ...prev, [vpn.id]: false }));
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Proxy Management</DialogTitle>
|
||||
<DialogTitle>Proxies & VPNs</DialogTitle>
|
||||
<DialogDescription>
|
||||
Manage your saved proxy configurations for reuse across profiles
|
||||
Manage your proxy and VPN configurations for reuse across profiles
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Proxy actions */}
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowExportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Export
|
||||
</RippleButton>
|
||||
<Tabs defaultValue="proxies">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="proxies" className="flex-1">
|
||||
Proxies
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vpns" className="flex-1">
|
||||
VPNs
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="proxies">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowExportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
disabled={storedProxies.length === 0}
|
||||
>
|
||||
<LuDownload className="w-4 h-4" />
|
||||
Export
|
||||
</RippleButton>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{storedProxies.some((p) => p.is_cloud_managed) && (
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowLocationDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoGlobe className="w-4 h-4" />
|
||||
Location
|
||||
</RippleButton>
|
||||
)}
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No proxies created yet. Create your first proxy using the
|
||||
button above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storedProxies.map((proxy) => {
|
||||
const isCloud = proxy.is_cloud_managed === true;
|
||||
const syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
);
|
||||
const isDerived = proxy.is_cloud_derived === true;
|
||||
return (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDerived && proxy.geo_country && (
|
||||
<FlagIcon
|
||||
countryCode={proxy.geo_country}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
{!isCloud && !isDerived && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{proxy.name}
|
||||
</div>
|
||||
{isCloud && cloudProxyUsage && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{cloudProxyUsage.used_mb} /{" "}
|
||||
{cloudProxyUsage.limit_mb} MB used
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isCloud ? (
|
||||
<Badge variant="outline">Cloud</Badge>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={proxy.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleSync(proxy)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[proxy.id] ||
|
||||
proxyInUse[proxy.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{proxyInUse[proxy.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this
|
||||
proxy is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{proxy.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<ProxyCheckButton
|
||||
proxy={proxy}
|
||||
profileId={proxy.id}
|
||||
checkingProfileId={checkingProxyId}
|
||||
cachedResult={proxyCheckResults[proxy.id]}
|
||||
setCheckingProfileId={setCheckingProxyId}
|
||||
onCheckComplete={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
onCheckFailed={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!isCloud && !isDerived && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleEditProxy(proxy)
|
||||
}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isCloud && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteProxy(proxy)
|
||||
}
|
||||
disabled={
|
||||
(proxyUsage[proxy.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{proxyUsage[proxy.id]} profile
|
||||
{proxyUsage[proxy.id] > 1
|
||||
? "s"
|
||||
: ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete proxy</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
{storedProxies.some((p) => p.is_cloud_managed) && (
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vpns">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowVpnImportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
</RippleButton>
|
||||
</div>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowLocationDialog(true)}
|
||||
onClick={handleCreateVpn}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoGlobe className="w-4 h-4" />
|
||||
Location
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
)}
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Proxies list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No proxies created yet. Create your first proxy using the button
|
||||
above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{storedProxies.map((proxy) => {
|
||||
const isCloud = proxy.is_cloud_managed === true;
|
||||
const syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
);
|
||||
const isDerived = proxy.is_cloud_derived === true;
|
||||
return (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDerived && proxy.geo_country && (
|
||||
<FlagIcon
|
||||
countryCode={proxy.geo_country}
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
{!isCloud && !isDerived && (
|
||||
{isLoadingVpns ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading VPNs...
|
||||
</div>
|
||||
) : vpnConfigs.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No VPN configs created yet. Import or create one using the
|
||||
buttons above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-16">Type</TableHead>
|
||||
<TableHead className="w-20">Usage</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{vpnConfigs.map((vpn) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
vpn,
|
||||
vpnSyncStatus[vpn.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={vpn.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -359,137 +697,115 @@ export function ProxyManagementDialog({
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{proxy.name}
|
||||
</div>
|
||||
{isCloud && cloudProxyUsage && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{cloudProxyUsage.used_mb} /{" "}
|
||||
{cloudProxyUsage.limit_mb} MB used
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isCloud ? (
|
||||
<Badge variant="outline">Cloud</Badge>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={proxy.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleSync(proxy)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[proxy.id] ||
|
||||
proxyInUse[proxy.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{proxyInUse[proxy.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this proxy
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{proxy.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<ProxyCheckButton
|
||||
proxy={proxy}
|
||||
profileId={proxy.id}
|
||||
checkingProfileId={checkingProxyId}
|
||||
cachedResult={proxyCheckResults[proxy.id]}
|
||||
setCheckingProfileId={setCheckingProxyId}
|
||||
onCheckComplete={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
onCheckFailed={(result) => {
|
||||
setProxyCheckResults((prev) => ({
|
||||
...prev,
|
||||
[proxy.id]: result,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!isCloud && !isDerived && (
|
||||
{vpn.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{vpn.vpn_type === "WireGuard"
|
||||
? "WG"
|
||||
: "OVPN"}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{vpnUsage[vpn.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditProxy(proxy)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isCloud && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() =>
|
||||
handleDeleteProxy(proxy)
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={vpn.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleVpnSync(vpn)
|
||||
}
|
||||
disabled={
|
||||
(proxyUsage[proxy.id] ?? 0) > 0
|
||||
isTogglingVpnSync[vpn.id] ||
|
||||
vpnInUse[vpn.id]
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||
{vpnInUse[vpn.id] ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{proxyUsage[proxy.id]} profile
|
||||
{proxyUsage[proxy.id] > 1 ? "s" : ""}
|
||||
Sync cannot be disabled while this VPN
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete proxy</p>
|
||||
<p>
|
||||
{vpn.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<VpnCheckButton
|
||||
vpnId={vpn.id}
|
||||
vpnName={vpn.name}
|
||||
checkingVpnId={checkingVpnId}
|
||||
setCheckingVpnId={setCheckingVpnId}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditVpn(vpn)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit VPN</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteVpn(vpn)}
|
||||
disabled={
|
||||
(vpnUsage[vpn.id] ?? 0) > 0
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{vpnUsage[vpn.id]} profile
|
||||
{vpnUsage[vpn.id] > 1 ? "s" : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete VPN</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
@@ -525,6 +841,25 @@ export function ProxyManagementDialog({
|
||||
isOpen={showLocationDialog}
|
||||
onClose={() => setShowLocationDialog(false)}
|
||||
/>
|
||||
|
||||
<VpnFormDialog
|
||||
isOpen={showVpnForm}
|
||||
onClose={handleVpnFormClose}
|
||||
editingVpn={editingVpn}
|
||||
/>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={vpnToDelete !== null}
|
||||
onClose={() => setVpnToDelete(null)}
|
||||
onConfirm={handleConfirmDeleteVpn}
|
||||
title="Delete VPN"
|
||||
description={`This action cannot be undone. This will permanently delete the VPN "${vpnToDelete?.name ?? ""}".`}
|
||||
confirmButtonText="Delete"
|
||||
isLoading={isDeletingVpn}
|
||||
/>
|
||||
<VpnImportDialog
|
||||
isOpen={showVpnImportDialog}
|
||||
onClose={() => setShowVpnImportDialog(false)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ColorPicker,
|
||||
ColorPickerAlpha,
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
@@ -113,6 +115,11 @@ export function SettingsDialog({
|
||||
const [requestingPermission, setRequestingPermission] =
|
||||
useState<PermissionType | null>(null);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [hasE2ePassword, setHasE2ePassword] = useState(false);
|
||||
const [e2ePassword, setE2ePassword] = useState("");
|
||||
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
|
||||
const [e2eError, setE2eError] = useState("");
|
||||
const [isSavingE2e, setIsSavingE2e] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
const { setTheme } = useTheme();
|
||||
@@ -202,6 +209,13 @@ export function SettingsDialog({
|
||||
colors: tokyoNightTheme.colors,
|
||||
});
|
||||
}
|
||||
// Check E2E password status
|
||||
try {
|
||||
const hasPassword = await invoke<boolean>("check_has_e2e_password");
|
||||
setHasE2ePassword(hasPassword);
|
||||
} catch {
|
||||
setHasE2ePassword(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
} finally {
|
||||
@@ -827,6 +841,145 @@ export function SettingsDialog({
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{/* Sync Encryption Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.encryption.title", "Sync Encryption")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t(
|
||||
"settings.encryption.description",
|
||||
"Set a password to enable E2E encrypted sync. If you lose this password, encrypted profiles cannot be recovered.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
{hasE2ePassword ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default">
|
||||
{t("settings.encryption.passwordSet", "Active")}
|
||||
</Badge>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"settings.encryption.passwordSetDescription",
|
||||
"E2E encryption password is set",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setHasE2ePassword(false);
|
||||
setE2ePassword("");
|
||||
setE2ePasswordConfirm("");
|
||||
setE2eError("");
|
||||
}}
|
||||
>
|
||||
{t("settings.encryption.changePassword", "Change Password")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await invoke("delete_e2e_password");
|
||||
setHasE2ePassword(false);
|
||||
showSuccessToast(
|
||||
t(
|
||||
"settings.encryption.removed",
|
||||
"Encryption password removed",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorToast(String(error));
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("settings.encryption.removePassword", "Remove Password")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t(
|
||||
"settings.encryption.passwordPlaceholder",
|
||||
"Password (min 8 characters)",
|
||||
)}
|
||||
value={e2ePassword}
|
||||
onChange={(e) => {
|
||||
setE2ePassword(e.target.value);
|
||||
setE2eError("");
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder={t(
|
||||
"settings.encryption.confirmPlaceholder",
|
||||
"Confirm password",
|
||||
)}
|
||||
value={e2ePasswordConfirm}
|
||||
onChange={(e) => {
|
||||
setE2ePasswordConfirm(e.target.value);
|
||||
setE2eError("");
|
||||
}}
|
||||
/>
|
||||
{e2eError && (
|
||||
<p className="text-sm text-destructive">{e2eError}</p>
|
||||
)}
|
||||
<LoadingButton
|
||||
variant="default"
|
||||
size="sm"
|
||||
isLoading={isSavingE2e}
|
||||
onClick={async () => {
|
||||
if (e2ePassword.length < 8) {
|
||||
setE2eError(
|
||||
t(
|
||||
"settings.encryption.passwordTooShort",
|
||||
"Password must be at least 8 characters",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (e2ePassword !== e2ePasswordConfirm) {
|
||||
setE2eError(
|
||||
t(
|
||||
"settings.encryption.passwordMismatch",
|
||||
"Passwords do not match",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
setIsSavingE2e(true);
|
||||
try {
|
||||
await invoke("set_e2e_password", {
|
||||
password: e2ePassword,
|
||||
});
|
||||
setHasE2ePassword(true);
|
||||
setE2ePassword("");
|
||||
setE2ePasswordConfirm("");
|
||||
showSuccessToast(
|
||||
t(
|
||||
"settings.encryption.passwordSaved",
|
||||
"Encryption password set",
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
showErrorToast(String(error));
|
||||
} finally {
|
||||
setIsSavingE2e(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("settings.encryption.setPassword", "Set Password")}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Commercial License Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Commercial License</Label>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,124 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
|
||||
interface UnsyncedEntityCounts {
|
||||
proxies: number;
|
||||
groups: number;
|
||||
vpns: number;
|
||||
}
|
||||
|
||||
interface SyncAllDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SyncAllDialog({ isOpen, onClose }: SyncAllDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [counts, setCounts] = useState<UnsyncedEntityCounts | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isEnabling, setIsEnabling] = useState(false);
|
||||
|
||||
const loadCounts = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await invoke<UnsyncedEntityCounts>(
|
||||
"get_unsynced_entity_counts",
|
||||
);
|
||||
setCounts(result);
|
||||
} catch (error) {
|
||||
console.error("Failed to get unsynced entity counts:", error);
|
||||
setCounts(null);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadCounts();
|
||||
}
|
||||
}, [isOpen, loadCounts]);
|
||||
|
||||
const handleEnableAll = useCallback(async () => {
|
||||
setIsEnabling(true);
|
||||
try {
|
||||
await invoke("enable_sync_for_all_entities");
|
||||
showSuccessToast(t("syncAll.success"));
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to enable sync for all entities:", error);
|
||||
showErrorToast(String(error));
|
||||
} finally {
|
||||
setIsEnabling(false);
|
||||
}
|
||||
}, [onClose, t]);
|
||||
|
||||
const totalCount =
|
||||
(counts?.proxies ?? 0) + (counts?.groups ?? 0) + (counts?.vpns ?? 0);
|
||||
|
||||
// Don't show if there's nothing to sync
|
||||
if (!isLoading && totalCount === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
if (counts?.proxies && counts.proxies > 0) {
|
||||
parts.push(t("syncAll.proxies", { count: counts.proxies }));
|
||||
}
|
||||
if (counts?.groups && counts.groups > 0) {
|
||||
parts.push(t("syncAll.groups", { count: counts.groups }));
|
||||
}
|
||||
if (counts?.vpns && counts.vpns > 0) {
|
||||
parts.push(t("syncAll.vpns", { count: counts.vpns }));
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen && totalCount > 0} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("syncAll.title")}</DialogTitle>
|
||||
<DialogDescription>{t("syncAll.description")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-8">
|
||||
<div className="w-6 h-6 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("syncAll.itemsList", { items: parts.join(", ") })}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={onClose} disabled={isEnabling}>
|
||||
{t("syncAll.skip")}
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={handleEnableAll}
|
||||
isLoading={isEnabling}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("syncAll.enableAll")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuEye, LuEyeOff, LuLock } from "react-icons/lu";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -28,7 +29,7 @@ import type { SyncSettings } from "@/types";
|
||||
|
||||
interface SyncConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onClose: (loginOccurred?: boolean) => void;
|
||||
}
|
||||
|
||||
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
@@ -59,7 +60,21 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("cloud");
|
||||
|
||||
const isConnected = Boolean(serverUrl && token);
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
"unknown" | "testing" | "connected" | "error"
|
||||
>("unknown");
|
||||
const hasConfig = Boolean(serverUrl && token);
|
||||
|
||||
const testConnection = useCallback(async (url: string) => {
|
||||
setConnectionStatus("testing");
|
||||
try {
|
||||
const healthUrl = `${url.replace(/\/$/, "")}/health`;
|
||||
const response = await fetch(healthUrl);
|
||||
setConnectionStatus(response.ok ? "connected" : "error");
|
||||
} catch {
|
||||
setConnectionStatus("error");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -67,15 +82,19 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const settings = await invoke<SyncSettings>("get_sync_settings");
|
||||
setServerUrl(settings.sync_server_url || "");
|
||||
setToken(settings.sync_token || "");
|
||||
if (settings.sync_server_url && settings.sync_token) {
|
||||
void testConnection(settings.sync_server_url);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load sync settings:", error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
}, [testConnection]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setConnectionStatus("unknown");
|
||||
void loadSettings();
|
||||
setCodeSent(false);
|
||||
setOtpCode("");
|
||||
@@ -102,15 +121,19 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
}
|
||||
|
||||
setIsTesting(true);
|
||||
setConnectionStatus("testing");
|
||||
try {
|
||||
const healthUrl = `${serverUrl.replace(/\/$/, "")}/health`;
|
||||
const response = await fetch(healthUrl);
|
||||
if (response.ok) {
|
||||
setConnectionStatus("connected");
|
||||
showSuccessToast("Connection successful!");
|
||||
} else {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast("Server responded with an error");
|
||||
}
|
||||
} catch {
|
||||
setConnectionStatus("error");
|
||||
showErrorToast("Failed to connect to server");
|
||||
} finally {
|
||||
setIsTesting(false);
|
||||
@@ -124,6 +147,11 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
syncServerUrl: serverUrl || null,
|
||||
syncToken: token || null,
|
||||
});
|
||||
try {
|
||||
await invoke("restart_sync_service");
|
||||
} catch (e) {
|
||||
console.error("Failed to restart sync service:", e);
|
||||
}
|
||||
showSuccessToast("Sync settings saved");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
@@ -141,8 +169,14 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
syncServerUrl: null,
|
||||
syncToken: null,
|
||||
});
|
||||
try {
|
||||
await invoke("restart_sync_service");
|
||||
} catch (e) {
|
||||
console.error("Failed to restart sync service:", e);
|
||||
}
|
||||
setServerUrl("");
|
||||
setToken("");
|
||||
setConnectionStatus("unknown");
|
||||
showSuccessToast("Sync disconnected");
|
||||
} catch (error) {
|
||||
console.error("Failed to disconnect:", error);
|
||||
@@ -179,8 +213,8 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
} catch (e) {
|
||||
console.error("Failed to restart sync service:", e);
|
||||
}
|
||||
// Auto-close dialog after successful login
|
||||
onClose();
|
||||
// Auto-close dialog after successful login, signal that login occurred
|
||||
onClose(true);
|
||||
} catch (error) {
|
||||
console.error("OTP verification failed:", error);
|
||||
showErrorToast(String(error));
|
||||
@@ -208,7 +242,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
}, [logout, t]);
|
||||
|
||||
// Determine which tabs are available
|
||||
const cloudBlocked = !isLoggedIn && isConnected;
|
||||
const cloudBlocked = !isLoggedIn && hasConfig;
|
||||
const selfHostedBlocked = isLoggedIn;
|
||||
|
||||
return (
|
||||
@@ -294,9 +328,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{t("sync.cloud.tabLabel")}
|
||||
{cloudBlocked && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{cloudBlocked && <ProBadge />}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
@@ -306,9 +338,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{t("sync.cloud.selfHostedTabLabel")}
|
||||
{selfHostedBlocked && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{selfHostedBlocked && <ProBadge />}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
@@ -430,17 +460,29 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isConnected && (
|
||||
{connectionStatus === "testing" && (
|
||||
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
||||
<div className="w-4 h-4 rounded-full border-2 border-current animate-spin border-t-transparent" />
|
||||
{t("sync.status.syncing")}
|
||||
</div>
|
||||
)}
|
||||
{connectionStatus === "connected" && (
|
||||
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
{t("sync.status.connected")}
|
||||
</div>
|
||||
)}
|
||||
{connectionStatus === "error" && (
|
||||
<div className="flex gap-2 items-center text-sm text-muted-foreground">
|
||||
<div className="w-2 h-2 rounded-full bg-red-500" />
|
||||
{t("sync.status.disconnected")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
{isConnected && (
|
||||
{hasConfig && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDisconnect}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ProBadge({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold px-1 py-0.5 rounded bg-primary text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
PRO
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { FiCheck } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import type { ProxyCheckResult } from "@/types";
|
||||
|
||||
interface VpnCheckButtonProps {
|
||||
vpnId: string;
|
||||
vpnName: string;
|
||||
checkingVpnId: string | null;
|
||||
setCheckingVpnId: (id: string | null) => void;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function VpnCheckButton({
|
||||
vpnId,
|
||||
vpnName,
|
||||
checkingVpnId,
|
||||
setCheckingVpnId,
|
||||
disabled = false,
|
||||
}: VpnCheckButtonProps) {
|
||||
const [result, setResult] = React.useState<ProxyCheckResult | undefined>();
|
||||
|
||||
const handleCheck = React.useCallback(async () => {
|
||||
if (checkingVpnId === vpnId) return;
|
||||
|
||||
setCheckingVpnId(vpnId);
|
||||
try {
|
||||
const checkResult = await invoke<ProxyCheckResult>("check_vpn_validity", {
|
||||
vpnId,
|
||||
});
|
||||
setResult(checkResult);
|
||||
|
||||
if (checkResult.is_valid) {
|
||||
toast.success(`VPN "${vpnName}" configuration is valid`);
|
||||
} else {
|
||||
toast.error(`VPN "${vpnName}" configuration is invalid`);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`VPN check failed: ${errorMessage}`);
|
||||
|
||||
setResult({
|
||||
ip: "",
|
||||
timestamp: Math.floor(Date.now() / 1000),
|
||||
is_valid: false,
|
||||
});
|
||||
} finally {
|
||||
setCheckingVpnId(null);
|
||||
}
|
||||
}, [vpnId, vpnName, checkingVpnId, setCheckingVpnId]);
|
||||
|
||||
const isCurrentlyChecking = checkingVpnId === vpnId;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0"
|
||||
onClick={handleCheck}
|
||||
disabled={isCurrentlyChecking || disabled}
|
||||
>
|
||||
{isCurrentlyChecking ? (
|
||||
<div className="w-3 h-3 rounded-full border border-current animate-spin border-t-transparent" />
|
||||
) : result?.is_valid ? (
|
||||
<FiCheck className="w-3 h-3 text-green-500" />
|
||||
) : result && !result.is_valid ? (
|
||||
<span className="text-destructive text-sm">✕</span>
|
||||
) : (
|
||||
<FiCheck className="w-3 h-3" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{isCurrentlyChecking ? (
|
||||
<p>Checking VPN config...</p>
|
||||
) : result?.is_valid ? (
|
||||
<div className="space-y-1">
|
||||
<p>Configuration valid</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
) : result && !result.is_valid ? (
|
||||
<div>
|
||||
<p>Configuration invalid</p>
|
||||
<p className="text-xs text-primary-foreground/70">
|
||||
Checked {formatRelativeTime(result.timestamp)}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p>Check VPN config validity</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import type { VpnConfig, VpnType } from "@/types";
|
||||
|
||||
interface VpnFormDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
editingVpn?: VpnConfig | null;
|
||||
}
|
||||
|
||||
interface WireGuardFormData {
|
||||
name: string;
|
||||
privateKey: string;
|
||||
address: string;
|
||||
dns: string;
|
||||
mtu: string;
|
||||
peerPublicKey: string;
|
||||
peerEndpoint: string;
|
||||
allowedIps: string;
|
||||
persistentKeepalive: string;
|
||||
presharedKey: string;
|
||||
}
|
||||
|
||||
interface OpenVpnFormData {
|
||||
name: string;
|
||||
rawConfig: string;
|
||||
}
|
||||
|
||||
const defaultWireGuardForm: WireGuardFormData = {
|
||||
name: "",
|
||||
privateKey: "",
|
||||
address: "",
|
||||
dns: "",
|
||||
mtu: "",
|
||||
peerPublicKey: "",
|
||||
peerEndpoint: "",
|
||||
allowedIps: "0.0.0.0/0, ::/0",
|
||||
persistentKeepalive: "",
|
||||
presharedKey: "",
|
||||
};
|
||||
|
||||
const defaultOpenVpnForm: OpenVpnFormData = {
|
||||
name: "",
|
||||
rawConfig: "",
|
||||
};
|
||||
|
||||
function buildWireGuardConfig(form: WireGuardFormData): string {
|
||||
const lines: string[] = ["[Interface]"];
|
||||
lines.push(`PrivateKey = ${form.privateKey.trim()}`);
|
||||
lines.push(`Address = ${form.address.trim()}`);
|
||||
if (form.dns.trim()) lines.push(`DNS = ${form.dns.trim()}`);
|
||||
if (form.mtu.trim()) lines.push(`MTU = ${form.mtu.trim()}`);
|
||||
lines.push("");
|
||||
lines.push("[Peer]");
|
||||
lines.push(`PublicKey = ${form.peerPublicKey.trim()}`);
|
||||
lines.push(`Endpoint = ${form.peerEndpoint.trim()}`);
|
||||
lines.push(`AllowedIPs = ${form.allowedIps.trim()}`);
|
||||
if (form.persistentKeepalive.trim())
|
||||
lines.push(`PersistentKeepalive = ${form.persistentKeepalive.trim()}`);
|
||||
if (form.presharedKey.trim())
|
||||
lines.push(`PresharedKey = ${form.presharedKey.trim()}`);
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
export function VpnFormDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
editingVpn,
|
||||
}: VpnFormDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [vpnType, setVpnType] = useState<VpnType>("WireGuard");
|
||||
const [wireGuardForm, setWireGuardForm] =
|
||||
useState<WireGuardFormData>(defaultWireGuardForm);
|
||||
const [openVpnForm, setOpenVpnForm] =
|
||||
useState<OpenVpnFormData>(defaultOpenVpnForm);
|
||||
|
||||
const resetForms = useCallback(() => {
|
||||
setVpnType("WireGuard");
|
||||
setWireGuardForm(defaultWireGuardForm);
|
||||
setOpenVpnForm(defaultOpenVpnForm);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (editingVpn) {
|
||||
setVpnType(editingVpn.vpn_type);
|
||||
if (editingVpn.vpn_type === "WireGuard") {
|
||||
setWireGuardForm({ ...defaultWireGuardForm, name: editingVpn.name });
|
||||
} else {
|
||||
setOpenVpnForm({ name: editingVpn.name, rawConfig: "" });
|
||||
}
|
||||
} else {
|
||||
resetForms();
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingVpn, resetForms]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
onClose();
|
||||
}
|
||||
}, [isSubmitting, onClose]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (editingVpn) {
|
||||
const name =
|
||||
vpnType === "WireGuard"
|
||||
? wireGuardForm.name.trim()
|
||||
: openVpnForm.name.trim();
|
||||
|
||||
if (!name) {
|
||||
toast.error("VPN name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await invoke("update_vpn_config", {
|
||||
vpnId: editingVpn.id,
|
||||
name,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("VPN updated successfully");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to update VPN: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (vpnType === "WireGuard") {
|
||||
const { name, privateKey, address, peerPublicKey, peerEndpoint } =
|
||||
wireGuardForm;
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error("VPN name is required");
|
||||
return;
|
||||
}
|
||||
if (!privateKey.trim()) {
|
||||
toast.error("Private key is required");
|
||||
return;
|
||||
}
|
||||
if (!address.trim()) {
|
||||
toast.error("Address is required");
|
||||
return;
|
||||
}
|
||||
if (!peerPublicKey.trim()) {
|
||||
toast.error("Peer public key is required");
|
||||
return;
|
||||
}
|
||||
if (!peerEndpoint.trim()) {
|
||||
toast.error("Peer endpoint is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const configData = buildWireGuardConfig(wireGuardForm);
|
||||
await invoke("create_vpn_config_manual", {
|
||||
name: name.trim(),
|
||||
vpnType: "WireGuard",
|
||||
configData,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("WireGuard VPN created successfully");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to create VPN: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
} else {
|
||||
const { name, rawConfig } = openVpnForm;
|
||||
|
||||
if (!name.trim()) {
|
||||
toast.error("VPN name is required");
|
||||
return;
|
||||
}
|
||||
if (!rawConfig.trim()) {
|
||||
toast.error("OpenVPN config content is required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
await invoke("create_vpn_config_manual", {
|
||||
name: name.trim(),
|
||||
vpnType: "OpenVPN",
|
||||
configData: rawConfig,
|
||||
});
|
||||
await emit("vpn-configs-changed");
|
||||
toast.success("OpenVPN configuration created successfully");
|
||||
onClose();
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to create VPN: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
}, [editingVpn, vpnType, wireGuardForm, openVpnForm, onClose]);
|
||||
|
||||
const updateWireGuard = useCallback(
|
||||
(field: keyof WireGuardFormData, value: string) => {
|
||||
setWireGuardForm((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateOpenVpn = useCallback(
|
||||
(field: keyof OpenVpnFormData, value: string) => {
|
||||
setOpenVpnForm((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const dialogTitle = editingVpn
|
||||
? "Edit VPN"
|
||||
: vpnType === "WireGuard"
|
||||
? "Create WireGuard VPN"
|
||||
: "Create OpenVPN Configuration";
|
||||
|
||||
const dialogDescription = editingVpn
|
||||
? "Update the name of your VPN configuration."
|
||||
: vpnType === "WireGuard"
|
||||
? "Enter your WireGuard interface and peer details."
|
||||
: "Paste your .ovpn configuration file content.";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||
<DialogDescription>{dialogDescription}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="max-h-[60vh] pr-4">
|
||||
<div className="grid gap-4 py-2">
|
||||
{!editingVpn && (
|
||||
<div className="grid gap-2">
|
||||
<Label>VPN Type</Label>
|
||||
<Select
|
||||
value={vpnType}
|
||||
onValueChange={(value) => setVpnType(value as VpnType)}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select VPN type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="WireGuard">WireGuard</SelectItem>
|
||||
<SelectItem value="OpenVPN">OpenVPN</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{vpnType === "WireGuard" && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-name">Name</Label>
|
||||
<Input
|
||||
id="wg-name"
|
||||
value={wireGuardForm.name}
|
||||
onChange={(e) => updateWireGuard("name", e.target.value)}
|
||||
placeholder="e.g. Home WireGuard"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingVpn && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-private-key">Private Key</Label>
|
||||
<Input
|
||||
id="wg-private-key"
|
||||
value={wireGuardForm.privateKey}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("privateKey", e.target.value)
|
||||
}
|
||||
placeholder="Base64-encoded private key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-address">Address</Label>
|
||||
<Input
|
||||
id="wg-address"
|
||||
value={wireGuardForm.address}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("address", e.target.value)
|
||||
}
|
||||
placeholder="e.g. 10.0.0.2/24"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-dns">DNS (optional)</Label>
|
||||
<Input
|
||||
id="wg-dns"
|
||||
value={wireGuardForm.dns}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("dns", e.target.value)
|
||||
}
|
||||
placeholder="e.g. 1.1.1.1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-mtu">MTU (optional)</Label>
|
||||
<Input
|
||||
id="wg-mtu"
|
||||
type="number"
|
||||
value={wireGuardForm.mtu}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("mtu", e.target.value)
|
||||
}
|
||||
placeholder="e.g. 1420"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-public-key">
|
||||
Peer Public Key
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-peer-public-key"
|
||||
value={wireGuardForm.peerPublicKey}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("peerPublicKey", e.target.value)
|
||||
}
|
||||
placeholder="Base64-encoded peer public key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-peer-endpoint">Peer Endpoint</Label>
|
||||
<Input
|
||||
id="wg-peer-endpoint"
|
||||
value={wireGuardForm.peerEndpoint}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("peerEndpoint", e.target.value)
|
||||
}
|
||||
placeholder="e.g. vpn.example.com:51820"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-allowed-ips">Allowed IPs</Label>
|
||||
<Input
|
||||
id="wg-allowed-ips"
|
||||
value={wireGuardForm.allowedIps}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("allowedIps", e.target.value)
|
||||
}
|
||||
placeholder="e.g. 0.0.0.0/0, ::/0"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-keepalive">
|
||||
Persistent Keepalive (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-keepalive"
|
||||
type="number"
|
||||
value={wireGuardForm.persistentKeepalive}
|
||||
onChange={(e) =>
|
||||
updateWireGuard(
|
||||
"persistentKeepalive",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
placeholder="e.g. 25"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="wg-preshared-key">
|
||||
Preshared Key (optional)
|
||||
</Label>
|
||||
<Input
|
||||
id="wg-preshared-key"
|
||||
value={wireGuardForm.presharedKey}
|
||||
onChange={(e) =>
|
||||
updateWireGuard("presharedKey", e.target.value)
|
||||
}
|
||||
placeholder="Base64-encoded preshared key"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{vpnType === "OpenVPN" && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ovpn-name">Name</Label>
|
||||
<Input
|
||||
id="ovpn-name"
|
||||
value={openVpnForm.name}
|
||||
onChange={(e) => updateOpenVpn("name", e.target.value)}
|
||||
placeholder="e.g. Work OpenVPN"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!editingVpn && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="ovpn-config">Raw Config</Label>
|
||||
<Textarea
|
||||
id="ovpn-config"
|
||||
value={openVpnForm.rawConfig}
|
||||
onChange={(e) =>
|
||||
updateOpenVpn("rawConfig", e.target.value)
|
||||
}
|
||||
placeholder="Paste your .ovpn file content here..."
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton isLoading={isSubmitting} onClick={handleSubmit}>
|
||||
{editingVpn ? "Update VPN" : "Create VPN"}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user