mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-07-01 02:45:31 +02:00
Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cf79f2b172 | |||
| 3669d63ddf | |||
| 478553a4a8 | |||
| 3d1471d41d | |||
| 12bc4ed08f | |||
| 48ba93cf9a | |||
| 43ee6856f9 | |||
| 56034a99d6 | |||
| a8be96d28e | |||
| 0a826ff03c | |||
| 250e206eef | |||
| dd6834a4af | |||
| 266ecda1c7 | |||
| 0d793e4cd8 | |||
| 23d25928fc | |||
| 3cb68c53ad | |||
| acd572ed23 | |||
| 9822ad4e3f | |||
| 01d600f97e | |||
| e1461693da | |||
| 576119e5a3 | |||
| 1ff17e6833 | |||
| 2ffa37371d | |||
| 6fa0f1348a | |||
| e298496fb7 | |||
| f6041192e9 | |||
| 4ef50672b4 | |||
| 3140ad99ae | |||
| 97b1225d40 | |||
| 8a96d18e46 | |||
| a723c8b30b | |||
| 4a56575dbd | |||
| 3331699540 | |||
| 1f28983a4e | |||
| 362f3e423b |
@@ -27,7 +27,7 @@ Hi there! To expedite issue processing please search open and closed issues befo
|
||||
|
||||
## Your Environment
|
||||
|
||||
<!-- Please provide as much information as you feel comfortable to help us understand the issue better -->
|
||||
<!-- Please provide as much information as you feel comfortable to help the maintainers understand the issue better -->
|
||||
|
||||
## Exception or Error or Screenshot
|
||||
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -18,31 +18,13 @@ permissions:
|
||||
id-token: write
|
||||
|
||||
jobs:
|
||||
validate-issue:
|
||||
analyze-issue:
|
||||
if: github.event_name == 'issues'
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
|
||||
- name: Save issue body to file
|
||||
env:
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
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: .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
|
||||
env:
|
||||
@@ -59,101 +41,31 @@ jobs:
|
||||
echo "is_first_time=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Parse validation result and take action
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RESPONSE_FILE: ${{ steps.validate.outputs.response-file }}
|
||||
run: |
|
||||
if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then
|
||||
RAW_OUTPUT=$(cat "$RESPONSE_FILE")
|
||||
else
|
||||
echo "::error::Response file not found: $RESPONSE_FILE"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
JSON_RESULT=$(printf "%s" "$RAW_OUTPUT" | sed -n '/```json/,/```/p' | sed '1d;$d')
|
||||
if [ -z "$JSON_RESULT" ]; then
|
||||
JSON_RESULT="$RAW_OUTPUT"
|
||||
fi
|
||||
|
||||
if ! echo "$JSON_RESULT" | jq empty 2>/dev/null; then
|
||||
echo "::warning::Invalid JSON in AI response, using fallback"
|
||||
JSON_RESULT='{"is_valid":true,"issue_type":"other","missing_info":[],"suggestions":[],"overall_assessment":"Unable to validate automatically"}'
|
||||
fi
|
||||
|
||||
IS_VALID=$(echo "$JSON_RESULT" | jq -r '.is_valid // false')
|
||||
ISSUE_TYPE=$(echo "$JSON_RESULT" | jq -r '.issue_type // "other"')
|
||||
MISSING_INFO=$(echo "$JSON_RESULT" | jq -r '.missing_info[]? // empty' | sed 's/^/- /')
|
||||
SUGGESTIONS=$(echo "$JSON_RESULT" | jq -r '.suggestions[]? // empty' | sed 's/^/- /')
|
||||
ASSESSMENT=$(echo "$JSON_RESULT" | jq -r '.overall_assessment // "No assessment provided"')
|
||||
|
||||
IS_FIRST_TIME="${{ steps.check-first-time.outputs.is_first_time }}"
|
||||
GREETING_SECTION=""
|
||||
if [ "$IS_FIRST_TIME" = "true" ]; then
|
||||
GREETING_SECTION="## 👋 Welcome!\n\nThank you for your first issue ❤️ If this is a feature request, please make sure it is clear what you want, why you want it, and how important it is to you. If you posted a bug report, please make sure it includes as much detail as possible.\n\n---\n\n"
|
||||
fi
|
||||
|
||||
if [ "$IS_VALID" = "false" ]; then
|
||||
{
|
||||
printf "%b" "$GREETING_SECTION"
|
||||
printf "## 🤖 Issue Validation\n\n"
|
||||
printf "Thank you for submitting this issue! However, it appears that some required information might be missing to help us better understand and address your concern.\n\n"
|
||||
printf "**Issue Type Detected:** \`%s\`\n\n" "$ISSUE_TYPE"
|
||||
printf "**Assessment:** %s\n\n" "$ASSESSMENT"
|
||||
printf "### 📋 Missing Information:\n%s\n\n" "$MISSING_INFO"
|
||||
printf "### 💡 Suggestions for Improvement:\n%s\n\n" "$SUGGESTIONS"
|
||||
printf "### 📝 How to Provide Additional Information:\n\n"
|
||||
printf "Please edit your original issue description to include the missing information. Here are our issue templates for reference:\n\n"
|
||||
printf -- "- **Bug Report Template:** [View Template](.github/ISSUE_TEMPLATE/01-bug-report.md)\n"
|
||||
printf -- "- **Feature Request Template:** [View Template](.github/ISSUE_TEMPLATE/02-feature-request.md)\n\n"
|
||||
printf "### 🔧 Quick Tips:\n"
|
||||
printf -- "- For **bug reports**: Include step-by-step reproduction instructions, your environment details, and any error messages\n"
|
||||
printf -- "- For **feature requests**: Describe the use case, expected behavior, and why this feature would be valuable\n"
|
||||
printf -- "- Add **screenshots** or **logs** when applicable\n\n"
|
||||
printf "Once you have updated the issue with the missing information, feel free to remove this comment or reply to let us know you have made the updates.\n\n"
|
||||
printf -- "---\n*This validation was performed automatically to ensure we have all the information needed to help you effectively.*\n"
|
||||
} > comment.md
|
||||
|
||||
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
|
||||
gh issue edit ${{ github.event.issue.number }} --add-label "needs-info"
|
||||
else
|
||||
SUGGESTIONS_SECTION=""
|
||||
if [ -n "$SUGGESTIONS" ]; then
|
||||
SUGGESTIONS_SECTION=$(printf "### 💡 Suggestions:\n%s\n\n" "$SUGGESTIONS")
|
||||
fi
|
||||
|
||||
{
|
||||
printf "%b" "$GREETING_SECTION"
|
||||
printf "## 🤖 Issue Validation\n\n"
|
||||
printf "**Issue Type Detected:** \`%s\`\n\n" "$ISSUE_TYPE"
|
||||
printf "**Assessment:** %s\n\n" "$ASSESSMENT"
|
||||
printf "%b" "$SUGGESTIONS_SECTION"
|
||||
printf -- "---\n*This validation was performed automatically to help triage issues.*\n"
|
||||
} > comment.md
|
||||
|
||||
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
|
||||
|
||||
case "$ISSUE_TYPE" in
|
||||
"bug_report")
|
||||
gh issue edit ${{ github.event.issue.number }} --add-label "bug"
|
||||
;;
|
||||
"feature_request")
|
||||
gh issue edit ${{ github.event.issue.number }} --add-label "enhancement"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
- name: Run opencode analysis
|
||||
uses: anomalyco/opencode/github@296250f1b7e1ec992a3a33bee999f5e09a1697d0 #v1.2.10
|
||||
- name: Analyze issue
|
||||
uses: anomalyco/opencode/github@6c7d968c4423a0cd6c85099c9377a6066313fa0a #v1.2.20
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
with:
|
||||
model: zai-coding-plan/glm-4.7
|
||||
prompt: |
|
||||
You are a triage bot for Donut Browser (open-source anti-detect browser, Tauri + Next.js + Rust).
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -f issue_body.txt comment.md
|
||||
${{ steps.check-first-time.outputs.is_first_time == 'true' && 'This is a first-time contributor. Start your comment with: "Thanks for opening your first issue!"' || '' }}
|
||||
|
||||
handle-pr:
|
||||
Analyze this issue and post a single concise comment. Format:
|
||||
|
||||
1. One sentence acknowledging what the user wants.
|
||||
2. A short **Action items** list — what specific info is missing or what the user should do next. Only include items that are actually missing. If the issue is complete, say so and skip this section.
|
||||
3. Label the issue: add "bug" label for bug reports, "enhancement" label for feature requests.
|
||||
|
||||
Rules:
|
||||
- Be brief. No filler, no generic tips, no templates.
|
||||
- If it's a bug report, check for: reproduction steps, OS/version, error messages. Only ask for what's actually missing.
|
||||
- If it's a feature request, check for: clear description of desired behavior, use case. Only ask for what's actually missing.
|
||||
- If the issue already has everything needed, just acknowledge it and label it.
|
||||
- Never exceed 6 items total.
|
||||
|
||||
analyze-pr:
|
||||
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
@@ -178,109 +90,29 @@ jobs:
|
||||
echo "is_first_time=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Get PR diff
|
||||
id: get-diff
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
gh pr diff ${{ github.event.pull_request.number }} > pr_diff.txt
|
||||
head -c 10000 pr_diff.txt > pr_diff_truncated.txt
|
||||
|
||||
- name: Save PR body to file
|
||||
env:
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
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: .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:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
RESPONSE_FILE: ${{ steps.analyze.outputs.response-file }}
|
||||
run: |
|
||||
if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then
|
||||
RAW_OUTPUT=$(cat "$RESPONSE_FILE")
|
||||
else
|
||||
echo "::error::Response file not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
JSON_RESULT=$(printf "%s" "$RAW_OUTPUT" | sed -n '/```json/,/```/p' | sed '1d;$d')
|
||||
if [ -z "$JSON_RESULT" ]; then
|
||||
JSON_RESULT="$RAW_OUTPUT"
|
||||
fi
|
||||
|
||||
if ! echo "$JSON_RESULT" | jq empty 2>/dev/null; then
|
||||
echo "::warning::Invalid JSON in AI response, using fallback"
|
||||
JSON_RESULT='{"summary":"Unable to analyze automatically","quality_score":"good","feedback":[],"suggestions":[],"security_notes":[]}'
|
||||
fi
|
||||
|
||||
SUMMARY=$(echo "$JSON_RESULT" | jq -r '.summary // "No summary"')
|
||||
QUALITY=$(echo "$JSON_RESULT" | jq -r '.quality_score // "good"')
|
||||
FEEDBACK=$(echo "$JSON_RESULT" | jq -r '.feedback[]? // empty' | sed 's/^/- /')
|
||||
SUGGESTIONS=$(echo "$JSON_RESULT" | jq -r '.suggestions[]? // empty' | sed 's/^/- /')
|
||||
SECURITY=$(echo "$JSON_RESULT" | jq -r '.security_notes[]? // empty' | sed 's/^/- ⚠️ /')
|
||||
|
||||
IS_FIRST_TIME="${{ steps.check-first-time.outputs.is_first_time }}"
|
||||
|
||||
{
|
||||
if [ "$IS_FIRST_TIME" = "true" ]; then
|
||||
printf "## 👋 Welcome!\n\n"
|
||||
printf "Thank you for your first contribution ❤️ A human will review your PR shortly. Make sure that the pipelines are green, so that the PR is considered ready for review and could be merged.\n\n"
|
||||
printf -- "---\n\n"
|
||||
fi
|
||||
|
||||
printf "## 🤖 PR Review\n\n"
|
||||
printf "**Summary:** %s\n\n" "$SUMMARY"
|
||||
|
||||
case "$QUALITY" in
|
||||
"good")
|
||||
printf "**Status:** ✅ Looking good!\n\n"
|
||||
;;
|
||||
"needs_work")
|
||||
printf "**Status:** 🔧 Some improvements suggested\n\n"
|
||||
;;
|
||||
"critical_issues")
|
||||
printf "**Status:** ⚠️ Please address the issues below\n\n"
|
||||
;;
|
||||
esac
|
||||
|
||||
if [ -n "$FEEDBACK" ]; then
|
||||
printf "### 📝 Feedback:\n%s\n\n" "$FEEDBACK"
|
||||
fi
|
||||
|
||||
if [ -n "$SUGGESTIONS" ]; then
|
||||
printf "### 💡 Suggestions:\n%s\n\n" "$SUGGESTIONS"
|
||||
fi
|
||||
|
||||
if [ -n "$SECURITY" ]; then
|
||||
printf "### 🔒 Security Notes:\n%s\n\n" "$SECURITY"
|
||||
fi
|
||||
|
||||
printf -- "---\n*This review was performed automatically. A human maintainer will also review your changes.*\n"
|
||||
} > comment.md
|
||||
|
||||
gh pr comment ${{ github.event.pull_request.number }} --body-file comment.md
|
||||
|
||||
- name: Run opencode analysis
|
||||
uses: anomalyco/opencode/github@296250f1b7e1ec992a3a33bee999f5e09a1697d0 #v1.2.10
|
||||
- name: Analyze PR
|
||||
uses: anomalyco/opencode/github@6c7d968c4423a0cd6c85099c9377a6066313fa0a #v1.2.20
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
with:
|
||||
model: zai-coding-plan/glm-4.7
|
||||
prompt: |
|
||||
You are a review bot for Donut Browser (open-source anti-detect browser, Tauri + Next.js + Rust).
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -f pr_diff.txt pr_diff_truncated.txt pr_body.txt comment.md
|
||||
${{ steps.check-first-time.outputs.is_first_time == 'true' && 'This is a first-time contributor. Start your comment with: "Thanks for your first PR!"' || '' }}
|
||||
|
||||
Review this PR and post a single concise comment. Format:
|
||||
|
||||
1. One sentence summarizing what this PR does.
|
||||
2. **Action items** — only list things that actually need to be fixed or addressed. If the PR looks good, say so and skip this section.
|
||||
|
||||
Rules:
|
||||
- Be brief. No filler, no praise padding.
|
||||
- Focus on: bugs, security issues, missing edge cases, breaking changes.
|
||||
- If the PR touches UI text or adds new strings, remind to update translation files in src/i18n/locales/.
|
||||
- If the PR modifies Tauri commands, remind to check the unused-commands test.
|
||||
- Do not nitpick style or formatting — the project has automated linting.
|
||||
- Never exceed 8 lines total.
|
||||
|
||||
opencode-command:
|
||||
if: |
|
||||
@@ -295,7 +127,7 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@296250f1b7e1ec992a3a33bee999f5e09a1697d0 #v1.2.10
|
||||
uses: anomalyco/opencode/github@6c7d968c4423a0cd6c85099c9377a6066313fa0a #v1.2.20
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
with:
|
||||
|
||||
@@ -82,7 +82,7 @@ jobs:
|
||||
- name: Generate release notes with AI
|
||||
id: generate-notes
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
uses: actions/ai-inference@e09e65981758de8b2fdab13c2bfb7c7d5493b0b6 # v2.0.7
|
||||
with:
|
||||
prompt-file: .github/prompts/release-notes.prompt.yml
|
||||
input: |
|
||||
|
||||
@@ -254,7 +254,7 @@ jobs:
|
||||
ls -la /tmp/packages/
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff #v5.6.0
|
||||
uses: actions/setup-go@4b73464bb391d4059bd26b0524d20df3927bd417 #v6.3.0
|
||||
with:
|
||||
go-version: "1.23"
|
||||
cache: false
|
||||
@@ -272,7 +272,7 @@ jobs:
|
||||
|
||||
- name: Sync existing repo metadata from R2
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
mkdir -p /tmp/repo
|
||||
@@ -296,7 +296,7 @@ jobs:
|
||||
|
||||
- name: Upload repository to R2
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
aws s3 sync /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
|
||||
@@ -310,23 +310,10 @@ jobs:
|
||||
|
||||
- name: Verify upload
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_ENDPOINT: "https://${{ secrets.R2_ENDPOINT_URL }}"
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
echo "DEB repo:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}"
|
||||
echo "RPM repo:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}"
|
||||
|
||||
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
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@57b11c6b7e54c402ccd9cda953f1072ec4f78e33 #v1.43.5
|
||||
uses: crate-ci/typos@631208b7aac2daa8b707f55e7331f9112b0e062d #v1.44.0
|
||||
|
||||
+4
-1
@@ -55,4 +55,7 @@ nodecar/nodecar-bin
|
||||
.cache/
|
||||
|
||||
# env
|
||||
.env
|
||||
.env
|
||||
|
||||
# next
|
||||
next-env.d.ts
|
||||
Vendored
+2
@@ -46,6 +46,7 @@
|
||||
"direnv",
|
||||
"distro",
|
||||
"dists",
|
||||
"DMABUF",
|
||||
"doctest",
|
||||
"doesn",
|
||||
"domcontentloaded",
|
||||
@@ -96,6 +97,7 @@
|
||||
"libayatana",
|
||||
"libc",
|
||||
"libcairo",
|
||||
"libfuse",
|
||||
"libgdk",
|
||||
"libglib",
|
||||
"libpango",
|
||||
|
||||
+3
-3
@@ -1,10 +1,10 @@
|
||||
# Code of Conduct
|
||||
|
||||
All participants of the Donut Browser project (referred to as "the project") are expected to abide by our Code of Conduct, both online and during in-person events that are hosted and/or associated with the project.
|
||||
All participants of the Donut Browser project (referred to as "the project") are expected to abide by this Code of Conduct, both online and during in-person events that are hosted and/or associated with the project.
|
||||
|
||||
## The Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we pledge to make participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
In the interest of fostering an open and welcoming environment, the maintainers pledge to make participation in the project and the community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
|
||||
|
||||
## The Standards
|
||||
|
||||
@@ -25,4 +25,4 @@ Examples of unacceptable behavior by participants include:
|
||||
|
||||
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.
|
||||
The maintainers 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 are deemed inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
+7
-7
@@ -10,11 +10,11 @@ Do keep in mind before you start working on an issue / posting a PR:
|
||||
|
||||
- Search existing PRs related to that issue which might close them
|
||||
- Confirm if other contributors are working on the same issue
|
||||
- Check if the feature aligns with our roadmap and project goals
|
||||
- Check if the feature aligns with the project's roadmap and goals
|
||||
|
||||
## Contributor License Agreement
|
||||
|
||||
By contributing to Donut Browser, you agree that your contributions will be licensed under the same terms as the project. You must agree to our [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md) before your contributions can be accepted. This agreement ensures that:
|
||||
By contributing to Donut Browser, you agree that your contributions will be licensed under the same terms as the project. You must agree to the [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md) before your contributions can be accepted. This agreement ensures that:
|
||||
|
||||
- Your contributions can be used in the open source version of Donut Browser (licensed under AGPL-3.0)
|
||||
- Donut Browser can offer commercial licenses for the software, including your contributions
|
||||
@@ -27,7 +27,7 @@ When you submit your first pull request, you acknowledge that you agree to the t
|
||||
- PRs with tests are highly appreciated
|
||||
- Avoid adding third party libraries, whenever possible
|
||||
- Unless you are helping out by updating dependencies, you should not be uploading your lock files or updating any dependencies in your PR
|
||||
- If you are unsure where to start, open a discussion and we will point you to a good first issue
|
||||
- If you are unsure where to start, open a discussion to get pointed to a good first issue
|
||||
|
||||
## Development Setup
|
||||
|
||||
@@ -80,7 +80,7 @@ This will start the app for local development with live reloading.
|
||||
|
||||
## Code Style & Quality
|
||||
|
||||
We use several tools to maintain code quality:
|
||||
The project uses several tools to maintain code quality:
|
||||
|
||||
- **Biome** for JavaScript/TypeScript linting and formatting
|
||||
- **Clippy** for Rust linting
|
||||
@@ -88,7 +88,7 @@ We use several tools to maintain code quality:
|
||||
|
||||
### Before Committing
|
||||
|
||||
Run these commands to ensure your code meets our standards:
|
||||
Run these commands to ensure your code meets the project's standards:
|
||||
|
||||
```bash
|
||||
# Format and lint frontend code
|
||||
@@ -151,7 +151,7 @@ Refs #00000
|
||||
|
||||
### PR Checklist
|
||||
|
||||
- [ ] Code follows our style guidelines
|
||||
- [ ] Code follows the project's style guidelines
|
||||
- [ ] I have performed a self-review of my code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] I have made corresponding changes to the documentation
|
||||
@@ -187,7 +187,7 @@ Please note that this project is released with a [Contributor Code of Conduct](C
|
||||
|
||||
## Recognition
|
||||
|
||||
All contributors will be recognized! We use the all-contributors specification to acknowledge everyone who contributes to the project.
|
||||
All contributors will be recognized! The project uses the all-contributors specification to acknowledge everyone who contributes.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -42,6 +42,19 @@
|
||||
|
||||
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
|
||||
|
||||
<details>
|
||||
<summary>Troubleshooting AppImage on Linux</summary>
|
||||
|
||||
If the AppImage segfaults on launch, install **libfuse2** (`sudo apt install libfuse2` / `yay -S libfuse2` / `sudo dnf install fuse-libs`), or bypass FUSE entirely:
|
||||
|
||||
```bash
|
||||
APPIMAGE_EXTRACT_AND_RUN=1 ./Donut.Browser_x.x.x_amd64.AppImage
|
||||
```
|
||||
|
||||
If that gives an EGL display error, try adding `WEBKIT_DISABLE_DMABUF_RENDERER=1` or `GDK_BACKEND=x11` to the command above. If issues persist, the **.deb** / **.rpm** packages are a more reliable alternative.
|
||||
|
||||
</details>
|
||||
|
||||
<!-- ## Supported Platforms
|
||||
|
||||
- ✅ **macOS** (Apple Silicon)
|
||||
@@ -64,7 +77,7 @@ Donut Browser supports syncing profiles, proxies, and groups across devices via
|
||||
|
||||
## Community
|
||||
|
||||
Have questions or want to contribute? We'd love to hear from you!
|
||||
Have questions or want to contribute? The team would love to hear from you!
|
||||
|
||||
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
|
||||
@@ -113,7 +126,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@donutbrowser.com](mailto:contact@donutbrowser.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 the team will get back to you as fast as possible.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -18,12 +18,12 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.996.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.996.0",
|
||||
"@nestjs/common": "^11.1.14",
|
||||
"@aws-sdk/client-s3": "^3.1004.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.1004.0",
|
||||
"@nestjs/common": "^11.1.16",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.14",
|
||||
"@nestjs/platform-express": "^11.1.14",
|
||||
"@nestjs/core": "^11.1.16",
|
||||
"@nestjs/platform-express": "^11.1.16",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2"
|
||||
@@ -31,12 +31,12 @@
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.16",
|
||||
"@nestjs/schematics": "^11.0.9",
|
||||
"@nestjs/testing": "^11.1.14",
|
||||
"@nestjs/testing": "^11.1.16",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/supertest": "^6.0.3",
|
||||
"@types/node": "^25.3.5",
|
||||
"@types/supertest": "^7.2.0",
|
||||
"jest": "^30.2.0",
|
||||
"source-map-support": "^0.5.21",
|
||||
"supertest": "^7.2.2",
|
||||
|
||||
@@ -43,6 +43,7 @@ export class AuthGuard implements CanActivate {
|
||||
prefix: "",
|
||||
teamPrefix: null,
|
||||
profileLimit: 0,
|
||||
teamProfileLimit: 0,
|
||||
} satisfies UserContext;
|
||||
return true;
|
||||
}
|
||||
@@ -59,6 +60,7 @@ export class AuthGuard implements CanActivate {
|
||||
prefix: decoded.prefix || `users/${decoded.sub}/`,
|
||||
teamPrefix: decoded.teamPrefix || null,
|
||||
profileLimit: decoded.profileLimit || 0,
|
||||
teamProfileLimit: decoded.teamProfileLimit || 0,
|
||||
} satisfies UserContext;
|
||||
return true;
|
||||
} catch (err) {
|
||||
|
||||
@@ -3,4 +3,5 @@ export interface UserContext {
|
||||
prefix: string; // '' for self-hosted, 'users/{id}/' for cloud
|
||||
teamPrefix: string | null; // 'teams/{id}/' or null
|
||||
profileLimit: number; // 0 for unlimited (self-hosted)
|
||||
teamProfileLimit: number; // 0 for unlimited or non-team users
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { NestFactory } from "@nestjs/core";
|
||||
import type { NestExpressApplication } from "@nestjs/platform-express";
|
||||
import { AppModule } from "./app.module.js";
|
||||
|
||||
function validateEnv() {
|
||||
@@ -11,7 +12,10 @@ function validateEnv() {
|
||||
async function bootstrap() {
|
||||
validateEnv();
|
||||
|
||||
const app = await NestFactory.create(AppModule);
|
||||
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||
|
||||
// biome-ignore lint/correctness/useHookAtTopLevel: NestJS method, not a React hook
|
||||
app.useBodyParser("json", { limit: "50mb" });
|
||||
|
||||
app.enableCors({
|
||||
origin: "*",
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Headers,
|
||||
HttpCode,
|
||||
Post,
|
||||
UnauthorizedException,
|
||||
} from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { SyncService } from "./sync.service.js";
|
||||
|
||||
@Controller("v1/internal")
|
||||
export class InternalController {
|
||||
private readonly internalKey: string | undefined;
|
||||
|
||||
constructor(
|
||||
private readonly syncService: SyncService,
|
||||
private readonly configService: ConfigService,
|
||||
) {
|
||||
this.internalKey = this.configService.get<string>("INTERNAL_KEY");
|
||||
}
|
||||
|
||||
@Post("cleanup-excess-profiles")
|
||||
@HttpCode(200)
|
||||
async cleanupExcessProfiles(
|
||||
@Headers("x-internal-key") key: string,
|
||||
@Body() body: { userId: string; maxProfiles: number },
|
||||
) {
|
||||
if (!this.internalKey || key !== this.internalKey) {
|
||||
throw new UnauthorizedException("Invalid internal key");
|
||||
}
|
||||
|
||||
return this.syncService.cleanupExcessProfiles(
|
||||
body.userId,
|
||||
body.maxProfiles,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { AuthGuard } from "../auth/auth.guard.js";
|
||||
import { InternalController } from "./internal.controller.js";
|
||||
import { SyncController } from "./sync.controller.js";
|
||||
import { SyncService } from "./sync.service.js";
|
||||
|
||||
@Module({
|
||||
controllers: [SyncController],
|
||||
controllers: [SyncController, InternalController],
|
||||
providers: [SyncService, AuthGuard],
|
||||
exports: [SyncService],
|
||||
})
|
||||
|
||||
@@ -145,6 +145,7 @@ export class SyncService implements OnModuleInit {
|
||||
*/
|
||||
private scopeKey(ctx: UserContext, key: string): string {
|
||||
if (ctx.mode === "self-hosted") return key;
|
||||
if (ctx.teamPrefix && key.startsWith(ctx.teamPrefix)) return key;
|
||||
return `${ctx.prefix}${key}`;
|
||||
}
|
||||
|
||||
@@ -309,10 +310,12 @@ export class SyncService implements OnModuleInit {
|
||||
);
|
||||
|
||||
const userPrefix = ctx?.prefix || "";
|
||||
const teamPrefix = ctx?.teamPrefix || "";
|
||||
const objects = (response.Contents || []).map((obj) => {
|
||||
// Strip user prefix from returned keys so client sees relative keys
|
||||
let key = obj.Key || "";
|
||||
if (userPrefix && key.startsWith(userPrefix)) {
|
||||
if (teamPrefix && key.startsWith(teamPrefix)) {
|
||||
key = key.substring(teamPrefix.length);
|
||||
} else if (userPrefix && key.startsWith(userPrefix)) {
|
||||
key = key.substring(userPrefix.length);
|
||||
}
|
||||
return {
|
||||
@@ -481,11 +484,15 @@ export class SyncService implements OnModuleInit {
|
||||
): Observable<SubscribeEventDto> {
|
||||
const basePrefixes = ["profiles/", "proxies/", "groups/", "tombstones/"];
|
||||
|
||||
// Scope prefixes for cloud users; self-hosted gets root prefixes
|
||||
const prefixes =
|
||||
ctx.mode === "self-hosted"
|
||||
? basePrefixes
|
||||
: basePrefixes.map((p) => `${ctx.prefix}${p}`);
|
||||
let prefixes: string[];
|
||||
if (ctx.mode === "self-hosted") {
|
||||
prefixes = basePrefixes;
|
||||
} else {
|
||||
prefixes = basePrefixes.map((p) => `${ctx.prefix}${p}`);
|
||||
if (ctx.teamPrefix) {
|
||||
prefixes.push(...basePrefixes.map((p) => `${ctx.teamPrefix}${p}`));
|
||||
}
|
||||
}
|
||||
|
||||
// Per-connection state (not shared across subscribers)
|
||||
let lastKnownState = new Map<string, string>();
|
||||
@@ -547,6 +554,135 @@ export class SyncService implements OnModuleInit {
|
||||
this.changeSubject.next(event);
|
||||
}
|
||||
|
||||
async cleanupExcessProfiles(
|
||||
userId: string,
|
||||
maxProfiles: number,
|
||||
): Promise<{ deletedProfiles: string[]; remaining: number }> {
|
||||
const userPrefix = `users/${userId}/`;
|
||||
const profilePrefix = `${userPrefix}profiles/`;
|
||||
|
||||
// List all profile directories
|
||||
const profiles: { id: string; lastModified: Date }[] = [];
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: profilePrefix,
|
||||
Delimiter: "/",
|
||||
MaxKeys: 1000,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
|
||||
if (result.CommonPrefixes) {
|
||||
for (const cp of result.CommonPrefixes) {
|
||||
if (!cp.Prefix) continue;
|
||||
const profileId = cp.Prefix.replace(profilePrefix, "").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
|
||||
// Get creation time from first object in the profile directory
|
||||
const objects = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: cp.Prefix,
|
||||
MaxKeys: 1,
|
||||
}),
|
||||
);
|
||||
|
||||
const firstObj = objects.Contents?.[0];
|
||||
profiles.push({
|
||||
id: profileId,
|
||||
lastModified: firstObj?.LastModified || new Date(0),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
continuationToken = result.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
if (profiles.length <= maxProfiles) {
|
||||
return { deletedProfiles: [], remaining: profiles.length };
|
||||
}
|
||||
|
||||
// Sort newest first — delete newest excess profiles
|
||||
profiles.sort(
|
||||
(a, b) => b.lastModified.getTime() - a.lastModified.getTime(),
|
||||
);
|
||||
|
||||
const excessCount = profiles.length - maxProfiles;
|
||||
const toDelete = profiles.slice(0, excessCount);
|
||||
const deletedProfiles: string[] = [];
|
||||
|
||||
for (const profile of toDelete) {
|
||||
const prefix = `${profilePrefix}${profile.id}/`;
|
||||
|
||||
// Delete all objects under this profile
|
||||
let delToken: string | undefined;
|
||||
do {
|
||||
const listResult = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: prefix,
|
||||
MaxKeys: 1000,
|
||||
ContinuationToken: delToken,
|
||||
}),
|
||||
);
|
||||
|
||||
const objects = listResult.Contents || [];
|
||||
if (objects.length > 0) {
|
||||
const deleteObjects = objects
|
||||
.filter((obj): obj is typeof obj & { Key: string } => !!obj.Key)
|
||||
.map((obj) => ({ Key: obj.Key }));
|
||||
|
||||
if (deleteObjects.length > 0) {
|
||||
await this.s3Client.send(
|
||||
new DeleteObjectsCommand({
|
||||
Bucket: this.bucket,
|
||||
Delete: { Objects: deleteObjects, Quiet: true },
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
delToken = listResult.NextContinuationToken;
|
||||
} while (delToken);
|
||||
|
||||
// Create tombstone
|
||||
const tombstoneKey = `${userPrefix}tombstones/profiles/${profile.id}`;
|
||||
const tombstoneData = JSON.stringify({
|
||||
prefix: `profiles/${profile.id}/`,
|
||||
deleted_at: new Date().toISOString(),
|
||||
reason: "excess_profile_cleanup",
|
||||
});
|
||||
|
||||
await this.s3Client.send(
|
||||
new PutObjectCommand({
|
||||
Bucket: this.bucket,
|
||||
Key: tombstoneKey,
|
||||
Body: tombstoneData,
|
||||
ContentType: "application/json",
|
||||
}),
|
||||
);
|
||||
|
||||
deletedProfiles.push(profile.id);
|
||||
this.logger.log(
|
||||
`Cleaned up excess profile ${profile.id} for user ${userId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Report updated profile usage to backend
|
||||
const remaining = profiles.length - deletedProfiles.length;
|
||||
await this.reportProfileUsage(userId, remaining).catch((err) =>
|
||||
this.logger.warn(`Failed to report usage after cleanup: ${err.message}`),
|
||||
);
|
||||
|
||||
return { deletedProfiles, remaining };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has reached their profile limit.
|
||||
* Counts objects in the profiles/ prefix.
|
||||
@@ -554,16 +690,33 @@ export class SyncService implements OnModuleInit {
|
||||
private async checkProfileLimit(ctx: UserContext): Promise<void> {
|
||||
if (ctx.profileLimit <= 0) return; // 0 = unlimited
|
||||
|
||||
const profilePrefix = `${ctx.prefix}profiles/`;
|
||||
const result = await this.s3Client.send(
|
||||
let count = 0;
|
||||
|
||||
const userResult = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: profilePrefix,
|
||||
Prefix: `${ctx.prefix}profiles/`,
|
||||
Delimiter: "/",
|
||||
}),
|
||||
);
|
||||
count += userResult.CommonPrefixes?.length || 0;
|
||||
|
||||
if (ctx.teamPrefix && ctx.teamProfileLimit && ctx.teamProfileLimit > 0) {
|
||||
const teamResult = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: `${ctx.teamPrefix}profiles/`,
|
||||
Delimiter: "/",
|
||||
}),
|
||||
);
|
||||
const teamCount = teamResult.CommonPrefixes?.length || 0;
|
||||
if (teamCount >= ctx.teamProfileLimit) {
|
||||
throw new ForbiddenException(
|
||||
`Team profile limit reached (${ctx.teamProfileLimit}). Ask the team owner to upgrade.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const count = result.CommonPrefixes?.length || 0;
|
||||
if (count >= ctx.profileLimit) {
|
||||
throw new ForbiddenException(
|
||||
`Profile limit reached (${ctx.profileLimit}). Upgrade your plan for more profiles.`,
|
||||
@@ -604,6 +757,35 @@ export class SyncService implements OnModuleInit {
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
private async countTeamProfiles(ctx: UserContext): Promise<number> {
|
||||
if (!ctx.teamPrefix) return 0;
|
||||
const profilePrefix = `${ctx.teamPrefix}profiles/`;
|
||||
let count = 0;
|
||||
let continuationToken: string | undefined;
|
||||
|
||||
do {
|
||||
const result = await this.s3Client.send(
|
||||
new ListObjectsV2Command({
|
||||
Bucket: this.bucket,
|
||||
Prefix: profilePrefix,
|
||||
Delimiter: "/",
|
||||
MaxKeys: 1000,
|
||||
ContinuationToken: continuationToken,
|
||||
}),
|
||||
);
|
||||
count += result.CommonPrefixes?.length || 0;
|
||||
continuationToken = result.NextContinuationToken;
|
||||
} while (continuationToken);
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
private extractTeamId(ctx: UserContext): string | null {
|
||||
if (!ctx.teamPrefix) return null;
|
||||
const match = ctx.teamPrefix.match(/^teams\/([^/]+)\/$/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fire-and-forget: count profiles and report to backend.
|
||||
*/
|
||||
@@ -614,7 +796,17 @@ export class SyncService implements OnModuleInit {
|
||||
if (!userId) return;
|
||||
|
||||
this.countProfiles(ctx)
|
||||
.then((count) => this.reportProfileUsage(userId, count))
|
||||
.then(async (count) => {
|
||||
await this.reportProfileUsage(userId, count);
|
||||
|
||||
if (ctx.teamPrefix) {
|
||||
const teamCount = await this.countTeamProfiles(ctx);
|
||||
const teamId = this.extractTeamId(ctx);
|
||||
if (teamId) {
|
||||
await this.reportProfileUsage(teamId, teamCount);
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) =>
|
||||
this.logger.warn(`Failed to report profile usage: ${err.message}`),
|
||||
);
|
||||
|
||||
+11
-11
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack -p 12341",
|
||||
@@ -56,32 +56,32 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"i18next": "^25.8.13",
|
||||
"lucide-react": "^0.575.0",
|
||||
"motion": "^12.34.3",
|
||||
"i18next": "^25.8.14",
|
||||
"lucide-react": "^0.577.0",
|
||||
"motion": "^12.35.0",
|
||||
"next": "^16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-i18next": "^16.5.4",
|
||||
"react-icons": "^5.5.0",
|
||||
"recharts": "3.7.0",
|
||||
"react-i18next": "^16.5.6",
|
||||
"react-icons": "^5.6.0",
|
||||
"recharts": "3.8.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.4",
|
||||
"@biomejs/biome": "2.4.6",
|
||||
"@tailwindcss/postcss": "^4.2.1",
|
||||
"@tauri-apps/cli": "~2.10.0",
|
||||
"@tauri-apps/cli": "~2.10.1",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/node": "^25.3.0",
|
||||
"@types/node": "^25.3.5",
|
||||
"@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",
|
||||
"lint-staged": "^16.3.2",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
|
||||
Generated
+1057
-1096
File diff suppressed because it is too large
Load Diff
Generated
+84
-64
@@ -1405,9 +1405,9 @@ checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b"
|
||||
|
||||
[[package]]
|
||||
name = "deflate64"
|
||||
version = "0.1.10"
|
||||
version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26bf8fc351c5ed29b5c2f0cbbac1b209b74f60ecd62e675a998df72c49af5204"
|
||||
checksum = "807800ff3288b621186fe0a8f3392c4652068257302709c24efd918c3dffcdc2"
|
||||
|
||||
[[package]]
|
||||
name = "defmt"
|
||||
@@ -1533,9 +1533,9 @@ checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b"
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.0"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "89a09f22a6c6069a18470eb92d2298acf25463f14256d24778e1230d789a2aec"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"block2",
|
||||
@@ -1588,7 +1588,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "donutbrowser"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
dependencies = [
|
||||
"aes-gcm",
|
||||
"argon2",
|
||||
@@ -1616,12 +1616,13 @@ dependencies = [
|
||||
"lazy_static",
|
||||
"libc",
|
||||
"log",
|
||||
"lz4_flex",
|
||||
"lzma-rs",
|
||||
"maxminddb",
|
||||
"mime_guess",
|
||||
"msi-extract",
|
||||
"muda",
|
||||
"nix 0.31.1",
|
||||
"nix 0.31.2",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"once_cell",
|
||||
@@ -3178,9 +3179,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff"
|
||||
version = "0.2.21"
|
||||
version = "0.2.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b3e3d65f018c6ae946ab16e80944b97096ed73c35b221d1c478a6c81d8f57940"
|
||||
checksum = "819b44bc7c87d9117eb522f14d46e918add69ff12713c475946b0a29363ed1c2"
|
||||
dependencies = [
|
||||
"jiff-static",
|
||||
"log",
|
||||
@@ -3191,9 +3192,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "jiff-static"
|
||||
version = "0.2.21"
|
||||
version = "0.2.22"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a17c2b211d863c7fde02cbea8a3c1a439b98e109286554f2860bdded7ff83818"
|
||||
checksum = "470252db18ecc35fd766c0891b1e3ec6cbbcd62507e85276c01bf75d8e94d4a1"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
@@ -3234,9 +3235,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.88"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c7e709f3e3d22866f9c25b3aff01af289b18422cc8b4262fb19103ee80fe513d"
|
||||
checksum = "b49715b7073f385ba4bc528e5747d02e66cb39c6146efb66b781f131f0fb399c"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"wasm-bindgen",
|
||||
@@ -3342,9 +3343,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.180"
|
||||
version = "0.2.182"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc"
|
||||
checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112"
|
||||
|
||||
[[package]]
|
||||
name = "libfuzzer-sys"
|
||||
@@ -3374,13 +3375,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.12"
|
||||
version = "0.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d0b95e02c851351f877147b7deea7b1afb1df71b63aa5f8270716e0c5720616"
|
||||
checksum = "1744e39d1d6a9948f4f388969627434e31128196de472883b39f148769bfe30a"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"libc",
|
||||
"redox_syscall 0.7.1",
|
||||
"plain",
|
||||
"redox_syscall 0.7.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3415,9 +3417,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "linux-raw-sys"
|
||||
version = "0.11.0"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||
checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
|
||||
|
||||
[[package]]
|
||||
name = "litemap"
|
||||
@@ -3452,6 +3454,15 @@ dependencies = [
|
||||
"imgref",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lz4_flex"
|
||||
version = "0.11.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08ab2867e3eeeca90e844d1940eab391c9dc5228783db2ed999acbc0a9ed375a"
|
||||
dependencies = [
|
||||
"twox-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rs"
|
||||
version = "0.3.0"
|
||||
@@ -3745,9 +3756,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "nix"
|
||||
version = "0.31.1"
|
||||
version = "0.31.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225e7cfe711e0ba79a68baeddb2982723e4235247aefce1482f2f16c27865b66"
|
||||
checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"cfg-if",
|
||||
@@ -3884,9 +3895,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7c2599ce0ec54857b29ce62166b0ed9b4f6f1a70ccc9a71165b6154caca8c05"
|
||||
checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f"
|
||||
dependencies = [
|
||||
"objc2-encode",
|
||||
"objc2-exception-helper",
|
||||
@@ -4455,9 +4466,9 @@ checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.16"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3b3cff922bd51709b605d9ead9aa71031d81447142d828eb4a6eba76fe619f9b"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "pin-utils"
|
||||
@@ -4467,9 +4478,9 @@ checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||
|
||||
[[package]]
|
||||
name = "piper"
|
||||
version = "0.2.4"
|
||||
version = "0.2.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066"
|
||||
checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"fastrand",
|
||||
@@ -4482,6 +4493,12 @@ version = "0.3.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plain"
|
||||
version = "0.2.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6"
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "0.0.23"
|
||||
@@ -4767,12 +4784,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "pxfm"
|
||||
version = "0.1.27"
|
||||
version = "0.1.28"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7186d3822593aa4393561d186d1393b3923e9d6163d3fbfd6e825e3e6cf3e6a8"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d"
|
||||
|
||||
[[package]]
|
||||
name = "qoi"
|
||||
@@ -5026,9 +5040,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "redox_syscall"
|
||||
version = "0.7.1"
|
||||
version = "0.7.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35985aa610addc02e24fc232012c86fd11f14111180f902b67e2d5331f8ebf2b"
|
||||
checksum = "6ce70a74e890531977d37e532c34d45e9055d2409ed08ddba14529471ed0be16"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
]
|
||||
@@ -5095,9 +5109,9 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973"
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.9"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a96887878f22d7bad8a3b6dc5b7440e0ada9a245242924394987b21cf2210a4c"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "rend"
|
||||
@@ -5235,9 +5249,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rgb"
|
||||
version = "0.8.52"
|
||||
version = "0.8.53"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c6a884d2998352bb4daf0183589aec883f16a6da1f4dde84d8e2e9a5409a1ce"
|
||||
checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4"
|
||||
dependencies = [
|
||||
"bytemuck",
|
||||
]
|
||||
@@ -5362,9 +5376,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustix"
|
||||
version = "1.1.3"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
|
||||
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
|
||||
dependencies = [
|
||||
"bitflags 2.11.0",
|
||||
"errno",
|
||||
@@ -5375,9 +5389,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.36"
|
||||
version = "0.23.37"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c665f33d38cea657d9614f766881e4d510e0eda4239891eea56b4cadcf01801b"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
@@ -5703,9 +5717,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with"
|
||||
version = "3.16.1"
|
||||
version = "3.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7"
|
||||
checksum = "381b283ce7bc6b476d903296fb59d0d36633652b633b27f64db4fb46dcbfc3b9"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -5722,9 +5736,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "serde_with_macros"
|
||||
version = "3.16.1"
|
||||
version = "3.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "52a8e3ca0ca629121f70ab50f95249e5a6f925cc0f6ffe8256c45b728875706c"
|
||||
checksum = "a6d4e30573c8cb306ed6ab1dca8423eec9a463ea0e155f45399455e0368b27e0"
|
||||
dependencies = [
|
||||
"darling",
|
||||
"proc-macro2",
|
||||
@@ -6665,9 +6679,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
version = "3.25.0"
|
||||
version = "3.26.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
@@ -7160,6 +7174,12 @@ dependencies = [
|
||||
"utf-8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "twox-hash"
|
||||
version = "2.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9ea3136b675547379c4bd395ca6b938e5ad3c3d20fad76e7fe85f9e0d011419c"
|
||||
|
||||
[[package]]
|
||||
name = "typed-path"
|
||||
version = "0.12.3"
|
||||
@@ -7549,9 +7569,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.111"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec1adf1535672f5b7824f817792b1afd731d7e843d2d04ec8f27e8cb51edd8ac"
|
||||
checksum = "6532f9a5c1ece3798cb1c2cfdba640b9b3ba884f5db45973a6f442510a87d38e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -7562,9 +7582,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-futures"
|
||||
version = "0.4.61"
|
||||
version = "0.4.64"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fe88540d1c934c4ec8e6db0afa536876c5441289d7f9f9123d4f065ac1250a6b"
|
||||
checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -7576,9 +7596,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.111"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "19e638317c08b21663aed4d2b9a2091450548954695ff4efa75bff5fa546b3b1"
|
||||
checksum = "18a2d50fcf105fb33bb15f00e7a77b772945a2ee45dcf454961fd843e74c18e6"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -7586,9 +7606,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.111"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2c64760850114d03d5f65457e96fc988f11f01d38fbaa51b254e4ab5809102af"
|
||||
checksum = "03ce4caeaac547cdf713d280eda22a730824dd11e6b8c3ca9e42247b25c631e3"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -7599,9 +7619,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.111"
|
||||
version = "0.2.114"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60eecd4fe26177cfa3339eb00b4a36445889ba3ad37080c2429879718e20ca41"
|
||||
checksum = "75a326b8c223ee17883a4251907455a2431acc2791c98c26279376490c378c16"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
@@ -7655,9 +7675,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.88"
|
||||
version = "0.3.91"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d6bb20ed2d9572df8584f6dc81d68a41a625cadc6f15999d649a70ce7e3597a"
|
||||
checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
@@ -8605,18 +8625,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.39"
|
||||
version = "0.8.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db6d35d663eadb6c932438e763b262fe1a70987f9ae936e60158176d710cae4a"
|
||||
checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.39"
|
||||
version = "0.8.40"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4122cd3169e94605190e77839c9a40d40ed048d305bfdc146e7df40ab0f3e517"
|
||||
checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.15.0"
|
||||
version = "0.16.0"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -100,6 +100,7 @@ maxminddb = "0.27"
|
||||
quick-xml = { version = "0.39", features = ["serialize"] }
|
||||
|
||||
# VPN support
|
||||
lz4_flex = "0.11"
|
||||
boringtun = "0.7"
|
||||
smoltcp = { version = "0.11", default-features = false, features = ["std", "medium-ip", "proto-ipv4", "proto-ipv6", "socket-tcp", "socket-udp"] }
|
||||
|
||||
|
||||
+55
-12
@@ -12,6 +12,7 @@ pub struct VersionComponent {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
pub patch: u32,
|
||||
pub build: u32,
|
||||
pub pre_release: Option<PreRelease>,
|
||||
}
|
||||
|
||||
@@ -47,6 +48,7 @@ impl VersionComponent {
|
||||
major: 999, // High major version to indicate it's a rolling release
|
||||
minor: 0,
|
||||
patch: 0,
|
||||
build: 0,
|
||||
pre_release: Some(PreRelease {
|
||||
kind: PreReleaseKind::Alpha,
|
||||
number: Some(999), // High number to indicate it's a rolling release
|
||||
@@ -66,6 +68,7 @@ impl VersionComponent {
|
||||
let major = parts.first().copied().unwrap_or(0);
|
||||
let minor = parts.get(1).copied().unwrap_or(0);
|
||||
let patch = parts.get(2).copied().unwrap_or(0);
|
||||
let build = parts.get(3).copied().unwrap_or(0);
|
||||
|
||||
// Parse pre-release part
|
||||
let pre_release = pre_release_part
|
||||
@@ -76,6 +79,7 @@ impl VersionComponent {
|
||||
major,
|
||||
minor,
|
||||
patch,
|
||||
build,
|
||||
pre_release,
|
||||
}
|
||||
}
|
||||
@@ -173,7 +177,12 @@ impl Ord for VersionComponent {
|
||||
match (self_is_twilight, other_is_twilight) {
|
||||
(true, true) => {
|
||||
// Both are twilight, compare by base version
|
||||
return (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch));
|
||||
return (self.major, self.minor, self.patch, self.build).cmp(&(
|
||||
other.major,
|
||||
other.minor,
|
||||
other.patch,
|
||||
other.build,
|
||||
));
|
||||
}
|
||||
(false, false) => {
|
||||
// Neither is twilight, continue with normal comparison
|
||||
@@ -181,8 +190,13 @@ impl Ord for VersionComponent {
|
||||
_ => unreachable!(), // Already handled above
|
||||
}
|
||||
|
||||
// Compare major.minor.patch first
|
||||
match (self.major, self.minor, self.patch).cmp(&(other.major, other.minor, other.patch)) {
|
||||
// Compare major.minor.patch.build first
|
||||
match (self.major, self.minor, self.patch, self.build).cmp(&(
|
||||
other.major,
|
||||
other.minor,
|
||||
other.patch,
|
||||
other.build,
|
||||
)) {
|
||||
Ordering::Equal => {
|
||||
// If numeric parts are equal, compare pre-release
|
||||
match (&self.pre_release, &other.pre_release) {
|
||||
@@ -1124,18 +1138,47 @@ impl ApiClient {
|
||||
log::info!("Fetching Wayfern version from https://donutbrowser.com/wayfern.json");
|
||||
let url = "https://donutbrowser.com/wayfern.json";
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||
.send()
|
||||
.await?;
|
||||
let mut last_err = None;
|
||||
let mut version_info: Option<WayfernVersionInfo> = None;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Failed to fetch Wayfern version: {}", response.status()).into());
|
||||
for attempt in 1..=3 {
|
||||
match self
|
||||
.client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(response) => {
|
||||
if !response.status().is_success() {
|
||||
last_err = Some(format!("HTTP {}", response.status()));
|
||||
} else {
|
||||
match response.json::<WayfernVersionInfo>().await {
|
||||
Ok(info) => {
|
||||
version_info = Some(info);
|
||||
break;
|
||||
}
|
||||
Err(e) => last_err = Some(format!("Failed to parse response: {e}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Wayfern fetch attempt {attempt}/3 failed: {e}");
|
||||
last_err = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if attempt < 3 {
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
}
|
||||
}
|
||||
|
||||
let version_info: WayfernVersionInfo = response.json().await?;
|
||||
let version_info = version_info.ok_or_else(|| {
|
||||
format!(
|
||||
"Failed to fetch Wayfern version after 3 attempts: {}",
|
||||
last_err.unwrap_or_default()
|
||||
)
|
||||
})?;
|
||||
log::info!("Fetched Wayfern version: {}", version_info.version);
|
||||
|
||||
// Cache the results (unless bypassing cache)
|
||||
|
||||
+194
-25
@@ -39,6 +39,7 @@ pub struct ApiProfile {
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub is_running: bool,
|
||||
pub proxy_bypass_rules: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
@@ -78,6 +79,8 @@ pub struct UpdateProfileRequest {
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
pub extension_group_id: Option<String>,
|
||||
pub proxy_bypass_rules: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -295,30 +298,24 @@ impl ApiServer {
|
||||
|
||||
// Create router with OpenAPI documentation
|
||||
let (v1_routes, _) = OpenApiRouter::new()
|
||||
.routes(routes!(
|
||||
get_profiles,
|
||||
create_profile,
|
||||
get_profile,
|
||||
update_profile,
|
||||
delete_profile,
|
||||
run_profile,
|
||||
open_url_in_profile,
|
||||
kill_profile,
|
||||
get_groups,
|
||||
create_group,
|
||||
get_group,
|
||||
update_group,
|
||||
delete_group,
|
||||
get_tags,
|
||||
get_proxies,
|
||||
create_proxy,
|
||||
get_proxy,
|
||||
update_proxy,
|
||||
delete_proxy,
|
||||
download_browser_api,
|
||||
get_browser_versions,
|
||||
check_browser_downloaded,
|
||||
))
|
||||
.routes(routes!(get_profiles, create_profile))
|
||||
.routes(routes!(get_profile, update_profile, delete_profile))
|
||||
.routes(routes!(run_profile))
|
||||
.routes(routes!(open_url_in_profile))
|
||||
.routes(routes!(kill_profile))
|
||||
.routes(routes!(get_groups, create_group))
|
||||
.routes(routes!(get_group, update_group, delete_group))
|
||||
.routes(routes!(get_tags))
|
||||
.routes(routes!(get_proxies, create_proxy))
|
||||
.routes(routes!(get_proxy, update_proxy, delete_proxy))
|
||||
.routes(routes!(get_extensions))
|
||||
.routes(routes!(delete_extension_api))
|
||||
.routes(routes!(get_extension_groups))
|
||||
.routes(routes!(delete_extension_group_api))
|
||||
.routes(routes!(download_browser_api))
|
||||
.routes(routes!(get_browser_versions))
|
||||
.routes(routes!(check_browser_downloaded))
|
||||
.routes(routes!(get_wayfern_token, refresh_wayfern_token))
|
||||
.split_for_parts();
|
||||
|
||||
let api = ApiDoc::openapi();
|
||||
@@ -337,7 +334,7 @@ impl ApiServer {
|
||||
.with_state(ws_state);
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/v1", v1_routes)
|
||||
.merge(v1_routes)
|
||||
.nest("/ws", ws_routes)
|
||||
.route("/openapi.json", get(move || async move { Json(api) }))
|
||||
.layer(CorsLayer::permissive())
|
||||
@@ -493,6 +490,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -547,6 +545,7 @@ async fn get_profile(
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules.clone(),
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
@@ -645,6 +644,7 @@ async fn create_profile(
|
||||
group_id: profile.group_id,
|
||||
tags: profile.tags,
|
||||
is_running: false,
|
||||
proxy_bypass_rules: profile.proxy_bypass_rules,
|
||||
},
|
||||
}))
|
||||
}
|
||||
@@ -748,6 +748,29 @@ async fn update_profile(
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(extension_group_id) = request.extension_group_id {
|
||||
let ext_group = if extension_group_id.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(extension_group_id)
|
||||
};
|
||||
if profile_manager
|
||||
.update_profile_extension_group(&id, ext_group)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(proxy_bypass_rules) = request.proxy_bypass_rules {
|
||||
if profile_manager
|
||||
.update_profile_proxy_bypass_rules(&state.app_handle, &id, proxy_bypass_rules)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated profile
|
||||
get_profile(Path(id), State(state)).await
|
||||
}
|
||||
@@ -1153,6 +1176,94 @@ async fn delete_proxy(
|
||||
}
|
||||
}
|
||||
|
||||
// Extension API endpoints
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/extensions",
|
||||
responses(
|
||||
(status = 200, description = "List of extensions"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn get_extensions(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<crate::extension_manager::Extension>>, StatusCode> {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.list_extensions()
|
||||
.map(Json)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/extension-groups",
|
||||
responses(
|
||||
(status = 200, description = "List of extension groups"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn get_extension_groups(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<crate::extension_manager::ExtensionGroup>>, StatusCode> {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.list_groups()
|
||||
.map(Json)
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/v1/extensions/{id}",
|
||||
params(("id" = String, Path, description = "Extension ID")),
|
||||
responses(
|
||||
(status = 204, description = "Extension deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Extension not found"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn delete_extension_api(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.delete_extension(&state.app_handle, &id)
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(|_| StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/v1/extension-groups/{id}",
|
||||
params(("id" = String, Path, description = "Extension Group ID")),
|
||||
responses(
|
||||
(status = 204, description = "Extension group deleted"),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 404, description = "Extension group not found"),
|
||||
),
|
||||
security(("bearer_auth" = [])),
|
||||
tag = "extensions"
|
||||
)]
|
||||
async fn delete_extension_group_api(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.delete_group(&state.app_handle, &id)
|
||||
.map(|_| StatusCode::NO_CONTENT)
|
||||
.map_err(|_| StatusCode::NOT_FOUND)
|
||||
}
|
||||
|
||||
// API Handler - Run Profile with Remote Debugging
|
||||
#[utoipa::path(
|
||||
post,
|
||||
@@ -1195,6 +1306,11 @@ async fn run_profile(
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Team lock check
|
||||
crate::team_lock::acquire_team_lock_if_needed(profile)
|
||||
.await
|
||||
.map_err(|_| StatusCode::CONFLICT)?;
|
||||
|
||||
// Generate a random port for remote debugging
|
||||
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
|
||||
|
||||
@@ -1289,6 +1405,8 @@ async fn kill_profile(
|
||||
.await
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
@@ -1384,3 +1502,54 @@ async fn check_browser_downloaded(
|
||||
let is_downloaded = crate::downloaded_browsers_registry::is_browser_downloaded(browser, version);
|
||||
Ok(Json(is_downloaded))
|
||||
}
|
||||
|
||||
// API Handlers - Wayfern Token
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||
pub struct WayfernTokenResponse {
|
||||
pub token: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/v1/wayfern-token",
|
||||
responses(
|
||||
(status = 200, description = "Current wayfern token", body = WayfernTokenResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "wayfern"
|
||||
)]
|
||||
async fn get_wayfern_token(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<WayfernTokenResponse>, StatusCode> {
|
||||
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
Ok(Json(WayfernTokenResponse { token }))
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/v1/wayfern-token/refresh",
|
||||
responses(
|
||||
(status = 200, description = "Refreshed wayfern token", body = WayfernTokenResponse),
|
||||
(status = 401, description = "Unauthorized"),
|
||||
(status = 500, description = "Failed to refresh token"),
|
||||
),
|
||||
security(
|
||||
("bearer_auth" = [])
|
||||
),
|
||||
tag = "wayfern"
|
||||
)]
|
||||
async fn refresh_wayfern_token(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<WayfernTokenResponse>, (StatusCode, String)> {
|
||||
crate::cloud_auth::CLOUD_AUTH
|
||||
.request_wayfern_token()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e))?;
|
||||
|
||||
let token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
Ok(Json(WayfernTokenResponse { token }))
|
||||
}
|
||||
|
||||
@@ -744,13 +744,36 @@ impl AppAutoUpdater {
|
||||
log::info!("Extracting update...");
|
||||
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
|
||||
|
||||
log::info!("Installing update (overwriting binary)...");
|
||||
self.install_update(&extracted_app_path).await?;
|
||||
// On Windows, MSI/EXE installers close the running app, so running them now
|
||||
// would kill the process before the "Update ready" toast can appear. Instead,
|
||||
// defer execution to restart_application() when the user clicks "Restart Now".
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let ext = extracted_app_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
if ext == "msi" || ext == "exe" {
|
||||
log::info!("Deferring Windows installer execution until user-initiated restart");
|
||||
*PENDING_INSTALLER_PATH.lock().unwrap() = Some(extracted_app_path);
|
||||
} else {
|
||||
log::info!("Installing update (overwriting binary)...");
|
||||
self.install_update(&extracted_app_path).await?;
|
||||
log::info!("Cleaning up temporary files...");
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Cleaning up temporary files...");
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
log::info!("Installing update (overwriting binary)...");
|
||||
self.install_update(&extracted_app_path).await?;
|
||||
log::info!("Cleaning up temporary files...");
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
}
|
||||
|
||||
log::info!("Update installed successfully, emitting app-update-ready event");
|
||||
log::info!("Update ready, emitting app-update-ready event");
|
||||
|
||||
let _ = events::emit("app-update-ready", update_info.new_version.clone());
|
||||
|
||||
@@ -1421,14 +1444,63 @@ rm "{}"
|
||||
{
|
||||
let app_path = self.get_current_app_path()?;
|
||||
let current_pid = std::process::id();
|
||||
let pending = PENDING_INSTALLER_PATH.lock().unwrap().take();
|
||||
|
||||
// Create a temporary restart batch script
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_path = temp_dir.join("donut_restart.bat");
|
||||
let update_temp_dir = temp_dir.join("donut_app_update");
|
||||
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"@echo off
|
||||
let script_content = if let Some(installer_path) = pending {
|
||||
let ext = installer_path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.unwrap_or("")
|
||||
.to_lowercase();
|
||||
let install_cmd = match ext.as_str() {
|
||||
"msi" => format!(
|
||||
"msiexec /i \"{}\" /quiet /norestart REBOOT=ReallySuppress",
|
||||
installer_path.to_str().unwrap()
|
||||
),
|
||||
"exe" => format!("\"{}\" /S", installer_path.to_str().unwrap()),
|
||||
_ => String::new(),
|
||||
};
|
||||
|
||||
format!(
|
||||
r#"@echo off
|
||||
rem Wait for the current process to exit
|
||||
:wait_loop
|
||||
tasklist /fi "PID eq {pid}" >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto wait_loop
|
||||
)
|
||||
|
||||
rem Wait a bit more to ensure clean exit
|
||||
timeout /t 2 /nobreak >nul
|
||||
|
||||
rem Run the installer
|
||||
{install_cmd}
|
||||
|
||||
rem Wait for installation to complete
|
||||
timeout /t 3 /nobreak >nul
|
||||
|
||||
rem Start the new application
|
||||
start "" "{app_path}"
|
||||
|
||||
rem Clean up installer temp files
|
||||
rmdir /s /q "{update_temp}"
|
||||
|
||||
rem Clean up this script
|
||||
del "%~f0"
|
||||
"#,
|
||||
pid = current_pid,
|
||||
install_cmd = install_cmd,
|
||||
app_path = app_path.to_str().unwrap(),
|
||||
update_temp = update_temp_dir.to_str().unwrap(),
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"@echo off
|
||||
rem Wait for the current process to exit
|
||||
:wait_loop
|
||||
tasklist /fi "PID eq {}" >nul 2>&1
|
||||
@@ -1446,24 +1518,20 @@ start "" "{}"
|
||||
rem Clean up this script
|
||||
del "%~f0"
|
||||
"#,
|
||||
current_pid,
|
||||
app_path.to_str().unwrap()
|
||||
);
|
||||
current_pid,
|
||||
app_path.to_str().unwrap()
|
||||
)
|
||||
};
|
||||
|
||||
// Write the script to file
|
||||
fs::write(&script_path, script_content)?;
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/C", script_path.to_str().unwrap()]);
|
||||
|
||||
// Start the process detached
|
||||
let _child = cmd.spawn()?;
|
||||
|
||||
// Give the script a moment to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Exit the current process
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
@@ -1534,6 +1602,16 @@ rm "{}"
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_app_updates() -> Result<Option<AppUpdateInfo>, String> {
|
||||
// The disable_auto_updates setting controls app self-updates only
|
||||
let disabled = crate::settings_manager::SettingsManager::instance()
|
||||
.load_settings()
|
||||
.map(|s| s.disable_auto_updates)
|
||||
.unwrap_or(false);
|
||||
if disabled {
|
||||
log::info!("App auto-updates disabled by user setting");
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let updater = AppAutoUpdater::instance();
|
||||
updater
|
||||
.check_for_updates()
|
||||
@@ -1926,4 +2004,5 @@ mod tests {
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref APP_AUTO_UPDATER: AppAutoUpdater = AppAutoUpdater::new();
|
||||
static ref PENDING_INSTALLER_PATH: std::sync::Mutex<Option<PathBuf>> = std::sync::Mutex::new(None);
|
||||
}
|
||||
|
||||
@@ -66,10 +66,18 @@ pub fn proxies_dir() -> PathBuf {
|
||||
data_dir().join("proxies")
|
||||
}
|
||||
|
||||
pub fn proxy_workers_dir() -> PathBuf {
|
||||
cache_dir().join("proxy_workers")
|
||||
}
|
||||
|
||||
pub fn vpn_dir() -> PathBuf {
|
||||
data_dir().join("vpn")
|
||||
}
|
||||
|
||||
pub fn extensions_dir() -> PathBuf {
|
||||
data_dir().join("extensions")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
@@ -151,7 +159,9 @@ mod tests {
|
||||
assert!(data_subdir().ends_with("data"));
|
||||
assert!(settings_dir().ends_with("settings"));
|
||||
assert!(proxies_dir().ends_with("proxies"));
|
||||
assert!(proxy_workers_dir().ends_with("proxy_workers"));
|
||||
assert!(vpn_dir().ends_with("vpn"));
|
||||
assert!(extensions_dir().ends_with("extensions"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+279
-75
@@ -1,5 +1,4 @@
|
||||
use crate::browser_version_manager::{BrowserVersionInfo, BrowserVersionManager};
|
||||
use crate::events;
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
use crate::settings_manager::SettingsManager;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -61,6 +60,10 @@ impl AutoUpdater {
|
||||
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
|
||||
|
||||
for profile in profiles {
|
||||
if profile.is_cross_os() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only check supported browsers
|
||||
if !self
|
||||
.browser_version_manager
|
||||
@@ -77,24 +80,25 @@ impl AutoUpdater {
|
||||
}
|
||||
|
||||
for (browser, profiles) in browser_profiles {
|
||||
// Get cached versions first, then try to fetch if needed
|
||||
let versions = if let Some(cached) = self
|
||||
// Always fetch fresh versions for update checks — stale cache would miss new releases
|
||||
let versions = match self
|
||||
.browser_version_manager
|
||||
.get_cached_browser_versions_detailed(&browser)
|
||||
.fetch_browser_versions_detailed(&browser, false)
|
||||
.await
|
||||
{
|
||||
cached
|
||||
} else if self.browser_version_manager.should_update_cache(&browser) {
|
||||
// Try to fetch fresh versions
|
||||
match self
|
||||
.browser_version_manager
|
||||
.fetch_browser_versions_detailed(&browser, false)
|
||||
.await
|
||||
{
|
||||
Ok(versions) => versions,
|
||||
Err(_) => continue, // Skip this browser if fetch fails
|
||||
Ok(versions) => versions,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to fetch versions for {browser}: {e}, trying cache");
|
||||
// Fall back to cache if network fails
|
||||
if let Some(cached) = self
|
||||
.browser_version_manager
|
||||
.get_cached_browser_versions_detailed(&browser)
|
||||
{
|
||||
cached
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
continue; // No cached versions and cache doesn't need update
|
||||
};
|
||||
|
||||
browser_versions.insert(browser.clone(), versions.clone());
|
||||
@@ -104,20 +108,29 @@ impl AutoUpdater {
|
||||
if let Some(update) = self.check_profile_update(&profile, &versions)? {
|
||||
// Apply chromium threshold logic
|
||||
if browser == "chromium" {
|
||||
// For chromium, only show notifications if there are 400+ new versions
|
||||
let current_version = &profile.version.parse::<u32>().unwrap();
|
||||
let new_version = &update.new_version.parse::<u32>().unwrap();
|
||||
// For chromium, only show notifications if there's a significant version jump
|
||||
// Compare the major version component (first number before the dot)
|
||||
let current_major: u32 = profile
|
||||
.version
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
let new_major: u32 = update
|
||||
.new_version
|
||||
.split('.')
|
||||
.next()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(0);
|
||||
|
||||
let result = new_version - current_version;
|
||||
let result = new_major.saturating_sub(current_major);
|
||||
log::info!(
|
||||
"Current version: {current_version}, New version: {new_version}, Result: {result}"
|
||||
"Current major version: {current_major}, New major version: {new_major}, Diff: {result}"
|
||||
);
|
||||
if result > 400 {
|
||||
if result > 0 {
|
||||
notifications.push(update);
|
||||
} else {
|
||||
log::info!(
|
||||
"Skipping chromium update notification: only {result} new versions (need 400+)"
|
||||
);
|
||||
log::info!("Skipping chromium update notification: same major version");
|
||||
}
|
||||
} else {
|
||||
notifications.push(update);
|
||||
@@ -132,78 +145,72 @@ impl AutoUpdater {
|
||||
pub async fn check_for_updates_with_progress(&self, app_handle: &tauri::AppHandle) {
|
||||
log::info!("Starting auto-update check with progress...");
|
||||
|
||||
// Browser auto-updates are always enabled — the disable_auto_updates setting
|
||||
// only controls app self-updates, not browser version updates.
|
||||
|
||||
// Check for browser updates and trigger auto-downloads
|
||||
match self.check_for_updates().await {
|
||||
Ok(update_notifications) => {
|
||||
if !update_notifications.is_empty() {
|
||||
log::info!(
|
||||
"Found {} browser updates to auto-download",
|
||||
update_notifications.len()
|
||||
);
|
||||
// Group by browser+version to avoid duplicate downloads
|
||||
let grouped = self.group_update_notifications(update_notifications);
|
||||
if !grouped.is_empty() {
|
||||
log::info!("Found {} browser updates", grouped.len());
|
||||
|
||||
// Trigger automatic downloads for each update
|
||||
for notification in update_notifications {
|
||||
for notification in grouped {
|
||||
log::info!(
|
||||
"Auto-downloading {} version {}",
|
||||
"Auto-updating {} to version {} ({} profiles)",
|
||||
notification.browser,
|
||||
notification.new_version
|
||||
notification.new_version,
|
||||
notification.affected_profiles.len()
|
||||
);
|
||||
|
||||
// Clone app_handle for the async task
|
||||
let browser = notification.browser.clone();
|
||||
let new_version = notification.new_version.clone();
|
||||
let notification_id = notification.id.clone();
|
||||
let affected_profiles = notification.affected_profiles.clone();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
// Spawn async task to handle the download and auto-update
|
||||
tokio::spawn(async move {
|
||||
// TODO: update the logic to use the downloaded browsers registry instance instead of the static method
|
||||
// First, check if browser already exists
|
||||
match crate::downloaded_browsers_registry::is_browser_downloaded(
|
||||
browser.clone(),
|
||||
new_version.clone(),
|
||||
) {
|
||||
true => {
|
||||
log::info!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
|
||||
let registry =
|
||||
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
|
||||
// Browser already exists, go straight to profile update
|
||||
match AutoUpdater::instance()
|
||||
.complete_browser_update_with_auto_update(
|
||||
&app_handle_clone,
|
||||
&browser.clone(),
|
||||
&new_version.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated_profiles) => {
|
||||
if registry.is_browser_downloaded(&browser, &new_version) {
|
||||
log::info!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
|
||||
|
||||
// Browser already exists, go straight to profile update
|
||||
match AutoUpdater::instance()
|
||||
.auto_update_profile_versions(&app_handle_clone, &browser, &new_version)
|
||||
.await
|
||||
{
|
||||
Ok(updated_profiles) => {
|
||||
if !updated_profiles.is_empty() {
|
||||
log::info!(
|
||||
"Auto-update completed for {} profiles: {:?}",
|
||||
"Auto-updated {} profiles to {browser} {new_version}: {:?}",
|
||||
updated_profiles.len(),
|
||||
updated_profiles
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to complete auto-update for {browser}: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to auto-update profiles for {browser}: {e}");
|
||||
}
|
||||
}
|
||||
false => {
|
||||
log::info!("Downloading browser {browser} version {new_version}...");
|
||||
} else {
|
||||
log::info!("Downloading browser {browser} version {new_version}...");
|
||||
|
||||
// Emit the auto-update event to trigger frontend handling
|
||||
let auto_update_event = serde_json::json!({
|
||||
"browser": browser,
|
||||
"new_version": new_version,
|
||||
"notification_id": notification_id,
|
||||
"affected_profiles": affected_profiles
|
||||
});
|
||||
|
||||
if let Err(e) = events::emit("browser-auto-update-available", &auto_update_event)
|
||||
{
|
||||
log::error!("Failed to emit auto-update event for {browser}: {e}");
|
||||
} else {
|
||||
log::info!("Emitted auto-update event for {browser}");
|
||||
// Download directly from Rust — download_browser_full already
|
||||
// auto-updates non-running profiles after successful download.
|
||||
match crate::downloader::download_browser(
|
||||
app_handle_clone,
|
||||
browser.clone(),
|
||||
new_version.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(actual_version) => {
|
||||
log::info!("Auto-download completed for {browser} {actual_version}");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to auto-download {browser} {new_version}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -217,6 +224,24 @@ impl AutoUpdater {
|
||||
log::error!("Failed to check for browser updates: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Also update any profiles that can be bumped to an already-installed newer version.
|
||||
// This handles cases where a version was downloaded but profiles weren't updated
|
||||
// (e.g., they were running at the time, or the update was missed).
|
||||
match self.update_profiles_to_latest_installed(app_handle) {
|
||||
Ok(updated) => {
|
||||
if !updated.is_empty() {
|
||||
log::info!(
|
||||
"Updated {} profiles to latest installed versions: {:?}",
|
||||
updated.len(),
|
||||
updated
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to update profiles to latest installed versions: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a specific profile has an available update
|
||||
@@ -313,9 +338,42 @@ impl AutoUpdater {
|
||||
// Find all profiles for this browser that should be updated
|
||||
for profile in profiles {
|
||||
if profile.browser == browser {
|
||||
if profile.is_cross_os() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if profile is currently running
|
||||
if profile.process_id.is_some() {
|
||||
continue; // Skip running profiles
|
||||
// Store as pending update so it gets applied when browser closes
|
||||
log::info!(
|
||||
"Profile {} is running, storing pending update {} -> {}",
|
||||
profile.name,
|
||||
profile.version,
|
||||
new_version
|
||||
);
|
||||
let mut state = self.load_auto_update_state().unwrap_or_default();
|
||||
let notification = UpdateNotification {
|
||||
id: format!("{}_{}_to_{}", browser, profile.version, new_version),
|
||||
browser: browser.to_string(),
|
||||
current_version: profile.version.clone(),
|
||||
new_version: new_version.to_string(),
|
||||
affected_profiles: vec![profile.name.clone()],
|
||||
is_stable_update: true,
|
||||
timestamp: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
};
|
||||
// Add if not already pending
|
||||
if !state
|
||||
.pending_updates
|
||||
.iter()
|
||||
.any(|u| u.id == notification.id)
|
||||
{
|
||||
state.pending_updates.push(notification);
|
||||
let _ = self.save_auto_update_state(&state);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this is an update (newer version)
|
||||
@@ -448,6 +506,148 @@ impl AutoUpdater {
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Get the latest installed version for a browser from the downloaded browsers registry
|
||||
pub fn get_latest_installed_version(&self, browser: &str) -> Option<String> {
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
let versions = registry.get_downloaded_versions(browser);
|
||||
versions
|
||||
.into_iter()
|
||||
.filter(|v| registry.is_browser_downloaded(browser, v))
|
||||
.max_by(|a, b| self.compare_versions(a, b))
|
||||
}
|
||||
|
||||
/// Update a single profile to the latest installed version for its browser.
|
||||
/// Used when a browser closes to ensure it's on the latest version.
|
||||
pub fn update_profile_to_latest_installed(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
) -> Option<crate::profile::BrowserProfile> {
|
||||
let latest = self.get_latest_installed_version(&profile.browser)?;
|
||||
|
||||
if !self.is_version_newer(&latest, &profile.version) {
|
||||
return None;
|
||||
}
|
||||
|
||||
// Only update stable->stable and nightly->nightly
|
||||
let is_profile_nightly =
|
||||
crate::api_client::is_browser_version_nightly(&profile.browser, &profile.version, None);
|
||||
let is_latest_nightly =
|
||||
crate::api_client::is_browser_version_nightly(&profile.browser, &latest, None);
|
||||
if is_profile_nightly != is_latest_nightly {
|
||||
return None;
|
||||
}
|
||||
|
||||
match self
|
||||
.profile_manager
|
||||
.update_profile_version(app_handle, &profile.id.to_string(), &latest)
|
||||
{
|
||||
Ok(updated) => {
|
||||
log::info!(
|
||||
"Updated profile {} from {} {} to latest installed version {}",
|
||||
profile.name,
|
||||
profile.browser,
|
||||
profile.version,
|
||||
latest
|
||||
);
|
||||
Some(updated)
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!(
|
||||
"Failed to update profile {} to latest installed version: {e}",
|
||||
profile.name
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Update all non-running profiles to the latest installed version for each browser.
|
||||
/// Handles the case where a newer version was downloaded but profiles weren't updated.
|
||||
pub fn update_profiles_to_latest_installed(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
let profiles = self
|
||||
.profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
let mut all_updated = Vec::new();
|
||||
|
||||
// Group profiles by browser
|
||||
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
|
||||
for profile in profiles {
|
||||
if profile.is_cross_os() {
|
||||
continue;
|
||||
}
|
||||
browser_profiles
|
||||
.entry(profile.browser.clone())
|
||||
.or_default()
|
||||
.push(profile);
|
||||
}
|
||||
|
||||
for (browser, profiles) in browser_profiles {
|
||||
let installed_versions = registry.get_downloaded_versions(&browser);
|
||||
if installed_versions.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the latest installed version that actually exists on disk
|
||||
let latest_installed = installed_versions
|
||||
.iter()
|
||||
.filter(|v| registry.is_browser_downloaded(&browser, v))
|
||||
.max_by(|a, b| self.compare_versions(a, b));
|
||||
|
||||
let latest_version = match latest_installed {
|
||||
Some(v) => v.clone(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
for profile in profiles {
|
||||
if profile.process_id.is_some() {
|
||||
continue;
|
||||
}
|
||||
|
||||
if !self.is_version_newer(&latest_version, &profile.version) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only update stable->stable and nightly->nightly
|
||||
let is_profile_nightly =
|
||||
crate::api_client::is_browser_version_nightly(&browser, &profile.version, None);
|
||||
let is_latest_nightly =
|
||||
crate::api_client::is_browser_version_nightly(&browser, &latest_version, None);
|
||||
if is_profile_nightly != is_latest_nightly {
|
||||
continue;
|
||||
}
|
||||
|
||||
match self.profile_manager.update_profile_version(
|
||||
app_handle,
|
||||
&profile.id.to_string(),
|
||||
&latest_version,
|
||||
) {
|
||||
Ok(_) => {
|
||||
log::info!(
|
||||
"Updated profile {} from {} {} to latest installed version {}",
|
||||
profile.name,
|
||||
browser,
|
||||
profile.version,
|
||||
latest_version
|
||||
);
|
||||
all_updated.push(profile.name);
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to update profile {}: {e}", profile.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(all_updated)
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
@@ -515,6 +715,10 @@ mod tests {
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -316,6 +316,20 @@ fn run_daemon() {
|
||||
}
|
||||
Event::Reopen { .. } => {
|
||||
tray::open_gui();
|
||||
|
||||
// Re-hide daemon from Dock. macOS activates the daemon (making it
|
||||
// visible) when the user clicks the Dock icon, overriding the
|
||||
// Accessory policy set at init.
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
use objc2::MainThreadMarker;
|
||||
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy};
|
||||
|
||||
if let Some(mtm) = MainThreadMarker::new() {
|
||||
let app = NSApplication::sharedApplication(mtm);
|
||||
app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -147,6 +147,11 @@ async fn main() {
|
||||
Arg::new("profile-id")
|
||||
.long("profile-id")
|
||||
.help("ID of the profile this proxy is associated with"),
|
||||
)
|
||||
.arg(
|
||||
Arg::new("bypass-rules")
|
||||
.long("bypass-rules")
|
||||
.help("JSON array of bypass rules (hostnames, IPs, or regex patterns)"),
|
||||
),
|
||||
)
|
||||
.subcommand(
|
||||
@@ -217,8 +222,12 @@ async fn main() {
|
||||
|
||||
let port = start_matches.get_one::<u16>("port").copied();
|
||||
let profile_id = start_matches.get_one::<String>("profile-id").cloned();
|
||||
let bypass_rules: Vec<String> = start_matches
|
||||
.get_one::<String>("bypass-rules")
|
||||
.and_then(|s| serde_json::from_str(s).ok())
|
||||
.unwrap_or_default();
|
||||
|
||||
match start_proxy_process_with_profile(upstream_url, port, profile_id).await {
|
||||
match start_proxy_process_with_profile(upstream_url, port, profile_id, bypass_rules).await {
|
||||
Ok(config) => {
|
||||
// Output the configuration as JSON for the Rust side to parse
|
||||
// Use println! here because this needs to go to stdout for parsing
|
||||
|
||||
+224
-35
@@ -1,5 +1,6 @@
|
||||
use crate::browser::{create_browser, BrowserType, ProxySettings};
|
||||
use crate::camoufox_manager::{CamoufoxConfig, CamoufoxManager};
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::events;
|
||||
use crate::platform_browser;
|
||||
@@ -37,6 +38,27 @@ impl BrowserRunner {
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
/// Refresh cloud proxy credentials if the profile uses a cloud or cloud-derived proxy,
|
||||
/// then resolve the proxy settings with profile-specific sid for sticky sessions.
|
||||
async fn resolve_proxy_with_refresh(
|
||||
&self,
|
||||
proxy_id: Option<&String>,
|
||||
profile_id: Option<&str>,
|
||||
) -> Option<ProxySettings> {
|
||||
let proxy_id = proxy_id?;
|
||||
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
|
||||
log::info!("Refreshing cloud proxy credentials before launch for proxy {proxy_id}");
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
}
|
||||
// For cloud-derived proxies, inject profile-specific sid for sticky sessions
|
||||
if let Some(pid) = profile_id {
|
||||
if PROXY_MANAGER.is_cloud_or_derived(proxy_id) {
|
||||
return PROXY_MANAGER.resolve_proxy_for_profile(proxy_id, pid);
|
||||
}
|
||||
}
|
||||
PROXY_MANAGER.get_proxy_settings_by_id(proxy_id)
|
||||
}
|
||||
|
||||
/// Get the executable path for a browser profile
|
||||
/// This is a common helper to eliminate code duplication across the codebase
|
||||
pub fn get_browser_executable_path(
|
||||
@@ -92,10 +114,10 @@ impl BrowserRunner {
|
||||
});
|
||||
|
||||
// Always start a local proxy for Camoufox (for traffic monitoring and geoip support)
|
||||
let mut upstream_proxy = profile
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let mut upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
@@ -138,6 +160,7 @@ impl BrowserRunner {
|
||||
upstream_proxy.as_ref(),
|
||||
0, // Use 0 as temporary PID, will be updated later
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -210,14 +233,6 @@ 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())
|
||||
@@ -227,6 +242,31 @@ impl BrowserRunner {
|
||||
None
|
||||
};
|
||||
|
||||
// Install extensions if an extension group is assigned
|
||||
if updated_profile.extension_group_id.is_some() {
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let ext_profile_path = if let Some(ref override_path) = override_profile_path {
|
||||
override_path.clone()
|
||||
} else {
|
||||
updated_profile.get_profile_data_path(&profiles_dir)
|
||||
};
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
match mgr.install_extensions_for_profile(&updated_profile, &ext_profile_path) {
|
||||
Ok(paths) => {
|
||||
if !paths.is_empty() {
|
||||
log::info!(
|
||||
"Installed {} Firefox extensions for profile: {}",
|
||||
paths.len(),
|
||||
updated_profile.name
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to install extensions for Camoufox profile: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Launch Camoufox browser
|
||||
log::info!("Launching Camoufox for profile: {}", profile.name);
|
||||
let camoufox_result = self
|
||||
@@ -332,10 +372,10 @@ impl BrowserRunner {
|
||||
});
|
||||
|
||||
// Always start a local proxy for Wayfern (for traffic monitoring and geoip support)
|
||||
let mut upstream_proxy = profile
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let mut upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
@@ -378,6 +418,7 @@ impl BrowserRunner {
|
||||
upstream_proxy.as_ref(),
|
||||
0, // Use 0 as temporary PID, will be updated later
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -455,6 +496,27 @@ impl BrowserRunner {
|
||||
crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy().to_string();
|
||||
|
||||
// Install extensions if an extension group is assigned
|
||||
let mut extension_paths = Vec::new();
|
||||
if updated_profile.extension_group_id.is_some() {
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
match mgr.install_extensions_for_profile(&updated_profile, &profile_data_path) {
|
||||
Ok(paths) => {
|
||||
if !paths.is_empty() {
|
||||
log::info!(
|
||||
"Prepared {} Chromium extensions for profile: {}",
|
||||
paths.len(),
|
||||
updated_profile.name
|
||||
);
|
||||
}
|
||||
extension_paths = paths;
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to install extensions for Wayfern profile: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get proxy URL from config
|
||||
let proxy_url = wayfern_config.proxy.as_deref();
|
||||
|
||||
@@ -468,6 +530,8 @@ impl BrowserRunner {
|
||||
url.as_deref(),
|
||||
proxy_url,
|
||||
profile.ephemeral,
|
||||
&extension_paths,
|
||||
remote_debugging_port,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
@@ -567,11 +631,10 @@ impl BrowserRunner {
|
||||
// Continue anyway, the error might not be critical
|
||||
}
|
||||
|
||||
// Get stored proxy settings for later use (removed as we handle this in proxy startup)
|
||||
let _stored_proxy_settings = profile
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
// Refresh cloud proxy credentials if needed before resolving
|
||||
let _stored_proxy_settings = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await;
|
||||
|
||||
// Use provided local proxy for Chromium-based browsers launch arguments
|
||||
let proxy_for_launch_args: Option<&ProxySettings> = local_proxy_settings;
|
||||
@@ -1025,10 +1088,10 @@ impl BrowserRunner {
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Always start a local proxy for API launches
|
||||
// Determine upstream proxy if configured; otherwise use DIRECT
|
||||
let upstream_proxy = profile
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
// Refresh cloud proxy credentials before resolving
|
||||
let upstream_proxy = self
|
||||
.resolve_proxy_with_refresh(profile.proxy_id.as_ref(), Some(&profile.id.to_string()))
|
||||
.await;
|
||||
|
||||
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
|
||||
let temp_pid = 1u32;
|
||||
@@ -1041,6 +1104,7 @@ impl BrowserRunner {
|
||||
upstream_proxy.as_ref(),
|
||||
temp_pid,
|
||||
Some(&profile_id_str),
|
||||
profile.proxy_bypass_rules.clone(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
@@ -1348,7 +1412,11 @@ impl BrowserRunner {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use crate::platform_browser;
|
||||
if let Err(e) = platform_browser::linux::kill_browser_process_impl(pid).await
|
||||
if let Err(e) = platform_browser::linux::kill_browser_process_impl(
|
||||
pid,
|
||||
Some(&profile_path_str),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
|
||||
} else {
|
||||
@@ -1429,7 +1497,12 @@ impl BrowserRunner {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use crate::platform_browser;
|
||||
if let Err(e) = platform_browser::linux::kill_browser_process_impl(pid).await {
|
||||
if let Err(e) = platform_browser::linux::kill_browser_process_impl(
|
||||
pid,
|
||||
Some(&profile_path_str),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
|
||||
} else {
|
||||
// Verify the process is actually dead after force kill
|
||||
@@ -1517,7 +1590,8 @@ impl BrowserRunner {
|
||||
{
|
||||
use crate::platform_browser;
|
||||
if let Err(kill_err) =
|
||||
platform_browser::linux::kill_browser_process_impl(pid).await
|
||||
platform_browser::linux::kill_browser_process_impl(pid, Some(&profile_path_str))
|
||||
.await
|
||||
{
|
||||
log::error!(
|
||||
"Failed to force kill Camoufox process {}: {}",
|
||||
@@ -1638,6 +1712,16 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// If no pending update was applied, check if a newer installed version exists
|
||||
if updated_profile.version == profile.version {
|
||||
if let Some(p) = self
|
||||
.auto_updater
|
||||
.update_profile_to_latest_installed(&app_handle, &updated_profile)
|
||||
{
|
||||
updated_profile = p;
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
.save_process_info(&updated_profile)
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))?;
|
||||
@@ -1787,7 +1871,12 @@ impl BrowserRunner {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use crate::platform_browser;
|
||||
if let Err(e) = platform_browser::linux::kill_browser_process_impl(pid).await {
|
||||
if let Err(e) = platform_browser::linux::kill_browser_process_impl(
|
||||
pid,
|
||||
Some(&profile_path_str),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to force kill Wayfern process {}: {}", pid, e);
|
||||
} else {
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
@@ -1858,7 +1947,8 @@ impl BrowserRunner {
|
||||
{
|
||||
use crate::platform_browser;
|
||||
if let Err(kill_err) =
|
||||
platform_browser::linux::kill_browser_process_impl(pid).await
|
||||
platform_browser::linux::kill_browser_process_impl(pid, Some(&profile_path_str))
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to force kill Wayfern process {}: {}", pid, kill_err);
|
||||
} else {
|
||||
@@ -1961,6 +2051,16 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// If no pending update was applied, check if a newer installed version exists
|
||||
if updated_profile.version == profile.version {
|
||||
if let Some(p) = self
|
||||
.auto_updater
|
||||
.update_profile_to_latest_installed(&app_handle, &updated_profile)
|
||||
{
|
||||
updated_profile = p;
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
.save_process_info(&updated_profile)
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))?;
|
||||
@@ -2155,7 +2255,12 @@ impl BrowserRunner {
|
||||
platform_browser::windows::kill_browser_process_impl(pid).await?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
platform_browser::linux::kill_browser_process_impl(pid).await?;
|
||||
{
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy().to_string();
|
||||
platform_browser::linux::kill_browser_process_impl(pid, Some(&profile_path_str)).await?;
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
return Err("Unsupported platform".into());
|
||||
@@ -2234,6 +2339,16 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// If no pending update was applied, check if a newer installed version exists
|
||||
if updated_profile.version == profile.version {
|
||||
if let Some(p) = self
|
||||
.auto_updater
|
||||
.update_profile_to_latest_installed(&app_handle, &updated_profile)
|
||||
{
|
||||
updated_profile = p;
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
.save_process_info(&updated_profile)
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))?;
|
||||
@@ -2462,6 +2577,16 @@ pub async fn launch_browser_profile(
|
||||
));
|
||||
}
|
||||
|
||||
// Team lock check: if profile is sync-enabled and user is on a team, acquire lock
|
||||
crate::team_lock::acquire_team_lock_if_needed(&profile).await?;
|
||||
|
||||
// Notify sync scheduler that profile is now running
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
scheduler
|
||||
.mark_profile_running(&profile.id.to_string())
|
||||
.await;
|
||||
}
|
||||
|
||||
let browser_runner = BrowserRunner::instance();
|
||||
|
||||
// Store the internal proxy settings for passing to launch_browser
|
||||
@@ -2492,10 +2617,13 @@ 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 mut upstream_proxy = profile_for_launch
|
||||
.proxy_id
|
||||
.as_ref()
|
||||
.and_then(|id| PROXY_MANAGER.get_proxy_settings_by_id(id));
|
||||
// Refresh cloud proxy credentials and inject profile-specific sid
|
||||
let mut upstream_proxy = BrowserRunner::instance()
|
||||
.resolve_proxy_with_refresh(
|
||||
profile_for_launch.proxy_id.as_ref(),
|
||||
Some(&profile_for_launch.id.to_string()),
|
||||
)
|
||||
.await;
|
||||
|
||||
// If profile has a VPN instead of proxy, start VPN worker and use it as upstream
|
||||
if upstream_proxy.is_none() {
|
||||
@@ -2532,6 +2660,7 @@ pub async fn launch_browser_profile(
|
||||
upstream_proxy.as_ref(),
|
||||
temp_pid,
|
||||
Some(&profile_id_str),
|
||||
profile_for_launch.proxy_bypass_rules.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
@@ -2664,6 +2793,66 @@ pub async fn kill_browser_profile(
|
||||
profile.name,
|
||||
profile.id
|
||||
);
|
||||
|
||||
// Release team lock if applicable
|
||||
crate::team_lock::release_team_lock_if_needed(&profile).await;
|
||||
|
||||
// Notify sync scheduler that profile stopped and queue sync
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let pid = profile.id.to_string();
|
||||
scheduler.mark_profile_stopped(&pid).await;
|
||||
if profile.is_sync_enabled() {
|
||||
log::info!("Profile '{}' killed, queuing sync", profile.name);
|
||||
scheduler.queue_profile_sync(pid).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-update non-running profiles and cleanup unused binaries
|
||||
let browser_for_update = profile.browser.clone();
|
||||
let app_handle_for_update = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
let mut versions = registry.get_downloaded_versions(&browser_for_update);
|
||||
if !versions.is_empty() {
|
||||
versions.sort_by(|a, b| crate::api_client::compare_versions(b, a));
|
||||
let latest_version = &versions[0];
|
||||
|
||||
let auto_updater = crate::auto_updater::AutoUpdater::instance();
|
||||
match auto_updater
|
||||
.auto_update_profile_versions(
|
||||
&app_handle_for_update,
|
||||
&browser_for_update,
|
||||
latest_version,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated) => {
|
||||
if !updated.is_empty() {
|
||||
log::info!(
|
||||
"Auto-updated {} profiles after stop: {:?}",
|
||||
updated.len(),
|
||||
updated
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to auto-update profile versions after stop: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match registry.cleanup_unused_binaries() {
|
||||
Ok(cleaned) => {
|
||||
if !cleaned.is_empty() {
|
||||
log::info!("Cleaned up unused binaries after stop: {:?}", cleaned);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to cleanup unused binaries after stop: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Err(e) => {
|
||||
|
||||
@@ -557,9 +557,11 @@ impl CamoufoxManager {
|
||||
/// Check if a Camoufox server is running with the given process ID
|
||||
async fn is_server_running(&self, process_id: u32) -> bool {
|
||||
// Check if the process is still running
|
||||
use sysinfo::{Pid, System};
|
||||
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
let system = System::new_all();
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
if let Some(process) = system.process(Pid::from(process_id as usize)) {
|
||||
// Check if this is actually a Camoufox process by looking at the command line
|
||||
let cmd = process.cmd();
|
||||
@@ -628,6 +630,9 @@ impl CamoufoxManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Write search.json.mozlz4 with default search engines (DuckDuckGo + Google)
|
||||
write_default_search_config(&profile_path);
|
||||
|
||||
self
|
||||
.launch_camoufox(
|
||||
&app_handle,
|
||||
@@ -641,6 +646,77 @@ impl CamoufoxManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn write_default_search_config(profile_path: &std::path::Path) {
|
||||
let search_file = profile_path.join("search.json.mozlz4");
|
||||
if search_file.exists() {
|
||||
return;
|
||||
}
|
||||
|
||||
let json = serde_json::json!({
|
||||
"version": 6,
|
||||
"engines": [
|
||||
{
|
||||
"_name": "DuckDuckGo",
|
||||
"_isAppProvided": false,
|
||||
"_metaData": { "order": 1 },
|
||||
"_urls": [
|
||||
{
|
||||
"template": "https://duckduckgo.com/?q={searchTerms}",
|
||||
"type": "text/html",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"template": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
|
||||
"type": "application/x-suggestions+json",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"_iconURL": "https://duckduckgo.com/favicon.ico"
|
||||
},
|
||||
{
|
||||
"_name": "Google",
|
||||
"_isAppProvided": false,
|
||||
"_metaData": { "order": 2 },
|
||||
"_urls": [
|
||||
{
|
||||
"template": "https://www.google.com/search?q={searchTerms}",
|
||||
"type": "text/html",
|
||||
"params": []
|
||||
},
|
||||
{
|
||||
"template": "https://www.google.com/complete/search?client=firefox&q={searchTerms}",
|
||||
"type": "application/x-suggestions+json",
|
||||
"params": []
|
||||
}
|
||||
],
|
||||
"_iconURL": "https://www.google.com/favicon.ico"
|
||||
}
|
||||
],
|
||||
"metaData": {
|
||||
"useSavedOrder": false,
|
||||
"defaultEngineId": "DuckDuckGo"
|
||||
}
|
||||
});
|
||||
|
||||
let json_bytes = match serde_json::to_vec(&json) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to serialize search config: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let magic = b"mozLz40\0";
|
||||
let compressed = lz4_flex::block::compress_prepend_size(&json_bytes);
|
||||
let mut output = Vec::with_capacity(magic.len() + compressed.len());
|
||||
output.extend_from_slice(magic);
|
||||
output.extend_from_slice(&compressed);
|
||||
|
||||
if let Err(e) = std::fs::write(&search_file, &output) {
|
||||
log::warn!("Failed to write search.json.mozlz4: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+316
-33
@@ -37,6 +37,14 @@ pub struct CloudUser {
|
||||
pub proxy_bandwidth_limit_mb: i64,
|
||||
#[serde(rename = "proxyBandwidthUsedMb")]
|
||||
pub proxy_bandwidth_used_mb: i64,
|
||||
#[serde(rename = "proxyBandwidthExtraMb", default)]
|
||||
pub proxy_bandwidth_extra_mb: i64,
|
||||
#[serde(rename = "teamId", default)]
|
||||
pub team_id: Option<String>,
|
||||
#[serde(rename = "teamName", default)]
|
||||
pub team_name: Option<String>,
|
||||
#[serde(rename = "teamRole", default)]
|
||||
pub team_role: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -73,6 +81,14 @@ struct SyncTokenResponse {
|
||||
sync_token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WayfernTokenResponse {
|
||||
token: String,
|
||||
#[serde(rename = "expiresIn")]
|
||||
#[allow(dead_code)]
|
||||
expires_in: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LocationItem {
|
||||
pub code: String,
|
||||
@@ -97,6 +113,7 @@ pub struct CloudAuthManager {
|
||||
client: Client,
|
||||
state: Mutex<Option<CloudAuthState>>,
|
||||
refresh_lock: tokio::sync::Mutex<()>,
|
||||
wayfern_token: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
@@ -110,6 +127,7 @@ impl CloudAuthManager {
|
||||
client: Client::new(),
|
||||
state: Mutex::new(state),
|
||||
refresh_lock: tokio::sync::Mutex::new(()),
|
||||
wayfern_token: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,7 +265,7 @@ impl CloudAuthManager {
|
||||
Self::encrypt_and_store(&path, b"DBCAT", token)
|
||||
}
|
||||
|
||||
fn load_access_token() -> Result<Option<String>, String> {
|
||||
pub(crate) fn load_access_token() -> Result<Option<String>, String> {
|
||||
let path = Self::get_settings_dir().join("cloud_access_token.dat");
|
||||
Self::decrypt_from_file(&path, b"DBCAT")
|
||||
}
|
||||
@@ -570,6 +588,12 @@ impl CloudAuthManager {
|
||||
}
|
||||
|
||||
pub async fn logout(&self) -> Result<(), String> {
|
||||
// Clear wayfern token
|
||||
self.clear_wayfern_token().await;
|
||||
|
||||
// Disconnect team lock manager
|
||||
crate::team_lock::TEAM_LOCK.disconnect().await;
|
||||
|
||||
// Try to call the logout API (best-effort)
|
||||
if let Ok(Some(access_token)) = Self::load_access_token() {
|
||||
let refresh_token = Self::load_refresh_token().ok().flatten();
|
||||
@@ -635,6 +659,13 @@ impl CloudAuthManager {
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn is_on_team_plan(&self) -> bool {
|
||||
if let Some(state) = self.get_user().await {
|
||||
return state.user.team_id.is_some();
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
pub async fn get_user(&self) -> Option<CloudAuthState> {
|
||||
let state = self.state.lock().await;
|
||||
state.clone()
|
||||
@@ -648,7 +679,7 @@ impl CloudAuthManager {
|
||||
|
||||
/// API call with 401 retry: if first attempt gets 401, refresh access token and retry once.
|
||||
/// Uses refresh_lock to prevent concurrent token rotations from racing.
|
||||
async fn api_call_with_retry<F, Fut, T>(&self, make_request: F) -> Result<T, String>
|
||||
pub async fn api_call_with_retry<F, Fut, T>(&self, make_request: F) -> Result<T, String>
|
||||
where
|
||||
F: Fn(String) -> Fut + Send,
|
||||
Fut: std::future::Future<Output = Result<T, String>> + Send,
|
||||
@@ -679,11 +710,12 @@ impl CloudAuthManager {
|
||||
|
||||
/// Fetch proxy configuration from the cloud backend
|
||||
async fn fetch_proxy_config(&self) -> Result<Option<CloudProxyConfigResponse>, String> {
|
||||
// Check cached user state for proxy bandwidth
|
||||
// Check cached user state for proxy bandwidth (subscription or extra)
|
||||
{
|
||||
let state = self.state.lock().await;
|
||||
match &*state {
|
||||
Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => {}
|
||||
Some(auth)
|
||||
if auth.user.proxy_bandwidth_limit_mb > 0 || auth.user.proxy_bandwidth_extra_mb > 0 => {}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
}
|
||||
@@ -822,13 +854,13 @@ impl CloudAuthManager {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch state list for a country from the cloud backend
|
||||
pub async fn fetch_states(&self, country: &str) -> Result<Vec<LocationItem>, String> {
|
||||
/// Fetch region list for a country from the cloud backend
|
||||
pub async fn fetch_regions(&self, country: &str) -> Result<Vec<LocationItem>, String> {
|
||||
let country = country.to_string();
|
||||
self
|
||||
.api_call_with_retry(move |access_token| {
|
||||
let url = format!(
|
||||
"{CLOUD_API_URL}/api/proxy/locations/states?country={}",
|
||||
"{CLOUD_API_URL}/api/proxy/locations/regions?country={}",
|
||||
country
|
||||
);
|
||||
let client = reqwest::Client::new();
|
||||
@@ -838,37 +870,40 @@ impl CloudAuthManager {
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch states: {e}"))?;
|
||||
.map_err(|e| format!("Failed to fetch regions: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("States fetch failed ({status}): {body}"));
|
||||
return Err(format!("Regions fetch failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<Vec<LocationItem>>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse states: {e}"))
|
||||
.map_err(|e| format!("Failed to parse regions: {e}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch city list for a country+state from the cloud backend
|
||||
/// Fetch city list for a country, optionally filtered by region
|
||||
pub async fn fetch_cities(
|
||||
&self,
|
||||
country: &str,
|
||||
state: &str,
|
||||
region: Option<&str>,
|
||||
) -> Result<Vec<LocationItem>, String> {
|
||||
let country = country.to_string();
|
||||
let state = state.to_string();
|
||||
let region = region.map(|s| s.to_string());
|
||||
self
|
||||
.api_call_with_retry(move |access_token| {
|
||||
let url = format!(
|
||||
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}&state={}",
|
||||
country, state
|
||||
let mut url = format!(
|
||||
"{CLOUD_API_URL}/api/proxy/locations/cities?country={}",
|
||||
country
|
||||
);
|
||||
if let Some(ref r) = region {
|
||||
url.push_str(&format!("®ion={}", r));
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
async move {
|
||||
let response = client
|
||||
@@ -893,8 +928,108 @@ impl CloudAuthManager {
|
||||
.await
|
||||
}
|
||||
|
||||
/// Fetch ISP list for a country, optionally filtered by region and city
|
||||
pub async fn fetch_isps(
|
||||
&self,
|
||||
country: &str,
|
||||
region: Option<&str>,
|
||||
city: Option<&str>,
|
||||
) -> Result<Vec<LocationItem>, String> {
|
||||
let country = country.to_string();
|
||||
let region = region.map(|s| s.to_string());
|
||||
let city = city.map(|s| s.to_string());
|
||||
self
|
||||
.api_call_with_retry(move |access_token| {
|
||||
let mut url = format!(
|
||||
"{CLOUD_API_URL}/api/proxy/locations/isps?country={}",
|
||||
country
|
||||
);
|
||||
if let Some(ref r) = region {
|
||||
url.push_str(&format!("®ion={}", r));
|
||||
}
|
||||
if let Some(ref c) = city {
|
||||
url.push_str(&format!("&city={}", c));
|
||||
}
|
||||
let client = reqwest::Client::new();
|
||||
async move {
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch ISPs: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("ISPs fetch failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<Vec<LocationItem>>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse ISPs: {e}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Request a wayfern token from the cloud API. Only succeeds for paid users.
|
||||
pub async fn request_wayfern_token(&self) -> Result<(), String> {
|
||||
if !self.has_active_paid_subscription().await {
|
||||
self.clear_wayfern_token().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let token = self
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/auth/wayfern-start");
|
||||
let client = reqwest::Client::new();
|
||||
async move {
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to request wayfern token: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Wayfern token request failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
let result: WayfernTokenResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse wayfern token response: {e}"))?;
|
||||
|
||||
Ok(result.token)
|
||||
}
|
||||
})
|
||||
.await?;
|
||||
|
||||
let mut wt = self.wayfern_token.lock().await;
|
||||
*wt = Some(token);
|
||||
log::info!("Wayfern token acquired");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the current wayfern token, if any.
|
||||
pub async fn get_wayfern_token(&self) -> Option<String> {
|
||||
let wt = self.wayfern_token.lock().await;
|
||||
wt.clone()
|
||||
}
|
||||
|
||||
/// Clear the cached wayfern token.
|
||||
pub async fn clear_wayfern_token(&self) {
|
||||
let mut wt = self.wayfern_token.lock().await;
|
||||
*wt = None;
|
||||
}
|
||||
|
||||
/// Background loop that refreshes the sync token periodically
|
||||
pub async fn start_sync_token_refresh_loop(app_handle: tauri::AppHandle) {
|
||||
let mut wayfern_refresh_counter: u32 = 0;
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(600)).await; // 10 minutes
|
||||
|
||||
@@ -902,6 +1037,8 @@ impl CloudAuthManager {
|
||||
continue;
|
||||
}
|
||||
|
||||
wayfern_refresh_counter += 1;
|
||||
|
||||
// Proactively refresh the access token if it's expired or expiring soon.
|
||||
// This runs first so subsequent API calls use a fresh token.
|
||||
if let Ok(Some(token)) = Self::load_access_token() {
|
||||
@@ -933,9 +1070,28 @@ impl CloudAuthManager {
|
||||
log::debug!("Failed to refresh cloud profile: {e}");
|
||||
}
|
||||
|
||||
// Reconnect team lock manager if needed
|
||||
if let Some(auth_state) = CLOUD_AUTH.get_user().await {
|
||||
if let Some(tid) = &auth_state.user.team_id {
|
||||
crate::team_lock::TEAM_LOCK.connect(tid).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Sync cloud proxy credentials
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
|
||||
// Refresh wayfern token every 12 hours (72 iterations of 10-minute loop)
|
||||
if wayfern_refresh_counter >= 72 {
|
||||
wayfern_refresh_counter = 0;
|
||||
if CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
|
||||
log::warn!("Failed to refresh wayfern token: {e}");
|
||||
}
|
||||
} else {
|
||||
CLOUD_AUTH.clear_wayfern_token().await;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = &app_handle; // keep app_handle alive
|
||||
}
|
||||
}
|
||||
@@ -971,11 +1127,23 @@ pub async fn cloud_verify_otp(
|
||||
Ok(None) => log::warn!("Sync token not available despite active subscription"),
|
||||
Err(e) => log::error!("Failed to pre-fetch sync token after login: {e}"),
|
||||
}
|
||||
|
||||
// Request wayfern token for paid users
|
||||
if let Err(e) = CLOUD_AUTH.request_wayfern_token().await {
|
||||
log::warn!("Failed to request wayfern token after login: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// Sync cloud proxy after login
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
|
||||
// Connect team lock manager if on a team plan
|
||||
if state.user.team_id.is_some() {
|
||||
if let Some(tid) = &state.user.team_id {
|
||||
crate::team_lock::TEAM_LOCK.connect(tid).await;
|
||||
}
|
||||
}
|
||||
|
||||
let _ = crate::events::emit_empty("cloud-auth-changed");
|
||||
|
||||
let _ = &app_handle;
|
||||
@@ -1005,6 +1173,9 @@ pub async fn cloud_logout(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
}
|
||||
let _ = manager.remove_sync_token(&app_handle).await;
|
||||
|
||||
// Remove cloud-managed and cloud-derived proxies
|
||||
crate::proxy_manager::PROXY_MANAGER.remove_cloud_proxies();
|
||||
|
||||
let _ = crate::events::emit_empty("cloud-auth-changed");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1014,33 +1185,59 @@ pub async fn cloud_has_active_subscription() -> Result<bool, String> {
|
||||
Ok(CLOUD_AUTH.has_active_paid_subscription().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_wayfern_token() -> Result<Option<String>, String> {
|
||||
Ok(CLOUD_AUTH.get_wayfern_token().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_refresh_wayfern_token() -> Result<Option<String>, String> {
|
||||
CLOUD_AUTH.request_wayfern_token().await?;
|
||||
Ok(CLOUD_AUTH.get_wayfern_token().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_countries() -> Result<Vec<LocationItem>, String> {
|
||||
CLOUD_AUTH.fetch_countries().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_states(country: String) -> Result<Vec<LocationItem>, String> {
|
||||
CLOUD_AUTH.fetch_states(&country).await
|
||||
pub async fn cloud_get_regions(country: String) -> Result<Vec<LocationItem>, String> {
|
||||
CLOUD_AUTH.fetch_regions(&country).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_cities(country: String, state: String) -> Result<Vec<LocationItem>, String> {
|
||||
CLOUD_AUTH.fetch_cities(&country, &state).await
|
||||
pub async fn cloud_get_cities(
|
||||
country: String,
|
||||
region: Option<String>,
|
||||
) -> Result<Vec<LocationItem>, String> {
|
||||
CLOUD_AUTH.fetch_cities(&country, region.as_deref()).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_isps(
|
||||
country: String,
|
||||
region: Option<String>,
|
||||
city: Option<String>,
|
||||
) -> Result<Vec<LocationItem>, String> {
|
||||
CLOUD_AUTH
|
||||
.fetch_isps(&country, region.as_deref(), city.as_deref())
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_cloud_location_proxy(
|
||||
name: String,
|
||||
country: String,
|
||||
state: Option<String>,
|
||||
region: Option<String>,
|
||||
city: Option<String>,
|
||||
isp: Option<String>,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
// If no cloud proxy exists yet, attempt to sync it first
|
||||
if !PROXY_MANAGER.has_cloud_proxy() {
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
}
|
||||
PROXY_MANAGER.create_cloud_location_proxy(name, country, state, city)
|
||||
PROXY_MANAGER.create_cloud_location_proxy(name, country, region, city, isp)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -1048,22 +1245,108 @@ pub struct CloudProxyUsage {
|
||||
pub used_mb: i64,
|
||||
pub limit_mb: i64,
|
||||
pub remaining_mb: i64,
|
||||
pub recurring_limit_mb: i64,
|
||||
pub extra_limit_mb: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ProxyUsageResponse {
|
||||
#[serde(rename = "usedMb")]
|
||||
used_mb: i64,
|
||||
#[serde(rename = "limitMb")]
|
||||
limit_mb: i64,
|
||||
#[serde(rename = "remainingMb")]
|
||||
remaining_mb: i64,
|
||||
#[serde(rename = "recurringLimitMb", default)]
|
||||
recurring_limit_mb: i64,
|
||||
#[serde(rename = "extraLimitMb", default)]
|
||||
extra_limit_mb: i64,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn cloud_get_proxy_usage() -> Result<Option<CloudProxyUsage>, String> {
|
||||
let state = CLOUD_AUTH.state.lock().await;
|
||||
match &*state {
|
||||
Some(auth) if auth.user.proxy_bandwidth_limit_mb > 0 => {
|
||||
let used = auth.user.proxy_bandwidth_used_mb;
|
||||
let limit = auth.user.proxy_bandwidth_limit_mb;
|
||||
Ok(Some(CloudProxyUsage {
|
||||
used_mb: used,
|
||||
limit_mb: limit,
|
||||
remaining_mb: (limit - used).max(0),
|
||||
}))
|
||||
let (has_proxy, cached_recurring, cached_extra) = {
|
||||
let state = CLOUD_AUTH.state.lock().await;
|
||||
match &*state {
|
||||
Some(auth)
|
||||
if auth.user.proxy_bandwidth_limit_mb > 0 || auth.user.proxy_bandwidth_extra_mb > 0 =>
|
||||
{
|
||||
(
|
||||
true,
|
||||
auth.user.proxy_bandwidth_limit_mb,
|
||||
auth.user.proxy_bandwidth_extra_mb,
|
||||
)
|
||||
}
|
||||
_ => return Ok(None),
|
||||
}
|
||||
};
|
||||
|
||||
if !has_proxy {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Fetch live usage from the API
|
||||
match CLOUD_AUTH
|
||||
.api_call_with_retry(|access_token| {
|
||||
let url = format!("{CLOUD_API_URL}/api/proxy/usage");
|
||||
let client = reqwest::Client::new();
|
||||
async move {
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch proxy usage: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Proxy usage API returned status {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
|
||||
response
|
||||
.json::<ProxyUsageResponse>()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse proxy usage: {e}"))
|
||||
}
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(usage) => Ok(Some(CloudProxyUsage {
|
||||
used_mb: usage.used_mb,
|
||||
limit_mb: usage.limit_mb,
|
||||
remaining_mb: usage.remaining_mb,
|
||||
recurring_limit_mb: if usage.recurring_limit_mb > 0 {
|
||||
usage.recurring_limit_mb
|
||||
} else {
|
||||
cached_recurring
|
||||
},
|
||||
extra_limit_mb: if usage.recurring_limit_mb > 0 {
|
||||
usage.extra_limit_mb
|
||||
} else {
|
||||
cached_extra
|
||||
},
|
||||
})),
|
||||
Err(e) => {
|
||||
log::warn!("Failed to fetch live proxy usage, falling back to cached: {e}");
|
||||
// Fallback to cached values
|
||||
let state = CLOUD_AUTH.state.lock().await;
|
||||
match &*state {
|
||||
Some(auth) => {
|
||||
let used = auth.user.proxy_bandwidth_used_mb;
|
||||
let total = cached_recurring + cached_extra;
|
||||
Ok(Some(CloudProxyUsage {
|
||||
used_mb: used,
|
||||
limit_mb: total,
|
||||
remaining_mb: (total - used).max(0),
|
||||
recurring_limit_mb: cached_recurring,
|
||||
extra_limit_mb: cached_extra,
|
||||
}))
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
_ => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -74,18 +74,20 @@ fn get_app_bundle_path() -> Option<std::path::PathBuf> {
|
||||
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")]
|
||||
{
|
||||
// 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.
|
||||
// Launch the GUI binary directly. The daemon lives inside the same .app
|
||||
// bundle, so `open` (even with `-n`) can re-activate the daemon instead
|
||||
// of launching the GUI. Directly running the binary avoids macOS's app
|
||||
// activation machinery. 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();
|
||||
let gui_binary = app_bundle.join("Contents").join("MacOS").join("Donut");
|
||||
if gui_binary.exists() {
|
||||
let _ = Command::new(&gui_binary).spawn();
|
||||
} else {
|
||||
let _ = Command::new("open").args(["-n"]).arg(&app_bundle).spawn();
|
||||
}
|
||||
} else {
|
||||
let _ = Command::new("open").args(["-n", "-a", "Donut"]).spawn();
|
||||
}
|
||||
|
||||
+214
-38
@@ -158,7 +158,11 @@ impl Downloader {
|
||||
let release = releases
|
||||
.iter()
|
||||
.find(|r| r.tag_name == version)
|
||||
.ok_or(format!("Camoufox version {version} not found"))?;
|
||||
.or_else(|| {
|
||||
log::info!("Camoufox: requested version {version} not found, using latest available");
|
||||
releases.first()
|
||||
})
|
||||
.ok_or("No Camoufox releases found".to_string())?;
|
||||
|
||||
// Get platform and architecture info
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
@@ -179,14 +183,10 @@ impl Downloader {
|
||||
.fetch_wayfern_version_with_caching(true)
|
||||
.await?;
|
||||
|
||||
// Verify requested version matches available version
|
||||
if version_info.version != version {
|
||||
return Err(
|
||||
format!(
|
||||
"Wayfern version {version} not found. Available version: {}",
|
||||
version_info.version
|
||||
)
|
||||
.into(),
|
||||
log::info!(
|
||||
"Wayfern: requested version {version}, using available version {}",
|
||||
version_info.version
|
||||
);
|
||||
}
|
||||
|
||||
@@ -659,6 +659,41 @@ impl Downloader {
|
||||
return Err("Please accept Wayfern Terms and Conditions before downloading browsers".into());
|
||||
}
|
||||
|
||||
// For Wayfern/Camoufox, resolve the actual available version from the API
|
||||
let version = if browser_str == "wayfern" {
|
||||
match self
|
||||
.api_client
|
||||
.fetch_wayfern_version_with_caching(true)
|
||||
.await
|
||||
{
|
||||
Ok(info) if info.version != version => {
|
||||
log::info!(
|
||||
"Wayfern: requested {version}, using available {}",
|
||||
info.version
|
||||
);
|
||||
info.version
|
||||
}
|
||||
_ => version,
|
||||
}
|
||||
} else if browser_str == "camoufox" {
|
||||
match self
|
||||
.api_client
|
||||
.fetch_camoufox_releases_with_caching(true)
|
||||
.await
|
||||
{
|
||||
Ok(releases) if !releases.is_empty() && releases[0].tag_name != version => {
|
||||
log::info!(
|
||||
"Camoufox: requested {version}, using available {}",
|
||||
releases[0].tag_name
|
||||
);
|
||||
releases[0].tag_name.clone()
|
||||
}
|
||||
_ => version,
|
||||
}
|
||||
} else {
|
||||
version
|
||||
};
|
||||
|
||||
// Check if this browser-version pair is already being downloaded
|
||||
let download_key = format!("{browser_str}-{version}");
|
||||
let cancel_token = {
|
||||
@@ -998,6 +1033,40 @@ impl Downloader {
|
||||
tokens.remove(&download_key);
|
||||
}
|
||||
|
||||
// Auto-update non-running profiles to the latest installed version and cleanup unused binaries
|
||||
{
|
||||
let app_handle_for_update = app_handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let auto_updater = crate::auto_updater::AutoUpdater::instance();
|
||||
match auto_updater.update_profiles_to_latest_installed(&app_handle_for_update) {
|
||||
Ok(updated) => {
|
||||
if !updated.is_empty() {
|
||||
log::info!(
|
||||
"Auto-updated {} profiles to latest installed versions: {:?}",
|
||||
updated.len(),
|
||||
updated
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to auto-update profile versions: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
let registry = crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
match registry.cleanup_unused_binaries() {
|
||||
Ok(cleaned) => {
|
||||
if !cleaned.is_empty() {
|
||||
log::info!("Cleaned up unused binaries after download: {:?}", cleaned);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to cleanup unused binaries: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
}
|
||||
@@ -1033,57 +1102,164 @@ 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.
|
||||
/// Find all candidate `distribution/` directories inside the Camoufox browser dir.
|
||||
/// On macOS: `<browser_dir>/<app>.app/Contents/Resources/distribution/`
|
||||
/// On Linux: `<browser_dir>/camoufox/distribution/`
|
||||
/// On Windows: `<browser_dir>/distribution/`
|
||||
/// Also includes `<browser_dir>/distribution/` as a fallback for all platforms.
|
||||
#[allow(clippy::vec_init_then_push)]
|
||||
fn find_camoufox_distribution_dirs(browser_dir: &Path) -> Vec<std::path::PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(entries) = std::fs::read_dir(browser_dir) {
|
||||
for entry in entries.flatten() {
|
||||
if entry.path().extension().is_some_and(|ext| ext == "app") {
|
||||
dirs.push(
|
||||
entry
|
||||
.path()
|
||||
.join("Contents")
|
||||
.join("Resources")
|
||||
.join("distribution"),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
dirs.push(browser_dir.join("camoufox").join("distribution"));
|
||||
}
|
||||
|
||||
// Fallback for all platforms
|
||||
dirs.push(browser_dir.join("distribution"));
|
||||
|
||||
dirs
|
||||
}
|
||||
|
||||
/// Set DuckDuckGo as the default search engine in Camoufox.
|
||||
/// Creates or updates distribution/policies.json with a proper DuckDuckGo engine definition.
|
||||
/// Called both at download time and at launch time to cover existing installations.
|
||||
pub fn configure_camoufox_search_engine(
|
||||
browser_dir: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let policies_path = browser_dir.join("distribution").join("policies.json");
|
||||
let distribution_dirs = find_camoufox_distribution_dirs(browser_dir);
|
||||
|
||||
if !policies_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
// Find an existing policies.json, or pick the first candidate dir to create one
|
||||
let (policies_path, mut policies) = {
|
||||
let mut found = None;
|
||||
for dir in &distribution_dirs {
|
||||
let path = dir.join("policies.json");
|
||||
if path.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(&path) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
found = Some((path, val));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
match found {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
// Pick the first candidate directory that exists (or can be created)
|
||||
let target_dir = distribution_dirs
|
||||
.iter()
|
||||
.find(|d| d.parent().is_some_and(|p| p.exists()))
|
||||
.or(distribution_dirs.first())
|
||||
.ok_or("No suitable distribution directory found")?;
|
||||
std::fs::create_dir_all(target_dir)?;
|
||||
(
|
||||
target_dir.join("policies.json"),
|
||||
serde_json::json!({"policies": {}}),
|
||||
)
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let content = std::fs::read_to_string(&policies_path)?;
|
||||
let mut policies: serde_json::Value = serde_json::from_str(&content)?;
|
||||
|
||||
let current_default = policies
|
||||
// Check if already configured
|
||||
let has_ddg_default = policies
|
||||
.get("policies")
|
||||
.and_then(|p| p.get("SearchEngines"))
|
||||
.and_then(|se| se.get("Default"))
|
||||
.and_then(|d| d.as_str())
|
||||
.unwrap_or("");
|
||||
== Some("DuckDuckGo");
|
||||
|
||||
if current_default == "DuckDuckGo" {
|
||||
let has_ddg_engine = policies
|
||||
.get("policies")
|
||||
.and_then(|p| p.get("SearchEngines"))
|
||||
.and_then(|se| se.get("Add"))
|
||||
.and_then(|a| a.as_array())
|
||||
.is_some_and(|arr| {
|
||||
arr
|
||||
.iter()
|
||||
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
|
||||
});
|
||||
|
||||
if has_ddg_default && has_ddg_engine {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
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()),
|
||||
);
|
||||
}
|
||||
let ddg_engine = serde_json::json!({
|
||||
"Name": "DuckDuckGo",
|
||||
"URLTemplate": "https://duckduckgo.com/?q={searchTerms}",
|
||||
"SuggestURLTemplate": "https://duckduckgo.com/ac/?q={searchTerms}&type=list",
|
||||
"Method": "GET",
|
||||
"IconURL": "https://duckduckgo.com/favicon.ico",
|
||||
"Alias": "ddg"
|
||||
});
|
||||
|
||||
// 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 policies.SearchEngines exists
|
||||
let policies_obj = policies
|
||||
.as_object_mut()
|
||||
.ok_or("Invalid policies.json")?
|
||||
.entry("policies")
|
||||
.or_insert(serde_json::json!({}));
|
||||
let se = policies_obj
|
||||
.as_object_mut()
|
||||
.ok_or("Invalid policies object")?
|
||||
.entry("SearchEngines")
|
||||
.or_insert(serde_json::json!({}));
|
||||
|
||||
// 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"));
|
||||
}
|
||||
if let Some(se_obj) = se.as_object_mut() {
|
||||
// Set DuckDuckGo as default
|
||||
se_obj.insert(
|
||||
"Default".to_string(),
|
||||
serde_json::Value::String("DuckDuckGo".to_string()),
|
||||
);
|
||||
|
||||
// Add DuckDuckGo engine definition if not present
|
||||
let add_arr = se_obj
|
||||
.entry("Add")
|
||||
.or_insert(serde_json::json!([]))
|
||||
.as_array_mut()
|
||||
.ok_or("SearchEngines.Add is not an array")?;
|
||||
|
||||
// Remove fake "None" engine
|
||||
add_arr.retain(|entry| entry.get("Name").and_then(|n| n.as_str()) != Some("None"));
|
||||
|
||||
// Add DuckDuckGo if not already present
|
||||
if !add_arr
|
||||
.iter()
|
||||
.any(|e| e.get("Name").and_then(|n| n.as_str()) == Some("DuckDuckGo"))
|
||||
{
|
||||
add_arr.push(ddg_engine);
|
||||
}
|
||||
|
||||
// Ensure DuckDuckGo is not in the Remove list
|
||||
if let Some(remove_arr) = se_obj.get_mut("Remove").and_then(|r| r.as_array_mut()) {
|
||||
remove_arr.retain(|v| v.as_str() != Some("DuckDuckGo"));
|
||||
}
|
||||
}
|
||||
|
||||
let updated = serde_json::to_string_pretty(&policies)?;
|
||||
std::fs::write(&policies_path, updated)?;
|
||||
log::info!("Set DuckDuckGo as default search engine in Camoufox policies.json");
|
||||
log::info!(
|
||||
"Configured DuckDuckGo search engine in {}",
|
||||
policies_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -273,6 +273,10 @@ mod tests {
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,7 @@ use directories::BaseDirs;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
@@ -22,6 +23,8 @@ pub struct GeoIPDownloadProgress {
|
||||
pub eta_seconds: Option<f64>,
|
||||
}
|
||||
|
||||
static DOWNLOAD_IN_PROGRESS: AtomicBool = AtomicBool::new(false);
|
||||
|
||||
pub struct GeoIPDownloader {
|
||||
client: Client,
|
||||
}
|
||||
@@ -126,6 +129,22 @@ impl GeoIPDownloader {
|
||||
pub async fn download_geoip_database(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if DOWNLOAD_IN_PROGRESS
|
||||
.compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
|
||||
.is_err()
|
||||
{
|
||||
log::info!("GeoIP database download already in progress, skipping");
|
||||
return Ok(());
|
||||
}
|
||||
let result = self.download_geoip_database_inner(_app_handle).await;
|
||||
DOWNLOAD_IN_PROGRESS.store(false, Ordering::SeqCst);
|
||||
result
|
||||
}
|
||||
|
||||
async fn download_geoip_database_inner(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Emit initial progress
|
||||
let _ = events::emit(
|
||||
|
||||
+283
-26
@@ -22,6 +22,7 @@ mod default_browser;
|
||||
mod downloaded_browsers_registry;
|
||||
mod downloader;
|
||||
mod ephemeral_dirs;
|
||||
mod extension_manager;
|
||||
mod extraction;
|
||||
mod geoip_downloader;
|
||||
mod group_manager;
|
||||
@@ -49,6 +50,7 @@ pub mod daemon_ws;
|
||||
pub mod events;
|
||||
mod mcp_server;
|
||||
mod tag_manager;
|
||||
mod team_lock;
|
||||
mod version_updater;
|
||||
pub mod vpn;
|
||||
pub mod vpn_worker_runner;
|
||||
@@ -61,7 +63,8 @@ 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_profile_vpn, update_wayfern_config,
|
||||
update_profile_proxy, update_profile_proxy_bypass_rules, update_profile_tags, update_profile_vpn,
|
||||
update_wayfern_config,
|
||||
};
|
||||
|
||||
use browser_version_manager::{
|
||||
@@ -87,7 +90,8 @@ use settings_manager::{
|
||||
use sync::{
|
||||
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,
|
||||
is_vpn_in_use_by_synced_profile, request_profile_sync, set_e2e_password,
|
||||
set_extension_group_sync_enabled, set_extension_sync_enabled, set_group_sync_enabled,
|
||||
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled,
|
||||
};
|
||||
|
||||
@@ -111,6 +115,13 @@ use app_auto_updater::{
|
||||
|
||||
use profile_importer::{detect_existing_profiles, import_browser_profile};
|
||||
|
||||
use extension_manager::{
|
||||
add_extension, add_extension_to_group, assign_extension_group_to_profile, create_extension_group,
|
||||
delete_extension, delete_extension_group, get_extension_group_for_profile, get_extension_icon,
|
||||
list_extension_groups, list_extensions, remove_extension_from_group, update_extension,
|
||||
update_extension_group,
|
||||
};
|
||||
|
||||
use group_manager::{
|
||||
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
|
||||
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
|
||||
@@ -293,7 +304,33 @@ async fn copy_profile_cookies(
|
||||
{
|
||||
return Err("Cookie copying requires an active Pro subscription".to_string());
|
||||
}
|
||||
cookie_manager::CookieManager::copy_cookies(&app_handle, request).await
|
||||
let target_ids = request.target_profile_ids.clone();
|
||||
let results = cookie_manager::CookieManager::copy_cookies(&app_handle, request).await?;
|
||||
|
||||
// Trigger sync for target profiles that have sync enabled
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let profile_manager = profile::manager::ProfileManager::instance();
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
let sync_ids: Vec<String> = target_ids
|
||||
.iter()
|
||||
.filter(|tid| {
|
||||
profiles
|
||||
.iter()
|
||||
.any(|p| p.id.to_string() == **tid && p.is_sync_enabled())
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
if !sync_ids.is_empty() {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
for id in sync_ids {
|
||||
scheduler.queue_profile_sync(id).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -308,7 +345,25 @@ async fn import_cookies_from_file(
|
||||
{
|
||||
return Err("Cookie import requires an active Pro subscription".to_string());
|
||||
}
|
||||
cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await
|
||||
let result =
|
||||
cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await?;
|
||||
|
||||
// Trigger sync for the profile if sync is enabled
|
||||
if let Some(scheduler) = crate::sync::get_global_scheduler() {
|
||||
let profile_manager = profile::manager::ProfileManager::instance();
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
|
||||
if profile.is_sync_enabled() {
|
||||
let pid = profile_id.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
scheduler.queue_profile_sync(pid).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -746,6 +801,62 @@ async fn list_active_vpn_connections() -> Result<Vec<vpn::VpnStatus>, String> {
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn generate_sample_fingerprint(
|
||||
app_handle: tauri::AppHandle,
|
||||
browser: String,
|
||||
version: String,
|
||||
config_json: String,
|
||||
) -> Result<String, String> {
|
||||
let temp_profile = crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "temp_fingerprint_gen".to_string(),
|
||||
browser: browser.clone(),
|
||||
version: version.clone(),
|
||||
process_id: None,
|
||||
proxy_id: None,
|
||||
vpn_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
wayfern_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_mode: crate::profile::types::SyncMode::Disabled,
|
||||
encryption_salt: None,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
};
|
||||
|
||||
if browser == "camoufox" {
|
||||
let config: crate::camoufox_manager::CamoufoxConfig =
|
||||
serde_json::from_str(&config_json).map_err(|e| format!("Failed to parse config: {e}"))?;
|
||||
let manager = crate::camoufox_manager::CamoufoxManager::instance();
|
||||
manager
|
||||
.generate_fingerprint_config(&app_handle, &temp_profile, &config)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
|
||||
} else if browser == "wayfern" {
|
||||
let config: crate::wayfern_manager::WayfernConfig =
|
||||
serde_json::from_str(&config_json).map_err(|e| format!("Failed to parse config: {e}"))?;
|
||||
let manager = crate::wayfern_manager::WayfernManager::instance();
|
||||
manager
|
||||
.generate_fingerprint_config(&app_handle, &temp_profile, &config)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to generate fingerprint: {e}"))
|
||||
} else {
|
||||
Err(format!(
|
||||
"Unsupported browser for fingerprint generation: {browser}"
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
@@ -808,6 +919,12 @@ pub fn run() {
|
||||
// Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk)
|
||||
ephemeral_dirs::recover_ephemeral_dirs();
|
||||
|
||||
// Extract icons and metadata for existing extensions that don't have them yet
|
||||
{
|
||||
let mgr = extension_manager::ExtensionManager::new();
|
||||
mgr.ensure_icons_extracted();
|
||||
}
|
||||
|
||||
// Start the daemon for tray icon
|
||||
if let Err(e) = daemon_spawn::ensure_daemon_running() {
|
||||
log::warn!("Failed to start daemon: {e}");
|
||||
@@ -951,6 +1068,93 @@ pub fn run() {
|
||||
version_updater::VersionUpdater::run_background_task().await;
|
||||
});
|
||||
|
||||
// TODO(v0.17+): Remove this migration block after a few releases.
|
||||
// Migrate proxy/VPN worker configs from old proxies/ dir to new proxy_workers/ cache dir.
|
||||
// Before v0.16, ephemeral worker configs (proxy_*, vpnw_*) lived alongside persistent
|
||||
// StoredProxy files in proxies/. Now they live in cache_dir/proxy_workers/.
|
||||
{
|
||||
let old_dir = crate::app_dirs::proxies_dir();
|
||||
let new_dir = crate::app_dirs::proxy_workers_dir();
|
||||
if old_dir.exists() {
|
||||
if let Err(e) = std::fs::create_dir_all(&new_dir) {
|
||||
log::error!("Failed to create proxy_workers dir: {e}");
|
||||
} else if let Ok(entries) = std::fs::read_dir(&old_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if (name.starts_with("proxy_") || name.starts_with("vpnw_"))
|
||||
&& name.ends_with(".json")
|
||||
{
|
||||
let dest = new_dir.join(name);
|
||||
match std::fs::rename(&path, &dest) {
|
||||
Ok(()) => log::info!("Migrated worker config {name} to proxy_workers/"),
|
||||
Err(e) => {
|
||||
// rename fails across filesystems, fall back to copy+delete
|
||||
if let Ok(content) = std::fs::read(&path) {
|
||||
if std::fs::write(&dest, &content).is_ok() {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
log::info!("Migrated worker config {name} to proxy_workers/ (copy)");
|
||||
}
|
||||
} else {
|
||||
log::warn!("Failed to migrate worker config {name}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear stale process IDs from profiles (processes that died while app was closed)
|
||||
{
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
let system = sysinfo::System::new_with_specifics(
|
||||
sysinfo::RefreshKind::nothing()
|
||||
.with_processes(sysinfo::ProcessRefreshKind::everything()),
|
||||
);
|
||||
for profile in profiles {
|
||||
if let Some(pid) = profile.process_id {
|
||||
let sysinfo_pid = sysinfo::Pid::from_u32(pid);
|
||||
if system.process(sysinfo_pid).is_none() {
|
||||
log::info!(
|
||||
"Clearing stale process_id {} for profile {}",
|
||||
pid,
|
||||
profile.name
|
||||
);
|
||||
let mut updated = profile.clone();
|
||||
updated.process_id = None;
|
||||
let _ = profile_manager.save_profile(&updated);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Immediately bump non-running profiles to the latest installed browser version.
|
||||
// This runs synchronously before any network calls so profiles are updated on launch.
|
||||
{
|
||||
let app_handle_bump = app.handle().clone();
|
||||
match auto_updater::AutoUpdater::instance()
|
||||
.update_profiles_to_latest_installed(&app_handle_bump)
|
||||
{
|
||||
Ok(updated) => {
|
||||
if !updated.is_empty() {
|
||||
log::info!(
|
||||
"Startup: bumped {} profiles to latest installed versions: {:?}",
|
||||
updated.len(),
|
||||
updated
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Startup: failed to bump profiles to latest installed versions: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let app_handle_auto_updater = app.handle().clone();
|
||||
|
||||
// Start the auto-update check task separately
|
||||
@@ -1006,29 +1210,31 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
let _app_handle_update = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
log::info!("Starting app update check at startup...");
|
||||
let updater = app_auto_updater::AppAutoUpdater::instance();
|
||||
match updater.check_for_updates().await {
|
||||
Ok(Some(update_info)) => {
|
||||
log::info!(
|
||||
"App update available: {} -> {}",
|
||||
update_info.current_version,
|
||||
update_info.new_version
|
||||
);
|
||||
// Emit update available event to the frontend
|
||||
if let Err(e) = events::emit("app-update-available", &update_info) {
|
||||
log::error!("Failed to emit app update event: {e}");
|
||||
} else {
|
||||
log::debug!("App update event emitted successfully");
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(3 * 60 * 60));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
log::info!("Checking for app updates...");
|
||||
match updater.check_for_updates().await {
|
||||
Ok(Some(update_info)) => {
|
||||
log::info!(
|
||||
"App update available: {} -> {}",
|
||||
update_info.current_version,
|
||||
update_info.new_version
|
||||
);
|
||||
if let Err(e) = events::emit("app-update-available", &update_info) {
|
||||
log::error!("Failed to emit app update event: {e}");
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
log::debug!("No app updates available");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to check for app updates: {e}");
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
log::debug!("No app updates available");
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to check for app updates: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -1175,6 +1381,20 @@ pub fn run() {
|
||||
);
|
||||
}
|
||||
|
||||
// Notify sync scheduler of running state changes
|
||||
if let Some(scheduler) = sync::get_global_scheduler() {
|
||||
if is_running {
|
||||
scheduler.mark_profile_running(&profile_id).await;
|
||||
} else {
|
||||
scheduler.mark_profile_stopped(&profile_id).await;
|
||||
// Queue sync after profile stops (if sync is enabled)
|
||||
if profile.is_sync_enabled() {
|
||||
log::info!("Profile '{}' stopped, queuing sync", profile.name);
|
||||
scheduler.queue_profile_sync(profile_id.clone()).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
last_running_states.insert(profile_id, is_running);
|
||||
} else {
|
||||
// Update the state even if unchanged to ensure we have it tracked
|
||||
@@ -1302,6 +1522,13 @@ pub fn run() {
|
||||
log::warn!("Failed to refresh cloud sync token on startup: {e}");
|
||||
}
|
||||
cloud_auth::CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
|
||||
// Request wayfern token on startup for paid users
|
||||
if cloud_auth::CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
if let Err(e) = cloud_auth::CLOUD_AUTH.request_wayfern_token().await {
|
||||
log::warn!("Failed to request wayfern token on startup: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
cloud_auth::CloudAuthManager::start_sync_token_refresh_loop(app_handle_cloud).await;
|
||||
});
|
||||
@@ -1329,6 +1556,7 @@ pub fn run() {
|
||||
update_profile_vpn,
|
||||
update_profile_tags,
|
||||
update_profile_note,
|
||||
update_profile_proxy_bypass_rules,
|
||||
check_browser_status,
|
||||
kill_browser_profile,
|
||||
rename_profile,
|
||||
@@ -1373,6 +1601,7 @@ pub fn run() {
|
||||
import_proxies_from_parsed,
|
||||
update_camoufox_config,
|
||||
update_wayfern_config,
|
||||
generate_sample_fingerprint,
|
||||
get_profile_groups,
|
||||
get_groups_with_profile_counts,
|
||||
create_profile_group,
|
||||
@@ -1380,6 +1609,19 @@ pub fn run() {
|
||||
delete_profile_group,
|
||||
assign_profiles_to_group,
|
||||
delete_selected_profiles,
|
||||
list_extensions,
|
||||
get_extension_icon,
|
||||
add_extension,
|
||||
update_extension,
|
||||
delete_extension,
|
||||
list_extension_groups,
|
||||
create_extension_group,
|
||||
update_extension_group,
|
||||
delete_extension_group,
|
||||
add_extension_to_group,
|
||||
remove_extension_from_group,
|
||||
assign_extension_group_to_profile,
|
||||
get_extension_group_for_profile,
|
||||
is_geoip_database_available,
|
||||
download_geoip_database,
|
||||
start_api_server,
|
||||
@@ -1398,6 +1640,8 @@ pub fn run() {
|
||||
is_group_in_use_by_synced_profile,
|
||||
set_vpn_sync_enabled,
|
||||
is_vpn_in_use_by_synced_profile,
|
||||
set_extension_sync_enabled,
|
||||
set_extension_group_sync_enabled,
|
||||
get_unsynced_entity_counts,
|
||||
enable_sync_for_all_entities,
|
||||
set_e2e_password,
|
||||
@@ -1437,10 +1681,16 @@ pub fn run() {
|
||||
cloud_auth::cloud_logout,
|
||||
cloud_auth::cloud_get_proxy_usage,
|
||||
cloud_auth::cloud_get_countries,
|
||||
cloud_auth::cloud_get_states,
|
||||
cloud_auth::cloud_get_regions,
|
||||
cloud_auth::cloud_get_cities,
|
||||
cloud_auth::cloud_get_isps,
|
||||
cloud_auth::create_cloud_location_proxy,
|
||||
cloud_auth::restart_sync_service
|
||||
cloud_auth::restart_sync_service,
|
||||
cloud_auth::cloud_get_wayfern_token,
|
||||
cloud_auth::cloud_refresh_wayfern_token,
|
||||
// Team lock commands
|
||||
team_lock::get_team_locks,
|
||||
team_lock::get_team_lock_status,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
@@ -1480,6 +1730,13 @@ mod tests {
|
||||
"get_vpn_config",
|
||||
"list_active_vpn_connections",
|
||||
"export_profile_cookies",
|
||||
"update_extension",
|
||||
"set_extension_sync_enabled",
|
||||
"set_extension_group_sync_enabled",
|
||||
"get_team_lock_status",
|
||||
"generate_sample_fingerprint",
|
||||
"cloud_get_wayfern_token",
|
||||
"cloud_refresh_wayfern_token",
|
||||
];
|
||||
|
||||
// Extract command names from the generate_handler! macro in this file
|
||||
|
||||
+416
-2
@@ -725,6 +725,114 @@ impl McpServer {
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "update_profile_proxy_bypass_rules".to_string(),
|
||||
description:
|
||||
"Update proxy bypass rules for a profile. Requests matching these rules will connect directly, bypassing the proxy."
|
||||
.to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the profile to update"
|
||||
},
|
||||
"rules": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Array of bypass rules. Supports hostnames (e.g. 'example.com'), IP addresses, and regex patterns."
|
||||
}
|
||||
},
|
||||
"required": ["profile_id", "rules"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "list_extensions".to_string(),
|
||||
description: "List all managed browser extensions. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "list_extension_groups".to_string(),
|
||||
description: "List all extension groups. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "create_extension_group".to_string(),
|
||||
description: "Create a new extension group. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": { "type": "string", "description": "Name for the extension group" }
|
||||
},
|
||||
"required": ["name"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "delete_extension".to_string(),
|
||||
description: "Delete a managed extension. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"extension_id": { "type": "string", "description": "The extension ID to delete" }
|
||||
},
|
||||
"required": ["extension_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "delete_extension_group".to_string(),
|
||||
description: "Delete an extension group. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"group_id": { "type": "string", "description": "The extension group ID to delete" }
|
||||
},
|
||||
"required": ["group_id"]
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "assign_extension_group_to_profile".to_string(),
|
||||
description: "Assign an extension group to a profile, or remove the assignment. Requires Pro subscription.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": { "type": "string", "description": "The profile ID" },
|
||||
"extension_group_id": { "type": "string", "description": "The extension group ID, or empty string to remove" }
|
||||
},
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
// Team lock tools
|
||||
McpTool {
|
||||
name: "get_team_locks".to_string(),
|
||||
description: "List all active team profile locks. Requires team plan.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": []
|
||||
}),
|
||||
},
|
||||
McpTool {
|
||||
name: "get_team_lock_status".to_string(),
|
||||
description: "Check if a profile is locked by a team member. Requires team plan.".to_string(),
|
||||
input_schema: serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"profile_id": {
|
||||
"type": "string",
|
||||
"description": "The UUID of the profile to check"
|
||||
}
|
||||
},
|
||||
"required": ["profile_id"]
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -826,6 +934,25 @@ impl McpServer {
|
||||
// Fingerprint management
|
||||
"get_profile_fingerprint" => self.handle_get_profile_fingerprint(&arguments).await,
|
||||
"update_profile_fingerprint" => self.handle_update_profile_fingerprint(&arguments).await,
|
||||
"update_profile_proxy_bypass_rules" => {
|
||||
self
|
||||
.handle_update_profile_proxy_bypass_rules(&arguments)
|
||||
.await
|
||||
}
|
||||
// Extension management
|
||||
"list_extensions" => self.handle_list_extensions().await,
|
||||
"list_extension_groups" => self.handle_list_extension_groups().await,
|
||||
"create_extension_group" => self.handle_create_extension_group(&arguments).await,
|
||||
"delete_extension" => self.handle_delete_extension_mcp(&arguments).await,
|
||||
"delete_extension_group" => self.handle_delete_extension_group_mcp(&arguments).await,
|
||||
"assign_extension_group_to_profile" => {
|
||||
self
|
||||
.handle_assign_extension_group_to_profile(&arguments)
|
||||
.await
|
||||
}
|
||||
// Team lock tools
|
||||
"get_team_locks" => self.handle_get_team_locks().await,
|
||||
"get_team_lock_status" => self.handle_get_team_lock_status(&arguments).await,
|
||||
_ => Err(McpError {
|
||||
code: -32602,
|
||||
message: format!("Unknown tool: {tool_name}"),
|
||||
@@ -940,6 +1067,14 @@ impl McpServer {
|
||||
});
|
||||
}
|
||||
|
||||
// Team lock check
|
||||
crate::team_lock::acquire_team_lock_if_needed(profile)
|
||||
.await
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: e,
|
||||
})?;
|
||||
|
||||
// Get app handle to launch
|
||||
let inner = self.inner.lock().await;
|
||||
let app_handle = inner.app_handle.as_ref().ok_or_else(|| McpError {
|
||||
@@ -1021,6 +1156,8 @@ impl McpServer {
|
||||
message: format!("Failed to kill browser: {e}"),
|
||||
})?;
|
||||
|
||||
crate::team_lock::release_team_lock_if_needed(profile).await;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
@@ -2066,6 +2203,272 @@ impl McpServer {
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_update_profile_proxy_bypass_rules(
|
||||
&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 rules: Vec<String> = arguments
|
||||
.get("rules")
|
||||
.and_then(|v| v.as_array())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing rules array".to_string(),
|
||||
})?
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
|
||||
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(),
|
||||
})?;
|
||||
|
||||
let profile = ProfileManager::instance()
|
||||
.update_profile_proxy_bypass_rules(app_handle, profile_id, rules.clone())
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to update proxy bypass rules: {e}"),
|
||||
})?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": format!(
|
||||
"Proxy bypass rules updated for profile '{}': {} rule(s) configured",
|
||||
profile.name,
|
||||
rules.len()
|
||||
)
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_list_extensions(&self) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let extensions = mgr.list_extensions().map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list extensions: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(extensions).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_list_extension_groups(&self) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let groups = mgr.list_groups().map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to list extension groups: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(groups).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_create_extension_group(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let name = arguments
|
||||
.get("name")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: name".to_string(),
|
||||
})?;
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
let group = mgr.create_group(name.to_string()).map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to create extension group: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(group).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_delete_extension_mcp(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let extension_id = arguments
|
||||
.get("extension_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: extension_id".to_string(),
|
||||
})?;
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.delete_extension_internal(extension_id)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to delete extension: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::json!({"success": true}))
|
||||
}
|
||||
|
||||
async fn handle_delete_extension_group_mcp(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management requires an active Pro subscription".to_string(),
|
||||
});
|
||||
}
|
||||
let group_id = arguments
|
||||
.get("group_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.ok_or_else(|| McpError {
|
||||
code: -32602,
|
||||
message: "Missing required parameter: group_id".to_string(),
|
||||
})?;
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
// For MCP, we don't have an app_handle, but we need one for sync deletion.
|
||||
// Use the delete_group_internal which skips sync remote deletion.
|
||||
mgr.delete_group_internal(group_id).map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to delete extension group: {e}"),
|
||||
})?;
|
||||
if let Err(e) = crate::events::emit_empty("extensions-changed") {
|
||||
log::error!("Failed to emit extensions-changed event: {e}");
|
||||
}
|
||||
Ok(serde_json::json!({"success": true}))
|
||||
}
|
||||
|
||||
async fn handle_assign_extension_group_to_profile(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.has_active_paid_subscription().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Extension management 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 required parameter: profile_id".to_string(),
|
||||
})?;
|
||||
let extension_group_id = arguments
|
||||
.get("extension_group_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| {
|
||||
if s.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(s.to_string())
|
||||
}
|
||||
})
|
||||
.unwrap_or(None);
|
||||
|
||||
// Validate compatibility if assigning
|
||||
if let Some(ref gid) = extension_group_id {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager.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 '{profile_id}' not found"),
|
||||
})?;
|
||||
let mgr = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
mgr
|
||||
.validate_group_compatibility(gid, &profile.browser)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("{e}"),
|
||||
})?;
|
||||
}
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profile = profile_manager
|
||||
.update_profile_extension_group(profile_id, extension_group_id)
|
||||
.map_err(|e| McpError {
|
||||
code: -32000,
|
||||
message: format!("Failed to assign extension group: {e}"),
|
||||
})?;
|
||||
Ok(serde_json::to_value(profile).unwrap())
|
||||
}
|
||||
|
||||
async fn handle_get_team_locks(&self) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.is_on_team_plan().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Team features require an active team plan".to_string(),
|
||||
});
|
||||
}
|
||||
let locks = crate::team_lock::TEAM_LOCK.get_locks().await;
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&locks).unwrap_or_default()
|
||||
}]
|
||||
}))
|
||||
}
|
||||
|
||||
async fn handle_get_team_lock_status(
|
||||
&self,
|
||||
arguments: &serde_json::Value,
|
||||
) -> Result<serde_json::Value, McpError> {
|
||||
if !CLOUD_AUTH.is_on_team_plan().await {
|
||||
return Err(McpError {
|
||||
code: -32000,
|
||||
message: "Team features require an active team plan".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 lock_status = crate::team_lock::TEAM_LOCK
|
||||
.get_lock_status(profile_id)
|
||||
.await;
|
||||
Ok(serde_json::json!({
|
||||
"content": [{
|
||||
"type": "text",
|
||||
"text": serde_json::to_string_pretty(&lock_status).unwrap_or_default()
|
||||
}]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
@@ -2081,8 +2484,8 @@ mod tests {
|
||||
let server = McpServer::new();
|
||||
let tools = server.get_tools();
|
||||
|
||||
// Should have at least 26 tools (24 + 2 fingerprint tools)
|
||||
assert!(tools.len() >= 26);
|
||||
// Should have at least 34 tools (26 + 6 extension tools + 2 team lock tools)
|
||||
assert!(tools.len() >= 34);
|
||||
|
||||
// Check tool names
|
||||
let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect();
|
||||
@@ -2118,6 +2521,17 @@ mod tests {
|
||||
// Fingerprint tools
|
||||
assert!(tool_names.contains(&"get_profile_fingerprint"));
|
||||
assert!(tool_names.contains(&"update_profile_fingerprint"));
|
||||
assert!(tool_names.contains(&"update_profile_proxy_bypass_rules"));
|
||||
// Extension tools
|
||||
assert!(tool_names.contains(&"list_extensions"));
|
||||
assert!(tool_names.contains(&"list_extension_groups"));
|
||||
assert!(tool_names.contains(&"create_extension_group"));
|
||||
assert!(tool_names.contains(&"delete_extension"));
|
||||
assert!(tool_names.contains(&"delete_extension_group"));
|
||||
assert!(tool_names.contains(&"assign_extension_group_to_profile"));
|
||||
// Team lock tools
|
||||
assert!(tool_names.contains(&"get_team_locks"));
|
||||
assert!(tool_names.contains(&"get_team_lock_status"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -874,18 +874,158 @@ pub mod linux {
|
||||
|
||||
pub async fn kill_browser_process_impl(
|
||||
pid: u32,
|
||||
profile_data_path: Option<&str>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
use sysinfo::{Pid, System};
|
||||
let system = System::new_all();
|
||||
if let Some(process) = system.process(Pid::from(pid as usize)) {
|
||||
if !process.kill() {
|
||||
return Err(format!("Failed to kill process {}", pid).into());
|
||||
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
log::info!("Attempting to kill browser process with PID: {pid}");
|
||||
|
||||
let mut pids_to_kill = vec![pid];
|
||||
|
||||
// Find all descendant processes
|
||||
let descendants = get_all_descendant_pids(pid);
|
||||
pids_to_kill.extend(descendants);
|
||||
|
||||
// Find additional processes using the same profile path
|
||||
if let Some(profile_path) = profile_data_path {
|
||||
let additional_pids = find_processes_by_profile_path(profile_path);
|
||||
for p in additional_pids {
|
||||
if !pids_to_kill.contains(&p) {
|
||||
log::info!("Found additional process {} using profile path", p);
|
||||
pids_to_kill.push(p);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
return Err(format!("Process {} not found", pid).into());
|
||||
}
|
||||
|
||||
log::info!("Successfully killed browser process with PID: {pid}");
|
||||
log::info!("Total processes to kill: {:?}", pids_to_kill);
|
||||
|
||||
// Send SIGKILL to all identified processes
|
||||
for &p in &pids_to_kill {
|
||||
log::info!("Sending SIGKILL to PID: {p}");
|
||||
let _ = Command::new("kill")
|
||||
.args(["-KILL", &p.to_string()])
|
||||
.output();
|
||||
}
|
||||
|
||||
// Also kill by process group and parent PID
|
||||
let pid_str = pid.to_string();
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-P", &pid_str])
|
||||
.output();
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Verify processes are dead
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
let mut still_running = Vec::new();
|
||||
for &p in &pids_to_kill {
|
||||
if system.process(Pid::from(p as usize)).is_some() {
|
||||
still_running.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if !still_running.is_empty() {
|
||||
log::info!(
|
||||
"Processes {:?} still running, trying final termination",
|
||||
still_running
|
||||
);
|
||||
|
||||
for p in &still_running {
|
||||
let _ = Command::new("kill")
|
||||
.args(["-KILL", &p.to_string()])
|
||||
.output();
|
||||
}
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
let mut final_still_running = Vec::new();
|
||||
for &p in &pids_to_kill {
|
||||
if system.process(Pid::from(p as usize)).is_some() {
|
||||
final_still_running.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
if !final_still_running.is_empty() {
|
||||
log::error!(
|
||||
"ERROR: Processes {:?} could not be terminated despite aggressive attempts",
|
||||
final_still_running
|
||||
);
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to terminate browser processes {:?} - still running",
|
||||
final_still_running
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Browser termination completed for PID: {pid}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn find_processes_by_profile_path(profile_path: &str) -> Vec<u32> {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
let mut pids = Vec::new();
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
|
||||
for (pid, process) in system.processes() {
|
||||
let cmd = process.cmd();
|
||||
if cmd.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let has_profile = cmd.iter().any(|arg| {
|
||||
if let Some(arg_str) = arg.to_str() {
|
||||
arg_str.contains(profile_path)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
|
||||
if has_profile {
|
||||
pids.push(pid.as_u32());
|
||||
}
|
||||
}
|
||||
|
||||
pids
|
||||
}
|
||||
|
||||
fn get_all_descendant_pids(parent_pid: u32) -> Vec<u32> {
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
let mut descendants = Vec::new();
|
||||
let mut to_check = vec![parent_pid];
|
||||
let mut checked = std::collections::HashSet::new();
|
||||
|
||||
while let Some(current_pid) = to_check.pop() {
|
||||
if checked.contains(¤t_pid) {
|
||||
continue;
|
||||
}
|
||||
checked.insert(current_pid);
|
||||
|
||||
for (pid, process) in system.processes() {
|
||||
let pid_u32 = pid.as_u32();
|
||||
if let Some(parent) = process.parent() {
|
||||
if parent.as_u32() == current_pid && !checked.contains(&pid_u32) {
|
||||
descendants.push(pid_u32);
|
||||
to_check.push(pid_u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
descendants
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use crate::api_client::is_browser_version_nightly;
|
||||
use crate::browser::{create_browser, BrowserType, ProxySettings};
|
||||
use crate::camoufox_manager::CamoufoxConfig;
|
||||
use crate::cloud_auth::CLOUD_AUTH;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::events;
|
||||
use crate::profile::types::{get_host_os, BrowserProfile, SyncMode};
|
||||
@@ -8,7 +9,7 @@ use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::wayfern_manager::WayfernConfig;
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
use sysinfo::{Pid, System};
|
||||
use sysinfo::{Pid, ProcessRefreshKind, RefreshKind, System};
|
||||
|
||||
pub struct ProfileManager {
|
||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
||||
@@ -53,6 +54,15 @@ impl ProfileManager {
|
||||
if proxy_id.is_some() && vpn_id.is_some() {
|
||||
return Err("Cannot set both proxy_id and vpn_id".into());
|
||||
}
|
||||
|
||||
// Sync cloud proxy credentials if the profile uses a cloud or cloud-derived proxy
|
||||
if let Some(ref pid) = proxy_id {
|
||||
if PROXY_MANAGER.is_cloud_or_derived(pid) || pid == crate::proxy_manager::CLOUD_PROXY_ID {
|
||||
log::info!("Syncing cloud proxy credentials before profile creation");
|
||||
CLOUD_AUTH.sync_cloud_proxy().await;
|
||||
}
|
||||
}
|
||||
|
||||
log::info!("Attempting to create profile: {name}");
|
||||
|
||||
// Check if a profile with this name already exists (case insensitive)
|
||||
@@ -167,6 +177,10 @@ impl ProfileManager {
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -284,6 +298,10 @@ impl ProfileManager {
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -333,6 +351,10 @@ impl ProfileManager {
|
||||
last_sync: None,
|
||||
host_os: Some(get_host_os()),
|
||||
ephemeral,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
};
|
||||
|
||||
// Save profile info
|
||||
@@ -722,6 +744,31 @@ impl ProfileManager {
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn update_profile_proxy_bypass_rules(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
rules: Vec<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profiles = self.list_profiles()?;
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
profile.proxy_bypass_rules = rules;
|
||||
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn delete_multiple_profiles(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
@@ -808,6 +855,7 @@ impl ProfileManager {
|
||||
pub fn clone_profile(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
custom_name: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
@@ -824,7 +872,10 @@ impl ProfileManager {
|
||||
}
|
||||
|
||||
let new_id = uuid::Uuid::new_v4();
|
||||
let clone_name = self.generate_clone_name(&source.name)?;
|
||||
let clone_name = match custom_name {
|
||||
Some(name) if !name.trim().is_empty() => name.trim().to_string(),
|
||||
_ => self.generate_clone_name(&source.name)?,
|
||||
};
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let source_dir = profiles_dir.join(source.id.to_string());
|
||||
@@ -856,6 +907,10 @@ impl ProfileManager {
|
||||
last_sync: None,
|
||||
host_os: Some(get_host_os()),
|
||||
ephemeral: false,
|
||||
extension_group_id: source.extension_group_id,
|
||||
proxy_bypass_rules: source.proxy_bypass_rules,
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
};
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
@@ -1127,6 +1182,32 @@ impl ProfileManager {
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn update_profile_extension_group(
|
||||
&self,
|
||||
profile_id: &str,
|
||||
extension_group_id: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profiles = self.list_profiles()?;
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
profile.extension_group_id = extension_group_id;
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
if let Err(e) = events::emit("profile-updated", &profile) {
|
||||
log::warn!("Failed to emit profile update event: {e}");
|
||||
}
|
||||
if let Err(e) = events::emit_empty("profiles-changed") {
|
||||
log::warn!("Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub async fn check_browser_status(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -1144,7 +1225,9 @@ impl ProfileManager {
|
||||
|
||||
// For non-camoufox browsers, use the existing PID-based logic
|
||||
let inner_profile = profile.clone();
|
||||
let mut system = System::new();
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
let mut is_running = false;
|
||||
let mut found_pid: Option<u32> = None;
|
||||
|
||||
@@ -1186,8 +1269,6 @@ impl ProfileManager {
|
||||
|
||||
// If we didn't find the browser with the stored PID, search all processes
|
||||
if !is_running {
|
||||
// Refresh all processes only when we need to search (expensive but necessary)
|
||||
system.refresh_all();
|
||||
for (pid, process) in system.processes() {
|
||||
let cmd = process.cmd();
|
||||
if cmd.len() >= 2 {
|
||||
@@ -1521,9 +1602,10 @@ impl ProfileManager {
|
||||
"user_pref(\"startup.homepage_welcome_url\", \"\");".to_string(),
|
||||
"user_pref(\"startup.homepage_welcome_url.additional\", \"\");".to_string(),
|
||||
"user_pref(\"startup.homepage_override_url\", \"\");".to_string(),
|
||||
// Keep extension updates enabled
|
||||
// Keep extension updates enabled and allow sideloaded extensions
|
||||
"user_pref(\"extensions.update.enabled\", true);".to_string(),
|
||||
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
|
||||
"user_pref(\"extensions.autoDisableScopes\", 0);".to_string(),
|
||||
// Completely disable browser update checking
|
||||
"user_pref(\"app.update.enabled\", false);".to_string(),
|
||||
"user_pref(\"app.update.auto\", false);".to_string(),
|
||||
@@ -1977,6 +2059,18 @@ pub fn update_profile_note(
|
||||
.map_err(|e| format!("Failed to update profile note: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_proxy_bypass_rules(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
rules: Vec<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_profile_proxy_bypass_rules(&app_handle, &profile_id, rules)
|
||||
.map_err(|e| format!("Failed to update proxy bypass rules: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_browser_status(
|
||||
app_handle: tauri::AppHandle,
|
||||
@@ -2103,9 +2197,9 @@ pub async fn update_wayfern_config(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn clone_profile(profile_id: String) -> Result<BrowserProfile, String> {
|
||||
pub fn clone_profile(profile_id: String, name: Option<String>) -> Result<BrowserProfile, String> {
|
||||
ProfileManager::instance()
|
||||
.clone_profile(&profile_id)
|
||||
.clone_profile(&profile_id, name)
|
||||
.map_err(|e| format!("Failed to clone profile: {e}"))
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,14 @@ pub struct BrowserProfile {
|
||||
pub host_os: Option<String>, // OS where profile was created ("macos", "windows", "linux")
|
||||
#[serde(default)]
|
||||
pub ephemeral: bool,
|
||||
#[serde(default)]
|
||||
pub extension_group_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub proxy_bypass_rules: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub created_by_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_by_email: Option<String>,
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
|
||||
@@ -559,6 +559,10 @@ impl ProfileImporter {
|
||||
last_sync: None,
|
||||
host_os: Some(crate::profile::types::get_host_os()),
|
||||
ephemeral: false,
|
||||
extension_group_id: None,
|
||||
proxy_bypass_rules: Vec::new(),
|
||||
created_by_id: None,
|
||||
created_by_email: None,
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
|
||||
+1034
-31
File diff suppressed because it is too large
Load Diff
@@ -12,13 +12,14 @@ pub async fn start_proxy_process(
|
||||
upstream_url: Option<String>,
|
||||
port: Option<u16>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
start_proxy_process_with_profile(upstream_url, port, None).await
|
||||
start_proxy_process_with_profile(upstream_url, port, None, Vec::new()).await
|
||||
}
|
||||
|
||||
pub async fn start_proxy_process_with_profile(
|
||||
upstream_url: Option<String>,
|
||||
port: Option<u16>,
|
||||
profile_id: Option<String>,
|
||||
bypass_rules: Vec<String>,
|
||||
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
|
||||
let id = generate_proxy_id();
|
||||
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
|
||||
@@ -30,8 +31,9 @@ pub async fn start_proxy_process_with_profile(
|
||||
listener.local_addr().unwrap().port()
|
||||
});
|
||||
|
||||
let config =
|
||||
ProxyConfig::new(id.clone(), upstream, Some(local_port)).with_profile_id(profile_id.clone());
|
||||
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port))
|
||||
.with_profile_id(profile_id.clone())
|
||||
.with_bypass_rules(bypass_rules);
|
||||
save_proxy_config(&config)?;
|
||||
|
||||
// Log profile_id for debugging
|
||||
|
||||
@@ -6,6 +6,7 @@ use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Method, Request, Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
use regex_lite::Regex;
|
||||
use std::convert::Infallible;
|
||||
use std::io;
|
||||
use std::net::SocketAddr;
|
||||
@@ -18,6 +19,38 @@ use tokio::net::TcpListener;
|
||||
use tokio::net::TcpStream;
|
||||
use url::Url;
|
||||
|
||||
enum CompiledRule {
|
||||
Regex(Regex),
|
||||
Exact(String),
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BypassMatcher {
|
||||
rules: Arc<Vec<CompiledRule>>,
|
||||
}
|
||||
|
||||
impl BypassMatcher {
|
||||
pub fn new(rules: &[String]) -> Self {
|
||||
let compiled = rules
|
||||
.iter()
|
||||
.map(|rule| match Regex::new(rule) {
|
||||
Ok(re) => CompiledRule::Regex(re),
|
||||
Err(_) => CompiledRule::Exact(rule.clone()),
|
||||
})
|
||||
.collect();
|
||||
Self {
|
||||
rules: Arc::new(compiled),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn should_bypass(&self, host: &str) -> bool {
|
||||
self.rules.iter().any(|rule| match rule {
|
||||
CompiledRule::Regex(re) => re.is_match(host),
|
||||
CompiledRule::Exact(exact) => host == exact,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrapper stream that counts bytes read and written
|
||||
struct CountingStream<S> {
|
||||
inner: S,
|
||||
@@ -133,19 +166,21 @@ impl AsyncWrite for PrependReader {
|
||||
async fn handle_request(
|
||||
req: Request<hyper::body::Incoming>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
// Handle CONNECT method for HTTPS tunneling
|
||||
if req.method() == Method::CONNECT {
|
||||
return handle_connect(req, upstream_url).await;
|
||||
return handle_connect(req, upstream_url, bypass_matcher).await;
|
||||
}
|
||||
|
||||
// Handle regular HTTP requests
|
||||
handle_http(req, upstream_url).await
|
||||
handle_http(req, upstream_url, bypass_matcher).await
|
||||
}
|
||||
|
||||
async fn handle_connect(
|
||||
req: Request<hyper::body::Incoming>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
let authority = req.uri().authority().cloned();
|
||||
|
||||
@@ -161,12 +196,13 @@ async fn handle_connect(
|
||||
(&target_addr[..], 443)
|
||||
};
|
||||
|
||||
// If no upstream proxy, connect directly
|
||||
// If no upstream proxy, or bypass rule matches, connect directly
|
||||
if upstream_url.is_none()
|
||||
|| upstream_url
|
||||
.as_ref()
|
||||
.map(|s| s == "DIRECT")
|
||||
.unwrap_or(false)
|
||||
|| bypass_matcher.should_bypass(target_host)
|
||||
{
|
||||
match TcpStream::connect(&target_addr).await {
|
||||
Ok(_stream) => {
|
||||
@@ -674,6 +710,7 @@ async fn handle_http_via_socks4(
|
||||
async fn handle_http(
|
||||
req: Request<hyper::body::Incoming>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
) -> Result<Response<Full<Bytes>>, Infallible> {
|
||||
// Extract domain for traffic tracking
|
||||
let domain = req
|
||||
@@ -689,13 +726,17 @@ async fn handle_http(
|
||||
req.uri().host()
|
||||
);
|
||||
|
||||
let should_bypass = bypass_matcher.should_bypass(&domain);
|
||||
|
||||
// Check if we need to handle SOCKS4 manually (reqwest doesn't support it)
|
||||
if let Some(ref upstream) = upstream_url {
|
||||
if upstream != "DIRECT" {
|
||||
if let Ok(url) = Url::parse(upstream) {
|
||||
if url.scheme() == "socks4" {
|
||||
// Handle SOCKS4 manually for HTTP requests
|
||||
return handle_http_via_socks4(req, upstream).await;
|
||||
if !should_bypass {
|
||||
if let Some(ref upstream) = upstream_url {
|
||||
if upstream != "DIRECT" {
|
||||
if let Ok(url) = Url::parse(upstream) {
|
||||
if url.scheme() == "socks4" {
|
||||
// Handle SOCKS4 manually for HTTP requests
|
||||
return handle_http_via_socks4(req, upstream).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -705,7 +746,9 @@ async fn handle_http(
|
||||
use reqwest::Client;
|
||||
|
||||
let client_builder = Client::builder();
|
||||
let client = if let Some(ref upstream) = upstream_url {
|
||||
let client = if should_bypass {
|
||||
client_builder.build().unwrap_or_default()
|
||||
} else if let Some(ref upstream) = upstream_url {
|
||||
if upstream == "DIRECT" {
|
||||
client_builder.build().unwrap_or_default()
|
||||
} else {
|
||||
@@ -1003,6 +1046,8 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
}
|
||||
});
|
||||
|
||||
let bypass_matcher = BypassMatcher::new(&config.bypass_rules);
|
||||
|
||||
// Keep the runtime alive with an infinite loop
|
||||
// This ensures the process doesn't exit even if there are no active connections
|
||||
loop {
|
||||
@@ -1014,6 +1059,7 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
log::error!("DEBUG: Accepted connection from {:?}", peer_addr);
|
||||
|
||||
let upstream = upstream_url.clone();
|
||||
let matcher = bypass_matcher.clone();
|
||||
|
||||
tokio::task::spawn(async move {
|
||||
// Read first bytes to detect CONNECT requests
|
||||
@@ -1108,7 +1154,9 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
"DEBUG: Handling CONNECT manually for: {}",
|
||||
String::from_utf8_lossy(&full_request[..full_request.len().min(200)])
|
||||
);
|
||||
if let Err(e) = handle_connect_from_buffer(stream, full_request, upstream).await {
|
||||
if let Err(e) =
|
||||
handle_connect_from_buffer(stream, full_request, upstream, matcher).await
|
||||
{
|
||||
log::error!("Error handling CONNECT request: {:?}", e);
|
||||
} else {
|
||||
log::error!("DEBUG: CONNECT handled successfully");
|
||||
@@ -1130,7 +1178,8 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
|
||||
inner: stream,
|
||||
};
|
||||
let io = TokioIo::new(prepended_reader);
|
||||
let service = service_fn(move |req| handle_request(req, upstream.clone()));
|
||||
let service =
|
||||
service_fn(move |req| handle_request(req, upstream.clone(), matcher.clone()));
|
||||
|
||||
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
|
||||
log::error!("Error serving connection: {:?}", err);
|
||||
@@ -1156,6 +1205,7 @@ async fn handle_connect_from_buffer(
|
||||
mut client_stream: TcpStream,
|
||||
request_buffer: Vec<u8>,
|
||||
upstream_url: Option<String>,
|
||||
bypass_matcher: BypassMatcher,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Parse the CONNECT request from the buffer
|
||||
let request_str = String::from_utf8_lossy(&request_buffer);
|
||||
@@ -1193,6 +1243,7 @@ async fn handle_connect_from_buffer(
|
||||
}
|
||||
|
||||
// Connect to target (directly or via upstream proxy)
|
||||
let should_bypass = bypass_matcher.should_bypass(target_host);
|
||||
let target_stream = match upstream_url.as_ref() {
|
||||
None => {
|
||||
// Direct connection
|
||||
@@ -1202,6 +1253,10 @@ async fn handle_connect_from_buffer(
|
||||
// Direct connection
|
||||
TcpStream::connect((target_host, target_port)).await?
|
||||
}
|
||||
_ if should_bypass => {
|
||||
// Bypass rule matched - connect directly
|
||||
TcpStream::connect((target_host, target_port)).await?
|
||||
}
|
||||
Some(upstream_url_str) => {
|
||||
// Connect via upstream proxy
|
||||
let upstream = Url::parse(upstream_url_str)?;
|
||||
|
||||
@@ -12,6 +12,8 @@ pub struct ProxyConfig {
|
||||
pub pid: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub profile_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub bypass_rules: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProxyConfig {
|
||||
@@ -24,6 +26,7 @@ impl ProxyConfig {
|
||||
local_url: None,
|
||||
pid: None,
|
||||
profile_id: None,
|
||||
bypass_rules: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,10 +34,15 @@ impl ProxyConfig {
|
||||
self.profile_id = profile_id;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn with_bypass_rules(mut self, bypass_rules: Vec<String>) -> Self {
|
||||
self.bypass_rules = bypass_rules;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_storage_dir() -> PathBuf {
|
||||
crate::app_dirs::proxies_dir()
|
||||
crate::app_dirs::proxy_workers_dir()
|
||||
}
|
||||
|
||||
pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
|
||||
@@ -123,7 +131,60 @@ pub fn generate_proxy_id() -> String {
|
||||
}
|
||||
|
||||
pub fn is_process_running(pid: u32) -> bool {
|
||||
use sysinfo::{Pid, System};
|
||||
let system = System::new();
|
||||
system.process(Pid::from(pid as usize)).is_some()
|
||||
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
|
||||
let system = System::new_with_specifics(
|
||||
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
|
||||
);
|
||||
system.process(sysinfo::Pid::from_u32(pid)).is_some()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_is_process_running_detects_current_process() {
|
||||
let pid = std::process::id();
|
||||
assert!(
|
||||
is_process_running(pid),
|
||||
"is_process_running must detect the current process (PID {pid})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_process_running_returns_false_for_dead_pid() {
|
||||
// Spawn a short-lived child and wait for it to exit
|
||||
let child = std::process::Command::new(if cfg!(windows) { "cmd" } else { "true" })
|
||||
.args(if cfg!(windows) {
|
||||
vec!["/C", "exit"]
|
||||
} else {
|
||||
vec![]
|
||||
})
|
||||
.spawn()
|
||||
.expect("failed to spawn child");
|
||||
let pid = child.id();
|
||||
let mut child = child;
|
||||
child.wait().expect("child failed");
|
||||
|
||||
assert!(
|
||||
!is_process_running(pid),
|
||||
"is_process_running must return false for a dead process (PID {pid})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_process_running_returns_false_for_nonexistent_pid() {
|
||||
// PID 0 is the "System Idle Process" on Windows and sysinfo reports it as running,
|
||||
// so only assert on non-Windows platforms where PID 0 is not a real user process.
|
||||
#[cfg(not(windows))]
|
||||
assert!(
|
||||
!is_process_running(0),
|
||||
"is_process_running must return false for PID 0"
|
||||
);
|
||||
// Very high PID unlikely to exist
|
||||
assert!(
|
||||
!is_process_running(u32::MAX),
|
||||
"is_process_running must return false for PID u32::MAX"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,6 +55,8 @@ pub struct AppSettings {
|
||||
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,
|
||||
#[serde(default)]
|
||||
pub disable_auto_updates: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
|
||||
@@ -89,6 +91,7 @@ impl Default for AppSettings {
|
||||
launch_on_login_declined: false,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
disable_auto_updates: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -128,23 +131,10 @@ impl SettingsManager {
|
||||
|
||||
// Parse the settings file - serde will use default values for missing fields
|
||||
match serde_json::from_str::<AppSettings>(&content) {
|
||||
Ok(settings) => {
|
||||
// Save the settings back to ensure any missing fields are written with defaults
|
||||
if let Err(e) = self.save_settings(&settings) {
|
||||
log::warn!("Warning: Failed to update settings file with defaults: {e}");
|
||||
}
|
||||
Ok(settings)
|
||||
}
|
||||
Ok(settings) => Ok(settings),
|
||||
Err(e) => {
|
||||
log::warn!("Warning: Failed to parse settings file, using defaults: {e}");
|
||||
let default_settings = AppSettings::default();
|
||||
|
||||
// Try to save default settings to fix the corrupted file
|
||||
if let Err(save_error) = self.save_settings(&default_settings) {
|
||||
log::warn!("Warning: Failed to save default settings: {save_error}");
|
||||
}
|
||||
|
||||
Ok(default_settings)
|
||||
Ok(AppSettings::default())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1033,6 +1023,7 @@ mod tests {
|
||||
launch_on_login_declined: false,
|
||||
language: None,
|
||||
window_resize_warning_dismissed: false,
|
||||
disable_auto_updates: false,
|
||||
};
|
||||
|
||||
let save_result = manager.save_settings(&test_settings);
|
||||
|
||||
@@ -210,63 +210,84 @@ impl SyncClient {
|
||||
&self,
|
||||
items: Vec<(String, Option<String>)>,
|
||||
) -> SyncResult<PresignUploadBatchResponse> {
|
||||
let request = PresignUploadBatchRequest {
|
||||
items: items
|
||||
.into_iter()
|
||||
.map(|(key, content_type)| PresignUploadBatchItem { key, content_type })
|
||||
.collect(),
|
||||
expires_in: Some(3600),
|
||||
};
|
||||
let chunk_size = 500;
|
||||
let mut all_items = Vec::new();
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url("presign-upload-batch"))
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
|
||||
for chunk in items.chunks(chunk_size) {
|
||||
let request = PresignUploadBatchRequest {
|
||||
items: chunk
|
||||
.iter()
|
||||
.map(|(key, content_type)| PresignUploadBatchItem {
|
||||
key: key.clone(),
|
||||
content_type: content_type.clone(),
|
||||
})
|
||||
.collect(),
|
||||
expires_in: Some(3600),
|
||||
};
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SyncError::AuthError(format!("({status}) {body}")));
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url("presign-upload-batch"))
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SyncError::AuthError(format!("({status}) {body}")));
|
||||
}
|
||||
|
||||
let batch_response: PresignUploadBatchResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::SerializationError(e.to_string()))?;
|
||||
|
||||
all_items.extend(batch_response.items);
|
||||
}
|
||||
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::SerializationError(e.to_string()))
|
||||
Ok(PresignUploadBatchResponse { items: all_items })
|
||||
}
|
||||
|
||||
pub async fn presign_download_batch(
|
||||
&self,
|
||||
keys: Vec<String>,
|
||||
) -> SyncResult<PresignDownloadBatchResponse> {
|
||||
let request = PresignDownloadBatchRequest {
|
||||
keys,
|
||||
expires_in: Some(3600),
|
||||
};
|
||||
let chunk_size = 500;
|
||||
let mut all_items = Vec::new();
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url("presign-download-batch"))
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
|
||||
for chunk in keys.chunks(chunk_size) {
|
||||
let request = PresignDownloadBatchRequest {
|
||||
keys: chunk.to_vec(),
|
||||
expires_in: Some(3600),
|
||||
};
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SyncError::AuthError(format!("({status}) {body}")));
|
||||
let response = self
|
||||
.client
|
||||
.post(self.url("presign-download-batch"))
|
||||
.header("Authorization", format!("Bearer {}", self.token))
|
||||
.json(&request)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::NetworkError(e.to_string()))?;
|
||||
|
||||
if response.status().is_client_error() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(SyncError::AuthError(format!("({status}) {body}")));
|
||||
}
|
||||
|
||||
let batch_response: PresignDownloadBatchResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::SerializationError(e.to_string()))?;
|
||||
|
||||
all_items.extend(batch_response.items);
|
||||
}
|
||||
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| SyncError::SerializationError(e.to_string()))
|
||||
Ok(PresignDownloadBatchResponse { items: all_items })
|
||||
}
|
||||
|
||||
pub async fn delete_prefix(
|
||||
|
||||
+1265
-95
File diff suppressed because it is too large
Load Diff
@@ -9,24 +9,44 @@ use std::time::SystemTime;
|
||||
|
||||
use super::types::{SyncError, SyncResult};
|
||||
|
||||
/// Default exclude patterns for volatile Chromium profile files
|
||||
/// Default exclude patterns for volatile browser profile files.
|
||||
/// Patterns use `**/` prefix to match at any directory depth, since the sync
|
||||
/// engine scans from `profiles/{uuid}/` which contains `profile/Default/...`.
|
||||
pub const DEFAULT_EXCLUDE_PATTERNS: &[&str] = &[
|
||||
"Cache/**",
|
||||
"Code Cache/**",
|
||||
"GPUCache/**",
|
||||
"GrShaderCache/**",
|
||||
"ShaderCache/**",
|
||||
"Service Worker/CacheStorage/**",
|
||||
"Crashpad/**",
|
||||
"Crash Reports/**",
|
||||
"BrowserMetrics/**",
|
||||
"blob_storage/**",
|
||||
// Chromium caches (re-downloadable / re-generated)
|
||||
"**/Cache/**",
|
||||
"**/Code Cache/**",
|
||||
"**/GPUCache/**",
|
||||
"**/GrShaderCache/**",
|
||||
"**/ShaderCache/**",
|
||||
"**/DawnCache/**",
|
||||
"**/DawnGraphiteCache/**",
|
||||
"**/Service Worker/CacheStorage/**",
|
||||
"**/Service Worker/ScriptCache/**",
|
||||
// Chromium transient / volatile data
|
||||
"**/Session Storage/**",
|
||||
"**/blob_storage/**",
|
||||
"**/Crashpad/**",
|
||||
"**/Crash Reports/**",
|
||||
"**/BrowserMetrics/**",
|
||||
"**/optimization_guide_model_store/**",
|
||||
"**/Safe Browsing/**",
|
||||
"**/component_crx_cache/**",
|
||||
// Firefox/Camoufox caches (re-downloadable / re-generated)
|
||||
"**/cache2/**",
|
||||
"**/startupCache/**",
|
||||
"**/safebrowsing/**",
|
||||
"**/storage/temporary/**",
|
||||
"**/crashes/**",
|
||||
"**/minidumps/**",
|
||||
// Common volatile files
|
||||
"*.log",
|
||||
"*.tmp",
|
||||
"**/LOG",
|
||||
"**/LOG.old",
|
||||
"**/LOCK",
|
||||
"**/*-journal",
|
||||
"**/*-wal",
|
||||
".donut-sync/**",
|
||||
];
|
||||
|
||||
@@ -528,6 +548,66 @@ mod tests {
|
||||
assert_eq!(manifest.files[0].path, "file1.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_generate_manifest_excludes_nested_caches() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let profile_dir = temp_dir.path().join("profile_root");
|
||||
fs::create_dir_all(&profile_dir).unwrap();
|
||||
|
||||
// Simulate real Chromium structure: profile/Default/Cache/...
|
||||
let default_dir = profile_dir.join("profile/Default");
|
||||
fs::create_dir_all(&default_dir).unwrap();
|
||||
fs::write(default_dir.join("Cookies"), "keep").unwrap();
|
||||
fs::create_dir_all(default_dir.join("Cache")).unwrap();
|
||||
fs::write(default_dir.join("Cache/data_0"), "exclude").unwrap();
|
||||
fs::create_dir_all(default_dir.join("Code Cache/js")).unwrap();
|
||||
fs::write(default_dir.join("Code Cache/js/abc"), "exclude").unwrap();
|
||||
fs::create_dir_all(default_dir.join("GPUCache")).unwrap();
|
||||
fs::write(default_dir.join("GPUCache/data_0"), "exclude").unwrap();
|
||||
fs::create_dir_all(default_dir.join("Session Storage")).unwrap();
|
||||
fs::write(default_dir.join("Session Storage/000003.log"), "exclude").unwrap();
|
||||
fs::create_dir_all(default_dir.join("Local Storage/leveldb")).unwrap();
|
||||
fs::write(default_dir.join("Local Storage/leveldb/000001.ldb"), "keep").unwrap();
|
||||
|
||||
// Caches at user-data-dir level
|
||||
fs::create_dir_all(profile_dir.join("profile/ShaderCache")).unwrap();
|
||||
fs::write(profile_dir.join("profile/ShaderCache/data"), "exclude").unwrap();
|
||||
fs::create_dir_all(profile_dir.join("profile/Crashpad")).unwrap();
|
||||
fs::write(profile_dir.join("profile/Crashpad/report"), "exclude").unwrap();
|
||||
|
||||
// metadata.json at root
|
||||
fs::write(profile_dir.join("metadata.json"), "keep").unwrap();
|
||||
|
||||
let mut cache = HashCache::default();
|
||||
let manifest = generate_manifest("test-profile", &profile_dir, &mut cache).unwrap();
|
||||
|
||||
let paths: Vec<&str> = manifest.files.iter().map(|f| f.path.as_str()).collect();
|
||||
assert!(
|
||||
paths.contains(&"metadata.json"),
|
||||
"metadata.json should be synced"
|
||||
);
|
||||
assert!(
|
||||
paths.contains(&"profile/Default/Cookies"),
|
||||
"Cookies should be synced"
|
||||
);
|
||||
assert!(
|
||||
paths.contains(&"profile/Default/Local Storage/leveldb/000001.ldb"),
|
||||
"Local Storage should be synced"
|
||||
);
|
||||
assert!(
|
||||
!paths.iter().any(|p| p.contains("Cache")),
|
||||
"Cache directories should be excluded: {paths:?}"
|
||||
);
|
||||
assert!(
|
||||
!paths.iter().any(|p| p.contains("Session Storage")),
|
||||
"Session Storage should be excluded: {paths:?}"
|
||||
);
|
||||
assert!(
|
||||
!paths.iter().any(|p| p.contains("Crashpad")),
|
||||
"Crashpad should be excluded: {paths:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compute_diff_upload_all_when_no_remote() {
|
||||
let local = SyncManifest {
|
||||
|
||||
@@ -13,9 +13,9 @@ pub use engine::{
|
||||
enable_vpn_sync_if_needed, get_unsynced_entity_counts, is_group_in_use_by_synced_profile,
|
||||
is_group_used_by_synced_profile, is_proxy_in_use_by_synced_profile,
|
||||
is_proxy_used_by_synced_profile, is_sync_configured, is_vpn_in_use_by_synced_profile,
|
||||
is_vpn_used_by_synced_profile, request_profile_sync, set_group_sync_enabled,
|
||||
set_profile_sync_mode, set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile,
|
||||
trigger_sync_for_profile, SyncEngine,
|
||||
is_vpn_used_by_synced_profile, request_profile_sync, set_extension_group_sync_enabled,
|
||||
set_extension_sync_enabled, set_group_sync_enabled, set_profile_sync_mode,
|
||||
set_proxy_sync_enabled, set_vpn_sync_enabled, sync_profile, trigger_sync_for_profile, SyncEngine,
|
||||
};
|
||||
pub use manifest::{compute_diff, generate_manifest, HashCache, ManifestDiff, SyncManifest};
|
||||
pub use scheduler::{get_global_scheduler, set_global_scheduler, SyncScheduler};
|
||||
|
||||
@@ -35,6 +35,8 @@ pub struct SyncScheduler {
|
||||
pending_proxies: Arc<Mutex<HashSet<String>>>,
|
||||
pending_groups: Arc<Mutex<HashSet<String>>>,
|
||||
pending_vpns: Arc<Mutex<HashSet<String>>>,
|
||||
pending_extensions: Arc<Mutex<HashSet<String>>>,
|
||||
pending_extension_groups: Arc<Mutex<HashSet<String>>>,
|
||||
pending_tombstones: Arc<Mutex<Vec<(String, String)>>>,
|
||||
running_profiles: Arc<Mutex<HashSet<String>>>,
|
||||
in_flight_profiles: Arc<Mutex<HashSet<String>>>,
|
||||
@@ -54,6 +56,8 @@ impl SyncScheduler {
|
||||
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_extensions: Arc::new(Mutex::new(HashSet::new())),
|
||||
pending_extension_groups: 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())),
|
||||
@@ -100,6 +104,18 @@ impl SyncScheduler {
|
||||
}
|
||||
drop(pending_vpns);
|
||||
|
||||
let pending_extensions = self.pending_extensions.lock().await;
|
||||
if !pending_extensions.is_empty() {
|
||||
return true;
|
||||
}
|
||||
drop(pending_extensions);
|
||||
|
||||
let pending_extension_groups = self.pending_extension_groups.lock().await;
|
||||
if !pending_extension_groups.is_empty() {
|
||||
return true;
|
||||
}
|
||||
drop(pending_extension_groups);
|
||||
|
||||
let pending_tombstones = self.pending_tombstones.lock().await;
|
||||
if !pending_tombstones.is_empty() {
|
||||
return true;
|
||||
@@ -148,10 +164,24 @@ impl SyncScheduler {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == profile_id) {
|
||||
return profile.process_id.is_some();
|
||||
if profile.process_id.is_some() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if locked by another team member (profile in use remotely)
|
||||
if crate::team_lock::TEAM_LOCK
|
||||
.is_locked_by_another(profile_id)
|
||||
.await
|
||||
{
|
||||
log::debug!(
|
||||
"Profile {} is locked by another team member, treating as running",
|
||||
profile_id
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
@@ -208,6 +238,16 @@ impl SyncScheduler {
|
||||
pending.insert(group_id);
|
||||
}
|
||||
|
||||
pub async fn queue_extension_sync(&self, extension_id: String) {
|
||||
let mut pending = self.pending_extensions.lock().await;
|
||||
pending.insert(extension_id);
|
||||
}
|
||||
|
||||
pub async fn queue_extension_group_sync(&self, extension_group_id: String) {
|
||||
let mut pending = self.pending_extension_groups.lock().await;
|
||||
pending.insert(extension_group_id);
|
||||
}
|
||||
|
||||
pub async fn queue_tombstone(&self, entity_type: String, entity_id: String) {
|
||||
let mut pending = self.pending_tombstones.lock().await;
|
||||
if !pending
|
||||
@@ -234,7 +274,7 @@ impl SyncScheduler {
|
||||
|
||||
let sync_enabled_profiles: Vec<_> = profiles
|
||||
.into_iter()
|
||||
.filter(|p| p.is_sync_enabled())
|
||||
.filter(|p| p.is_sync_enabled() && !p.is_cross_os())
|
||||
.collect();
|
||||
|
||||
if sync_enabled_profiles.is_empty() {
|
||||
@@ -250,17 +290,38 @@ impl SyncScheduler {
|
||||
for profile in sync_enabled_profiles {
|
||||
let profile_id = profile.id.to_string();
|
||||
let is_running = profile.process_id.is_some();
|
||||
let is_team_locked = crate::team_lock::TEAM_LOCK
|
||||
.is_locked_by_another(&profile_id)
|
||||
.await;
|
||||
let should_wait = is_running || is_team_locked;
|
||||
|
||||
// Track running state in the scheduler
|
||||
if is_running {
|
||||
self.mark_profile_running(&profile_id).await;
|
||||
}
|
||||
|
||||
if should_wait {
|
||||
log::info!(
|
||||
"Profile '{}' is {} — will sync after it becomes available",
|
||||
profile.name,
|
||||
if is_running {
|
||||
"running locally"
|
||||
} else {
|
||||
"locked by a team member"
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Emit initial status
|
||||
let _ = events::emit(
|
||||
"profile-sync-status",
|
||||
serde_json::json!({
|
||||
"profile_id": profile_id,
|
||||
"status": if is_running { "waiting" } else { "syncing" }
|
||||
"status": if should_wait { "waiting" } else { "syncing" }
|
||||
}),
|
||||
);
|
||||
|
||||
// Queue for immediate sync (or wait if running)
|
||||
// Queue for sync — running profiles will be deferred by the scheduler
|
||||
self.queue_profile_sync_immediate(profile_id).await;
|
||||
}
|
||||
}
|
||||
@@ -286,6 +347,8 @@ impl SyncScheduler {
|
||||
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::Extension(id) => scheduler.queue_extension_sync(id).await,
|
||||
SyncWorkItem::ExtensionGroup(id) => scheduler.queue_extension_group_sync(id).await,
|
||||
SyncWorkItem::Tombstone(entity_type, entity_id) => {
|
||||
scheduler.queue_tombstone(entity_type, entity_id).await
|
||||
}
|
||||
@@ -306,6 +369,8 @@ impl SyncScheduler {
|
||||
self.process_pending_proxies(app_handle).await;
|
||||
self.process_pending_groups(app_handle).await;
|
||||
self.process_pending_vpns(app_handle).await;
|
||||
self.process_pending_extensions(app_handle).await;
|
||||
self.process_pending_extension_groups(app_handle).await;
|
||||
self.process_pending_tombstones(app_handle).await;
|
||||
}
|
||||
|
||||
@@ -356,7 +421,7 @@ impl SyncScheduler {
|
||||
profile_manager.list_profiles().ok().and_then(|profiles| {
|
||||
profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled())
|
||||
.find(|p| p.id.to_string() == profile_id && p.is_sync_enabled() && !p.is_cross_os())
|
||||
})
|
||||
};
|
||||
|
||||
@@ -385,6 +450,8 @@ impl SyncScheduler {
|
||||
&& self.pending_proxies.lock().await.is_empty()
|
||||
&& self.pending_groups.lock().await.is_empty()
|
||||
&& self.pending_vpns.lock().await.is_empty()
|
||||
&& self.pending_extensions.lock().await.is_empty()
|
||||
&& self.pending_extension_groups.lock().await.is_empty()
|
||||
};
|
||||
|
||||
match result {
|
||||
@@ -465,7 +532,8 @@ impl SyncScheduler {
|
||||
"proxy-sync-status",
|
||||
serde_json::json!({
|
||||
"id": proxy_id,
|
||||
"status": "error"
|
||||
"status": "error",
|
||||
"error": e.to_string()
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -531,7 +599,8 @@ impl SyncScheduler {
|
||||
"group-sync-status",
|
||||
serde_json::json!({
|
||||
"id": group_id,
|
||||
"status": "error"
|
||||
"status": "error",
|
||||
"error": e.to_string()
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -594,7 +663,8 @@ impl SyncScheduler {
|
||||
"vpn-sync-status",
|
||||
serde_json::json!({
|
||||
"id": vpn_id,
|
||||
"status": "error"
|
||||
"status": "error",
|
||||
"error": e.to_string()
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -618,6 +688,82 @@ impl SyncScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_pending_extensions(&self, app_handle: &tauri::AppHandle) {
|
||||
let extensions_to_sync: Vec<String> = {
|
||||
let mut pending = self.pending_extensions.lock().await;
|
||||
let list: Vec<String> = pending.drain().collect();
|
||||
list
|
||||
};
|
||||
|
||||
if extensions_to_sync.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match SyncEngine::create_from_settings(app_handle).await {
|
||||
Ok(engine) => {
|
||||
for ext_id in extensions_to_sync {
|
||||
log::info!("Syncing extension {}", ext_id);
|
||||
if let Err(e) = engine
|
||||
.sync_extension_by_id_with_handle(&ext_id, app_handle)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to sync extension {}: {}", ext_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.is_sync_in_progress().await {
|
||||
log::debug!("All syncs completed after extension sync, triggering cleanup");
|
||||
let registry =
|
||||
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||
log::warn!("Cleanup after sync failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create sync engine: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_pending_extension_groups(&self, app_handle: &tauri::AppHandle) {
|
||||
let groups_to_sync: Vec<String> = {
|
||||
let mut pending = self.pending_extension_groups.lock().await;
|
||||
let list: Vec<String> = pending.drain().collect();
|
||||
list
|
||||
};
|
||||
|
||||
if groups_to_sync.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
match SyncEngine::create_from_settings(app_handle).await {
|
||||
Ok(engine) => {
|
||||
for group_id in groups_to_sync {
|
||||
log::info!("Syncing extension group {}", group_id);
|
||||
if let Err(e) = engine
|
||||
.sync_extension_group_by_id_with_handle(&group_id, app_handle)
|
||||
.await
|
||||
{
|
||||
log::error!("Failed to sync extension group {}: {}", group_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.is_sync_in_progress().await {
|
||||
log::debug!("All syncs completed after extension group sync, triggering cleanup");
|
||||
let registry =
|
||||
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||
log::warn!("Cleanup after sync failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
log::error!("Failed to create sync engine: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_pending_tombstones(&self, _app_handle: &tauri::AppHandle) {
|
||||
let tombstones: Vec<(String, String)> = {
|
||||
let mut pending = self.pending_tombstones.lock().await;
|
||||
@@ -695,6 +841,32 @@ impl SyncScheduler {
|
||||
}
|
||||
}
|
||||
}
|
||||
"extension" => {
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
if let Ok(ext) = manager.get_extension(&entity_id) {
|
||||
if ext.sync_enabled {
|
||||
log::info!(
|
||||
"Extension {} was deleted remotely, deleting locally",
|
||||
entity_id
|
||||
);
|
||||
let _ = manager.delete_extension_internal(&entity_id);
|
||||
let _ = events::emit("extensions-changed", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
"extension_group" => {
|
||||
let manager = crate::extension_manager::EXTENSION_MANAGER.lock().unwrap();
|
||||
if let Ok(group) = manager.get_group(&entity_id) {
|
||||
if group.sync_enabled {
|
||||
log::info!(
|
||||
"Extension group {} was deleted remotely, deleting locally",
|
||||
entity_id
|
||||
);
|
||||
let _ = manager.delete_group_internal(&entity_id);
|
||||
let _ = events::emit("extensions-changed", ());
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,8 @@ pub enum SyncWorkItem {
|
||||
Proxy(String),
|
||||
Group(String),
|
||||
Vpn(String),
|
||||
Extension(String),
|
||||
ExtensionGroup(String),
|
||||
Tombstone(String, String),
|
||||
}
|
||||
|
||||
@@ -206,8 +208,21 @@ impl SyncSubscription {
|
||||
data_line.and_then(|data| serde_json::from_str(data).ok())
|
||||
}
|
||||
|
||||
fn strip_team_prefix(key: &str) -> &str {
|
||||
if key.starts_with("teams/") {
|
||||
if let Some(rest) = key.find('/').and_then(|first_slash| {
|
||||
key[first_slash + 1..]
|
||||
.find('/')
|
||||
.map(|second_slash| first_slash + 1 + second_slash + 1)
|
||||
}) {
|
||||
return &key[rest..];
|
||||
}
|
||||
}
|
||||
key
|
||||
}
|
||||
|
||||
fn handle_event(event: &SubscribeEvent, work_tx: &mpsc::UnboundedSender<SyncWorkItem>) {
|
||||
let Some(key) = &event.key else {
|
||||
let Some(raw_key) = &event.key else {
|
||||
return;
|
||||
};
|
||||
|
||||
@@ -215,6 +230,8 @@ impl SyncSubscription {
|
||||
return;
|
||||
}
|
||||
|
||||
let key = Self::strip_team_prefix(raw_key);
|
||||
|
||||
let work_item = if key.starts_with("profiles/") {
|
||||
key
|
||||
.strip_prefix("profiles/")
|
||||
@@ -235,6 +252,16 @@ impl SyncSubscription {
|
||||
.strip_prefix("vpns/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
.map(|s| SyncWorkItem::Vpn(s.to_string()))
|
||||
} else if key.starts_with("extensions/") {
|
||||
key
|
||||
.strip_prefix("extensions/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
.map(|s| SyncWorkItem::Extension(s.to_string()))
|
||||
} else if key.starts_with("extension_groups/") {
|
||||
key
|
||||
.strip_prefix("extension_groups/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
.map(|s| SyncWorkItem::ExtensionGroup(s.to_string()))
|
||||
} else if key.starts_with("tombstones/") {
|
||||
key.strip_prefix("tombstones/").and_then(|rest| {
|
||||
if rest.starts_with("profiles/") {
|
||||
@@ -257,6 +284,16 @@ impl SyncSubscription {
|
||||
.strip_prefix("vpns/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
.map(|id| SyncWorkItem::Tombstone("vpn".to_string(), id.to_string()))
|
||||
} else if rest.starts_with("extensions/") {
|
||||
rest
|
||||
.strip_prefix("extensions/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
.map(|id| SyncWorkItem::Tombstone("extension".to_string(), id.to_string()))
|
||||
} else if rest.starts_with("extension_groups/") {
|
||||
rest
|
||||
.strip_prefix("extension_groups/")
|
||||
.and_then(|s| s.strip_suffix(".json"))
|
||||
.map(|id| SyncWorkItem::Tombstone("extension_group".to_string(), id.to_string()))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
use lazy_static::lazy_static;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tokio::sync::{Mutex, RwLock};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::cloud_auth::{CloudAuthManager, CLOUD_API_URL, CLOUD_AUTH};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileLockInfo {
|
||||
#[serde(rename = "profileId")]
|
||||
pub profile_id: String,
|
||||
#[serde(rename = "lockedBy")]
|
||||
pub locked_by: String,
|
||||
#[serde(rename = "lockedByEmail")]
|
||||
pub locked_by_email: String,
|
||||
#[serde(rename = "lockedAt")]
|
||||
pub locked_at: String,
|
||||
#[serde(rename = "expiresAt", default)]
|
||||
pub expires_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[allow(dead_code)]
|
||||
struct AcquireLockResponse {
|
||||
success: bool,
|
||||
#[serde(rename = "lockedBy")]
|
||||
locked_by: Option<String>,
|
||||
#[serde(rename = "lockedByEmail")]
|
||||
locked_by_email: Option<String>,
|
||||
}
|
||||
|
||||
pub struct TeamLockManager {
|
||||
locks: RwLock<HashMap<String, ProfileLockInfo>>,
|
||||
heartbeat_handle: Mutex<Option<JoinHandle<()>>>,
|
||||
connected_team_id: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
lazy_static! {
|
||||
pub static ref TEAM_LOCK: TeamLockManager = TeamLockManager::new();
|
||||
}
|
||||
|
||||
impl TeamLockManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
locks: RwLock::new(HashMap::new()),
|
||||
heartbeat_handle: Mutex::new(None),
|
||||
connected_team_id: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn connect(&self, team_id: &str) {
|
||||
log::info!("Connecting team lock manager for team: {team_id}");
|
||||
|
||||
{
|
||||
let mut tid = self.connected_team_id.lock().await;
|
||||
*tid = Some(team_id.to_string());
|
||||
}
|
||||
|
||||
if let Err(e) = self.fetch_initial_locks(team_id).await {
|
||||
log::warn!("Failed to fetch initial locks: {e}");
|
||||
}
|
||||
|
||||
self.start_heartbeat_loop().await;
|
||||
}
|
||||
|
||||
pub async fn disconnect(&self) {
|
||||
log::info!("Disconnecting team lock manager");
|
||||
|
||||
{
|
||||
let mut handle = self.heartbeat_handle.lock().await;
|
||||
if let Some(h) = handle.take() {
|
||||
h.abort();
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
let mut locks = self.locks.write().await;
|
||||
locks.clear();
|
||||
}
|
||||
|
||||
{
|
||||
let mut tid = self.connected_team_id.lock().await;
|
||||
*tid = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn acquire_lock(&self, profile_id: &str) -> Result<(), String> {
|
||||
let team_id = self.get_team_id().await?;
|
||||
let client = Client::new();
|
||||
|
||||
let access_token =
|
||||
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
||||
|
||||
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks");
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.json(&serde_json::json!({ "profileId": profile_id }))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to acquire lock: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
return Err(format!("Lock acquisition failed ({status}): {body}"));
|
||||
}
|
||||
|
||||
let result: AcquireLockResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse lock response: {e}"))?;
|
||||
|
||||
if !result.success {
|
||||
let email = result
|
||||
.locked_by_email
|
||||
.unwrap_or_else(|| "another user".to_string());
|
||||
return Err(format!("Profile is in use by {email}"));
|
||||
}
|
||||
|
||||
// Update local cache
|
||||
if let Some(user) = CLOUD_AUTH.get_user().await {
|
||||
let mut locks = self.locks.write().await;
|
||||
locks.insert(
|
||||
profile_id.to_string(),
|
||||
ProfileLockInfo {
|
||||
profile_id: profile_id.to_string(),
|
||||
locked_by: user.user.id.clone(),
|
||||
locked_by_email: user.user.email.clone(),
|
||||
locked_at: chrono::Utc::now().to_rfc3339(),
|
||||
expires_at: None,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let _ = crate::events::emit(
|
||||
"team-lock-acquired",
|
||||
serde_json::json!({ "profileId": profile_id }),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn release_lock(&self, profile_id: &str) -> Result<(), String> {
|
||||
let team_id = self.get_team_id().await?;
|
||||
let client = Client::new();
|
||||
|
||||
let access_token =
|
||||
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
||||
|
||||
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks/{profile_id}");
|
||||
let _ = client
|
||||
.delete(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
{
|
||||
let mut locks = self.locks.write().await;
|
||||
locks.remove(profile_id);
|
||||
}
|
||||
|
||||
let _ = crate::events::emit(
|
||||
"team-lock-released",
|
||||
serde_json::json!({ "profileId": profile_id }),
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_locks(&self) -> Vec<ProfileLockInfo> {
|
||||
let locks = self.locks.read().await;
|
||||
locks.values().cloned().collect()
|
||||
}
|
||||
|
||||
pub async fn get_lock_status(&self, profile_id: &str) -> Option<ProfileLockInfo> {
|
||||
let locks = self.locks.read().await;
|
||||
locks.get(profile_id).cloned()
|
||||
}
|
||||
|
||||
pub async fn is_locked_by_another(&self, profile_id: &str) -> bool {
|
||||
let locks = self.locks.read().await;
|
||||
if let Some(lock) = locks.get(profile_id) {
|
||||
if let Some(user) = CLOUD_AUTH.get_user().await {
|
||||
return lock.locked_by != user.user.id;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
async fn fetch_initial_locks(&self, team_id: &str) -> Result<(), String> {
|
||||
let client = Client::new();
|
||||
let access_token =
|
||||
CloudAuthManager::load_access_token()?.ok_or_else(|| "Not logged in".to_string())?;
|
||||
|
||||
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks");
|
||||
let response = client
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {access_token}"))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch locks: {e}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err("Failed to fetch locks".to_string());
|
||||
}
|
||||
|
||||
let lock_list: Vec<ProfileLockInfo> = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to parse locks: {e}"))?;
|
||||
|
||||
let mut locks = self.locks.write().await;
|
||||
locks.clear();
|
||||
for lock in lock_list {
|
||||
locks.insert(lock.profile_id.clone(), lock);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn start_heartbeat_loop(&self) {
|
||||
let mut handle = self.heartbeat_handle.lock().await;
|
||||
if let Some(h) = handle.take() {
|
||||
h.abort();
|
||||
}
|
||||
|
||||
let h = tokio::spawn(async move {
|
||||
loop {
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
|
||||
let team_id = match TEAM_LOCK.get_team_id().await {
|
||||
Ok(id) => id,
|
||||
Err(_) => break,
|
||||
};
|
||||
|
||||
let held_locks: Vec<String> = {
|
||||
let locks = TEAM_LOCK.locks.read().await;
|
||||
if let Some(user) = CLOUD_AUTH.get_user().await {
|
||||
locks
|
||||
.values()
|
||||
.filter(|l| l.locked_by == user.user.id)
|
||||
.map(|l| l.profile_id.clone())
|
||||
.collect()
|
||||
} else {
|
||||
vec![]
|
||||
}
|
||||
};
|
||||
|
||||
for profile_id in held_locks {
|
||||
let client = Client::new();
|
||||
if let Ok(Some(token)) = CloudAuthManager::load_access_token() {
|
||||
let url = format!("{CLOUD_API_URL}/api/teams/{team_id}/locks/{profile_id}/heartbeat");
|
||||
let _ = client
|
||||
.post(&url)
|
||||
.header("Authorization", format!("Bearer {token}"))
|
||||
.send()
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Refresh lock state from server
|
||||
if let Err(e) = TEAM_LOCK.fetch_initial_locks(&team_id).await {
|
||||
log::debug!("Failed to refresh locks: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
*handle = Some(h);
|
||||
}
|
||||
|
||||
async fn get_team_id(&self) -> Result<String, String> {
|
||||
let tid = self.connected_team_id.lock().await;
|
||||
tid
|
||||
.clone()
|
||||
.ok_or_else(|| "Not connected to a team".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
/// Acquire team lock if profile is sync-enabled and user is on a team.
|
||||
/// Returns Ok(()) if lock acquired or not applicable, Err with message if locked by another.
|
||||
pub async fn acquire_team_lock_if_needed(
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
) -> Result<(), String> {
|
||||
if !profile.is_sync_enabled() {
|
||||
return Ok(());
|
||||
}
|
||||
if !CLOUD_AUTH.is_on_team_plan().await {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if TEAM_LOCK
|
||||
.is_locked_by_another(&profile.id.to_string())
|
||||
.await
|
||||
{
|
||||
if let Some(lock) = TEAM_LOCK.get_lock_status(&profile.id.to_string()).await {
|
||||
return Err(format!("Profile is in use by {}", lock.locked_by_email));
|
||||
}
|
||||
return Err("Profile is in use by another team member".to_string());
|
||||
}
|
||||
|
||||
TEAM_LOCK.acquire_lock(&profile.id.to_string()).await
|
||||
}
|
||||
|
||||
/// Release team lock if profile is sync-enabled and user is on a team.
|
||||
/// Logs warnings on failure but does not return errors.
|
||||
pub async fn release_team_lock_if_needed(profile: &crate::profile::BrowserProfile) {
|
||||
if !profile.is_sync_enabled() {
|
||||
return;
|
||||
}
|
||||
if !CLOUD_AUTH.is_on_team_plan().await {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(e) = TEAM_LOCK.release_lock(&profile.id.to_string()).await {
|
||||
log::warn!(
|
||||
"Failed to release team lock for profile {}: {e}",
|
||||
profile.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Tauri commands ---
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_team_locks() -> Result<Vec<ProfileLockInfo>, String> {
|
||||
Ok(TEAM_LOCK.get_locks().await)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_team_lock_status(profile_id: String) -> Result<Option<ProfileLockInfo>, String> {
|
||||
Ok(TEAM_LOCK.get_lock_status(&profile_id).await)
|
||||
}
|
||||
@@ -362,20 +362,15 @@ impl VersionUpdater {
|
||||
eprintln!("Failed to emit completion progress: {e}");
|
||||
}
|
||||
|
||||
// After all version updates are complete, trigger auto-update check
|
||||
if total_new_versions > 0 {
|
||||
println!(
|
||||
"Found {total_new_versions} new versions across all browsers. Checking for auto-updates..."
|
||||
);
|
||||
|
||||
// Trigger auto-update check which will automatically download browsers
|
||||
self
|
||||
.auto_updater
|
||||
.check_for_updates_with_progress(app_handle)
|
||||
.await;
|
||||
} else {
|
||||
println!("No new versions found, skipping auto-update check");
|
||||
}
|
||||
// Always check for auto-updates — profiles may still be on older versions
|
||||
// even if no new versions were found in the cache this cycle
|
||||
println!(
|
||||
"Checking for browser auto-updates (found {total_new_versions} new versions in cache)..."
|
||||
);
|
||||
self
|
||||
.auto_updater
|
||||
.check_for_updates_with_progress(app_handle)
|
||||
.await;
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
@@ -311,12 +311,18 @@ impl WayfernManager {
|
||||
"windows"
|
||||
});
|
||||
|
||||
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
|
||||
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
let mut refresh_params = json!({ "operatingSystem": os });
|
||||
if let Some(ref token) = wayfern_token {
|
||||
refresh_params
|
||||
.as_object_mut()
|
||||
.unwrap()
|
||||
.insert("wayfernToken".to_string(), json!(token));
|
||||
}
|
||||
|
||||
let refresh_result = self
|
||||
.send_cdp_command(
|
||||
&ws_url,
|
||||
"Wayfern.refreshFingerprint",
|
||||
json!({ "operatingSystem": os }),
|
||||
)
|
||||
.send_cdp_command(&ws_url, "Wayfern.refreshFingerprint", refresh_params)
|
||||
.await;
|
||||
|
||||
if let Err(e) = refresh_result {
|
||||
@@ -396,6 +402,8 @@ impl WayfernManager {
|
||||
url: Option<&str>,
|
||||
proxy_url: Option<&str>,
|
||||
ephemeral: bool,
|
||||
extension_paths: &[String],
|
||||
remote_debugging_port: Option<u16>,
|
||||
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
let p = PathBuf::from(path);
|
||||
@@ -413,7 +421,10 @@ impl WayfernManager {
|
||||
.map_err(|e| format!("Failed to get Wayfern executable path: {e}"))?
|
||||
};
|
||||
|
||||
let port = Self::find_free_port().await?;
|
||||
let port = match remote_debugging_port {
|
||||
Some(p) => p,
|
||||
None => Self::find_free_port().await?,
|
||||
};
|
||||
log::info!("Launching Wayfern on CDP port {port}");
|
||||
|
||||
let mut args = vec![
|
||||
@@ -448,6 +459,10 @@ impl WayfernManager {
|
||||
args.push("--disable-sync".to_string());
|
||||
}
|
||||
|
||||
if !extension_paths.is_empty() {
|
||||
args.push(format!("--load-extension={}", extension_paths.join(",")));
|
||||
}
|
||||
|
||||
// Don't add URL to args - we'll navigate via CDP after setting fingerprint
|
||||
// This ensures fingerprint is applied at navigation commit time
|
||||
|
||||
@@ -523,16 +538,21 @@ impl WayfernManager {
|
||||
);
|
||||
}
|
||||
|
||||
// Include wayfern token if available (enables cross-OS fingerprinting for paid users)
|
||||
let wayfern_token = crate::cloud_auth::CLOUD_AUTH.get_wayfern_token().await;
|
||||
let mut fingerprint_params = fingerprint_for_cdp.clone();
|
||||
if let Some(ref token) = wayfern_token {
|
||||
if let Some(obj) = fingerprint_params.as_object_mut() {
|
||||
obj.insert("wayfernToken".to_string(), json!(token));
|
||||
}
|
||||
}
|
||||
|
||||
for target in &page_targets {
|
||||
if let Some(ws_url) = &target.websocket_debugger_url {
|
||||
log::info!("Applying fingerprint to target via WebSocket: {}", ws_url);
|
||||
// Wayfern.setFingerprint expects the fingerprint object directly, NOT wrapped
|
||||
match self
|
||||
.send_cdp_command(
|
||||
ws_url,
|
||||
"Wayfern.setFingerprint",
|
||||
fingerprint_for_cdp.clone(),
|
||||
)
|
||||
.send_cdp_command(ws_url, "Wayfern.setFingerprint", fingerprint_params.clone())
|
||||
.await
|
||||
{
|
||||
Ok(result) => log::info!(
|
||||
@@ -834,6 +854,8 @@ impl WayfernManager {
|
||||
url,
|
||||
proxy_url,
|
||||
profile.ephemeral,
|
||||
&[],
|
||||
None,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut",
|
||||
"version": "0.15.0",
|
||||
"version": "0.16.0",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
|
||||
|
||||
@@ -7,6 +7,45 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::sleep;
|
||||
|
||||
/// Start a simple HTTP server that returns a specific body for any request.
|
||||
/// Returns the (port, JoinHandle).
|
||||
async fn start_mock_http_server(response_body: &'static str) -> (u16, tokio::task::JoinHandle<()>) {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
use http_body_util::Full;
|
||||
use hyper::body::Bytes;
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::{Response, StatusCode};
|
||||
use hyper_util::rt::TokioIo;
|
||||
|
||||
while let Ok((stream, _)) = listener.accept().await {
|
||||
let io = TokioIo::new(stream);
|
||||
tokio::task::spawn(async move {
|
||||
let service = service_fn(move |_req| {
|
||||
let body = response_body;
|
||||
async move {
|
||||
Ok::<_, hyper::Error>(
|
||||
Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.body(Full::new(Bytes::from(body)))
|
||||
.unwrap(),
|
||||
)
|
||||
}
|
||||
});
|
||||
let _ = http1::Builder::new().serve_connection(io, service).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for listener to be ready
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
(port, handle)
|
||||
}
|
||||
|
||||
/// Setup function to ensure donut-proxy binary exists and cleanup stale proxies
|
||||
async fn setup_test() -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let cargo_manifest_dir = std::env::var("CARGO_MANIFEST_DIR")?;
|
||||
@@ -637,3 +676,448 @@ async fn test_proxy_stop() -> Result<(), Box<dyn std::error::Error + Send + Sync
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that bypass rules cause requests to bypass the upstream proxy.
|
||||
/// Requests to bypassed hosts go directly to the target, while
|
||||
/// requests to non-bypassed hosts are routed through the upstream.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bypass_rules_http_direct() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let binary_path = setup_test().await?;
|
||||
let mut tracker = ProxyTestTracker::new(binary_path.clone());
|
||||
|
||||
// Start a target HTTP server (this is where bypassed requests should arrive)
|
||||
let (target_port, target_handle) = start_mock_http_server("DIRECT-TARGET-RESPONSE").await;
|
||||
println!("Target server listening on port {target_port}");
|
||||
|
||||
// Start a mock upstream proxy (non-bypassed requests go here)
|
||||
let (upstream_port, upstream_handle) = start_mock_http_server("UPSTREAM-PROXY-RESPONSE").await;
|
||||
println!("Mock upstream proxy listening on port {upstream_port}");
|
||||
|
||||
// Start donut-proxy with upstream + bypass rules for "127.0.0.1"
|
||||
let bypass_rules = serde_json::json!(["127.0.0.1"]).to_string();
|
||||
let output = TestUtils::execute_command(
|
||||
&binary_path,
|
||||
&[
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--proxy-port",
|
||||
&upstream_port.to_string(),
|
||||
"--type",
|
||||
"http",
|
||||
"--bypass-rules",
|
||||
&bypass_rules,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
target_handle.abort();
|
||||
upstream_handle.abort();
|
||||
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
let local_port = config["localPort"].as_u64().unwrap() as u16;
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
println!("Donut-proxy started on port {local_port} with bypass rules for 127.0.0.1");
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Test 1: Request to 127.0.0.1 should be BYPASSED (direct connection to target)
|
||||
{
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
let request = format!(
|
||||
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).await?;
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
|
||||
println!(
|
||||
"Bypass response: {}",
|
||||
&response_str[..response_str.len().min(300)]
|
||||
);
|
||||
|
||||
assert!(
|
||||
response_str.contains("DIRECT-TARGET-RESPONSE"),
|
||||
"Bypassed request should reach target directly, got: {}",
|
||||
&response_str[..response_str.len().min(300)]
|
||||
);
|
||||
assert!(
|
||||
!response_str.contains("UPSTREAM-PROXY-RESPONSE"),
|
||||
"Bypassed request should NOT go through upstream"
|
||||
);
|
||||
println!("Bypass test passed: request to 127.0.0.1 went directly to target");
|
||||
}
|
||||
|
||||
// Test 2: Request to non-bypassed host should go through upstream
|
||||
{
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
let request =
|
||||
b"GET http://non-bypass-host.test/ HTTP/1.1\r\nHost: non-bypass-host.test\r\nConnection: close\r\n\r\n";
|
||||
stream.write_all(request).await?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).await?;
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
|
||||
println!(
|
||||
"Non-bypass response: {}",
|
||||
&response_str[..response_str.len().min(300)]
|
||||
);
|
||||
|
||||
assert!(
|
||||
response_str.contains("UPSTREAM-PROXY-RESPONSE"),
|
||||
"Non-bypassed request should go through upstream, got: {}",
|
||||
&response_str[..response_str.len().min(300)]
|
||||
);
|
||||
assert!(
|
||||
!response_str.contains("DIRECT-TARGET-RESPONSE"),
|
||||
"Non-bypassed request should NOT reach target directly"
|
||||
);
|
||||
println!("Non-bypass test passed: request to non-bypass-host.test went through upstream");
|
||||
}
|
||||
|
||||
// Cleanup
|
||||
tracker.cleanup_all().await;
|
||||
target_handle.abort();
|
||||
upstream_handle.abort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test bypass rules with regex patterns.
|
||||
/// Verifies that regex-based rules match hosts correctly.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bypass_rules_regex_pattern() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let binary_path = setup_test().await?;
|
||||
let mut tracker = ProxyTestTracker::new(binary_path.clone());
|
||||
|
||||
let (target_port, target_handle) = start_mock_http_server("REGEX-DIRECT-RESPONSE").await;
|
||||
let (upstream_port, upstream_handle) = start_mock_http_server("REGEX-UPSTREAM-RESPONSE").await;
|
||||
|
||||
// Use regex bypass rule: ^127\.0\.0\.\d+ (matches any 127.0.0.x address)
|
||||
let bypass_rules = serde_json::json!([r"^127\.0\.0\.\d+"]).to_string();
|
||||
let output = TestUtils::execute_command(
|
||||
&binary_path,
|
||||
&[
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--proxy-port",
|
||||
&upstream_port.to_string(),
|
||||
"--type",
|
||||
"http",
|
||||
"--bypass-rules",
|
||||
&bypass_rules,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
target_handle.abort();
|
||||
upstream_handle.abort();
|
||||
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
let local_port = config["localPort"].as_u64().unwrap() as u16;
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Request to 127.0.0.1 should match regex and be bypassed
|
||||
{
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
let request = format!(
|
||||
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).await?;
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
|
||||
assert!(
|
||||
response_str.contains("REGEX-DIRECT-RESPONSE"),
|
||||
"Regex-bypassed request should reach target directly, got: {}",
|
||||
&response_str[..response_str.len().min(300)]
|
||||
);
|
||||
println!("Regex bypass test passed: 127.0.0.1 matched regex rule");
|
||||
}
|
||||
|
||||
// Request to non-matching host should go through upstream
|
||||
{
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
let request =
|
||||
b"GET http://example.com/ HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n";
|
||||
stream.write_all(request).await?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).await?;
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
|
||||
assert!(
|
||||
response_str.contains("REGEX-UPSTREAM-RESPONSE"),
|
||||
"Non-matching request should go through upstream, got: {}",
|
||||
&response_str[..response_str.len().min(300)]
|
||||
);
|
||||
println!("Regex non-bypass test passed: example.com did not match regex rule");
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
target_handle.abort();
|
||||
upstream_handle.abort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that bypass rules are persisted in the proxy config on disk.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bypass_rules_in_config() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let binary_path = setup_test().await?;
|
||||
let mut tracker = ProxyTestTracker::new(binary_path.clone());
|
||||
|
||||
let bypass_rules =
|
||||
serde_json::json!(["example.com", "192.168.0.0/16", r".*\.internal\.net"]).to_string();
|
||||
let output = TestUtils::execute_command(
|
||||
&binary_path,
|
||||
&["proxy", "start", "--bypass-rules", &bypass_rules],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Read the proxy config file from disk to verify bypass rules are persisted
|
||||
let proxies_dir = donutbrowser_lib::app_dirs::proxy_workers_dir();
|
||||
let config_file = proxies_dir.join(format!("{proxy_id}.json"));
|
||||
|
||||
assert!(
|
||||
config_file.exists(),
|
||||
"Proxy config file should exist at {:?}",
|
||||
config_file
|
||||
);
|
||||
|
||||
let config_content = std::fs::read_to_string(&config_file)?;
|
||||
let disk_config: Value = serde_json::from_str(&config_content)?;
|
||||
|
||||
let rules = disk_config["bypass_rules"]
|
||||
.as_array()
|
||||
.expect("bypass_rules should be an array in the config");
|
||||
|
||||
assert_eq!(rules.len(), 3, "Should have 3 bypass rules");
|
||||
assert_eq!(rules[0], "example.com");
|
||||
assert_eq!(rules[1], "192.168.0.0/16");
|
||||
assert_eq!(rules[2], r".*\.internal\.net");
|
||||
|
||||
println!(
|
||||
"Config persistence test passed: {} bypass rules found in config",
|
||||
rules.len()
|
||||
);
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test bypass rules with multiple rule types combined (exact + regex).
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_bypass_rules_multiple_rules() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
let binary_path = setup_test().await?;
|
||||
let mut tracker = ProxyTestTracker::new(binary_path.clone());
|
||||
|
||||
let (target_port, target_handle) = start_mock_http_server("MULTI-DIRECT-RESPONSE").await;
|
||||
let (upstream_port, upstream_handle) = start_mock_http_server("MULTI-UPSTREAM-RESPONSE").await;
|
||||
|
||||
// Multiple bypass rules: exact match + regex
|
||||
let bypass_rules = serde_json::json!(["127.0.0.1", r"^localhost$"]).to_string();
|
||||
let output = TestUtils::execute_command(
|
||||
&binary_path,
|
||||
&[
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--proxy-port",
|
||||
&upstream_port.to_string(),
|
||||
"--type",
|
||||
"http",
|
||||
"--bypass-rules",
|
||||
&bypass_rules,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
target_handle.abort();
|
||||
upstream_handle.abort();
|
||||
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
let local_port = config["localPort"].as_u64().unwrap() as u16;
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// Request via 127.0.0.1 (exact match rule) → bypass
|
||||
{
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
let request = format!(
|
||||
"GET http://127.0.0.1:{target_port}/ HTTP/1.1\r\nHost: 127.0.0.1:{target_port}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).await?;
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
|
||||
assert!(
|
||||
response_str.contains("MULTI-DIRECT-RESPONSE"),
|
||||
"Exact-match bypassed request should reach target, got: {}",
|
||||
&response_str[..response_str.len().min(300)]
|
||||
);
|
||||
println!("Multi-rule test: exact match bypass works");
|
||||
}
|
||||
|
||||
// Request via localhost (regex match rule) → bypass
|
||||
{
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
let request = format!(
|
||||
"GET http://localhost:{target_port}/ HTTP/1.1\r\nHost: localhost:{target_port}\r\nConnection: close\r\n\r\n"
|
||||
);
|
||||
stream.write_all(request.as_bytes()).await?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).await?;
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
|
||||
assert!(
|
||||
response_str.contains("MULTI-DIRECT-RESPONSE"),
|
||||
"Regex-match bypassed request should reach target, got: {}",
|
||||
&response_str[..response_str.len().min(300)]
|
||||
);
|
||||
println!("Multi-rule test: regex match bypass works");
|
||||
}
|
||||
|
||||
// Request to non-matching host → upstream
|
||||
{
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
let request =
|
||||
b"GET http://other-host.test/ HTTP/1.1\r\nHost: other-host.test\r\nConnection: close\r\n\r\n";
|
||||
stream.write_all(request).await?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).await?;
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
|
||||
assert!(
|
||||
response_str.contains("MULTI-UPSTREAM-RESPONSE"),
|
||||
"Non-matching request should go through upstream, got: {}",
|
||||
&response_str[..response_str.len().min(300)]
|
||||
);
|
||||
println!("Multi-rule test: non-matching host goes through upstream");
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
target_handle.abort();
|
||||
upstream_handle.abort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test that an empty bypass rules list means everything goes through upstream.
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_no_bypass_rules_all_through_upstream(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let binary_path = setup_test().await?;
|
||||
let mut tracker = ProxyTestTracker::new(binary_path.clone());
|
||||
|
||||
let (upstream_port, upstream_handle) = start_mock_http_server("ALL-UPSTREAM-RESPONSE").await;
|
||||
|
||||
// Start proxy with empty bypass rules
|
||||
let bypass_rules = serde_json::json!([]).to_string();
|
||||
let output = TestUtils::execute_command(
|
||||
&binary_path,
|
||||
&[
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"127.0.0.1",
|
||||
"--proxy-port",
|
||||
&upstream_port.to_string(),
|
||||
"--type",
|
||||
"http",
|
||||
"--bypass-rules",
|
||||
&bypass_rules,
|
||||
],
|
||||
)
|
||||
.await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
upstream_handle.abort();
|
||||
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
let local_port = config["localPort"].as_u64().unwrap() as u16;
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
sleep(Duration::from_millis(500)).await;
|
||||
|
||||
// All requests should go through upstream when bypass rules are empty
|
||||
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
|
||||
let request =
|
||||
b"GET http://any-host.test/ HTTP/1.1\r\nHost: any-host.test\r\nConnection: close\r\n\r\n";
|
||||
stream.write_all(request).await?;
|
||||
|
||||
let mut response = Vec::new();
|
||||
stream.read_to_end(&mut response).await?;
|
||||
let response_str = String::from_utf8_lossy(&response);
|
||||
|
||||
assert!(
|
||||
response_str.contains("ALL-UPSTREAM-RESPONSE"),
|
||||
"With no bypass rules, all requests should go through upstream, got: {}",
|
||||
&response_str[..response_str.len().min(300)]
|
||||
);
|
||||
println!("Empty bypass rules test passed: all traffic goes through upstream");
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
upstream_handle.abort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -238,6 +238,46 @@ fn create_test_profile_bundle(temp_dir: &Path) -> Vec<u8> {
|
||||
encoder.finish().unwrap()
|
||||
}
|
||||
|
||||
fn create_test_profile_bundle_with_bypass_rules(temp_dir: &Path, bypass_rules: &[&str]) -> Vec<u8> {
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use tar::Builder;
|
||||
|
||||
let metadata = json!({
|
||||
"id": "test-bypass-profile-id",
|
||||
"name": "Bypass Rules Profile",
|
||||
"browser": "camoufox",
|
||||
"version": "120.0.0",
|
||||
"release_type": "stable",
|
||||
"sync_enabled": true,
|
||||
"tags": [],
|
||||
"proxy_bypass_rules": bypass_rules
|
||||
});
|
||||
|
||||
let profile_dir = temp_dir.join("bypass_profile");
|
||||
fs::create_dir_all(&profile_dir).unwrap();
|
||||
fs::write(profile_dir.join("test_file.txt"), "bypass test content").unwrap();
|
||||
|
||||
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
|
||||
{
|
||||
let mut tar = Builder::new(&mut encoder);
|
||||
|
||||
let metadata_json = serde_json::to_string_pretty(&metadata).unwrap();
|
||||
let mut header = tar::Header::new_gnu();
|
||||
header.set_size(metadata_json.len() as u64);
|
||||
header.set_mode(0o644);
|
||||
header.set_cksum();
|
||||
tar
|
||||
.append_data(&mut header, "metadata.json", metadata_json.as_bytes())
|
||||
.unwrap();
|
||||
|
||||
tar.append_dir_all("profile", &profile_dir).unwrap();
|
||||
tar.finish().unwrap();
|
||||
}
|
||||
|
||||
encoder.finish().unwrap()
|
||||
}
|
||||
|
||||
fn extract_bundle(data: &[u8], target_dir: &Path) -> serde_json::Value {
|
||||
use flate2::read::GzDecoder;
|
||||
use tar::Archive;
|
||||
@@ -727,3 +767,77 @@ async fn test_delta_sync_only_changed_files() {
|
||||
client.delete(&file2_key, None).await.unwrap();
|
||||
client.delete(&file3_key, None).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_profile_bypass_rules_sync() {
|
||||
ensure_sync_server_available().await;
|
||||
let client = TestClient::new();
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let profile_id = uuid::Uuid::new_v4().to_string();
|
||||
let test_key = format!("profiles/{}.tar.gz", profile_id);
|
||||
|
||||
let bypass_rules = vec!["example.com", "192.168.1.0/24", ".*\\.internal\\.net"];
|
||||
|
||||
let bundle = create_test_profile_bundle_with_bypass_rules(temp_dir.path(), &bypass_rules);
|
||||
|
||||
let presign = client
|
||||
.presign_upload(&test_key, "application/gzip")
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.upload_bytes(&presign.url, &bundle, "application/gzip")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let stat = client.stat(&test_key).await.unwrap();
|
||||
assert!(stat.exists);
|
||||
|
||||
// Download and verify bypass rules survive the round-trip
|
||||
let download_presign = client.presign_download(&test_key).await.unwrap();
|
||||
let downloaded = client.download_bytes(&download_presign.url).await.unwrap();
|
||||
assert_eq!(downloaded.len(), bundle.len());
|
||||
|
||||
let extract_dir = temp_dir.path().join("extracted");
|
||||
fs::create_dir_all(&extract_dir).unwrap();
|
||||
let metadata = extract_bundle(&downloaded, &extract_dir);
|
||||
|
||||
assert_eq!(metadata["name"], "Bypass Rules Profile");
|
||||
assert_eq!(metadata["browser"], "camoufox");
|
||||
|
||||
let synced_rules = metadata["proxy_bypass_rules"]
|
||||
.as_array()
|
||||
.expect("proxy_bypass_rules should be an array");
|
||||
assert_eq!(synced_rules.len(), 3);
|
||||
assert_eq!(synced_rules[0], "example.com");
|
||||
assert_eq!(synced_rules[1], "192.168.1.0/24");
|
||||
assert_eq!(synced_rules[2], ".*\\.internal\\.net");
|
||||
|
||||
// Also verify empty bypass rules are handled correctly
|
||||
let empty_bundle = create_test_profile_bundle_with_bypass_rules(temp_dir.path(), &[]);
|
||||
let empty_key = format!("profiles/{}.tar.gz", uuid::Uuid::new_v4());
|
||||
|
||||
let presign2 = client
|
||||
.presign_upload(&empty_key, "application/gzip")
|
||||
.await
|
||||
.unwrap();
|
||||
client
|
||||
.upload_bytes(&presign2.url, &empty_bundle, "application/gzip")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let download_presign2 = client.presign_download(&empty_key).await.unwrap();
|
||||
let downloaded2 = client.download_bytes(&download_presign2.url).await.unwrap();
|
||||
|
||||
let extract_dir2 = temp_dir.path().join("extracted2");
|
||||
fs::create_dir_all(&extract_dir2).unwrap();
|
||||
let metadata2 = extract_bundle(&downloaded2, &extract_dir2);
|
||||
|
||||
let empty_rules = metadata2["proxy_bypass_rules"]
|
||||
.as_array()
|
||||
.expect("proxy_bypass_rules should be an array");
|
||||
assert!(empty_rules.is_empty());
|
||||
|
||||
// Cleanup
|
||||
client.delete(&test_key, None).await.unwrap();
|
||||
client.delete(&empty_key, None).await.unwrap();
|
||||
}
|
||||
|
||||
+142
-41
@@ -5,11 +5,14 @@ import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CloneProfileDialog } from "@/components/clone-profile-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 { ExtensionGroupAssignmentDialog } from "@/components/extension-group-assignment-dialog";
|
||||
import { ExtensionManagementDialog } from "@/components/extension-management-dialog";
|
||||
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
||||
import { GroupBadges } from "@/components/group-badges";
|
||||
import { GroupManagementDialog } from "@/components/group-management-dialog";
|
||||
@@ -44,6 +47,7 @@ import {
|
||||
dismissToast,
|
||||
showErrorToast,
|
||||
showSuccessToast,
|
||||
showSyncProgressToast,
|
||||
showToast,
|
||||
} from "@/lib/toast-utils";
|
||||
import type {
|
||||
@@ -139,8 +143,18 @@ export default function Home() {
|
||||
useState(false);
|
||||
const [groupManagementDialogOpen, setGroupManagementDialogOpen] =
|
||||
useState(false);
|
||||
const [extensionManagementDialogOpen, setExtensionManagementDialogOpen] =
|
||||
useState(false);
|
||||
const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] =
|
||||
useState(false);
|
||||
const [
|
||||
extensionGroupAssignmentDialogOpen,
|
||||
setExtensionGroupAssignmentDialogOpen,
|
||||
] = useState(false);
|
||||
const [
|
||||
selectedProfilesForExtensionGroup,
|
||||
setSelectedProfilesForExtensionGroup,
|
||||
] = useState<string[]>([]);
|
||||
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
|
||||
useState(false);
|
||||
const [cookieCopyDialogOpen, setCookieCopyDialogOpen] = useState(false);
|
||||
@@ -165,9 +179,12 @@ export default function Home() {
|
||||
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
|
||||
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [cloneProfile, setCloneProfile] = useState<BrowserProfile | null>(null);
|
||||
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
||||
const [launchOnLoginDialogOpen, setLaunchOnLoginDialogOpen] = useState(false);
|
||||
const [windowResizeWarningOpen, setWindowResizeWarningOpen] = useState(false);
|
||||
const [windowResizeWarningBrowserType, setWindowResizeWarningBrowserType] =
|
||||
useState<string | undefined>(undefined);
|
||||
const windowResizeWarningResolver = useRef<
|
||||
((proceed: boolean) => void) | null
|
||||
>(null);
|
||||
@@ -185,8 +202,6 @@ export default function Home() {
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
const userInitiatedSyncIds = useRef<Set<string>>(new Set());
|
||||
|
||||
const handleSelectGroup = useCallback((groupId: string) => {
|
||||
setSelectedGroupId(groupId);
|
||||
setSelectedProfiles([]);
|
||||
@@ -498,23 +513,38 @@ export default function Home() {
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
extensionGroupId?: string;
|
||||
ephemeral?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
await invoke<BrowserProfile>("create_browser_profile_new", {
|
||||
name: profileData.name,
|
||||
browserStr: profileData.browserStr,
|
||||
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,
|
||||
});
|
||||
const profile = await invoke<BrowserProfile>(
|
||||
"create_browser_profile_new",
|
||||
{
|
||||
name: profileData.name,
|
||||
browserStr: profileData.browserStr,
|
||||
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,
|
||||
},
|
||||
);
|
||||
|
||||
if (profileData.extensionGroupId) {
|
||||
try {
|
||||
await invoke("assign_extension_group_to_profile", {
|
||||
profileId: profile.id,
|
||||
extensionGroupId: profileData.extensionGroupId,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to assign extension group:", err);
|
||||
}
|
||||
}
|
||||
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (error) {
|
||||
@@ -523,7 +553,6 @@ export default function Home() {
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[selectedGroupId],
|
||||
@@ -541,6 +570,7 @@ export default function Home() {
|
||||
if (!dismissed) {
|
||||
const proceed = await new Promise<boolean>((resolve) => {
|
||||
windowResizeWarningResolver.current = resolve;
|
||||
setWindowResizeWarningBrowserType(profile.browser);
|
||||
setWindowResizeWarningOpen(true);
|
||||
});
|
||||
if (!proceed) {
|
||||
@@ -565,16 +595,8 @@ export default function Home() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCloneProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
try {
|
||||
await invoke<BrowserProfile>("clone_profile", {
|
||||
profileId: profile.id,
|
||||
});
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to clone profile:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to clone profile: ${errorMessage}`);
|
||||
}
|
||||
const handleCloneProfile = useCallback((profile: BrowserProfile) => {
|
||||
setCloneProfile(profile);
|
||||
}, []);
|
||||
|
||||
const handleDeleteProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
@@ -688,6 +710,22 @@ export default function Home() {
|
||||
setSelectedProfiles([]);
|
||||
}, [selectedProfiles, handleAssignProfilesToGroup]);
|
||||
|
||||
const handleAssignExtensionGroup = useCallback((profileIds: string[]) => {
|
||||
setSelectedProfilesForExtensionGroup(profileIds);
|
||||
setExtensionGroupAssignmentDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleBulkExtensionGroupAssignment = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
handleAssignExtensionGroup(selectedProfiles);
|
||||
setSelectedProfiles([]);
|
||||
}, [selectedProfiles, handleAssignExtensionGroup]);
|
||||
|
||||
const handleExtensionGroupAssignmentComplete = useCallback(() => {
|
||||
setExtensionGroupAssignmentDialogOpen(false);
|
||||
setSelectedProfilesForExtensionGroup([]);
|
||||
}, []);
|
||||
|
||||
const handleAssignProfilesToProxy = useCallback((profileIds: string[]) => {
|
||||
setSelectedProfilesForProxy(profileIds);
|
||||
setProxyAssignmentDialogOpen(true);
|
||||
@@ -755,9 +793,6 @@ export default function Home() {
|
||||
profileId: profile.id,
|
||||
syncMode: enabling ? "Regular" : "Disabled",
|
||||
});
|
||||
if (enabling) {
|
||||
userInitiatedSyncIds.current.add(profile.id);
|
||||
}
|
||||
showSuccessToast(enabling ? "Sync enabled" : "Sync disabled", {
|
||||
description: enabling
|
||||
? "Profile sync has been enabled"
|
||||
@@ -772,46 +807,85 @@ export default function Home() {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
let unlistenStatus: (() => void) | undefined;
|
||||
let unlistenProgress: (() => void) | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
unlisten = await listen<{
|
||||
unlistenStatus = await listen<{
|
||||
profile_id: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
profile_name?: string;
|
||||
}>("profile-sync-status", (event) => {
|
||||
const { profile_id, status, error } = event.payload;
|
||||
if (!userInitiatedSyncIds.current.has(profile_id)) return;
|
||||
|
||||
const { profile_id, status, error, profile_name } = event.payload;
|
||||
const toastId = `sync-${profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === profile_id);
|
||||
const name = profile?.name ?? "Unknown";
|
||||
const name = profile_name || profile?.name || "Unknown";
|
||||
|
||||
if (status === "syncing") {
|
||||
showToast({
|
||||
type: "loading",
|
||||
title: `Syncing profile '${name}'...`,
|
||||
id: toastId,
|
||||
duration: 30000,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
onCancel: () => dismissToast(toastId),
|
||||
});
|
||||
} 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);
|
||||
}
|
||||
});
|
||||
|
||||
unlistenProgress = await listen<{
|
||||
profile_id: string;
|
||||
phase: string;
|
||||
total_files?: number;
|
||||
total_bytes?: number;
|
||||
completed_files?: number;
|
||||
completed_bytes?: number;
|
||||
speed_bytes_per_sec?: number;
|
||||
eta_seconds?: number;
|
||||
failed_count?: number;
|
||||
profile_name?: string;
|
||||
}>("profile-sync-progress", (event) => {
|
||||
const payload = event.payload;
|
||||
const toastId = `sync-${payload.profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === payload.profile_id);
|
||||
const name = payload.profile_name || profile?.name || "Unknown";
|
||||
|
||||
if (
|
||||
payload.phase === "started" ||
|
||||
payload.phase === "uploading" ||
|
||||
payload.phase === "downloading"
|
||||
) {
|
||||
showSyncProgressToast(
|
||||
name,
|
||||
{
|
||||
completed_files: payload.completed_files ?? 0,
|
||||
total_files: payload.total_files ?? 0,
|
||||
completed_bytes: payload.completed_bytes ?? 0,
|
||||
total_bytes: payload.total_bytes ?? 0,
|
||||
speed_bytes_per_sec: payload.speed_bytes_per_sec ?? 0,
|
||||
eta_seconds: payload.eta_seconds ?? 0,
|
||||
failed_count: payload.failed_count ?? 0,
|
||||
phase: payload.phase,
|
||||
},
|
||||
{ id: toastId },
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync status events:", error);
|
||||
console.error("Failed to listen for sync events:", error);
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
if (unlistenStatus) unlistenStatus();
|
||||
if (unlistenProgress) unlistenProgress();
|
||||
};
|
||||
}, [profiles]);
|
||||
|
||||
@@ -1012,8 +1086,10 @@ export default function Home() {
|
||||
onSettingsDialogOpen={setSettingsDialogOpen}
|
||||
onSyncConfigDialogOpen={setSyncConfigDialogOpen}
|
||||
onIntegrationsDialogOpen={setIntegrationsDialogOpen}
|
||||
onExtensionManagementDialogOpen={setExtensionManagementDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full mt-2.5">
|
||||
@@ -1044,6 +1120,8 @@ export default function Home() {
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
onBulkProxyAssignment={handleBulkProxyAssignment}
|
||||
onBulkCopyCookies={handleBulkCopyCookies}
|
||||
onBulkExtensionGroupAssignment={handleBulkExtensionGroupAssignment}
|
||||
onAssignExtensionGroup={handleAssignExtensionGroup}
|
||||
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
|
||||
onToggleProfileSync={handleToggleProfileSync}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
@@ -1118,6 +1196,12 @@ export default function Home() {
|
||||
onPermissionGranted={checkNextPermission}
|
||||
/>
|
||||
|
||||
<CloneProfileDialog
|
||||
isOpen={!!cloneProfile}
|
||||
onClose={() => setCloneProfile(null)}
|
||||
profile={cloneProfile}
|
||||
/>
|
||||
|
||||
<CamoufoxConfigDialog
|
||||
isOpen={camoufoxConfigDialogOpen}
|
||||
onClose={() => {
|
||||
@@ -1142,6 +1226,12 @@ export default function Home() {
|
||||
onGroupManagementComplete={handleGroupManagementComplete}
|
||||
/>
|
||||
|
||||
<ExtensionManagementDialog
|
||||
isOpen={extensionManagementDialogOpen}
|
||||
onClose={() => setExtensionManagementDialogOpen(false)}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
/>
|
||||
|
||||
<GroupAssignmentDialog
|
||||
isOpen={groupAssignmentDialogOpen}
|
||||
onClose={() => {
|
||||
@@ -1152,6 +1242,16 @@ export default function Home() {
|
||||
profiles={profiles}
|
||||
/>
|
||||
|
||||
<ExtensionGroupAssignmentDialog
|
||||
isOpen={extensionGroupAssignmentDialogOpen}
|
||||
onClose={() => {
|
||||
setExtensionGroupAssignmentDialogOpen(false);
|
||||
}}
|
||||
selectedProfiles={selectedProfilesForExtensionGroup}
|
||||
onAssignmentComplete={handleExtensionGroupAssignmentComplete}
|
||||
profiles={profiles}
|
||||
/>
|
||||
|
||||
<ProxyAssignmentDialog
|
||||
isOpen={proxyAssignmentDialogOpen}
|
||||
onClose={() => {
|
||||
@@ -1248,6 +1348,7 @@ export default function Home() {
|
||||
|
||||
<WindowResizeWarningDialog
|
||||
isOpen={windowResizeWarningOpen}
|
||||
browserType={windowResizeWarningBrowserType}
|
||||
onResult={(proceed) => {
|
||||
setWindowResizeWarningOpen(false);
|
||||
windowResizeWarningResolver.current?.(proceed);
|
||||
|
||||
@@ -164,6 +164,8 @@ export function CamoufoxConfigDialog({
|
||||
readOnly={isRunning}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={profile.version}
|
||||
profileBrowser="wayfern"
|
||||
/>
|
||||
) : (
|
||||
<SharedCamoufoxConfigForm
|
||||
@@ -174,6 +176,8 @@ export function CamoufoxConfigDialog({
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={profile.version}
|
||||
profileBrowser="camoufox"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface CloneProfileDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
onCloneComplete?: () => void;
|
||||
}
|
||||
|
||||
export function CloneProfileDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
onCloneComplete,
|
||||
}: CloneProfileDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [name, setName] = React.useState("");
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen && profile) {
|
||||
const defaultName = `${profile.name} (Copy)`;
|
||||
setName(defaultName);
|
||||
setTimeout(() => {
|
||||
inputRef.current?.focus();
|
||||
inputRef.current?.select();
|
||||
}, 0);
|
||||
} else {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isOpen, profile]);
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
const handleClone = async () => {
|
||||
if (!name.trim() || isLoading) return;
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await invoke<BrowserProfile>("clone_profile", {
|
||||
profileId: profile.id,
|
||||
name: name.trim(),
|
||||
});
|
||||
onClose();
|
||||
onCloneComplete?.();
|
||||
} catch (err: unknown) {
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to clone profile: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("profileInfo.clone.title")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("profileInfo.clone.description")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void handleClone();
|
||||
}}
|
||||
placeholder={t("profileInfo.clone.namePlaceholder")}
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
onClick={() => void handleClone()}
|
||||
isLoading={isLoading}
|
||||
disabled={!name.trim()}
|
||||
>
|
||||
{t("profileInfo.clone.button")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,11 +4,22 @@ 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 { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -18,13 +29,16 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
@@ -34,6 +48,7 @@ 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 { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrowserReleaseTypes,
|
||||
CamoufoxConfig,
|
||||
@@ -74,6 +89,7 @@ interface CreateProfileDialogProps {
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
extensionGroupId?: string;
|
||||
ephemeral?: boolean;
|
||||
}) => Promise<void>;
|
||||
selectedGroupId?: string;
|
||||
@@ -126,6 +142,7 @@ export function CreateProfileDialog({
|
||||
const [selectedBrowser, setSelectedBrowser] =
|
||||
useState<BrowserTypeString | null>(null);
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
||||
|
||||
// Camoufox anti-detect states
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
|
||||
@@ -166,6 +183,21 @@ export function CreateProfileDialog({
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [selectedExtensionGroupId, setSelectedExtensionGroupId] =
|
||||
useState<string>();
|
||||
const [extensionGroups, setExtensionGroups] = useState<
|
||||
{ id: string; name: string; extension_ids: string[] }[]
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
invoke<{ id: string; name: string; extension_ids: string[] }[]>(
|
||||
"list_extension_groups",
|
||||
)
|
||||
.then(setExtensionGroups)
|
||||
.catch(() => setExtensionGroups([]));
|
||||
}
|
||||
}, [isOpen]);
|
||||
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
|
||||
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
|
||||
const [releaseTypesError, setReleaseTypesError] = useState<string | null>(
|
||||
@@ -179,7 +211,7 @@ export function CreateProfileDialog({
|
||||
downloadBrowser,
|
||||
loadDownloadedVersions,
|
||||
isVersionDownloaded,
|
||||
downloadedVersions,
|
||||
downloadedVersionsMap,
|
||||
} = useBrowserDownload();
|
||||
|
||||
const loadSupportedBrowsers = useCallback(async () => {
|
||||
@@ -344,8 +376,9 @@ export function CreateProfileDialog({
|
||||
if (bestVersion && isVersionDownloaded(bestVersion.version)) {
|
||||
return bestVersion;
|
||||
}
|
||||
if (downloadedVersions.length > 0) {
|
||||
const fallbackVersion = downloadedVersions[0];
|
||||
const browserDownloaded = downloadedVersionsMap[browserType ?? ""] ?? [];
|
||||
if (browserDownloaded.length > 0) {
|
||||
const fallbackVersion = browserDownloaded[0];
|
||||
const releaseType =
|
||||
browserType === "firefox-developer" ? "nightly" : "stable";
|
||||
return {
|
||||
@@ -355,7 +388,7 @@ export function CreateProfileDialog({
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[getBestAvailableVersion, isVersionDownloaded, downloadedVersions],
|
||||
[getBestAvailableVersion, isVersionDownloaded, downloadedVersionsMap],
|
||||
);
|
||||
|
||||
const handleDownload = async (browserStr: string) => {
|
||||
@@ -405,6 +438,7 @@ export function CreateProfileDialog({
|
||||
wayfernConfig: finalWayfernConfig,
|
||||
groupId:
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
});
|
||||
} else {
|
||||
@@ -429,6 +463,7 @@ export function CreateProfileDialog({
|
||||
camoufoxConfig: finalCamoufoxConfig,
|
||||
groupId:
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
extensionGroupId: selectedExtensionGroupId,
|
||||
ephemeral,
|
||||
});
|
||||
}
|
||||
@@ -538,8 +573,13 @@ export function CreateProfileDialog({
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>
|
||||
{currentStep === "browser-selection"
|
||||
? "Create New Profile"
|
||||
: "Configure Profile"}
|
||||
? t("createProfile.title")
|
||||
: t("createProfile.configureTitle", {
|
||||
browser:
|
||||
selectedBrowser === "wayfern"
|
||||
? t("createProfile.chromiumLabel")
|
||||
: t("createProfile.firefoxLabel"),
|
||||
})}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
@@ -557,62 +597,54 @@ export function CreateProfileDialog({
|
||||
<>
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
{/* Anti-Detect Browser Selection */}
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">
|
||||
Anti-Detect Browser
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Choose a browser with anti-detection capabilities
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-3 pt-8">
|
||||
{/* Wayfern (Chromium) - First */}
|
||||
<Button
|
||||
onClick={() => handleBrowserSelect("wayfern")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center w-8 h-8">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon("wayfern");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-6 h-6" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{t("createProfile.chromiumLabel")}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("createProfile.chromiumSubtitle")}
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
<div className="space-y-3">
|
||||
{/* Wayfern (Chromium) - First */}
|
||||
<Button
|
||||
onClick={() => handleBrowserSelect("wayfern")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center w-8 h-8">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon("wayfern");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-6 h-6" />
|
||||
) : null;
|
||||
})()}
|
||||
{/* Camoufox (Firefox) - Second */}
|
||||
<Button
|
||||
onClick={() => handleBrowserSelect("camoufox")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center w-8 h-8">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon("camoufox");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-6 h-6" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{t("createProfile.firefoxLabel")}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Wayfern</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Anti-Detect Browser
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("createProfile.firefoxSubtitle")}
|
||||
</div>
|
||||
</Button>
|
||||
|
||||
{/* Camoufox (Firefox) - Second */}
|
||||
<Button
|
||||
onClick={() => handleBrowserSelect("camoufox")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center w-8 h-8">
|
||||
{(() => {
|
||||
const IconComponent =
|
||||
getBrowserIcon("camoufox");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-6 h-6" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Camoufox</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Anti-Detect Browser
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -804,6 +836,10 @@ export function CreateProfileDialog({
|
||||
isCreating
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={
|
||||
getBestAvailableVersion("wayfern")?.version
|
||||
}
|
||||
profileBrowser="wayfern"
|
||||
/>
|
||||
</div>
|
||||
) : selectedBrowser === "camoufox" ? (
|
||||
@@ -896,6 +932,14 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{crossOsUnlocked && (
|
||||
<Alert className="border-yellow-500/50 bg-yellow-500/10">
|
||||
<AlertDescription className="text-sm">
|
||||
{t("createProfile.camoufoxWarning")}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
@@ -903,6 +947,10 @@ export function CreateProfileDialog({
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
limitedMode={!crossOsUnlocked}
|
||||
profileVersion={
|
||||
getBestAvailableVersion("camoufox")?.version
|
||||
}
|
||||
profileBrowser="camoufox"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
@@ -1020,52 +1068,125 @@ export function CreateProfileDialog({
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedProxyId(
|
||||
value === "none" ? undefined : value,
|
||||
)
|
||||
}
|
||||
<Popover
|
||||
open={proxyPopoverOpen}
|
||||
onOpenChange={setProxyPopoverOpen}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy / VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
No proxy / VPN
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Proxies</SelectLabel>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem
|
||||
key={proxy.id}
|
||||
value={proxy.id}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={proxyPopoverOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{(() => {
|
||||
if (!selectedProxyId)
|
||||
return "No proxy / VPN";
|
||||
if (selectedProxyId.startsWith("vpn-")) {
|
||||
const vpn = vpnConfigs.find(
|
||||
(v) =>
|
||||
v.id === selectedProxyId.slice(4),
|
||||
);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "No proxy / VPN";
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedProxyId,
|
||||
);
|
||||
return proxy?.name ?? "No proxy / VPN";
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[240px] p-0"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No proxies or VPNs found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(undefined);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedProxyId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(proxy.id);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedProxyId === proxy.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.name}`}
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(
|
||||
`vpn-${vpn.id}`,
|
||||
);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedProxyId ===
|
||||
`vpn-${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>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies or VPNs available. Add one to route
|
||||
@@ -1073,6 +1194,37 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Extension Group */}
|
||||
{extensionGroups.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.extensionGroup")}</Label>
|
||||
<Select
|
||||
value={selectedExtensionGroupId || "none"}
|
||||
onValueChange={(val) =>
|
||||
setSelectedExtensionGroupId(
|
||||
val === "none" ? undefined : val,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={t("profileInfo.values.none")}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("profileInfo.values.none")}
|
||||
</SelectItem>
|
||||
{extensionGroups.map((g) => (
|
||||
<SelectItem key={g.id} value={g.id}>
|
||||
{g.name} ({g.extension_ids.length})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -1207,52 +1359,125 @@ export function CreateProfileDialog({
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 || vpnConfigs.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedProxyId(
|
||||
value === "none" ? undefined : value,
|
||||
)
|
||||
}
|
||||
<Popover
|
||||
open={proxyPopoverOpen}
|
||||
onOpenChange={setProxyPopoverOpen}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy / VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
No proxy / VPN
|
||||
</SelectItem>
|
||||
{storedProxies.length > 0 && (
|
||||
<SelectGroup>
|
||||
<SelectLabel>Proxies</SelectLabel>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem
|
||||
key={proxy.id}
|
||||
value={proxy.id}
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={proxyPopoverOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{(() => {
|
||||
if (!selectedProxyId)
|
||||
return "No proxy / VPN";
|
||||
if (selectedProxyId.startsWith("vpn-")) {
|
||||
const vpn = vpnConfigs.find(
|
||||
(v) =>
|
||||
v.id === selectedProxyId.slice(4),
|
||||
);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "No proxy / VPN";
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedProxyId,
|
||||
);
|
||||
return proxy?.name ?? "No proxy / VPN";
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[240px] p-0"
|
||||
sideOffset={8}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>
|
||||
No proxies or VPNs found.
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(undefined);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{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>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
!selectedProxyId
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(proxy.id);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedProxyId === proxy.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.name}`}
|
||||
onSelect={() => {
|
||||
setSelectedProxyId(
|
||||
`vpn-${vpn.id}`,
|
||||
);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedProxyId ===
|
||||
`vpn-${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>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies or VPNs available. Add one to route
|
||||
|
||||
@@ -116,6 +116,20 @@ interface TwilightUpdateToastProps extends BaseToastProps {
|
||||
hasUpdate?: boolean;
|
||||
}
|
||||
|
||||
interface SyncProgressToastProps extends BaseToastProps {
|
||||
type: "sync-progress";
|
||||
progress?: {
|
||||
completed_files: number;
|
||||
total_files: number;
|
||||
completed_bytes: number;
|
||||
total_bytes: number;
|
||||
speed_bytes_per_sec: number;
|
||||
eta_seconds: number;
|
||||
failed_count: number;
|
||||
phase: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ToastProps =
|
||||
| LoadingToastProps
|
||||
| SuccessToastProps
|
||||
@@ -123,7 +137,38 @@ type ToastProps =
|
||||
| DownloadToastProps
|
||||
| VersionUpdateToastProps
|
||||
| FetchingToastProps
|
||||
| TwilightUpdateToastProps;
|
||||
| TwilightUpdateToastProps
|
||||
| SyncProgressToastProps;
|
||||
|
||||
function formatBytesCompact(bytes: number): string {
|
||||
if (bytes === 0) return "0 B";
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
const i = Math.min(
|
||||
Math.floor(Math.log(bytes) / Math.log(1024)),
|
||||
units.length - 1,
|
||||
);
|
||||
const value = bytes / 1024 ** i;
|
||||
return `${i === 0 ? value : value.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
function formatSpeedCompact(bytesPerSec: number): string {
|
||||
if (bytesPerSec >= 1024 * 1024) {
|
||||
return `${(bytesPerSec / (1024 * 1024)).toFixed(1)} MB/s`;
|
||||
}
|
||||
return `${(bytesPerSec / 1024).toFixed(0)} KB/s`;
|
||||
}
|
||||
|
||||
function formatEtaCompact(seconds: number): string {
|
||||
if (seconds >= 3600) {
|
||||
const h = Math.floor(seconds / 3600);
|
||||
const m = Math.floor((seconds % 3600) / 60);
|
||||
return `${h}h ${m}m`;
|
||||
}
|
||||
if (seconds >= 60) {
|
||||
return `${Math.floor(seconds / 60)} min`;
|
||||
}
|
||||
return `${Math.round(seconds)}s`;
|
||||
}
|
||||
|
||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
switch (type) {
|
||||
@@ -153,6 +198,10 @@ function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "sync-progress":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
@@ -237,6 +286,39 @@ export function UnifiedToast(props: ToastProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sync progress */}
|
||||
{type === "sync-progress" &&
|
||||
progress &&
|
||||
"completed_files" in progress && (
|
||||
<div className="mt-1">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.phase === "uploading" ? "Uploading" : "Downloading"}{" "}
|
||||
{progress.completed_files}/{progress.total_files} files
|
||||
{" \u2022 "}
|
||||
{formatBytesCompact(progress.completed_bytes)} /{" "}
|
||||
{formatBytesCompact(progress.total_bytes)}
|
||||
{progress.speed_bytes_per_sec > 0 && (
|
||||
<>
|
||||
{" \u2022 "}
|
||||
{formatSpeedCompact(progress.speed_bytes_per_sec)}
|
||||
</>
|
||||
)}
|
||||
{progress.eta_seconds > 0 &&
|
||||
progress.completed_files < progress.total_files && (
|
||||
<>
|
||||
{" \u2022 ~"}
|
||||
{formatEtaCompact(progress.eta_seconds)} remaining
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
{progress.failed_count > 0 && (
|
||||
<p className="text-xs text-destructive mt-0.5">
|
||||
{progress.failed_count} file(s) failed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Twilight update progress */}
|
||||
{type === "twilight-update" && (
|
||||
<div className="mt-2">
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BrowserProfile, ExtensionGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ExtensionGroupAssignmentDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedProfiles: string[];
|
||||
onAssignmentComplete: () => void;
|
||||
profiles?: BrowserProfile[];
|
||||
}
|
||||
|
||||
export function ExtensionGroupAssignmentDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedProfiles,
|
||||
onAssignmentComplete,
|
||||
profiles = [],
|
||||
}: ExtensionGroupAssignmentDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [groups, setGroups] = useState<ExtensionGroup[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const groupList = await invoke<ExtensionGroup[]>("list_extension_groups");
|
||||
setGroups(groupList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load extension groups:", err);
|
||||
setError(
|
||||
err instanceof Error ? err.message : "Failed to load extension groups",
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAssign = useCallback(async () => {
|
||||
setIsAssigning(true);
|
||||
setError(null);
|
||||
try {
|
||||
for (const profileId of selectedProfiles) {
|
||||
await invoke("assign_extension_group_to_profile", {
|
||||
profileId,
|
||||
extensionGroupId: selectedGroupId,
|
||||
});
|
||||
}
|
||||
|
||||
toast.success(t("extensions.assignSuccess"));
|
||||
onAssignmentComplete();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to assign extension group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to assign extension group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsAssigning(false);
|
||||
}
|
||||
}, [selectedProfiles, selectedGroupId, onAssignmentComplete, onClose, t]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadGroups();
|
||||
setSelectedGroupId(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, loadGroups]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("extensions.assignTitle")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("extensions.assignDescription", {
|
||||
count: selectedProfiles.length,
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>{t("extensions.assignTitle")}:</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileId) => {
|
||||
const profile = profiles.find(
|
||||
(p: BrowserProfile) => p.id === profileId,
|
||||
);
|
||||
const displayName = profile ? profile.name : profileId;
|
||||
return (
|
||||
<li key={profileId} className="truncate">
|
||||
• {displayName}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="extension-group-select">
|
||||
{t("extensions.extensionGroup")}:
|
||||
</Label>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{t("common.buttons.loading")}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGroupId || "none"}
|
||||
onValueChange={(value) => {
|
||||
setSelectedGroupId(value === "none" ? null : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">
|
||||
{t("extensions.noGroup")}
|
||||
</SelectItem>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
{t("common.buttons.cancel")}
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isAssigning}
|
||||
onClick={() => void handleAssign()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{t("common.buttons.apply")}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -43,6 +43,7 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
function getSyncStatusDot(
|
||||
group: GroupWithCount,
|
||||
liveStatus: SyncStatus | undefined,
|
||||
errorMessage?: string,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (group.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
@@ -64,7 +65,11 @@ function getSyncStatusDot(
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
|
||||
return {
|
||||
color: "bg-red-500",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
|
||||
}
|
||||
@@ -95,6 +100,9 @@ export function GroupManagementDialog({
|
||||
const [groupSyncStatus, setGroupSyncStatus] = useState<
|
||||
Record<string, SyncStatus>
|
||||
>({});
|
||||
const [groupSyncErrors, setGroupSyncErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [groupInUse, setGroupInUse] = useState<Record<string, boolean>>({});
|
||||
const [isTogglingSync, setIsTogglingSync] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
@@ -105,14 +113,17 @@ export function GroupManagementDialog({
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ id: string; status: string }>(
|
||||
unlisten = await listen<{ id: string; status: string; error?: string }>(
|
||||
"group-sync-status",
|
||||
(event) => {
|
||||
const { id, status } = event.payload;
|
||||
const { id, status, error } = event.payload;
|
||||
setGroupSyncStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: status as SyncStatus,
|
||||
}));
|
||||
if (error) {
|
||||
setGroupSyncErrors((prev) => ({ ...prev, [id]: error }));
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -216,7 +227,7 @@ export function GroupManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Profile Groups</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -225,149 +236,152 @@ export function GroupManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Groups</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
<ScrollArea className="overflow-y-auto flex-1">
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Groups</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No groups created yet. Create your first group using the
|
||||
button above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Profiles</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
group,
|
||||
groupSyncStatus[group.id],
|
||||
groupSyncErrors[group.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{group.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{group.count}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={group.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleSync(group)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[group.id] ||
|
||||
groupInUse[group.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{groupInUse[group.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this group
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{group.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No groups created yet. Create your first group using the button
|
||||
above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<ScrollArea className="h-[240px]">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-20">Profiles</TableHead>
|
||||
<TableHead className="w-24">Sync</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group) => {
|
||||
const syncDot = getSyncStatusDot(
|
||||
group,
|
||||
groupSyncStatus[group.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{group.name}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{group.count}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={group.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleSync(group)
|
||||
}
|
||||
disabled={
|
||||
isTogglingSync[group.id] ||
|
||||
groupInUse[group.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{groupInUse[group.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this group
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{group.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete group</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -2,7 +2,15 @@ import { useTranslation } from "react-i18next";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import { LuCloud, LuPlug, LuSearch, LuUsers, LuX } from "react-icons/lu";
|
||||
import {
|
||||
LuCloud,
|
||||
LuPlug,
|
||||
LuPuzzle,
|
||||
LuSearch,
|
||||
LuUsers,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Button } from "./ui/button";
|
||||
import { CardTitle } from "./ui/card";
|
||||
@@ -13,6 +21,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Input } from "./ui/input";
|
||||
import { ProBadge } from "./ui/pro-badge";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type Props = {
|
||||
@@ -23,8 +32,10 @@ type Props = {
|
||||
onCreateProfileDialogOpen: (open: boolean) => void;
|
||||
onSyncConfigDialogOpen: (open: boolean) => void;
|
||||
onIntegrationsDialogOpen: (open: boolean) => void;
|
||||
onExtensionManagementDialogOpen: (open: boolean) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
};
|
||||
|
||||
const HomeHeader = ({
|
||||
@@ -35,8 +46,10 @@ const HomeHeader = ({
|
||||
onCreateProfileDialogOpen,
|
||||
onSyncConfigDialogOpen,
|
||||
onIntegrationsDialogOpen,
|
||||
onExtensionManagementDialogOpen,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
crossOsUnlocked = false,
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const handleLogoClick = () => {
|
||||
@@ -124,6 +137,17 @@ const HomeHeader = ({
|
||||
<LuUsers className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.groups")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!crossOsUnlocked}
|
||||
className={cn(!crossOsUnlocked && "opacity-50")}
|
||||
onClick={() => {
|
||||
onExtensionManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<LuPuzzle className="mr-2 w-4 h-4" />
|
||||
{t("header.menu.extensions")}
|
||||
{!crossOsUnlocked && <ProBadge className="ml-auto" />}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onSyncConfigDialogOpen(true);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -29,26 +30,31 @@ export function LocationProxyDialog({
|
||||
onClose,
|
||||
}: LocationProxyDialogProps) {
|
||||
const [countries, setCountries] = useState<LocationItem[]>([]);
|
||||
const [states, setStates] = useState<LocationItem[]>([]);
|
||||
const [regions, setRegions] = useState<LocationItem[]>([]);
|
||||
const [cities, setCities] = useState<LocationItem[]>([]);
|
||||
const [isps, setIsps] = useState<LocationItem[]>([]);
|
||||
|
||||
const [selectedCountry, setSelectedCountry] = useState("");
|
||||
const [selectedState, setSelectedState] = useState("");
|
||||
const [selectedRegion, setSelectedRegion] = useState("");
|
||||
const [selectedCity, setSelectedCity] = useState("");
|
||||
const [selectedIsp, setSelectedIsp] = useState("");
|
||||
const [proxyName, setProxyName] = useState("");
|
||||
|
||||
const [isLoadingCountries, setIsLoadingCountries] = useState(false);
|
||||
const [isLoadingStates, setIsLoadingStates] = useState(false);
|
||||
const [isLoadingRegions, setIsLoadingRegions] = useState(false);
|
||||
const [isLoadingCities, setIsLoadingCities] = useState(false);
|
||||
const [isLoadingIsps, setIsLoadingIsps] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setSelectedCountry("");
|
||||
setSelectedState("");
|
||||
setSelectedRegion("");
|
||||
setSelectedCity("");
|
||||
setSelectedIsp("");
|
||||
setProxyName("");
|
||||
setStates([]);
|
||||
setRegions([]);
|
||||
setCities([]);
|
||||
setIsps([]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
@@ -65,52 +71,87 @@ export function LocationProxyDialog({
|
||||
.finally(() => setIsLoadingCountries(false));
|
||||
}, [isOpen]);
|
||||
|
||||
// Fetch states when country changes
|
||||
// Fetch regions when country changes
|
||||
useEffect(() => {
|
||||
if (!selectedCountry) {
|
||||
setStates([]);
|
||||
setRegions([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingStates(true);
|
||||
setSelectedState("");
|
||||
setIsLoadingRegions(true);
|
||||
setSelectedRegion("");
|
||||
setSelectedCity("");
|
||||
setSelectedIsp("");
|
||||
setCities([]);
|
||||
invoke<LocationItem[]>("cloud_get_states", { country: selectedCountry })
|
||||
.then((data) => setStates(data))
|
||||
.catch((err) => console.error("Failed to fetch states:", err))
|
||||
.finally(() => setIsLoadingStates(false));
|
||||
setIsps([]);
|
||||
invoke<LocationItem[]>("cloud_get_regions", { country: selectedCountry })
|
||||
.then((data) => setRegions(data))
|
||||
.catch((err) => console.error("Failed to fetch regions:", err))
|
||||
.finally(() => setIsLoadingRegions(false));
|
||||
}, [selectedCountry]);
|
||||
|
||||
// Fetch cities when state changes
|
||||
// Fetch cities when country or region changes (cities can be loaded without region)
|
||||
useEffect(() => {
|
||||
if (!selectedCountry || !selectedState) {
|
||||
if (!selectedCountry) {
|
||||
setCities([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingCities(true);
|
||||
setSelectedCity("");
|
||||
invoke<LocationItem[]>("cloud_get_cities", {
|
||||
const args: { country: string; region?: string } = {
|
||||
country: selectedCountry,
|
||||
state: selectedState,
|
||||
})
|
||||
};
|
||||
if (selectedRegion) {
|
||||
args.region = selectedRegion;
|
||||
}
|
||||
invoke<LocationItem[]>("cloud_get_cities", args)
|
||||
.then((data) => setCities(data))
|
||||
.catch((err) => console.error("Failed to fetch cities:", err))
|
||||
.finally(() => setIsLoadingCities(false));
|
||||
}, [selectedCountry, selectedState]);
|
||||
}, [selectedCountry, selectedRegion]);
|
||||
|
||||
// Fetch ISPs when country/region/city changes
|
||||
useEffect(() => {
|
||||
if (!selectedCountry) {
|
||||
setIsps([]);
|
||||
return;
|
||||
}
|
||||
setIsLoadingIsps(true);
|
||||
setSelectedIsp("");
|
||||
const args: { country: string; region?: string; city?: string } = {
|
||||
country: selectedCountry,
|
||||
};
|
||||
if (selectedRegion) args.region = selectedRegion;
|
||||
if (selectedCity) args.city = selectedCity;
|
||||
invoke<LocationItem[]>("cloud_get_isps", args)
|
||||
.then((data) => setIsps(data))
|
||||
.catch((err) => console.error("Failed to fetch ISPs:", err))
|
||||
.finally(() => setIsLoadingIsps(false));
|
||||
}, [selectedCountry, selectedRegion, selectedCity]);
|
||||
|
||||
// Auto-generate name from selections
|
||||
useEffect(() => {
|
||||
const parts: string[] = [];
|
||||
const countryItem = countries.find((c) => c.code === selectedCountry);
|
||||
if (countryItem) parts.push(countryItem.name);
|
||||
const stateItem = states.find((s) => s.code === selectedState);
|
||||
if (stateItem) parts.push(stateItem.name);
|
||||
const regionItem = regions.find((s) => s.code === selectedRegion);
|
||||
if (regionItem) parts.push(regionItem.name);
|
||||
const cityItem = cities.find((c) => c.code === selectedCity);
|
||||
if (cityItem) parts.push(cityItem.name);
|
||||
const ispItem = isps.find((i) => i.code === selectedIsp);
|
||||
if (ispItem) parts.push(ispItem.name);
|
||||
if (parts.length > 0) {
|
||||
setProxyName(parts.join(" - "));
|
||||
}
|
||||
}, [selectedCountry, selectedState, selectedCity, countries, states, cities]);
|
||||
}, [
|
||||
selectedCountry,
|
||||
selectedRegion,
|
||||
selectedCity,
|
||||
selectedIsp,
|
||||
countries,
|
||||
regions,
|
||||
cities,
|
||||
isps,
|
||||
]);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!selectedCountry || !proxyName.trim()) return;
|
||||
@@ -119,8 +160,9 @@ export function LocationProxyDialog({
|
||||
await invoke("create_cloud_location_proxy", {
|
||||
name: proxyName.trim(),
|
||||
country: selectedCountry,
|
||||
state: selectedState || null,
|
||||
region: selectedRegion || null,
|
||||
city: selectedCity || null,
|
||||
isp: selectedIsp || null,
|
||||
});
|
||||
toast.success("Location proxy created");
|
||||
await emit("stored-proxies-changed");
|
||||
@@ -133,14 +175,26 @@ export function LocationProxyDialog({
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [selectedCountry, selectedState, selectedCity, proxyName, handleClose]);
|
||||
}, [
|
||||
selectedCountry,
|
||||
selectedRegion,
|
||||
selectedCity,
|
||||
selectedIsp,
|
||||
proxyName,
|
||||
handleClose,
|
||||
]);
|
||||
|
||||
const countryOptions = countries.map((c) => ({
|
||||
value: c.code,
|
||||
label: c.name,
|
||||
}));
|
||||
const stateOptions = states.map((s) => ({ value: s.code, label: s.name }));
|
||||
const regionOptions = regions.map((s) => ({ value: s.code, label: s.name }));
|
||||
const cityOptions = cities.map((c) => ({ value: c.code, label: c.name }));
|
||||
const ispOptions = isps.map((i) => ({ value: i.code, label: i.name }));
|
||||
|
||||
const LoadingSpinner = () => (
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
@@ -148,48 +202,102 @@ export function LocationProxyDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Location Proxy</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a geo-targeted proxy from your cloud credentials
|
||||
Create a geo-targeted proxy with a 24-hour sticky session
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Country - always visible */}
|
||||
<div className="space-y-2">
|
||||
<Label>Country (required)</Label>
|
||||
<Label className="flex items-center gap-2">
|
||||
Country (required)
|
||||
{isLoadingCountries && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={countryOptions}
|
||||
value={selectedCountry}
|
||||
onValueChange={setSelectedCountry}
|
||||
placeholder={isLoadingCountries ? "Loading..." : "Select country"}
|
||||
placeholder={
|
||||
isLoadingCountries ? "Loading countries..." : "Select country"
|
||||
}
|
||||
searchPlaceholder="Search countries..."
|
||||
disabled={isLoadingCountries}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedCountry && stateOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>State (optional)</Label>
|
||||
<Combobox
|
||||
options={stateOptions}
|
||||
value={selectedState}
|
||||
onValueChange={setSelectedState}
|
||||
placeholder={isLoadingStates ? "Loading..." : "Select state"}
|
||||
searchPlaceholder="Search states..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Region - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
Region (optional)
|
||||
{isLoadingRegions && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={regionOptions}
|
||||
value={selectedRegion}
|
||||
onValueChange={setSelectedRegion}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
: isLoadingRegions
|
||||
? "Loading regions..."
|
||||
: regionOptions.length === 0
|
||||
? "No regions available"
|
||||
: "Select region"
|
||||
}
|
||||
searchPlaceholder="Search regions..."
|
||||
disabled={!selectedCountry || isLoadingRegions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedState && cityOptions.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label>City (optional)</Label>
|
||||
<Combobox
|
||||
options={cityOptions}
|
||||
value={selectedCity}
|
||||
onValueChange={setSelectedCity}
|
||||
placeholder={isLoadingCities ? "Loading..." : "Select city"}
|
||||
searchPlaceholder="Search cities..."
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* City - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
City (optional)
|
||||
{isLoadingCities && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={cityOptions}
|
||||
value={selectedCity}
|
||||
onValueChange={setSelectedCity}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
: isLoadingCities
|
||||
? "Loading cities..."
|
||||
: cityOptions.length === 0
|
||||
? "No cities available"
|
||||
: "Select city"
|
||||
}
|
||||
searchPlaceholder="Search cities..."
|
||||
disabled={!selectedCountry || isLoadingCities}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* ISP - always visible, disabled until country is selected */}
|
||||
<div className="space-y-2">
|
||||
<Label className="flex items-center gap-2">
|
||||
ISP (optional)
|
||||
{isLoadingIsps && <LoadingSpinner />}
|
||||
</Label>
|
||||
<Combobox
|
||||
options={ispOptions}
|
||||
value={selectedIsp}
|
||||
onValueChange={setSelectedIsp}
|
||||
placeholder={
|
||||
!selectedCountry
|
||||
? "Select a country first"
|
||||
: isLoadingIsps
|
||||
? "Loading ISPs..."
|
||||
: ispOptions.length === 0
|
||||
? "No ISPs available"
|
||||
: "Select ISP"
|
||||
}
|
||||
searchPlaceholder="Search ISPs..."
|
||||
disabled={!selectedCountry || isLoadingIsps}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="space-y-2">
|
||||
<Label>Name</Label>
|
||||
<Input
|
||||
|
||||
@@ -16,16 +16,22 @@ 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 {
|
||||
LuCheck,
|
||||
LuChevronDown,
|
||||
LuChevronUp,
|
||||
LuCookie,
|
||||
LuInfo,
|
||||
LuLock,
|
||||
LuPuzzle,
|
||||
LuTrash2,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import {
|
||||
ProfileBypassRulesDialog,
|
||||
ProfileInfoDialog,
|
||||
} from "@/components/profile-info-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -37,18 +43,11 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
@@ -64,8 +63,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useTableSorting } from "@/hooks/use-table-sorting";
|
||||
import { useTeamLocks } from "@/hooks/use-team-locks";
|
||||
import { useVpnEvents } from "@/hooks/use-vpn-events";
|
||||
import {
|
||||
getBrowserDisplayName,
|
||||
@@ -199,9 +200,18 @@ type TableMeta = {
|
||||
profileId: string,
|
||||
country: LocationItem,
|
||||
) => Promise<void>;
|
||||
|
||||
// Team locks
|
||||
isProfileLockedByAnother: (profileId: string) => boolean;
|
||||
getProfileLockEmail: (profileId: string) => string | undefined;
|
||||
};
|
||||
|
||||
type SyncStatusDot = { color: string; tooltip: string; animate: boolean };
|
||||
type SyncStatusDot = {
|
||||
color: string;
|
||||
tooltip: string;
|
||||
animate: boolean;
|
||||
encrypted: boolean;
|
||||
};
|
||||
|
||||
function getProfileSyncStatusDot(
|
||||
profile: BrowserProfile,
|
||||
@@ -214,6 +224,7 @@ function getProfileSyncStatusDot(
|
||||
| undefined,
|
||||
errorMessage?: string,
|
||||
): SyncStatusDot | null {
|
||||
const encrypted = profile.sync_mode === "Encrypted";
|
||||
const status =
|
||||
liveStatus ??
|
||||
(profile.sync_mode && profile.sync_mode !== "Disabled"
|
||||
@@ -222,12 +233,18 @@ function getProfileSyncStatusDot(
|
||||
|
||||
switch (status) {
|
||||
case "syncing":
|
||||
return { color: "bg-yellow-500", tooltip: "Syncing...", animate: true };
|
||||
return {
|
||||
color: "bg-yellow-500",
|
||||
tooltip: "Syncing...",
|
||||
animate: true,
|
||||
encrypted,
|
||||
};
|
||||
case "waiting":
|
||||
return {
|
||||
color: "bg-yellow-500",
|
||||
tooltip: "Waiting to sync",
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
case "synced":
|
||||
return {
|
||||
@@ -236,12 +253,14 @@ function getProfileSyncStatusDot(
|
||||
? `Synced ${new Date(profile.last_sync * 1000).toLocaleString()}`
|
||||
: "Synced",
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
case "error":
|
||||
return {
|
||||
color: "bg-red-500",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
animate: false,
|
||||
encrypted,
|
||||
};
|
||||
case "disabled":
|
||||
if (profile.last_sync) {
|
||||
@@ -249,6 +268,7 @@ function getProfileSyncStatusDot(
|
||||
color: "bg-gray-400",
|
||||
tooltip: `Sync disabled, last sync ${formatRelativeTime(profile.last_sync)}`,
|
||||
animate: false,
|
||||
encrypted: false,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -775,6 +795,8 @@ interface ProfilesDataTableProps {
|
||||
onBulkGroupAssignment?: () => void;
|
||||
onBulkProxyAssignment?: () => void;
|
||||
onBulkCopyCookies?: () => void;
|
||||
onBulkExtensionGroupAssignment?: () => void;
|
||||
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
@@ -800,6 +822,8 @@ export function ProfilesDataTable({
|
||||
onBulkGroupAssignment,
|
||||
onBulkProxyAssignment,
|
||||
onBulkCopyCookies,
|
||||
onBulkExtensionGroupAssignment,
|
||||
onAssignExtensionGroup,
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
crossOsUnlocked = false,
|
||||
@@ -868,6 +892,10 @@ export function ProfilesDataTable({
|
||||
const [profileToDelete, setProfileToDelete] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
const [profileForInfoDialog, setProfileForInfoDialog] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [bypassRulesProfile, setBypassRulesProfile] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [launchingProfiles, setLaunchingProfiles] = React.useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -877,6 +905,8 @@ export function ProfilesDataTable({
|
||||
|
||||
const { storedProxies } = useProxyEvents();
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
const { user } = useCloudAuth();
|
||||
const { isProfileLocked, getLockInfo } = useTeamLocks(user?.id);
|
||||
|
||||
const [proxyOverrides, setProxyOverrides] = React.useState<
|
||||
Record<string, string | null>
|
||||
@@ -1018,8 +1048,9 @@ export function ProfilesDataTable({
|
||||
await invoke("create_cloud_location_proxy", {
|
||||
name: country.name,
|
||||
country: country.code,
|
||||
state: null,
|
||||
region: null,
|
||||
city: null,
|
||||
isp: null,
|
||||
});
|
||||
await emit("stored-proxies-changed");
|
||||
// Wait briefly for proxy list to update, then find and assign the new proxy
|
||||
@@ -1492,6 +1523,11 @@ export function ProfilesDataTable({
|
||||
canCreateLocationProxy,
|
||||
loadCountries,
|
||||
handleCreateCountryProxy,
|
||||
|
||||
// Team locks
|
||||
isProfileLockedByAnother: isProfileLocked,
|
||||
getProfileLockEmail: (profileId: string) =>
|
||||
getLockInfo(profileId)?.lockedByEmail,
|
||||
}),
|
||||
[
|
||||
t,
|
||||
@@ -1544,6 +1580,8 @@ export function ProfilesDataTable({
|
||||
canCreateLocationProxy,
|
||||
loadCountries,
|
||||
handleCreateCountryProxy,
|
||||
isProfileLocked,
|
||||
getLockInfo,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -1586,6 +1624,7 @@ export function ProfilesDataTable({
|
||||
const osName = profile.host_os
|
||||
? getOSDisplayName(profile.host_os)
|
||||
: "another OS";
|
||||
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
|
||||
const OsIcon =
|
||||
profile.host_os === "macos"
|
||||
? FaApple
|
||||
@@ -1610,10 +1649,7 @@ export function ProfilesDataTable({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
This profile was created on {osName} and is not supported on
|
||||
this system
|
||||
</p>
|
||||
<p>{crossOsTooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -1624,14 +1660,10 @@ export function ProfilesDataTable({
|
||||
const osName = profile.host_os
|
||||
? getOSDisplayName(profile.host_os)
|
||||
: "another OS";
|
||||
const crossOsTooltip = t("crossOs.viewOnly", { os: osName });
|
||||
return (
|
||||
<NonHoverableTooltip
|
||||
content={
|
||||
<p>
|
||||
This profile was created on {osName} and is not supported on
|
||||
this system
|
||||
</p>
|
||||
}
|
||||
content={<p>{crossOsTooltip}</p>}
|
||||
sideOffset={4}
|
||||
horizontalOffset={8}
|
||||
>
|
||||
@@ -1734,9 +1766,13 @@ export function ProfilesDataTable({
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const canLaunch = meta.browserState.canLaunchProfile(profile);
|
||||
const tooltipContent =
|
||||
meta.browserState.getLaunchTooltipContent(profile);
|
||||
const isLockedByAnother = meta.isProfileLockedByAnother(profile.id);
|
||||
const canLaunch =
|
||||
meta.browserState.canLaunchProfile(profile) && !isLockedByAnother;
|
||||
const lockEmail = meta.getProfileLockEmail(profile.id);
|
||||
const tooltipContent = isLockedByAnother
|
||||
? meta.t("sync.team.cannotLaunchLocked", { email: lockEmail })
|
||||
: meta.browserState.getLaunchTooltipContent(profile);
|
||||
|
||||
const handleProfileStop = async (profile: BrowserProfile) => {
|
||||
meta.setStoppingProfiles((prev: Set<string>) =>
|
||||
@@ -1900,34 +1936,50 @@ export function ProfilesDataTable({
|
||||
const isStopping = meta.stoppingProfiles.has(profile.id);
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isCrossOs;
|
||||
const lockedEmail = meta.getProfileLockEmail(profile.id);
|
||||
const isLocked = meta.isProfileLockedByAnother(profile.id);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none w-30 h-6",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
meta.setProfileToRename(profile);
|
||||
meta.setNewProfileName(profile.name);
|
||||
meta.setRenameError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"px-2 py-1 mr-auto text-left bg-transparent rounded border-none w-30 h-6",
|
||||
isDisabled
|
||||
? "opacity-60 cursor-not-allowed"
|
||||
: "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (isDisabled) return;
|
||||
meta.setProfileToRename(profile);
|
||||
meta.setNewProfileName(profile.name);
|
||||
meta.setRenameError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
</button>
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (isDisabled) return;
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
meta.setProfileToRename(profile);
|
||||
meta.setNewProfileName(profile.name);
|
||||
meta.setRenameError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
</button>
|
||||
{isLocked && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{meta.t("sync.team.profileLocked", { email: lockedEmail })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -2277,9 +2329,15 @@ export function ProfilesDataTable({
|
||||
<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" : ""}`}
|
||||
/>
|
||||
{dot.encrypted ? (
|
||||
<LuLock
|
||||
className={`w-3 h-3 ${dot.color.replace("bg-", "text-")}${dot.animate ? " animate-pulse" : ""}`}
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`w-2 h-2 rounded-full ${dot.color}${dot.animate ? " animate-pulse" : ""}`}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{dot.tooltip}</TooltipContent>
|
||||
@@ -2289,132 +2347,28 @@ export function ProfilesDataTable({
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
size: 40,
|
||||
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 isDisabled =
|
||||
isRunning || isLaunching || isStopping || isCrossOs;
|
||||
const isDeleteDisabled = isRunning || isLaunching || isStopping;
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 w-8 h-8"
|
||||
disabled={!meta.isClient}
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<IoEllipsisHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onOpenTrafficDialog?.(profile.id);
|
||||
}}
|
||||
disabled={isCrossOs}
|
||||
>
|
||||
{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}
|
||||
>
|
||||
{meta.t("profiles.actions.assignToGroup")}
|
||||
</DropdownMenuItem>
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
meta.onConfigureCamoufox && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onConfigureCamoufox?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
{meta.t("profiles.actions.changeFingerprint")}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
!profile.ephemeral &&
|
||||
meta.onCopyCookiesToProfile && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onCopyCookiesToProfile?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled || !meta.crossOsUnlocked}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{meta.t("profiles.actions.copyCookiesToProfile")}
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</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={isDeleteDisabled}
|
||||
>
|
||||
{meta.t("profiles.actions.delete")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 w-8 h-8"
|
||||
disabled={!meta.isClient}
|
||||
onClick={() => setProfileForInfoDialog(profile)}
|
||||
>
|
||||
<span className="sr-only">Profile info</span>
|
||||
<LuInfo className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[],
|
||||
[t],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
@@ -2456,7 +2410,14 @@ export function ProfilesDataTable({
|
||||
<TableRow key={headerGroup.id} className="overflow-visible">
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<TableHead key={header.id}>
|
||||
<TableHead
|
||||
key={header.id}
|
||||
style={{
|
||||
width: header.column.columnDef.size
|
||||
? `${header.column.getSize()}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
@@ -2471,25 +2432,42 @@ export function ProfilesDataTable({
|
||||
</TableHeader>
|
||||
<TableBody className="overflow-visible">
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className={cn(
|
||||
"overflow-visible hover:bg-accent/50",
|
||||
isCrossOsProfile(row.original) && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id} className="overflow-visible">
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
table.getRowModel().rows.map((row) => {
|
||||
const rowIsCrossOs = isCrossOsProfile(row.original);
|
||||
const crossOsTitle = rowIsCrossOs
|
||||
? t("crossOs.viewOnly", {
|
||||
os: getOSDisplayName(row.original.host_os ?? ""),
|
||||
})
|
||||
: undefined;
|
||||
return (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
title={crossOsTitle}
|
||||
className={cn(
|
||||
"overflow-visible hover:bg-accent/50",
|
||||
rowIsCrossOs && "opacity-60",
|
||||
)}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
className="overflow-visible"
|
||||
style={{
|
||||
width: cell.column.columnDef.size
|
||||
? `${cell.column.getSize()}px`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
@@ -2512,6 +2490,47 @@ export function ProfilesDataTable({
|
||||
confirmButtonText="Delete Profile"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
{profileForInfoDialog &&
|
||||
(() => {
|
||||
const infoProfile = profileForInfoDialog;
|
||||
const infoIsRunning =
|
||||
browserState.isClient && runningProfiles.has(infoProfile.id);
|
||||
const infoIsLaunching = launchingProfiles.has(infoProfile.id);
|
||||
const infoIsStopping = stoppingProfiles.has(infoProfile.id);
|
||||
const infoIsCrossOs = isCrossOsProfile(infoProfile);
|
||||
const infoIsDisabled =
|
||||
infoIsRunning || infoIsLaunching || infoIsStopping || infoIsCrossOs;
|
||||
return (
|
||||
<ProfileInfoDialog
|
||||
isOpen={profileForInfoDialog !== null}
|
||||
onClose={() => setProfileForInfoDialog(null)}
|
||||
profile={infoProfile}
|
||||
storedProxies={storedProxies}
|
||||
vpnConfigs={vpnConfigs}
|
||||
onOpenTrafficDialog={(profileId) => {
|
||||
const profile = profiles.find((p) => p.id === profileId);
|
||||
setTrafficDialogProfile({ id: profileId, name: profile?.name });
|
||||
}}
|
||||
onOpenProfileSyncDialog={onOpenProfileSyncDialog}
|
||||
onAssignProfilesToGroup={onAssignProfilesToGroup}
|
||||
onConfigureCamoufox={onConfigureCamoufox}
|
||||
onCopyCookiesToProfile={onCopyCookiesToProfile}
|
||||
onOpenCookieManagement={onOpenCookieManagement}
|
||||
onAssignExtensionGroup={onAssignExtensionGroup}
|
||||
onOpenBypassRules={(profile) => setBypassRulesProfile(profile)}
|
||||
onCloneProfile={onCloneProfile}
|
||||
onDeleteProfile={(profile) => {
|
||||
setProfileForInfoDialog(null);
|
||||
setProfileToDelete(profile);
|
||||
}}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
isRunning={infoIsRunning}
|
||||
isDisabled={infoIsDisabled}
|
||||
isCrossOs={infoIsCrossOs}
|
||||
syncStatuses={syncStatuses}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
<DataTableActionBar table={table}>
|
||||
<DataTableActionBarSelection table={table} />
|
||||
{onBulkGroupAssignment && (
|
||||
@@ -2532,6 +2551,27 @@ export function ProfilesDataTable({
|
||||
<FiWifi />
|
||||
</DataTableActionBarAction>
|
||||
)}
|
||||
{onBulkExtensionGroupAssignment && (
|
||||
<DataTableActionBarAction
|
||||
tooltip={
|
||||
crossOsUnlocked
|
||||
? "Assign Extension Group"
|
||||
: "Assign Extension Group (Pro)"
|
||||
}
|
||||
onClick={onBulkExtensionGroupAssignment}
|
||||
size="icon"
|
||||
disabled={!crossOsUnlocked}
|
||||
>
|
||||
<span className="relative">
|
||||
<LuPuzzle />
|
||||
{!crossOsUnlocked && (
|
||||
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[6px] font-bold leading-none bg-primary text-primary-foreground px-0.5 rounded-sm">
|
||||
PRO
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</DataTableActionBarAction>
|
||||
)}
|
||||
{onBulkCopyCookies && (
|
||||
<DataTableActionBarAction
|
||||
tooltip={crossOsUnlocked ? "Copy Cookies" : "Copy Cookies (Pro)"}
|
||||
@@ -2539,7 +2579,14 @@ export function ProfilesDataTable({
|
||||
size="icon"
|
||||
disabled={!crossOsUnlocked}
|
||||
>
|
||||
<LuCookie />
|
||||
<span className="relative">
|
||||
<LuCookie />
|
||||
{!crossOsUnlocked && (
|
||||
<span className="absolute -bottom-1.5 left-1/2 -translate-x-1/2 text-[6px] font-bold leading-none bg-primary text-primary-foreground px-0.5 rounded-sm">
|
||||
PRO
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</DataTableActionBarAction>
|
||||
)}
|
||||
{onBulkDelete && (
|
||||
@@ -2562,6 +2609,12 @@ export function ProfilesDataTable({
|
||||
profileName={trafficDialogProfile.name}
|
||||
/>
|
||||
)}
|
||||
<ProfileBypassRulesDialog
|
||||
isOpen={bypassRulesProfile !== null}
|
||||
onClose={() => setBypassRulesProfile(null)}
|
||||
profileId={bypassRulesProfile?.id ?? null}
|
||||
initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,632 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
|
||||
import {
|
||||
LuChevronRight,
|
||||
LuClipboard,
|
||||
LuClipboardCheck,
|
||||
LuCookie,
|
||||
LuCopy,
|
||||
LuFingerprint,
|
||||
LuGlobe,
|
||||
LuGroup,
|
||||
LuPlus,
|
||||
LuPuzzle,
|
||||
LuRefreshCw,
|
||||
LuSettings,
|
||||
LuShieldCheck,
|
||||
LuTrash2,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
getBrowserDisplayName,
|
||||
getOSDisplayName,
|
||||
getProfileIcon,
|
||||
isCrossOsProfile,
|
||||
} from "@/lib/browser-utils";
|
||||
import { formatRelativeTime } from "@/lib/flag-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
ProfileGroup,
|
||||
StoredProxy,
|
||||
VpnConfig,
|
||||
} from "@/types";
|
||||
|
||||
interface ProfileInfoDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
storedProxies: StoredProxy[];
|
||||
vpnConfigs: VpnConfig[];
|
||||
onOpenTrafficDialog?: (profileId: string) => void;
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
onAssignProfilesToGroup?: (profileIds: string[]) => void;
|
||||
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
||||
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
||||
onOpenCookieManagement?: (profile: BrowserProfile) => void;
|
||||
onAssignExtensionGroup?: (profileIds: string[]) => void;
|
||||
onOpenBypassRules?: (profile: BrowserProfile) => void;
|
||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||
onDeleteProfile?: (profile: BrowserProfile) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
isRunning?: boolean;
|
||||
isDisabled?: boolean;
|
||||
isCrossOs?: boolean;
|
||||
syncStatuses: Record<string, { status: string; error?: string }>;
|
||||
}
|
||||
|
||||
function OSIcon({ os }: { os: string }) {
|
||||
switch (os) {
|
||||
case "macos":
|
||||
return <FaApple className="w-3.5 h-3.5" />;
|
||||
case "windows":
|
||||
return <FaWindows className="w-3.5 h-3.5" />;
|
||||
case "linux":
|
||||
return <FaLinux className="w-3.5 h-3.5" />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function InfoCard({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/50 border px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-sm mt-0.5 truncate">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileInfoDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
storedProxies,
|
||||
vpnConfigs,
|
||||
onOpenTrafficDialog,
|
||||
onOpenProfileSyncDialog,
|
||||
onAssignProfilesToGroup,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onOpenCookieManagement,
|
||||
onAssignExtensionGroup,
|
||||
onOpenBypassRules,
|
||||
onCloneProfile,
|
||||
onDeleteProfile,
|
||||
crossOsUnlocked = false,
|
||||
isRunning = false,
|
||||
isDisabled = false,
|
||||
isCrossOs = false,
|
||||
syncStatuses,
|
||||
}: ProfileInfoDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
const [groupName, setGroupName] = React.useState<string | null>(null);
|
||||
const [extensionGroupName, setExtensionGroupName] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !profile?.group_id) {
|
||||
setGroupName(null);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const groups = await invoke<ProfileGroup[]>("get_groups");
|
||||
const group = groups.find((g) => g.id === profile.group_id);
|
||||
setGroupName(group?.name ?? null);
|
||||
} catch {
|
||||
setGroupName(null);
|
||||
}
|
||||
})();
|
||||
}, [isOpen, profile?.group_id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen || !profile?.extension_group_id) {
|
||||
setExtensionGroupName(null);
|
||||
return;
|
||||
}
|
||||
(async () => {
|
||||
try {
|
||||
const group = await invoke<{ name: string } | null>(
|
||||
"get_extension_group_for_profile",
|
||||
{ profileId: profile.id },
|
||||
);
|
||||
setExtensionGroupName(group?.name ?? null);
|
||||
} catch {
|
||||
setExtensionGroupName(null);
|
||||
}
|
||||
})();
|
||||
}, [isOpen, profile?.extension_group_id, profile?.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setCopied(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
const ProfileIcon = getProfileIcon(profile);
|
||||
const isCamoufoxOrWayfern =
|
||||
profile.browser === "camoufox" || profile.browser === "wayfern";
|
||||
const isDeleteDisabled = isRunning;
|
||||
|
||||
const proxyName = profile.proxy_id
|
||||
? storedProxies.find((p) => p.id === profile.proxy_id)?.name
|
||||
: null;
|
||||
const vpnName = profile.vpn_id
|
||||
? vpnConfigs.find((v) => v.id === profile.vpn_id)?.name
|
||||
: null;
|
||||
const networkLabel = vpnName
|
||||
? `VPN: ${vpnName}`
|
||||
: proxyName
|
||||
? `Proxy: ${proxyName}`
|
||||
: t("profileInfo.values.none");
|
||||
|
||||
const syncStatus = syncStatuses[profile.id];
|
||||
const syncMode = profile.sync_mode ?? "Disabled";
|
||||
const syncLabel = syncStatus
|
||||
? `${syncMode} (${syncStatus.status})`
|
||||
: syncMode;
|
||||
|
||||
const handleCopyId = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(profile.id);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleAction = (action: () => void) => {
|
||||
onClose();
|
||||
action();
|
||||
};
|
||||
|
||||
const releaseLabel =
|
||||
profile.release_type.charAt(0).toUpperCase() +
|
||||
profile.release_type.slice(1);
|
||||
const hasTags = profile.tags && profile.tags.length > 0;
|
||||
const hasNote = !!profile.note;
|
||||
const showCrossOs = !!(profile.host_os && isCrossOsProfile(profile));
|
||||
|
||||
type ActionItem = {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
destructive?: boolean;
|
||||
proBadge?: boolean;
|
||||
runningBadge?: boolean;
|
||||
hidden?: boolean;
|
||||
};
|
||||
|
||||
const actions: ActionItem[] = [
|
||||
{
|
||||
icon: <LuGlobe className="w-4 h-4" />,
|
||||
label: t("profiles.actions.viewNetwork"),
|
||||
onClick: () => handleAction(() => onOpenTrafficDialog?.(profile.id)),
|
||||
disabled: isCrossOs,
|
||||
},
|
||||
{
|
||||
icon: <LuRefreshCw className="w-4 h-4" />,
|
||||
label: t("profiles.actions.syncSettings"),
|
||||
onClick: () => handleAction(() => onOpenProfileSyncDialog?.(profile)),
|
||||
disabled: isCrossOs,
|
||||
hidden: profile.ephemeral === true,
|
||||
},
|
||||
{
|
||||
icon: <LuGroup className="w-4 h-4" />,
|
||||
label: t("profiles.actions.assignToGroup"),
|
||||
onClick: () =>
|
||||
handleAction(() => onAssignProfilesToGroup?.([profile.id])),
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
},
|
||||
{
|
||||
icon: <LuFingerprint className="w-4 h-4" />,
|
||||
label: t("profiles.actions.changeFingerprint"),
|
||||
onClick: () => handleAction(() => onConfigureCamoufox?.(profile)),
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
hidden: !isCamoufoxOrWayfern || !onConfigureCamoufox,
|
||||
},
|
||||
{
|
||||
icon: <LuCopy className="w-4 h-4" />,
|
||||
label: t("profiles.actions.copyCookiesToProfile"),
|
||||
onClick: () => handleAction(() => onCopyCookiesToProfile?.(profile)),
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
runningBadge: isRunning && crossOsUnlocked,
|
||||
hidden:
|
||||
!isCamoufoxOrWayfern ||
|
||||
profile.ephemeral === true ||
|
||||
!onCopyCookiesToProfile,
|
||||
},
|
||||
{
|
||||
icon: <LuCookie className="w-4 h-4" />,
|
||||
label: t("profileInfo.actions.manageCookies"),
|
||||
onClick: () => handleAction(() => onOpenCookieManagement?.(profile)),
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
runningBadge: isRunning && crossOsUnlocked,
|
||||
hidden:
|
||||
!isCamoufoxOrWayfern ||
|
||||
profile.ephemeral === true ||
|
||||
!onOpenCookieManagement,
|
||||
},
|
||||
{
|
||||
icon: <LuSettings className="w-4 h-4" />,
|
||||
label: t("profiles.actions.clone"),
|
||||
onClick: () => handleAction(() => onCloneProfile?.(profile)),
|
||||
disabled: isDisabled,
|
||||
runningBadge: isRunning,
|
||||
hidden: profile.ephemeral === true,
|
||||
},
|
||||
{
|
||||
icon: <LuPuzzle className="w-4 h-4" />,
|
||||
label: t("profileInfo.actions.assignExtensionGroup"),
|
||||
onClick: () => handleAction(() => onAssignExtensionGroup?.([profile.id])),
|
||||
disabled: isDisabled || !crossOsUnlocked,
|
||||
proBadge: !crossOsUnlocked,
|
||||
runningBadge: isRunning && crossOsUnlocked,
|
||||
hidden: profile.ephemeral === true,
|
||||
},
|
||||
{
|
||||
icon: <LuShieldCheck className="w-4 h-4" />,
|
||||
label: t("profileInfo.network.bypassRulesTitle"),
|
||||
onClick: () => handleAction(() => onOpenBypassRules?.(profile)),
|
||||
},
|
||||
{
|
||||
icon: <LuTrash2 className="w-4 h-4" />,
|
||||
label: t("profiles.actions.delete"),
|
||||
onClick: () => handleAction(() => onDeleteProfile?.(profile)),
|
||||
disabled: isDeleteDisabled,
|
||||
destructive: true,
|
||||
},
|
||||
];
|
||||
|
||||
const visibleActions = actions.filter((a) => !a.hidden);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("profileInfo.title")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<Tabs defaultValue="info">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="info" className="flex-1">
|
||||
{t("profileInfo.tabs.info")}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="flex-1">
|
||||
{t("profileInfo.tabs.settings")}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="info">
|
||||
<div className="overflow-y-auto max-h-[calc(80vh-12rem)] pr-1">
|
||||
<div className="flex flex-col gap-4 py-3">
|
||||
{/* Hero */}
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-muted p-2.5 shrink-0">
|
||||
<ProfileIcon className="w-8 h-8 text-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3 className="text-base font-semibold truncate">
|
||||
{profile.name}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-1.5 mt-1">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getBrowserDisplayName(profile.browser)}{" "}
|
||||
{profile.version}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{releaseLabel}
|
||||
</Badge>
|
||||
{isRunning && (
|
||||
<Badge className="text-xs bg-primary/15 text-primary border-primary/25">
|
||||
{t("common.status.running")}
|
||||
</Badge>
|
||||
)}
|
||||
{profile.ephemeral && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("profiles.ephemeralBadge")}
|
||||
</Badge>
|
||||
)}
|
||||
{showCrossOs && profile.host_os && (
|
||||
<Badge variant="outline" className="text-xs gap-1">
|
||||
<OSIcon os={profile.host_os} />
|
||||
{getOSDisplayName(profile.host_os)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile ID */}
|
||||
<div className="flex items-center gap-2 rounded-md bg-muted/50 border px-3 py-2">
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
ID
|
||||
</span>
|
||||
<span className="font-mono text-xs truncate flex-1">
|
||||
{profile.id}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleCopyId()}
|
||||
className="text-muted-foreground hover:text-foreground transition-colors shrink-0"
|
||||
>
|
||||
{copied ? (
|
||||
<LuClipboardCheck className="w-3.5 h-3.5" />
|
||||
) : (
|
||||
<LuClipboard className="w-3.5 h-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Network & Organization */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.proxyVpn")}
|
||||
value={networkLabel}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.group")}
|
||||
value={groupName ?? t("profileInfo.values.none")}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.extensionGroup")}
|
||||
value={extensionGroupName ?? t("profileInfo.values.none")}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.lastLaunched")}
|
||||
value={
|
||||
profile.last_launch
|
||||
? formatRelativeTime(profile.last_launch)
|
||||
: t("profileInfo.values.never")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Sync */}
|
||||
<div className="rounded-md bg-muted/50 border px-3 py-2.5 flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profileInfo.fields.syncStatus")}
|
||||
</p>
|
||||
<p className="text-sm mt-0.5">{syncLabel}</p>
|
||||
</div>
|
||||
<Badge
|
||||
variant={syncMode === "Disabled" ? "outline" : "secondary"}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
{syncMode === "Disabled"
|
||||
? t("sync.mode.disabled")
|
||||
: syncStatus?.status === "syncing"
|
||||
? t("common.status.syncing")
|
||||
: t("common.status.synced")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{hasTags && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("profileInfo.fields.tags")}
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{profile.tags?.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="text-xs"
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Note */}
|
||||
{hasNote && (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t("profileInfo.fields.note")}
|
||||
</span>
|
||||
<p className="text-sm rounded-md bg-muted/50 border px-3 py-2 whitespace-pre-wrap break-words">
|
||||
{profile.note}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Team */}
|
||||
{profile.created_by_email && (
|
||||
<div className="rounded-md bg-muted/50 border px-3 py-2.5">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("sync.team.title")}
|
||||
</p>
|
||||
<p className="text-sm mt-0.5">
|
||||
{t("sync.team.createdBy", {
|
||||
email: profile.created_by_email,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<div className="overflow-y-auto max-h-[calc(80vh-12rem)]">
|
||||
<div className="flex flex-col py-1">
|
||||
{visibleActions.map((action) => (
|
||||
<button
|
||||
key={action.label}
|
||||
type="button"
|
||||
disabled={action.disabled}
|
||||
onClick={action.onClick}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2.5 rounded-md text-sm transition-colors text-left w-full",
|
||||
"hover:bg-accent disabled:opacity-50 disabled:pointer-events-none",
|
||||
action.destructive &&
|
||||
"text-destructive hover:bg-destructive/10",
|
||||
)}
|
||||
>
|
||||
{action.icon}
|
||||
<span className="flex-1 flex items-center gap-2">
|
||||
{action.label}
|
||||
{action.runningBadge && (
|
||||
<span className="px-1.5 py-0.5 text-[10px] font-semibold rounded bg-primary/15 text-primary uppercase">
|
||||
{t("common.status.running")}
|
||||
</span>
|
||||
)}
|
||||
{action.proBadge && !action.runningBadge && <ProBadge />}
|
||||
</span>
|
||||
<LuChevronRight className="w-4 h-4 text-muted-foreground" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProfileBypassRulesDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profileId: string | null;
|
||||
initialRules?: string[];
|
||||
}
|
||||
|
||||
export function ProfileBypassRulesDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profileId,
|
||||
initialRules,
|
||||
}: ProfileBypassRulesDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [bypassRules, setBypassRules] = React.useState<string[]>([]);
|
||||
const [newRule, setNewRule] = React.useState("");
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setBypassRules(initialRules ?? []);
|
||||
setNewRule("");
|
||||
}
|
||||
}, [isOpen, initialRules]);
|
||||
|
||||
const updateBypassRules = async (rules: string[]) => {
|
||||
if (!profileId) return;
|
||||
try {
|
||||
await invoke("update_profile_proxy_bypass_rules", {
|
||||
profileId,
|
||||
rules,
|
||||
});
|
||||
setBypassRules(rules);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddRule = () => {
|
||||
const trimmed = newRule.trim();
|
||||
if (!trimmed || bypassRules.includes(trimmed)) return;
|
||||
const updated = [...bypassRules, trimmed];
|
||||
setNewRule("");
|
||||
void updateBypassRules(updated);
|
||||
};
|
||||
|
||||
const handleRemoveRule = (rule: string) => {
|
||||
void updateBypassRules(bypassRules.filter((r) => r !== rule));
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="sm:max-w-lg max-h-[80vh] flex flex-col">
|
||||
<DialogHeader className="shrink-0">
|
||||
<DialogTitle>{t("profileInfo.network.bypassRulesTitle")}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<ScrollArea className="flex-1 min-h-0">
|
||||
<div className="flex flex-col gap-3 py-2">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("profileInfo.network.bypassRulesDescription")}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newRule}
|
||||
onChange={(e) => setNewRule(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleAddRule();
|
||||
}}
|
||||
placeholder={t("profileInfo.network.rulePlaceholder")}
|
||||
className="flex-1 text-sm"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAddRule}
|
||||
disabled={!newRule.trim()}
|
||||
>
|
||||
<LuPlus className="w-4 h-4 mr-1" />
|
||||
{t("profileInfo.network.addRule")}
|
||||
</Button>
|
||||
</div>
|
||||
{bypassRules.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground py-2">
|
||||
{t("profileInfo.network.noRules")}
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{bypassRules.map((rule) => (
|
||||
<div
|
||||
key={rule}
|
||||
className="flex items-center justify-between gap-2 px-3 py-1.5 rounded-md bg-muted text-sm"
|
||||
>
|
||||
<span className="font-mono text-xs truncate">{rule}</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveRule(rule)}
|
||||
className="text-muted-foreground hover:text-destructive transition-colors shrink-0"
|
||||
>
|
||||
<LuX className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("profileInfo.network.ruleTypes")}
|
||||
</p>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
<DialogFooter className="shrink-0">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{t("common.buttons.close")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, SyncMode, SyncSettings } from "@/types";
|
||||
import { isSyncEnabled } from "@/types";
|
||||
@@ -34,40 +35,59 @@ export function ProfileSyncDialog({
|
||||
onSyncConfigOpen,
|
||||
}: ProfileSyncDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const isCloudSyncEligible =
|
||||
cloudUser != null &&
|
||||
cloudUser.plan !== "free" &&
|
||||
(cloudUser.subscriptionStatus === "active" ||
|
||||
cloudUser.planPeriod === "lifetime");
|
||||
const canUseEncryption =
|
||||
isCloudSyncEligible &&
|
||||
cloudUser != null &&
|
||||
(cloudUser.plan !== "team" || cloudUser.teamRole === "owner");
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSyncing, setIsSyncing] = useState(false);
|
||||
const [syncMode, setSyncMode] = useState<SyncMode>(
|
||||
profile?.sync_mode ?? "Disabled",
|
||||
);
|
||||
const [hasConfig, setHasConfig] = useState(false);
|
||||
const [hasSelfHostedConfig, setHasSelfHostedConfig] = useState(false);
|
||||
const [hasE2ePassword, setHasE2ePassword] = useState(false);
|
||||
const [isCheckingConfig, setIsCheckingConfig] = useState(false);
|
||||
const [userChangedMode, setUserChangedMode] = useState(false);
|
||||
|
||||
const hasConfig = isCloudSyncEligible || hasSelfHostedConfig;
|
||||
|
||||
const checkSyncConfig = useCallback(async () => {
|
||||
setIsCheckingConfig(true);
|
||||
try {
|
||||
const settings = await invoke<SyncSettings>("get_sync_settings");
|
||||
setHasConfig(Boolean(settings.sync_server_url && settings.sync_token));
|
||||
setHasSelfHostedConfig(
|
||||
Boolean(settings.sync_server_url && settings.sync_token),
|
||||
);
|
||||
const hasPassword = await invoke<boolean>("check_has_e2e_password");
|
||||
setHasE2ePassword(hasPassword);
|
||||
} catch {
|
||||
setHasConfig(false);
|
||||
setHasSelfHostedConfig(false);
|
||||
} finally {
|
||||
setIsCheckingConfig(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && profile) {
|
||||
setSyncMode(profile.sync_mode ?? "Disabled");
|
||||
setUserChangedMode(false);
|
||||
void checkSyncConfig();
|
||||
}
|
||||
}, [isOpen, profile, checkSyncConfig]);
|
||||
|
||||
const handleOpenChange = useCallback(
|
||||
(open: boolean) => {
|
||||
if (open && profile) {
|
||||
setSyncMode(profile.sync_mode ?? "Disabled");
|
||||
void checkSyncConfig();
|
||||
}
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[profile, onClose, checkSyncConfig],
|
||||
[onClose],
|
||||
);
|
||||
|
||||
const handleModeChange = useCallback(
|
||||
@@ -81,6 +101,11 @@ export function ProfileSyncDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
if (newMode === "Encrypted" && !canUseEncryption) {
|
||||
showErrorToast(t("settings.encryption.requiresProOrOwner"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (newMode === "Encrypted" && !hasE2ePassword) {
|
||||
showErrorToast(t("sync.mode.passwordRequired"));
|
||||
return;
|
||||
@@ -93,6 +118,7 @@ export function ProfileSyncDialog({
|
||||
syncMode: newMode,
|
||||
});
|
||||
setSyncMode(newMode as SyncMode);
|
||||
setUserChangedMode(true);
|
||||
showSuccessToast(
|
||||
newMode !== "Disabled"
|
||||
? t("sync.mode.enabledToast")
|
||||
@@ -105,7 +131,15 @@ export function ProfileSyncDialog({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[profile, hasConfig, hasE2ePassword, onSyncConfigOpen, onClose, t],
|
||||
[
|
||||
profile,
|
||||
hasConfig,
|
||||
hasE2ePassword,
|
||||
canUseEncryption,
|
||||
onSyncConfigOpen,
|
||||
onClose,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const handleSyncNow = useCallback(async () => {
|
||||
@@ -214,29 +248,47 @@ export function ProfileSyncDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex items-start space-x-3">
|
||||
<RadioGroupItem value="Encrypted" id="sync-encrypted" />
|
||||
<Label htmlFor="sync-encrypted" className="cursor-pointer">
|
||||
<RadioGroupItem
|
||||
value="Encrypted"
|
||||
id="sync-encrypted"
|
||||
disabled={!canUseEncryption}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="sync-encrypted"
|
||||
className={
|
||||
canUseEncryption
|
||||
? "cursor-pointer"
|
||||
: "cursor-not-allowed opacity-50"
|
||||
}
|
||||
>
|
||||
<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.",
|
||||
)}
|
||||
{canUseEncryption
|
||||
? t(
|
||||
"sync.mode.encryptedDescription",
|
||||
"Encrypted before upload. Server never sees plaintext data.",
|
||||
)
|
||||
: t(
|
||||
"settings.encryption.requiresProOrOwner",
|
||||
"Profile encryption is available for Pro users and team owners.",
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
{syncMode === "Encrypted" &&
|
||||
!hasE2ePassword &&
|
||||
userChangedMode && (
|
||||
<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>{t("sync.mode.lastSynced", "Last Synced")}</Label>
|
||||
|
||||
@@ -3,9 +3,19 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,14 +26,11 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectLabel,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserProfile, StoredProxy, VpnConfig } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
@@ -51,6 +58,7 @@ export function ProxyAssignmentDialog({
|
||||
"none",
|
||||
);
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
const [proxyPopoverOpen, setProxyPopoverOpen] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleValueChange = useCallback((value: string) => {
|
||||
@@ -126,13 +134,6 @@ export function ProxyAssignmentDialog({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const selectValue =
|
||||
selectionType === "none"
|
||||
? "none"
|
||||
: selectionType === "vpn"
|
||||
? `vpn-${selectedId}`
|
||||
: (selectedId ?? "none");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
@@ -166,43 +167,112 @@ export function ProxyAssignmentDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="proxy-vpn-select">Assign Proxy / VPN:</Label>
|
||||
<Select value={selectValue} onValueChange={handleValueChange}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a proxy or VPN" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<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"
|
||||
<Popover open={proxyPopoverOpen} onOpenChange={setProxyPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={proxyPopoverOpen}
|
||||
className="w-full justify-between font-normal"
|
||||
>
|
||||
{(() => {
|
||||
if (selectionType === "none") return "None";
|
||||
if (selectionType === "vpn") {
|
||||
const vpn = vpnConfigs.find((v) => v.id === selectedId);
|
||||
return vpn
|
||||
? `${vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"} — ${vpn.name}`
|
||||
: "None";
|
||||
}
|
||||
const proxy = storedProxies.find(
|
||||
(p) => p.id === selectedId,
|
||||
);
|
||||
return proxy
|
||||
? `${proxy.name}${proxy.is_cloud_managed ? " (Included)" : ""}`
|
||||
: "None";
|
||||
})()}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[240px] p-0" sideOffset={8}>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search proxies or VPNs..." />
|
||||
<CommandList>
|
||||
<CommandEmpty>No proxies or VPNs found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() => {
|
||||
handleValueChange("none");
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectionType === "none"
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
None
|
||||
</CommandItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<CommandItem
|
||||
key={proxy.id}
|
||||
value={proxy.name}
|
||||
onSelect={() => {
|
||||
handleValueChange(proxy.id);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectionType === "proxy" &&
|
||||
selectedId === proxy.id
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
{proxy.name}
|
||||
{proxy.is_cloud_managed ? " (Included)" : ""}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
{vpnConfigs.length > 0 && (
|
||||
<CommandGroup heading="VPNs">
|
||||
{vpnConfigs.map((vpn) => (
|
||||
<CommandItem
|
||||
key={vpn.id}
|
||||
value={`vpn-${vpn.name}`}
|
||||
onSelect={() => {
|
||||
handleValueChange(`vpn-${vpn.id}`);
|
||||
setProxyPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{vpn.vpn_type === "WireGuard" ? "WG" : "OVPN"}
|
||||
</Badge>
|
||||
{vpn.name}
|
||||
</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectGroup>
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectionType === "vpn" && selectedId === 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>
|
||||
)}
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
|
||||
@@ -53,6 +53,7 @@ type SyncStatus = "disabled" | "syncing" | "synced" | "error" | "waiting";
|
||||
function getSyncStatusDot(
|
||||
item: { sync_enabled?: boolean; last_sync?: number },
|
||||
liveStatus: SyncStatus | undefined,
|
||||
errorMessage?: string,
|
||||
): { color: string; tooltip: string; animate: boolean } {
|
||||
const status = liveStatus ?? (item.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
@@ -74,7 +75,11 @@ function getSyncStatusDot(
|
||||
animate: false,
|
||||
};
|
||||
case "error":
|
||||
return { color: "bg-red-500", tooltip: "Sync error", animate: false };
|
||||
return {
|
||||
color: "bg-red-500",
|
||||
tooltip: errorMessage ? `Sync error: ${errorMessage}` : "Sync error",
|
||||
animate: false,
|
||||
};
|
||||
default:
|
||||
return { color: "bg-gray-400", tooltip: "Not synced", animate: false };
|
||||
}
|
||||
@@ -104,6 +109,9 @@ export function ProxyManagementDialog({
|
||||
const [proxySyncStatus, setProxySyncStatus] = useState<
|
||||
Record<string, SyncStatus>
|
||||
>({});
|
||||
const [proxySyncErrors, setProxySyncErrors] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [proxyInUse, setProxyInUse] = useState<Record<string, boolean>>({});
|
||||
const [isTogglingSync, setIsTogglingSync] = useState<Record<string, boolean>>(
|
||||
{},
|
||||
@@ -119,6 +127,9 @@ export function ProxyManagementDialog({
|
||||
const [vpnSyncStatus, setVpnSyncStatus] = useState<
|
||||
Record<string, SyncStatus>
|
||||
>({});
|
||||
const [vpnSyncErrors, setVpnSyncErrors] = useState<Record<string, string>>(
|
||||
{},
|
||||
);
|
||||
const [vpnInUse, setVpnInUse] = useState<Record<string, boolean>>({});
|
||||
const [isTogglingVpnSync, setIsTogglingVpnSync] = useState<
|
||||
Record<string, boolean>
|
||||
@@ -126,50 +137,30 @@ export function ProxyManagementDialog({
|
||||
|
||||
const { storedProxies: rawProxies, proxyUsage, isLoading } = useProxyEvents();
|
||||
const { vpnConfigs, vpnUsage, isLoading: isLoadingVpns } = useVpnEvents();
|
||||
const [cloudProxyUsage, setCloudProxyUsage] = useState<{
|
||||
used_mb: number;
|
||||
limit_mb: number;
|
||||
} | null>(null);
|
||||
|
||||
// Sort cloud-managed proxies first
|
||||
const storedProxies = [...rawProxies].sort((a, b) => {
|
||||
if (a.is_cloud_managed && !b.is_cloud_managed) return -1;
|
||||
if (!a.is_cloud_managed && b.is_cloud_managed) return 1;
|
||||
return a.name.toLowerCase().localeCompare(b.name.toLowerCase());
|
||||
});
|
||||
|
||||
// Fetch cloud proxy usage
|
||||
useEffect(() => {
|
||||
const fetchUsage = async () => {
|
||||
try {
|
||||
const usage = await invoke<{
|
||||
used_mb: number;
|
||||
limit_mb: number;
|
||||
remaining_mb: number;
|
||||
} | null>("cloud_get_proxy_usage");
|
||||
setCloudProxyUsage(usage);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
if (isOpen) {
|
||||
void fetchUsage();
|
||||
}
|
||||
}, [isOpen]);
|
||||
// Filter out the base cloud-managed proxy (it's an internal indicator, not user-facing)
|
||||
// Keep cloud-derived location proxies
|
||||
const storedProxies = rawProxies
|
||||
.filter((p) => !p.is_cloud_managed)
|
||||
.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase()));
|
||||
const hasCloudProxy = rawProxies.some((p) => p.is_cloud_managed);
|
||||
|
||||
// Listen for proxy sync status events
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ id: string; status: string }>(
|
||||
unlisten = await listen<{ id: string; status: string; error?: string }>(
|
||||
"proxy-sync-status",
|
||||
(event) => {
|
||||
const { id, status } = event.payload;
|
||||
const { id, status, error } = event.payload;
|
||||
setProxySyncStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: status as SyncStatus,
|
||||
}));
|
||||
if (error) {
|
||||
setProxySyncErrors((prev) => ({ ...prev, [id]: error }));
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -185,14 +176,17 @@ export function ProxyManagementDialog({
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setupListener = async () => {
|
||||
unlisten = await listen<{ id: string; status: string }>(
|
||||
unlisten = await listen<{ id: string; status: string; error?: string }>(
|
||||
"vpn-sync-status",
|
||||
(event) => {
|
||||
const { id, status } = event.payload;
|
||||
const { id, status, error } = event.payload;
|
||||
setVpnSyncStatus((prev) => ({
|
||||
...prev,
|
||||
[id]: status as SyncStatus,
|
||||
}));
|
||||
if (error) {
|
||||
setVpnSyncErrors((prev) => ({ ...prev, [id]: error }));
|
||||
}
|
||||
},
|
||||
);
|
||||
};
|
||||
@@ -370,7 +364,7 @@ export function ProxyManagementDialog({
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Proxies & VPNs</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -378,96 +372,96 @@ export function ProxyManagementDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="proxies">
|
||||
<TabsList className="w-full">
|
||||
<TabsTrigger value="proxies" className="flex-1">
|
||||
Proxies
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vpns" className="flex-1">
|
||||
VPNs
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
<ScrollArea className="overflow-y-auto flex-1">
|
||||
<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) && (
|
||||
<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={() => setShowLocationDialog(true)}
|
||||
onClick={() => setShowImportDialog(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoGlobe className="w-4 h-4" />
|
||||
Location
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
</RippleButton>
|
||||
)}
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</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">
|
||||
{hasCloudProxy && (
|
||||
<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>
|
||||
</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">
|
||||
{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 syncDot = getSyncStatusDot(
|
||||
proxy,
|
||||
proxySyncStatus[proxy.id],
|
||||
proxySyncErrors[proxy.id],
|
||||
);
|
||||
const isDerived = proxy.is_cloud_derived === true;
|
||||
return (
|
||||
<TableRow key={proxy.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
{isDerived && proxy.geo_country && (
|
||||
<FlagIcon
|
||||
@@ -475,7 +469,7 @@ export function ProxyManagementDialog({
|
||||
className="shrink-0"
|
||||
/>
|
||||
)}
|
||||
{!isCloud && !isDerived && (
|
||||
{!isDerived && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div
|
||||
@@ -493,23 +487,13 @@ export function ProxyManagementDialog({
|
||||
)}
|
||||
{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>
|
||||
) : (
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center">
|
||||
@@ -540,48 +524,50 @@ export function ProxyManagementDialog({
|
||||
)}
|
||||
</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 && (
|
||||
</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,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
{!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>
|
||||
)}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
@@ -613,199 +599,202 @@ export function ProxyManagementDialog({
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="vpns">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
<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={() => setShowVpnImportDialog(true)}
|
||||
onClick={handleCreateVpn}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUpload className="w-4 h-4" />
|
||||
Import
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={handleCreateVpn}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{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">
|
||||
{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],
|
||||
vpnSyncErrors[vpn.id],
|
||||
);
|
||||
return (
|
||||
<TableRow key={vpn.id}>
|
||||
<TableCell className="font-medium">
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
{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>
|
||||
<div
|
||||
className={`w-2 h-2 rounded-full shrink-0 ${syncDot.color} ${
|
||||
syncDot.animate
|
||||
? "animate-pulse"
|
||||
: ""
|
||||
}`}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{syncDot.tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{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>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={vpn.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleVpnSync(vpn)
|
||||
}
|
||||
disabled={
|
||||
isTogglingVpnSync[vpn.id] ||
|
||||
vpnInUse[vpn.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{vpnInUse[vpn.id] ? (
|
||||
<p>
|
||||
Sync cannot be disabled while this VPN
|
||||
is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
{vpn.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</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
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
checked={vpn.sync_enabled}
|
||||
onCheckedChange={() =>
|
||||
handleToggleVpnSync(vpn)
|
||||
}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
disabled={
|
||||
isTogglingVpnSync[vpn.id] ||
|
||||
vpnInUse[vpn.id]
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(vpnUsage[vpn.id] ?? 0) > 0 ? (
|
||||
{vpnInUse[vpn.id] ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{vpnUsage[vpn.id]} profile
|
||||
{vpnUsage[vpn.id] > 1 ? "s" : ""}
|
||||
Sync cannot be disabled while this
|
||||
VPN is used by synced profiles
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete VPN</p>
|
||||
<p>
|
||||
{vpn.sync_enabled
|
||||
? "Disable sync"
|
||||
: "Enable sync"}
|
||||
</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</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>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
|
||||
@@ -9,6 +9,7 @@ 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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ColorPicker,
|
||||
ColorPickerAlpha,
|
||||
@@ -39,6 +40,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { useCloudAuth } from "@/hooks/use-cloud-auth";
|
||||
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
|
||||
import { useLanguage } from "@/hooks/use-language";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
@@ -59,6 +61,7 @@ interface AppSettings {
|
||||
api_enabled: boolean;
|
||||
api_port: number;
|
||||
api_token?: string;
|
||||
disable_auto_updates?: boolean;
|
||||
}
|
||||
|
||||
interface CustomThemeState {
|
||||
@@ -115,6 +118,7 @@ export function SettingsDialog({
|
||||
const [requestingPermission, setRequestingPermission] =
|
||||
useState<PermissionType | null>(null);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [isLinux, setIsLinux] = useState(false);
|
||||
const [hasE2ePassword, setHasE2ePassword] = useState(false);
|
||||
const [e2ePassword, setE2ePassword] = useState("");
|
||||
const [e2ePasswordConfirm, setE2ePasswordConfirm] = useState("");
|
||||
@@ -129,6 +133,13 @@ export function SettingsDialog({
|
||||
isCameraAccessGranted,
|
||||
} = usePermissions();
|
||||
const { trialStatus } = useCommercialTrial();
|
||||
const { user: cloudUser } = useCloudAuth();
|
||||
const canUseEncryption =
|
||||
cloudUser != null &&
|
||||
cloudUser.plan !== "free" &&
|
||||
(cloudUser.subscriptionStatus === "active" ||
|
||||
cloudUser.planPeriod === "lifetime") &&
|
||||
(cloudUser.plan !== "team" || cloudUser.teamRole === "owner");
|
||||
const {
|
||||
currentLanguage,
|
||||
changeLanguage,
|
||||
@@ -478,6 +489,8 @@ export function SettingsDialog({
|
||||
const userAgent = navigator.userAgent;
|
||||
const isMac = userAgent.includes("Mac");
|
||||
setIsMacOS(isMac);
|
||||
const isLin = !userAgent.includes("Mac") && !userAgent.includes("Win");
|
||||
setIsLinux(isLin);
|
||||
|
||||
if (isMac) {
|
||||
loadPermissions().catch(console.error);
|
||||
@@ -539,7 +552,8 @@ export function SettingsDialog({
|
||||
JSON.stringify(originalSettings.custom_theme ?? {})) ||
|
||||
(settings.theme !== "custom" &&
|
||||
JSON.stringify(settings.custom_theme ?? {}) !==
|
||||
JSON.stringify(originalSettings.custom_theme ?? {}));
|
||||
JSON.stringify(originalSettings.custom_theme ?? {})) ||
|
||||
settings.disable_auto_updates !== originalSettings.disable_auto_updates;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
@@ -853,7 +867,14 @@ export function SettingsDialog({
|
||||
)}
|
||||
</p>
|
||||
|
||||
{hasE2ePassword ? (
|
||||
{!canUseEncryption ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"settings.encryption.requiresProOrOwner",
|
||||
"Profile encryption is available for Pro users and team owners.",
|
||||
)}
|
||||
</p>
|
||||
) : hasE2ePassword ? (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="default">
|
||||
@@ -1013,6 +1034,29 @@ export function SettingsDialog({
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Advanced</Label>
|
||||
|
||||
{!isLinux && (
|
||||
<div className="flex items-start space-x-3 p-3 rounded-lg border">
|
||||
<Checkbox
|
||||
id="disable-auto-updates"
|
||||
checked={settings.disable_auto_updates || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateSetting("disable_auto_updates", checked as boolean)
|
||||
}
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<Label
|
||||
htmlFor="disable-auto-updates"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("settings.disableAutoUpdates")}
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.disableAutoUpdatesDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<LoadingButton
|
||||
isLoading={isClearingCache}
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import MultipleSelector, { type Option } from "@/components/multiple-selector";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -33,6 +35,8 @@ interface SharedCamoufoxConfigFormProps {
|
||||
browserType?: "camoufox" | "wayfern"; // Browser type to customize form options
|
||||
crossOsUnlocked?: boolean; // Allow selecting non-current OS (paid feature)
|
||||
limitedMode?: boolean; // Blur and disable advanced fields while keeping basic options accessible
|
||||
profileVersion?: string;
|
||||
profileBrowser?: string;
|
||||
}
|
||||
|
||||
// Determine if fingerprint editing should be disabled
|
||||
@@ -124,6 +128,8 @@ export function SharedCamoufoxConfigForm({
|
||||
browserType = "camoufox",
|
||||
crossOsUnlocked = false,
|
||||
limitedMode = false,
|
||||
profileVersion,
|
||||
profileBrowser,
|
||||
}: SharedCamoufoxConfigFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
@@ -132,6 +138,26 @@ export function SharedCamoufoxConfigForm({
|
||||
const [fingerprintConfig, setFingerprintConfig] =
|
||||
useState<CamoufoxFingerprintConfig>({});
|
||||
const [currentOS] = useState<CamoufoxOS>(getCurrentOS);
|
||||
const [isGeneratingFingerprint, setIsGeneratingFingerprint] = useState(false);
|
||||
|
||||
const handleGenerateFingerprint = async () => {
|
||||
if (!profileVersion) return;
|
||||
const browser = profileBrowser || browserType || "camoufox";
|
||||
setIsGeneratingFingerprint(true);
|
||||
try {
|
||||
const configJson = JSON.stringify(config);
|
||||
const result = await invoke<string>("generate_sample_fingerprint", {
|
||||
browser,
|
||||
version: profileVersion,
|
||||
configJson,
|
||||
});
|
||||
onConfigChange("fingerprint", result);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate fingerprint:", error);
|
||||
} finally {
|
||||
setIsGeneratingFingerprint(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Get selected OS (defaults to current OS)
|
||||
const selectedOS = config.os || currentOS;
|
||||
@@ -223,7 +249,22 @@ export function SharedCamoufoxConfigForm({
|
||||
<div className="space-y-6">
|
||||
{/* Operating System Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.osLabel")}</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("fingerprint.osLabel")}</Label>
|
||||
{profileVersion && (!isCreating || crossOsUnlocked) && (
|
||||
<LoadingButton
|
||||
isLoading={isGeneratingFingerprint}
|
||||
onClick={handleGenerateFingerprint}
|
||||
disabled={readOnly}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{isCreating
|
||||
? t("fingerprint.generateFingerprint")
|
||||
: t("fingerprint.refreshFingerprint")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={selectedOS}
|
||||
onValueChange={(value: CamoufoxOS) => onConfigChange("os", value)}
|
||||
@@ -1046,18 +1087,19 @@ export function SharedCamoufoxConfigForm({
|
||||
</fieldset>
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[2]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("fingerprint.proFeature")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[1]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[1]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[1]" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1253,18 +1295,19 @@ export function SharedCamoufoxConfigForm({
|
||||
</fieldset>
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[2]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("fingerprint.proFeature")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[1]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[1]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[1]" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,14 @@ interface SyncConfigDialogProps {
|
||||
onClose: (loginOccurred?: boolean) => void;
|
||||
}
|
||||
|
||||
interface ProxyUsage {
|
||||
used_mb: number;
|
||||
limit_mb: number;
|
||||
remaining_mb: number;
|
||||
recurring_limit_mb: number;
|
||||
extra_limit_mb: number;
|
||||
}
|
||||
|
||||
export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -59,6 +67,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
const [isVerifying, setIsVerifying] = useState(false);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<string>("cloud");
|
||||
const [liveProxyUsage, setLiveProxyUsage] = useState<ProxyUsage | null>(null);
|
||||
|
||||
const [connectionStatus, setConnectionStatus] = useState<
|
||||
"unknown" | "testing" | "connected" | "error"
|
||||
@@ -99,6 +108,9 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
setCodeSent(false);
|
||||
setOtpCode("");
|
||||
setEmail("");
|
||||
invoke<ProxyUsage | null>("cloud_get_proxy_usage")
|
||||
.then(setLiveProxyUsage)
|
||||
.catch(() => setLiveProxyUsage(null));
|
||||
}
|
||||
}, [isOpen, loadSettings]);
|
||||
|
||||
@@ -288,14 +300,64 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
{user.proxyBandwidthLimitMb > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Proxy Bandwidth</span>
|
||||
<span>
|
||||
{user.proxyBandwidthUsedMb} / {user.proxyBandwidthLimitMb}{" "}
|
||||
MB
|
||||
</span>
|
||||
</div>
|
||||
{liveProxyUsage && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Recurring Proxy Bandwidth
|
||||
</span>
|
||||
<span>
|
||||
{Math.max(
|
||||
0,
|
||||
liveProxyUsage.recurring_limit_mb -
|
||||
liveProxyUsage.used_mb,
|
||||
)}{" "}
|
||||
/ {liveProxyUsage.recurring_limit_mb} MB remaining
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
Extra Proxy Bandwidth
|
||||
</span>
|
||||
<span>
|
||||
{Math.max(
|
||||
0,
|
||||
liveProxyUsage.remaining_mb -
|
||||
Math.max(
|
||||
0,
|
||||
liveProxyUsage.recurring_limit_mb -
|
||||
liveProxyUsage.used_mb,
|
||||
),
|
||||
)}{" "}
|
||||
/ {liveProxyUsage.extra_limit_mb} MB remaining
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{user.teamName && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("sync.team.name")}
|
||||
</span>
|
||||
<span>{user.teamName}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">
|
||||
{t("sync.team.role")}
|
||||
</span>
|
||||
<span className="capitalize">
|
||||
{user.teamRole === "owner"
|
||||
? t("sync.team.roleOwner")
|
||||
: user.teamRole === "admin"
|
||||
? t("sync.team.roleAdmin")
|
||||
: t("sync.team.roleMember")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground pt-1">
|
||||
{t("sync.team.manageOnWeb")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ import {
|
||||
XAxis,
|
||||
YAxis,
|
||||
} from "recharts";
|
||||
import type {
|
||||
NameType,
|
||||
ValueType,
|
||||
} from "recharts/types/component/DefaultTooltipContent";
|
||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -192,7 +196,7 @@ export function TrafficDetailsDialog({
|
||||
|
||||
// Tooltip render function
|
||||
const renderTooltip = React.useCallback(
|
||||
(props: TooltipContentProps<number, string>) => {
|
||||
(props: TooltipContentProps<ValueType, NameType>) => {
|
||||
const { active, payload, label } = props;
|
||||
if (!active || !payload?.length) return null;
|
||||
|
||||
|
||||
@@ -6,7 +6,10 @@ import type {
|
||||
Props as DefaultLegendContentProps,
|
||||
LegendPayload,
|
||||
} from "recharts/types/component/DefaultLegendContent";
|
||||
import type { Payload } from "recharts/types/component/DefaultTooltipContent";
|
||||
import type {
|
||||
NameType,
|
||||
ValueType,
|
||||
} from "recharts/types/component/DefaultTooltipContent";
|
||||
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -111,7 +114,7 @@ const ChartTooltip = RechartsPrimitive.Tooltip;
|
||||
|
||||
const ChartTooltipContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
TooltipContentProps<number, string> &
|
||||
TooltipContentProps<ValueType, NameType> &
|
||||
React.ComponentProps<"div"> & {
|
||||
hideLabel?: boolean;
|
||||
hideIndicator?: boolean;
|
||||
@@ -195,8 +198,8 @@ const ChartTooltipContent = React.forwardRef<
|
||||
{!nestLabel ? tooltipLabel : null}
|
||||
<div className="grid gap-1.5">
|
||||
{payload
|
||||
.filter((item: Payload<number, string>) => item.type !== "none")
|
||||
.map((item: Payload<number, string>, index: number) => {
|
||||
.filter((item) => item.type !== "none")
|
||||
.map((item, index) => {
|
||||
const key = `${nameKey || item.name || item.dataKey || "value"}`;
|
||||
const itemConfig = getPayloadConfigFromPayload(config, item, key);
|
||||
const indicatorColor = color || item.payload?.fill || item.color;
|
||||
|
||||
@@ -32,6 +32,7 @@ interface ComboboxProps {
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
className?: string;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
@@ -41,16 +42,18 @@ export function Combobox({
|
||||
placeholder = "Select option...",
|
||||
searchPlaceholder = "Search...",
|
||||
className,
|
||||
disabled,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<Popover open={open} onOpenChange={disabled ? undefined : setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn("w-full justify-between", className)}
|
||||
>
|
||||
{value
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -31,6 +33,8 @@ interface WayfernConfigFormProps {
|
||||
readOnly?: boolean;
|
||||
crossOsUnlocked?: boolean;
|
||||
limitedMode?: boolean;
|
||||
profileVersion?: string;
|
||||
profileBrowser?: string;
|
||||
}
|
||||
|
||||
const isFingerprintEditingDisabled = (config: WayfernConfig): boolean => {
|
||||
@@ -62,6 +66,8 @@ export function WayfernConfigForm({
|
||||
readOnly = false,
|
||||
crossOsUnlocked = false,
|
||||
limitedMode = false,
|
||||
profileVersion,
|
||||
profileBrowser,
|
||||
}: WayfernConfigFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
@@ -70,6 +76,25 @@ export function WayfernConfigForm({
|
||||
const [fingerprintConfig, setFingerprintConfig] =
|
||||
useState<WayfernFingerprintConfig>({});
|
||||
const [currentOS] = useState<WayfernOS>(getCurrentOS);
|
||||
const [isGeneratingFingerprint, setIsGeneratingFingerprint] = useState(false);
|
||||
|
||||
const handleGenerateFingerprint = async () => {
|
||||
if (!profileVersion) return;
|
||||
setIsGeneratingFingerprint(true);
|
||||
try {
|
||||
const configJson = JSON.stringify(config);
|
||||
const result = await invoke<string>("generate_sample_fingerprint", {
|
||||
browser: profileBrowser || "wayfern",
|
||||
version: profileVersion,
|
||||
configJson,
|
||||
});
|
||||
onConfigChange("fingerprint", result);
|
||||
} catch (error) {
|
||||
console.error("Failed to generate fingerprint:", error);
|
||||
} finally {
|
||||
setIsGeneratingFingerprint(false);
|
||||
}
|
||||
};
|
||||
|
||||
const selectedOS = config.os || currentOS;
|
||||
|
||||
@@ -150,7 +175,22 @@ export function WayfernConfigForm({
|
||||
<div className="space-y-6">
|
||||
{/* Operating System Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label>{t("fingerprint.osLabel")}</Label>
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>{t("fingerprint.osLabel")}</Label>
|
||||
{profileVersion && (!isCreating || crossOsUnlocked) && (
|
||||
<LoadingButton
|
||||
isLoading={isGeneratingFingerprint}
|
||||
onClick={handleGenerateFingerprint}
|
||||
disabled={readOnly}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
{isCreating
|
||||
? t("fingerprint.generateFingerprint")
|
||||
: t("fingerprint.refreshFingerprint")}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
<Select
|
||||
value={selectedOS}
|
||||
onValueChange={(value: WayfernOS) => onConfigChange("os", value)}
|
||||
@@ -998,18 +1038,19 @@ export function WayfernConfigForm({
|
||||
</fieldset>
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[2]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("fingerprint.proFeature")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[1]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[1]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[1]" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -1212,18 +1253,19 @@ export function WayfernConfigForm({
|
||||
</fieldset>
|
||||
{limitedMode && (
|
||||
<>
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[2]">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="absolute inset-0 backdrop-blur-[6px] bg-background/30 z-[1]" />
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 top-0 h-6 bg-gradient-to-b from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[2]" />
|
||||
<div className="absolute inset-0 flex items-center justify-center z-[3]">
|
||||
<div className="flex items-center gap-2 rounded-md bg-background/80 px-3 py-1.5">
|
||||
<ProBadge />
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{t("fingerprint.proFeature")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute inset-y-0 left-0 w-6 bg-gradient-to-r from-background to-transparent z-[1]" />
|
||||
<div className="absolute inset-y-0 right-0 w-6 bg-gradient-to-l from-background to-transparent z-[1]" />
|
||||
<div className="absolute inset-x-0 bottom-0 h-6 bg-gradient-to-t from-background to-transparent z-[1]" />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
@@ -17,15 +17,23 @@ import { Label } from "@/components/ui/label";
|
||||
interface WindowResizeWarningDialogProps {
|
||||
isOpen: boolean;
|
||||
onResult: (proceed: boolean) => void;
|
||||
browserType?: string;
|
||||
}
|
||||
|
||||
export function WindowResizeWarningDialog({
|
||||
isOpen,
|
||||
onResult,
|
||||
browserType,
|
||||
}: WindowResizeWarningDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dontShowAgain, setDontShowAgain] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setDontShowAgain(false);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (dontShowAgain) {
|
||||
try {
|
||||
@@ -41,6 +49,16 @@ export function WindowResizeWarningDialog({
|
||||
onResult(false);
|
||||
};
|
||||
|
||||
const isCamoufox = browserType === "camoufox";
|
||||
|
||||
const title = isCamoufox
|
||||
? t("warnings.windowResizeCamoufoxTitle")
|
||||
: t("warnings.windowResizeTitle");
|
||||
|
||||
const description = isCamoufox
|
||||
? t("warnings.windowResizeCamoufoxDescription")
|
||||
: t("warnings.windowResizeDescription");
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen}>
|
||||
<DialogContent
|
||||
@@ -50,12 +68,10 @@ export function WindowResizeWarningDialog({
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("warnings.windowResizeTitle")}</DialogTitle>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("warnings.windowResizeDescription")}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
|
||||
@@ -49,7 +49,9 @@ export function useBrowserDownload() {
|
||||
const [availableVersions, setAvailableVersions] = useState<GithubRelease[]>(
|
||||
[],
|
||||
);
|
||||
const [downloadedVersions, setDownloadedVersions] = useState<string[]>([]);
|
||||
const [downloadedVersionsMap, setDownloadedVersionsMap] = useState<
|
||||
Record<string, string[]>
|
||||
>({});
|
||||
const [downloadingBrowsers, setDownloadingBrowsers] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -166,12 +168,12 @@ export function useBrowserDownload() {
|
||||
|
||||
const loadDownloadedVersions = useCallback(async (browserStr: string) => {
|
||||
try {
|
||||
const downloadedVersions = await invoke<string[]>(
|
||||
const versions = await invoke<string[]>(
|
||||
"get_downloaded_browser_versions",
|
||||
{ browserStr },
|
||||
);
|
||||
setDownloadedVersions(downloadedVersions);
|
||||
return downloadedVersions;
|
||||
setDownloadedVersionsMap((prev) => ({ ...prev, [browserStr]: versions }));
|
||||
return versions;
|
||||
} catch (error) {
|
||||
console.error("Failed to load downloaded versions:", error);
|
||||
throw error;
|
||||
@@ -243,9 +245,11 @@ export function useBrowserDownload() {
|
||||
|
||||
const isVersionDownloaded = useCallback(
|
||||
(version: string) => {
|
||||
return downloadedVersions.includes(version);
|
||||
return Object.values(downloadedVersionsMap).some((versions) =>
|
||||
versions.includes(version),
|
||||
);
|
||||
},
|
||||
[downloadedVersions],
|
||||
[downloadedVersionsMap],
|
||||
);
|
||||
|
||||
// Check if a browser type is currently downloading
|
||||
@@ -434,7 +438,7 @@ export function useBrowserDownload() {
|
||||
|
||||
return {
|
||||
availableVersions,
|
||||
downloadedVersions,
|
||||
downloadedVersionsMap,
|
||||
isDownloading,
|
||||
isBrowserDownloading,
|
||||
downloadingBrowsers,
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { Extension, ExtensionGroup } from "@/types";
|
||||
|
||||
export function useExtensionEvents() {
|
||||
const [extensions, setExtensions] = useState<Extension[]>([]);
|
||||
const [extensionGroups, setExtensionGroups] = useState<ExtensionGroup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadExtensions = useCallback(async () => {
|
||||
try {
|
||||
const exts = await invoke<Extension[]>("list_extensions");
|
||||
setExtensions(exts);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load extensions:", err);
|
||||
setExtensions([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadExtensionGroups = useCallback(async () => {
|
||||
try {
|
||||
const groups = await invoke<ExtensionGroup[]>("list_extension_groups");
|
||||
setExtensionGroups(groups);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load extension groups:", err);
|
||||
setExtensionGroups([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
await Promise.all([loadExtensions(), loadExtensionGroups()]);
|
||||
}, [loadExtensions, loadExtensionGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
await loadAll();
|
||||
unlisten = await listen("extensions-changed", () => {
|
||||
void loadAll();
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("Failed to setup extension event listeners:", err);
|
||||
setError(
|
||||
`Failed to setup extension event listeners: ${JSON.stringify(err)}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void setup();
|
||||
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [loadAll]);
|
||||
|
||||
return {
|
||||
extensions,
|
||||
extensionGroups,
|
||||
isLoading,
|
||||
error,
|
||||
loadExtensions,
|
||||
loadExtensionGroups,
|
||||
loadAll,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ProfileLockInfo } from "@/types";
|
||||
|
||||
export function useTeamLocks(currentUserId?: string) {
|
||||
const [locks, setLocks] = useState<ProfileLockInfo[]>([]);
|
||||
|
||||
const fetchLocks = useCallback(async () => {
|
||||
try {
|
||||
const result = await invoke<ProfileLockInfo[]>("get_team_locks");
|
||||
setLocks(result);
|
||||
} catch {
|
||||
// Not connected to a team or not logged in
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchLocks();
|
||||
|
||||
const unlistenAcquired = listen<{ profileId: string }>(
|
||||
"team-lock-acquired",
|
||||
() => fetchLocks(),
|
||||
);
|
||||
const unlistenReleased = listen<{ profileId: string }>(
|
||||
"team-lock-released",
|
||||
() => fetchLocks(),
|
||||
);
|
||||
|
||||
return () => {
|
||||
unlistenAcquired.then((fn) => fn());
|
||||
unlistenReleased.then((fn) => fn());
|
||||
};
|
||||
}, [fetchLocks]);
|
||||
|
||||
const isProfileLocked = useCallback(
|
||||
(profileId: string): boolean => {
|
||||
const lock = locks.find((l) => l.profileId === profileId);
|
||||
if (!lock) return false;
|
||||
if (currentUserId && lock.lockedBy === currentUserId) return false;
|
||||
return true;
|
||||
},
|
||||
[locks, currentUserId],
|
||||
);
|
||||
|
||||
const getLockInfo = useCallback(
|
||||
(profileId: string): ProfileLockInfo | undefined => {
|
||||
return locks.find((l) => l.profileId === profileId);
|
||||
},
|
||||
[locks],
|
||||
);
|
||||
|
||||
return { locks, isProfileLocked, getLockInfo, refetchLocks: fetchLocks };
|
||||
}
|
||||
+138
-6
@@ -120,7 +120,8 @@
|
||||
"removed": "Encryption password removed",
|
||||
"passwordSaved": "Encryption password set",
|
||||
"passwordMismatch": "Passwords do not match",
|
||||
"passwordTooShort": "Password must be at least 8 characters"
|
||||
"passwordTooShort": "Password must be at least 8 characters",
|
||||
"requiresProOrOwner": "Profile encryption is available for Pro users and team owners."
|
||||
},
|
||||
"commercial": {
|
||||
"title": "Commercial License",
|
||||
@@ -133,7 +134,9 @@
|
||||
"title": "Advanced",
|
||||
"clearCache": "Clear All Version Cache",
|
||||
"clearCacheDescription": "Clear all cached browser version data and refresh all browser versions from their sources. This will force a fresh download of version information for all browsers."
|
||||
}
|
||||
},
|
||||
"disableAutoUpdates": "Disable App Auto Updates",
|
||||
"disableAutoUpdatesDescription": "Prevent the app from automatically checking and installing Donut Browser updates. Browser updates are not affected."
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Search profiles...",
|
||||
@@ -146,7 +149,8 @@
|
||||
"groups": "Groups",
|
||||
"syncService": "Account",
|
||||
"integrations": "Integrations",
|
||||
"importProfile": "Import Profile"
|
||||
"importProfile": "Import Profile",
|
||||
"extensions": "Extensions"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
@@ -187,7 +191,7 @@
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Create New Profile",
|
||||
"configureTitle": "Configure Profile",
|
||||
"configureTitle": "Create New {{browser}} Profile",
|
||||
"antiDetect": {
|
||||
"title": "Anti-Detect Browser",
|
||||
"description": "Choose a browser with anti-detection capabilities",
|
||||
@@ -217,7 +221,12 @@
|
||||
"latestNeedsDownload": "Latest version ({{version}}) needs to be downloaded",
|
||||
"latestAvailable": "Latest version ({{version}}) is available",
|
||||
"latestDownloading": "Downloading version ({{version}})..."
|
||||
}
|
||||
},
|
||||
"chromiumLabel": "Chromium",
|
||||
"chromiumSubtitle": "Powered by Wayfern",
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Powered by Camoufox",
|
||||
"camoufoxWarning": "Firefox (Camoufox) is maintained by a third-party organization. For production use, please use Chromium."
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Delete Profile",
|
||||
@@ -339,6 +348,19 @@
|
||||
"logoutConfirm": "Are you sure you want to log out? Cloud sync will stop.",
|
||||
"loginSuccess": "Successfully logged in!",
|
||||
"logoutSuccess": "Successfully logged out."
|
||||
},
|
||||
"team": {
|
||||
"title": "Team",
|
||||
"name": "Team Name",
|
||||
"role": "Role",
|
||||
"roleOwner": "Owner",
|
||||
"roleAdmin": "Admin",
|
||||
"roleMember": "Member",
|
||||
"manageOnWeb": "Manage team on the web dashboard",
|
||||
"profileLocked": "In use by {{email}}",
|
||||
"profileLockedShort": "In use",
|
||||
"cannotLaunchLocked": "Cannot launch — profile is in use by {{email}}",
|
||||
"createdBy": "Created by {{email}}"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
@@ -503,6 +525,7 @@
|
||||
"verifying": "Verifying {{browser}} {{version}}",
|
||||
"syncing": "Syncing...",
|
||||
"syncingProfile": "Syncing profile '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} files ({{size}})",
|
||||
"updatingVersions": "Updating browser versions..."
|
||||
}
|
||||
},
|
||||
@@ -624,11 +647,15 @@
|
||||
"productSub": "Product Sub",
|
||||
"brand": "Brand",
|
||||
"brandVersion": "Brand Version",
|
||||
"proFeature": "This is a Pro feature"
|
||||
"proFeature": "This is a Pro feature",
|
||||
"generateFingerprint": "Generate Fingerprint",
|
||||
"refreshFingerprint": "Refresh Fingerprint"
|
||||
},
|
||||
"warnings": {
|
||||
"windowResizeTitle": "Custom Window Dimensions",
|
||||
"windowResizeDescription": "Changing browser window dimensions may increase the chance of website detection that browser information is spoofed.",
|
||||
"windowResizeCamoufoxTitle": "Viewport Locked by Camoufox",
|
||||
"windowResizeCamoufoxDescription": "Camoufox locks the viewport to the spoofed screen dimensions for anti-fingerprinting. Resizing the window may cause cropped or grey areas. This is expected behavior.",
|
||||
"dontShowAgain": "Don't show this again",
|
||||
"continue": "Continue",
|
||||
"cancel": "Cancel"
|
||||
@@ -676,6 +703,111 @@
|
||||
"error": "Failed to export cookies"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "Profile Details",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "Profile ID",
|
||||
"browser": "Browser",
|
||||
"releaseType": "Release Type",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"group": "Group",
|
||||
"tags": "Tags",
|
||||
"note": "Note",
|
||||
"syncStatus": "Sync Status",
|
||||
"lastLaunched": "Last Launched",
|
||||
"hostOs": "Host OS",
|
||||
"ephemeral": "Ephemeral",
|
||||
"extensionGroup": "Extension Group"
|
||||
},
|
||||
"values": {
|
||||
"none": "None",
|
||||
"never": "Never",
|
||||
"copied": "Copied!",
|
||||
"yes": "Yes"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Proxy Bypass Rules",
|
||||
"bypassRulesTitle": "Proxy Bypass Rules",
|
||||
"bypassRulesDescription": "Requests matching these rules will connect directly, bypassing the proxy.",
|
||||
"addRule": "Add Rule",
|
||||
"rulePlaceholder": "e.g. example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "No bypass rules configured.",
|
||||
"ruleTypes": "Supports hostnames, IP addresses, and regex patterns."
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Manage Cookies",
|
||||
"assignExtensionGroup": "Assign Extension Group"
|
||||
},
|
||||
"clone": {
|
||||
"title": "Clone Profile",
|
||||
"description": "Enter a name for the cloned profile",
|
||||
"namePlaceholder": "Profile name",
|
||||
"button": "Clone"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensions",
|
||||
"description": "Manage browser extensions and extension groups for your profiles.",
|
||||
"upload": "Upload",
|
||||
"delete": "Delete",
|
||||
"extensionsTab": "Extensions",
|
||||
"groupsTab": "Groups",
|
||||
"managedNotice": "Extensions managed here will replace any manually installed extensions in profiles when launched.",
|
||||
"proRequired": "Extension management is a Pro feature",
|
||||
"empty": "No extensions uploaded yet.",
|
||||
"noGroups": "No extension groups created yet.",
|
||||
"createGroup": "Create Group",
|
||||
"addToGroup": "Add extension...",
|
||||
"removeFromGroup": "Remove from group",
|
||||
"deleteGroup": "Delete group",
|
||||
"extensionGroup": "Extension Group",
|
||||
"compatibility": {
|
||||
"label": "Compatibility",
|
||||
"chromium": "Chromium",
|
||||
"firefox": "Firefox",
|
||||
"both": "Chromium & Firefox"
|
||||
},
|
||||
"selectedFile": "Selected file",
|
||||
"namePlaceholder": "Extension name",
|
||||
"groupNamePlaceholder": "Group name",
|
||||
"uploadSuccess": "Extension uploaded successfully",
|
||||
"deleteSuccess": "Extension deleted successfully",
|
||||
"groupCreateSuccess": "Extension group created successfully",
|
||||
"groupUpdateSuccess": "Extension group updated successfully",
|
||||
"groupDeleteSuccess": "Extension group deleted successfully",
|
||||
"deleteConfirmTitle": "Delete Extension",
|
||||
"deleteConfirmDescription": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
|
||||
"deleteGroupConfirmTitle": "Delete Extension Group",
|
||||
"deleteGroupConfirmDescription": "Are you sure you want to delete the group \"{{name}}\"? This action cannot be undone.",
|
||||
"invalidFileType": "Invalid file type. Please upload a .crx, .xpi, or .zip file.",
|
||||
"readError": "Failed to read the extension file.",
|
||||
"assignTitle": "Assign Extension Group",
|
||||
"assignDescription": "Assign {{count}} selected profile(s) to an extension group.",
|
||||
"noGroup": "None (No Extension Group)",
|
||||
"assignSuccess": "Extension group assigned successfully",
|
||||
"editExtension": "Edit extension",
|
||||
"updateSuccess": "Extension updated successfully",
|
||||
"reupload": "Re-upload",
|
||||
"version": "Version",
|
||||
"author": "Author",
|
||||
"homepage": "Homepage",
|
||||
"editGroup": "Edit Group",
|
||||
"editGroupDescription": "Update the group name and manage which extensions are included.",
|
||||
"groupExtensions": "Extensions in this group",
|
||||
"noExtensionsInGroup": "No extensions added yet",
|
||||
"editExtensionDescription": "Update extension name, view metadata, or re-upload the extension file.",
|
||||
"metadata": "Metadata",
|
||||
"noMetadata": "No metadata available from manifest.",
|
||||
"selectFile": "Choose File",
|
||||
"syncEnabled": "Sync enabled",
|
||||
"syncDisabled": "Sync disabled",
|
||||
"syncEnableTooltip": "Enable sync",
|
||||
"syncDisableTooltip": "Disable sync"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "Fingerprint editing is a Pro feature",
|
||||
|
||||
+138
-6
@@ -120,7 +120,8 @@
|
||||
"removed": "Contraseña de cifrado eliminada",
|
||||
"passwordSaved": "Contraseña de cifrado establecida",
|
||||
"passwordMismatch": "Las contraseñas no coinciden",
|
||||
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres"
|
||||
"passwordTooShort": "La contraseña debe tener al menos 8 caracteres",
|
||||
"requiresProOrOwner": "El cifrado de perfiles está disponible para usuarios Pro y propietarios de equipos."
|
||||
},
|
||||
"commercial": {
|
||||
"title": "Licencia Comercial",
|
||||
@@ -133,7 +134,9 @@
|
||||
"title": "Avanzado",
|
||||
"clearCache": "Limpiar Toda la Caché de Versiones",
|
||||
"clearCacheDescription": "Limpia todos los datos de versiones de navegadores en caché y actualiza todas las versiones desde sus fuentes. Esto forzará una descarga nueva de información de versiones para todos los navegadores."
|
||||
}
|
||||
},
|
||||
"disableAutoUpdates": "Desactivar Actualizaciones Automáticas de la App",
|
||||
"disableAutoUpdatesDescription": "Evita que la aplicación busque e instale actualizaciones de Donut Browser automáticamente. Las actualizaciones de navegadores no se ven afectadas."
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Buscar perfiles...",
|
||||
@@ -146,7 +149,8 @@
|
||||
"groups": "Grupos",
|
||||
"syncService": "Cuenta",
|
||||
"integrations": "Integraciones",
|
||||
"importProfile": "Importar Perfil"
|
||||
"importProfile": "Importar Perfil",
|
||||
"extensions": "Extensiones"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
@@ -187,7 +191,7 @@
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Crear Nuevo Perfil",
|
||||
"configureTitle": "Configurar Perfil",
|
||||
"configureTitle": "Crear Nuevo Perfil de {{browser}}",
|
||||
"antiDetect": {
|
||||
"title": "Navegador Anti-Detección",
|
||||
"description": "Elige un navegador con capacidades anti-detección",
|
||||
@@ -217,7 +221,12 @@
|
||||
"latestNeedsDownload": "La última versión ({{version}}) necesita ser descargada",
|
||||
"latestAvailable": "La última versión ({{version}}) está disponible",
|
||||
"latestDownloading": "Descargando versión ({{version}})..."
|
||||
}
|
||||
},
|
||||
"chromiumLabel": "Chromium",
|
||||
"chromiumSubtitle": "Impulsado por Wayfern",
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Impulsado por Camoufox",
|
||||
"camoufoxWarning": "Firefox (Camoufox) está mantenido por una organización de terceros. Para uso en producción, utilice Chromium."
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Eliminar Perfil",
|
||||
@@ -339,6 +348,19 @@
|
||||
"logoutConfirm": "¿Estás seguro de que deseas cerrar sesión? La sincronización en la nube se detendrá.",
|
||||
"loginSuccess": "¡Sesión iniciada exitosamente!",
|
||||
"logoutSuccess": "Sesión cerrada exitosamente."
|
||||
},
|
||||
"team": {
|
||||
"title": "Equipo",
|
||||
"name": "Nombre del Equipo",
|
||||
"role": "Rol",
|
||||
"roleOwner": "Propietario",
|
||||
"roleAdmin": "Administrador",
|
||||
"roleMember": "Miembro",
|
||||
"manageOnWeb": "Gestionar equipo en el panel web",
|
||||
"profileLocked": "En uso por {{email}}",
|
||||
"profileLockedShort": "En uso",
|
||||
"cannotLaunchLocked": "No se puede iniciar — el perfil está en uso por {{email}}",
|
||||
"createdBy": "Creado por {{email}}"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
@@ -503,6 +525,7 @@
|
||||
"verifying": "Verificando {{browser}} {{version}}",
|
||||
"syncing": "Sincronizando...",
|
||||
"syncingProfile": "Sincronizando perfil '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} archivos ({{size}})",
|
||||
"updatingVersions": "Actualizando versiones de navegadores..."
|
||||
}
|
||||
},
|
||||
@@ -624,11 +647,15 @@
|
||||
"productSub": "Product Sub",
|
||||
"brand": "Marca",
|
||||
"brandVersion": "Versión de marca",
|
||||
"proFeature": "Esta es una función Pro"
|
||||
"proFeature": "Esta es una función Pro",
|
||||
"generateFingerprint": "Generar Huella Digital",
|
||||
"refreshFingerprint": "Actualizar Huella Digital"
|
||||
},
|
||||
"warnings": {
|
||||
"windowResizeTitle": "Dimensiones de ventana personalizadas",
|
||||
"windowResizeDescription": "Cambiar las dimensiones de la ventana del navegador puede aumentar la posibilidad de que los sitios web detecten que la información del navegador está falsificada.",
|
||||
"windowResizeCamoufoxTitle": "Viewport bloqueado por Camoufox",
|
||||
"windowResizeCamoufoxDescription": "Camoufox bloquea el viewport a las dimensiones de pantalla falsificadas para anti-fingerprinting. Redimensionar la ventana puede causar áreas recortadas o grises. Este es el comportamiento esperado.",
|
||||
"dontShowAgain": "No mostrar esto de nuevo",
|
||||
"continue": "Continuar",
|
||||
"cancel": "Cancelar"
|
||||
@@ -676,6 +703,111 @@
|
||||
"error": "Error al exportar cookies"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "Detalles del Perfil",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"settings": "Configuración"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "ID del Perfil",
|
||||
"browser": "Navegador",
|
||||
"releaseType": "Tipo de Versión",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"group": "Grupo",
|
||||
"tags": "Etiquetas",
|
||||
"note": "Nota",
|
||||
"syncStatus": "Estado de Sincronización",
|
||||
"lastLaunched": "Último Lanzamiento",
|
||||
"hostOs": "SO Host",
|
||||
"ephemeral": "Efímero",
|
||||
"extensionGroup": "Grupo de Extensiones"
|
||||
},
|
||||
"values": {
|
||||
"none": "Ninguno",
|
||||
"never": "Nunca",
|
||||
"copied": "¡Copiado!",
|
||||
"yes": "Sí"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Reglas de Omisión de Proxy",
|
||||
"bypassRulesTitle": "Reglas de Omisión de Proxy",
|
||||
"bypassRulesDescription": "Las solicitudes que coincidan con estas reglas se conectarán directamente, omitiendo el proxy.",
|
||||
"addRule": "Agregar Regla",
|
||||
"rulePlaceholder": "ej. example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "No hay reglas de omisión configuradas.",
|
||||
"ruleTypes": "Soporta nombres de host, direcciones IP y patrones regex."
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Administrar Cookies",
|
||||
"assignExtensionGroup": "Asignar Grupo de Extensiones"
|
||||
},
|
||||
"clone": {
|
||||
"title": "Clonar Perfil",
|
||||
"description": "Ingrese un nombre para el perfil clonado",
|
||||
"namePlaceholder": "Nombre del perfil",
|
||||
"button": "Clonar"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensiones",
|
||||
"description": "Administra extensiones de navegador y grupos de extensiones para tus perfiles.",
|
||||
"upload": "Subir",
|
||||
"delete": "Eliminar",
|
||||
"extensionsTab": "Extensiones",
|
||||
"groupsTab": "Grupos",
|
||||
"managedNotice": "Las extensiones administradas aquí reemplazarán cualquier extensión instalada manualmente en los perfiles al iniciarlos.",
|
||||
"proRequired": "La gestión de extensiones es una función Pro",
|
||||
"empty": "No se han subido extensiones aún.",
|
||||
"noGroups": "No se han creado grupos de extensiones aún.",
|
||||
"createGroup": "Crear Grupo",
|
||||
"addToGroup": "Agregar extensión...",
|
||||
"removeFromGroup": "Eliminar del grupo",
|
||||
"deleteGroup": "Eliminar grupo",
|
||||
"extensionGroup": "Grupo de Extensiones",
|
||||
"compatibility": {
|
||||
"label": "Compatibilidad",
|
||||
"chromium": "Chromium",
|
||||
"firefox": "Firefox",
|
||||
"both": "Chromium y Firefox"
|
||||
},
|
||||
"selectedFile": "Archivo seleccionado",
|
||||
"namePlaceholder": "Nombre de la extensión",
|
||||
"groupNamePlaceholder": "Nombre del grupo",
|
||||
"uploadSuccess": "Extensión subida exitosamente",
|
||||
"deleteSuccess": "Extensión eliminada exitosamente",
|
||||
"groupCreateSuccess": "Grupo de extensiones creado exitosamente",
|
||||
"groupUpdateSuccess": "Grupo de extensiones actualizado exitosamente",
|
||||
"groupDeleteSuccess": "Grupo de extensiones eliminado exitosamente",
|
||||
"deleteConfirmTitle": "Eliminar Extensión",
|
||||
"deleteConfirmDescription": "¿Estás seguro de que deseas eliminar \"{{name}}\"? Esta acción no se puede deshacer.",
|
||||
"deleteGroupConfirmTitle": "Eliminar Grupo de Extensiones",
|
||||
"deleteGroupConfirmDescription": "¿Estás seguro de que deseas eliminar el grupo \"{{name}}\"? Esta acción no se puede deshacer.",
|
||||
"invalidFileType": "Tipo de archivo no válido. Suba un archivo .crx, .xpi o .zip.",
|
||||
"readError": "No se pudo leer el archivo de extensión.",
|
||||
"assignTitle": "Asignar Grupo de Extensiones",
|
||||
"assignDescription": "Asignar {{count}} perfil(es) seleccionado(s) a un grupo de extensiones.",
|
||||
"noGroup": "Ninguno (Sin Grupo de Extensiones)",
|
||||
"assignSuccess": "Grupo de extensiones asignado exitosamente",
|
||||
"editExtension": "Editar extensión",
|
||||
"updateSuccess": "Extensión actualizada exitosamente",
|
||||
"reupload": "Re-subir",
|
||||
"version": "Versión",
|
||||
"author": "Autor",
|
||||
"homepage": "Página de inicio",
|
||||
"editGroup": "Editar grupo",
|
||||
"editGroupDescription": "Actualiza el nombre del grupo y gestiona qué extensiones están incluidas.",
|
||||
"groupExtensions": "Extensiones en este grupo",
|
||||
"noExtensionsInGroup": "Aún no se han añadido extensiones",
|
||||
"editExtensionDescription": "Actualizar el nombre de la extensión, ver metadatos o volver a cargar el archivo de extensión.",
|
||||
"metadata": "Metadatos",
|
||||
"noMetadata": "No hay metadatos disponibles del manifiesto.",
|
||||
"selectFile": "Elegir archivo",
|
||||
"syncEnabled": "Sincronización habilitada",
|
||||
"syncDisabled": "Sincronización deshabilitada",
|
||||
"syncEnableTooltip": "Habilitar sincronización",
|
||||
"syncDisableTooltip": "Deshabilitar sincronización"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "La edición de huellas digitales es una función Pro",
|
||||
|
||||
+138
-6
@@ -120,7 +120,8 @@
|
||||
"removed": "Mot de passe de chiffrement supprimé",
|
||||
"passwordSaved": "Mot de passe de chiffrement défini",
|
||||
"passwordMismatch": "Les mots de passe ne correspondent pas",
|
||||
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères"
|
||||
"passwordTooShort": "Le mot de passe doit contenir au moins 8 caractères",
|
||||
"requiresProOrOwner": "Le chiffrement des profils est disponible pour les utilisateurs Pro et les propriétaires d'équipe."
|
||||
},
|
||||
"commercial": {
|
||||
"title": "Licence commerciale",
|
||||
@@ -133,7 +134,9 @@
|
||||
"title": "Avancé",
|
||||
"clearCache": "Effacer tout le cache des versions",
|
||||
"clearCacheDescription": "Efface toutes les données de versions de navigateurs en cache et actualise toutes les versions depuis leurs sources. Cela forcera un nouveau téléchargement des informations de version pour tous les navigateurs."
|
||||
}
|
||||
},
|
||||
"disableAutoUpdates": "Désactiver les mises à jour automatiques de l'app",
|
||||
"disableAutoUpdatesDescription": "Empêche l'application de vérifier et d'installer automatiquement les mises à jour de Donut Browser. Les mises à jour des navigateurs ne sont pas affectées."
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Rechercher des profils...",
|
||||
@@ -146,7 +149,8 @@
|
||||
"groups": "Groupes",
|
||||
"syncService": "Compte",
|
||||
"integrations": "Intégrations",
|
||||
"importProfile": "Importer un profil"
|
||||
"importProfile": "Importer un profil",
|
||||
"extensions": "Extensions"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
@@ -187,7 +191,7 @@
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Créer un nouveau profil",
|
||||
"configureTitle": "Configurer le profil",
|
||||
"configureTitle": "Créer un nouveau profil {{browser}}",
|
||||
"antiDetect": {
|
||||
"title": "Navigateur anti-détection",
|
||||
"description": "Choisissez un navigateur avec des capacités anti-détection",
|
||||
@@ -217,7 +221,12 @@
|
||||
"latestNeedsDownload": "La dernière version ({{version}}) doit être téléchargée",
|
||||
"latestAvailable": "La dernière version ({{version}}) est disponible",
|
||||
"latestDownloading": "Téléchargement de la version ({{version}})..."
|
||||
}
|
||||
},
|
||||
"chromiumLabel": "Chromium",
|
||||
"chromiumSubtitle": "Propulsé par Wayfern",
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Propulsé par Camoufox",
|
||||
"camoufoxWarning": "Firefox (Camoufox) est maintenu par une organisation tierce. Pour une utilisation en production, veuillez utiliser Chromium."
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Supprimer le profil",
|
||||
@@ -339,6 +348,19 @@
|
||||
"logoutConfirm": "Êtes-vous sûr de vouloir vous déconnecter ? La synchronisation cloud sera arrêtée.",
|
||||
"loginSuccess": "Connexion réussie !",
|
||||
"logoutSuccess": "Déconnexion réussie."
|
||||
},
|
||||
"team": {
|
||||
"title": "Équipe",
|
||||
"name": "Nom de l'équipe",
|
||||
"role": "Rôle",
|
||||
"roleOwner": "Propriétaire",
|
||||
"roleAdmin": "Administrateur",
|
||||
"roleMember": "Membre",
|
||||
"manageOnWeb": "Gérer l'équipe sur le tableau de bord web",
|
||||
"profileLocked": "Utilisé par {{email}}",
|
||||
"profileLockedShort": "En cours d'utilisation",
|
||||
"cannotLaunchLocked": "Impossible de lancer — le profil est utilisé par {{email}}",
|
||||
"createdBy": "Créé par {{email}}"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
@@ -503,6 +525,7 @@
|
||||
"verifying": "Vérification de {{browser}} {{version}}",
|
||||
"syncing": "Synchronisation...",
|
||||
"syncingProfile": "Synchronisation du profil '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} fichiers ({{size}})",
|
||||
"updatingVersions": "Mise à jour des versions de navigateurs..."
|
||||
}
|
||||
},
|
||||
@@ -624,11 +647,15 @@
|
||||
"productSub": "Product Sub",
|
||||
"brand": "Marque",
|
||||
"brandVersion": "Version de la marque",
|
||||
"proFeature": "Ceci est une fonctionnalité Pro"
|
||||
"proFeature": "Ceci est une fonctionnalité Pro",
|
||||
"generateFingerprint": "Générer l'empreinte",
|
||||
"refreshFingerprint": "Actualiser l'empreinte"
|
||||
},
|
||||
"warnings": {
|
||||
"windowResizeTitle": "Dimensions de fenêtre personnalisées",
|
||||
"windowResizeDescription": "Modifier les dimensions de la fenêtre du navigateur peut augmenter les chances de détection par les sites web que les informations du navigateur sont falsifiées.",
|
||||
"windowResizeCamoufoxTitle": "Viewport verrouillé par Camoufox",
|
||||
"windowResizeCamoufoxDescription": "Camoufox verrouille le viewport aux dimensions d'écran falsifiées pour l'anti-fingerprinting. Redimensionner la fenêtre peut causer des zones recadrées ou grises. C'est le comportement attendu.",
|
||||
"dontShowAgain": "Ne plus afficher",
|
||||
"continue": "Continuer",
|
||||
"cancel": "Annuler"
|
||||
@@ -676,6 +703,111 @@
|
||||
"error": "Échec de l'exportation des cookies"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "Détails du Profil",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"settings": "Paramètres"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "ID du Profil",
|
||||
"browser": "Navigateur",
|
||||
"releaseType": "Type de Version",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"group": "Groupe",
|
||||
"tags": "Tags",
|
||||
"note": "Note",
|
||||
"syncStatus": "État de Synchronisation",
|
||||
"lastLaunched": "Dernier Lancement",
|
||||
"hostOs": "OS Hôte",
|
||||
"ephemeral": "Éphémère",
|
||||
"extensionGroup": "Groupe d'Extensions"
|
||||
},
|
||||
"values": {
|
||||
"none": "Aucun",
|
||||
"never": "Jamais",
|
||||
"copied": "Copié !",
|
||||
"yes": "Oui"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Règles de Contournement du Proxy",
|
||||
"bypassRulesTitle": "Règles de Contournement du Proxy",
|
||||
"bypassRulesDescription": "Les requêtes correspondant à ces règles se connecteront directement, contournant le proxy.",
|
||||
"addRule": "Ajouter une Règle",
|
||||
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "Aucune règle de contournement configurée.",
|
||||
"ruleTypes": "Prend en charge les noms d'hôte, les adresses IP et les expressions régulières."
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Gérer les Cookies",
|
||||
"assignExtensionGroup": "Assigner un Groupe d'Extensions"
|
||||
},
|
||||
"clone": {
|
||||
"title": "Cloner le Profil",
|
||||
"description": "Entrez un nom pour le profil cloné",
|
||||
"namePlaceholder": "Nom du profil",
|
||||
"button": "Cloner"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensions",
|
||||
"description": "Gérez les extensions de navigateur et les groupes d'extensions pour vos profils.",
|
||||
"upload": "Télécharger",
|
||||
"delete": "Supprimer",
|
||||
"extensionsTab": "Extensions",
|
||||
"groupsTab": "Groupes",
|
||||
"managedNotice": "Les extensions gérées ici remplaceront toutes les extensions installées manuellement dans les profils lors du lancement.",
|
||||
"proRequired": "La gestion des extensions est une fonctionnalité Pro",
|
||||
"empty": "Aucune extension téléchargée pour l'instant.",
|
||||
"noGroups": "Aucun groupe d'extensions créé pour l'instant.",
|
||||
"createGroup": "Créer un Groupe",
|
||||
"addToGroup": "Ajouter une extension...",
|
||||
"removeFromGroup": "Retirer du groupe",
|
||||
"deleteGroup": "Supprimer le groupe",
|
||||
"extensionGroup": "Groupe d'Extensions",
|
||||
"compatibility": {
|
||||
"label": "Compatibilité",
|
||||
"chromium": "Chromium",
|
||||
"firefox": "Firefox",
|
||||
"both": "Chromium et Firefox"
|
||||
},
|
||||
"selectedFile": "Fichier sélectionné",
|
||||
"namePlaceholder": "Nom de l'extension",
|
||||
"groupNamePlaceholder": "Nom du groupe",
|
||||
"uploadSuccess": "Extension téléchargée avec succès",
|
||||
"deleteSuccess": "Extension supprimée avec succès",
|
||||
"groupCreateSuccess": "Groupe d'extensions créé avec succès",
|
||||
"groupUpdateSuccess": "Groupe d'extensions mis à jour avec succès",
|
||||
"groupDeleteSuccess": "Groupe d'extensions supprimé avec succès",
|
||||
"deleteConfirmTitle": "Supprimer l'Extension",
|
||||
"deleteConfirmDescription": "Êtes-vous sûr de vouloir supprimer \"{{name}}\" ? Cette action est irréversible.",
|
||||
"deleteGroupConfirmTitle": "Supprimer le Groupe d'Extensions",
|
||||
"deleteGroupConfirmDescription": "Êtes-vous sûr de vouloir supprimer le groupe \"{{name}}\" ? Cette action est irréversible.",
|
||||
"invalidFileType": "Type de fichier non valide. Veuillez télécharger un fichier .crx, .xpi ou .zip.",
|
||||
"readError": "Impossible de lire le fichier d'extension.",
|
||||
"assignTitle": "Assigner un Groupe d'Extensions",
|
||||
"assignDescription": "Assigner {{count}} profil(s) sélectionné(s) à un groupe d'extensions.",
|
||||
"noGroup": "Aucun (Pas de Groupe d'Extensions)",
|
||||
"assignSuccess": "Groupe d'extensions assigné avec succès",
|
||||
"editExtension": "Modifier l'extension",
|
||||
"updateSuccess": "Extension mise à jour avec succès",
|
||||
"reupload": "Re-télécharger",
|
||||
"version": "Version",
|
||||
"author": "Auteur",
|
||||
"homepage": "Page d'accueil",
|
||||
"editGroup": "Modifier le groupe",
|
||||
"editGroupDescription": "Mettez à jour le nom du groupe et gérez les extensions incluses.",
|
||||
"groupExtensions": "Extensions dans ce groupe",
|
||||
"noExtensionsInGroup": "Aucune extension ajoutée",
|
||||
"editExtensionDescription": "Modifier le nom de l'extension, voir les métadonnées ou re-télécharger le fichier d'extension.",
|
||||
"metadata": "Métadonnées",
|
||||
"noMetadata": "Aucune métadonnée disponible depuis le manifeste.",
|
||||
"selectFile": "Choisir un fichier",
|
||||
"syncEnabled": "Synchronisation activée",
|
||||
"syncDisabled": "Synchronisation désactivée",
|
||||
"syncEnableTooltip": "Activer la synchronisation",
|
||||
"syncDisableTooltip": "Désactiver la synchronisation"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "La modification d'empreinte est une fonctionnalité Pro",
|
||||
|
||||
+138
-6
@@ -120,7 +120,8 @@
|
||||
"removed": "暗号化パスワードが削除されました",
|
||||
"passwordSaved": "暗号化パスワードが設定されました",
|
||||
"passwordMismatch": "パスワードが一致しません",
|
||||
"passwordTooShort": "パスワードは8文字以上である必要があります"
|
||||
"passwordTooShort": "パスワードは8文字以上である必要があります",
|
||||
"requiresProOrOwner": "プロファイルの暗号化はProユーザーとチームオーナーのみ利用できます。"
|
||||
},
|
||||
"commercial": {
|
||||
"title": "商用ライセンス",
|
||||
@@ -133,7 +134,9 @@
|
||||
"title": "詳細設定",
|
||||
"clearCache": "すべてのバージョンキャッシュをクリア",
|
||||
"clearCacheDescription": "キャッシュされたすべてのブラウザバージョンデータをクリアし、すべてのブラウザバージョンをソースから更新します。これにより、すべてのブラウザのバージョン情報が強制的に再ダウンロードされます。"
|
||||
}
|
||||
},
|
||||
"disableAutoUpdates": "アプリの自動更新を無効にする",
|
||||
"disableAutoUpdatesDescription": "Donut Browserの自動更新確認・インストールを無効にします。ブラウザの更新には影響しません。"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "プロファイルを検索...",
|
||||
@@ -146,7 +149,8 @@
|
||||
"groups": "グループ",
|
||||
"syncService": "アカウント",
|
||||
"integrations": "統合",
|
||||
"importProfile": "プロファイルをインポート"
|
||||
"importProfile": "プロファイルをインポート",
|
||||
"extensions": "拡張機能"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
@@ -187,7 +191,7 @@
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "新しいプロファイルを作成",
|
||||
"configureTitle": "プロファイルを設定",
|
||||
"configureTitle": "新しい{{browser}}プロファイルを作成",
|
||||
"antiDetect": {
|
||||
"title": "アンチ検出ブラウザ",
|
||||
"description": "アンチ検出機能を持つブラウザを選択",
|
||||
@@ -217,7 +221,12 @@
|
||||
"latestNeedsDownload": "最新バージョン ({{version}}) をダウンロードする必要があります",
|
||||
"latestAvailable": "最新バージョン ({{version}}) は利用可能です",
|
||||
"latestDownloading": "バージョン ({{version}}) をダウンロード中..."
|
||||
}
|
||||
},
|
||||
"chromiumLabel": "Chromium",
|
||||
"chromiumSubtitle": "Wayfern搭載",
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Camoufox搭載",
|
||||
"camoufoxWarning": "Firefox(Camoufox)はサードパーティの組織によって管理されています。本番環境での使用にはChromiumをご利用ください。"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "プロファイルを削除",
|
||||
@@ -339,6 +348,19 @@
|
||||
"logoutConfirm": "ログアウトしてもよろしいですか?クラウド同期が停止します。",
|
||||
"loginSuccess": "ログインに成功しました!",
|
||||
"logoutSuccess": "ログアウトしました。"
|
||||
},
|
||||
"team": {
|
||||
"title": "チーム",
|
||||
"name": "チーム名",
|
||||
"role": "役割",
|
||||
"roleOwner": "オーナー",
|
||||
"roleAdmin": "管理者",
|
||||
"roleMember": "メンバー",
|
||||
"manageOnWeb": "Webダッシュボードでチームを管理",
|
||||
"profileLocked": "{{email}} が使用中",
|
||||
"profileLockedShort": "使用中",
|
||||
"cannotLaunchLocked": "起動できません — {{email}} がプロファイルを使用中です",
|
||||
"createdBy": "{{email}} が作成"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
@@ -503,6 +525,7 @@
|
||||
"verifying": "{{browser}} {{version}} を確認中",
|
||||
"syncing": "同期中...",
|
||||
"syncingProfile": "プロファイル '{{name}}' を同期中...",
|
||||
"syncingProfileWithProgress": "{{count}} ファイル ({{size}})",
|
||||
"updatingVersions": "ブラウザバージョンを更新中..."
|
||||
}
|
||||
},
|
||||
@@ -624,11 +647,15 @@
|
||||
"productSub": "Product Sub",
|
||||
"brand": "ブランド",
|
||||
"brandVersion": "ブランドバージョン",
|
||||
"proFeature": "これはPro機能です"
|
||||
"proFeature": "これはPro機能です",
|
||||
"generateFingerprint": "フィンガープリントを生成",
|
||||
"refreshFingerprint": "フィンガープリントを更新"
|
||||
},
|
||||
"warnings": {
|
||||
"windowResizeTitle": "カスタムウィンドウサイズ",
|
||||
"windowResizeDescription": "ブラウザウィンドウのサイズを変更すると、ブラウザ情報が偽装されていることをウェブサイトに検出される可能性が高くなります。",
|
||||
"windowResizeCamoufoxTitle": "Camoufoxによりビューポートがロックされています",
|
||||
"windowResizeCamoufoxDescription": "Camoufoxはアンチフィンガープリントのためにビューポートを偽装された画面サイズにロックします。ウィンドウのサイズを変更すると、切り取られた領域やグレーの領域が表示される場合があります。これは想定された動作です。",
|
||||
"dontShowAgain": "今後表示しない",
|
||||
"continue": "続行",
|
||||
"cancel": "キャンセル"
|
||||
@@ -676,6 +703,111 @@
|
||||
"error": "Cookieのエクスポートに失敗しました"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "プロフィール詳細",
|
||||
"tabs": {
|
||||
"info": "情報",
|
||||
"settings": "設定"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "プロフィールID",
|
||||
"browser": "ブラウザ",
|
||||
"releaseType": "リリースタイプ",
|
||||
"proxyVpn": "プロキシ / VPN",
|
||||
"group": "グループ",
|
||||
"tags": "タグ",
|
||||
"note": "メモ",
|
||||
"syncStatus": "同期ステータス",
|
||||
"lastLaunched": "最終起動",
|
||||
"hostOs": "ホストOS",
|
||||
"ephemeral": "エフェメラル",
|
||||
"extensionGroup": "拡張機能グループ"
|
||||
},
|
||||
"values": {
|
||||
"none": "なし",
|
||||
"never": "なし",
|
||||
"copied": "コピーしました!",
|
||||
"yes": "はい"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "プロキシバイパスルール",
|
||||
"bypassRulesTitle": "プロキシバイパスルール",
|
||||
"bypassRulesDescription": "これらのルールに一致するリクエストは、プロキシをバイパスして直接接続します。",
|
||||
"addRule": "ルールを追加",
|
||||
"rulePlaceholder": "例: example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "バイパスルールは設定されていません。",
|
||||
"ruleTypes": "ホスト名、IPアドレス、正規表現パターンをサポートしています。"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Cookieを管理",
|
||||
"assignExtensionGroup": "拡張機能グループを割り当て"
|
||||
},
|
||||
"clone": {
|
||||
"title": "プロフィールを複製",
|
||||
"description": "複製されたプロフィールの名前を入力してください",
|
||||
"namePlaceholder": "プロフィール名",
|
||||
"button": "複製"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "拡張機能",
|
||||
"description": "プロファイル用のブラウザ拡張機能と拡張機能グループを管理します。",
|
||||
"upload": "アップロード",
|
||||
"delete": "削除",
|
||||
"extensionsTab": "拡張機能",
|
||||
"groupsTab": "グループ",
|
||||
"managedNotice": "ここで管理される拡張機能は、起動時にプロファイルに手動でインストールされた拡張機能を置き換えます。",
|
||||
"proRequired": "拡張機能管理はプロ機能です",
|
||||
"empty": "まだ拡張機能がアップロードされていません。",
|
||||
"noGroups": "まだ拡張機能グループが作成されていません。",
|
||||
"createGroup": "グループを作成",
|
||||
"addToGroup": "拡張機能を追加...",
|
||||
"removeFromGroup": "グループから削除",
|
||||
"deleteGroup": "グループを削除",
|
||||
"extensionGroup": "拡張機能グループ",
|
||||
"compatibility": {
|
||||
"label": "互換性",
|
||||
"chromium": "Chromium",
|
||||
"firefox": "Firefox",
|
||||
"both": "Chromium & Firefox"
|
||||
},
|
||||
"selectedFile": "選択されたファイル",
|
||||
"namePlaceholder": "拡張機能名",
|
||||
"groupNamePlaceholder": "グループ名",
|
||||
"uploadSuccess": "拡張機能が正常にアップロードされました",
|
||||
"deleteSuccess": "拡張機能が正常に削除されました",
|
||||
"groupCreateSuccess": "拡張機能グループが正常に作成されました",
|
||||
"groupUpdateSuccess": "拡張機能グループが正常に更新されました",
|
||||
"groupDeleteSuccess": "拡張機能グループが正常に削除されました",
|
||||
"deleteConfirmTitle": "拡張機能を削除",
|
||||
"deleteConfirmDescription": "「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"deleteGroupConfirmTitle": "拡張機能グループを削除",
|
||||
"deleteGroupConfirmDescription": "グループ「{{name}}」を削除してもよろしいですか?この操作は元に戻せません。",
|
||||
"invalidFileType": "無効なファイルタイプです。.crx、.xpi、または .zip ファイルをアップロードしてください。",
|
||||
"readError": "拡張機能ファイルの読み取りに失敗しました。",
|
||||
"assignTitle": "拡張機能グループの割り当て",
|
||||
"assignDescription": "選択した{{count}}件のプロファイルを拡張機能グループに割り当てます。",
|
||||
"noGroup": "なし(拡張機能グループなし)",
|
||||
"assignSuccess": "拡張機能グループが正常に割り当てられました",
|
||||
"editExtension": "拡張機能を編集",
|
||||
"updateSuccess": "拡張機能が正常に更新されました",
|
||||
"reupload": "再アップロード",
|
||||
"version": "バージョン",
|
||||
"author": "作者",
|
||||
"homepage": "ホームページ",
|
||||
"editGroup": "グループを編集",
|
||||
"editGroupDescription": "グループ名を更新し、含まれる拡張機能を管理します。",
|
||||
"groupExtensions": "このグループの拡張機能",
|
||||
"noExtensionsInGroup": "拡張機能がまだ追加されていません",
|
||||
"editExtensionDescription": "拡張機能の名前を更新、メタデータを表示、またはファイルを再アップロードします。",
|
||||
"metadata": "メタデータ",
|
||||
"noMetadata": "マニフェストからのメタデータはありません。",
|
||||
"selectFile": "ファイルを選択",
|
||||
"syncEnabled": "同期が有効",
|
||||
"syncDisabled": "同期が無効",
|
||||
"syncEnableTooltip": "同期を有効にする",
|
||||
"syncDisableTooltip": "同期を無効にする"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "フィンガープリント編集はプロ機能です",
|
||||
|
||||
+138
-6
@@ -120,7 +120,8 @@
|
||||
"removed": "Senha de criptografia removida",
|
||||
"passwordSaved": "Senha de criptografia definida",
|
||||
"passwordMismatch": "As senhas não coincidem",
|
||||
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres"
|
||||
"passwordTooShort": "A senha deve ter pelo menos 8 caracteres",
|
||||
"requiresProOrOwner": "A criptografia de perfis está disponível para usuários Pro e proprietários de equipe."
|
||||
},
|
||||
"commercial": {
|
||||
"title": "Licença Comercial",
|
||||
@@ -133,7 +134,9 @@
|
||||
"title": "Avançado",
|
||||
"clearCache": "Limpar Todo o Cache de Versões",
|
||||
"clearCacheDescription": "Limpa todos os dados de versões de navegadores em cache e atualiza todas as versões de suas fontes. Isso forçará um novo download das informações de versão para todos os navegadores."
|
||||
}
|
||||
},
|
||||
"disableAutoUpdates": "Desativar Atualizações Automáticas do App",
|
||||
"disableAutoUpdatesDescription": "Impede que o aplicativo verifique e instale atualizações do Donut Browser automaticamente. As atualizações de navegadores não são afetadas."
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Pesquisar perfis...",
|
||||
@@ -146,7 +149,8 @@
|
||||
"groups": "Grupos",
|
||||
"syncService": "Conta",
|
||||
"integrations": "Integrações",
|
||||
"importProfile": "Importar Perfil"
|
||||
"importProfile": "Importar Perfil",
|
||||
"extensions": "Extensões"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
@@ -187,7 +191,7 @@
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Criar Novo Perfil",
|
||||
"configureTitle": "Configurar Perfil",
|
||||
"configureTitle": "Criar Novo Perfil de {{browser}}",
|
||||
"antiDetect": {
|
||||
"title": "Navegador Anti-Detecção",
|
||||
"description": "Escolha um navegador com capacidades anti-detecção",
|
||||
@@ -217,7 +221,12 @@
|
||||
"latestNeedsDownload": "A versão mais recente ({{version}}) precisa ser baixada",
|
||||
"latestAvailable": "A versão mais recente ({{version}}) está disponível",
|
||||
"latestDownloading": "Baixando versão ({{version}})..."
|
||||
}
|
||||
},
|
||||
"chromiumLabel": "Chromium",
|
||||
"chromiumSubtitle": "Desenvolvido com Wayfern",
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "Desenvolvido com Camoufox",
|
||||
"camoufoxWarning": "O Firefox (Camoufox) é mantido por uma organização terceira. Para uso em produção, utilize o Chromium."
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Excluir Perfil",
|
||||
@@ -339,6 +348,19 @@
|
||||
"logoutConfirm": "Tem certeza de que deseja sair? A sincronização na nuvem será interrompida.",
|
||||
"loginSuccess": "Login realizado com sucesso!",
|
||||
"logoutSuccess": "Logout realizado com sucesso."
|
||||
},
|
||||
"team": {
|
||||
"title": "Equipe",
|
||||
"name": "Nome da Equipe",
|
||||
"role": "Função",
|
||||
"roleOwner": "Proprietário",
|
||||
"roleAdmin": "Administrador",
|
||||
"roleMember": "Membro",
|
||||
"manageOnWeb": "Gerenciar equipe no painel web",
|
||||
"profileLocked": "Em uso por {{email}}",
|
||||
"profileLockedShort": "Em uso",
|
||||
"cannotLaunchLocked": "Não é possível iniciar — o perfil está em uso por {{email}}",
|
||||
"createdBy": "Criado por {{email}}"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
@@ -503,6 +525,7 @@
|
||||
"verifying": "Verificando {{browser}} {{version}}",
|
||||
"syncing": "Sincronizando...",
|
||||
"syncingProfile": "Sincronizando perfil '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} arquivos ({{size}})",
|
||||
"updatingVersions": "Atualizando versões de navegadores..."
|
||||
}
|
||||
},
|
||||
@@ -624,11 +647,15 @@
|
||||
"productSub": "Product Sub",
|
||||
"brand": "Marca",
|
||||
"brandVersion": "Versão da Marca",
|
||||
"proFeature": "Este é um recurso Pro"
|
||||
"proFeature": "Este é um recurso Pro",
|
||||
"generateFingerprint": "Gerar Impressão Digital",
|
||||
"refreshFingerprint": "Atualizar Impressão Digital"
|
||||
},
|
||||
"warnings": {
|
||||
"windowResizeTitle": "Dimensões de janela personalizadas",
|
||||
"windowResizeDescription": "Alterar as dimensões da janela do navegador pode aumentar a chance de detecção pelos sites de que as informações do navegador estão falsificadas.",
|
||||
"windowResizeCamoufoxTitle": "Viewport bloqueado pelo Camoufox",
|
||||
"windowResizeCamoufoxDescription": "O Camoufox bloqueia o viewport nas dimensões de tela falsificadas para anti-fingerprinting. Redimensionar a janela pode causar áreas cortadas ou cinzas. Este é o comportamento esperado.",
|
||||
"dontShowAgain": "Não mostrar novamente",
|
||||
"continue": "Continuar",
|
||||
"cancel": "Cancelar"
|
||||
@@ -676,6 +703,111 @@
|
||||
"error": "Falha ao exportar cookies"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "Detalhes do Perfil",
|
||||
"tabs": {
|
||||
"info": "Info",
|
||||
"settings": "Configurações"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "ID do Perfil",
|
||||
"browser": "Navegador",
|
||||
"releaseType": "Tipo de Versão",
|
||||
"proxyVpn": "Proxy / VPN",
|
||||
"group": "Grupo",
|
||||
"tags": "Tags",
|
||||
"note": "Nota",
|
||||
"syncStatus": "Status de Sincronização",
|
||||
"lastLaunched": "Último Lançamento",
|
||||
"hostOs": "SO Host",
|
||||
"ephemeral": "Efêmero",
|
||||
"extensionGroup": "Grupo de Extensões"
|
||||
},
|
||||
"values": {
|
||||
"none": "Nenhum",
|
||||
"never": "Nunca",
|
||||
"copied": "Copiado!",
|
||||
"yes": "Sim"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Regras de Bypass de Proxy",
|
||||
"bypassRulesTitle": "Regras de Bypass de Proxy",
|
||||
"bypassRulesDescription": "Solicitações que correspondam a estas regras se conectarão diretamente, ignorando o proxy.",
|
||||
"addRule": "Adicionar Regra",
|
||||
"rulePlaceholder": "ex. example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "Nenhuma regra de bypass configurada.",
|
||||
"ruleTypes": "Suporta nomes de host, endereços IP e padrões regex."
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Gerenciar Cookies",
|
||||
"assignExtensionGroup": "Atribuir Grupo de Extensões"
|
||||
},
|
||||
"clone": {
|
||||
"title": "Clonar Perfil",
|
||||
"description": "Digite um nome para o perfil clonado",
|
||||
"namePlaceholder": "Nome do perfil",
|
||||
"button": "Clonar"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Extensões",
|
||||
"description": "Gerencie extensões de navegador e grupos de extensões para seus perfis.",
|
||||
"upload": "Enviar",
|
||||
"delete": "Excluir",
|
||||
"extensionsTab": "Extensões",
|
||||
"groupsTab": "Grupos",
|
||||
"managedNotice": "As extensões gerenciadas aqui substituirão quaisquer extensões instaladas manualmente nos perfis ao serem iniciados.",
|
||||
"proRequired": "O gerenciamento de extensões é um recurso Pro",
|
||||
"empty": "Nenhuma extensão enviada ainda.",
|
||||
"noGroups": "Nenhum grupo de extensões criado ainda.",
|
||||
"createGroup": "Criar Grupo",
|
||||
"addToGroup": "Adicionar extensão...",
|
||||
"removeFromGroup": "Remover do grupo",
|
||||
"deleteGroup": "Excluir grupo",
|
||||
"extensionGroup": "Grupo de Extensões",
|
||||
"compatibility": {
|
||||
"label": "Compatibilidade",
|
||||
"chromium": "Chromium",
|
||||
"firefox": "Firefox",
|
||||
"both": "Chromium e Firefox"
|
||||
},
|
||||
"selectedFile": "Arquivo selecionado",
|
||||
"namePlaceholder": "Nome da extensão",
|
||||
"groupNamePlaceholder": "Nome do grupo",
|
||||
"uploadSuccess": "Extensão enviada com sucesso",
|
||||
"deleteSuccess": "Extensão excluída com sucesso",
|
||||
"groupCreateSuccess": "Grupo de extensões criado com sucesso",
|
||||
"groupUpdateSuccess": "Grupo de extensões atualizado com sucesso",
|
||||
"groupDeleteSuccess": "Grupo de extensões excluído com sucesso",
|
||||
"deleteConfirmTitle": "Excluir Extensão",
|
||||
"deleteConfirmDescription": "Tem certeza de que deseja excluir \"{{name}}\"? Esta ação não pode ser desfeita.",
|
||||
"deleteGroupConfirmTitle": "Excluir Grupo de Extensões",
|
||||
"deleteGroupConfirmDescription": "Tem certeza de que deseja excluir o grupo \"{{name}}\"? Esta ação não pode ser desfeita.",
|
||||
"invalidFileType": "Tipo de arquivo inválido. Envie um arquivo .crx, .xpi ou .zip.",
|
||||
"readError": "Falha ao ler o arquivo de extensão.",
|
||||
"assignTitle": "Atribuir Grupo de Extensões",
|
||||
"assignDescription": "Atribuir {{count}} perfil(is) selecionado(s) a um grupo de extensões.",
|
||||
"noGroup": "Nenhum (Sem Grupo de Extensões)",
|
||||
"assignSuccess": "Grupo de extensões atribuído com sucesso",
|
||||
"editExtension": "Editar extensão",
|
||||
"updateSuccess": "Extensão atualizada com sucesso",
|
||||
"reupload": "Re-enviar",
|
||||
"version": "Versão",
|
||||
"author": "Autor",
|
||||
"homepage": "Página inicial",
|
||||
"editGroup": "Editar grupo",
|
||||
"editGroupDescription": "Atualize o nome do grupo e gerencie quais extensões estão incluídas.",
|
||||
"groupExtensions": "Extensões neste grupo",
|
||||
"noExtensionsInGroup": "Nenhuma extensão adicionada ainda",
|
||||
"editExtensionDescription": "Atualizar o nome da extensão, ver metadados ou reenviar o arquivo da extensão.",
|
||||
"metadata": "Metadados",
|
||||
"noMetadata": "Nenhum metadado disponível do manifesto.",
|
||||
"selectFile": "Escolher arquivo",
|
||||
"syncEnabled": "Sincronização ativada",
|
||||
"syncDisabled": "Sincronização desativada",
|
||||
"syncEnableTooltip": "Ativar sincronização",
|
||||
"syncDisableTooltip": "Desativar sincronização"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "A edição de impressão digital é um recurso Pro",
|
||||
|
||||
+138
-6
@@ -120,7 +120,8 @@
|
||||
"removed": "Пароль шифрования удалён",
|
||||
"passwordSaved": "Пароль шифрования установлен",
|
||||
"passwordMismatch": "Пароли не совпадают",
|
||||
"passwordTooShort": "Пароль должен содержать не менее 8 символов"
|
||||
"passwordTooShort": "Пароль должен содержать не менее 8 символов",
|
||||
"requiresProOrOwner": "Шифрование профилей доступно для пользователей Pro и владельцев команд."
|
||||
},
|
||||
"commercial": {
|
||||
"title": "Коммерческая лицензия",
|
||||
@@ -133,7 +134,9 @@
|
||||
"title": "Дополнительно",
|
||||
"clearCache": "Очистить весь кэш версий",
|
||||
"clearCacheDescription": "Очищает все кэшированные данные версий браузеров и обновляет все версии из источников. Это принудительно загрузит информацию о версиях для всех браузеров."
|
||||
}
|
||||
},
|
||||
"disableAutoUpdates": "Отключить автообновление приложения",
|
||||
"disableAutoUpdatesDescription": "Запретить автоматическую проверку и установку обновлений Donut Browser. Обновления браузеров не затрагиваются."
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "Поиск профилей...",
|
||||
@@ -146,7 +149,8 @@
|
||||
"groups": "Группы",
|
||||
"syncService": "Аккаунт",
|
||||
"integrations": "Интеграции",
|
||||
"importProfile": "Импорт профиля"
|
||||
"importProfile": "Импорт профиля",
|
||||
"extensions": "Расширения"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
@@ -187,7 +191,7 @@
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Создать новый профиль",
|
||||
"configureTitle": "Настроить профиль",
|
||||
"configureTitle": "Создать новый профиль {{browser}}",
|
||||
"antiDetect": {
|
||||
"title": "Антидетект браузер",
|
||||
"description": "Выберите браузер с возможностями защиты от обнаружения",
|
||||
@@ -217,7 +221,12 @@
|
||||
"latestNeedsDownload": "Последнюю версию ({{version}}) необходимо скачать",
|
||||
"latestAvailable": "Последняя версия ({{version}}) доступна",
|
||||
"latestDownloading": "Загрузка версии ({{version}})..."
|
||||
}
|
||||
},
|
||||
"chromiumLabel": "Chromium",
|
||||
"chromiumSubtitle": "На базе Wayfern",
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "На базе Camoufox",
|
||||
"camoufoxWarning": "Firefox (Camoufox) поддерживается сторонней организацией. Для промышленного использования используйте Chromium."
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "Удалить профиль",
|
||||
@@ -339,6 +348,19 @@
|
||||
"logoutConfirm": "Вы уверены, что хотите выйти? Облачная синхронизация будет остановлена.",
|
||||
"loginSuccess": "Вход выполнен успешно!",
|
||||
"logoutSuccess": "Выход выполнен успешно."
|
||||
},
|
||||
"team": {
|
||||
"title": "Команда",
|
||||
"name": "Название команды",
|
||||
"role": "Роль",
|
||||
"roleOwner": "Владелец",
|
||||
"roleAdmin": "Администратор",
|
||||
"roleMember": "Участник",
|
||||
"manageOnWeb": "Управление командой в веб-панели",
|
||||
"profileLocked": "Используется {{email}}",
|
||||
"profileLockedShort": "Используется",
|
||||
"cannotLaunchLocked": "Невозможно запустить — профиль используется {{email}}",
|
||||
"createdBy": "Создано {{email}}"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
@@ -503,6 +525,7 @@
|
||||
"verifying": "Проверка {{browser}} {{version}}",
|
||||
"syncing": "Синхронизация...",
|
||||
"syncingProfile": "Синхронизация профиля '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} файлов ({{size}})",
|
||||
"updatingVersions": "Обновление версий браузеров..."
|
||||
}
|
||||
},
|
||||
@@ -624,11 +647,15 @@
|
||||
"productSub": "Product Sub",
|
||||
"brand": "Бренд",
|
||||
"brandVersion": "Версия бренда",
|
||||
"proFeature": "Это функция Pro"
|
||||
"proFeature": "Это функция Pro",
|
||||
"generateFingerprint": "Сгенерировать отпечаток",
|
||||
"refreshFingerprint": "Обновить отпечаток"
|
||||
},
|
||||
"warnings": {
|
||||
"windowResizeTitle": "Пользовательские размеры окна",
|
||||
"windowResizeDescription": "Изменение размеров окна браузера может повысить вероятность обнаружения сайтами того, что информация браузера подменена.",
|
||||
"windowResizeCamoufoxTitle": "Viewport заблокирован Camoufox",
|
||||
"windowResizeCamoufoxDescription": "Camoufox блокирует viewport на подменённых размерах экрана для защиты от фингерпринтинга. Изменение размера окна может вызвать обрезанные или серые области. Это ожидаемое поведение.",
|
||||
"dontShowAgain": "Больше не показывать",
|
||||
"continue": "Продолжить",
|
||||
"cancel": "Отмена"
|
||||
@@ -676,6 +703,111 @@
|
||||
"error": "Ошибка экспорта cookies"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "Детали профиля",
|
||||
"tabs": {
|
||||
"info": "Информация",
|
||||
"settings": "Настройки"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "ID профиля",
|
||||
"browser": "Браузер",
|
||||
"releaseType": "Тип релиза",
|
||||
"proxyVpn": "Прокси / VPN",
|
||||
"group": "Группа",
|
||||
"tags": "Теги",
|
||||
"note": "Заметка",
|
||||
"syncStatus": "Статус синхронизации",
|
||||
"lastLaunched": "Последний запуск",
|
||||
"hostOs": "ОС хоста",
|
||||
"ephemeral": "Эфемерный",
|
||||
"extensionGroup": "Группа расширений"
|
||||
},
|
||||
"values": {
|
||||
"none": "Нет",
|
||||
"never": "Никогда",
|
||||
"copied": "Скопировано!",
|
||||
"yes": "Да"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Правила обхода прокси",
|
||||
"bypassRulesTitle": "Правила обхода прокси",
|
||||
"bypassRulesDescription": "Запросы, соответствующие этим правилам, будут подключаться напрямую, минуя прокси.",
|
||||
"addRule": "Добавить правило",
|
||||
"rulePlaceholder": "напр. example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "Правила обхода не настроены.",
|
||||
"ruleTypes": "Поддерживает имена хостов, IP-адреса и шаблоны регулярных выражений."
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "Управление Cookie",
|
||||
"assignExtensionGroup": "Назначить группу расширений"
|
||||
},
|
||||
"clone": {
|
||||
"title": "Клонировать профиль",
|
||||
"description": "Введите имя для клонированного профиля",
|
||||
"namePlaceholder": "Имя профиля",
|
||||
"button": "Клонировать"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "Расширения",
|
||||
"description": "Управляйте расширениями браузера и группами расширений для ваших профилей.",
|
||||
"upload": "Загрузить",
|
||||
"delete": "Удалить",
|
||||
"extensionsTab": "Расширения",
|
||||
"groupsTab": "Группы",
|
||||
"managedNotice": "Расширения, управляемые здесь, заменят все вручную установленные расширения в профилях при запуске.",
|
||||
"proRequired": "Управление расширениями — функция Pro",
|
||||
"empty": "Расширения ещё не загружены.",
|
||||
"noGroups": "Группы расширений ещё не созданы.",
|
||||
"createGroup": "Создать группу",
|
||||
"addToGroup": "Добавить расширение...",
|
||||
"removeFromGroup": "Удалить из группы",
|
||||
"deleteGroup": "Удалить группу",
|
||||
"extensionGroup": "Группа расширений",
|
||||
"compatibility": {
|
||||
"label": "Совместимость",
|
||||
"chromium": "Chromium",
|
||||
"firefox": "Firefox",
|
||||
"both": "Chromium и Firefox"
|
||||
},
|
||||
"selectedFile": "Выбранный файл",
|
||||
"namePlaceholder": "Название расширения",
|
||||
"groupNamePlaceholder": "Название группы",
|
||||
"uploadSuccess": "Расширение успешно загружено",
|
||||
"deleteSuccess": "Расширение успешно удалено",
|
||||
"groupCreateSuccess": "Группа расширений успешно создана",
|
||||
"groupUpdateSuccess": "Группа расширений успешно обновлена",
|
||||
"groupDeleteSuccess": "Группа расширений успешно удалена",
|
||||
"deleteConfirmTitle": "Удалить расширение",
|
||||
"deleteConfirmDescription": "Вы уверены, что хотите удалить «{{name}}»? Это действие нельзя отменить.",
|
||||
"deleteGroupConfirmTitle": "Удалить группу расширений",
|
||||
"deleteGroupConfirmDescription": "Вы уверены, что хотите удалить группу «{{name}}»? Это действие нельзя отменить.",
|
||||
"invalidFileType": "Недопустимый тип файла. Загрузите файл .crx, .xpi или .zip.",
|
||||
"readError": "Не удалось прочитать файл расширения.",
|
||||
"assignTitle": "Назначить группу расширений",
|
||||
"assignDescription": "Назначить {{count}} выбранных профилей в группу расширений.",
|
||||
"noGroup": "Нет (Без группы расширений)",
|
||||
"assignSuccess": "Группа расширений успешно назначена",
|
||||
"editExtension": "Редактировать расширение",
|
||||
"updateSuccess": "Расширение успешно обновлено",
|
||||
"reupload": "Загрузить заново",
|
||||
"version": "Версия",
|
||||
"author": "Автор",
|
||||
"homepage": "Домашняя страница",
|
||||
"editGroup": "Редактировать группу",
|
||||
"editGroupDescription": "Обновите название группы и управляйте включёнными расширениями.",
|
||||
"groupExtensions": "Расширения в этой группе",
|
||||
"noExtensionsInGroup": "Расширения ещё не добавлены",
|
||||
"editExtensionDescription": "Обновите имя расширения, просмотрите метаданные или загрузите файл расширения повторно.",
|
||||
"metadata": "Метаданные",
|
||||
"noMetadata": "Метаданные из манифеста недоступны.",
|
||||
"selectFile": "Выбрать файл",
|
||||
"syncEnabled": "Синхронизация включена",
|
||||
"syncDisabled": "Синхронизация отключена",
|
||||
"syncEnableTooltip": "Включить синхронизацию",
|
||||
"syncDisableTooltip": "Отключить синхронизацию"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "Редактирование отпечатка — функция Pro",
|
||||
|
||||
+138
-6
@@ -120,7 +120,8 @@
|
||||
"removed": "加密密码已删除",
|
||||
"passwordSaved": "加密密码已设置",
|
||||
"passwordMismatch": "密码不匹配",
|
||||
"passwordTooShort": "密码必须至少8个字符"
|
||||
"passwordTooShort": "密码必须至少8个字符",
|
||||
"requiresProOrOwner": "配置文件加密仅适用于Pro用户和团队所有者。"
|
||||
},
|
||||
"commercial": {
|
||||
"title": "商业许可",
|
||||
@@ -133,7 +134,9 @@
|
||||
"title": "高级",
|
||||
"clearCache": "清除所有版本缓存",
|
||||
"clearCacheDescription": "清除所有缓存的浏览器版本数据并从源刷新所有浏览器版本。这将强制重新下载所有浏览器的版本信息。"
|
||||
}
|
||||
},
|
||||
"disableAutoUpdates": "禁用应用自动更新",
|
||||
"disableAutoUpdatesDescription": "阻止应用程序自动检查和安装 Donut Browser 更新。浏览器更新不受影响。"
|
||||
},
|
||||
"header": {
|
||||
"searchPlaceholder": "搜索配置文件...",
|
||||
@@ -146,7 +149,8 @@
|
||||
"groups": "分组",
|
||||
"syncService": "账户",
|
||||
"integrations": "集成",
|
||||
"importProfile": "导入配置文件"
|
||||
"importProfile": "导入配置文件",
|
||||
"extensions": "扩展程序"
|
||||
}
|
||||
},
|
||||
"profiles": {
|
||||
@@ -187,7 +191,7 @@
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "创建新配置文件",
|
||||
"configureTitle": "配置配置文件",
|
||||
"configureTitle": "创建新的 {{browser}} 配置文件",
|
||||
"antiDetect": {
|
||||
"title": "防检测浏览器",
|
||||
"description": "选择具有防检测功能的浏览器",
|
||||
@@ -217,7 +221,12 @@
|
||||
"latestNeedsDownload": "最新版本 ({{version}}) 需要下载",
|
||||
"latestAvailable": "最新版本 ({{version}}) 可用",
|
||||
"latestDownloading": "正在下载版本 ({{version}})..."
|
||||
}
|
||||
},
|
||||
"chromiumLabel": "Chromium",
|
||||
"chromiumSubtitle": "由 Wayfern 驱动",
|
||||
"firefoxLabel": "Firefox",
|
||||
"firefoxSubtitle": "由 Camoufox 驱动",
|
||||
"camoufoxWarning": "Firefox(Camoufox)由第三方组织维护。在生产环境中,请使用 Chromium。"
|
||||
},
|
||||
"deleteDialog": {
|
||||
"title": "删除配置文件",
|
||||
@@ -339,6 +348,19 @@
|
||||
"logoutConfirm": "您确定要退出登录吗?云同步将会停止。",
|
||||
"loginSuccess": "登录成功!",
|
||||
"logoutSuccess": "已成功退出登录。"
|
||||
},
|
||||
"team": {
|
||||
"title": "团队",
|
||||
"name": "团队名称",
|
||||
"role": "角色",
|
||||
"roleOwner": "所有者",
|
||||
"roleAdmin": "管理员",
|
||||
"roleMember": "成员",
|
||||
"manageOnWeb": "在网页控制台管理团队",
|
||||
"profileLocked": "{{email}} 正在使用中",
|
||||
"profileLockedShort": "使用中",
|
||||
"cannotLaunchLocked": "无法启动 — 配置文件正被 {{email}} 使用",
|
||||
"createdBy": "由 {{email}} 创建"
|
||||
}
|
||||
},
|
||||
"integrations": {
|
||||
@@ -503,6 +525,7 @@
|
||||
"verifying": "正在验证 {{browser}} {{version}}",
|
||||
"syncing": "同步中...",
|
||||
"syncingProfile": "正在同步配置文件 '{{name}}'...",
|
||||
"syncingProfileWithProgress": "{{count}} 个文件 ({{size}})",
|
||||
"updatingVersions": "正在更新浏览器版本..."
|
||||
}
|
||||
},
|
||||
@@ -624,11 +647,15 @@
|
||||
"productSub": "Product Sub",
|
||||
"brand": "品牌",
|
||||
"brandVersion": "品牌版本",
|
||||
"proFeature": "这是 Pro 功能"
|
||||
"proFeature": "这是 Pro 功能",
|
||||
"generateFingerprint": "生成指纹",
|
||||
"refreshFingerprint": "刷新指纹"
|
||||
},
|
||||
"warnings": {
|
||||
"windowResizeTitle": "自定义窗口尺寸",
|
||||
"windowResizeDescription": "更改浏览器窗口尺寸可能会增加网站检测到浏览器信息被伪装的概率。",
|
||||
"windowResizeCamoufoxTitle": "视口已被 Camoufox 锁定",
|
||||
"windowResizeCamoufoxDescription": "Camoufox 将视口锁定为伪装的屏幕尺寸以防止指纹识别。调整窗口大小可能会导致内容被裁剪或出现灰色区域。这是预期行为。",
|
||||
"dontShowAgain": "不再显示",
|
||||
"continue": "继续",
|
||||
"cancel": "取消"
|
||||
@@ -676,6 +703,111 @@
|
||||
"error": "导出 Cookies 失败"
|
||||
}
|
||||
},
|
||||
"profileInfo": {
|
||||
"title": "配置文件详情",
|
||||
"tabs": {
|
||||
"info": "信息",
|
||||
"settings": "设置"
|
||||
},
|
||||
"fields": {
|
||||
"profileId": "配置文件 ID",
|
||||
"browser": "浏览器",
|
||||
"releaseType": "发布类型",
|
||||
"proxyVpn": "代理 / VPN",
|
||||
"group": "分组",
|
||||
"tags": "标签",
|
||||
"note": "备注",
|
||||
"syncStatus": "同步状态",
|
||||
"lastLaunched": "上次启动",
|
||||
"hostOs": "主机操作系统",
|
||||
"ephemeral": "临时",
|
||||
"extensionGroup": "扩展程序组"
|
||||
},
|
||||
"values": {
|
||||
"none": "无",
|
||||
"never": "从未",
|
||||
"copied": "已复制!",
|
||||
"yes": "是"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "代理绕过规则",
|
||||
"bypassRulesTitle": "代理绕过规则",
|
||||
"bypassRulesDescription": "匹配这些规则的请求将直接连接,绕过代理。",
|
||||
"addRule": "添加规则",
|
||||
"rulePlaceholder": "例如 example.com, 192.168.1.*, .*\\.local",
|
||||
"noRules": "未配置绕过规则。",
|
||||
"ruleTypes": "支持主机名、IP地址和正则表达式模式。"
|
||||
},
|
||||
"actions": {
|
||||
"manageCookies": "管理 Cookie",
|
||||
"assignExtensionGroup": "分配扩展程序组"
|
||||
},
|
||||
"clone": {
|
||||
"title": "克隆配置文件",
|
||||
"description": "输入克隆配置文件的名称",
|
||||
"namePlaceholder": "配置文件名称",
|
||||
"button": "克隆"
|
||||
}
|
||||
},
|
||||
"extensions": {
|
||||
"title": "扩展程序",
|
||||
"description": "管理配置文件的浏览器扩展程序和扩展程序组。",
|
||||
"upload": "上传",
|
||||
"delete": "删除",
|
||||
"extensionsTab": "扩展程序",
|
||||
"groupsTab": "分组",
|
||||
"managedNotice": "此处管理的扩展程序将在启动时替换配置文件中手动安装的所有扩展程序。",
|
||||
"proRequired": "扩展程序管理是 Pro 功能",
|
||||
"empty": "尚未上传任何扩展程序。",
|
||||
"noGroups": "尚未创建任何扩展程序组。",
|
||||
"createGroup": "创建分组",
|
||||
"addToGroup": "添加扩展程序...",
|
||||
"removeFromGroup": "从分组中移除",
|
||||
"deleteGroup": "删除分组",
|
||||
"extensionGroup": "扩展程序组",
|
||||
"compatibility": {
|
||||
"label": "兼容性",
|
||||
"chromium": "Chromium",
|
||||
"firefox": "Firefox",
|
||||
"both": "Chromium 和 Firefox"
|
||||
},
|
||||
"selectedFile": "已选文件",
|
||||
"namePlaceholder": "扩展程序名称",
|
||||
"groupNamePlaceholder": "分组名称",
|
||||
"uploadSuccess": "扩展程序上传成功",
|
||||
"deleteSuccess": "扩展程序删除成功",
|
||||
"groupCreateSuccess": "扩展程序组创建成功",
|
||||
"groupUpdateSuccess": "扩展程序组更新成功",
|
||||
"groupDeleteSuccess": "扩展程序组删除成功",
|
||||
"deleteConfirmTitle": "删除扩展程序",
|
||||
"deleteConfirmDescription": "确定要删除「{{name}}」吗?此操作无法撤消。",
|
||||
"deleteGroupConfirmTitle": "删除扩展程序组",
|
||||
"deleteGroupConfirmDescription": "确定要删除分组「{{name}}」吗?此操作无法撤消。",
|
||||
"invalidFileType": "无效的文件类型。请上传 .crx、.xpi 或 .zip 文件。",
|
||||
"readError": "读取扩展程序文件失败。",
|
||||
"assignTitle": "分配扩展程序组",
|
||||
"assignDescription": "将 {{count}} 个选定的配置文件分配到扩展程序组。",
|
||||
"noGroup": "无(不使用扩展程序组)",
|
||||
"assignSuccess": "扩展程序组分配成功",
|
||||
"editExtension": "编辑扩展",
|
||||
"updateSuccess": "扩展更新成功",
|
||||
"reupload": "重新上传",
|
||||
"version": "版本",
|
||||
"author": "作者",
|
||||
"homepage": "主页",
|
||||
"editGroup": "编辑分组",
|
||||
"editGroupDescription": "更新分组名称并管理包含的扩展。",
|
||||
"groupExtensions": "此分组中的扩展",
|
||||
"noExtensionsInGroup": "尚未添加扩展",
|
||||
"editExtensionDescription": "更新扩展名称、查看元数据或重新上传扩展文件。",
|
||||
"metadata": "元数据",
|
||||
"noMetadata": "清单中没有可用的元数据。",
|
||||
"selectFile": "选择文件",
|
||||
"syncEnabled": "同步已启用",
|
||||
"syncDisabled": "同步已禁用",
|
||||
"syncEnableTooltip": "启用同步",
|
||||
"syncDisableTooltip": "禁用同步"
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "指纹编辑是 Pro 功能",
|
||||
|
||||
+47
-1
@@ -48,12 +48,27 @@ interface VersionUpdateToastProps extends BaseToastProps {
|
||||
};
|
||||
}
|
||||
|
||||
interface SyncProgressToastProps extends BaseToastProps {
|
||||
type: "sync-progress";
|
||||
progress?: {
|
||||
completed_files: number;
|
||||
total_files: number;
|
||||
completed_bytes: number;
|
||||
total_bytes: number;
|
||||
speed_bytes_per_sec: number;
|
||||
eta_seconds: number;
|
||||
failed_count: number;
|
||||
phase: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ToastProps =
|
||||
| SuccessToastProps
|
||||
| ErrorToastProps
|
||||
| DownloadToastProps
|
||||
| LoadingToastProps
|
||||
| VersionUpdateToastProps;
|
||||
| VersionUpdateToastProps
|
||||
| SyncProgressToastProps;
|
||||
|
||||
export function showToast(props: ToastProps & { id?: string }) {
|
||||
const toastId = props.id ?? `toast-${props.type}-${Date.now()}`;
|
||||
@@ -85,6 +100,9 @@ export function showToast(props: ToastProps & { id?: string }) {
|
||||
case "version-update":
|
||||
duration = 15000;
|
||||
break;
|
||||
case "sync-progress":
|
||||
duration = Number.POSITIVE_INFINITY;
|
||||
break;
|
||||
default:
|
||||
duration = 5000;
|
||||
}
|
||||
@@ -232,6 +250,34 @@ export function dismissToast(id: string) {
|
||||
sonnerToast.dismiss(id);
|
||||
}
|
||||
|
||||
export function showSyncProgressToast(
|
||||
profileName: string,
|
||||
progress: {
|
||||
completed_files: number;
|
||||
total_files: number;
|
||||
completed_bytes: number;
|
||||
total_bytes: number;
|
||||
speed_bytes_per_sec: number;
|
||||
eta_seconds: number;
|
||||
failed_count: number;
|
||||
phase: string;
|
||||
},
|
||||
options?: { id?: string },
|
||||
) {
|
||||
return showToast({
|
||||
type: "sync-progress",
|
||||
title: `Syncing profile '${profileName}'...`,
|
||||
progress,
|
||||
id: options?.id,
|
||||
duration: Number.POSITIVE_INFINITY,
|
||||
onCancel: () => {
|
||||
if (options?.id) {
|
||||
dismissToast(options.id);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function showUnifiedVersionUpdateToast(
|
||||
title: string,
|
||||
options?: {
|
||||
|
||||
@@ -31,6 +31,36 @@ export interface BrowserProfile {
|
||||
last_sync?: number; // Timestamp of last successful sync (epoch seconds)
|
||||
host_os?: string; // OS where profile was created ("macos", "windows", "linux")
|
||||
ephemeral?: boolean;
|
||||
extension_group_id?: string;
|
||||
proxy_bypass_rules?: string[];
|
||||
created_by_id?: string;
|
||||
created_by_email?: string;
|
||||
}
|
||||
|
||||
export interface Extension {
|
||||
id: string;
|
||||
name: string;
|
||||
file_name: string;
|
||||
file_type: string;
|
||||
browser_compatibility: string[];
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
sync_enabled?: boolean;
|
||||
last_sync?: number;
|
||||
version?: string;
|
||||
description?: string;
|
||||
author?: string;
|
||||
homepage_url?: string;
|
||||
}
|
||||
|
||||
export interface ExtensionGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
extension_ids: string[];
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
sync_enabled?: boolean;
|
||||
last_sync?: number;
|
||||
}
|
||||
|
||||
export type SyncMode = "Disabled" | "Regular" | "Encrypted";
|
||||
@@ -52,6 +82,18 @@ export interface CloudUser {
|
||||
cloudProfilesUsed: number;
|
||||
proxyBandwidthLimitMb: number;
|
||||
proxyBandwidthUsedMb: number;
|
||||
proxyBandwidthExtraMb: number;
|
||||
teamId?: string;
|
||||
teamName?: string;
|
||||
teamRole?: string;
|
||||
}
|
||||
|
||||
export interface ProfileLockInfo {
|
||||
profileId: string;
|
||||
lockedBy: string;
|
||||
lockedByEmail: string;
|
||||
lockedAt: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
export interface CloudAuthState {
|
||||
@@ -89,7 +131,9 @@ export interface StoredProxy {
|
||||
is_cloud_derived?: boolean;
|
||||
geo_country?: string;
|
||||
geo_state?: string;
|
||||
geo_region?: string;
|
||||
geo_city?: string;
|
||||
geo_isp?: string;
|
||||
}
|
||||
|
||||
export interface LocationItem {
|
||||
|
||||
Reference in New Issue
Block a user