mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-04 02:08:43 +02:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21d80fde56 | ||
|
|
3732d3a6e1 | ||
|
|
2e193987df | ||
|
|
ddc2657165 | ||
|
|
98798b83df | ||
|
|
ed82f74932 | ||
|
|
cc5379f957 | ||
|
|
8b9ad44ebc | ||
|
|
206be3ff12 | ||
|
|
1afc2ca5ff | ||
|
|
c61b3d3188 | ||
|
|
97da1ca288 | ||
|
|
6484656de0 | ||
|
|
961e3f2185 | ||
|
|
f515a4f327 | ||
|
|
4ba2c5ec24 | ||
|
|
f378f0fbde |
@@ -0,0 +1,76 @@
|
||||
messages:
|
||||
- role: system
|
||||
content: |-
|
||||
You are an issue validation assistant for Donut Browser, an anti-detect browser.
|
||||
|
||||
Analyze the provided issue content and determine if it contains sufficient information based on these requirements:
|
||||
|
||||
For Bug Reports, the issue should include:
|
||||
1. Clear description of the problem
|
||||
2. Steps to reproduce the issue (numbered list preferred)
|
||||
3. Expected vs actual behavior
|
||||
4. Environment information (OS, browser version, etc.)
|
||||
5. Error messages, stack traces, or screenshots if applicable
|
||||
|
||||
For Feature Requests, the issue should include:
|
||||
1. Clear description of the requested feature
|
||||
2. Use case or problem it solves
|
||||
3. Proposed solution or how it should work
|
||||
4. Priority level or importance
|
||||
|
||||
General Requirements for all issues:
|
||||
1. Descriptive title
|
||||
2. Sufficient detail to understand and act upon
|
||||
3. Professional tone and clear communication
|
||||
|
||||
Constraints:
|
||||
- Maximum 3 items in missing_info array
|
||||
- Maximum 3 items in suggestions array
|
||||
- Each array item must be under 80 characters
|
||||
- overall_assessment must be under 100 characters
|
||||
- role: user
|
||||
content: |-
|
||||
## Issue Content to Analyze:
|
||||
|
||||
**Title:** {{issue_title}}
|
||||
|
||||
**Body:**
|
||||
{{issue_body}}
|
||||
|
||||
**Labels:** {{issue_labels}}
|
||||
model: openai/gpt-4.1
|
||||
responseFormat: json_schema
|
||||
jsonSchema: |-
|
||||
{
|
||||
"name": "issue_validation",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"is_valid": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the issue contains sufficient information"
|
||||
},
|
||||
"issue_type": {
|
||||
"type": "string",
|
||||
"enum": ["bug_report", "feature_request", "other"]
|
||||
},
|
||||
"missing_info": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Missing information items (max 3, each under 80 characters)"
|
||||
},
|
||||
"suggestions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Suggestions for improvement (max 3, each under 80 characters)"
|
||||
},
|
||||
"overall_assessment": {
|
||||
"type": "string",
|
||||
"description": "One sentence assessment under 100 characters"
|
||||
}
|
||||
},
|
||||
"required": ["is_valid", "issue_type", "missing_info", "suggestions", "overall_assessment"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
messages:
|
||||
- role: system
|
||||
content: |-
|
||||
You are a code review assistant for Donut Browser, an open-source anti-detect browser built with Tauri, Next.js, and Rust.
|
||||
|
||||
Review the provided pull request and provide constructive feedback. Focus on:
|
||||
1. Code quality and best practices
|
||||
2. Potential bugs or issues
|
||||
3. Security concerns (especially important for an anti-detect browser)
|
||||
4. Performance implications
|
||||
5. Consistency with the project's patterns
|
||||
|
||||
Constraints:
|
||||
- Maximum 4 items in feedback array
|
||||
- Maximum 3 items in suggestions array
|
||||
- Maximum 2 items in security_notes array
|
||||
- Each array item must be under 150 characters
|
||||
- summary must be under 200 characters
|
||||
- Be constructive and helpful, not harsh
|
||||
- role: user
|
||||
content: |-
|
||||
## Pull Request to Review:
|
||||
|
||||
**Title:** {{pr_title}}
|
||||
|
||||
**Description:**
|
||||
{{pr_body}}
|
||||
|
||||
**Diff:**
|
||||
{{pr_diff}}
|
||||
model: openai/gpt-4.1
|
||||
responseFormat: json_schema
|
||||
jsonSchema: |-
|
||||
{
|
||||
"name": "pr_review",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"summary": {
|
||||
"type": "string",
|
||||
"description": "Brief 1-2 sentence summary under 200 characters"
|
||||
},
|
||||
"quality_score": {
|
||||
"type": "string",
|
||||
"enum": ["good", "needs_work", "critical_issues"]
|
||||
},
|
||||
"feedback": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Feedback points (max 4, each under 150 characters)"
|
||||
},
|
||||
"suggestions": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Suggestions (max 3, each under 150 characters)"
|
||||
},
|
||||
"security_notes": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Security notes if any (max 2, each under 150 characters)"
|
||||
}
|
||||
},
|
||||
"required": ["summary", "quality_score", "feedback", "suggestions", "security_notes"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
messages:
|
||||
- role: system
|
||||
content: |-
|
||||
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful anti-detect browser desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
|
||||
|
||||
Guidelines:
|
||||
- Use clear, user-friendly language
|
||||
- Group related commits logically
|
||||
- Omit minor commits like formatting, typos unless significant
|
||||
- Focus on user-facing changes
|
||||
- Use emojis sparingly and consistently
|
||||
- Keep descriptions concise but informative
|
||||
- If commits are unclear, infer the purpose from the context
|
||||
- Only include sections that have relevant changes
|
||||
- role: user
|
||||
content: |-
|
||||
Generate release notes for version {{version}} based on these commits:
|
||||
|
||||
{{commits}}
|
||||
|
||||
Use this format:
|
||||
|
||||
## What's New in {{version}}
|
||||
|
||||
[Brief 1-2 sentence overview]
|
||||
|
||||
### New Features
|
||||
### Bug Fixes
|
||||
### Improvements
|
||||
### Documentation
|
||||
### Dependencies
|
||||
### Developer Experience
|
||||
model: openai/gpt-4.1
|
||||
@@ -26,81 +26,22 @@ jobs:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
|
||||
- name: Get issue templates
|
||||
id: get-templates
|
||||
run: |
|
||||
if [ -f ".github/ISSUE_TEMPLATE/01-bug-report.md" ]; then
|
||||
echo "bug-template-exists=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ -f ".github/ISSUE_TEMPLATE/02-feature-request.md" ]; then
|
||||
echo "feature-template-exists=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create issue analysis prompt
|
||||
id: create-prompt
|
||||
- name: Save issue body to file
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }}
|
||||
run: |
|
||||
cat > issue_analysis.txt << EOF
|
||||
## Issue Content to Analyze:
|
||||
|
||||
**Title:** $ISSUE_TITLE
|
||||
|
||||
**Body:**
|
||||
$ISSUE_BODY
|
||||
|
||||
**Labels:** $ISSUE_LABELS
|
||||
EOF
|
||||
run: printf '%s' "${ISSUE_BODY:-}" > issue_body.txt
|
||||
|
||||
- name: Validate issue with AI
|
||||
id: validate
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
prompt-file: issue_analysis.txt
|
||||
system-prompt: |
|
||||
You are an issue validation assistant for Donut Browser, an anti-detect browser.
|
||||
|
||||
Analyze the provided issue content and determine if it contains sufficient information based on these requirements:
|
||||
|
||||
**For Bug Reports, the issue should include:**
|
||||
1. Clear description of the problem
|
||||
2. Steps to reproduce the issue (numbered list preferred)
|
||||
3. Expected vs actual behavior
|
||||
4. Environment information (OS, browser version, etc.)
|
||||
5. Error messages, stack traces, or screenshots if applicable
|
||||
|
||||
**For Feature Requests, the issue should include:**
|
||||
1. Clear description of the requested feature
|
||||
2. Use case or problem it solves
|
||||
3. Proposed solution or how it should work
|
||||
4. Priority level or importance
|
||||
|
||||
**General Requirements for all issues:**
|
||||
1. Descriptive title
|
||||
2. Sufficient detail to understand and act upon
|
||||
3. Professional tone and clear communication
|
||||
|
||||
Respond ONLY with valid JSON (no markdown fences). Keep responses concise.
|
||||
|
||||
JSON structure:
|
||||
{
|
||||
"is_valid": true|false,
|
||||
"issue_type": "bug_report"|"feature_request"|"other",
|
||||
"missing_info": ["item1", "item2"],
|
||||
"suggestions": ["suggestion1", "suggestion2"],
|
||||
"overall_assessment": "One sentence assessment"
|
||||
}
|
||||
|
||||
IMPORTANT CONSTRAINTS:
|
||||
- Maximum 3 items in missing_info array
|
||||
- Maximum 3 items in suggestions array
|
||||
- Each array item must be under 80 characters
|
||||
- overall_assessment must be under 100 characters
|
||||
- Output ONLY the JSON object, nothing else
|
||||
model: gpt-5-mini
|
||||
prompt-file: .github/prompts/issue-validation.prompt.yml
|
||||
input: |
|
||||
issue_title: ${{ github.event.issue.title }}
|
||||
issue_labels: ${{ join(github.event.issue.labels.*.name, ', ') }}
|
||||
file_input: |
|
||||
issue_body: ./issue_body.txt
|
||||
max-tokens: 1024
|
||||
|
||||
- name: Check if first-time contributor
|
||||
id: check-first-time
|
||||
@@ -203,14 +144,14 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run opencode analysis
|
||||
uses: anomalyco/opencode/github@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
|
||||
uses: anomalyco/opencode/github@296250f1b7e1ec992a3a33bee999f5e09a1697d0 #v1.2.10
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
with:
|
||||
model: zai-coding-plan/glm-4.7
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -f issue_analysis.txt comment.md
|
||||
run: rm -f issue_body.txt comment.md
|
||||
|
||||
handle-pr:
|
||||
if: github.event_name == 'pull_request' && github.actor != 'dependabot[bot]'
|
||||
@@ -245,54 +186,22 @@ jobs:
|
||||
gh pr diff ${{ github.event.pull_request.number }} > pr_diff.txt
|
||||
head -c 10000 pr_diff.txt > pr_diff_truncated.txt
|
||||
|
||||
- name: Create PR analysis prompt
|
||||
- name: Save PR body to file
|
||||
env:
|
||||
PR_TITLE: ${{ github.event.pull_request.title }}
|
||||
PR_BODY: ${{ github.event.pull_request.body }}
|
||||
run: |
|
||||
{
|
||||
printf "## Pull Request to Review:\n\n"
|
||||
printf "**Title:** %s\n\n" "$PR_TITLE"
|
||||
printf "**Description:**\n%s\n\n" "$PR_BODY"
|
||||
printf "**Diff:**\n"
|
||||
cat pr_diff_truncated.txt
|
||||
} > pr_analysis.txt
|
||||
run: printf '%s' "${PR_BODY:-No description provided}" > pr_body.txt
|
||||
|
||||
- name: Analyze PR with AI
|
||||
id: analyze
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
prompt-file: pr_analysis.txt
|
||||
system-prompt: |
|
||||
You are a code review assistant for Donut Browser, an open-source anti-detect browser built with Tauri, Next.js, and Rust.
|
||||
|
||||
Review the provided pull request and provide constructive feedback. Focus on:
|
||||
1. Code quality and best practices
|
||||
2. Potential bugs or issues
|
||||
3. Security concerns (especially important for an anti-detect browser)
|
||||
4. Performance implications
|
||||
5. Consistency with the project's patterns
|
||||
|
||||
Respond ONLY with valid JSON (no markdown fences).
|
||||
|
||||
JSON structure:
|
||||
{
|
||||
"summary": "Brief 1-2 sentence summary of what this PR does",
|
||||
"quality_score": "good"|"needs_work"|"critical_issues",
|
||||
"feedback": ["feedback point 1", "feedback point 2"],
|
||||
"suggestions": ["suggestion 1", "suggestion 2"],
|
||||
"security_notes": ["security note if any"] or []
|
||||
}
|
||||
|
||||
IMPORTANT CONSTRAINTS:
|
||||
- Maximum 4 items in feedback array
|
||||
- Maximum 3 items in suggestions array
|
||||
- Maximum 2 items in security_notes array
|
||||
- Each array item must be under 150 characters
|
||||
- summary must be under 200 characters
|
||||
- Be constructive and helpful, not harsh
|
||||
- Output ONLY the JSON object, nothing else
|
||||
model: gpt-5-mini
|
||||
prompt-file: .github/prompts/pr-review.prompt.yml
|
||||
input: |
|
||||
pr_title: ${{ github.event.pull_request.title }}
|
||||
file_input: |
|
||||
pr_body: ./pr_body.txt
|
||||
pr_diff: ./pr_diff_truncated.txt
|
||||
max-tokens: 1024
|
||||
|
||||
- name: Post PR feedback comment
|
||||
env:
|
||||
@@ -364,14 +273,14 @@ jobs:
|
||||
gh pr comment ${{ github.event.pull_request.number }} --body-file comment.md
|
||||
|
||||
- name: Run opencode analysis
|
||||
uses: anomalyco/opencode/github@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
|
||||
uses: anomalyco/opencode/github@296250f1b7e1ec992a3a33bee999f5e09a1697d0 #v1.2.10
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
with:
|
||||
model: zai-coding-plan/glm-4.7
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -f pr_diff.txt pr_diff_truncated.txt pr_analysis.txt comment.md
|
||||
run: rm -f pr_diff.txt pr_diff_truncated.txt pr_body.txt comment.md
|
||||
|
||||
opencode-command:
|
||||
if: |
|
||||
@@ -386,7 +295,7 @@ jobs:
|
||||
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
|
||||
|
||||
- name: Run opencode
|
||||
uses: anomalyco/opencode/github@d1482e148399bfaf808674549199f5f4aa69a22d #v1.2.4
|
||||
uses: anomalyco/opencode/github@296250f1b7e1ec992a3a33bee999f5e09a1697d0 #v1.2.10
|
||||
env:
|
||||
ZHIPU_API_KEY: ${{ secrets.ZHIPU_API_KEY }}
|
||||
with:
|
||||
|
||||
@@ -84,45 +84,12 @@ jobs:
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
uses: actions/ai-inference@a380166897b5408b8fb7dddd148142794cb5624a # v2.0.6
|
||||
with:
|
||||
prompt-file: commits.txt
|
||||
system-prompt: |
|
||||
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful anti-detect browser.
|
||||
|
||||
Analyze the provided commit messages and generate well-structured release notes following this format:
|
||||
|
||||
## What's New in ${{ steps.get-previous-tag.outputs.current-tag }}
|
||||
|
||||
[Brief 1-2 sentence overview of the release]
|
||||
|
||||
### ✨ New Features
|
||||
[List new features with brief descriptions]
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
[List bug fixes]
|
||||
|
||||
### 🔧 Improvements
|
||||
[List improvements and enhancements]
|
||||
|
||||
### 📚 Documentation
|
||||
[List documentation updates if any]
|
||||
|
||||
### 🔄 Dependencies
|
||||
[List dependency updates if any]
|
||||
|
||||
### 🛠️ Developer Experience
|
||||
[List development-related changes if any]
|
||||
|
||||
Guidelines:
|
||||
- Use clear, user-friendly language
|
||||
- Group related commits logically
|
||||
- Omit minor commits like formatting, typos unless significant
|
||||
- Focus on user-facing changes
|
||||
- Use emojis sparingly and consistently
|
||||
- Keep descriptions concise but informative
|
||||
- If commits are unclear, infer the purpose from the context
|
||||
|
||||
The application is a desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
|
||||
model: gpt-5-mini
|
||||
prompt-file: .github/prompts/release-notes.prompt.yml
|
||||
input: |
|
||||
version: ${{ steps.get-previous-tag.outputs.current-tag }}
|
||||
file_input: |
|
||||
commits: ./commits.txt
|
||||
max-tokens: 4096
|
||||
|
||||
- name: Update release with generated notes
|
||||
if: steps.get-release.outputs.is-prerelease == 'false'
|
||||
|
||||
@@ -231,6 +231,93 @@ jobs:
|
||||
# branch: main
|
||||
# commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
|
||||
|
||||
publish-repos:
|
||||
needs: [release]
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Download Linux packages from release
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
mkdir -p /tmp/packages
|
||||
gh release download "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "*.deb" \
|
||||
--dir /tmp/packages
|
||||
gh release download "$GITHUB_REF_NAME" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--pattern "*.rpm" \
|
||||
--dir /tmp/packages
|
||||
echo "Downloaded packages:"
|
||||
ls -la /tmp/packages/
|
||||
|
||||
- name: Setup Go
|
||||
uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff #v5.6.0
|
||||
with:
|
||||
go-version: "1.23"
|
||||
cache: false
|
||||
|
||||
- name: Install repogen
|
||||
run: |
|
||||
go install github.com/ralt/repogen/cmd/repogen@latest
|
||||
echo "$(go env GOPATH)/bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: Configure AWS CLI for Cloudflare R2
|
||||
run: |
|
||||
aws configure set aws_access_key_id "${{ secrets.R2_ACCESS_KEY_ID }}"
|
||||
aws configure set aws_secret_access_key "${{ secrets.R2_SECRET_ACCESS_KEY }}"
|
||||
aws configure set default.region auto
|
||||
|
||||
- name: Sync existing repo metadata from R2
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
mkdir -p /tmp/repo
|
||||
aws s3 sync "s3://${R2_BUCKET}/dists" /tmp/repo/dists \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
|
||||
aws s3 sync "s3://${R2_BUCKET}/repodata" /tmp/repo/repodata \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete 2>/dev/null || true
|
||||
|
||||
- name: Generate repository with repogen
|
||||
run: |
|
||||
repogen generate \
|
||||
--input-dir /tmp/packages \
|
||||
--output-dir /tmp/repo \
|
||||
--incremental \
|
||||
--arch amd64,arm64 \
|
||||
--origin "Donut Browser" \
|
||||
--label "Donut Browser" \
|
||||
--codename stable \
|
||||
--components main \
|
||||
--verbose
|
||||
|
||||
- name: Upload repository to R2
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
aws s3 sync /tmp/repo/dists "s3://${R2_BUCKET}/dists" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete
|
||||
aws s3 sync /tmp/repo/pool "s3://${R2_BUCKET}/pool" \
|
||||
--endpoint-url "${R2_ENDPOINT}"
|
||||
aws s3 sync /tmp/repo/repodata "s3://${R2_BUCKET}/repodata" \
|
||||
--endpoint-url "${R2_ENDPOINT}" --delete
|
||||
aws s3 sync /tmp/repo/Packages "s3://${R2_BUCKET}/Packages" \
|
||||
--endpoint-url "${R2_ENDPOINT}"
|
||||
|
||||
- name: Verify upload
|
||||
env:
|
||||
R2_ENDPOINT: ${{ secrets.R2_ENDPOINT_URL }}
|
||||
R2_BUCKET: ${{ secrets.R2_BUCKET_NAME }}
|
||||
run: |
|
||||
echo "DEB repo:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/dists/stable/" --endpoint-url "${R2_ENDPOINT}"
|
||||
echo "RPM repo:"
|
||||
aws s3 ls "s3://${R2_BUCKET}/repodata/" --endpoint-url "${R2_ENDPOINT}"
|
||||
|
||||
bump-homebrew-cask:
|
||||
needs: [release]
|
||||
runs-on: macos-latest
|
||||
@@ -242,5 +329,4 @@ jobs:
|
||||
HOMEBREW_GITHUB_API_TOKEN: ${{ secrets.HOMEBREW_GITHUB_API_TOKEN }}
|
||||
run: |
|
||||
VERSION="${GITHUB_REF_NAME#v}"
|
||||
brew tap --force homebrew/cask
|
||||
brew bump-cask-pr --version "$VERSION" --no-browse donutbrowser
|
||||
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@78bc6fb2c0d734235d57a2d6b9de923cc325ebdd #v1.43.4
|
||||
uses: crate-ci/typos@57b11c6b7e54c402ccd9cda953f1072ec4f78e33 #v1.43.5
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
|
||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: "This issue has been inactive for 60 days. Please respond to keep it open."
|
||||
|
||||
Vendored
+10
@@ -31,6 +31,7 @@
|
||||
"cmdk",
|
||||
"codegen",
|
||||
"codesign",
|
||||
"codesigning",
|
||||
"commitish",
|
||||
"Crashpad",
|
||||
"CTYPE",
|
||||
@@ -44,6 +45,7 @@
|
||||
"devedition",
|
||||
"direnv",
|
||||
"distro",
|
||||
"dists",
|
||||
"doctest",
|
||||
"doesn",
|
||||
"domcontentloaded",
|
||||
@@ -65,6 +67,7 @@
|
||||
"gettimezone",
|
||||
"gifs",
|
||||
"globset",
|
||||
"GOPATH",
|
||||
"gsettings",
|
||||
"healthreport",
|
||||
"hiddenimports",
|
||||
@@ -77,6 +80,7 @@
|
||||
"idletime",
|
||||
"idna",
|
||||
"infobars",
|
||||
"inkey",
|
||||
"Inno",
|
||||
"kdeglobals",
|
||||
"keras",
|
||||
@@ -132,10 +136,12 @@
|
||||
"oscpu",
|
||||
"outpath",
|
||||
"OVPN",
|
||||
"passout",
|
||||
"patchelf",
|
||||
"pathex",
|
||||
"pathlib",
|
||||
"peerconnection",
|
||||
"PHANDLER",
|
||||
"pids",
|
||||
"pixbuf",
|
||||
"pkexec",
|
||||
@@ -154,6 +160,9 @@
|
||||
"pytest",
|
||||
"pyyaml",
|
||||
"quic",
|
||||
"ralt",
|
||||
"repodata",
|
||||
"repogen",
|
||||
"reportingpolicy",
|
||||
"reqwest",
|
||||
"ridedott",
|
||||
@@ -173,6 +182,7 @@
|
||||
"shadcn",
|
||||
"showcursor",
|
||||
"shutil",
|
||||
"sighandler",
|
||||
"signon",
|
||||
"signum",
|
||||
"sklearn",
|
||||
|
||||
@@ -18,8 +18,8 @@
|
||||
"test:e2e": "jest --config ./test/jest-e2e.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.994.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.994.0",
|
||||
"@aws-sdk/client-s3": "^3.995.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.995.0",
|
||||
"@nestjs/common": "^11.1.14",
|
||||
"@nestjs/config": "^4.0.3",
|
||||
"@nestjs/core": "^11.1.14",
|
||||
|
||||
+4
-4
@@ -56,9 +56,9 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.3",
|
||||
"flag-icons": "^7.5.0",
|
||||
"i18next": "^25.8.11",
|
||||
"i18next": "^25.8.13",
|
||||
"lucide-react": "^0.575.0",
|
||||
"motion": "^12.34.2",
|
||||
"motion": "^12.34.3",
|
||||
"next": "^16.1.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
@@ -72,7 +72,7 @@
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.4.3",
|
||||
"@biomejs/biome": "2.4.4",
|
||||
"@tailwindcss/postcss": "^4.2.0",
|
||||
"@tauri-apps/cli": "~2.10.0",
|
||||
"@types/color": "^4.2.0",
|
||||
@@ -87,7 +87,7 @@
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.29.3",
|
||||
"packageManager": "pnpm@10.30.1",
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||
"biome check --fix"
|
||||
|
||||
Generated
+187
-187
@@ -84,14 +84,14 @@ importers:
|
||||
specifier: ^7.5.0
|
||||
version: 7.5.0
|
||||
i18next:
|
||||
specifier: ^25.8.11
|
||||
version: 25.8.11(typescript@5.9.3)
|
||||
specifier: ^25.8.13
|
||||
version: 25.8.13(typescript@5.9.3)
|
||||
lucide-react:
|
||||
specifier: ^0.575.0
|
||||
version: 0.575.0(react@19.2.4)
|
||||
motion:
|
||||
specifier: ^12.34.2
|
||||
version: 12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
specifier: ^12.34.3
|
||||
version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
next:
|
||||
specifier: ^16.1.6
|
||||
version: 16.1.6(@babel/core@7.29.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
@@ -109,7 +109,7 @@ importers:
|
||||
version: 19.2.4(react@19.2.4)
|
||||
react-i18next:
|
||||
specifier: ^16.5.4
|
||||
version: 16.5.4(i18next@25.8.11(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
version: 16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3)
|
||||
react-icons:
|
||||
specifier: ^5.5.0
|
||||
version: 5.5.0(react@19.2.4)
|
||||
@@ -127,8 +127,8 @@ importers:
|
||||
version: 2.3.0
|
||||
devDependencies:
|
||||
'@biomejs/biome':
|
||||
specifier: 2.4.3
|
||||
version: 2.4.3
|
||||
specifier: 2.4.4
|
||||
version: 2.4.4
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
@@ -172,11 +172,11 @@ importers:
|
||||
donut-sync:
|
||||
dependencies:
|
||||
'@aws-sdk/client-s3':
|
||||
specifier: ^3.994.0
|
||||
version: 3.994.0
|
||||
specifier: ^3.995.0
|
||||
version: 3.995.0
|
||||
'@aws-sdk/s3-request-presigner':
|
||||
specifier: ^3.994.0
|
||||
version: 3.994.0
|
||||
specifier: ^3.995.0
|
||||
version: 3.995.0
|
||||
'@nestjs/common':
|
||||
specifier: ^11.1.14
|
||||
version: 11.1.14(reflect-metadata@0.2.2)(rxjs@7.8.2)
|
||||
@@ -308,8 +308,8 @@ packages:
|
||||
'@aws-crypto/util@5.2.0':
|
||||
resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==}
|
||||
|
||||
'@aws-sdk/client-s3@3.994.0':
|
||||
resolution: {integrity: sha512-zIVQt/XfE2zTFrcPEf8R+KRaRD1++XHMPRhxXM2kVA6NA6Aq/cFCUyYOYYwSbWLF/XeToaX1auYGn3IoZKruPQ==}
|
||||
'@aws-sdk/client-s3@3.995.0':
|
||||
resolution: {integrity: sha512-r+t8qrQ0m9zoovYOH+wilp/glFRB/E+blsDyWzq2C+9qmyhCAQwaxjLaHM8T/uluAmhtZQIYqOH9ILRnvWtRNw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-sso@3.993.0':
|
||||
@@ -404,12 +404,12 @@ packages:
|
||||
resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/s3-request-presigner@3.994.0':
|
||||
resolution: {integrity: sha512-g/jYc++IunLJZpeyLJrbC39XAf57BYjWjKkxRQlwj5fC90Scg4t/2FS0BV2u/U9UZX5HqpQQahI60tPEN84JqQ==}
|
||||
'@aws-sdk/s3-request-presigner@3.995.0':
|
||||
resolution: {integrity: sha512-+eJYJcB6XkViGbSAoWl7sQx3izt2y64Oy6SWec/HY2a6ibd5RtUJbEIkvoS2RUu7drm4yu2WyLgSNMQg/7nt0g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.994.0':
|
||||
resolution: {integrity: sha512-8y04Lv497KKd7f2TVlm2RaKQaNfnY17ZH8d3m+7sW/3R3BhZvHgWQZyqTb/vcN2ERz1YAnWx6woJyB3ZNFvakw==}
|
||||
'@aws-sdk/signature-v4-multi-region@3.995.0':
|
||||
resolution: {integrity: sha512-9Qx5JcAucnxnomREPb2D6L8K8GLG0rknt3+VK/BU3qTUynAcV4W21DQ04Z2RKDw+DYpW88lsZpXbVetWST2WUg==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/token-providers@3.993.0':
|
||||
@@ -428,8 +428,8 @@ packages:
|
||||
resolution: {integrity: sha512-j6vioBeRZ4eHX4SWGvGPpwGg/xSOcK7f1GL0VM+rdf3ZFTIsUEhCFmD78B+5r2PgztcECSzEfvHQX01k8dPQPw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/util-endpoints@3.994.0':
|
||||
resolution: {integrity: sha512-L2obUBw4ACMMd1F/SG5LdfPyZ0xJNs9Maifwr3w0uWO+4YvHmk9FfRskfSfE/SLZ9S387oSZ+1xiP7BfVCP/Og==}
|
||||
'@aws-sdk/util-endpoints@3.995.0':
|
||||
resolution: {integrity: sha512-aym/pjB8SLbo9w2nmkrDdAAVKVlf7CM71B9mKhjDbJTzwpSFBPHqJIMdDyj0mLumKC0aIVDr1H6U+59m9GvMFw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/util-format-url@3.972.3':
|
||||
@@ -443,8 +443,8 @@ packages:
|
||||
'@aws-sdk/util-user-agent-browser@3.972.3':
|
||||
resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==}
|
||||
|
||||
'@aws-sdk/util-user-agent-node@3.972.9':
|
||||
resolution: {integrity: sha512-JNswdsLdQemxqaSIBL2HRhsHPUBBziAgoi5RQv6/9avmE5g5RSdt1hWr3mHJ7OxqRYf+KeB11ExWbiqfrnoeaA==}
|
||||
'@aws-sdk/util-user-agent-node@3.972.10':
|
||||
resolution: {integrity: sha512-LVXzICPlsheET+sE6tkcS47Q5HkSTrANIlqL1iFxGAY/wRQ236DX/PCAK56qMh9QJoXAfXfoRW0B0Og4R+X7Nw==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
peerDependencies:
|
||||
aws-crt: '>=1.0.0'
|
||||
@@ -641,59 +641,59 @@ packages:
|
||||
'@bcoe/v8-coverage@0.2.3':
|
||||
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
|
||||
|
||||
'@biomejs/biome@2.4.3':
|
||||
resolution: {integrity: sha512-cBrjf6PNF6yfL8+kcNl85AjiK2YHNsbU0EvDOwiZjBPbMbQ5QcgVGFpjD0O52p8nec5O8NYw7PKw3xUR7fPAkQ==}
|
||||
'@biomejs/biome@2.4.4':
|
||||
resolution: {integrity: sha512-tigwWS5KfJf0cABVd52NVaXyAVv4qpUXOWJ1rxFL8xF1RVoeS2q/LK+FHgYoKMclJCuRoCWAPy1IXaN9/mS61Q==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
hasBin: true
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.4.3':
|
||||
resolution: {integrity: sha512-eOafSFlI/CF4id2tlwq9CVHgeEqvTL5SrhWff6ZORp6S3NL65zdsR3ugybItkgF8Pf4D9GSgtbB6sE3UNgOM9w==}
|
||||
'@biomejs/cli-darwin-arm64@2.4.4':
|
||||
resolution: {integrity: sha512-jZ+Xc6qvD6tTH5jM6eKX44dcbyNqJHssfl2nnwT6vma6B1sj7ZLTGIk6N5QwVBs5xGN52r3trk5fgd3sQ9We9A==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.4.3':
|
||||
resolution: {integrity: sha512-V2+av4ilbWcBMNufTtMMXVW00nPwyIjI5qf7n9wSvUaZ+tt0EvMGk46g9sAFDJBEDOzSyoRXiSP6pCvKTOEbPA==}
|
||||
'@biomejs/cli-darwin-x64@2.4.4':
|
||||
resolution: {integrity: sha512-Dh1a/+W+SUCXhEdL7TiX3ArPTFCQKJTI1mGncZNWfO+6suk+gYA4lNyJcBB+pwvF49uw0pEbUS49BgYOY4hzUg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.3':
|
||||
resolution: {integrity: sha512-QuFzvsGo8BA4Xm7jGX5idkw6BqFblcCPySMTvq0AhGYnhUej5VJIDJbmTKfHqwjHepZiC4fA+T5i6wmiZolZNw==}
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.4':
|
||||
resolution: {integrity: sha512-+sPAXq3bxmFwhVFJnSwkSF5Rw2ZAJMH3MF6C9IveAEOdSpgajPhoQhbbAK12SehN9j2QrHpk4J/cHsa/HqWaYQ==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.4.3':
|
||||
resolution: {integrity: sha512-0m+O0x9FgK99FAwDK+fiDtjs2wnqq7bvfj17KJVeCkTwT/liI+Q9njJG7lwXK0iSJVXeFNRIxukpVI3SifMYAA==}
|
||||
'@biomejs/cli-linux-arm64@2.4.4':
|
||||
resolution: {integrity: sha512-V/NFfbWhsUU6w+m5WYbBenlEAz8eYnSqRMDMAW3K+3v0tYVkNyZn8VU0XPxk/lOqNXLSCCrV7FmV/u3SjCBShg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.4.3':
|
||||
resolution: {integrity: sha512-qEc0OCpj/uytruQ4wLM0yWNJLZy0Up8H1Er5MW3SrstqM6J2d4XqdNA86xzCy8MQCHpoVZ3lFye3GBlIL4/ljw==}
|
||||
'@biomejs/cli-linux-x64-musl@2.4.4':
|
||||
resolution: {integrity: sha512-gGvFTGpOIQDb5CQ2VC0n9Z2UEqlP46c4aNgHmAMytYieTGEcfqhfCFnhs6xjt0S3igE6q5GLuIXtdQt3Izok+g==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@biomejs/cli-linux-x64@2.4.3':
|
||||
resolution: {integrity: sha512-NVqh0saIU0u5OfOp/0jFdlKRE59+XyMvWmtx0f6Nm/2OpdxBl04coRIftBbY9d1gfu+23JVv4CItAqPYrjYh5w==}
|
||||
'@biomejs/cli-linux-x64@2.4.4':
|
||||
resolution: {integrity: sha512-R4+ZCDtG9kHArasyBO+UBD6jr/FcFCTH8QkNTOCu0pRJzCWyWC4EtZa2AmUZB5h3e0jD7bRV2KvrENcf8rndBg==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.4.3':
|
||||
resolution: {integrity: sha512-gRO96vrIARilv/Cp2ZnmNNL5LSZg3RO75GPp13hsLO3N4YVpE7saaMDp2bcyV48y2N2Pbit1brkGVGta0yd6VQ==}
|
||||
'@biomejs/cli-win32-arm64@2.4.4':
|
||||
resolution: {integrity: sha512-trzCqM7x+Gn832zZHgr28JoYagQNX4CZkUZhMUac2YxvvyDRLJDrb5m9IA7CaZLlX6lTQmADVfLEKP1et1Ma4Q==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@biomejs/cli-win32-x64@2.4.3':
|
||||
resolution: {integrity: sha512-vSm/vOJe06pf14aGHfHl3Ar91Nlx4YYmohElDJ+17UbRwe99n987S/MhAlQOkONqf1utJor04ChkCPmKb8SWdw==}
|
||||
'@biomejs/cli-win32-x64@2.4.4':
|
||||
resolution: {integrity: sha512-gnOHKVPFAAPrpoPt2t+Q6FZ7RPry/FDV3GcpU53P3PtLNnQjBmKyN2Vh/JtqXet+H4pme8CC76rScwdjDcT1/A==}
|
||||
engines: {node: '>=14.21.3'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
@@ -2218,141 +2218,141 @@ packages:
|
||||
'@rolldown/pluginutils@1.0.0-rc.3':
|
||||
resolution: {integrity: sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.57.1':
|
||||
resolution: {integrity: sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg==}
|
||||
'@rollup/rollup-android-arm-eabi@4.58.0':
|
||||
resolution: {integrity: sha512-mr0tmS/4FoVk1cnaeN244A/wjvGDNItZKR8hRhnmCzygyRXYtKF5jVDSIILR1U97CTzAYmbgIj/Dukg62ggG5w==}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-android-arm64@4.57.1':
|
||||
resolution: {integrity: sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w==}
|
||||
'@rollup/rollup-android-arm64@4.58.0':
|
||||
resolution: {integrity: sha512-+s++dbp+/RTte62mQD9wLSbiMTV+xr/PeRJEc/sFZFSBRlHPNPVaf5FXlzAL77Mr8FtSfQqCN+I598M8U41ccQ==}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.57.1':
|
||||
resolution: {integrity: sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg==}
|
||||
'@rollup/rollup-darwin-arm64@4.58.0':
|
||||
resolution: {integrity: sha512-MFWBwTcYs0jZbINQBXHfSrpSQJq3IUOakcKPzfeSznONop14Pxuqa0Kg19GD0rNBMPQI2tFtu3UzapZpH0Uc1Q==}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.57.1':
|
||||
resolution: {integrity: sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w==}
|
||||
'@rollup/rollup-darwin-x64@4.58.0':
|
||||
resolution: {integrity: sha512-yiKJY7pj9c9JwzuKYLFaDZw5gma3fI9bkPEIyofvVfsPqjCWPglSHdpdwXpKGvDeYDms3Qal8qGMEHZ1M/4Udg==}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.57.1':
|
||||
resolution: {integrity: sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug==}
|
||||
'@rollup/rollup-freebsd-arm64@4.58.0':
|
||||
resolution: {integrity: sha512-x97kCoBh5MOevpn/CNK9W1x8BEzO238541BGWBc315uOlN0AD/ifZ1msg+ZQB05Ux+VF6EcYqpiagfLJ8U3LvQ==}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.57.1':
|
||||
resolution: {integrity: sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q==}
|
||||
'@rollup/rollup-freebsd-x64@4.58.0':
|
||||
resolution: {integrity: sha512-Aa8jPoZ6IQAG2eIrcXPpjRcMjROMFxCt1UYPZZtCxRV68WkuSigYtQ/7Zwrcr2IvtNJo7T2JfDXyMLxq5L4Jlg==}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.57.1':
|
||||
resolution: {integrity: sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw==}
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.58.0':
|
||||
resolution: {integrity: sha512-Ob8YgT5kD/lSIYW2Rcngs5kNB/44Q2RzBSPz9brf2WEtcGR7/f/E9HeHn1wYaAwKBni+bdXEwgHvUd0x12lQSA==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
|
||||
resolution: {integrity: sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw==}
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.58.0':
|
||||
resolution: {integrity: sha512-K+RI5oP1ceqoadvNt1FecL17Qtw/n9BgRSzxif3rTL2QlIu88ccvY+Y9nnHe/cmT5zbH9+bpiJuG1mGHRVwF4Q==}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g==}
|
||||
'@rollup/rollup-linux-arm64-gnu@4.58.0':
|
||||
resolution: {integrity: sha512-T+17JAsCKUjmbopcKepJjHWHXSjeW7O5PL7lEFaeQmiVyw4kkc5/lyYKzrv6ElWRX/MrEWfPiJWqbTvfIvjM1Q==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q==}
|
||||
'@rollup/rollup-linux-arm64-musl@4.58.0':
|
||||
resolution: {integrity: sha512-cCePktb9+6R9itIJdeCFF9txPU7pQeEHB5AbHu/MKsfH/k70ZtOeq1k4YAtBv9Z7mmKI5/wOLYjQ+B9QdxR6LA==}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA==}
|
||||
'@rollup/rollup-linux-loong64-gnu@4.58.0':
|
||||
resolution: {integrity: sha512-iekUaLkfliAsDl4/xSdoCJ1gnnIXvoNz85C8U8+ZxknM5pBStfZjeXgB8lXobDQvvPRCN8FPmmuTtH+z95HTmg==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw==}
|
||||
'@rollup/rollup-linux-loong64-musl@4.58.0':
|
||||
resolution: {integrity: sha512-68ofRgJNl/jYJbxFjCKE7IwhbfxOl1muPN4KbIqAIe32lm22KmU7E8OPvyy68HTNkI2iV/c8y2kSPSm2mW/Q9Q==}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w==}
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.58.0':
|
||||
resolution: {integrity: sha512-dpz8vT0i+JqUKuSNPCP5SYyIV2Lh0sNL1+FhM7eLC457d5B9/BC3kDPp5BBftMmTNsBarcPcoz5UGSsnCiw4XQ==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw==}
|
||||
'@rollup/rollup-linux-ppc64-musl@4.58.0':
|
||||
resolution: {integrity: sha512-4gdkkf9UJ7tafnweBCR/mk4jf3Jfl0cKX9Np80t5i78kjIH0ZdezUv/JDI2VtruE5lunfACqftJ8dIMGN4oHew==}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A==}
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.58.0':
|
||||
resolution: {integrity: sha512-YFS4vPnOkDTD/JriUeeZurFYoJhPf9GQQEF/v4lltp3mVcBmnsAdjEWhr2cjUCZzZNzxCG0HZOvJU44UGHSdzw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw==}
|
||||
'@rollup/rollup-linux-riscv64-musl@4.58.0':
|
||||
resolution: {integrity: sha512-x2xgZlFne+QVNKV8b4wwaCS8pwq3y14zedZ5DqLzjdRITvreBk//4Knbcvm7+lWmms9V9qFp60MtUd0/t/PXPw==}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg==}
|
||||
'@rollup/rollup-linux-s390x-gnu@4.58.0':
|
||||
resolution: {integrity: sha512-jIhrujyn4UnWF8S+DHSkAkDEO3hLX0cjzxJZPLF80xFyzyUIYgSMRcYQ3+uqEoyDD2beGq7Dj7edi8OnJcS/hg==}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg==}
|
||||
'@rollup/rollup-linux-x64-gnu@4.58.0':
|
||||
resolution: {integrity: sha512-+410Srdoh78MKSJxTQ+hZ/Mx+ajd6RjjPwBPNd0R3J9FtL6ZA0GqiiyNjCO9In0IzZkCNrpGymSfn+kgyPQocg==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.57.1':
|
||||
resolution: {integrity: sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw==}
|
||||
'@rollup/rollup-linux-x64-musl@4.58.0':
|
||||
resolution: {integrity: sha512-ZjMyby5SICi227y1MTR3VYBpFTdZs823Rs/hpakufleBoufoOIB6jtm9FEoxn/cgO7l6PM2rCEl5Kre5vX0QrQ==}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.57.1':
|
||||
resolution: {integrity: sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw==}
|
||||
'@rollup/rollup-openbsd-x64@4.58.0':
|
||||
resolution: {integrity: sha512-ds4iwfYkSQ0k1nb8LTcyXw//ToHOnNTJtceySpL3fa7tc/AsE+UpUFphW126A6fKBGJD5dhRvg8zw1rvoGFxmw==}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.57.1':
|
||||
resolution: {integrity: sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ==}
|
||||
'@rollup/rollup-openharmony-arm64@4.58.0':
|
||||
resolution: {integrity: sha512-fd/zpJniln4ICdPkjWFhZYeY/bpnaN9pGa6ko+5WD38I0tTqk9lXMgXZg09MNdhpARngmxiCg0B0XUamNw/5BQ==}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.57.1':
|
||||
resolution: {integrity: sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ==}
|
||||
'@rollup/rollup-win32-arm64-msvc@4.58.0':
|
||||
resolution: {integrity: sha512-YpG8dUOip7DCz3nr/JUfPbIUo+2d/dy++5bFzgi4ugOGBIox+qMbbqt/JoORwvI/C9Kn2tz6+Bieoqd5+B1CjA==}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.57.1':
|
||||
resolution: {integrity: sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew==}
|
||||
'@rollup/rollup-win32-ia32-msvc@4.58.0':
|
||||
resolution: {integrity: sha512-b9DI8jpFQVh4hIXFr0/+N/TzLdpBIoPzjt0Rt4xJbW3mzguV3mduR9cNgiuFcuL/TeORejJhCWiAXe3E/6PxWA==}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.57.1':
|
||||
resolution: {integrity: sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ==}
|
||||
'@rollup/rollup-win32-x64-gnu@4.58.0':
|
||||
resolution: {integrity: sha512-CSrVpmoRJFN06LL9xhkitkwUcTZtIotYAF5p6XOR2zW0Zz5mzb3IPpcoPhB02frzMHFNo1reQ9xSF5fFm3hUsQ==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.57.1':
|
||||
resolution: {integrity: sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA==}
|
||||
'@rollup/rollup-win32-x64-msvc@4.58.0':
|
||||
resolution: {integrity: sha512-QFsBgQNTnh5K0t/sBsjJLq24YVqEIVkGpfN2VHsnN90soZyhaiA9UUHufcctVNL4ypJY0wrwad0wslx2KJQ1/w==}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
@@ -3866,8 +3866,8 @@ packages:
|
||||
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
framer-motion@12.34.2:
|
||||
resolution: {integrity: sha512-CcnYTzbRybm1/OE8QLXfXI8gR1cx5T4dF3D2kn5IyqsGNeLAKl2iFHb2BzFyXBGqESntDt6rPYl4Jhrb7tdB8g==}
|
||||
framer-motion@12.34.3:
|
||||
resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
@@ -3997,8 +3997,8 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
i18next@25.8.11:
|
||||
resolution: {integrity: sha512-LZ32llTLGludnddjLoijHV7TbmVubU5eJnsWf8taiuM3jmSfUuvBLuyDeubJKS1yBjLBgb7As124M4KWNcBvpw==}
|
||||
i18next@25.8.13:
|
||||
resolution: {integrity: sha512-E0vzjBY1yM+nsFrtgkjLhST2NBkirkvOVoQa0MSldhsuZ3jUge7ZNpuwG0Cfc74zwo5ZwRzg3uOgT+McBn32iA==}
|
||||
peerDependencies:
|
||||
typescript: ^5
|
||||
peerDependenciesMeta:
|
||||
@@ -4554,14 +4554,14 @@ packages:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
hasBin: true
|
||||
|
||||
motion-dom@12.34.2:
|
||||
resolution: {integrity: sha512-n7gknp7gHcW7DUcmet0JVPLVHmE3j9uWwDp5VbE3IkCNnW5qdu0mOhjNYzXMkrQjrgr+h6Db3EDM2QBhW2qNxQ==}
|
||||
motion-dom@12.34.3:
|
||||
resolution: {integrity: sha512-sYgFe+pR9aIM7o4fhs2aXtOI+oqlUd33N9Yoxcgo1Fv7M20sRkHtCmzE/VRNIcq7uNJ+qio+Xubt1FXH3pQ+eQ==}
|
||||
|
||||
motion-utils@12.29.2:
|
||||
resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==}
|
||||
|
||||
motion@12.34.2:
|
||||
resolution: {integrity: sha512-QAthwCtW6N0TpZ+bBmBMzdwuftoay2yFV2DT44jRcUQhPbFPdAX+pjzmIUNM3sMYDD5OAraJagRGAKE8q5OsmA==}
|
||||
motion@12.34.3:
|
||||
resolution: {integrity: sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==}
|
||||
peerDependencies:
|
||||
'@emotion/is-prop-valid': '*'
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
@@ -4970,8 +4970,8 @@ packages:
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rollup@4.57.1:
|
||||
resolution: {integrity: sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==}
|
||||
rollup@4.58.0:
|
||||
resolution: {integrity: sha512-wbT0mBmWbIvvq8NeEYWWvevvxnOyhKChir47S66WCxw1SXqhw7ssIYejnQEVt7XYQpsj2y8F9PM+Cr3SNEa0gw==}
|
||||
engines: {node: '>=18.0.0', npm: '>=8.0.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -5691,7 +5691,7 @@ snapshots:
|
||||
'@smithy/util-utf8': 2.3.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-s3@3.994.0':
|
||||
'@aws-sdk/client-s3@3.995.0':
|
||||
dependencies:
|
||||
'@aws-crypto/sha1-browser': 5.2.0
|
||||
'@aws-crypto/sha256-browser': 5.2.0
|
||||
@@ -5709,11 +5709,11 @@ snapshots:
|
||||
'@aws-sdk/middleware-ssec': 3.972.3
|
||||
'@aws-sdk/middleware-user-agent': 3.972.11
|
||||
'@aws-sdk/region-config-resolver': 3.972.3
|
||||
'@aws-sdk/signature-v4-multi-region': 3.994.0
|
||||
'@aws-sdk/signature-v4-multi-region': 3.995.0
|
||||
'@aws-sdk/types': 3.973.1
|
||||
'@aws-sdk/util-endpoints': 3.994.0
|
||||
'@aws-sdk/util-endpoints': 3.995.0
|
||||
'@aws-sdk/util-user-agent-browser': 3.972.3
|
||||
'@aws-sdk/util-user-agent-node': 3.972.9
|
||||
'@aws-sdk/util-user-agent-node': 3.972.10
|
||||
'@smithy/config-resolver': 4.4.6
|
||||
'@smithy/core': 3.23.2
|
||||
'@smithy/eventstream-serde-browser': 4.2.8
|
||||
@@ -5764,7 +5764,7 @@ snapshots:
|
||||
'@aws-sdk/types': 3.973.1
|
||||
'@aws-sdk/util-endpoints': 3.993.0
|
||||
'@aws-sdk/util-user-agent-browser': 3.972.3
|
||||
'@aws-sdk/util-user-agent-node': 3.972.9
|
||||
'@aws-sdk/util-user-agent-node': 3.972.10
|
||||
'@smithy/config-resolver': 4.4.6
|
||||
'@smithy/core': 3.23.2
|
||||
'@smithy/fetch-http-handler': 5.3.9
|
||||
@@ -6026,7 +6026,7 @@ snapshots:
|
||||
'@aws-sdk/types': 3.973.1
|
||||
'@aws-sdk/util-endpoints': 3.993.0
|
||||
'@aws-sdk/util-user-agent-browser': 3.972.3
|
||||
'@aws-sdk/util-user-agent-node': 3.972.9
|
||||
'@aws-sdk/util-user-agent-node': 3.972.10
|
||||
'@smithy/config-resolver': 4.4.6
|
||||
'@smithy/core': 3.23.2
|
||||
'@smithy/fetch-http-handler': 5.3.9
|
||||
@@ -6064,9 +6064,9 @@ snapshots:
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/s3-request-presigner@3.994.0':
|
||||
'@aws-sdk/s3-request-presigner@3.995.0':
|
||||
dependencies:
|
||||
'@aws-sdk/signature-v4-multi-region': 3.994.0
|
||||
'@aws-sdk/signature-v4-multi-region': 3.995.0
|
||||
'@aws-sdk/types': 3.973.1
|
||||
'@aws-sdk/util-format-url': 3.972.3
|
||||
'@smithy/middleware-endpoint': 4.4.16
|
||||
@@ -6075,7 +6075,7 @@ snapshots:
|
||||
'@smithy/types': 4.12.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/signature-v4-multi-region@3.994.0':
|
||||
'@aws-sdk/signature-v4-multi-region@3.995.0':
|
||||
dependencies:
|
||||
'@aws-sdk/middleware-sdk-s3': 3.972.11
|
||||
'@aws-sdk/types': 3.973.1
|
||||
@@ -6113,7 +6113,7 @@ snapshots:
|
||||
'@smithy/util-endpoints': 3.2.8
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/util-endpoints@3.994.0':
|
||||
'@aws-sdk/util-endpoints@3.995.0':
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.1
|
||||
'@smithy/types': 4.12.0
|
||||
@@ -6139,7 +6139,7 @@ snapshots:
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/util-user-agent-node@3.972.9':
|
||||
'@aws-sdk/util-user-agent-node@3.972.10':
|
||||
dependencies:
|
||||
'@aws-sdk/middleware-user-agent': 3.972.11
|
||||
'@aws-sdk/types': 3.973.1
|
||||
@@ -6356,39 +6356,39 @@ snapshots:
|
||||
|
||||
'@bcoe/v8-coverage@0.2.3': {}
|
||||
|
||||
'@biomejs/biome@2.4.3':
|
||||
'@biomejs/biome@2.4.4':
|
||||
optionalDependencies:
|
||||
'@biomejs/cli-darwin-arm64': 2.4.3
|
||||
'@biomejs/cli-darwin-x64': 2.4.3
|
||||
'@biomejs/cli-linux-arm64': 2.4.3
|
||||
'@biomejs/cli-linux-arm64-musl': 2.4.3
|
||||
'@biomejs/cli-linux-x64': 2.4.3
|
||||
'@biomejs/cli-linux-x64-musl': 2.4.3
|
||||
'@biomejs/cli-win32-arm64': 2.4.3
|
||||
'@biomejs/cli-win32-x64': 2.4.3
|
||||
'@biomejs/cli-darwin-arm64': 2.4.4
|
||||
'@biomejs/cli-darwin-x64': 2.4.4
|
||||
'@biomejs/cli-linux-arm64': 2.4.4
|
||||
'@biomejs/cli-linux-arm64-musl': 2.4.4
|
||||
'@biomejs/cli-linux-x64': 2.4.4
|
||||
'@biomejs/cli-linux-x64-musl': 2.4.4
|
||||
'@biomejs/cli-win32-arm64': 2.4.4
|
||||
'@biomejs/cli-win32-x64': 2.4.4
|
||||
|
||||
'@biomejs/cli-darwin-arm64@2.4.3':
|
||||
'@biomejs/cli-darwin-arm64@2.4.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-darwin-x64@2.4.3':
|
||||
'@biomejs/cli-darwin-x64@2.4.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.3':
|
||||
'@biomejs/cli-linux-arm64-musl@2.4.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-arm64@2.4.3':
|
||||
'@biomejs/cli-linux-arm64@2.4.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64-musl@2.4.3':
|
||||
'@biomejs/cli-linux-x64-musl@2.4.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-linux-x64@2.4.3':
|
||||
'@biomejs/cli-linux-x64@2.4.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-arm64@2.4.3':
|
||||
'@biomejs/cli-win32-arm64@2.4.4':
|
||||
optional: true
|
||||
|
||||
'@biomejs/cli-win32-x64@2.4.3':
|
||||
'@biomejs/cli-win32-x64@2.4.4':
|
||||
optional: true
|
||||
|
||||
'@borewit/text-codec@0.2.1': {}
|
||||
@@ -7918,79 +7918,79 @@ snapshots:
|
||||
|
||||
'@rolldown/pluginutils@1.0.0-rc.3': {}
|
||||
|
||||
'@rollup/rollup-android-arm-eabi@4.57.1':
|
||||
'@rollup/rollup-android-arm-eabi@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-android-arm64@4.57.1':
|
||||
'@rollup/rollup-android-arm64@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-arm64@4.57.1':
|
||||
'@rollup/rollup-darwin-arm64@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-darwin-x64@4.57.1':
|
||||
'@rollup/rollup-darwin-x64@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-arm64@4.57.1':
|
||||
'@rollup/rollup-freebsd-arm64@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-freebsd-x64@4.57.1':
|
||||
'@rollup/rollup-freebsd-x64@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.57.1':
|
||||
'@rollup/rollup-linux-arm-gnueabihf@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.57.1':
|
||||
'@rollup/rollup-linux-arm-musleabihf@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-arm64-gnu@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-arm64-musl@4.57.1':
|
||||
'@rollup/rollup-linux-arm64-musl@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-loong64-gnu@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-loong64-musl@4.57.1':
|
||||
'@rollup/rollup-linux-loong64-musl@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-ppc64-gnu@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-ppc64-musl@4.57.1':
|
||||
'@rollup/rollup-linux-ppc64-musl@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-riscv64-gnu@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-riscv64-musl@4.57.1':
|
||||
'@rollup/rollup-linux-riscv64-musl@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-s390x-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-s390x-gnu@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-gnu@4.57.1':
|
||||
'@rollup/rollup-linux-x64-gnu@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-linux-x64-musl@4.57.1':
|
||||
'@rollup/rollup-linux-x64-musl@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openbsd-x64@4.57.1':
|
||||
'@rollup/rollup-openbsd-x64@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-openharmony-arm64@4.57.1':
|
||||
'@rollup/rollup-openharmony-arm64@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-arm64-msvc@4.57.1':
|
||||
'@rollup/rollup-win32-arm64-msvc@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-ia32-msvc@4.57.1':
|
||||
'@rollup/rollup-win32-ia32-msvc@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-gnu@4.57.1':
|
||||
'@rollup/rollup-win32-x64-gnu@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@rollup/rollup-win32-x64-msvc@4.57.1':
|
||||
'@rollup/rollup-win32-x64-msvc@4.58.0':
|
||||
optional: true
|
||||
|
||||
'@sinclair/typebox@0.34.48': {}
|
||||
@@ -9609,9 +9609,9 @@ snapshots:
|
||||
|
||||
forwarded@0.2.0: {}
|
||||
|
||||
framer-motion@12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
motion-dom: 12.34.2
|
||||
motion-dom: 12.34.3
|
||||
motion-utils: 12.29.2
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
@@ -9734,7 +9734,7 @@ snapshots:
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
i18next@25.8.11(typescript@5.9.3):
|
||||
i18next@25.8.13(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
optionalDependencies:
|
||||
@@ -10415,15 +10415,15 @@ snapshots:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
|
||||
motion-dom@12.34.2:
|
||||
motion-dom@12.34.3:
|
||||
dependencies:
|
||||
motion-utils: 12.29.2
|
||||
|
||||
motion-utils@12.29.2: {}
|
||||
|
||||
motion@12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||
dependencies:
|
||||
framer-motion: 12.34.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
framer-motion: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
tslib: 2.8.1
|
||||
optionalDependencies:
|
||||
react: 19.2.4
|
||||
@@ -10713,11 +10713,11 @@ snapshots:
|
||||
|
||||
react-fast-compare@3.2.2: {}
|
||||
|
||||
react-i18next@16.5.4(i18next@25.8.11(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
|
||||
react-i18next@16.5.4(i18next@25.8.13(typescript@5.9.3))(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(typescript@5.9.3):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.28.6
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 25.8.11(typescript@5.9.3)
|
||||
i18next: 25.8.13(typescript@5.9.3)
|
||||
react: 19.2.4
|
||||
use-sync-external-store: 1.6.0(react@19.2.4)
|
||||
optionalDependencies:
|
||||
@@ -10834,35 +10834,35 @@ snapshots:
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rollup@4.57.1:
|
||||
rollup@4.58.0:
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
optionalDependencies:
|
||||
'@rollup/rollup-android-arm-eabi': 4.57.1
|
||||
'@rollup/rollup-android-arm64': 4.57.1
|
||||
'@rollup/rollup-darwin-arm64': 4.57.1
|
||||
'@rollup/rollup-darwin-x64': 4.57.1
|
||||
'@rollup/rollup-freebsd-arm64': 4.57.1
|
||||
'@rollup/rollup-freebsd-x64': 4.57.1
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.57.1
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.57.1
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-arm64-musl': 4.57.1
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-loong64-musl': 4.57.1
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.57.1
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.57.1
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-x64-gnu': 4.57.1
|
||||
'@rollup/rollup-linux-x64-musl': 4.57.1
|
||||
'@rollup/rollup-openbsd-x64': 4.57.1
|
||||
'@rollup/rollup-openharmony-arm64': 4.57.1
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.57.1
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.57.1
|
||||
'@rollup/rollup-win32-x64-gnu': 4.57.1
|
||||
'@rollup/rollup-win32-x64-msvc': 4.57.1
|
||||
'@rollup/rollup-android-arm-eabi': 4.58.0
|
||||
'@rollup/rollup-android-arm64': 4.58.0
|
||||
'@rollup/rollup-darwin-arm64': 4.58.0
|
||||
'@rollup/rollup-darwin-x64': 4.58.0
|
||||
'@rollup/rollup-freebsd-arm64': 4.58.0
|
||||
'@rollup/rollup-freebsd-x64': 4.58.0
|
||||
'@rollup/rollup-linux-arm-gnueabihf': 4.58.0
|
||||
'@rollup/rollup-linux-arm-musleabihf': 4.58.0
|
||||
'@rollup/rollup-linux-arm64-gnu': 4.58.0
|
||||
'@rollup/rollup-linux-arm64-musl': 4.58.0
|
||||
'@rollup/rollup-linux-loong64-gnu': 4.58.0
|
||||
'@rollup/rollup-linux-loong64-musl': 4.58.0
|
||||
'@rollup/rollup-linux-ppc64-gnu': 4.58.0
|
||||
'@rollup/rollup-linux-ppc64-musl': 4.58.0
|
||||
'@rollup/rollup-linux-riscv64-gnu': 4.58.0
|
||||
'@rollup/rollup-linux-riscv64-musl': 4.58.0
|
||||
'@rollup/rollup-linux-s390x-gnu': 4.58.0
|
||||
'@rollup/rollup-linux-x64-gnu': 4.58.0
|
||||
'@rollup/rollup-linux-x64-musl': 4.58.0
|
||||
'@rollup/rollup-openbsd-x64': 4.58.0
|
||||
'@rollup/rollup-openharmony-arm64': 4.58.0
|
||||
'@rollup/rollup-win32-arm64-msvc': 4.58.0
|
||||
'@rollup/rollup-win32-ia32-msvc': 4.58.0
|
||||
'@rollup/rollup-win32-x64-gnu': 4.58.0
|
||||
'@rollup/rollup-win32-x64-msvc': 4.58.0
|
||||
fsevents: 2.3.3
|
||||
|
||||
router@2.2.0:
|
||||
@@ -11403,7 +11403,7 @@ snapshots:
|
||||
fdir: 6.5.0(picomatch@4.0.3)
|
||||
picomatch: 4.0.3
|
||||
postcss: 8.5.6
|
||||
rollup: 4.57.1
|
||||
rollup: 4.58.0
|
||||
tinyglobby: 0.2.15
|
||||
optionalDependencies:
|
||||
'@types/node': 25.3.0
|
||||
|
||||
@@ -34,6 +34,8 @@
|
||||
"deep-link:allow-get-current",
|
||||
"dialog:default",
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:allow-write-text-file",
|
||||
"macos-permissions:default",
|
||||
"macos-permissions:allow-request-microphone-permission",
|
||||
"macos-permissions:allow-request-camera-permission",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
@@ -464,13 +463,7 @@ impl ApiClient {
|
||||
}
|
||||
|
||||
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let app_name = if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
};
|
||||
let cache_dir = base_dirs.cache_dir().join(app_name).join("version_cache");
|
||||
let cache_dir = crate::app_dirs::cache_dir().join("version_cache");
|
||||
fs::create_dir_all(&cache_dir)?;
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
@@ -605,6 +605,7 @@ async fn create_profile(
|
||||
camoufox_config,
|
||||
wayfern_config,
|
||||
request.group_id.clone(),
|
||||
false,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
use directories::BaseDirs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
static BASE_DIRS: OnceLock<BaseDirs> = OnceLock::new();
|
||||
|
||||
fn base_dirs() -> &'static BaseDirs {
|
||||
BASE_DIRS.get_or_init(|| BaseDirs::new().expect("Failed to get base directories"))
|
||||
}
|
||||
|
||||
pub fn app_name() -> &'static str {
|
||||
if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
}
|
||||
}
|
||||
|
||||
pub fn data_dir() -> PathBuf {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if let Some(dir) = TEST_DATA_DIR.with(|cell| cell.borrow().clone()) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(dir) = std::env::var("DONUTBROWSER_DATA_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
|
||||
base_dirs().data_local_dir().join(app_name())
|
||||
}
|
||||
|
||||
pub fn cache_dir() -> PathBuf {
|
||||
#[cfg(test)]
|
||||
{
|
||||
if let Some(dir) = TEST_CACHE_DIR.with(|cell| cell.borrow().clone()) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(dir) = std::env::var("DONUTBROWSER_CACHE_DIR") {
|
||||
return PathBuf::from(dir);
|
||||
}
|
||||
|
||||
base_dirs().cache_dir().join(app_name())
|
||||
}
|
||||
|
||||
pub fn profiles_dir() -> PathBuf {
|
||||
data_dir().join("profiles")
|
||||
}
|
||||
|
||||
pub fn binaries_dir() -> PathBuf {
|
||||
data_dir().join("binaries")
|
||||
}
|
||||
|
||||
pub fn data_subdir() -> PathBuf {
|
||||
data_dir().join("data")
|
||||
}
|
||||
|
||||
pub fn settings_dir() -> PathBuf {
|
||||
data_dir().join("settings")
|
||||
}
|
||||
|
||||
pub fn proxies_dir() -> PathBuf {
|
||||
data_dir().join("proxies")
|
||||
}
|
||||
|
||||
pub fn vpn_dir() -> PathBuf {
|
||||
data_dir().join("vpn")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
thread_local! {
|
||||
static TEST_DATA_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
static TEST_CACHE_DIR: std::cell::RefCell<Option<PathBuf>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub struct TestDirGuard {
|
||||
kind: TestDirKind,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
enum TestDirKind {
|
||||
Data,
|
||||
Cache,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
impl Drop for TestDirGuard {
|
||||
fn drop(&mut self) {
|
||||
match self.kind {
|
||||
TestDirKind::Data => TEST_DATA_DIR.with(|cell| *cell.borrow_mut() = None),
|
||||
TestDirKind::Cache => TEST_CACHE_DIR.with(|cell| *cell.borrow_mut() = None),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_test_data_dir(dir: PathBuf) -> TestDirGuard {
|
||||
TEST_DATA_DIR.with(|cell| *cell.borrow_mut() = Some(dir));
|
||||
TestDirGuard {
|
||||
kind: TestDirKind::Data,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn set_test_cache_dir(dir: PathBuf) -> TestDirGuard {
|
||||
TEST_CACHE_DIR.with(|cell| *cell.borrow_mut() = Some(dir));
|
||||
TestDirGuard {
|
||||
kind: TestDirKind::Cache,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_app_name() {
|
||||
let name = app_name();
|
||||
assert!(
|
||||
name == "DonutBrowser" || name == "DonutBrowserDev",
|
||||
"app_name should be DonutBrowser or DonutBrowserDev, got: {name}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_data_dir_returns_path() {
|
||||
let dir = data_dir();
|
||||
assert!(
|
||||
dir.to_string_lossy().contains(app_name()),
|
||||
"data_dir should contain app_name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_dir_returns_path() {
|
||||
let dir = cache_dir();
|
||||
assert!(
|
||||
dir.to_string_lossy().contains(app_name()),
|
||||
"cache_dir should contain app_name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_subdirectory_helpers() {
|
||||
assert!(profiles_dir().ends_with("profiles"));
|
||||
assert!(binaries_dir().ends_with("binaries"));
|
||||
assert!(data_subdir().ends_with("data"));
|
||||
assert!(settings_dir().ends_with("settings"));
|
||||
assert!(proxies_dir().ends_with("proxies"));
|
||||
assert!(vpn_dir().ends_with("vpn"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_test_data_dir() {
|
||||
let tmp = PathBuf::from("/tmp/test-donut-data");
|
||||
let _guard = set_test_data_dir(tmp.clone());
|
||||
assert_eq!(data_dir(), tmp);
|
||||
assert_eq!(profiles_dir(), tmp.join("profiles"));
|
||||
assert_eq!(binaries_dir(), tmp.join("binaries"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_set_test_cache_dir() {
|
||||
let tmp = PathBuf::from("/tmp/test-donut-cache");
|
||||
let _guard = set_test_cache_dir(tmp.clone());
|
||||
assert_eq!(cache_dir(), tmp);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_guard_cleanup() {
|
||||
let original_data = data_dir();
|
||||
let original_cache = cache_dir();
|
||||
|
||||
{
|
||||
let _guard = set_test_data_dir(PathBuf::from("/tmp/test-cleanup-data"));
|
||||
assert_eq!(data_dir(), PathBuf::from("/tmp/test-cleanup-data"));
|
||||
}
|
||||
assert_eq!(data_dir(), original_data);
|
||||
|
||||
{
|
||||
let _guard = set_test_cache_dir(PathBuf::from("/tmp/test-cleanup-cache"));
|
||||
assert_eq!(cache_dir(), PathBuf::from("/tmp/test-cleanup-cache"));
|
||||
}
|
||||
assert_eq!(cache_dir(), original_cache);
|
||||
}
|
||||
}
|
||||
@@ -522,6 +522,7 @@ mod tests {
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,41 @@ fn run_daemon() {
|
||||
// Store tray icon in Option - created after event loop starts
|
||||
let mut tray_icon: Option<TrayIcon> = None;
|
||||
|
||||
// Install signal handlers so SIGTERM/SIGINT trigger graceful shutdown
|
||||
#[cfg(unix)]
|
||||
unsafe {
|
||||
extern "C" fn signal_handler(_sig: libc::c_int) {
|
||||
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
libc::signal(
|
||||
libc::SIGTERM,
|
||||
signal_handler as *const () as libc::sighandler_t,
|
||||
);
|
||||
libc::signal(
|
||||
libc::SIGINT,
|
||||
signal_handler as *const () as libc::sighandler_t,
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
extern "system" {
|
||||
fn SetConsoleCtrlHandler(
|
||||
handler: Option<unsafe extern "system" fn(u32) -> i32>,
|
||||
add: i32,
|
||||
) -> i32;
|
||||
}
|
||||
|
||||
unsafe extern "system" fn ctrl_handler(_ctrl_type: u32) -> i32 {
|
||||
SHOULD_QUIT.store(true, std::sync::atomic::Ordering::SeqCst);
|
||||
1 // TRUE
|
||||
}
|
||||
|
||||
unsafe {
|
||||
SetConsoleCtrlHandler(Some(ctrl_handler), 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run the event loop
|
||||
event_loop.run(move |event, _, control_flow| {
|
||||
// Use WaitUntil to check for menu events periodically while staying low on CPU
|
||||
@@ -267,6 +302,28 @@ fn stop_daemon() {
|
||||
let state = read_state();
|
||||
|
||||
if let Some(pid) = state.daemon_pid {
|
||||
// On Windows, taskkill /F kills instantly with no handler, so kill GUI first
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::process::Command;
|
||||
|
||||
let state_path = get_state_path();
|
||||
if let Ok(content) = fs::read_to_string(&state_path) {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&content) {
|
||||
if let Some(gui_pid) = val.get("gui_pid").and_then(|v| v.as_u64()) {
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &gui_pid.to_string(), "/F"])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output();
|
||||
eprintln!("Sent stop signal to daemon (PID {})", pid);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe {
|
||||
@@ -274,15 +331,6 @@ fn stop_daemon() {
|
||||
}
|
||||
eprintln!("Sent stop signal to daemon (PID {})", pid);
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::process::Command;
|
||||
let _ = Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output();
|
||||
eprintln!("Sent stop signal to daemon (PID {})", pid);
|
||||
}
|
||||
} else {
|
||||
eprintln!("Daemon is not running");
|
||||
}
|
||||
|
||||
@@ -6,13 +6,11 @@ use crate::platform_browser;
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::wayfern_manager::{WayfernConfig, WayfernManager};
|
||||
use directories::BaseDirs;
|
||||
use serde::Serialize;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use sysinfo::System;
|
||||
pub struct BrowserRunner {
|
||||
base_dirs: BaseDirs,
|
||||
pub profile_manager: &'static ProfileManager,
|
||||
pub downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||
auto_updater: &'static crate::auto_updater::AutoUpdater,
|
||||
@@ -23,7 +21,6 @@ pub struct BrowserRunner {
|
||||
impl BrowserRunner {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
profile_manager: ProfileManager::instance(),
|
||||
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
|
||||
auto_updater: crate::auto_updater::AutoUpdater::instance(),
|
||||
@@ -37,14 +34,7 @@ impl BrowserRunner {
|
||||
}
|
||||
|
||||
pub fn get_binaries_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
/// Get the executable path for a browser profile
|
||||
@@ -239,6 +229,15 @@ impl BrowserRunner {
|
||||
log::warn!("Failed to configure Camoufox search engine: {e}");
|
||||
}
|
||||
|
||||
// Create ephemeral dir for ephemeral profiles
|
||||
let override_profile_path = if profile.ephemeral {
|
||||
let dir = crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
Some(dir)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Launch Camoufox browser
|
||||
log::info!("Launching Camoufox for profile: {}", profile.name);
|
||||
let camoufox_result = self
|
||||
@@ -248,6 +247,7 @@ impl BrowserRunner {
|
||||
updated_profile.clone(),
|
||||
camoufox_config,
|
||||
url,
|
||||
override_profile_path,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
@@ -451,12 +451,19 @@ impl BrowserRunner {
|
||||
);
|
||||
}
|
||||
|
||||
// Create ephemeral dir for ephemeral profiles
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::create_ephemeral_dir(&profile.id.to_string())
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> { e.into() })?;
|
||||
}
|
||||
|
||||
// Launch Wayfern browser
|
||||
log::info!("Launching Wayfern for profile: {}", profile.name);
|
||||
|
||||
// Get profile path for Wayfern
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = updated_profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(&updated_profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy().to_string();
|
||||
|
||||
// Get proxy URL from config
|
||||
@@ -471,6 +478,7 @@ impl BrowserRunner {
|
||||
&wayfern_config,
|
||||
url.as_deref(),
|
||||
proxy_url,
|
||||
profile.ephemeral,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
@@ -803,7 +811,8 @@ impl BrowserRunner {
|
||||
if profile.browser == "camoufox" {
|
||||
// Get the profile path based on the UUID
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
// Check if the process is running
|
||||
@@ -857,7 +866,8 @@ impl BrowserRunner {
|
||||
// Handle Wayfern profiles using WayfernManager
|
||||
if profile.browser == "wayfern" {
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
// Check if the process is running
|
||||
@@ -1255,7 +1265,8 @@ impl BrowserRunner {
|
||||
if profile.browser == "camoufox" {
|
||||
// Search by profile path to find the running Camoufox instance
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
log::info!(
|
||||
@@ -1673,6 +1684,10 @@ impl BrowserRunner {
|
||||
);
|
||||
}
|
||||
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Camoufox process cleanup completed for profile: {} (ID: {})",
|
||||
profile.name,
|
||||
@@ -1698,7 +1713,8 @@ impl BrowserRunner {
|
||||
// Handle Wayfern profiles using WayfernManager
|
||||
if profile.browser == "wayfern" {
|
||||
let profiles_dir = self.profile_manager.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
log::info!(
|
||||
@@ -1991,6 +2007,10 @@ impl BrowserRunner {
|
||||
);
|
||||
}
|
||||
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
log::info!(
|
||||
"Wayfern process cleanup completed for profile: {} (ID: {})",
|
||||
profile.name,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
use crate::camoufox::{CamoufoxConfigBuilder, GeoIPOption, ScreenConstraints};
|
||||
use crate::profile::BrowserProfile;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
@@ -74,7 +73,6 @@ struct CamoufoxManagerInner {
|
||||
|
||||
pub struct CamoufoxManager {
|
||||
inner: Arc<AsyncMutex<CamoufoxManagerInner>>,
|
||||
base_dirs: BaseDirs,
|
||||
}
|
||||
|
||||
impl CamoufoxManager {
|
||||
@@ -83,7 +81,6 @@ impl CamoufoxManager {
|
||||
inner: Arc::new(AsyncMutex::new(CamoufoxManagerInner {
|
||||
instances: HashMap::new(),
|
||||
})),
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,14 +89,7 @@ impl CamoufoxManager {
|
||||
}
|
||||
|
||||
pub fn get_profiles_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("profiles");
|
||||
path
|
||||
crate::app_dirs::profiles_dir()
|
||||
}
|
||||
|
||||
/// Generate Camoufox fingerprint configuration during profile creation
|
||||
@@ -592,10 +582,15 @@ impl CamoufoxManager {
|
||||
profile: BrowserProfile,
|
||||
config: CamoufoxConfig,
|
||||
url: Option<String>,
|
||||
override_profile_path: Option<std::path::PathBuf>,
|
||||
) -> Result<CamoufoxLaunchResult, String> {
|
||||
// Get profile path
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_path = if let Some(ref override_path) = override_profile_path {
|
||||
override_path.clone()
|
||||
} else {
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
profile.get_profile_data_path(&profiles_dir)
|
||||
};
|
||||
let profile_path_str = profile_path.to_string_lossy();
|
||||
|
||||
// Check if there's already a running instance for this profile
|
||||
@@ -607,6 +602,24 @@ impl CamoufoxManager {
|
||||
// Clean up any dead instances before launching
|
||||
let _ = self.cleanup_dead_instances().await;
|
||||
|
||||
// For ephemeral profiles, write Firefox prefs to keep all data inside the profile dir
|
||||
if override_profile_path.is_some() {
|
||||
let cache_dir = profile_path.join("cache2");
|
||||
let user_js_path = profile_path.join("user.js");
|
||||
let prefs = format!(
|
||||
concat!(
|
||||
"user_pref(\"browser.cache.disk.parent_directory\", \"{}\");\n",
|
||||
"user_pref(\"browser.cache.disk.enable\", false);\n",
|
||||
"user_pref(\"browser.cache.memory.enable\", true);\n",
|
||||
"user_pref(\"browser.privatebrowsing.autostart\", true);\n",
|
||||
),
|
||||
cache_dir.to_string_lossy().replace('\\', "\\\\"),
|
||||
);
|
||||
if let Err(e) = std::fs::write(&user_js_path, prefs) {
|
||||
log::warn!("Failed to write ephemeral user.js: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
self
|
||||
.launch_camoufox(
|
||||
&app_handle,
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::profile::manager::ProfileManager;
|
||||
use crate::profile::BrowserProfile;
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::AppHandle;
|
||||
@@ -62,6 +63,14 @@ pub struct CookieCopyResult {
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of a cookie import operation
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CookieImportResult {
|
||||
pub cookies_imported: usize,
|
||||
pub cookies_replaced: usize,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct CookieManager;
|
||||
|
||||
impl CookieManager {
|
||||
@@ -493,4 +502,499 @@ impl CookieManager {
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Parse Netscape format cookies from text content
|
||||
fn parse_netscape_cookies(content: &str) -> (Vec<UnifiedCookie>, Vec<String>) {
|
||||
let mut cookies = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
for (i, line) in content.lines().enumerate() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() || line.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let fields: Vec<&str> = line.split('\t').collect();
|
||||
if fields.len() < 7 {
|
||||
errors.push(format!(
|
||||
"Line {}: expected 7 tab-separated fields, got {}",
|
||||
i + 1,
|
||||
fields.len()
|
||||
));
|
||||
continue;
|
||||
}
|
||||
|
||||
let domain = fields[0].to_string();
|
||||
let path = fields[2].to_string();
|
||||
let is_secure = fields[3].eq_ignore_ascii_case("TRUE");
|
||||
let expires = fields[4].parse::<i64>().unwrap_or(0);
|
||||
let name = fields[5].to_string();
|
||||
let value = fields[6].to_string();
|
||||
|
||||
cookies.push(UnifiedCookie {
|
||||
name,
|
||||
value,
|
||||
domain,
|
||||
path,
|
||||
expires,
|
||||
is_secure,
|
||||
is_http_only: false,
|
||||
same_site: 0,
|
||||
creation_time: now,
|
||||
last_accessed: now,
|
||||
});
|
||||
}
|
||||
|
||||
(cookies, errors)
|
||||
}
|
||||
|
||||
/// Parse JSON format cookies (array of cookie objects, e.g. from browser extensions)
|
||||
fn parse_json_cookies(content: &str) -> (Vec<UnifiedCookie>, Vec<String>) {
|
||||
let mut cookies = Vec::new();
|
||||
let mut errors = Vec::new();
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
|
||||
let arr: Vec<Value> = match serde_json::from_str(content) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
errors.push(format!("Failed to parse JSON: {e}"));
|
||||
return (cookies, errors);
|
||||
}
|
||||
};
|
||||
|
||||
for (i, obj) in arr.iter().enumerate() {
|
||||
let name = match obj.get("name").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
errors.push(format!("Cookie {}: missing 'name' field", i + 1));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let value = obj
|
||||
.get("value")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let domain = match obj.get("domain").and_then(|v| v.as_str()) {
|
||||
Some(s) => s.to_string(),
|
||||
None => {
|
||||
errors.push(format!("Cookie {}: missing 'domain' field", i + 1));
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let path = obj
|
||||
.get("path")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("/")
|
||||
.to_string();
|
||||
let is_secure = obj.get("secure").and_then(|v| v.as_bool()).unwrap_or(false);
|
||||
let is_http_only = obj
|
||||
.get("httpOnly")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let is_session = obj
|
||||
.get("session")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
let expires = if is_session {
|
||||
0
|
||||
} else {
|
||||
obj
|
||||
.get("expirationDate")
|
||||
.and_then(|v| v.as_f64())
|
||||
.map(|f| f as i64)
|
||||
.unwrap_or(0)
|
||||
};
|
||||
let same_site = obj
|
||||
.get("sameSite")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| match s {
|
||||
"lax" => 1,
|
||||
"strict" => 2,
|
||||
_ => 0, // "no_restriction" or unrecognized
|
||||
})
|
||||
.unwrap_or(0);
|
||||
|
||||
cookies.push(UnifiedCookie {
|
||||
name,
|
||||
value,
|
||||
domain,
|
||||
path,
|
||||
expires,
|
||||
is_secure,
|
||||
is_http_only,
|
||||
same_site,
|
||||
creation_time: now,
|
||||
last_accessed: now,
|
||||
});
|
||||
}
|
||||
|
||||
(cookies, errors)
|
||||
}
|
||||
|
||||
/// Auto-detect cookie format and parse
|
||||
fn parse_cookies(content: &str) -> (Vec<UnifiedCookie>, Vec<String>) {
|
||||
let trimmed = content.trim();
|
||||
if trimmed.starts_with('[') && serde_json::from_str::<Vec<Value>>(trimmed).is_ok() {
|
||||
return Self::parse_json_cookies(trimmed);
|
||||
}
|
||||
Self::parse_netscape_cookies(content)
|
||||
}
|
||||
|
||||
/// Format cookies as Netscape TXT
|
||||
pub fn format_netscape_cookies(cookies: &[UnifiedCookie]) -> String {
|
||||
let mut lines = Vec::new();
|
||||
lines.push("# Netscape HTTP Cookie File".to_string());
|
||||
for cookie in cookies {
|
||||
let flag = if cookie.domain.starts_with('.') {
|
||||
"TRUE"
|
||||
} else {
|
||||
"FALSE"
|
||||
};
|
||||
let secure = if cookie.is_secure { "TRUE" } else { "FALSE" };
|
||||
lines.push(format!(
|
||||
"{}\t{}\t{}\t{}\t{}\t{}\t{}",
|
||||
cookie.domain, flag, cookie.path, secure, cookie.expires, cookie.name, cookie.value
|
||||
));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
/// Format cookies as JSON
|
||||
pub fn format_json_cookies(cookies: &[UnifiedCookie]) -> String {
|
||||
let arr: Vec<Value> = cookies
|
||||
.iter()
|
||||
.map(|c| {
|
||||
let same_site_str = match c.same_site {
|
||||
1 => "lax",
|
||||
2 => "strict",
|
||||
_ => "no_restriction",
|
||||
};
|
||||
serde_json::json!({
|
||||
"name": c.name,
|
||||
"value": c.value,
|
||||
"domain": c.domain,
|
||||
"path": c.path,
|
||||
"secure": c.is_secure,
|
||||
"httpOnly": c.is_http_only,
|
||||
"sameSite": same_site_str,
|
||||
"expirationDate": c.expires,
|
||||
"session": c.expires == 0,
|
||||
"hostOnly": !c.domain.starts_with('.'),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
serde_json::to_string_pretty(&arr).unwrap_or_else(|_| "[]".to_string())
|
||||
}
|
||||
|
||||
/// Public API: Import cookies with auto-format detection
|
||||
pub async fn import_cookies(
|
||||
app_handle: &AppHandle,
|
||||
profile_id: &str,
|
||||
content: &str,
|
||||
) -> Result<CookieImportResult, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles_dir = profile_manager.get_profiles_dir();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == profile_id)
|
||||
.ok_or_else(|| format!("Profile not found: {profile_id}"))?;
|
||||
|
||||
let is_running = profile_manager
|
||||
.check_browser_status(app_handle.clone(), profile)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_running {
|
||||
return Err(format!(
|
||||
"Cannot import cookies while browser is running for profile: {}",
|
||||
profile.name
|
||||
));
|
||||
}
|
||||
|
||||
let (cookies, parse_errors) = Self::parse_cookies(content);
|
||||
|
||||
if cookies.is_empty() {
|
||||
return Err("No valid cookies found in the file".to_string());
|
||||
}
|
||||
|
||||
let db_path = Self::get_cookie_db_path(profile, &profiles_dir)?;
|
||||
|
||||
let write_result = match profile.browser.as_str() {
|
||||
"camoufox" => Self::write_firefox_cookies(&db_path, &cookies),
|
||||
"wayfern" => Self::write_chrome_cookies(&db_path, &cookies),
|
||||
_ => return Err(format!("Unsupported browser type: {}", profile.browser)),
|
||||
};
|
||||
|
||||
match write_result {
|
||||
Ok((imported, replaced)) => Ok(CookieImportResult {
|
||||
cookies_imported: imported,
|
||||
cookies_replaced: replaced,
|
||||
errors: parse_errors,
|
||||
}),
|
||||
Err(e) => Err(format!("Failed to write cookies: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Public API: Export cookies from a profile in the specified format
|
||||
pub fn export_cookies(profile_id: &str, format: &str) -> Result<String, String> {
|
||||
let result = Self::read_cookies(profile_id)?;
|
||||
let all_cookies: Vec<UnifiedCookie> =
|
||||
result.domains.into_iter().flat_map(|d| d.cookies).collect();
|
||||
|
||||
match format {
|
||||
"json" => Ok(Self::format_json_cookies(&all_cookies)),
|
||||
"netscape" => Ok(Self::format_netscape_cookies(&all_cookies)),
|
||||
_ => Err(format!("Unsupported export format: {format}")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_netscape_cookies_valid() {
|
||||
let content = "# Netscape HTTP Cookie File\n\
|
||||
.example.com\tTRUE\t/\tTRUE\t1700000000\tsession_id\tabc123\n\
|
||||
example.com\tFALSE\t/path\tFALSE\t0\ttoken\txyz";
|
||||
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
|
||||
assert_eq!(cookies.len(), 2);
|
||||
assert!(errors.is_empty());
|
||||
|
||||
assert_eq!(cookies[0].domain, ".example.com");
|
||||
assert_eq!(cookies[0].name, "session_id");
|
||||
assert_eq!(cookies[0].value, "abc123");
|
||||
assert_eq!(cookies[0].path, "/");
|
||||
assert!(cookies[0].is_secure);
|
||||
assert_eq!(cookies[0].expires, 1700000000);
|
||||
|
||||
assert_eq!(cookies[1].domain, "example.com");
|
||||
assert!(!cookies[1].is_secure);
|
||||
assert_eq!(cookies[1].expires, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_netscape_cookies_skips_comments_and_blanks() {
|
||||
let content = "# Comment line\n\n \n# Another comment\n\
|
||||
.test.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n";
|
||||
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert!(errors.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_netscape_cookies_malformed_lines() {
|
||||
let content = "not\tenough\tfields\n\
|
||||
.ok.com\tTRUE\t/\tFALSE\t0\tname\tvalue\n";
|
||||
let (cookies, errors) = CookieManager::parse_netscape_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert_eq!(errors.len(), 1);
|
||||
assert!(errors[0].contains("expected 7 tab-separated fields"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_cookies_valid() {
|
||||
let content = r#"[
|
||||
{
|
||||
"name": "sid",
|
||||
"value": "abc",
|
||||
"domain": ".example.com",
|
||||
"path": "/",
|
||||
"secure": true,
|
||||
"httpOnly": true,
|
||||
"sameSite": "lax",
|
||||
"expirationDate": 1700000000,
|
||||
"session": false
|
||||
}
|
||||
]"#;
|
||||
let (cookies, errors) = CookieManager::parse_json_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(cookies[0].name, "sid");
|
||||
assert_eq!(cookies[0].domain, ".example.com");
|
||||
assert!(cookies[0].is_secure);
|
||||
assert!(cookies[0].is_http_only);
|
||||
assert_eq!(cookies[0].same_site, 1);
|
||||
assert_eq!(cookies[0].expires, 1700000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_cookies_session() {
|
||||
let content = r#"[{"name": "s", "value": "v", "domain": ".d.com", "session": true, "expirationDate": 9999}]"#;
|
||||
let (cookies, errors) = CookieManager::parse_json_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(cookies[0].expires, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_json_cookies_same_site_mapping() {
|
||||
let content = r#"[
|
||||
{"name": "a", "value": "", "domain": ".d.com", "sameSite": "no_restriction"},
|
||||
{"name": "b", "value": "", "domain": ".d.com", "sameSite": "lax"},
|
||||
{"name": "c", "value": "", "domain": ".d.com", "sameSite": "strict"}
|
||||
]"#;
|
||||
let (cookies, _) = CookieManager::parse_json_cookies(content);
|
||||
assert_eq!(cookies[0].same_site, 0);
|
||||
assert_eq!(cookies[1].same_site, 1);
|
||||
assert_eq!(cookies[2].same_site, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cookies_auto_detect_json() {
|
||||
let content = r#"[{"name": "x", "value": "y", "domain": ".test.com"}]"#;
|
||||
let (cookies, _) = CookieManager::parse_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert_eq!(cookies[0].name, "x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_cookies_auto_detect_netscape() {
|
||||
let content = ".test.com\tTRUE\t/\tFALSE\t0\tname\tvalue";
|
||||
let (cookies, _) = CookieManager::parse_cookies(content);
|
||||
assert_eq!(cookies.len(), 1);
|
||||
assert_eq!(cookies[0].name, "name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_netscape_cookies() {
|
||||
let cookies = vec![UnifiedCookie {
|
||||
name: "sid".to_string(),
|
||||
value: "abc".to_string(),
|
||||
domain: ".example.com".to_string(),
|
||||
path: "/".to_string(),
|
||||
expires: 1700000000,
|
||||
is_secure: true,
|
||||
is_http_only: false,
|
||||
same_site: 0,
|
||||
creation_time: 0,
|
||||
last_accessed: 0,
|
||||
}];
|
||||
let output = CookieManager::format_netscape_cookies(&cookies);
|
||||
assert!(output.contains("# Netscape HTTP Cookie File"));
|
||||
assert!(output.contains(".example.com\tTRUE\t/\tTRUE\t1700000000\tsid\tabc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_json_cookies() {
|
||||
let cookies = vec![UnifiedCookie {
|
||||
name: "sid".to_string(),
|
||||
value: "abc".to_string(),
|
||||
domain: ".example.com".to_string(),
|
||||
path: "/".to_string(),
|
||||
expires: 1700000000,
|
||||
is_secure: true,
|
||||
is_http_only: true,
|
||||
same_site: 1,
|
||||
creation_time: 0,
|
||||
last_accessed: 0,
|
||||
}];
|
||||
let output = CookieManager::format_json_cookies(&cookies);
|
||||
let parsed: Vec<Value> = serde_json::from_str(&output).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0]["name"], "sid");
|
||||
assert_eq!(parsed[0]["sameSite"], "lax");
|
||||
assert_eq!(parsed[0]["session"], false);
|
||||
assert_eq!(parsed[0]["hostOnly"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_netscape_roundtrip() {
|
||||
let cookies = vec![
|
||||
UnifiedCookie {
|
||||
name: "a".to_string(),
|
||||
value: "1".to_string(),
|
||||
domain: ".d.com".to_string(),
|
||||
path: "/".to_string(),
|
||||
expires: 1700000000,
|
||||
is_secure: true,
|
||||
is_http_only: false,
|
||||
same_site: 0,
|
||||
creation_time: 0,
|
||||
last_accessed: 0,
|
||||
},
|
||||
UnifiedCookie {
|
||||
name: "b".to_string(),
|
||||
value: "2".to_string(),
|
||||
domain: "d.com".to_string(),
|
||||
path: "/p".to_string(),
|
||||
expires: 0,
|
||||
is_secure: false,
|
||||
is_http_only: false,
|
||||
same_site: 0,
|
||||
creation_time: 0,
|
||||
last_accessed: 0,
|
||||
},
|
||||
];
|
||||
let formatted = CookieManager::format_netscape_cookies(&cookies);
|
||||
let (parsed, errors) = CookieManager::parse_netscape_cookies(&formatted);
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert_eq!(parsed[0].name, "a");
|
||||
assert_eq!(parsed[0].domain, ".d.com");
|
||||
assert!(parsed[0].is_secure);
|
||||
assert_eq!(parsed[1].name, "b");
|
||||
assert_eq!(parsed[1].domain, "d.com");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_json_roundtrip() {
|
||||
let cookies = vec![UnifiedCookie {
|
||||
name: "tok".to_string(),
|
||||
value: "xyz".to_string(),
|
||||
domain: ".site.org".to_string(),
|
||||
path: "/app".to_string(),
|
||||
expires: 1700000000,
|
||||
is_secure: false,
|
||||
is_http_only: true,
|
||||
same_site: 2,
|
||||
creation_time: 0,
|
||||
last_accessed: 0,
|
||||
}];
|
||||
let formatted = CookieManager::format_json_cookies(&cookies);
|
||||
let (parsed, errors) = CookieManager::parse_json_cookies(&formatted);
|
||||
assert!(errors.is_empty());
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0].name, "tok");
|
||||
assert_eq!(parsed[0].domain, ".site.org");
|
||||
assert_eq!(parsed[0].path, "/app");
|
||||
assert!(!parsed[0].is_secure);
|
||||
assert!(parsed[0].is_http_only);
|
||||
assert_eq!(parsed[0].same_site, 2);
|
||||
assert_eq!(parsed[0].expires, 1700000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_time_to_unix() {
|
||||
assert_eq!(CookieManager::chrome_time_to_unix(0), 0);
|
||||
let chrome_time: i64 = (1700000000 + CookieManager::WINDOWS_EPOCH_DIFF) * 1_000_000;
|
||||
assert_eq!(CookieManager::chrome_time_to_unix(chrome_time), 1700000000);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unix_to_chrome_time() {
|
||||
assert_eq!(CookieManager::unix_to_chrome_time(0), 0);
|
||||
let expected = (1700000000 + CookieManager::WINDOWS_EPOCH_DIFF) * 1_000_000;
|
||||
assert_eq!(CookieManager::unix_to_chrome_time(1700000000), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_chrome_time_roundtrip() {
|
||||
let unix = 1700000000_i64;
|
||||
let chrome = CookieManager::unix_to_chrome_time(unix);
|
||||
assert_eq!(CookieManager::chrome_time_to_unix(chrome), unix);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -244,16 +244,22 @@ pub fn enable_autostart() -> io::Result<()> {
|
||||
|
||||
let desktop_path = autostart_dir.join("donut-daemon.desktop");
|
||||
|
||||
let escaped_daemon_path = daemon_path
|
||||
.display()
|
||||
.to_string()
|
||||
.replace('\\', "\\\\")
|
||||
.replace('"', "\\\"")
|
||||
.replace('`', "\\`")
|
||||
.replace('$', "\\$");
|
||||
let desktop_content = format!(
|
||||
r#"[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Donut Browser Daemon
|
||||
Exec={} run
|
||||
Exec="{escaped_daemon_path}" run
|
||||
Hidden=false
|
||||
NoDisplay=true
|
||||
X-GNOME-Autostart-enabled=true
|
||||
"#,
|
||||
daemon_path.display()
|
||||
);
|
||||
|
||||
fs::write(&desktop_path, desktop_content)?;
|
||||
|
||||
@@ -127,9 +127,49 @@ pub fn activate_gui() {
|
||||
}
|
||||
}
|
||||
|
||||
fn read_gui_pid() -> Option<u32> {
|
||||
let path = super::autostart::get_data_dir()?.join("daemon-state.json");
|
||||
let content = std::fs::read_to_string(path).ok()?;
|
||||
let val: serde_json::Value = serde_json::from_str(&content).ok()?;
|
||||
val.get("gui_pid")?.as_u64().map(|p| p as u32)
|
||||
}
|
||||
|
||||
fn kill_gui_by_pid() -> bool {
|
||||
let Some(pid) = read_gui_pid() else {
|
||||
return false;
|
||||
};
|
||||
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let ret = unsafe { libc::kill(pid as i32, libc::SIGTERM) };
|
||||
ret == 0
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
{
|
||||
Command::new("taskkill")
|
||||
.args(["/PID", &pid.to_string(), "/F"])
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(not(any(unix, windows)))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub fn quit_gui() {
|
||||
log::info!("[daemon] Quitting GUI...");
|
||||
|
||||
if kill_gui_by_pid() {
|
||||
log::info!("[daemon] GUI killed by PID");
|
||||
return;
|
||||
}
|
||||
|
||||
log::info!("[daemon] PID-based kill failed, falling back to name-based kill");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let _ = Command::new("osascript")
|
||||
|
||||
@@ -313,3 +313,26 @@ pub fn ensure_daemon_running() -> Result<(), String> {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn register_gui_pid() {
|
||||
let path = get_state_path();
|
||||
let mut val: serde_json::Value = if path.exists() {
|
||||
fs::read_to_string(&path)
|
||||
.ok()
|
||||
.and_then(|c| serde_json::from_str(&c).ok())
|
||||
.unwrap_or_else(|| serde_json::json!({}))
|
||||
} else {
|
||||
serde_json::json!({})
|
||||
};
|
||||
|
||||
if let Some(obj) = val.as_object_mut() {
|
||||
obj.insert(
|
||||
"gui_pid".to_string(),
|
||||
serde_json::Value::Number(std::process::id().into()),
|
||||
);
|
||||
}
|
||||
|
||||
if let Ok(content) = serde_json::to_string_pretty(&val) {
|
||||
let _ = fs::write(&path, content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +204,7 @@ mod windows {
|
||||
.map_err(|e| format!("Failed to set ApplicationDescription: {}", e))?;
|
||||
|
||||
app_key
|
||||
.set_value("ApplicationIcon", &format!("{},0", exe_path))
|
||||
.set_value("ApplicationIcon", &format!("\"{}\",0", exe_path))
|
||||
.map_err(|e| format!("Failed to set ApplicationIcon: {}", e))?;
|
||||
|
||||
// Create Capabilities key
|
||||
@@ -273,7 +273,7 @@ mod windows {
|
||||
.map_err(|e| format!("Failed to create DefaultIcon key: {}", e))?;
|
||||
|
||||
icon_key
|
||||
.set_value("", &format!("{},0", exe_path))
|
||||
.set_value("", &format!("\"{}\",0", exe_path))
|
||||
.map_err(|e| format!("Failed to set default icon: {}", e))?;
|
||||
|
||||
// Create shell\open\command key
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
@@ -71,16 +70,7 @@ impl DownloadedBrowsersRegistry {
|
||||
}
|
||||
|
||||
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("data");
|
||||
path.push("downloaded_browsers.json");
|
||||
Ok(path)
|
||||
Ok(crate::app_dirs::data_subdir().join("downloaded_browsers.json"))
|
||||
}
|
||||
|
||||
pub fn add_browser(&self, info: DownloadedBrowserInfo) {
|
||||
@@ -128,19 +118,7 @@ impl DownloadedBrowsersRegistry {
|
||||
};
|
||||
let browser_instance = create_browser(browser_type.clone());
|
||||
|
||||
// Get binaries directory
|
||||
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
} else {
|
||||
return false;
|
||||
};
|
||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||
|
||||
let files_exist = browser_instance.is_version_downloaded(version, &binaries_dir);
|
||||
|
||||
@@ -535,15 +513,7 @@ impl DownloadedBrowsersRegistry {
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get binaries directory path
|
||||
let base_dirs = directories::BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let mut binaries_dir = base_dirs.data_local_dir().to_path_buf();
|
||||
binaries_dir.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
binaries_dir.push("binaries");
|
||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||
|
||||
let version_dir = binaries_dir.join(browser).join(version);
|
||||
|
||||
@@ -830,19 +800,7 @@ impl DownloadedBrowsersRegistry {
|
||||
|
||||
let browser = create_browser(browser_type.clone());
|
||||
|
||||
// Get binaries directory
|
||||
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
} else {
|
||||
return Err("Failed to get base directories".into());
|
||||
};
|
||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||
|
||||
log::info!(
|
||||
"binaries_dir: {binaries_dir:?} for profile: {}",
|
||||
|
||||
@@ -681,21 +681,7 @@ impl Downloader {
|
||||
|
||||
// Use injected registry instance
|
||||
|
||||
// Get binaries directory - we need to get it from somewhere
|
||||
// This is a bit tricky since we don't have access to BrowserRunner's get_binaries_dir
|
||||
// We'll need to replicate this logic
|
||||
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
} else {
|
||||
return Err("Failed to get base directories".into());
|
||||
};
|
||||
let binaries_dir = crate::app_dirs::binaries_dir();
|
||||
|
||||
// Check if registry thinks it's downloaded, but also verify files actually exist
|
||||
if self.registry.is_browser_downloaded(&browser_str, &version) {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::profile::BrowserProfile;
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref EPHEMERAL_DIRS: Mutex<HashMap<String, PathBuf>> = Mutex::new(HashMap::new());
|
||||
}
|
||||
|
||||
pub fn create_ephemeral_dir(profile_id: &str) -> Result<PathBuf, String> {
|
||||
let dir_name = format!("donut-ephemeral-{profile_id}");
|
||||
let dir_path = std::env::temp_dir().join(dir_name);
|
||||
|
||||
std::fs::create_dir_all(&dir_path).map_err(|e| format!("Failed to create ephemeral dir: {e}"))?;
|
||||
|
||||
EPHEMERAL_DIRS
|
||||
.lock()
|
||||
.map_err(|e| format!("Failed to lock ephemeral dirs: {e}"))?
|
||||
.insert(profile_id.to_string(), dir_path.clone());
|
||||
|
||||
log::info!(
|
||||
"Created ephemeral dir for profile {}: {}",
|
||||
profile_id,
|
||||
dir_path.display()
|
||||
);
|
||||
|
||||
Ok(dir_path)
|
||||
}
|
||||
|
||||
pub fn get_ephemeral_dir(profile_id: &str) -> Option<PathBuf> {
|
||||
EPHEMERAL_DIRS.lock().ok()?.get(profile_id).cloned()
|
||||
}
|
||||
|
||||
pub fn remove_ephemeral_dir(profile_id: &str) {
|
||||
let dir = EPHEMERAL_DIRS
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|mut map| map.remove(profile_id));
|
||||
|
||||
if let Some(dir_path) = dir {
|
||||
if dir_path.exists() {
|
||||
if let Err(e) = std::fs::remove_dir_all(&dir_path) {
|
||||
log::warn!("Failed to remove ephemeral dir {}: {e}", dir_path.display());
|
||||
} else {
|
||||
log::info!(
|
||||
"Removed ephemeral dir for profile {}: {}",
|
||||
profile_id,
|
||||
dir_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cleanup_stale_dirs() {
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let entries = match std::fs::read_dir(&temp_dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
log::warn!("Failed to read temp dir for ephemeral cleanup: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
if let Some(name) = entry.file_name().to_str() {
|
||||
if name.starts_with("donut-ephemeral-") && entry.path().is_dir() {
|
||||
if let Err(e) = std::fs::remove_dir_all(entry.path()) {
|
||||
log::warn!(
|
||||
"Failed to clean up stale ephemeral dir {}: {e}",
|
||||
entry.path().display()
|
||||
);
|
||||
} else {
|
||||
log::info!("Cleaned up stale ephemeral dir: {}", entry.path().display());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_effective_profile_path(profile: &BrowserProfile, profiles_dir: &Path) -> PathBuf {
|
||||
if profile.ephemeral {
|
||||
if let Some(dir) = get_ephemeral_dir(&profile.id.to_string()) {
|
||||
return dir;
|
||||
}
|
||||
}
|
||||
profile.get_profile_data_path(profiles_dir)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn make_test_profile(id: uuid::Uuid, ephemeral: bool) -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
id,
|
||||
name: "test".to_string(),
|
||||
browser: "camoufox".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
vpn_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
wayfern_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
note: None,
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_ephemeral_dir_lifecycle() {
|
||||
// Test create, get, effective path, remove, and cleanup all in sequence
|
||||
// to avoid race conditions between parallel tests.
|
||||
|
||||
// 1. Create and get
|
||||
let profile_id = uuid::Uuid::new_v4();
|
||||
let id_str = profile_id.to_string();
|
||||
let dir = create_ephemeral_dir(&id_str).unwrap();
|
||||
assert!(dir.is_dir());
|
||||
assert_eq!(get_ephemeral_dir(&id_str), Some(dir.clone()));
|
||||
|
||||
// 2. Effective path for ephemeral profile returns ephemeral dir
|
||||
let ephemeral_profile = make_test_profile(profile_id, true);
|
||||
let profiles_dir = std::env::temp_dir().join("test_profiles_ephemeral");
|
||||
assert_eq!(
|
||||
get_effective_profile_path(&ephemeral_profile, &profiles_dir),
|
||||
dir
|
||||
);
|
||||
|
||||
// 3. Remove cleans up dir and map entry
|
||||
remove_ephemeral_dir(&id_str);
|
||||
assert!(!dir.exists());
|
||||
assert!(get_ephemeral_dir(&id_str).is_none());
|
||||
|
||||
// 4. Effective path for persistent profile returns normal path
|
||||
let persistent_profile = make_test_profile(uuid::Uuid::new_v4(), false);
|
||||
let expected = persistent_profile.get_profile_data_path(&profiles_dir);
|
||||
assert_eq!(
|
||||
get_effective_profile_path(&persistent_profile, &profiles_dir),
|
||||
expected
|
||||
);
|
||||
|
||||
// 5. Cleanup stale dirs
|
||||
let stale_id = uuid::Uuid::new_v4().to_string();
|
||||
let stale_dir = std::env::temp_dir().join(format!("donut-ephemeral-{stale_id}"));
|
||||
std::fs::create_dir_all(&stale_dir).unwrap();
|
||||
assert!(stale_dir.exists());
|
||||
cleanup_stale_dirs();
|
||||
assert!(!stale_dir.exists());
|
||||
}
|
||||
}
|
||||
@@ -76,21 +76,39 @@ impl GeoIPDownloader {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Check if GeoIP database is missing for Camoufox profiles
|
||||
|
||||
fn get_timestamp_path() -> PathBuf {
|
||||
crate::app_dirs::cache_dir().join("geoip_last_download")
|
||||
}
|
||||
|
||||
fn is_geoip_stale() -> bool {
|
||||
let timestamp_path = Self::get_timestamp_path();
|
||||
let Ok(content) = std::fs::read_to_string(×tamp_path) else {
|
||||
return true;
|
||||
};
|
||||
let Ok(timestamp) = content.trim().parse::<u64>() else {
|
||||
return true;
|
||||
};
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
const SEVEN_DAYS: u64 = 7 * 24 * 60 * 60;
|
||||
now.saturating_sub(timestamp) > SEVEN_DAYS
|
||||
}
|
||||
|
||||
/// Check if GeoIP database is missing or stale for Camoufox profiles
|
||||
pub fn check_missing_geoip_database(
|
||||
&self,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get all profiles
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
// Check if there are any Camoufox profiles
|
||||
let has_camoufox_profiles = profiles.iter().any(|profile| profile.browser == "camoufox");
|
||||
|
||||
if has_camoufox_profiles {
|
||||
// Check if GeoIP database is available
|
||||
return Ok(!Self::is_geoip_database_available());
|
||||
return Ok(!Self::is_geoip_database_available() || Self::is_geoip_stale());
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
@@ -201,6 +219,17 @@ impl GeoIPDownloader {
|
||||
|
||||
file.flush().await?;
|
||||
|
||||
// Write download timestamp
|
||||
let timestamp_path = Self::get_timestamp_path();
|
||||
if let Some(parent) = timestamp_path.parent() {
|
||||
let _ = fs::create_dir_all(parent).await;
|
||||
}
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs();
|
||||
let _ = fs::write(×tamp_path, now.to_string()).await;
|
||||
|
||||
// Emit completion
|
||||
let _ = events::emit(
|
||||
"geoip-download-progress",
|
||||
@@ -362,6 +391,31 @@ mod tests {
|
||||
assert!(path.to_string_lossy().ends_with("GeoLite2-City.mmdb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_geoip_stale() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
let _guard = crate::app_dirs::set_test_cache_dir(tmp.path().to_path_buf());
|
||||
|
||||
// No timestamp file → stale
|
||||
assert!(GeoIPDownloader::is_geoip_stale());
|
||||
|
||||
let timestamp_path = GeoIPDownloader::get_timestamp_path();
|
||||
std::fs::create_dir_all(timestamp_path.parent().unwrap()).unwrap();
|
||||
|
||||
// Recent timestamp → not stale
|
||||
let now = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs();
|
||||
std::fs::write(×tamp_path, now.to_string()).unwrap();
|
||||
assert!(!GeoIPDownloader::is_geoip_stale());
|
||||
|
||||
// 8 days ago → stale
|
||||
let eight_days_ago = now - 8 * 24 * 60 * 60;
|
||||
std::fs::write(×tamp_path, eight_days_ago.to_string()).unwrap();
|
||||
assert!(GeoIPDownloader::is_geoip_stale());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_geoip_database_available() {
|
||||
// Test that the function works correctly regardless of file system state.
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::events;
|
||||
@@ -33,48 +31,15 @@ struct GroupsData {
|
||||
groups: Vec<ProfileGroup>,
|
||||
}
|
||||
|
||||
pub struct GroupManager {
|
||||
base_dirs: BaseDirs,
|
||||
data_dir_override: Option<PathBuf>,
|
||||
}
|
||||
pub struct GroupManager;
|
||||
|
||||
impl GroupManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
|
||||
.ok()
|
||||
.map(PathBuf::from),
|
||||
}
|
||||
Self
|
||||
}
|
||||
|
||||
// Helper for tests to override data directory without global env var
|
||||
#[allow(dead_code)]
|
||||
pub fn with_data_dir_override(dir: &Path) -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: Some(dir.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_groups_file_path(&self) -> PathBuf {
|
||||
if let Some(dir) = &self.data_dir_override {
|
||||
let mut override_path = dir.clone();
|
||||
// Ensure the directory exists before returning the path
|
||||
let _ = fs::create_dir_all(&override_path);
|
||||
override_path.push("groups.json");
|
||||
return override_path;
|
||||
}
|
||||
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("data");
|
||||
path.push("groups.json");
|
||||
path
|
||||
fn get_groups_file_path(&self) -> std::path::PathBuf {
|
||||
crate::app_dirs::data_subdir().join("groups.json")
|
||||
}
|
||||
|
||||
fn load_groups_data(&self) -> Result<GroupsData, Box<dyn std::error::Error>> {
|
||||
|
||||
+45
-8
@@ -11,6 +11,7 @@ static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
mod api_client;
|
||||
mod api_server;
|
||||
mod app_auto_updater;
|
||||
pub mod app_dirs;
|
||||
mod auto_updater;
|
||||
mod browser;
|
||||
mod browser_runner;
|
||||
@@ -20,6 +21,7 @@ mod camoufox_manager;
|
||||
mod default_browser;
|
||||
mod downloaded_browsers_registry;
|
||||
mod downloader;
|
||||
mod ephemeral_dirs;
|
||||
mod extraction;
|
||||
mod geoip_downloader;
|
||||
mod group_manager;
|
||||
@@ -283,9 +285,41 @@ async fn copy_profile_cookies(
|
||||
app_handle: tauri::AppHandle,
|
||||
request: cookie_manager::CookieCopyRequest,
|
||||
) -> Result<Vec<cookie_manager::CookieCopyResult>, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Cookie copying requires an active Pro subscription".to_string());
|
||||
}
|
||||
cookie_manager::CookieManager::copy_cookies(&app_handle, request).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn import_cookies_from_file(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
content: String,
|
||||
) -> Result<cookie_manager::CookieImportResult, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Cookie import requires an active Pro subscription".to_string());
|
||||
}
|
||||
cookie_manager::CookieManager::import_cookies(&app_handle, &profile_id, &content).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn export_profile_cookies(profile_id: String, format: String) -> Result<String, String> {
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Cookie export requires an active Pro subscription".to_string());
|
||||
}
|
||||
cookie_manager::CookieManager::export_cookies(&profile_id, &format)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn check_wayfern_terms_accepted() -> bool {
|
||||
wayfern_terms::WayfernTermsManager::instance().is_terms_accepted()
|
||||
@@ -685,12 +719,7 @@ pub fn run() {
|
||||
pending.push(url.clone());
|
||||
}
|
||||
|
||||
// Configure logging plugin with separate logs for dev and production
|
||||
let log_file_name = if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
};
|
||||
let log_file_name = app_dirs::app_name();
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(
|
||||
@@ -731,18 +760,24 @@ pub fn run() {
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.setup(|app| {
|
||||
// Clean up stale ephemeral profile dirs from previous sessions
|
||||
ephemeral_dirs::cleanup_stale_dirs();
|
||||
|
||||
// Start the daemon for tray icon
|
||||
if let Err(e) = daemon_spawn::ensure_daemon_running() {
|
||||
log::warn!("Failed to start daemon: {e}");
|
||||
}
|
||||
|
||||
// Register this GUI's PID in daemon state so the daemon can kill us directly
|
||||
daemon_spawn::register_gui_pid();
|
||||
|
||||
// Monitor daemon health - quit GUI if daemon dies
|
||||
let app_handle_daemon = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Give the daemon time to fully start
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(1));
|
||||
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
@@ -1313,6 +1348,8 @@ pub fn run() {
|
||||
enable_sync_for_all_entities,
|
||||
read_profile_cookies,
|
||||
copy_profile_cookies,
|
||||
import_cookies_from_file,
|
||||
export_profile_cookies,
|
||||
check_wayfern_terms_accepted,
|
||||
check_wayfern_downloaded,
|
||||
accept_wayfern_terms,
|
||||
|
||||
@@ -6,13 +6,11 @@ use crate::events;
|
||||
use crate::profile::types::{get_host_os, BrowserProfile};
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::wayfern_manager::WayfernConfig;
|
||||
use directories::BaseDirs;
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
use sysinfo::{Pid, System};
|
||||
|
||||
pub struct ProfileManager {
|
||||
base_dirs: BaseDirs,
|
||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
||||
wayfern_manager: &'static crate::wayfern_manager::WayfernManager,
|
||||
}
|
||||
@@ -20,7 +18,6 @@ pub struct ProfileManager {
|
||||
impl ProfileManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
|
||||
wayfern_manager: crate::wayfern_manager::WayfernManager::instance(),
|
||||
}
|
||||
@@ -31,25 +28,11 @@ impl ProfileManager {
|
||||
}
|
||||
|
||||
pub fn get_profiles_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("profiles");
|
||||
path
|
||||
crate::app_dirs::profiles_dir()
|
||||
}
|
||||
|
||||
pub fn get_binaries_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -65,6 +48,7 @@ impl ProfileManager {
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: bool,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
if proxy_id.is_some() && vpn_id.is_some() {
|
||||
return Err("Cannot set both proxy_id and vpn_id".into());
|
||||
@@ -89,7 +73,9 @@ impl ProfileManager {
|
||||
|
||||
// Create profile directory with UUID and profile subdirectory
|
||||
create_dir_all(&profile_uuid_dir)?;
|
||||
create_dir_all(&profile_data_dir)?;
|
||||
if !ephemeral {
|
||||
create_dir_all(&profile_data_dir)?;
|
||||
}
|
||||
|
||||
// For Camoufox profiles, generate fingerprint during creation
|
||||
let final_camoufox_config = if browser == "camoufox" {
|
||||
@@ -179,6 +165,7 @@ impl ProfileManager {
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -294,6 +281,7 @@ impl ProfileManager {
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
host_os: None,
|
||||
ephemeral: false,
|
||||
};
|
||||
|
||||
match self
|
||||
@@ -341,6 +329,7 @@ impl ProfileManager {
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
host_os: Some(get_host_os()),
|
||||
ephemeral,
|
||||
};
|
||||
|
||||
// Save profile info
|
||||
@@ -354,16 +343,19 @@ impl ProfileManager {
|
||||
log::info!("Profile '{name}' created successfully with ID: {profile_id}");
|
||||
|
||||
// Create user.js with common Firefox preferences and apply proxy settings if provided
|
||||
if let Some(proxy_id_ref) = &proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
|
||||
// Skip for ephemeral profiles since the data dir is created at launch time
|
||||
if !ephemeral {
|
||||
if let Some(proxy_id_ref) = &proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||
self.apply_proxy_settings_to_profile(&profile_data_dir, &proxy_settings, None)?;
|
||||
} else {
|
||||
// Proxy ID provided but not found, disable proxy
|
||||
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
|
||||
}
|
||||
} else {
|
||||
// Proxy ID provided but not found, disable proxy
|
||||
// Create user.js with common Firefox preferences but no proxy
|
||||
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
|
||||
}
|
||||
} else {
|
||||
// Create user.js with common Firefox preferences but no proxy
|
||||
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
|
||||
}
|
||||
|
||||
// Emit profile creation event
|
||||
@@ -859,6 +851,7 @@ impl ProfileManager {
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
host_os: Some(get_host_os()),
|
||||
ephemeral: false,
|
||||
};
|
||||
|
||||
self.save_profile(&new_profile)?;
|
||||
@@ -1307,7 +1300,8 @@ impl ProfileManager {
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let launcher = self.camoufox_manager;
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
// Check if there's a running Camoufox instance for this profile
|
||||
@@ -1351,6 +1345,10 @@ impl ProfileManager {
|
||||
}
|
||||
Ok(None) => {
|
||||
// No running instance found, clear process ID if set and stop proxy
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
@@ -1381,6 +1379,10 @@ impl ProfileManager {
|
||||
Err(e) => {
|
||||
// Error checking status, assume not running and clear process ID
|
||||
log::warn!("Warning: Failed to check Camoufox status: {e}");
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
@@ -1422,7 +1424,8 @@ impl ProfileManager {
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let manager = self.wayfern_manager;
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_data_path =
|
||||
crate::ephemeral_dirs::get_effective_profile_path(profile, &profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
|
||||
// Check if there's a running Wayfern instance for this profile
|
||||
@@ -1466,6 +1469,10 @@ impl ProfileManager {
|
||||
}
|
||||
None => {
|
||||
// No running instance found, clear process ID if set
|
||||
if profile.ephemeral {
|
||||
crate::ephemeral_dirs::remove_ephemeral_dir(&profile.id.to_string());
|
||||
}
|
||||
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
@@ -1682,9 +1689,11 @@ impl ProfileManager {
|
||||
let pac_content = "function FindProxyForURL(url, host) { return 'DIRECT'; }";
|
||||
let pac_path = uuid_dir.join("proxy.pac");
|
||||
fs::write(&pac_path, pac_content)?;
|
||||
let pac_url =
|
||||
url::Url::from_file_path(&pac_path).map_err(|_| "Failed to convert PAC path to file URL")?;
|
||||
preferences.push(format!(
|
||||
"user_pref(\"network.proxy.autoconfig_url\", \"file://{}\");",
|
||||
pac_path.to_string_lossy()
|
||||
"user_pref(\"network.proxy.autoconfig_url\", \"{}\");",
|
||||
pac_url.as_str()
|
||||
));
|
||||
|
||||
fs::write(user_js_path, preferences.join("\n"))?;
|
||||
@@ -1843,6 +1852,33 @@ mod tests {
|
||||
"Should set SSL proxy port"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_pac_url_encodes_spaces_in_path() {
|
||||
let (manager, temp_dir) = create_test_profile_manager();
|
||||
|
||||
let uuid_dir = temp_dir.path().join("path with spaces");
|
||||
let profile_dir = uuid_dir.join("profile");
|
||||
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
|
||||
|
||||
let result = manager.disable_proxy_settings_in_profile(&profile_dir);
|
||||
assert!(result.is_ok(), "Should handle paths with spaces");
|
||||
|
||||
let user_js = fs::read_to_string(profile_dir.join("user.js")).unwrap();
|
||||
let pac_line = user_js
|
||||
.lines()
|
||||
.find(|l| l.contains("autoconfig_url"))
|
||||
.expect("Should have autoconfig_url preference");
|
||||
|
||||
assert!(
|
||||
!pac_line.contains("path with spaces"),
|
||||
"PAC URL should not contain raw spaces: {pac_line}"
|
||||
);
|
||||
assert!(
|
||||
pac_line.contains("path%20with%20spaces"),
|
||||
"PAC URL should percent-encode spaces: {pac_line}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
@@ -1858,6 +1894,7 @@ pub async fn create_browser_profile_with_group(
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: bool,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
@@ -1872,6 +1909,7 @@ pub async fn create_browser_profile_with_group(
|
||||
camoufox_config,
|
||||
wayfern_config,
|
||||
group_id,
|
||||
ephemeral,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create profile: {e}"))
|
||||
@@ -1972,6 +2010,7 @@ pub async fn create_browser_profile_new(
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
wayfern_config: Option<WayfernConfig>,
|
||||
group_id: Option<String>,
|
||||
ephemeral: Option<bool>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let fingerprint_os = camoufox_config
|
||||
.as_ref()
|
||||
@@ -1998,6 +2037,7 @@ pub async fn create_browser_profile_new(
|
||||
camoufox_config,
|
||||
wayfern_config,
|
||||
group_id,
|
||||
ephemeral.unwrap_or(false),
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -2008,6 +2048,14 @@ pub async fn update_camoufox_config(
|
||||
profile_id: String,
|
||||
config: CamoufoxConfig,
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint editing requires an active Pro subscription".to_string());
|
||||
}
|
||||
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.is_fingerprint_os_allowed(config.os.as_deref())
|
||||
.await
|
||||
@@ -2028,6 +2076,14 @@ pub async fn update_wayfern_config(
|
||||
profile_id: String,
|
||||
config: WayfernConfig,
|
||||
) -> Result<(), String> {
|
||||
if config.fingerprint.is_some()
|
||||
&& !crate::cloud_auth::CLOUD_AUTH
|
||||
.has_active_paid_subscription()
|
||||
.await
|
||||
{
|
||||
return Err("Fingerprint editing requires an active Pro subscription".to_string());
|
||||
}
|
||||
|
||||
if !crate::cloud_auth::CLOUD_AUTH
|
||||
.is_fingerprint_os_allowed(config.os.as_deref())
|
||||
.await
|
||||
|
||||
@@ -45,6 +45,8 @@ pub struct BrowserProfile {
|
||||
pub last_sync: Option<u64>, // Timestamp of last successful sync (epoch seconds)
|
||||
#[serde(default)]
|
||||
pub host_os: Option<String>, // OS where profile was created ("macos", "windows", "linux")
|
||||
#[serde(default)]
|
||||
pub ephemeral: bool,
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
|
||||
@@ -557,6 +557,7 @@ impl ProfileImporter {
|
||||
sync_enabled: false,
|
||||
last_sync: None,
|
||||
host_os: Some(crate::profile::types::get_host_os()),
|
||||
ephemeral: false,
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
use chrono::Utc;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
@@ -149,18 +148,15 @@ pub struct ProxyManager {
|
||||
// Track active proxy IDs by profile name for targeted cleanup
|
||||
profile_active_proxy_ids: Mutex<HashMap<String, String>>, // Maps profile name to proxy id
|
||||
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
|
||||
base_dirs: BaseDirs,
|
||||
}
|
||||
|
||||
impl ProxyManager {
|
||||
pub fn new() -> Self {
|
||||
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
|
||||
let manager = Self {
|
||||
active_proxies: Mutex::new(HashMap::new()),
|
||||
profile_proxies: Mutex::new(HashMap::new()),
|
||||
profile_active_proxy_ids: Mutex::new(HashMap::new()),
|
||||
stored_proxies: Mutex::new(HashMap::new()),
|
||||
base_dirs,
|
||||
};
|
||||
|
||||
// Load stored proxies on initialization
|
||||
@@ -171,27 +167,12 @@ impl ProxyManager {
|
||||
manager
|
||||
}
|
||||
|
||||
// Get the path to the proxies directory
|
||||
fn get_proxies_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("proxies");
|
||||
path
|
||||
crate::app_dirs::proxies_dir()
|
||||
}
|
||||
|
||||
// Get the path to the proxy check cache directory
|
||||
fn get_proxy_check_cache_dir(&self) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let mut path = self.base_dirs.cache_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("proxy_checks");
|
||||
let path = crate::app_dirs::cache_dir().join("proxy_checks");
|
||||
fs::create_dir_all(&path)?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -35,15 +34,7 @@ impl ProxyConfig {
|
||||
}
|
||||
|
||||
pub fn get_storage_dir() -> PathBuf {
|
||||
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("proxies");
|
||||
path
|
||||
crate::app_dirs::proxies_dir()
|
||||
}
|
||||
|
||||
pub fn save_proxy_config(config: &ProxyConfig) -> Result<(), Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::PathBuf;
|
||||
@@ -91,27 +90,11 @@ impl Default for AppSettings {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SettingsManager {
|
||||
base_dirs: BaseDirs,
|
||||
data_dir_override: Option<PathBuf>,
|
||||
}
|
||||
pub struct SettingsManager;
|
||||
|
||||
impl SettingsManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
|
||||
.ok()
|
||||
.map(PathBuf::from),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn with_data_dir_override(dir: &std::path::Path) -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: Some(dir.to_path_buf()),
|
||||
}
|
||||
pub(crate) fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static SettingsManager {
|
||||
@@ -119,18 +102,7 @@ impl SettingsManager {
|
||||
}
|
||||
|
||||
pub fn get_settings_dir(&self) -> PathBuf {
|
||||
if let Some(dir) = &self.data_dir_override {
|
||||
return dir.join("settings");
|
||||
}
|
||||
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("settings");
|
||||
path
|
||||
crate::app_dirs::settings_dir()
|
||||
}
|
||||
|
||||
pub fn get_settings_file(&self) -> PathBuf {
|
||||
@@ -950,16 +922,16 @@ mod tests {
|
||||
use super::*;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_settings_manager() -> (SettingsManager, TempDir) {
|
||||
fn create_test_settings_manager() -> (SettingsManager, TempDir, crate::app_dirs::TestDirGuard) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
let manager = SettingsManager::with_data_dir_override(temp_dir.path());
|
||||
(manager, temp_dir)
|
||||
let guard = crate::app_dirs::set_test_data_dir(temp_dir.path().to_path_buf());
|
||||
let manager = SettingsManager::new();
|
||||
(manager, temp_dir, guard)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_manager_creation() {
|
||||
let (_manager, _temp_dir) = create_test_settings_manager();
|
||||
// Test passes if no panic occurs
|
||||
let (_manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -992,7 +964,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_load_settings_nonexistent_file() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let result = manager.load_settings();
|
||||
assert!(
|
||||
@@ -1010,7 +982,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_settings() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let test_settings = AppSettings {
|
||||
set_as_default_browser: true,
|
||||
@@ -1029,11 +1001,9 @@ mod tests {
|
||||
language: None,
|
||||
};
|
||||
|
||||
// Save settings
|
||||
let save_result = manager.save_settings(&test_settings);
|
||||
assert!(save_result.is_ok(), "Should save settings successfully");
|
||||
|
||||
// Load settings back
|
||||
let load_result = manager.load_settings();
|
||||
assert!(load_result.is_ok(), "Should load settings successfully");
|
||||
|
||||
@@ -1050,7 +1020,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_load_table_sorting_nonexistent_file() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let result = manager.load_table_sorting();
|
||||
assert!(
|
||||
@@ -1065,18 +1035,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_table_sorting() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let test_sorting = TableSortingSettings {
|
||||
column: "browser".to_string(),
|
||||
direction: "desc".to_string(),
|
||||
};
|
||||
|
||||
// Save sorting
|
||||
let save_result = manager.save_table_sorting(&test_sorting);
|
||||
assert!(save_result.is_ok(), "Should save sorting successfully");
|
||||
|
||||
// Load sorting back
|
||||
let load_result = manager.load_table_sorting();
|
||||
assert!(load_result.is_ok(), "Should load sorting successfully");
|
||||
|
||||
@@ -1093,45 +1061,37 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_should_show_launch_on_login_prompt() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let result = manager.should_show_launch_on_login_prompt();
|
||||
assert!(result.is_ok(), "Should not fail");
|
||||
|
||||
// By default, should show prompt (not declined, autostart not enabled)
|
||||
let _should_show = result.unwrap();
|
||||
// Note: The actual value depends on system autostart state, so we just test it doesn't fail
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_decline_launch_on_login() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
// Initially not declined
|
||||
let settings = manager.load_settings().unwrap();
|
||||
assert!(!settings.launch_on_login_declined);
|
||||
|
||||
// Decline
|
||||
manager.decline_launch_on_login().unwrap();
|
||||
|
||||
// Should be declined now
|
||||
let settings = manager.load_settings().unwrap();
|
||||
assert!(settings.launch_on_login_declined);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_corrupted_settings_file() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
// Create settings directory
|
||||
let settings_dir = manager.get_settings_dir();
|
||||
fs::create_dir_all(&settings_dir).expect("Should create settings directory");
|
||||
|
||||
// Write corrupted JSON
|
||||
let settings_file = manager.get_settings_file();
|
||||
fs::write(&settings_file, "{ invalid json }").expect("Should write corrupted file");
|
||||
|
||||
// Should handle corrupted file gracefully
|
||||
let result = manager.load_settings();
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
@@ -1151,7 +1111,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_settings_file_paths() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
let (manager, _temp_dir, _guard) = create_test_settings_manager();
|
||||
|
||||
let settings_dir = manager.get_settings_dir();
|
||||
let settings_file = manager.get_settings_file();
|
||||
|
||||
@@ -1,56 +1,22 @@
|
||||
use crate::profile::BrowserProfile;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
struct TagsData {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct TagManager {
|
||||
base_dirs: BaseDirs,
|
||||
data_dir_override: Option<PathBuf>,
|
||||
}
|
||||
pub struct TagManager;
|
||||
|
||||
impl TagManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
|
||||
.ok()
|
||||
.map(PathBuf::from),
|
||||
}
|
||||
Self
|
||||
}
|
||||
|
||||
// Helper for tests to override data directory without global env var
|
||||
#[allow(dead_code)]
|
||||
pub fn with_data_dir_override(dir: &Path) -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: Some(dir.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_tags_file_path(&self) -> PathBuf {
|
||||
if let Some(dir) = &self.data_dir_override {
|
||||
let mut override_path = dir.clone();
|
||||
let _ = fs::create_dir_all(&override_path);
|
||||
override_path.push("tags.json");
|
||||
return override_path;
|
||||
}
|
||||
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("data");
|
||||
path.push("tags.json");
|
||||
path
|
||||
fn get_tags_file_path(&self) -> std::path::PathBuf {
|
||||
crate::app_dirs::data_subdir().join("tags.json")
|
||||
}
|
||||
|
||||
fn load_tags_data(&self) -> Result<TagsData, Box<dyn std::error::Error>> {
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
@@ -328,17 +327,8 @@ fn acquire_file_lock(lock_path: &PathBuf) -> Result<FileLockGuard, Box<dyn std::
|
||||
Ok(FileLockGuard { _file: file })
|
||||
}
|
||||
|
||||
/// Get the traffic stats storage directory
|
||||
pub fn get_traffic_stats_dir() -> PathBuf {
|
||||
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
|
||||
let mut path = base_dirs.cache_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("traffic_stats");
|
||||
path
|
||||
crate::app_dirs::cache_dir().join("traffic_stats")
|
||||
}
|
||||
|
||||
/// Get the storage key for traffic stats (profile_id if available, otherwise proxy_id)
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
@@ -67,13 +66,7 @@ impl VersionUpdater {
|
||||
}
|
||||
|
||||
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let app_name = if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
};
|
||||
let cache_dir = base_dirs.cache_dir().join(app_name).join("version_cache");
|
||||
let cache_dir = crate::app_dirs::cache_dir().join("version_cache");
|
||||
fs::create_dir_all(&cache_dir)?;
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
@@ -97,22 +97,17 @@ impl VpnStorage {
|
||||
|
||||
/// Get the storage file path
|
||||
fn get_storage_path() -> PathBuf {
|
||||
let data_dir = directories::ProjectDirs::from("com", "donut", "donutbrowser")
|
||||
.map(|dirs| dirs.data_local_dir().to_path_buf())
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
if !data_dir.exists() {
|
||||
let _ = fs::create_dir_all(&data_dir);
|
||||
let vpn_dir = crate::app_dirs::vpn_dir();
|
||||
if !vpn_dir.exists() {
|
||||
let _ = fs::create_dir_all(&vpn_dir);
|
||||
}
|
||||
|
||||
data_dir.join("vpn_configs.json")
|
||||
Self::migrate_from_old_location(&vpn_dir);
|
||||
vpn_dir.join("vpn_configs.json")
|
||||
}
|
||||
|
||||
/// Get or create the encryption key
|
||||
fn get_or_create_key() -> [u8; 32] {
|
||||
let key_path = directories::ProjectDirs::from("com", "donut", "donutbrowser")
|
||||
.map(|dirs| dirs.data_local_dir().join(".vpn_key"))
|
||||
.unwrap_or_else(|| PathBuf::from(".vpn_key"));
|
||||
let key_path = crate::app_dirs::vpn_dir().join(".vpn_key");
|
||||
|
||||
if key_path.exists() {
|
||||
if let Ok(key_data) = fs::read(&key_path) {
|
||||
@@ -138,6 +133,22 @@ impl VpnStorage {
|
||||
key
|
||||
}
|
||||
|
||||
/// Migrate VPN configs from the old ProjectDirs location to the new app_dirs location.
|
||||
fn migrate_from_old_location(new_dir: &std::path::Path) {
|
||||
let old_dir = match directories::ProjectDirs::from("com", "donut", "donutbrowser") {
|
||||
Some(dirs) => dirs.data_local_dir().to_path_buf(),
|
||||
None => return,
|
||||
};
|
||||
|
||||
for filename in &["vpn_configs.json", ".vpn_key"] {
|
||||
let old_path = old_dir.join(filename);
|
||||
let new_path = new_dir.join(filename);
|
||||
if old_path.exists() && !new_path.exists() {
|
||||
let _ = fs::copy(&old_path, &new_path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load storage data from disk
|
||||
fn load_storage(&self) -> Result<VpnStorageData, VpnError> {
|
||||
if !self.storage_path.exists() {
|
||||
@@ -423,8 +434,7 @@ mod tests {
|
||||
|
||||
fn create_test_storage() -> (VpnStorage, TempDir) {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let mut storage = VpnStorage::new();
|
||||
storage.storage_path = temp_dir.path().join("test_vpn_configs.json");
|
||||
let storage = VpnStorage::with_dir(temp_dir.path());
|
||||
(storage, temp_dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
use crate::profile::BrowserProfile;
|
||||
use directories::BaseDirs;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::json;
|
||||
@@ -72,8 +71,6 @@ struct WayfernManagerInner {
|
||||
|
||||
pub struct WayfernManager {
|
||||
inner: Arc<AsyncMutex<WayfernManagerInner>>,
|
||||
#[allow(dead_code)]
|
||||
base_dirs: BaseDirs,
|
||||
http_client: Client,
|
||||
}
|
||||
|
||||
@@ -91,7 +88,6 @@ impl WayfernManager {
|
||||
inner: Arc::new(AsyncMutex::new(WayfernManagerInner {
|
||||
instances: HashMap::new(),
|
||||
})),
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
http_client: Client::new(),
|
||||
}
|
||||
}
|
||||
@@ -102,26 +98,12 @@ impl WayfernManager {
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn get_profiles_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("profiles");
|
||||
path
|
||||
crate::app_dirs::profiles_dir()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
fn get_binaries_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
crate::app_dirs::binaries_dir()
|
||||
}
|
||||
|
||||
async fn find_free_port() -> Result<u16, Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -398,6 +380,7 @@ impl WayfernManager {
|
||||
Ok(fingerprint_json)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn launch_wayfern(
|
||||
&self,
|
||||
_app_handle: &AppHandle,
|
||||
@@ -406,6 +389,7 @@ impl WayfernManager {
|
||||
config: &WayfernConfig,
|
||||
url: Option<&str>,
|
||||
proxy_url: Option<&str>,
|
||||
ephemeral: bool,
|
||||
) -> Result<WayfernLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
let p = PathBuf::from(path);
|
||||
@@ -450,6 +434,11 @@ impl WayfernManager {
|
||||
args.push(format!("--proxy-server={proxy}"));
|
||||
}
|
||||
|
||||
if ephemeral {
|
||||
args.push(format!("--disk-cache-dir={}/cache", profile_path));
|
||||
args.push("--incognito".to_string());
|
||||
}
|
||||
|
||||
// Don't add URL to args - we'll navigate via CDP after setting fingerprint
|
||||
// This ensures fingerprint is applied at navigation commit time
|
||||
|
||||
@@ -731,6 +720,7 @@ impl WayfernManager {
|
||||
config,
|
||||
url,
|
||||
proxy_url,
|
||||
profile.ephemeral,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -516,13 +516,7 @@ async fn test_traffic_tracking() -> Result<(), Box<dyn std::error::Error + Send
|
||||
// Wait for traffic stats to be flushed (happens every second)
|
||||
sleep(Duration::from_secs(2)).await;
|
||||
|
||||
// Verify traffic was tracked by checking traffic stats file exists
|
||||
// Note: Traffic stats are stored in the cache directory
|
||||
let cache_dir = directories::BaseDirs::new()
|
||||
.expect("Failed to get base directories")
|
||||
.cache_dir()
|
||||
.to_path_buf();
|
||||
let traffic_stats_dir = cache_dir.join("DonutBrowserDev").join("traffic_stats");
|
||||
let traffic_stats_dir = donutbrowser_lib::app_dirs::cache_dir().join("traffic_stats");
|
||||
let stats_file = traffic_stats_dir.join(format!("{}.json", proxy_id));
|
||||
|
||||
if stats_file.exists() {
|
||||
|
||||
+99
-27
@@ -7,6 +7,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CommercialTrialModal } from "@/components/commercial-trial-modal";
|
||||
import { CookieCopyDialog } from "@/components/cookie-copy-dialog";
|
||||
import { CookieExportDialog } from "@/components/cookie-export-dialog";
|
||||
import { CookieImportDialog } from "@/components/cookie-import-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
||||
@@ -44,7 +46,12 @@ import {
|
||||
showSuccessToast,
|
||||
showToast,
|
||||
} from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, CamoufoxConfig, WayfernConfig } from "@/types";
|
||||
import type {
|
||||
BrowserProfile,
|
||||
CamoufoxConfig,
|
||||
SyncSettings,
|
||||
WayfernConfig,
|
||||
} from "@/types";
|
||||
|
||||
type BrowserTypeString =
|
||||
| "firefox"
|
||||
@@ -105,6 +112,23 @@ export default function Home() {
|
||||
(cloudUser?.subscriptionStatus === "active" ||
|
||||
cloudUser?.planPeriod === "lifetime");
|
||||
|
||||
const [selfHostedSyncConfigured, setSelfHostedSyncConfigured] =
|
||||
useState(false);
|
||||
|
||||
const checkSelfHostedSync = useCallback(async () => {
|
||||
try {
|
||||
const settings = await invoke<SyncSettings>("get_sync_settings");
|
||||
const hasConfig = Boolean(
|
||||
settings.sync_server_url && settings.sync_token,
|
||||
);
|
||||
setSelfHostedSyncConfigured(hasConfig && !cloudUser);
|
||||
} catch {
|
||||
setSelfHostedSyncConfigured(false);
|
||||
}
|
||||
}, [cloudUser]);
|
||||
|
||||
const syncUnlocked = crossOsUnlocked || selfHostedSyncConfigured;
|
||||
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [integrationsDialogOpen, setIntegrationsDialogOpen] = useState(false);
|
||||
@@ -120,6 +144,12 @@ export default function Home() {
|
||||
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
|
||||
useState(false);
|
||||
const [cookieCopyDialogOpen, setCookieCopyDialogOpen] = useState(false);
|
||||
const [cookieImportDialogOpen, setCookieImportDialogOpen] = useState(false);
|
||||
const [currentProfileForCookieImport, setCurrentProfileForCookieImport] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [cookieExportDialogOpen, setCookieExportDialogOpen] = useState(false);
|
||||
const [currentProfileForCookieExport, setCurrentProfileForCookieExport] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [selectedProfilesForCookies, setSelectedProfilesForCookies] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
@@ -464,6 +494,7 @@ export default function Home() {
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
ephemeral?: boolean;
|
||||
}) => {
|
||||
try {
|
||||
await invoke<BrowserProfile>("create_browser_profile_new", {
|
||||
@@ -478,6 +509,7 @@ export default function Home() {
|
||||
groupId:
|
||||
profileData.groupId ||
|
||||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
|
||||
ephemeral: profileData.ephemeral,
|
||||
});
|
||||
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
@@ -666,6 +698,16 @@ export default function Home() {
|
||||
setCookieCopyDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleImportCookies = useCallback((profile: BrowserProfile) => {
|
||||
setCurrentProfileForCookieImport(profile);
|
||||
setCookieImportDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleExportCookies = useCallback((profile: BrowserProfile) => {
|
||||
setCurrentProfileForCookieExport(profile);
|
||||
setCookieExportDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleGroupAssignmentComplete = useCallback(async () => {
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setGroupAssignmentDialogOpen(false);
|
||||
@@ -715,34 +757,37 @@ export default function Home() {
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
unlisten = await listen<{ profile_id: string; status: string }>(
|
||||
"profile-sync-status",
|
||||
(event) => {
|
||||
const { profile_id, status } = event.payload;
|
||||
if (!userInitiatedSyncIds.current.has(profile_id)) return;
|
||||
unlisten = await listen<{
|
||||
profile_id: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>("profile-sync-status", (event) => {
|
||||
const { profile_id, status, error } = event.payload;
|
||||
if (!userInitiatedSyncIds.current.has(profile_id)) return;
|
||||
|
||||
const toastId = `sync-${profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === profile_id);
|
||||
const name = profile?.name ?? "Unknown";
|
||||
const toastId = `sync-${profile_id}`;
|
||||
const profile = profiles.find((p) => p.id === profile_id);
|
||||
const name = profile?.name ?? "Unknown";
|
||||
|
||||
if (status === "syncing") {
|
||||
showToast({
|
||||
type: "loading",
|
||||
title: `Syncing profile '${name}'...`,
|
||||
id: toastId,
|
||||
duration: 30000,
|
||||
});
|
||||
} else if (status === "synced") {
|
||||
dismissToast(toastId);
|
||||
showSuccessToast(`Profile '${name}' synced successfully`);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
} else if (status === "error") {
|
||||
dismissToast(toastId);
|
||||
showErrorToast(`Failed to sync profile '${name}'`);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (status === "syncing") {
|
||||
showToast({
|
||||
type: "loading",
|
||||
title: `Syncing profile '${name}'...`,
|
||||
id: toastId,
|
||||
duration: 30000,
|
||||
});
|
||||
} else if (status === "synced") {
|
||||
dismissToast(toastId);
|
||||
showSuccessToast(`Profile '${name}' synced successfully`);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
} else if (status === "error") {
|
||||
dismissToast(toastId);
|
||||
showErrorToast(
|
||||
`Failed to sync profile '${name}'${error ? `: ${error}` : ""}`,
|
||||
);
|
||||
userInitiatedSyncIds.current.delete(profile_id);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync status events:", error);
|
||||
}
|
||||
@@ -895,6 +940,11 @@ export default function Home() {
|
||||
}
|
||||
}, [isInitialized, checkAllPermissions]);
|
||||
|
||||
// Check self-hosted sync config on mount and when cloud user changes
|
||||
useEffect(() => {
|
||||
void checkSelfHostedSync();
|
||||
}, [checkSelfHostedSync]);
|
||||
|
||||
// Filter data by selected group and search query
|
||||
const filteredProfiles = useMemo(() => {
|
||||
let filtered = profiles;
|
||||
@@ -964,6 +1014,8 @@ export default function Home() {
|
||||
onRenameProfile={handleRenameProfile}
|
||||
onConfigureCamoufox={handleConfigureCamoufox}
|
||||
onCopyCookiesToProfile={handleCopyCookiesToProfile}
|
||||
onImportCookies={handleImportCookies}
|
||||
onExportCookies={handleExportCookies}
|
||||
runningProfiles={runningProfiles}
|
||||
isUpdating={isUpdating}
|
||||
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
|
||||
@@ -978,6 +1030,7 @@ export default function Home() {
|
||||
onOpenProfileSyncDialog={handleOpenProfileSyncDialog}
|
||||
onToggleProfileSync={handleToggleProfileSync}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
syncUnlocked={syncUnlocked}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
@@ -1106,6 +1159,24 @@ export default function Home() {
|
||||
onCopyComplete={() => setSelectedProfilesForCookies([])}
|
||||
/>
|
||||
|
||||
<CookieImportDialog
|
||||
isOpen={cookieImportDialogOpen}
|
||||
onClose={() => {
|
||||
setCookieImportDialogOpen(false);
|
||||
setCurrentProfileForCookieImport(null);
|
||||
}}
|
||||
profile={currentProfileForCookieImport}
|
||||
/>
|
||||
|
||||
<CookieExportDialog
|
||||
isOpen={cookieExportDialogOpen}
|
||||
onClose={() => {
|
||||
setCookieExportDialogOpen(false);
|
||||
setCurrentProfileForCookieExport(null);
|
||||
}}
|
||||
profile={currentProfileForCookieExport}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => setShowBulkDeleteConfirmation(false)}
|
||||
@@ -1122,6 +1193,7 @@ export default function Home() {
|
||||
isOpen={syncConfigDialogOpen}
|
||||
onClose={(loginOccurred) => {
|
||||
setSyncConfigDialogOpen(false);
|
||||
void checkSelfHostedSync();
|
||||
if (loginOccurred) {
|
||||
setSyncAllDialogOpen(true);
|
||||
}
|
||||
|
||||
@@ -26,7 +26,9 @@ const getCurrentOS = (): CamoufoxOS => {
|
||||
return "linux";
|
||||
};
|
||||
|
||||
import { LuLock } from "react-icons/lu";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
import { ProBadge } from "./ui/pro-badge";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface CamoufoxConfigDialogProps {
|
||||
@@ -155,13 +157,13 @@ export function CamoufoxConfigDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 h-[300px]">
|
||||
<div className="py-4">
|
||||
<div className="py-4 relative">
|
||||
{profile.browser === "wayfern" ? (
|
||||
<WayfernConfigForm
|
||||
config={config as WayfernConfig}
|
||||
onConfigChange={updateConfig}
|
||||
forceAdvanced={true}
|
||||
readOnly={isRunning}
|
||||
readOnly={isRunning || !crossOsUnlocked}
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
) : (
|
||||
@@ -169,11 +171,20 @@ export function CamoufoxConfigDialog({
|
||||
config={config as CamoufoxConfig}
|
||||
onConfigChange={updateConfig}
|
||||
forceAdvanced={true}
|
||||
readOnly={isRunning}
|
||||
readOnly={isRunning || !crossOsUnlocked}
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
)}
|
||||
{!crossOsUnlocked && (
|
||||
<div className="absolute inset-0 backdrop-blur-sm bg-background/60 z-10 flex flex-col items-center justify-center gap-2">
|
||||
<LuLock className="w-6 h-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
Fingerprint editing is a Pro feature
|
||||
</p>
|
||||
<ProBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
@@ -181,7 +192,7 @@ export function CamoufoxConfigDialog({
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
{isRunning ? "Close" : "Cancel"}
|
||||
</RippleButton>
|
||||
{!isRunning && (
|
||||
{!isRunning && crossOsUnlocked && (
|
||||
<LoadingButton
|
||||
isLoading={isSaving}
|
||||
onClick={handleSave}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { save } from "@tauri-apps/plugin-dialog";
|
||||
import { writeTextFile } from "@tauri-apps/plugin-fs";
|
||||
import { useCallback, useState } from "react";
|
||||
import { LuDownload } from "react-icons/lu";
|
||||
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 { RippleButton } from "@/components/ui/ripple";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
interface CookieExportDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
}
|
||||
|
||||
export function CookieExportDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
}: CookieExportDialogProps) {
|
||||
const [format, setFormat] = useState<"netscape" | "json">("json");
|
||||
const [isExporting, setIsExporting] = useState(false);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setFormat("json");
|
||||
setIsExporting(false);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!profile) return;
|
||||
setIsExporting(true);
|
||||
try {
|
||||
const content = await invoke<string>("export_profile_cookies", {
|
||||
profileId: profile.id,
|
||||
format,
|
||||
});
|
||||
|
||||
const ext = format === "json" ? "json" : "txt";
|
||||
const defaultName = `${profile.name}_cookies.${ext}`;
|
||||
|
||||
const filePath = await save({
|
||||
defaultPath: defaultName,
|
||||
filters: [
|
||||
{
|
||||
name: format === "json" ? "JSON" : "Text",
|
||||
extensions: [ext],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
if (!filePath) {
|
||||
setIsExporting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
await writeTextFile(filePath, content);
|
||||
toast.success("Cookies exported successfully");
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsExporting(false);
|
||||
}
|
||||
}, [profile, format, handleClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Export Cookies</DialogTitle>
|
||||
<DialogDescription>
|
||||
Export cookies from this profile.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Format</Label>
|
||||
<Select
|
||||
value={format}
|
||||
onValueChange={(v) => setFormat(v as "netscape" | "json")}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="json">JSON</SelectItem>
|
||||
<SelectItem value="netscape">Netscape TXT</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isExporting}
|
||||
onClick={() => void handleExport()}
|
||||
>
|
||||
<LuDownload className="w-4 h-4 mr-2" />
|
||||
Export
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { LuUpload } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { RippleButton } from "@/components/ui/ripple";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
interface CookieImportResult {
|
||||
cookies_imported: number;
|
||||
cookies_replaced: number;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
interface CookieImportDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
}
|
||||
|
||||
const countCookies = (content: string): number => {
|
||||
const trimmed = content.trim();
|
||||
if (trimmed.startsWith("[")) {
|
||||
try {
|
||||
const arr = JSON.parse(trimmed);
|
||||
if (Array.isArray(arr)) return arr.length;
|
||||
} catch {
|
||||
// Fall through to Netscape counting
|
||||
}
|
||||
}
|
||||
return content.split("\n").filter((line) => {
|
||||
const l = line.trim();
|
||||
return l && !l.startsWith("#");
|
||||
}).length;
|
||||
};
|
||||
|
||||
export function CookieImportDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
}: CookieImportDialogProps) {
|
||||
const [fileContent, setFileContent] = useState<string | null>(null);
|
||||
const [fileName, setFileName] = useState<string | null>(null);
|
||||
const [cookieCount, setCookieCount] = useState(0);
|
||||
const [isImporting, setIsImporting] = useState(false);
|
||||
const [result, setResult] = useState<CookieImportResult | null>(null);
|
||||
|
||||
const resetState = useCallback(() => {
|
||||
setFileContent(null);
|
||||
setFileName(null);
|
||||
setCookieCount(0);
|
||||
setIsImporting(false);
|
||||
setResult(null);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
resetState();
|
||||
onClose();
|
||||
}, [resetState, onClose]);
|
||||
|
||||
const handleFileRead = useCallback((file: File) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = e.target?.result as string;
|
||||
setFileContent(content);
|
||||
setFileName(file.name);
|
||||
setCookieCount(countCookies(content));
|
||||
};
|
||||
reader.onerror = () => {
|
||||
toast.error("Failed to read file");
|
||||
};
|
||||
reader.readAsText(file);
|
||||
}, []);
|
||||
|
||||
const handleImport = useCallback(async () => {
|
||||
if (!fileContent || !profile) return;
|
||||
setIsImporting(true);
|
||||
try {
|
||||
const importResult = await invoke<CookieImportResult>(
|
||||
"import_cookies_from_file",
|
||||
{
|
||||
profileId: profile.id,
|
||||
content: fileContent,
|
||||
},
|
||||
);
|
||||
setResult(importResult);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [fileContent, profile]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Import Cookies</DialogTitle>
|
||||
<DialogDescription>
|
||||
{!fileContent &&
|
||||
"Import cookies from a Netscape or JSON format file."}
|
||||
{fileContent &&
|
||||
!result &&
|
||||
`${cookieCount} cookies found in ${fileName}`}
|
||||
{result && "Cookie import completed"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{!fileContent && (
|
||||
<div className="space-y-4">
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="flex flex-col items-center justify-center border-2 border-dashed rounded-lg p-8 transition-colors cursor-pointer border-muted-foreground/25 hover:border-muted-foreground/50"
|
||||
onClick={() =>
|
||||
document.getElementById("cookie-file-input")?.click()
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
e.preventDefault();
|
||||
document.getElementById("cookie-file-input")?.click();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<LuUpload className="w-10 h-10 text-muted-foreground mb-4" />
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Click to choose a cookie file
|
||||
<br />
|
||||
<span className="text-xs">(.txt, .cookies, or .json)</span>
|
||||
</p>
|
||||
<input
|
||||
id="cookie-file-input"
|
||||
type="file"
|
||||
accept=".txt,.cookies,.json"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleFileRead(file);
|
||||
e.target.value = "";
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{fileContent && !result && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3 p-4 bg-muted/30 rounded-lg">
|
||||
<div>
|
||||
<div className="font-medium">{fileName}</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{cookieCount} cookies found
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="space-y-4">
|
||||
<div className="p-4 rounded-lg bg-green-500/10">
|
||||
<div className="font-medium text-green-600 dark:text-green-400">
|
||||
Successfully imported {result.cookies_imported} cookies (
|
||||
{result.cookies_replaced} replaced)
|
||||
</div>
|
||||
{result.errors.length > 0 && (
|
||||
<div className="mt-2 text-sm text-muted-foreground">
|
||||
{result.errors.length} line(s) skipped
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
{!fileContent && (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
)}
|
||||
|
||||
{fileContent && !result && (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={resetState}>
|
||||
Back
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
onClick={() => void handleImport()}
|
||||
disabled={cookieCount === 0}
|
||||
>
|
||||
Import
|
||||
</LoadingButton>
|
||||
</>
|
||||
)}
|
||||
|
||||
{result && <RippleButton onClick={handleClose}>Done</RippleButton>}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -3,10 +3,12 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuLock } 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 { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,6 +18,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
@@ -73,6 +76,7 @@ interface CreateProfileDialogProps {
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
wayfernConfig?: WayfernConfig;
|
||||
groupId?: string;
|
||||
ephemeral?: boolean;
|
||||
}) => Promise<void>;
|
||||
selectedGroupId?: string;
|
||||
crossOsUnlocked?: boolean;
|
||||
@@ -162,6 +166,7 @@ export function CreateProfileDialog({
|
||||
const { vpnConfigs } = useVpnEvents();
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [ephemeral, setEphemeral] = useState(false);
|
||||
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
|
||||
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
|
||||
const [releaseTypesError, setReleaseTypesError] = useState<string | null>(
|
||||
@@ -380,6 +385,7 @@ export function CreateProfileDialog({
|
||||
wayfernConfig: finalWayfernConfig,
|
||||
groupId:
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
ephemeral,
|
||||
});
|
||||
} else {
|
||||
// Default to Camoufox
|
||||
@@ -403,6 +409,7 @@ export function CreateProfileDialog({
|
||||
camoufoxConfig: finalCamoufoxConfig,
|
||||
groupId:
|
||||
selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
ephemeral,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
@@ -457,6 +464,7 @@ export function CreateProfileDialog({
|
||||
setWayfernConfig({
|
||||
os: getCurrentOS() as WayfernOS, // Reset to current OS
|
||||
});
|
||||
setEphemeral(false);
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -658,6 +666,28 @@ export function CreateProfileDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Ephemeral Toggle */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="ephemeral"
|
||||
checked={ephemeral}
|
||||
onCheckedChange={(checked) =>
|
||||
setEphemeral(checked === true)
|
||||
}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<Label
|
||||
htmlFor="ephemeral"
|
||||
className="cursor-pointer"
|
||||
>
|
||||
Ephemeral
|
||||
</Label>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Browser data is deleted when closed
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedBrowser === "wayfern" ? (
|
||||
// Wayfern Configuration
|
||||
<div className="space-y-6">
|
||||
@@ -748,12 +778,23 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={updateWayfernConfig}
|
||||
isCreating
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
<div className="relative">
|
||||
<WayfernConfigForm
|
||||
config={wayfernConfig}
|
||||
onConfigChange={updateWayfernConfig}
|
||||
isCreating
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
{!crossOsUnlocked && (
|
||||
<div className="absolute inset-0 backdrop-blur-sm bg-background/60 z-10 flex flex-col items-center justify-center gap-2">
|
||||
<LuLock className="w-6 h-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
Fingerprint editing is a Pro feature
|
||||
</p>
|
||||
<ProBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : selectedBrowser === "camoufox" ? (
|
||||
// Camoufox Configuration
|
||||
@@ -845,13 +886,24 @@ export function CreateProfileDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
isCreating
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
<div className="relative">
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
isCreating
|
||||
browserType="camoufox"
|
||||
crossOsUnlocked={crossOsUnlocked}
|
||||
/>
|
||||
{!crossOsUnlocked && (
|
||||
<div className="absolute inset-0 backdrop-blur-sm bg-background/60 z-10 flex flex-col items-center justify-center gap-2">
|
||||
<LuLock className="w-6 h-6 text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground font-medium">
|
||||
Fingerprint editing is a Pro feature
|
||||
</p>
|
||||
<ProBadge />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// Regular Browser Configuration (should not happen in anti-detect tab)
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
LuChevronDown,
|
||||
LuChevronUp,
|
||||
LuCookie,
|
||||
LuLock,
|
||||
LuTrash2,
|
||||
LuUsers,
|
||||
} from "react-icons/lu";
|
||||
@@ -48,6 +47,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
@@ -176,16 +176,19 @@ type TableMeta = {
|
||||
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
||||
onCloneProfile?: (profile: BrowserProfile) => void;
|
||||
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
||||
onImportCookies?: (profile: BrowserProfile) => void;
|
||||
onExportCookies?: (profile: BrowserProfile) => void;
|
||||
|
||||
// Traffic snapshots (lightweight real-time data)
|
||||
trafficSnapshots: Record<string, TrafficSnapshot>;
|
||||
onOpenTrafficDialog?: (profileId: string) => void;
|
||||
|
||||
// Sync
|
||||
syncStatuses: Record<string, string>;
|
||||
syncStatuses: Record<string, { status: string; error?: string }>;
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
syncUnlocked?: boolean;
|
||||
|
||||
// Country proxy creation (inline in proxy dropdown)
|
||||
countries: LocationItem[];
|
||||
@@ -208,6 +211,7 @@ function getProfileSyncStatusDot(
|
||||
| "error"
|
||||
| "disabled"
|
||||
| undefined,
|
||||
errorMessage?: string,
|
||||
): SyncStatusDot | null {
|
||||
const status = liveStatus ?? (profile.sync_enabled ? "synced" : "disabled");
|
||||
|
||||
@@ -229,7 +233,11 @@ function getProfileSyncStatusDot(
|
||||
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,
|
||||
};
|
||||
case "disabled":
|
||||
if (profile.last_sync) {
|
||||
return {
|
||||
@@ -750,6 +758,8 @@ interface ProfilesDataTableProps {
|
||||
onRenameProfile: (profileId: string, newName: string) => Promise<void>;
|
||||
onConfigureCamoufox: (profile: BrowserProfile) => void;
|
||||
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
|
||||
onImportCookies?: (profile: BrowserProfile) => void;
|
||||
onExportCookies?: (profile: BrowserProfile) => void;
|
||||
runningProfiles: Set<string>;
|
||||
isUpdating: (browser: string) => boolean;
|
||||
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
|
||||
@@ -764,6 +774,7 @@ interface ProfilesDataTableProps {
|
||||
onOpenProfileSyncDialog?: (profile: BrowserProfile) => void;
|
||||
onToggleProfileSync?: (profile: BrowserProfile) => void;
|
||||
crossOsUnlocked?: boolean;
|
||||
syncUnlocked?: boolean;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -775,6 +786,8 @@ export function ProfilesDataTable({
|
||||
onRenameProfile,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onImportCookies,
|
||||
onExportCookies,
|
||||
runningProfiles,
|
||||
isUpdating,
|
||||
onAssignProfilesToGroup,
|
||||
@@ -787,6 +800,7 @@ export function ProfilesDataTable({
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
crossOsUnlocked = false,
|
||||
syncUnlocked = false,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
@@ -897,7 +911,7 @@ export function ProfilesDataTable({
|
||||
name?: string;
|
||||
} | null>(null);
|
||||
const [syncStatuses, setSyncStatuses] = React.useState<
|
||||
Record<string, string>
|
||||
Record<string, { status: string; error?: string }>
|
||||
>({});
|
||||
|
||||
// Country proxy creation state (for inline proxy creation in dropdown)
|
||||
@@ -1038,13 +1052,17 @@ export function ProfilesDataTable({
|
||||
let unlisten: (() => void) | undefined;
|
||||
(async () => {
|
||||
try {
|
||||
unlisten = await listen<{ profile_id: string; status: string }>(
|
||||
"profile-sync-status",
|
||||
(event) => {
|
||||
const { profile_id, status } = event.payload;
|
||||
setSyncStatuses((prev) => ({ ...prev, [profile_id]: status }));
|
||||
},
|
||||
);
|
||||
unlisten = await listen<{
|
||||
profile_id: string;
|
||||
status: string;
|
||||
error?: string;
|
||||
}>("profile-sync-status", (event) => {
|
||||
const { profile_id, status, error } = event.payload;
|
||||
setSyncStatuses((prev) => ({
|
||||
...prev,
|
||||
[profile_id]: { status, error },
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to listen for sync status events:", error);
|
||||
}
|
||||
@@ -1459,6 +1477,8 @@ export function ProfilesDataTable({
|
||||
onCloneProfile,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onImportCookies,
|
||||
onExportCookies,
|
||||
|
||||
// Traffic snapshots (lightweight real-time data)
|
||||
trafficSnapshots,
|
||||
@@ -1472,6 +1492,7 @@ export function ProfilesDataTable({
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
crossOsUnlocked,
|
||||
syncUnlocked,
|
||||
|
||||
// Country proxy creation
|
||||
countries,
|
||||
@@ -1519,10 +1540,13 @@ export function ProfilesDataTable({
|
||||
onCloneProfile,
|
||||
onConfigureCamoufox,
|
||||
onCopyCookiesToProfile,
|
||||
onImportCookies,
|
||||
onExportCookies,
|
||||
syncStatuses,
|
||||
onOpenProfileSyncDialog,
|
||||
onToggleProfileSync,
|
||||
crossOsUnlocked,
|
||||
syncUnlocked,
|
||||
countries,
|
||||
canCreateLocationProxy,
|
||||
loadCountries,
|
||||
@@ -1916,7 +1940,14 @@ export function ProfilesDataTable({
|
||||
}
|
||||
}}
|
||||
>
|
||||
{display}
|
||||
<span className="flex items-center gap-1">
|
||||
{display}
|
||||
{profile.ephemeral && (
|
||||
<span className="px-1 py-0.5 text-[10px] leading-none rounded bg-muted text-muted-foreground font-medium">
|
||||
Ephemeral
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
@@ -2262,7 +2293,8 @@ export function ProfilesDataTable({
|
||||
cell: ({ row, table }) => {
|
||||
const profile = row.original;
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const liveStatus = meta.syncStatuses[profile.id] as
|
||||
const syncEntry = meta.syncStatuses[profile.id];
|
||||
const liveStatus = syncEntry?.status as
|
||||
| "syncing"
|
||||
| "waiting"
|
||||
| "synced"
|
||||
@@ -2270,7 +2302,11 @@ export function ProfilesDataTable({
|
||||
| "disabled"
|
||||
| undefined;
|
||||
|
||||
const dot = getProfileSyncStatusDot(profile, liveStatus);
|
||||
const dot = getProfileSyncStatusDot(
|
||||
profile,
|
||||
liveStatus,
|
||||
syncEntry?.error,
|
||||
);
|
||||
if (!dot) return null;
|
||||
|
||||
return (
|
||||
@@ -2332,17 +2368,15 @@ export function ProfilesDataTable({
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (meta.crossOsUnlocked) {
|
||||
if (meta.syncUnlocked) {
|
||||
meta.onToggleProfileSync?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={!meta.crossOsUnlocked || isCrossOs}
|
||||
disabled={!meta.syncUnlocked || isCrossOs}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{profile.sync_enabled ? "Disable Sync" : "Enable Sync"}
|
||||
{!meta.crossOsUnlocked && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{!meta.syncUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
@@ -2362,29 +2396,76 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Change Fingerprint
|
||||
<span className="flex items-center gap-2">
|
||||
Change Fingerprint
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
!profile.ephemeral &&
|
||||
meta.onCopyCookiesToProfile && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onCopyCookiesToProfile?.(profile);
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onCopyCookiesToProfile?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
disabled={isDisabled || !meta.crossOsUnlocked}
|
||||
>
|
||||
Copy Cookies to Profile
|
||||
<span className="flex items-center gap-2">
|
||||
Copy Cookies to Profile
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onCloneProfile?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Clone Profile
|
||||
</DropdownMenuItem>
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
!profile.ephemeral &&
|
||||
meta.onImportCookies && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onImportCookies?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled || !meta.crossOsUnlocked}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
Import Cookies
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(profile.browser === "camoufox" ||
|
||||
profile.browser === "wayfern") &&
|
||||
!profile.ephemeral &&
|
||||
meta.onExportCookies && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (meta.crossOsUnlocked) {
|
||||
meta.onExportCookies?.(profile);
|
||||
}
|
||||
}}
|
||||
disabled={isDisabled || !meta.crossOsUnlocked}
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
Export Cookies
|
||||
{!meta.crossOsUnlocked && <ProBadge />}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!profile.ephemeral && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onCloneProfile?.(profile);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
Clone Profile
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setProfileToDelete(profile);
|
||||
@@ -2521,9 +2602,10 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
{onBulkCopyCookies && (
|
||||
<DataTableActionBarAction
|
||||
tooltip="Copy Cookies"
|
||||
tooltip={crossOsUnlocked ? "Copy Cookies" : "Copy Cookies (Pro)"}
|
||||
onClick={onBulkCopyCookies}
|
||||
size="icon"
|
||||
disabled={!crossOsUnlocked}
|
||||
>
|
||||
<LuCookie />
|
||||
</DataTableActionBarAction>
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuLock } from "react-icons/lu";
|
||||
import MultipleSelector, { type Option } from "@/components/multiple-selector";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -237,9 +237,7 @@ export function SharedCamoufoxConfigForm({
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
{osLabels[os]}
|
||||
{isDisabled && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{isDisabled && <ProBadge />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
@@ -1011,9 +1009,7 @@ export function SharedCamoufoxConfigForm({
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
{osLabels[os]}
|
||||
{isDisabled && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{isDisabled && <ProBadge />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuEye, LuEyeOff, LuLock } from "react-icons/lu";
|
||||
import { LuEye, LuEyeOff } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import {
|
||||
Tooltip,
|
||||
@@ -294,9 +295,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{t("sync.cloud.tabLabel")}
|
||||
{cloudBlocked && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{cloudBlocked && <ProBadge />}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
@@ -306,9 +305,7 @@ export function SyncConfigDialog({ isOpen, onClose }: SyncConfigDialogProps) {
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
{t("sync.cloud.selfHostedTabLabel")}
|
||||
{selfHostedBlocked && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{selfHostedBlocked && <ProBadge />}
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function ProBadge({ className }: { className?: string }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"text-[10px] font-semibold px-1 py-0.5 rounded bg-primary text-primary-foreground",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
PRO
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { LuLock } from "react-icons/lu";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ProBadge } from "@/components/ui/pro-badge";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -166,9 +166,7 @@ export function WayfernConfigForm({
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
{osLabels[os]}
|
||||
{isDisabled && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{isDisabled && <ProBadge />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
@@ -959,9 +957,7 @@ export function WayfernConfigForm({
|
||||
<SelectItem key={os} value={os} disabled={isDisabled}>
|
||||
<span className="flex items-center gap-2">
|
||||
{osLabels[os]}
|
||||
{isDisabled && (
|
||||
<LuLock className="w-3 h-3 text-muted-foreground" />
|
||||
)}
|
||||
{isDisabled && <ProBadge />}
|
||||
</span>
|
||||
</SelectItem>
|
||||
);
|
||||
|
||||
@@ -158,7 +158,10 @@
|
||||
"copyCookies": "Copy Cookies",
|
||||
"configure": "Configure",
|
||||
"clone": "Clone Profile"
|
||||
}
|
||||
},
|
||||
"ephemeral": "Ephemeral",
|
||||
"ephemeralDescription": "Browser data is deleted when closed",
|
||||
"ephemeralBadge": "Ephemeral"
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Create New Profile",
|
||||
@@ -503,5 +506,32 @@
|
||||
"viewOnly": "This profile was created on {{os}} and is not supported on this system",
|
||||
"cannotLaunch": "This profile was created on {{os}} and is not supported on this system",
|
||||
"cannotModify": "Cannot modify sync settings for a cross-OS profile"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Import Cookies",
|
||||
"description": "Import cookies from a Netscape or JSON format file.",
|
||||
"selectFile": "Choose File",
|
||||
"preview": "{{count}} cookies found",
|
||||
"success": "Successfully imported {{imported}} cookies ({{replaced}} replaced)",
|
||||
"error": "Failed to import cookies",
|
||||
"proFeature": "Cookie import is a Pro feature"
|
||||
},
|
||||
"export": {
|
||||
"title": "Export Cookies",
|
||||
"description": "Export cookies from this profile.",
|
||||
"formatLabel": "Format",
|
||||
"netscape": "Netscape TXT",
|
||||
"json": "JSON",
|
||||
"success": "Cookies exported successfully",
|
||||
"error": "Failed to export cookies"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "Fingerprint editing is a Pro feature",
|
||||
"cookieCopyLocked": "Cookie copying is a Pro feature",
|
||||
"cookieImportLocked": "Cookie import is a Pro feature",
|
||||
"cookieExportLocked": "Cookie export is a Pro feature"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@
|
||||
"copyCookies": "Copiar Cookies",
|
||||
"configure": "Configurar",
|
||||
"clone": "Clonar perfil"
|
||||
}
|
||||
},
|
||||
"ephemeral": "Efímero",
|
||||
"ephemeralDescription": "Los datos del navegador se eliminan al cerrarlo",
|
||||
"ephemeralBadge": "Efímero"
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Crear Nuevo Perfil",
|
||||
@@ -503,5 +506,32 @@
|
||||
"viewOnly": "Este perfil fue creado en {{os}} y no es compatible con este sistema",
|
||||
"cannotLaunch": "Este perfil fue creado en {{os}} y no es compatible con este sistema",
|
||||
"cannotModify": "No se pueden modificar los ajustes de sincronización de un perfil de otro sistema operativo"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Importar Cookies",
|
||||
"description": "Importar cookies desde un archivo en formato Netscape o JSON.",
|
||||
"selectFile": "Elegir Archivo",
|
||||
"preview": "{{count}} cookies encontradas",
|
||||
"success": "Se importaron {{imported}} cookies exitosamente ({{replaced}} reemplazadas)",
|
||||
"error": "Error al importar cookies",
|
||||
"proFeature": "La importación de cookies es una función Pro"
|
||||
},
|
||||
"export": {
|
||||
"title": "Exportar Cookies",
|
||||
"description": "Exportar cookies de este perfil.",
|
||||
"formatLabel": "Formato",
|
||||
"netscape": "Netscape TXT",
|
||||
"json": "JSON",
|
||||
"success": "Cookies exportadas exitosamente",
|
||||
"error": "Error al exportar cookies"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "La edición de huellas digitales es una función Pro",
|
||||
"cookieCopyLocked": "La copia de cookies es una función Pro",
|
||||
"cookieImportLocked": "La importación de cookies es una función Pro",
|
||||
"cookieExportLocked": "La exportación de cookies es una función Pro"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@
|
||||
"copyCookies": "Copier les cookies",
|
||||
"configure": "Configurer",
|
||||
"clone": "Cloner le profil"
|
||||
}
|
||||
},
|
||||
"ephemeral": "Éphémère",
|
||||
"ephemeralDescription": "Les données du navigateur sont supprimées à la fermeture",
|
||||
"ephemeralBadge": "Éphémère"
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Créer un nouveau profil",
|
||||
@@ -503,5 +506,32 @@
|
||||
"viewOnly": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système",
|
||||
"cannotLaunch": "Ce profil a été créé sur {{os}} et n'est pas pris en charge sur ce système",
|
||||
"cannotModify": "Impossible de modifier les paramètres de synchronisation d'un profil d'un autre système d'exploitation"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Importer des Cookies",
|
||||
"description": "Importer des cookies depuis un fichier au format Netscape ou JSON.",
|
||||
"selectFile": "Choisir un Fichier",
|
||||
"preview": "{{count}} cookies trouvés",
|
||||
"success": "{{imported}} cookies importés avec succès ({{replaced}} remplacés)",
|
||||
"error": "Échec de l'importation des cookies",
|
||||
"proFeature": "L'importation de cookies est une fonctionnalité Pro"
|
||||
},
|
||||
"export": {
|
||||
"title": "Exporter les Cookies",
|
||||
"description": "Exporter les cookies de ce profil.",
|
||||
"formatLabel": "Format",
|
||||
"netscape": "Netscape TXT",
|
||||
"json": "JSON",
|
||||
"success": "Cookies exportés avec succès",
|
||||
"error": "Échec de l'exportation des cookies"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "La modification d'empreinte est une fonctionnalité Pro",
|
||||
"cookieCopyLocked": "La copie de cookies est une fonctionnalité Pro",
|
||||
"cookieImportLocked": "L'importation de cookies est une fonctionnalité Pro",
|
||||
"cookieExportLocked": "L'exportation de cookies est une fonctionnalité Pro"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@
|
||||
"copyCookies": "Cookieをコピー",
|
||||
"configure": "設定",
|
||||
"clone": "プロファイルを複製"
|
||||
}
|
||||
},
|
||||
"ephemeral": "一時的",
|
||||
"ephemeralDescription": "ブラウザを閉じるとデータが削除されます",
|
||||
"ephemeralBadge": "一時的"
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "新しいプロファイルを作成",
|
||||
@@ -503,5 +506,32 @@
|
||||
"viewOnly": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
|
||||
"cannotLaunch": "このプロファイルは{{os}}で作成されたもので、このシステムではサポートされていません",
|
||||
"cannotModify": "他のOSのプロファイルの同期設定は変更できません"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Cookieのインポート",
|
||||
"description": "NetscapeまたはJSON形式のファイルからCookieをインポートします。",
|
||||
"selectFile": "ファイルを選択",
|
||||
"preview": "{{count}}件のCookieが見つかりました",
|
||||
"success": "{{imported}}件のCookieをインポートしました({{replaced}}件を置換)",
|
||||
"error": "Cookieのインポートに失敗しました",
|
||||
"proFeature": "Cookieのインポートはプロ機能です"
|
||||
},
|
||||
"export": {
|
||||
"title": "Cookieのエクスポート",
|
||||
"description": "このプロファイルからCookieをエクスポートします。",
|
||||
"formatLabel": "形式",
|
||||
"netscape": "Netscape TXT",
|
||||
"json": "JSON",
|
||||
"success": "Cookieのエクスポートに成功しました",
|
||||
"error": "Cookieのエクスポートに失敗しました"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "フィンガープリント編集はプロ機能です",
|
||||
"cookieCopyLocked": "Cookieのコピーはプロ機能です",
|
||||
"cookieImportLocked": "Cookieのインポートはプロ機能です",
|
||||
"cookieExportLocked": "Cookieのエクスポートはプロ機能です"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@
|
||||
"copyCookies": "Copiar Cookies",
|
||||
"configure": "Configurar",
|
||||
"clone": "Clonar perfil"
|
||||
}
|
||||
},
|
||||
"ephemeral": "Efêmero",
|
||||
"ephemeralDescription": "Os dados do navegador são excluídos ao fechar",
|
||||
"ephemeralBadge": "Efêmero"
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Criar Novo Perfil",
|
||||
@@ -503,5 +506,32 @@
|
||||
"viewOnly": "Este perfil foi criado em {{os}} e não é compatível com este sistema",
|
||||
"cannotLaunch": "Este perfil foi criado em {{os}} e não é compatível com este sistema",
|
||||
"cannotModify": "Não é possível modificar as configurações de sincronização de um perfil de outro sistema operacional"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Importar Cookies",
|
||||
"description": "Importar cookies de um arquivo no formato Netscape ou JSON.",
|
||||
"selectFile": "Escolher Arquivo",
|
||||
"preview": "{{count}} cookies encontrados",
|
||||
"success": "{{imported}} cookies importados com sucesso ({{replaced}} substituídos)",
|
||||
"error": "Falha ao importar cookies",
|
||||
"proFeature": "A importação de cookies é um recurso Pro"
|
||||
},
|
||||
"export": {
|
||||
"title": "Exportar Cookies",
|
||||
"description": "Exportar cookies deste perfil.",
|
||||
"formatLabel": "Formato",
|
||||
"netscape": "Netscape TXT",
|
||||
"json": "JSON",
|
||||
"success": "Cookies exportados com sucesso",
|
||||
"error": "Falha ao exportar cookies"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "A edição de impressão digital é um recurso Pro",
|
||||
"cookieCopyLocked": "A cópia de cookies é um recurso Pro",
|
||||
"cookieImportLocked": "A importação de cookies é um recurso Pro",
|
||||
"cookieExportLocked": "A exportação de cookies é um recurso Pro"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@
|
||||
"copyCookies": "Копировать Cookie",
|
||||
"configure": "Настроить",
|
||||
"clone": "Клонировать профиль"
|
||||
}
|
||||
},
|
||||
"ephemeral": "Временный",
|
||||
"ephemeralDescription": "Данные браузера удаляются при закрытии",
|
||||
"ephemeralBadge": "Временный"
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "Создать новый профиль",
|
||||
@@ -503,5 +506,32 @@
|
||||
"viewOnly": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
|
||||
"cannotLaunch": "Этот профиль был создан на {{os}} и не поддерживается в этой системе",
|
||||
"cannotModify": "Невозможно изменить настройки синхронизации профиля другой ОС"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "Импорт Cookies",
|
||||
"description": "Импорт cookies из файла в формате Netscape или JSON.",
|
||||
"selectFile": "Выбрать файл",
|
||||
"preview": "Найдено {{count}} cookies",
|
||||
"success": "Успешно импортировано {{imported}} cookies ({{replaced}} заменено)",
|
||||
"error": "Ошибка импорта cookies",
|
||||
"proFeature": "Импорт cookies — функция Pro"
|
||||
},
|
||||
"export": {
|
||||
"title": "Экспорт Cookies",
|
||||
"description": "Экспорт cookies из этого профиля.",
|
||||
"formatLabel": "Формат",
|
||||
"netscape": "Netscape TXT",
|
||||
"json": "JSON",
|
||||
"success": "Cookies успешно экспортированы",
|
||||
"error": "Ошибка экспорта cookies"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "Редактирование отпечатка — функция Pro",
|
||||
"cookieCopyLocked": "Копирование cookies — функция Pro",
|
||||
"cookieImportLocked": "Импорт cookies — функция Pro",
|
||||
"cookieExportLocked": "Экспорт cookies — функция Pro"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -158,7 +158,10 @@
|
||||
"copyCookies": "复制 Cookies",
|
||||
"configure": "配置",
|
||||
"clone": "克隆配置文件"
|
||||
}
|
||||
},
|
||||
"ephemeral": "临时",
|
||||
"ephemeralDescription": "关闭浏览器时数据将被删除",
|
||||
"ephemeralBadge": "临时"
|
||||
},
|
||||
"createProfile": {
|
||||
"title": "创建新配置文件",
|
||||
@@ -503,5 +506,32 @@
|
||||
"viewOnly": "此配置文件在 {{os}} 上创建,不受此系统支持",
|
||||
"cannotLaunch": "此配置文件在 {{os}} 上创建,不受此系统支持",
|
||||
"cannotModify": "无法修改跨操作系统配置文件的同步设置"
|
||||
},
|
||||
"cookies": {
|
||||
"import": {
|
||||
"title": "导入 Cookies",
|
||||
"description": "从 Netscape 或 JSON 格式文件导入 Cookies。",
|
||||
"selectFile": "选择文件",
|
||||
"preview": "找到 {{count}} 个 Cookies",
|
||||
"success": "成功导入 {{imported}} 个 Cookies(替换了 {{replaced}} 个)",
|
||||
"error": "导入 Cookies 失败",
|
||||
"proFeature": "导入 Cookies 是 Pro 功能"
|
||||
},
|
||||
"export": {
|
||||
"title": "导出 Cookies",
|
||||
"description": "从此配置文件导出 Cookies。",
|
||||
"formatLabel": "格式",
|
||||
"netscape": "Netscape TXT",
|
||||
"json": "JSON",
|
||||
"success": "Cookies 导出成功",
|
||||
"error": "导出 Cookies 失败"
|
||||
}
|
||||
},
|
||||
"pro": {
|
||||
"badge": "PRO",
|
||||
"fingerprintLocked": "指纹编辑是 Pro 功能",
|
||||
"cookieCopyLocked": "Cookie 复制是 Pro 功能",
|
||||
"cookieImportLocked": "Cookie 导入是 Pro 功能",
|
||||
"cookieExportLocked": "Cookie 导出是 Pro 功能"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface BrowserProfile {
|
||||
sync_enabled?: boolean; // Whether sync is enabled for this profile
|
||||
last_sync?: number; // Timestamp of last successful sync (epoch seconds)
|
||||
host_os?: string; // OS where profile was created ("macos", "windows", "linux")
|
||||
ephemeral?: boolean;
|
||||
}
|
||||
|
||||
export type SyncStatus = "Disabled" | "Syncing" | "Synced" | "Error";
|
||||
|
||||
Reference in New Issue
Block a user