mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-04 01:25:12 +02:00
Compare commits
256 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1d71729c9e | |||
| a14da3d2f0 | |||
| 59c69c44a1 | |||
| 025523d0d3 | |||
| 76d17df281 | |||
| 727fa51a64 | |||
| 80305ef903 | |||
| 4d98606f28 | |||
| c2d083a10d | |||
| 6d1d15d366 | |||
| 2b2c855679 | |||
| e80043167f | |||
| 2ee3a90e25 | |||
| 231ac3f26c | |||
| 41c02c539f | |||
| ec78787079 | |||
| 7fc6f985dd | |||
| 5814f00f3d | |||
| 621a2dd0a1 | |||
| 3564762872 | |||
| b12d3af3bd | |||
| 32e70a5943 | |||
| 8b8ba31cce | |||
| 201e0270c7 | |||
| ceb2eec80e | |||
| f2b3b2cc69 | |||
| 8ac077d81b | |||
| dab5ab5805 | |||
| 83a7c0e394 | |||
| f622c77a3e | |||
| 9af33efb08 | |||
| 90cdf34e2b | |||
| bc2cbffcf4 | |||
| 341a461abf | |||
| 69b7963dd4 | |||
| c1079cf7b1 | |||
| 1e3f1d4668 | |||
| bb62ca350c | |||
| 1281fb3955 | |||
| ac878aed48 | |||
| 140621dcbe | |||
| 55b8955a20 | |||
| 1611c8e536 | |||
| c17bb56fec | |||
| cea8030268 | |||
| d9e3e1f3ef | |||
| d48e26c7eb | |||
| b7b75ec3d8 | |||
| 4a8b0bd407 | |||
| 8f24410f11 | |||
| ff4aa572fe | |||
| 0abea50279 | |||
| 1f90b12fe5 | |||
| bc0c31f527 | |||
| 357499168f | |||
| 7b1311f2ca | |||
| d755978b34 | |||
| bb164ce743 | |||
| daa36f008b | |||
| 92ef2798d2 | |||
| a9720676ae | |||
| fbcec2cbc1 | |||
| 5d395f606e | |||
| 6963e07be5 | |||
| c28537d304 | |||
| c303de4a8a | |||
| 21a13fb217 | |||
| 90d8e782de | |||
| ad2d9b73f2 | |||
| e645e212f2 | |||
| 7df92ae8ee | |||
| 18a0254ca7 | |||
| b0a58c3131 | |||
| 467e82ca93 | |||
| 8d9654044a | |||
| 711d94c9c1 | |||
| 305051d03d | |||
| 8695884535 | |||
| bb96936550 | |||
| 730621e5a1 | |||
| 714102eb25 | |||
| e5378c1bb7 | |||
| b7c8d5672a | |||
| 3fb81e2ca0 | |||
| 510de96393 | |||
| 3688e88d67 | |||
| 9646a41788 | |||
| f7679d25ca | |||
| 2d42772718 | |||
| 3980f835d6 | |||
| d0185dd5ae | |||
| de39fa4555 | |||
| f773eb6f1c | |||
| 458c30433d | |||
| 1cb9ffa249 | |||
| 5c58b5c644 | |||
| f41311a7bb | |||
| c8c09c296e | |||
| ca0c2614f4 | |||
| dca5a2970e | |||
| e0a1dd5a8a | |||
| e48b681215 | |||
| 6796912606 | |||
| 7105f6544f | |||
| 3003f868e7 | |||
| b733d26f10 | |||
| 675c2417d7 | |||
| e10a7bf089 | |||
| c8e3cd39ff | |||
| 0103150dc7 | |||
| be57ac3219 | |||
| b4067b5e34 | |||
| 3fa8822139 | |||
| 1e0ef0b497 | |||
| 2da832f100 | |||
| 284dbc5a3b | |||
| f328ceeb4f | |||
| 1bfbb365c5 | |||
| 1a15af1ded | |||
| ca20ccb489 | |||
| 0daba2eb9b | |||
| 1dfdfc6a21 | |||
| ee2f728194 | |||
| 702c1545bf | |||
| 53f403b82c | |||
| 2cae2824d3 | |||
| ca790c74ce | |||
| 25d4b30975 | |||
| 687fc7817f | |||
| 28818bed77 | |||
| d1d953b3e2 | |||
| c2153d463c | |||
| fa142a8cb0 | |||
| cf291fb0d1 | |||
| 5fed6b7c3f | |||
| 9ba51cd4e3 | |||
| a507a3daed | |||
| aabae8d3d4 | |||
| b7e6c1eb84 | |||
| d05f2190e8 | |||
| 34a9418474 | |||
| 63e125738d | |||
| 58d82d12c4 | |||
| b1c86709b0 | |||
| 12651f9f85 | |||
| c1815fdfdc | |||
| 1662c1efba | |||
| 4a8e905a44 | |||
| e165e35f2c | |||
| cbeb099cc9 | |||
| fef0c963cb | |||
| cd28531588 | |||
| ed913309dd | |||
| cecb4579c7 | |||
| 9cfed6d73e | |||
| a461fd4798 | |||
| 5159f943df | |||
| 72af5f682f | |||
| 6d77c872f2 | |||
| 0baecbdb0c | |||
| eb2af5c10b | |||
| 76cef4757a | |||
| 00d74bddaf | |||
| b5b08a0196 | |||
| ff35717cb5 | |||
| 669611ec68 | |||
| 8f1b84f615 | |||
| 2bf6531767 | |||
| da0af075fc | |||
| 83f4c2c162 | |||
| e675441171 | |||
| cf77d96042 | |||
| a4706a7f9a | |||
| b088ae675b | |||
| 54fd9b7282 | |||
| 77a50c60d1 | |||
| 62b9768006 | |||
| 66d3420000 | |||
| 8f05c48594 | |||
| a5709d95c7 | |||
| eef3e19d2f | |||
| 2c57920d44 | |||
| 57a36a5fc2 | |||
| a2a980d203 | |||
| 2deacbacab | |||
| 7cd7d077ae | |||
| 8679d0ca62 | |||
| 20cf9de4fa | |||
| 4202d595f2 | |||
| 3086ea0085 | |||
| 1ddbc5228c | |||
| 2d02095d4d | |||
| 4e0d985996 | |||
| 5af751a9b2 | |||
| da7f791274 | |||
| f4c33ad96e | |||
| 5b31cfaf32 | |||
| 4997854577 | |||
| a43e41a020 | |||
| b22b4cacf9 | |||
| 7f0df6f943 | |||
| dccf843952 | |||
| fc6ddb7cbf | |||
| 63000c72bd | |||
| 2fd344b9bb | |||
| 44bd34d8f0 | |||
| d3822bdd88 | |||
| ed1132bdc3 | |||
| fcae0623c0 | |||
| fe843e14f1 | |||
| b071e971b3 | |||
| 0b7cf547b3 | |||
| f024ce19ae | |||
| e1d3ff9000 | |||
| e2a168b188 | |||
| af767da32c | |||
| b5dfe1233e | |||
| dddf8e2e39 | |||
| adcb20fab9 | |||
| ff9c633b07 | |||
| 7ca76b1f78 | |||
| 4887a3db4d | |||
| e38cd2e560 | |||
| 7e7b47cae3 | |||
| 13ae170166 | |||
| df78e22650 | |||
| 328e6f16ee | |||
| 40ad32af6d | |||
| f299eeaea5 | |||
| 84142caac9 | |||
| d06dbb6c70 | |||
| cf5b498bd6 | |||
| 3c28a169bd | |||
| 25653e166b | |||
| 0b4263140d | |||
| b500c28b96 | |||
| 7c2be81531 | |||
| b55ef469ed | |||
| 76a206093d | |||
| 3e88dbc30e | |||
| 031823587e | |||
| c7a1ac228c | |||
| 8ede335bed | |||
| b170b8846d | |||
| 632d90a022 | |||
| 3bec00a2cd | |||
| 3b78971df8 | |||
| 5f9a716f62 | |||
| 4d07984d99 | |||
| 188e14e5b5 | |||
| bc1b9e9757 | |||
| e742e5fdfa | |||
| 9ce7757cb2 | |||
| 3ca454a2c5 | |||
| 689ac8e3ca | |||
| 0e1c5dcfb6 |
@@ -3,4 +3,4 @@ description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Don't leave comments that don't add value
|
||||
Don't leave comments that don't add value.
|
||||
@@ -3,4 +3,4 @@ description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times
|
||||
Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Anytime you change nodecar's code and try to test, recompile it with "cd nodecar && pnpm build".
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless I have explicitly specified in the request otherwise.
|
||||
@@ -59,6 +59,9 @@ jobs:
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
- name: Install banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install dependencies from lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -80,7 +83,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b1e4dc3db58c9601794e22a9f6d28d45461b9dbf #v3.29.0
|
||||
|
||||
@@ -16,6 +16,6 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@1ff4c56187458b34cd602aee93e897344ce34bfc #v2.3.10
|
||||
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -69,13 +69,11 @@ jobs:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b #v2.4.0
|
||||
secrets: inherit
|
||||
with:
|
||||
compat-lookup: true
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Auto-merge minor and patch updates
|
||||
uses: ridedott/merge-me-action@338053c6f9b9311a6be80208f6f0723981e40627 #v2.10.122
|
||||
secrets: inherit
|
||||
uses: ridedott/merge-me-action@f96a67511b4be051e77760230e6a3fb9cb7b1903 #v2.10.124
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.SECRET_DEPENDABOT_GITHUB_TOKEN }}
|
||||
MERGE_METHOD: SQUASH
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
name: Greetings
|
||||
|
||||
on: [pull_request_target, issues]
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened]
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
jobs:
|
||||
greeting:
|
||||
@@ -9,8 +13,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/first-interaction@34f15e814fe48ac9312ccf29db4e74fa767cbab7 #v1.3.0
|
||||
- uses: actions/first-interaction@2d4393e6bc0e2efb2e48fba7e06819c3bf61ffc9 # v2.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-message: "Thank you for your first issue ❤️ If it's a feature request, please make sure it's clear what you want, why you want it, and how important it is to you. If you posted a bug report, please make sure it includes as much detail as possible."
|
||||
pr-message: "Welcome to the community and thank you for your first contribution ❤️ A human will review your PR shortly. Make sure that the pipelines are green, so that the PR is considered ready for a review and could be merged."
|
||||
|
||||
@@ -5,6 +5,7 @@ on:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
models: read
|
||||
|
||||
@@ -48,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Validate issue with AI
|
||||
id: validate
|
||||
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
|
||||
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
|
||||
with:
|
||||
prompt-file: issue_analysis.txt
|
||||
system-prompt: |
|
||||
@@ -90,17 +91,25 @@ jobs:
|
||||
```
|
||||
|
||||
Be constructive and helpful in your feedback. If the issue is incomplete, provide specific guidance on what's needed.
|
||||
model: gpt-4o
|
||||
model: openai/gpt-4o
|
||||
|
||||
- name: Parse validation result and take action
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Get the AI response
|
||||
VALIDATION_RESULT='${{ steps.validate.outputs.response }}'
|
||||
# Prefer reading from the response file to avoid output truncation
|
||||
RESPONSE_FILE='${{ steps.validate.outputs.response-file }}'
|
||||
if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then
|
||||
RAW_OUTPUT=$(cat "$RESPONSE_FILE")
|
||||
else
|
||||
RAW_OUTPUT='${{ steps.validate.outputs.response }}'
|
||||
fi
|
||||
|
||||
# Extract JSON from the response (handle potential markdown formatting)
|
||||
JSON_RESULT=$(echo "$VALIDATION_RESULT" | sed -n '/```json/,/```/p' | sed '1d;$d' || echo "$VALIDATION_RESULT")
|
||||
# Extract JSON if wrapped in markdown code fences; otherwise use raw
|
||||
JSON_RESULT=$(printf "%s" "$RAW_OUTPUT" | sed -n '/```json/,/```/p' | sed '1d;$d')
|
||||
if [ -z "$JSON_RESULT" ]; then
|
||||
JSON_RESULT="$RAW_OUTPUT"
|
||||
fi
|
||||
|
||||
# Parse JSON fields
|
||||
IS_VALID=$(echo "$JSON_RESULT" | jq -r '.is_valid // false')
|
||||
@@ -156,7 +165,24 @@ jobs:
|
||||
echo "✅ Validation comment posted and 'needs-info' label added"
|
||||
else
|
||||
echo "✅ Issue contains sufficient information"
|
||||
|
||||
|
||||
# Prepare a summary comment even when valid
|
||||
cat > comment.md << EOF
|
||||
## 🤖 Issue Validation
|
||||
|
||||
**Issue Type Detected:** \`$ISSUE_TYPE\`
|
||||
|
||||
**Assessment:** $ASSESSMENT
|
||||
|
||||
$( [ -n "$SUGGESTIONS" ] && echo "### 💡 Suggestions:" && echo "$SUGGESTIONS" )
|
||||
|
||||
---
|
||||
*This validation was performed automatically to help triage issues.*
|
||||
EOF
|
||||
|
||||
# Post the summary comment
|
||||
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
|
||||
|
||||
# Add appropriate labels based on issue type
|
||||
case "$ISSUE_TYPE" in
|
||||
"bug_report")
|
||||
|
||||
@@ -30,9 +30,8 @@ permissions:
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
fail-fast: true
|
||||
matrix:
|
||||
os: [macos-latest]
|
||||
os: [macos-latest, ubuntu-latest]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -62,6 +61,9 @@ jobs:
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Install banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install dependencies (Ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -83,16 +85,21 @@ jobs:
|
||||
pnpm run build:win-x64
|
||||
fi
|
||||
|
||||
# TODO: replace with an integration test that fetches everything from rust
|
||||
# - name: Download Camoufox for testing
|
||||
# run: npx camoufox-js fetch
|
||||
# continue-on-error: true
|
||||
|
||||
- name: Copy nodecar binary to Tauri binaries
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-aarch64-apple-darwin
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-aarch64-apple-darwin
|
||||
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
|
||||
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-x86_64-pc-windows-msvc.exe
|
||||
cp nodecar/nodecar-bin.exe src-tauri/binaries/nodecar-x86_64-pc-windows-msvc.exe
|
||||
fi
|
||||
|
||||
- name: Create empty 'dist' directory
|
||||
@@ -106,7 +113,7 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings -D clippy::all
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Run Rust unit tests
|
||||
- name: Run Rust tests
|
||||
run: cargo test
|
||||
working-directory: src-tauri
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
scan-scheduled:
|
||||
name: Scheduled Security Scan
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
scan-pr:
|
||||
name: PR Security Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
- name: Generate release notes with AI
|
||||
id: generate-notes
|
||||
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
|
||||
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
|
||||
with:
|
||||
prompt-file: commits.txt
|
||||
system-prompt: |
|
||||
|
||||
@@ -13,7 +13,7 @@ env:
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -67,37 +67,37 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- platform: "macos-latest"
|
||||
args: "--target aarch64-apple-darwin"
|
||||
args: "--target aarch64-apple-darwin --verbose"
|
||||
arch: "aarch64"
|
||||
target: "aarch64-apple-darwin"
|
||||
pkg_target: "latest-macos-arm64"
|
||||
nodecar_script: "build:mac-aarch64"
|
||||
- platform: "macos-latest"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
args: "--target x86_64-apple-darwin --verbose"
|
||||
arch: "x86_64"
|
||||
target: "x86_64-apple-darwin"
|
||||
pkg_target: "latest-macos-x64"
|
||||
nodecar_script: "build:mac-x86_64"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: "--target x86_64-unknown-linux-gnu"
|
||||
args: "--target x86_64-unknown-linux-gnu --verbose"
|
||||
arch: "x86_64"
|
||||
target: "x86_64-unknown-linux-gnu"
|
||||
pkg_target: "latest-linux-x64"
|
||||
nodecar_script: "build:linux-x64"
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: "--target aarch64-unknown-linux-gnu"
|
||||
args: "--target aarch64-unknown-linux-gnu --verbose"
|
||||
arch: "aarch64"
|
||||
target: "aarch64-unknown-linux-gnu"
|
||||
pkg_target: "latest-linux-arm64"
|
||||
nodecar_script: "build:linux-arm64"
|
||||
# - platform: "windows-latest"
|
||||
# args: "--target x86_64-pc-windows-msvc"
|
||||
# args: "--target x86_64-pc-windows-msvc --verbose"
|
||||
# arch: "x86_64"
|
||||
# target: "x86_64-pc-windows-msvc"
|
||||
# pkg_target: "latest-win-x64"
|
||||
# nodecar_script: "build:win-x64"
|
||||
# - platform: "windows-11-arm"
|
||||
# args: "--target aarch64-pc-windows-msvc"
|
||||
# args: "--target aarch64-pc-windows-msvc --verbose"
|
||||
# arch: "aarch64"
|
||||
# target: "aarch64-pc-windows-msvc"
|
||||
# pkg_target: "latest-win-arm64"
|
||||
@@ -132,6 +132,9 @@ jobs:
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
- name: Install banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -146,11 +149,15 @@ jobs:
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
|
||||
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}.exe
|
||||
else
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
fi
|
||||
|
||||
# - name: Download Camoufox for testing
|
||||
# run: npx camoufox-js fetch
|
||||
# continue-on-error: true
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
@@ -172,4 +179,3 @@ jobs:
|
||||
with:
|
||||
branch: main
|
||||
commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
|
||||
file_pattern: CHANGELOG.md
|
||||
|
||||
@@ -12,7 +12,7 @@ env:
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -66,41 +66,41 @@ jobs:
|
||||
matrix:
|
||||
include:
|
||||
- platform: "macos-latest"
|
||||
args: "--target aarch64-apple-darwin"
|
||||
args: "--target aarch64-apple-darwin --verbose"
|
||||
arch: "aarch64"
|
||||
target: "aarch64-apple-darwin"
|
||||
pkg_target: "latest-macos-arm64"
|
||||
nodecar_script: "build:mac-aarch64"
|
||||
- platform: "macos-latest"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
args: "--target x86_64-apple-darwin --verbose"
|
||||
arch: "x86_64"
|
||||
target: "x86_64-apple-darwin"
|
||||
pkg_target: "latest-macos-x64"
|
||||
nodecar_script: "build:mac-x86_64"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: "--target x86_64-unknown-linux-gnu"
|
||||
args: "--target x86_64-unknown-linux-gnu --verbose"
|
||||
arch: "x86_64"
|
||||
target: "x86_64-unknown-linux-gnu"
|
||||
pkg_target: "latest-linux-x64"
|
||||
nodecar_script: "build:linux-x64"
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: "--target aarch64-unknown-linux-gnu"
|
||||
args: "--target aarch64-unknown-linux-gnu --verbose"
|
||||
arch: "aarch64"
|
||||
target: "aarch64-unknown-linux-gnu"
|
||||
pkg_target: "latest-linux-arm64"
|
||||
nodecar_script: "build:linux-arm64"
|
||||
- platform: "windows-latest"
|
||||
args: "--target x86_64-pc-windows-msvc"
|
||||
args: "--target x86_64-pc-windows-msvc --verbose"
|
||||
arch: "x86_64"
|
||||
target: "x86_64-pc-windows-msvc"
|
||||
pkg_target: "latest-win-x64"
|
||||
nodecar_script: "build:win-x64"
|
||||
- platform: "windows-11-arm"
|
||||
args: "--target aarch64-pc-windows-msvc"
|
||||
arch: "aarch64"
|
||||
target: "aarch64-pc-windows-msvc"
|
||||
pkg_target: "latest-win-arm64"
|
||||
nodecar_script: "build:win-arm64"
|
||||
# - platform: "windows-11-arm"
|
||||
# args: "--target aarch64-pc-windows-msvc --verbose"
|
||||
# arch: "aarch64"
|
||||
# target: "aarch64-pc-windows-msvc"
|
||||
# pkg_target: "latest-win-arm64"
|
||||
# nodecar_script: "build:win-arm64"
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
@@ -131,6 +131,9 @@ jobs:
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
- name: Install banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -145,11 +148,15 @@ jobs:
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
|
||||
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}.exe
|
||||
else
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
fi
|
||||
|
||||
# - name: Download Camoufox for testing
|
||||
# run: npx camoufox-js fetch
|
||||
# continue-on-error: true
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
|
||||
@@ -23,4 +23,4 @@ jobs:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@392b78fe18a52790c53f42456e46124f77346842 #v1.34.0
|
||||
uses: crate-ci/typos@52bd719c2c91f9d676e2aa359fc8e0db8925e6d8 #v1.35.3
|
||||
|
||||
+4
-1
@@ -46,4 +46,7 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
!**/.gitkeep
|
||||
!**/.gitkeep
|
||||
|
||||
# nodecar
|
||||
nodecar/nodecar-bin
|
||||
Vendored
+74
@@ -1,25 +1,44 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"adwaita",
|
||||
"ahooks",
|
||||
"akhilmhdh",
|
||||
"appimage",
|
||||
"appindicator",
|
||||
"applescript",
|
||||
"asyncio",
|
||||
"autoconfig",
|
||||
"autologin",
|
||||
"biomejs",
|
||||
"breezedark",
|
||||
"browserforge",
|
||||
"busctl",
|
||||
"CAMOU",
|
||||
"camoufox",
|
||||
"cdylib",
|
||||
"certifi",
|
||||
"CFURL",
|
||||
"checkin",
|
||||
"chrono",
|
||||
"CLICOLOR",
|
||||
"clippy",
|
||||
"cmdk",
|
||||
"codegen",
|
||||
"codesign",
|
||||
"commitish",
|
||||
"CTYPE",
|
||||
"daijro",
|
||||
"dataclasses",
|
||||
"datareporting",
|
||||
"datas",
|
||||
"dconf",
|
||||
"devedition",
|
||||
"distro",
|
||||
"doctest",
|
||||
"doesn",
|
||||
"domcontentloaded",
|
||||
"donutbrowser",
|
||||
"doorhanger",
|
||||
"dpkg",
|
||||
"dtolnay",
|
||||
"dyld",
|
||||
@@ -27,17 +46,31 @@
|
||||
"errorlevel",
|
||||
"esac",
|
||||
"esbuild",
|
||||
"etree",
|
||||
"flate",
|
||||
"frontmost",
|
||||
"geoip",
|
||||
"getcwd",
|
||||
"gettimezone",
|
||||
"gifs",
|
||||
"gsettings",
|
||||
"healthreport",
|
||||
"hiddenimports",
|
||||
"hkcu",
|
||||
"hooksconfig",
|
||||
"hookspath",
|
||||
"icns",
|
||||
"idlelib",
|
||||
"idletime",
|
||||
"idna",
|
||||
"Inno",
|
||||
"kdeglobals",
|
||||
"keras",
|
||||
"KHTML",
|
||||
"Kolkata",
|
||||
"kreadconfig",
|
||||
"launchservices",
|
||||
"letterboxing",
|
||||
"libatk",
|
||||
"libayatana",
|
||||
"libcairo",
|
||||
@@ -48,6 +81,8 @@
|
||||
"libwebkit",
|
||||
"libxdo",
|
||||
"localtime",
|
||||
"lxml",
|
||||
"lzma",
|
||||
"mmdb",
|
||||
"mountpoint",
|
||||
"msiexec",
|
||||
@@ -55,26 +90,54 @@
|
||||
"msys",
|
||||
"Mullvad",
|
||||
"mullvadbrowser",
|
||||
"mypy",
|
||||
"noarchive",
|
||||
"nobrowse",
|
||||
"noconfirm",
|
||||
"nodecar",
|
||||
"nodemon",
|
||||
"norestart",
|
||||
"NSIS",
|
||||
"ntlm",
|
||||
"numpy",
|
||||
"objc",
|
||||
"orhun",
|
||||
"orjson",
|
||||
"osascript",
|
||||
"oscpu",
|
||||
"outpath",
|
||||
"pathex",
|
||||
"pathlib",
|
||||
"peerconnection",
|
||||
"pids",
|
||||
"pixbuf",
|
||||
"plasmohq",
|
||||
"platformdirs",
|
||||
"prefs",
|
||||
"propertylist",
|
||||
"psutil",
|
||||
"pycache",
|
||||
"pydantic",
|
||||
"pyee",
|
||||
"pyinstaller",
|
||||
"pyoxidizer",
|
||||
"pytest",
|
||||
"pyyaml",
|
||||
"reqwest",
|
||||
"ridedott",
|
||||
"rlib",
|
||||
"rustc",
|
||||
"SARIF",
|
||||
"scipy",
|
||||
"screeninfo",
|
||||
"serde",
|
||||
"setuptools",
|
||||
"shadcn",
|
||||
"showcursor",
|
||||
"shutil",
|
||||
"signon",
|
||||
"signum",
|
||||
"sklearn",
|
||||
"sonner",
|
||||
"splitn",
|
||||
"sspi",
|
||||
@@ -91,15 +154,23 @@
|
||||
"tasklist",
|
||||
"tauri",
|
||||
"TERX",
|
||||
"testpass",
|
||||
"testuser",
|
||||
"timedatectl",
|
||||
"titlebar",
|
||||
"tkinter",
|
||||
"Torbrowser",
|
||||
"tqdm",
|
||||
"trackingprotection",
|
||||
"turbopack",
|
||||
"turtledemo",
|
||||
"udeps",
|
||||
"unlisten",
|
||||
"unminimize",
|
||||
"unrs",
|
||||
"urlencoding",
|
||||
"urllib",
|
||||
"venv",
|
||||
"vercel",
|
||||
"VERYSILENT",
|
||||
"webgl",
|
||||
@@ -107,7 +178,10 @@
|
||||
"winreg",
|
||||
"wiremock",
|
||||
"xattr",
|
||||
"xfconf",
|
||||
"xsettings",
|
||||
"zhom",
|
||||
"zipball",
|
||||
"zoneinfo"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Instructions for AI Agents
|
||||
|
||||
- After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test". It means that you first format the code, then lint it, then test it, so that no part is broken after your changes.
|
||||
- Don't leave comments that don't add value
|
||||
- Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times
|
||||
- Don't leave comments that don't add value.
|
||||
- Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times.
|
||||
- Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
|
||||
- Anytime you change nodecar's code and try to test, recompile it with "cd nodecar && pnpm build".
|
||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless I have explicitly specified in the request otherwise.
|
||||
+6
-5
@@ -26,6 +26,7 @@ Ensure you have the following dependencies installed:
|
||||
- Node.js (see `.node-version` for exact version)
|
||||
- pnpm package manager
|
||||
- Latest Rust and Cargo toolchain
|
||||
- [Banderole](https://github.com/zhom/banderole)
|
||||
- [Tauri prerequisites guide](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
## Run Locally
|
||||
@@ -46,12 +47,13 @@ After having the above dependencies installed, proceed through the following ste
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Install nodecar dependencies**
|
||||
4. **Build nodecar**
|
||||
|
||||
Building nodecar requires you to have `banderole` installed.
|
||||
|
||||
```bash
|
||||
cd nodecar
|
||||
pnpm install --frozen-lockfile
|
||||
cd ..
|
||||
pnpm build
|
||||
```
|
||||
|
||||
5. **Start the development server**
|
||||
@@ -105,7 +107,6 @@ Make sure the build completes successfully without errors.
|
||||
## Testing
|
||||
|
||||
- Always test your changes on the target platform
|
||||
- Test both development and production builds
|
||||
- Verify that existing functionality still works
|
||||
- Add tests for new features when possible
|
||||
|
||||
@@ -155,7 +156,7 @@ Donut Browser is built with:
|
||||
|
||||
- **Frontend**: Next.js React application
|
||||
- **Backend**: Tauri (Rust) for native functionality
|
||||
- **Node.js Sidecar**: `nodecar` binary for proxy support
|
||||
- **Node.js Sidecar**: `nodecar` binary for access to JavaScript ecosystem
|
||||
- **Build System**: GitHub Actions for CI/CD
|
||||
|
||||
Understanding this architecture will help you contribute more effectively.
|
||||
|
||||
@@ -25,10 +25,6 @@
|
||||
</a>
|
||||
</p>
|
||||
|
||||
## Donut Browser
|
||||
|
||||
> A free and open source anti-detect browser built with [Tauri](https://v2.tauri.app/).
|
||||
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="assets/preview-dark.png" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="assets/preview.png" />
|
||||
@@ -38,9 +34,10 @@
|
||||
## Features
|
||||
|
||||
- Create unlimited number of local browser profiles completely isolated from each other
|
||||
- Safely use multiple accounts on one device by using anti-detect browser profiles, powered by [Camoufox](https://camoufox.com)
|
||||
- Proxy support with basic auth for all browsers except for TOR Browser
|
||||
- Import profiles from your existing browsers
|
||||
- Automatic updates both for browsers and for the app itself
|
||||
- Automatic updates for browsers
|
||||
- Set Donut Browser as your default browser to control in which profile to open links
|
||||
|
||||
## Download
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 523 KiB After Width: | Height: | Size: 111 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 540 KiB After Width: | Height: | Size: 114 KiB |
@@ -22,7 +22,7 @@ if [ -z "$TARGET_TRIPLE" ]; then
|
||||
fi
|
||||
|
||||
# Copy the file with target triple suffix
|
||||
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
|
||||
cp "nodecar-bin" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
|
||||
|
||||
# Also copy a generic version for Tauri to find
|
||||
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar${EXT}"
|
||||
cp "nodecar-bin" "../src-tauri/binaries/nodecar${EXT}"
|
||||
+15
-14
@@ -3,35 +3,36 @@
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"bin": "dist/index.js",
|
||||
"scripts": {
|
||||
"watch": "nodemon --exec ts-node --esm ./src/index.ts --watch src",
|
||||
"dev": "node --loader ts-node/esm ./src/index.ts",
|
||||
"start": "tsc && node ./dist/index.js",
|
||||
"test": "tsc && node ./dist/test-proxy.js",
|
||||
"rename-binary": "sh ./copy-binary.sh",
|
||||
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:mac-aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:mac-x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:win-arm64": "tsc && pkg ./dist/index.js --targets latest-win-arm64 --output dist/nodecar && pnpm rename-binary"
|
||||
"build": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:mac-aarch64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:mac-x86_64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:linux-x64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:linux-arm64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:win-x64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:win-arm64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.0.10",
|
||||
"@yao-pkg/pkg": "^6.5.1",
|
||||
"camoufox-js": "^0.6.0",
|
||||
"@types/node": "^24.2.1",
|
||||
"commander": "^14.0.0",
|
||||
"dotenv": "^17.0.1",
|
||||
"donutbrowser-camoufox-js": "^0.6.6",
|
||||
"dotenv": "^17.2.1",
|
||||
"fingerprint-generator": "^2.1.69",
|
||||
"get-port": "^7.1.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"playwright-core": "^1.54.2",
|
||||
"proxy-chain": "^2.5.9",
|
||||
"tmp": "^0.2.3",
|
||||
"tmp": "^0.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3"
|
||||
"typescript": "^5.9.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tmp": "^0.2.6"
|
||||
|
||||
+403
-447
@@ -1,503 +1,459 @@
|
||||
import { spawn } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import { launchOptions } from "donutbrowser-camoufox-js";
|
||||
import type { LaunchOptions } from "donutbrowser-camoufox-js/dist/utils.js";
|
||||
import {
|
||||
type CamoufoxConfig,
|
||||
deleteCamoufoxConfig,
|
||||
generateCamoufoxId,
|
||||
getCamoufoxConfig,
|
||||
listCamoufoxConfigs,
|
||||
saveCamoufoxConfig,
|
||||
} from "./camoufox-storage.js";
|
||||
|
||||
export interface CamoufoxConfig {
|
||||
id: string;
|
||||
pid?: number;
|
||||
executablePath: string;
|
||||
profilePath: string;
|
||||
url?: string;
|
||||
options: CamoufoxLaunchOptions;
|
||||
}
|
||||
|
||||
export interface CamoufoxLaunchOptions {
|
||||
// Operating system to use for fingerprint generation
|
||||
os?: "windows" | "macos" | "linux" | string[];
|
||||
|
||||
// Blocking options
|
||||
block_images?: boolean;
|
||||
block_webrtc?: boolean;
|
||||
block_webgl?: boolean;
|
||||
|
||||
// Security options
|
||||
disable_coop?: boolean;
|
||||
|
||||
// Geolocation options
|
||||
geoip?: string | boolean;
|
||||
|
||||
// UI behavior
|
||||
humanize?: boolean | number;
|
||||
|
||||
// Localization
|
||||
locale?: string | string[];
|
||||
|
||||
// Extensions and fonts
|
||||
addons?: string[];
|
||||
fonts?: string[];
|
||||
custom_fonts_only?: boolean;
|
||||
exclude_addons?: string[];
|
||||
|
||||
// Screen and window
|
||||
screen?: {
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
/**
|
||||
* Convert camoufox fingerprint format to fingerprint-generator format
|
||||
* @param camoufoxFingerprint The camoufox fingerprint object
|
||||
* @returns fingerprint-generator object
|
||||
*/
|
||||
function convertCamoufoxToFingerprintGenerator(
|
||||
camoufoxFingerprint: Record<string, any>,
|
||||
): any {
|
||||
const fingerprintObj: Record<string, any> = {
|
||||
navigator: {},
|
||||
screen: {},
|
||||
videoCard: {},
|
||||
headers: {},
|
||||
battery: {},
|
||||
};
|
||||
window?: [number, number];
|
||||
|
||||
// Fingerprint
|
||||
fingerprint?: any;
|
||||
// Mapping from camoufox keys to fingerprint-generator structure based on the YAML
|
||||
const mappings: Record<string, string> = {
|
||||
// Navigator properties
|
||||
"navigator.userAgent": "navigator.userAgent",
|
||||
"navigator.platform": "navigator.platform",
|
||||
"navigator.hardwareConcurrency": "navigator.hardwareConcurrency",
|
||||
"navigator.maxTouchPoints": "navigator.maxTouchPoints",
|
||||
"navigator.doNotTrack": "navigator.doNotTrack",
|
||||
"navigator.appCodeName": "navigator.appCodeName",
|
||||
"navigator.appName": "navigator.appName",
|
||||
"navigator.appVersion": "navigator.appVersion",
|
||||
"navigator.oscpu": "navigator.oscpu",
|
||||
"navigator.product": "navigator.product",
|
||||
"navigator.language": "navigator.language",
|
||||
"navigator.languages": "navigator.languages",
|
||||
"navigator.globalPrivacyControl": "navigator.globalPrivacyControl",
|
||||
|
||||
// Version and mode
|
||||
ff_version?: number;
|
||||
headless?: boolean;
|
||||
main_world_eval?: boolean;
|
||||
// Screen properties
|
||||
"screen.width": "screen.width",
|
||||
"screen.height": "screen.height",
|
||||
"screen.availWidth": "screen.availWidth",
|
||||
"screen.availHeight": "screen.availHeight",
|
||||
"screen.availTop": "screen.availTop",
|
||||
"screen.availLeft": "screen.availLeft",
|
||||
"screen.colorDepth": "screen.colorDepth",
|
||||
"screen.pixelDepth": "screen.pixelDepth",
|
||||
"window.outerWidth": "screen.outerWidth",
|
||||
"window.outerHeight": "screen.outerHeight",
|
||||
"window.innerWidth": "screen.innerWidth",
|
||||
"window.innerHeight": "screen.innerHeight",
|
||||
"window.screenX": "screen.screenX",
|
||||
"window.screenY": "screen.screenY",
|
||||
"screen.pageXOffset": "screen.pageXOffset",
|
||||
"screen.pageYOffset": "screen.pageYOffset",
|
||||
"window.devicePixelRatio": "screen.devicePixelRatio",
|
||||
"document.body.clientWidth": "screen.clientWidth",
|
||||
"document.body.clientHeight": "screen.clientHeight",
|
||||
|
||||
// Custom executable path
|
||||
executable_path?: string;
|
||||
// WebGL properties
|
||||
"webGl:vendor": "videoCard.vendor",
|
||||
"webGl:renderer": "videoCard.renderer",
|
||||
|
||||
// Firefox preferences
|
||||
firefox_user_prefs?: Record<string, any>;
|
||||
// Headers
|
||||
"headers.Accept-Encoding": "headers.Accept-Encoding",
|
||||
|
||||
// Proxy settings
|
||||
proxy?:
|
||||
| string
|
||||
| {
|
||||
server: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
bypass?: string;
|
||||
};
|
||||
|
||||
// Cache and performance
|
||||
enable_cache?: boolean;
|
||||
|
||||
// Additional options
|
||||
args?: string[];
|
||||
env?: Record<string, string | number | boolean>;
|
||||
debug?: boolean;
|
||||
virtual_display?: string;
|
||||
webgl_config?: [string, string];
|
||||
|
||||
// Custom options
|
||||
timezone?: string;
|
||||
country?: string;
|
||||
geolocation?: {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
accuracy?: number;
|
||||
// Battery
|
||||
"battery:charging": "battery.charging",
|
||||
"battery:chargingTime": "battery.chargingTime",
|
||||
"battery:dischargingTime": "battery.dischargingTime",
|
||||
};
|
||||
}
|
||||
|
||||
// Store for active Camoufox processes
|
||||
const activeCamoufoxProcesses = new Map<string, CamoufoxConfig>();
|
||||
// Apply mappings
|
||||
for (const [camoufoxKey, fingerprintPath] of Object.entries(mappings)) {
|
||||
if (camoufoxFingerprint[camoufoxKey] !== undefined) {
|
||||
const pathParts = fingerprintPath.split(".");
|
||||
let current = fingerprintObj;
|
||||
|
||||
/**
|
||||
* Generate a unique ID for the Camoufox instance
|
||||
*/
|
||||
function generateCamoufoxId(): string {
|
||||
return `camoufox_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Camoufox configuration to storage
|
||||
*/
|
||||
function saveCamoufoxConfig(config: CamoufoxConfig): void {
|
||||
try {
|
||||
const configDir = path.join(os.tmpdir(), "nodecar_camoufox");
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
const configFile = path.join(configDir, `${config.id}.json`);
|
||||
fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
|
||||
activeCamoufoxProcesses.set(config.id, config);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save Camoufox config: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Camoufox configuration from storage
|
||||
*/
|
||||
function loadCamoufoxConfig(id: string): CamoufoxConfig | null {
|
||||
try {
|
||||
const configFile = path.join(os.tmpdir(), "nodecar_camoufox", `${id}.json`);
|
||||
if (fs.existsSync(configFile)) {
|
||||
const config = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
||||
activeCamoufoxProcesses.set(id, config);
|
||||
return config;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load Camoufox config: ${error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Camoufox configuration from storage
|
||||
*/
|
||||
function deleteCamoufoxConfig(id: string): boolean {
|
||||
try {
|
||||
const configFile = path.join(os.tmpdir(), "nodecar_camoufox", `${id}.json`);
|
||||
if (fs.existsSync(configFile)) {
|
||||
fs.unlinkSync(configFile);
|
||||
}
|
||||
activeCamoufoxProcesses.delete(id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete Camoufox config: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all Camoufox configurations on startup
|
||||
*/
|
||||
function loadAllCamoufoxConfigs(): void {
|
||||
try {
|
||||
const configDir = path.join(os.tmpdir(), "nodecar_camoufox");
|
||||
if (fs.existsSync(configDir)) {
|
||||
const files = fs.readdirSync(configDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
const id = path.basename(file, ".json");
|
||||
loadCamoufoxConfig(id);
|
||||
// Navigate to the nested property, creating objects as needed
|
||||
for (let i = 0; i < pathParts.length - 1; i++) {
|
||||
const part = pathParts[i];
|
||||
if (!current[part]) {
|
||||
current[part] = {};
|
||||
}
|
||||
current = current[part];
|
||||
}
|
||||
|
||||
// Set the final value
|
||||
const finalKey = pathParts[pathParts.length - 1];
|
||||
current[finalKey] = camoufoxFingerprint[camoufoxKey];
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load Camoufox configs: ${error}`);
|
||||
}
|
||||
|
||||
// Handle fonts separately
|
||||
if (camoufoxFingerprint.fonts && Array.isArray(camoufoxFingerprint.fonts)) {
|
||||
fingerprintObj.fonts = camoufoxFingerprint.fonts;
|
||||
}
|
||||
|
||||
return { ...camoufoxFingerprint, ...fingerprintObj };
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process is still running
|
||||
* Start a Camoufox instance in a separate process
|
||||
* @param options Camoufox launch options
|
||||
* @param profilePath Profile directory path
|
||||
* @param url Optional URL to open
|
||||
* @returns Promise resolving to the Camoufox configuration
|
||||
*/
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Camoufox options to command line arguments
|
||||
*/
|
||||
function buildCamoufoxArgs(
|
||||
options: CamoufoxLaunchOptions,
|
||||
profilePath: string,
|
||||
url?: string,
|
||||
): string[] {
|
||||
const args: string[] = [];
|
||||
|
||||
// Always use profile
|
||||
args.push("-profile", profilePath);
|
||||
|
||||
// Cache enabled by default as requested
|
||||
if (options.enable_cache !== false) {
|
||||
// Cache is enabled by default in Camoufox, no special args needed
|
||||
}
|
||||
|
||||
// Headless mode
|
||||
if (options.headless) {
|
||||
args.push("-headless");
|
||||
}
|
||||
|
||||
// No remote for security (anti-detect)
|
||||
args.push("-no-remote");
|
||||
|
||||
// Custom Firefox user preferences will be written to user.js in profile
|
||||
|
||||
// Additional custom args
|
||||
if (options.args) {
|
||||
args.push(...options.args);
|
||||
}
|
||||
|
||||
// URL to open
|
||||
if (url) {
|
||||
args.push(url);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user.js file with Camoufox preferences
|
||||
*/
|
||||
function createUserJs(
|
||||
profilePath: string,
|
||||
options: CamoufoxLaunchOptions,
|
||||
): void {
|
||||
const preferences: string[] = [];
|
||||
|
||||
// Anti-detect preferences
|
||||
preferences.push('user_pref("privacy.resistFingerprinting", true);');
|
||||
preferences.push(
|
||||
'user_pref("privacy.resistFingerprinting.letterboxing", true);',
|
||||
);
|
||||
preferences.push('user_pref("privacy.trackingprotection.enabled", true);');
|
||||
|
||||
// Disable telemetry and data collection
|
||||
preferences.push(
|
||||
'user_pref("datareporting.healthreport.uploadEnabled", false);',
|
||||
);
|
||||
preferences.push(
|
||||
'user_pref("datareporting.policy.dataSubmissionEnabled", false);',
|
||||
);
|
||||
preferences.push('user_pref("toolkit.telemetry.enabled", false);');
|
||||
preferences.push('user_pref("toolkit.telemetry.unified", false);');
|
||||
|
||||
// Block options
|
||||
if (options.block_images) {
|
||||
preferences.push('user_pref("permissions.default.image", 2);');
|
||||
}
|
||||
|
||||
if (options.block_webrtc) {
|
||||
preferences.push('user_pref("media.peerconnection.enabled", false);');
|
||||
preferences.push('user_pref("media.navigator.enabled", false);');
|
||||
}
|
||||
|
||||
if (options.block_webgl) {
|
||||
preferences.push('user_pref("webgl.disabled", true);');
|
||||
preferences.push('user_pref("webgl.disable-extensions", true);');
|
||||
}
|
||||
|
||||
// COOP settings
|
||||
if (options.disable_coop) {
|
||||
preferences.push(
|
||||
'user_pref("browser.tabs.remote.useCrossOriginOpenerPolicy", false);',
|
||||
);
|
||||
}
|
||||
|
||||
// Locale settings
|
||||
if (options.locale) {
|
||||
const localeStr = Array.isArray(options.locale)
|
||||
? options.locale[0]
|
||||
: options.locale;
|
||||
preferences.push(`user_pref("intl.locale.requested", "${localeStr}");`);
|
||||
preferences.push(`user_pref("general.useragent.locale", "${localeStr}");`);
|
||||
}
|
||||
|
||||
// Timezone
|
||||
if (options.timezone) {
|
||||
preferences.push(
|
||||
`user_pref("privacy.resistFingerprinting.timezone", "${options.timezone}");`,
|
||||
);
|
||||
}
|
||||
|
||||
// Custom Firefox preferences
|
||||
if (options.firefox_user_prefs) {
|
||||
for (const [key, value] of Object.entries(options.firefox_user_prefs)) {
|
||||
if (typeof value === "string") {
|
||||
preferences.push(`user_pref("${key}", "${value}");`);
|
||||
} else if (typeof value === "boolean") {
|
||||
preferences.push(`user_pref("${key}", ${value});`);
|
||||
} else if (typeof value === "number") {
|
||||
preferences.push(`user_pref("${key}", ${value});`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy settings
|
||||
if (options.proxy) {
|
||||
if (typeof options.proxy === "string") {
|
||||
// Parse proxy URL
|
||||
try {
|
||||
const proxyUrl = new URL(options.proxy);
|
||||
const port =
|
||||
parseInt(proxyUrl.port) ||
|
||||
(proxyUrl.protocol === "https:" ? 443 : 80);
|
||||
|
||||
if (proxyUrl.protocol.startsWith("socks")) {
|
||||
preferences.push('user_pref("network.proxy.type", 1);');
|
||||
preferences.push(
|
||||
`user_pref("network.proxy.socks", "${proxyUrl.hostname}");`,
|
||||
);
|
||||
preferences.push(`user_pref("network.proxy.socks_port", ${port});`);
|
||||
if (proxyUrl.protocol === "socks5:") {
|
||||
preferences.push('user_pref("network.proxy.socks_version", 5);');
|
||||
} else {
|
||||
preferences.push('user_pref("network.proxy.socks_version", 4);');
|
||||
}
|
||||
} else {
|
||||
preferences.push('user_pref("network.proxy.type", 1);');
|
||||
preferences.push(
|
||||
`user_pref("network.proxy.http", "${proxyUrl.hostname}");`,
|
||||
);
|
||||
preferences.push(`user_pref("network.proxy.http_port", ${port});`);
|
||||
preferences.push(
|
||||
`user_pref("network.proxy.ssl", "${proxyUrl.hostname}");`,
|
||||
);
|
||||
preferences.push(`user_pref("network.proxy.ssl_port", ${port});`);
|
||||
}
|
||||
|
||||
if (proxyUrl.username && proxyUrl.password) {
|
||||
// Note: Basic auth for proxies is handled differently in modern Firefox
|
||||
preferences.push(
|
||||
'user_pref("network.proxy.allow_hijacking_localhost", true);',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Invalid proxy URL: ${options.proxy}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Geolocation
|
||||
if (options.geolocation) {
|
||||
preferences.push('user_pref("geo.enabled", true);');
|
||||
preferences.push(
|
||||
`user_pref("geo.wifi.uri", "data:application/json,{\\"location\\": {\\"lat\\": ${options.geolocation.latitude}, \\"lng\\": ${options.geolocation.longitude}}, \\"accuracy\\": ${options.geolocation.accuracy || 100}}");`,
|
||||
);
|
||||
} else {
|
||||
preferences.push('user_pref("geo.enabled", false);');
|
||||
}
|
||||
|
||||
// Write user.js file
|
||||
const userJsPath = path.join(profilePath, "user.js");
|
||||
fs.writeFileSync(userJsPath, preferences.join("\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch Camoufox browser with specified options
|
||||
*/
|
||||
export async function launchCamoufox(
|
||||
executablePath: string,
|
||||
profilePath: string,
|
||||
options: CamoufoxLaunchOptions = {},
|
||||
export async function startCamoufoxProcess(
|
||||
options: LaunchOptions = {},
|
||||
profilePath?: string,
|
||||
url?: string,
|
||||
customConfig?: string,
|
||||
): Promise<CamoufoxConfig> {
|
||||
// Generate a unique ID for this instance
|
||||
const id = generateCamoufoxId();
|
||||
|
||||
// Ensure profile directory exists
|
||||
if (!fs.existsSync(profilePath)) {
|
||||
fs.mkdirSync(profilePath, { recursive: true });
|
||||
}
|
||||
|
||||
// Create user.js with preferences
|
||||
createUserJs(profilePath, options);
|
||||
|
||||
// Build command line arguments
|
||||
const args = buildCamoufoxArgs(options, profilePath, url);
|
||||
|
||||
// Prepare environment variables
|
||||
const env = {
|
||||
...process.env,
|
||||
...options.env,
|
||||
};
|
||||
|
||||
// Handle virtual display
|
||||
if (options.virtual_display) {
|
||||
env.DISPLAY = options.virtual_display;
|
||||
}
|
||||
|
||||
// Launch the process
|
||||
const child = spawn(executablePath, args, {
|
||||
env: env as NodeJS.ProcessEnv,
|
||||
detached: true,
|
||||
stdio: options.debug ? "inherit" : "ignore",
|
||||
});
|
||||
|
||||
if (!child.pid) {
|
||||
throw new Error("Failed to launch Camoufox process");
|
||||
}
|
||||
// Ensure profile path is absolute if provided
|
||||
const absoluteProfilePath = profilePath
|
||||
? path.resolve(profilePath)
|
||||
: undefined;
|
||||
|
||||
// Create the Camoufox configuration
|
||||
const config: CamoufoxConfig = {
|
||||
id,
|
||||
pid: child.pid,
|
||||
executablePath,
|
||||
profilePath,
|
||||
options: JSON.parse(JSON.stringify(options)), // Deep clone to avoid reference sharing
|
||||
profilePath: absoluteProfilePath,
|
||||
url,
|
||||
options,
|
||||
customConfig,
|
||||
};
|
||||
|
||||
// Save configuration
|
||||
// Save the configuration before starting the process
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
// Handle process exit
|
||||
child.on("exit", (code, signal) => {
|
||||
console.log(
|
||||
`Camoufox process ${child.pid} exited with code ${code}, signal ${signal}`,
|
||||
);
|
||||
deleteCamoufoxConfig(id);
|
||||
// Build the command arguments
|
||||
const args = [
|
||||
path.join(__dirname, "index.js"),
|
||||
"camoufox-worker",
|
||||
"start",
|
||||
"--id",
|
||||
id,
|
||||
];
|
||||
|
||||
// Spawn the process with proper detachment - similar to proxy implementation
|
||||
const child = spawn(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"], // Capture stdout and stderr for startup feedback
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
// Ensure Camoufox can find its dependencies
|
||||
NODE_PATH: process.env.NODE_PATH || "",
|
||||
},
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`Camoufox process error: ${error}`);
|
||||
deleteCamoufoxConfig(id);
|
||||
// Wait for the worker to start successfully or fail - with shorter timeout for quick response
|
||||
return new Promise<CamoufoxConfig>((resolve, reject) => {
|
||||
let resolved = false;
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
|
||||
// Shorter timeout for quick startup feedback
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
child.kill("SIGKILL");
|
||||
reject(
|
||||
new Error(`Camoufox worker ${id} startup timeout after 5 seconds`),
|
||||
);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Handle stdout - look for success JSON
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
stdoutBuffer += output;
|
||||
|
||||
// Look for success JSON message
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line.trim());
|
||||
if (parsed.success && parsed.id === id && parsed.processId) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
config.processId = parsed.processId;
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
// Unref immediately after success to detach properly
|
||||
child.unref();
|
||||
resolve(config);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle stderr - look for error JSON
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
stderrBuffer += output;
|
||||
|
||||
// Look for error JSON message
|
||||
const lines = stderrBuffer.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line.trim());
|
||||
if (parsed.error && parsed.id === id) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(
|
||||
new Error(
|
||||
`Camoufox worker failed: ${parsed.message || parsed.error}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
if (code !== 0) {
|
||||
reject(
|
||||
new Error(
|
||||
`Camoufox worker ${id} exited with code ${code} and signal ${signal}. Stderr: ${stderrBuffer}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Process exited successfully but we didn't get success message
|
||||
reject(
|
||||
new Error(
|
||||
`Camoufox worker ${id} exited without success confirmation`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Detach the child process so it can continue running independently
|
||||
child.unref();
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a Camoufox process by ID
|
||||
* Stop a Camoufox process
|
||||
* @param id The Camoufox ID to stop
|
||||
* @returns Promise resolving to true if stopped, false if not found
|
||||
*/
|
||||
export async function stopCamoufox(id: string): Promise<boolean> {
|
||||
const config = activeCamoufoxProcesses.get(id) || loadCamoufoxConfig(id);
|
||||
export async function stopCamoufoxProcess(id: string): Promise<boolean> {
|
||||
const config = getCamoufoxConfig(id);
|
||||
|
||||
if (!config || !config.pid) {
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isProcessRunning(config.pid)) {
|
||||
process.kill(config.pid, "SIGTERM");
|
||||
// Method 1: If we have a process ID, kill by PID with proper signal sequence
|
||||
if (config.processId) {
|
||||
try {
|
||||
// First try SIGTERM for graceful shutdown
|
||||
process.kill(config.processId, "SIGTERM");
|
||||
// Give it more time to terminate gracefully (increased from 2s to 5s)
|
||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
||||
|
||||
// Wait a moment for graceful shutdown
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Force kill if still running
|
||||
if (isProcessRunning(config.pid)) {
|
||||
process.kill(config.pid, "SIGKILL");
|
||||
}
|
||||
// Check if process is still running
|
||||
try {
|
||||
process.kill(config.processId, 0); // Signal 0 checks if process exists
|
||||
process.kill(config.processId, "SIGKILL");
|
||||
} catch {}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Method 2: Pattern-based kill as fallback
|
||||
const killByPattern = spawn(
|
||||
"pkill",
|
||||
["-TERM", "-f", `camoufox-worker.*${id}`],
|
||||
{
|
||||
stdio: "ignore",
|
||||
},
|
||||
);
|
||||
|
||||
// Wait for pattern-based kill command to complete
|
||||
await new Promise<void>((resolve) => {
|
||||
killByPattern.on("exit", () => resolve());
|
||||
// Timeout after 3 seconds
|
||||
setTimeout(() => resolve(), 3000);
|
||||
});
|
||||
|
||||
// Final cleanup with SIGKILL if needed
|
||||
setTimeout(() => {
|
||||
spawn("pkill", ["-KILL", "-f", `camoufox-worker.*${id}`], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Delete the configuration
|
||||
deleteCamoufoxConfig(id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to stop Camoufox process: ${error}`);
|
||||
} catch {
|
||||
// Delete the configuration even if stopping failed
|
||||
deleteCamoufoxConfig(id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Camoufox processes
|
||||
* Stop all Camoufox processes
|
||||
* @returns Promise resolving when all instances are stopped
|
||||
*/
|
||||
export function listCamoufoxProcesses(): any[] {
|
||||
loadAllCamoufoxConfigs();
|
||||
export async function stopAllCamoufoxProcesses(): Promise<void> {
|
||||
const configs = listCamoufoxConfigs();
|
||||
|
||||
// Filter out dead processes
|
||||
const activeConfigs: any[] = [];
|
||||
|
||||
for (const [id, config] of activeCamoufoxProcesses) {
|
||||
if (config.pid && isProcessRunning(config.pid)) {
|
||||
// Return in snake_case format for Rust compatibility
|
||||
activeConfigs.push({
|
||||
id: config.id,
|
||||
pid: config.pid,
|
||||
executable_path: config.executablePath,
|
||||
profile_path: config.profilePath,
|
||||
url: config.url,
|
||||
options: config.options,
|
||||
});
|
||||
} else {
|
||||
// Clean up dead processes
|
||||
deleteCamoufoxConfig(id);
|
||||
}
|
||||
}
|
||||
|
||||
return activeConfigs;
|
||||
const stopPromises = configs.map((config) => stopCamoufoxProcess(config.id));
|
||||
await Promise.all(stopPromises);
|
||||
}
|
||||
|
||||
// Load existing configurations on module initialization
|
||||
loadAllCamoufoxConfigs();
|
||||
interface GenerateConfigOptions {
|
||||
proxy?: string;
|
||||
maxWidth?: number;
|
||||
maxHeight?: number;
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
geoip?: string | boolean;
|
||||
blockImages?: boolean;
|
||||
blockWebrtc?: boolean;
|
||||
blockWebgl?: boolean;
|
||||
executablePath?: string;
|
||||
fingerprint?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate Camoufox configuration using launchOptions
|
||||
* @param options Configuration options
|
||||
* @returns Promise resolving to the generated config JSON string
|
||||
*/
|
||||
export async function generateCamoufoxConfig(
|
||||
options: GenerateConfigOptions,
|
||||
): Promise<string> {
|
||||
try {
|
||||
const launchOpts: any = {
|
||||
headless: false,
|
||||
i_know_what_im_doing: true,
|
||||
config: {
|
||||
disableTheming: true,
|
||||
showcursor: false,
|
||||
},
|
||||
};
|
||||
|
||||
if (options.geoip) {
|
||||
launchOpts.geoip = true;
|
||||
}
|
||||
|
||||
if (options.blockImages) {
|
||||
launchOpts.block_images = true;
|
||||
}
|
||||
if (options.blockWebrtc) {
|
||||
launchOpts.block_webrtc = true;
|
||||
}
|
||||
if (options.blockWebgl) {
|
||||
launchOpts.block_webgl = true;
|
||||
}
|
||||
|
||||
if (options.executablePath) {
|
||||
launchOpts.executable_path = options.executablePath;
|
||||
}
|
||||
|
||||
if (options.proxy) {
|
||||
launchOpts.proxy = options.proxy;
|
||||
}
|
||||
|
||||
// If fingerprint is provided, use it and ignore other options except executable_path and block_*
|
||||
if (options.fingerprint) {
|
||||
try {
|
||||
const camoufoxFingerprint = JSON.parse(options.fingerprint);
|
||||
|
||||
if (camoufoxFingerprint.timezone) {
|
||||
launchOpts.config.timezone = camoufoxFingerprint.timezone;
|
||||
}
|
||||
|
||||
// Convert camoufox fingerprint format to fingerprint-generator format
|
||||
const fingerprintObj =
|
||||
convertCamoufoxToFingerprintGenerator(camoufoxFingerprint);
|
||||
launchOpts.fingerprint = fingerprintObj;
|
||||
} catch (error) {
|
||||
throw new Error(`Invalid fingerprint JSON: ${error}`);
|
||||
}
|
||||
} else {
|
||||
// Use individual options to build configuration
|
||||
|
||||
// Build screen configuration with min/max dimensions
|
||||
const screen: {
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
} = {};
|
||||
|
||||
if (options.minWidth) screen.minWidth = options.minWidth;
|
||||
if (options.maxWidth) screen.maxWidth = options.maxWidth;
|
||||
if (options.minHeight) screen.minHeight = options.minHeight;
|
||||
if (options.maxHeight) screen.maxHeight = options.maxHeight;
|
||||
|
||||
if (Object.keys(screen).length > 0) {
|
||||
launchOpts.screen = screen;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate the configuration using launchOptions
|
||||
const generatedOptions = await launchOptions(launchOpts);
|
||||
|
||||
// Extract the environment variables that contain the config
|
||||
const envVars = generatedOptions.env || {};
|
||||
|
||||
// Reconstruct the config from environment variables using getEnvVars utility
|
||||
let configStr = "";
|
||||
let chunkIndex = 1;
|
||||
|
||||
while (envVars[`CAMOU_CONFIG_${chunkIndex}`]) {
|
||||
configStr += envVars[`CAMOU_CONFIG_${chunkIndex}`];
|
||||
chunkIndex++;
|
||||
}
|
||||
|
||||
if (!configStr) {
|
||||
throw new Error("No configuration generated");
|
||||
}
|
||||
|
||||
// Parse and return the config as JSON string
|
||||
const config = JSON.parse(configStr);
|
||||
return JSON.stringify(config);
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to generate Camoufox config: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { LaunchOptions } from "donutbrowser-camoufox-js/dist/utils.js";
|
||||
import tmp from "tmp";
|
||||
|
||||
export interface CamoufoxConfig {
|
||||
id: string;
|
||||
options: LaunchOptions;
|
||||
profilePath?: string;
|
||||
url?: string;
|
||||
processId?: number;
|
||||
customConfig?: string; // JSON string of the fingerprint config
|
||||
}
|
||||
|
||||
const STORAGE_DIR = path.join(tmp.tmpdir, "donutbrowser", "camoufox");
|
||||
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a Camoufox configuration to disk
|
||||
* @param config The Camoufox configuration to save
|
||||
*/
|
||||
export function saveCamoufoxConfig(config: CamoufoxConfig): void {
|
||||
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Camoufox configuration by ID
|
||||
* @param id The Camoufox ID
|
||||
* @returns The Camoufox configuration or null if not found
|
||||
*/
|
||||
export function getCamoufoxConfig(id: string): CamoufoxConfig | null {
|
||||
const filePath = path.join(STORAGE_DIR, `${id}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
return JSON.parse(content) as CamoufoxConfig;
|
||||
} catch (error) {
|
||||
console.error({
|
||||
message: `Error reading Camoufox config ${id}`,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Camoufox configuration
|
||||
* @param id The Camoufox ID to delete
|
||||
* @returns True if deleted, false if not found
|
||||
*/
|
||||
export function deleteCamoufoxConfig(id: string): boolean {
|
||||
const filePath = path.join(STORAGE_DIR, `${id}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error({
|
||||
message: `Error deleting Camoufox config ${id}`,
|
||||
error: (error as Error).message,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all saved Camoufox configurations
|
||||
* @returns Array of Camoufox configurations
|
||||
*/
|
||||
export function listCamoufoxConfigs(): CamoufoxConfig[] {
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(STORAGE_DIR)
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => {
|
||||
try {
|
||||
const content = fs.readFileSync(
|
||||
path.join(STORAGE_DIR, file),
|
||||
"utf-8",
|
||||
);
|
||||
return JSON.parse(content) as CamoufoxConfig;
|
||||
} catch (error) {
|
||||
console.error({
|
||||
message: `Error reading Camoufox config ${file}`,
|
||||
error,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((config): config is CamoufoxConfig => config !== null)
|
||||
.map((config) => {
|
||||
config.options = "Removed for logging" as any;
|
||||
config.customConfig = "Removed for logging" as any;
|
||||
return config;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error({ message: "Error listing Camoufox configs:", error });
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a Camoufox configuration
|
||||
* @param config The Camoufox configuration to update
|
||||
* @returns True if updated, false if not found
|
||||
*/
|
||||
export function updateCamoufoxConfig(config: CamoufoxConfig): boolean {
|
||||
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
|
||||
|
||||
try {
|
||||
fs.readFileSync(filePath, "utf-8");
|
||||
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
console.error({
|
||||
message: `Config ${config.id} was deleted while the app was running`,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
console.error({
|
||||
message: `Error updating Camoufox config ${config.id}`,
|
||||
error,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for a Camoufox instance
|
||||
* @returns A unique ID string
|
||||
*/
|
||||
export function generateCamoufoxId(): string {
|
||||
// Include process ID to ensure uniqueness across multiple processes
|
||||
return `camoufox_${Date.now()}_${process.pid}_${Math.floor(Math.random() * 10000)}`;
|
||||
}
|
||||
@@ -0,0 +1,307 @@
|
||||
import { launchOptions } from "donutbrowser-camoufox-js";
|
||||
import type { LaunchOptions } from "donutbrowser-camoufox-js/dist/utils.js";
|
||||
import { type Browser, type BrowserContext, firefox } from "playwright-core";
|
||||
import { getCamoufoxConfig, saveCamoufoxConfig } from "./camoufox-storage.js";
|
||||
import { getEnvVars, parseProxyString } from "./utils.js";
|
||||
|
||||
/**
|
||||
* Run a Camoufox browser server as a worker process
|
||||
* @param id The Camoufox configuration ID
|
||||
*/
|
||||
export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
// Get the Camoufox configuration
|
||||
const config = getCamoufoxConfig(id);
|
||||
|
||||
if (!config) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
error: "Configuration not found",
|
||||
id: id,
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
config.processId = process.pid;
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
id: id,
|
||||
processId: process.pid,
|
||||
profilePath: config.profilePath,
|
||||
message: "Camoufox worker started successfully",
|
||||
}),
|
||||
);
|
||||
|
||||
// Launch browser in background - this can take time and may fail
|
||||
setImmediate(async () => {
|
||||
let browser: Browser | null = null;
|
||||
let context: BrowserContext | null = null;
|
||||
let windowCheckInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
// Graceful shutdown handler with access to browser and server
|
||||
const gracefulShutdown = async () => {
|
||||
try {
|
||||
// Clear any intervals first
|
||||
if (windowCheckInterval) {
|
||||
clearInterval(windowCheckInterval);
|
||||
}
|
||||
|
||||
// Close browser context and server if they exist
|
||||
if (context && !context.pages) {
|
||||
// Context is already closed
|
||||
} else if (context) {
|
||||
await context.close();
|
||||
}
|
||||
|
||||
if (browser?.isConnected()) {
|
||||
await browser.close();
|
||||
}
|
||||
} catch {
|
||||
// Ignore cleanup errors during shutdown
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
// Handle various quit signals for proper macOS Command+Q support
|
||||
process.on("SIGTERM", () => void gracefulShutdown());
|
||||
process.on("SIGINT", () => void gracefulShutdown());
|
||||
process.on("SIGHUP", () => void gracefulShutdown());
|
||||
process.on("SIGQUIT", () => void gracefulShutdown());
|
||||
|
||||
// Handle uncaught exceptions and unhandled rejections
|
||||
process.on("uncaughtException", () => void gracefulShutdown());
|
||||
process.on("unhandledRejection", () => void gracefulShutdown());
|
||||
|
||||
try {
|
||||
// Deep clone to avoid reference sharing and ensure fresh configuration for each instance
|
||||
const camoufoxOptions: LaunchOptions = JSON.parse(
|
||||
JSON.stringify(config.options || {}),
|
||||
);
|
||||
|
||||
// Add profile path if provided
|
||||
if (config.profilePath) {
|
||||
camoufoxOptions.user_data_dir = config.profilePath;
|
||||
}
|
||||
|
||||
// Ensure block options are properly set
|
||||
if (camoufoxOptions.block_images) {
|
||||
camoufoxOptions.block_images = true;
|
||||
}
|
||||
|
||||
if (camoufoxOptions.block_webgl) {
|
||||
camoufoxOptions.block_webgl = true;
|
||||
}
|
||||
|
||||
if (camoufoxOptions.block_webrtc) {
|
||||
camoufoxOptions.block_webrtc = true;
|
||||
}
|
||||
|
||||
// Check for headless mode from config (no environment variable check)
|
||||
if (camoufoxOptions.headless) {
|
||||
camoufoxOptions.headless = true;
|
||||
}
|
||||
|
||||
// Always set these defaults - ensure they are applied for each instance
|
||||
camoufoxOptions.i_know_what_im_doing = true;
|
||||
camoufoxOptions.config = {
|
||||
disableTheming: true,
|
||||
showcursor: false,
|
||||
...(camoufoxOptions.config || {}),
|
||||
};
|
||||
|
||||
// Generate fresh options for this specific instance
|
||||
const generatedOptions = await launchOptions(camoufoxOptions);
|
||||
|
||||
// Start with process environment to ensure proper inheritance
|
||||
let finalEnv = { ...process.env };
|
||||
|
||||
// Add generated options environment variables
|
||||
if (generatedOptions.env) {
|
||||
finalEnv = { ...finalEnv, ...generatedOptions.env };
|
||||
}
|
||||
|
||||
// If we have a custom config from Rust, use it directly as environment variables
|
||||
if (config.customConfig) {
|
||||
try {
|
||||
// Parse the custom config JSON string
|
||||
const customConfigObj = JSON.parse(config.customConfig);
|
||||
|
||||
// Ensure default config values are preserved even with custom config
|
||||
const mergedConfig = {
|
||||
...customConfigObj,
|
||||
disableTheming: true,
|
||||
showcursor: false,
|
||||
};
|
||||
|
||||
// Convert merged config to environment variables using getEnvVars
|
||||
const customEnvVars = getEnvVars(mergedConfig);
|
||||
|
||||
// Merge custom config with generated config (custom takes precedence)
|
||||
finalEnv = { ...finalEnv, ...customEnvVars };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Camoufox worker ${id}: Failed to parse custom config, using generated config:`,
|
||||
error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Prepare profile path for persistent context
|
||||
const profilePath = config.profilePath || "";
|
||||
|
||||
// Launch persistent context with the final configuration
|
||||
const finalOptions: any = {
|
||||
...generatedOptions,
|
||||
env: finalEnv,
|
||||
};
|
||||
|
||||
// If a custom executable path was provided, ensure Playwright uses it
|
||||
if (
|
||||
(camoufoxOptions as any).executable_path &&
|
||||
typeof (camoufoxOptions as any).executable_path === "string"
|
||||
) {
|
||||
finalOptions.executablePath = (camoufoxOptions as any)
|
||||
.executable_path as string;
|
||||
}
|
||||
|
||||
// Only add proxy if it exists and is valid
|
||||
if (camoufoxOptions.proxy) {
|
||||
try {
|
||||
finalOptions.proxy = parseProxyString(camoufoxOptions.proxy);
|
||||
} catch (error) {
|
||||
console.error({
|
||||
message: "Failed to parse proxy, launching without proxy",
|
||||
error,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Use launchPersistentContext instead of launchServer
|
||||
context = await firefox.launchPersistentContext(
|
||||
profilePath,
|
||||
finalOptions,
|
||||
);
|
||||
|
||||
// Get the browser instance from context
|
||||
browser = context.browser();
|
||||
|
||||
// Handle browser disconnection for proper cleanup
|
||||
if (browser) {
|
||||
browser.on("disconnected", () => void gracefulShutdown());
|
||||
}
|
||||
|
||||
// Handle context close for proper cleanup
|
||||
context.on("close", () => void gracefulShutdown());
|
||||
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
// Monitor for window closure
|
||||
const startWindowMonitoring = () => {
|
||||
windowCheckInterval = setInterval(async () => {
|
||||
try {
|
||||
// Check if context is still active
|
||||
if (!context?.pages || context.pages().length === 0) {
|
||||
if (windowCheckInterval) {
|
||||
clearInterval(windowCheckInterval);
|
||||
}
|
||||
await gracefulShutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if browser is still connected (if available)
|
||||
if (browser && !browser.isConnected()) {
|
||||
if (windowCheckInterval) {
|
||||
clearInterval(windowCheckInterval);
|
||||
}
|
||||
await gracefulShutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check pages in the persistent context
|
||||
const pages = context.pages();
|
||||
if (pages.length === 0) {
|
||||
if (windowCheckInterval) {
|
||||
clearInterval(windowCheckInterval);
|
||||
}
|
||||
await gracefulShutdown();
|
||||
}
|
||||
} catch {
|
||||
// If we can't check windows, assume browser is closing
|
||||
if (windowCheckInterval) {
|
||||
clearInterval(windowCheckInterval);
|
||||
}
|
||||
await gracefulShutdown();
|
||||
}
|
||||
}, 1000); // Check every second
|
||||
};
|
||||
|
||||
// Handle URL opening if provided
|
||||
if (config.url) {
|
||||
try {
|
||||
const pages = await context.pages();
|
||||
if (pages.length) {
|
||||
const page = pages[0];
|
||||
await page.goto(config.url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
// Start monitoring after page is created
|
||||
startWindowMonitoring();
|
||||
}
|
||||
} catch (urlError) {
|
||||
console.error({
|
||||
message: "Failed to open URL",
|
||||
error: urlError,
|
||||
});
|
||||
// URL opening failure doesn't affect startup success
|
||||
// Still start monitoring
|
||||
startWindowMonitoring();
|
||||
}
|
||||
} else {
|
||||
// Start monitoring after page is created
|
||||
startWindowMonitoring();
|
||||
}
|
||||
|
||||
// Monitor browser/context connection
|
||||
const keepAlive = setInterval(async () => {
|
||||
try {
|
||||
// Check if context is still active
|
||||
if (!context?.pages) {
|
||||
clearInterval(keepAlive);
|
||||
await gracefulShutdown();
|
||||
return;
|
||||
}
|
||||
|
||||
// Check browser connection if available
|
||||
if (browser && !browser.isConnected()) {
|
||||
clearInterval(keepAlive);
|
||||
await gracefulShutdown();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error({
|
||||
message: "Error in keepAlive check",
|
||||
error,
|
||||
});
|
||||
clearInterval(keepAlive);
|
||||
await gracefulShutdown();
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
console.error({
|
||||
message: "Failed to launch Camoufox",
|
||||
error,
|
||||
});
|
||||
// Browser launch failed, attempt cleanup
|
||||
await gracefulShutdown();
|
||||
}
|
||||
});
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
}
|
||||
+293
-258
@@ -1,9 +1,13 @@
|
||||
import { program } from "commander";
|
||||
import type { LaunchOptions } from "donutbrowser-camoufox-js/dist/utils.js";
|
||||
import {
|
||||
launchCamoufox,
|
||||
listCamoufoxProcesses,
|
||||
stopCamoufox,
|
||||
} from "./camoufox-launcher";
|
||||
generateCamoufoxConfig,
|
||||
startCamoufoxProcess,
|
||||
stopAllCamoufoxProcesses,
|
||||
stopCamoufoxProcess,
|
||||
} from "./camoufox-launcher.js";
|
||||
import { listCamoufoxConfigs } from "./camoufox-storage.js";
|
||||
import { runCamoufoxWorker } from "./camoufox-worker.js";
|
||||
import {
|
||||
startProxyProcess,
|
||||
stopAllProxyProcesses,
|
||||
@@ -49,14 +53,12 @@ program
|
||||
},
|
||||
) => {
|
||||
if (action === "start") {
|
||||
let upstreamUrl: string;
|
||||
let upstreamUrl: string | undefined;
|
||||
|
||||
// Build upstream URL from individual components if provided
|
||||
if (options.host && options.proxyPort && options.type) {
|
||||
const protocol =
|
||||
options.type === "socks4" || options.type === "socks5"
|
||||
? options.type
|
||||
: "http";
|
||||
// Preserve provided scheme (http, https, socks4, socks5)
|
||||
const protocol = String(options.type).toLowerCase();
|
||||
const auth =
|
||||
options.username && options.password
|
||||
? `${encodeURIComponent(options.username)}:${encodeURIComponent(
|
||||
@@ -66,16 +68,8 @@ program
|
||||
upstreamUrl = `${protocol}://${auth}${options.host}:${options.proxyPort}`;
|
||||
} else if (options.upstream) {
|
||||
upstreamUrl = options.upstream;
|
||||
} else {
|
||||
console.error(
|
||||
"Error: Either --upstream URL or --host, --proxy-port, and --type are required",
|
||||
);
|
||||
console.log(
|
||||
"Example: proxy start --host datacenter.proxyempire.io --proxy-port 9000 --type http --username user --password pass",
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
// If no upstream is provided, create a direct proxy
|
||||
|
||||
try {
|
||||
const config = await startProxyProcess(upstreamUrl, {
|
||||
@@ -154,275 +148,316 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Command for Camoufox anti-detect browser
|
||||
// Command for Camoufox management
|
||||
program
|
||||
.command("camoufox")
|
||||
.argument("<action>", "launch, stop, list, or open-url for Camoufox browser")
|
||||
.requiredOption("--executable-path <path>", "path to Camoufox executable")
|
||||
.requiredOption("--profile-path <path>", "path to browser profile directory")
|
||||
.argument(
|
||||
"<action>",
|
||||
"start, stop, list, or generate-config Camoufox instances",
|
||||
)
|
||||
.option("--id <id>", "Camoufox ID for stop command")
|
||||
.option("--profile-path <path>", "profile directory path")
|
||||
.option("--url <url>", "URL to open")
|
||||
.option("--id <id>", "Camoufox instance ID (for stop/open-url actions)")
|
||||
|
||||
// Operating system fingerprinting
|
||||
.option(
|
||||
"--os <os>",
|
||||
"OS to emulate (windows, macos, linux, or comma-separated list)",
|
||||
)
|
||||
|
||||
// Blocking options
|
||||
.option("--block-images", "block all images")
|
||||
.option("--block-webrtc", "block WebRTC entirely")
|
||||
// Config generation options
|
||||
.option("--proxy <proxy>", "proxy URL for config generation")
|
||||
.option("--max-width <width>", "maximum screen width", parseInt)
|
||||
.option("--max-height <height>", "maximum screen height", parseInt)
|
||||
.option("--min-width <width>", "minimum screen width", parseInt)
|
||||
.option("--min-height <height>", "minimum screen height", parseInt)
|
||||
.option("--geoip", "enable geoip")
|
||||
.option("--block-images", "block images")
|
||||
.option("--block-webrtc", "block WebRTC")
|
||||
.option("--block-webgl", "block WebGL")
|
||||
|
||||
// Security options
|
||||
.option("--disable-coop", "disable Cross-Origin-Opener-Policy")
|
||||
|
||||
// Geolocation and IP
|
||||
.option(
|
||||
"--geoip <ip>",
|
||||
"IP address for geolocation spoofing (or 'auto' for automatic)",
|
||||
)
|
||||
.option("--country <country>", "country code for geolocation")
|
||||
.option("--timezone <timezone>", "timezone to spoof")
|
||||
.option("--latitude <lat>", "latitude for geolocation", parseFloat)
|
||||
.option("--longitude <lng>", "longitude for geolocation", parseFloat)
|
||||
|
||||
// UI and behavior
|
||||
.option(
|
||||
"--humanize [duration]",
|
||||
"humanize cursor movement (optional max duration in seconds)",
|
||||
(val) => (val ? parseFloat(val) : true),
|
||||
)
|
||||
.option("--executable-path <path>", "executable path")
|
||||
.option("--fingerprint <json>", "fingerprint JSON string")
|
||||
.option("--headless", "run in headless mode")
|
||||
.option("--custom-config <json>", "custom config JSON string")
|
||||
|
||||
// Localization
|
||||
.option("--locale <locale>", "locale(s) to use (comma-separated)")
|
||||
.description("manage Camoufox browser instances")
|
||||
.action(
|
||||
async (
|
||||
action: string,
|
||||
options: Record<string, string | number | boolean | undefined>,
|
||||
) => {
|
||||
if (action === "start") {
|
||||
try {
|
||||
// Build Camoufox options in the format expected by camoufox-js
|
||||
const camoufoxOptions: LaunchOptions = {};
|
||||
|
||||
// Extensions and fonts
|
||||
.option("--addons <addons>", "Firefox addons to load (comma-separated paths)")
|
||||
.option("--fonts <fonts>", "additional fonts to load (comma-separated)")
|
||||
.option("--custom-fonts-only", "use only custom fonts, exclude OS fonts")
|
||||
.option(
|
||||
"--exclude-addons <addons>",
|
||||
"default addons to exclude (comma-separated)",
|
||||
)
|
||||
// OS fingerprinting
|
||||
if (options.os && typeof options.os === "string") {
|
||||
camoufoxOptions.os = options.os.includes(",")
|
||||
? (options.os.split(",") as ("windows" | "macos" | "linux")[])
|
||||
: (options.os as "windows" | "macos" | "linux");
|
||||
}
|
||||
|
||||
// Screen and window
|
||||
.option("--screen-min-width <width>", "minimum screen width", parseInt)
|
||||
.option("--screen-max-width <width>", "maximum screen width", parseInt)
|
||||
.option("--screen-min-height <height>", "minimum screen height", parseInt)
|
||||
.option("--screen-max-height <height>", "maximum screen height", parseInt)
|
||||
.option("--window-width <width>", "fixed window width", parseInt)
|
||||
.option("--window-height <height>", "fixed window height", parseInt)
|
||||
// Blocking options
|
||||
if (options.blockImages) camoufoxOptions.block_images = true;
|
||||
if (options.blockWebrtc) camoufoxOptions.block_webrtc = true;
|
||||
if (options.blockWebgl) camoufoxOptions.block_webgl = true;
|
||||
|
||||
// Advanced options
|
||||
.option("--ff-version <version>", "Firefox version to emulate", parseInt)
|
||||
.option("--main-world-eval", "enable main world script evaluation")
|
||||
.option("--webgl-vendor <vendor>", "WebGL vendor string")
|
||||
.option("--webgl-renderer <renderer>", "WebGL renderer string")
|
||||
// Security options
|
||||
if (options.disableCoop) camoufoxOptions.disable_coop = true;
|
||||
|
||||
// Proxy
|
||||
.option(
|
||||
"--proxy <proxy>",
|
||||
"proxy URL (protocol://[username:password@]host:port)",
|
||||
)
|
||||
if (options.geoip) {
|
||||
camoufoxOptions.geoip = true;
|
||||
}
|
||||
|
||||
// Cache and performance
|
||||
.option("--disable-cache", "disable browser cache (cache enabled by default)")
|
||||
if (options.latitude && options.longitude) {
|
||||
camoufoxOptions.geolocation = {
|
||||
latitude: options.latitude as number,
|
||||
longitude: options.longitude as number,
|
||||
accuracy: 100,
|
||||
};
|
||||
}
|
||||
if (options.country)
|
||||
camoufoxOptions.country = options.country as string;
|
||||
if (options.timezone)
|
||||
camoufoxOptions.timezone = options.timezone as string;
|
||||
|
||||
// Environment and debugging
|
||||
.option("--virtual-display <display>", "virtual display number (e.g., :99)")
|
||||
.option("--debug", "enable debug output")
|
||||
.option("--args <args>", "additional browser arguments (comma-separated)")
|
||||
.option("--env <env>", "environment variables (JSON string)")
|
||||
if (options.humanize)
|
||||
camoufoxOptions.humanize = options.humanize as boolean;
|
||||
if (options.headless) camoufoxOptions.headless = true;
|
||||
|
||||
// Firefox preferences
|
||||
.option("--firefox-prefs <prefs>", "Firefox user preferences (JSON string)")
|
||||
// Localization
|
||||
if (options.locale && typeof options.locale === "string") {
|
||||
camoufoxOptions.locale = options.locale.includes(",")
|
||||
? options.locale.split(",")
|
||||
: options.locale;
|
||||
}
|
||||
|
||||
.description("launch and manage Camoufox anti-detect browser instances")
|
||||
.action(async (action: string, options: any) => {
|
||||
try {
|
||||
if (action === "launch") {
|
||||
// Validate required options
|
||||
if (!options.executablePath || !options.profilePath) {
|
||||
// Extensions and fonts
|
||||
if (options.addons && typeof options.addons === "string")
|
||||
camoufoxOptions.addons = options.addons.split(",");
|
||||
if (options.fonts && typeof options.fonts === "string")
|
||||
camoufoxOptions.fonts = options.fonts.split(",");
|
||||
if (options.customFontsOnly) camoufoxOptions.custom_fonts_only = true;
|
||||
if (
|
||||
options.excludeAddons &&
|
||||
typeof options.excludeAddons === "string"
|
||||
)
|
||||
camoufoxOptions.exclude_addons = options.excludeAddons.split(
|
||||
",",
|
||||
) as "UBO"[];
|
||||
|
||||
// Executable path: forward through to camoufox-js and ultimately Playwright
|
||||
if (
|
||||
options.executablePath &&
|
||||
typeof options.executablePath === "string"
|
||||
) {
|
||||
// camoufox-js uses snake_case for this option
|
||||
(camoufoxOptions as any).executable_path =
|
||||
options.executablePath as string;
|
||||
}
|
||||
|
||||
// Screen and window
|
||||
const screen: {
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
} = {};
|
||||
if (options.screenMinWidth)
|
||||
screen.minWidth = options.screenMinWidth as number;
|
||||
if (options.screenMaxWidth)
|
||||
screen.maxWidth = options.screenMaxWidth as number;
|
||||
if (options.screenMinHeight)
|
||||
screen.minHeight = options.screenMinHeight as number;
|
||||
if (options.screenMaxHeight)
|
||||
screen.maxHeight = options.screenMaxHeight as number;
|
||||
if (Object.keys(screen).length > 0) camoufoxOptions.screen = screen;
|
||||
|
||||
if (options.windowWidth && options.windowHeight) {
|
||||
camoufoxOptions.window = [
|
||||
options.windowWidth as number,
|
||||
options.windowHeight as number,
|
||||
];
|
||||
}
|
||||
|
||||
// Advanced options
|
||||
if (options.ffVersion)
|
||||
camoufoxOptions.ff_version = options.ffVersion as number;
|
||||
if (options.mainWorldEval) camoufoxOptions.main_world_eval = true;
|
||||
if (options.webglVendor && options.webglRenderer) {
|
||||
camoufoxOptions.webgl_config = [
|
||||
options.webglVendor as string,
|
||||
options.webglRenderer as string,
|
||||
];
|
||||
}
|
||||
|
||||
// Proxy
|
||||
if (options.proxy) camoufoxOptions.proxy = options.proxy as string;
|
||||
|
||||
// Cache and performance - default to enabled
|
||||
camoufoxOptions.enable_cache = !options.disableCache;
|
||||
|
||||
// Environment and debugging
|
||||
if (options.virtualDisplay)
|
||||
camoufoxOptions.virtual_display = options.virtualDisplay as string;
|
||||
if (options.debug) camoufoxOptions.debug = true;
|
||||
|
||||
// Handle headless mode via flag instead of environment variable
|
||||
if (options.headless) {
|
||||
camoufoxOptions.headless = true;
|
||||
}
|
||||
if (options.args && typeof options.args === "string")
|
||||
camoufoxOptions.args = options.args.split(",");
|
||||
if (options.env && typeof options.env === "string") {
|
||||
try {
|
||||
camoufoxOptions.env = JSON.parse(options.env);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
error: "Invalid JSON for --env option",
|
||||
message: String(e),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Firefox preferences
|
||||
if (
|
||||
options.firefoxPrefs &&
|
||||
typeof options.firefoxPrefs === "string"
|
||||
) {
|
||||
try {
|
||||
camoufoxOptions.firefox_user_prefs = JSON.parse(
|
||||
options.firefoxPrefs,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
error: "Invalid JSON for --firefox-prefs option",
|
||||
message: String(e),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
const config = await startCamoufoxProcess(
|
||||
camoufoxOptions,
|
||||
typeof options.profilePath === "string"
|
||||
? options.profilePath
|
||||
: undefined,
|
||||
typeof options.url === "string" ? options.url : undefined,
|
||||
typeof options.customConfig === "string"
|
||||
? options.customConfig
|
||||
: undefined,
|
||||
);
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
id: config.id,
|
||||
processId: config.processId,
|
||||
profilePath: config.profilePath,
|
||||
url: config.url,
|
||||
}),
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
"Error: --executable-path and --profile-path are required for launch",
|
||||
JSON.stringify({
|
||||
error: "Failed to start Camoufox",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build Camoufox options
|
||||
const camoufoxOptions: any = {
|
||||
enable_cache: !options.disableCache, // Cache enabled by default as requested
|
||||
};
|
||||
|
||||
// OS fingerprinting
|
||||
if (options.os) {
|
||||
camoufoxOptions.os = options.os.includes(",")
|
||||
? options.os.split(",")
|
||||
: options.os;
|
||||
}
|
||||
|
||||
// Blocking options
|
||||
if (options.blockImages) camoufoxOptions.block_images = true;
|
||||
if (options.blockWebrtc) camoufoxOptions.block_webrtc = true;
|
||||
if (options.blockWebgl) camoufoxOptions.block_webgl = true;
|
||||
|
||||
// Security options
|
||||
if (options.disableCoop) camoufoxOptions.disable_coop = true;
|
||||
|
||||
// Geolocation
|
||||
if (options.geoip) {
|
||||
camoufoxOptions.geoip =
|
||||
options.geoip === "auto" ? true : options.geoip;
|
||||
}
|
||||
if (options.latitude && options.longitude) {
|
||||
camoufoxOptions.geolocation = {
|
||||
latitude: options.latitude,
|
||||
longitude: options.longitude,
|
||||
accuracy: 100,
|
||||
};
|
||||
}
|
||||
if (options.country) camoufoxOptions.country = options.country;
|
||||
if (options.timezone) camoufoxOptions.timezone = options.timezone;
|
||||
|
||||
// UI and behavior
|
||||
if (options.humanize) camoufoxOptions.humanize = options.humanize;
|
||||
if (options.headless) camoufoxOptions.headless = true;
|
||||
|
||||
// Localization
|
||||
if (options.locale) {
|
||||
camoufoxOptions.locale = options.locale.includes(",")
|
||||
? options.locale.split(",")
|
||||
: options.locale;
|
||||
}
|
||||
|
||||
// Extensions and fonts
|
||||
if (options.addons) camoufoxOptions.addons = options.addons.split(",");
|
||||
if (options.fonts) camoufoxOptions.fonts = options.fonts.split(",");
|
||||
if (options.customFontsOnly) camoufoxOptions.custom_fonts_only = true;
|
||||
if (options.excludeAddons)
|
||||
camoufoxOptions.exclude_addons = options.excludeAddons.split(",");
|
||||
|
||||
// Screen and window
|
||||
const screen: any = {};
|
||||
if (options.screenMinWidth) screen.minWidth = options.screenMinWidth;
|
||||
if (options.screenMaxWidth) screen.maxWidth = options.screenMaxWidth;
|
||||
if (options.screenMinHeight) screen.minHeight = options.screenMinHeight;
|
||||
if (options.screenMaxHeight) screen.maxHeight = options.screenMaxHeight;
|
||||
if (Object.keys(screen).length > 0) camoufoxOptions.screen = screen;
|
||||
|
||||
if (options.windowWidth && options.windowHeight) {
|
||||
camoufoxOptions.window = [options.windowWidth, options.windowHeight];
|
||||
}
|
||||
|
||||
// Advanced options
|
||||
if (options.ffVersion) camoufoxOptions.ff_version = options.ffVersion;
|
||||
if (options.mainWorldEval) camoufoxOptions.main_world_eval = true;
|
||||
if (options.webglVendor && options.webglRenderer) {
|
||||
camoufoxOptions.webgl_config = [
|
||||
options.webglVendor,
|
||||
options.webglRenderer,
|
||||
];
|
||||
}
|
||||
|
||||
// Proxy
|
||||
if (options.proxy) camoufoxOptions.proxy = options.proxy;
|
||||
|
||||
// Environment and debugging
|
||||
if (options.virtualDisplay)
|
||||
camoufoxOptions.virtual_display = options.virtualDisplay;
|
||||
if (options.debug) camoufoxOptions.debug = true;
|
||||
if (options.args) camoufoxOptions.args = options.args.split(",");
|
||||
if (options.env) {
|
||||
try {
|
||||
camoufoxOptions.env = JSON.parse(options.env);
|
||||
} catch (e) {
|
||||
console.error("Invalid JSON for --env option");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Firefox preferences
|
||||
if (options.firefoxPrefs) {
|
||||
try {
|
||||
camoufoxOptions.firefox_user_prefs = JSON.parse(
|
||||
options.firefoxPrefs,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("Invalid JSON for --firefox-prefs option");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Launch Camoufox
|
||||
const config = await launchCamoufox(
|
||||
options.executablePath,
|
||||
options.profilePath,
|
||||
camoufoxOptions,
|
||||
options.url,
|
||||
);
|
||||
|
||||
// Output the configuration as JSON for the Rust side to parse
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
id: config.id,
|
||||
pid: config.pid,
|
||||
executable_path: config.executablePath,
|
||||
profile_path: config.profilePath,
|
||||
url: config.url,
|
||||
}),
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
} else if (action === "stop") {
|
||||
if (!options.id) {
|
||||
console.error("Error: --id is required for stop action");
|
||||
process.exit(1);
|
||||
return;
|
||||
if (options.id && typeof options.id === "string") {
|
||||
const stopped = await stopCamoufoxProcess(options.id);
|
||||
console.log(JSON.stringify({ success: stopped }));
|
||||
} else {
|
||||
await stopAllCamoufoxProcesses();
|
||||
console.log(JSON.stringify({ success: true }));
|
||||
}
|
||||
|
||||
const success = await stopCamoufox(options.id);
|
||||
console.log(JSON.stringify({ success }));
|
||||
process.exit(0);
|
||||
} else if (action === "list") {
|
||||
const processes = listCamoufoxProcesses();
|
||||
// Convert camelCase to snake_case for Rust compatibility
|
||||
const rustCompatibleProcesses = processes.map((process) => ({
|
||||
id: process.id,
|
||||
pid: process.pid,
|
||||
executable_path: process.executablePath,
|
||||
profile_path: process.profilePath,
|
||||
url: process.url,
|
||||
}));
|
||||
console.log(JSON.stringify(rustCompatibleProcesses));
|
||||
const configs = listCamoufoxConfigs();
|
||||
console.log(JSON.stringify(configs));
|
||||
process.exit(0);
|
||||
} else if (action === "open-url") {
|
||||
if (!options.id || !options.url) {
|
||||
console.error(
|
||||
"Error: --id and --url are required for open-url action",
|
||||
);
|
||||
} else if (action === "generate-config") {
|
||||
try {
|
||||
const config = await generateCamoufoxConfig({
|
||||
proxy:
|
||||
typeof options.proxy === "string" ? options.proxy : undefined,
|
||||
maxWidth:
|
||||
typeof options.maxWidth === "number"
|
||||
? options.maxWidth
|
||||
: undefined,
|
||||
maxHeight:
|
||||
typeof options.maxHeight === "number"
|
||||
? options.maxHeight
|
||||
: undefined,
|
||||
minWidth:
|
||||
typeof options.minWidth === "number"
|
||||
? options.minWidth
|
||||
: undefined,
|
||||
minHeight:
|
||||
typeof options.minHeight === "number"
|
||||
? options.minHeight
|
||||
: undefined,
|
||||
geoip: Boolean(options.geoip),
|
||||
blockImages:
|
||||
typeof options.blockImages === "boolean"
|
||||
? options.blockImages
|
||||
: undefined,
|
||||
blockWebrtc:
|
||||
typeof options.blockWebrtc === "boolean"
|
||||
? options.blockWebrtc
|
||||
: undefined,
|
||||
blockWebgl:
|
||||
typeof options.blockWebgl === "boolean"
|
||||
? options.blockWebgl
|
||||
: undefined,
|
||||
executablePath:
|
||||
typeof options.executablePath === "string"
|
||||
? options.executablePath
|
||||
: undefined,
|
||||
fingerprint:
|
||||
typeof options.fingerprint === "string"
|
||||
? options.fingerprint
|
||||
: undefined,
|
||||
});
|
||||
console.log(config);
|
||||
process.exit(0);
|
||||
} catch (error: unknown) {
|
||||
console.error({
|
||||
error: "Failed to generate config",
|
||||
message:
|
||||
error instanceof Error ? error.message : JSON.stringify(error),
|
||||
});
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// This would require implementing URL opening in existing instance
|
||||
// For now, we'll return an error as this feature would need additional implementation
|
||||
console.error("open-url action is not yet implemented");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error(
|
||||
"Invalid action. Use 'launch', 'stop', 'list', or 'open-url'",
|
||||
);
|
||||
console.error({
|
||||
error: "Invalid action",
|
||||
message: "Use 'start', 'stop', 'list', or 'generate-config'",
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`Camoufox command failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Command for Camoufox worker (internal use)
|
||||
program
|
||||
.command("camoufox-worker")
|
||||
.argument("<action>", "start a Camoufox worker")
|
||||
.requiredOption("--id <id>", "Camoufox configuration ID")
|
||||
.description("run a Camoufox worker process")
|
||||
.action(async (action: string, options: { id: string }) => {
|
||||
if (action === "start") {
|
||||
await runCamoufoxWorker(options.id);
|
||||
} else {
|
||||
console.error({
|
||||
error: "Invalid action for camoufox-worker",
|
||||
message: "Use 'start'",
|
||||
});
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -2,23 +2,23 @@ import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import getPort from "get-port";
|
||||
import {
|
||||
type ProxyConfig,
|
||||
deleteProxyConfig,
|
||||
generateProxyId,
|
||||
getProxyConfig,
|
||||
isProcessRunning,
|
||||
listProxyConfigs,
|
||||
type ProxyConfig,
|
||||
saveProxyConfig,
|
||||
} from "./proxy-storage";
|
||||
|
||||
/**
|
||||
* Start a proxy in a separate process
|
||||
* @param upstreamUrl The upstream proxy URL
|
||||
* @param upstreamUrl The upstream proxy URL (optional for direct proxy)
|
||||
* @param options Optional configuration
|
||||
* @returns Promise resolving to the proxy configuration
|
||||
*/
|
||||
export async function startProxyProcess(
|
||||
upstreamUrl: string,
|
||||
upstreamUrl?: string,
|
||||
options: { port?: number; ignoreProxyCertificate?: boolean } = {},
|
||||
): Promise<ProxyConfig> {
|
||||
// Generate a unique ID for this proxy
|
||||
@@ -30,7 +30,7 @@ export async function startProxyProcess(
|
||||
// Create the proxy configuration
|
||||
const config: ProxyConfig = {
|
||||
id,
|
||||
upstreamUrl,
|
||||
upstreamUrl: upstreamUrl || "DIRECT",
|
||||
localPort: port,
|
||||
ignoreProxyCertificate: options.ignoreProxyCertificate ?? false,
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import tmp from "tmp";
|
||||
|
||||
export interface ProxyConfig {
|
||||
id: string;
|
||||
upstreamUrl: string;
|
||||
upstreamUrl: string; // Can be "DIRECT" for direct proxy
|
||||
localPort?: number;
|
||||
ignoreProxyCertificate?: boolean;
|
||||
localUrl?: string;
|
||||
|
||||
+10
-15
@@ -19,6 +19,10 @@ export async function runProxyWorker(id: string): Promise<void> {
|
||||
port: config.localPort,
|
||||
host: "127.0.0.1",
|
||||
prepareRequestFunction: () => {
|
||||
// If upstreamUrl is "DIRECT", don't use upstream proxy
|
||||
if (config.upstreamUrl === "DIRECT") {
|
||||
return {};
|
||||
}
|
||||
return {
|
||||
upstreamProxyUrl: config.upstreamUrl,
|
||||
ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate ?? false,
|
||||
@@ -27,28 +31,22 @@ export async function runProxyWorker(id: string): Promise<void> {
|
||||
});
|
||||
|
||||
// Handle process termination gracefully
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
console.log(`Proxy worker ${id} received ${signal}, shutting down...`);
|
||||
const gracefulShutdown = async () => {
|
||||
try {
|
||||
await server.close(true);
|
||||
console.log(`Proxy worker ${id} shut down successfully`);
|
||||
} catch (error) {
|
||||
console.error(`Error during shutdown for proxy ${id}:`, error);
|
||||
}
|
||||
} catch {}
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => void gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => void gracefulShutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void gracefulShutdown());
|
||||
process.on("SIGINT", () => void gracefulShutdown());
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error(`Uncaught exception in proxy worker ${id}:`, error);
|
||||
process.on("uncaughtException", () => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error(`Unhandled rejection in proxy worker ${id}:`, reason);
|
||||
process.on("unhandledRejection", () => {
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
@@ -61,9 +59,6 @@ export async function runProxyWorker(id: string): Promise<void> {
|
||||
config.localUrl = `http://127.0.0.1:${server.port}`;
|
||||
updateProxyConfig(config);
|
||||
|
||||
console.log(`Proxy worker ${id} started on port ${server.port}`);
|
||||
console.log(`Forwarding to upstream proxy: ${config.upstreamUrl}`);
|
||||
|
||||
// Keep the process alive
|
||||
setInterval(() => {
|
||||
// Do nothing, just keep the process alive
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { LaunchOptions } from "playwright-core";
|
||||
|
||||
const OS_MAP: { [key: string]: "mac" | "win" | "lin" } = {
|
||||
darwin: "mac",
|
||||
linux: "lin",
|
||||
win32: "win",
|
||||
};
|
||||
|
||||
const OS_NAME: "mac" | "win" | "lin" = OS_MAP[process.platform];
|
||||
|
||||
export function getEnvVars(configMap: Record<string, string>) {
|
||||
const envVars: {
|
||||
[key: string]: string | undefined;
|
||||
} = {};
|
||||
let updatedConfigData: Uint8Array;
|
||||
|
||||
try {
|
||||
// Ensure we're working with a fresh copy of the config
|
||||
const configCopy = JSON.parse(JSON.stringify(configMap));
|
||||
updatedConfigData = new TextEncoder().encode(JSON.stringify(configCopy));
|
||||
} catch (e) {
|
||||
console.error(`Error updating config: ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const chunkSize = OS_NAME === "win" ? 2047 : 32767;
|
||||
const configStr = new TextDecoder().decode(updatedConfigData);
|
||||
|
||||
for (let i = 0; i < configStr.length; i += chunkSize) {
|
||||
const chunk = configStr.slice(i, i + chunkSize);
|
||||
const envName = `CAMOU_CONFIG_${Math.floor(i / chunkSize) + 1}`;
|
||||
try {
|
||||
envVars[envName] = chunk;
|
||||
} catch (e) {
|
||||
console.error(`Error setting ${envName}: ${e}`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
return envVars;
|
||||
}
|
||||
|
||||
export function parseProxyString(proxyString: LaunchOptions["proxy"] | string) {
|
||||
if (typeof proxyString === "object") {
|
||||
return proxyString;
|
||||
}
|
||||
|
||||
if (!proxyString || typeof proxyString !== "string") {
|
||||
throw new Error("Invalid proxy string provided");
|
||||
}
|
||||
|
||||
// Remove any leading/trailing whitespace
|
||||
const trimmed = proxyString.trim();
|
||||
|
||||
// Handle different proxy string formats:
|
||||
// 1. http://username:password@host:port
|
||||
// 2. host:port
|
||||
// 3. protocol://host:port
|
||||
// 4. username:password@host:port
|
||||
|
||||
let server = "";
|
||||
let username: string | undefined;
|
||||
let password: string | undefined;
|
||||
|
||||
try {
|
||||
// Try parsing as URL first (handles protocol://username:password@host:port)
|
||||
if (trimmed.includes("://")) {
|
||||
const url = new URL(trimmed);
|
||||
server = `${url.hostname}:${url.port}`;
|
||||
|
||||
if (url.username) {
|
||||
username = decodeURIComponent(url.username);
|
||||
}
|
||||
if (url.password) {
|
||||
password = decodeURIComponent(url.password);
|
||||
}
|
||||
} else {
|
||||
// Handle formats without protocol
|
||||
let workingString = trimmed;
|
||||
|
||||
// Check for username:password@ prefix
|
||||
const authMatch = workingString.match(/^([^:@]+):([^@]+)@(.+)$/);
|
||||
if (authMatch) {
|
||||
username = authMatch[1];
|
||||
password = authMatch[2];
|
||||
workingString = authMatch[3];
|
||||
}
|
||||
|
||||
// The remaining part should be host:port
|
||||
server = workingString;
|
||||
}
|
||||
|
||||
// Validate that we have a server
|
||||
if (!server) {
|
||||
throw new Error("Could not extract server information");
|
||||
}
|
||||
|
||||
// Basic validation for host:port format
|
||||
if (!server.includes(":") || server.split(":").length !== 2) {
|
||||
throw new Error("Server must be in host:port format");
|
||||
}
|
||||
|
||||
const result: LaunchOptions["proxy"] = { server };
|
||||
|
||||
if (username !== undefined) {
|
||||
result.username = username;
|
||||
}
|
||||
|
||||
if (password !== undefined) {
|
||||
result.password = password;
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
throw new Error(
|
||||
`Failed to parse proxy string: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
+22
-20
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.7.0",
|
||||
"version": "0.9.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -30,48 +30,50 @@
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "^2.6.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.0",
|
||||
"@tauri-apps/plugin-fs": "~2.4.0",
|
||||
"@tauri-apps/api": "^2.7.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.2",
|
||||
"@tauri-apps/plugin-fs": "~2.4.1",
|
||||
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||
"ahooks": "^3.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"next": "^15.3.5",
|
||||
"motion": "^12.23.12",
|
||||
"next": "^15.4.6",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.6",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@biomejs/biome": "2.1.4",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tauri-apps/cli": "^2.6.2",
|
||||
"@types/node": "^24.0.10",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@tauri-apps/cli": "^2.7.1",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.2",
|
||||
"lint-staged": "^16.1.5",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "~5.8.3"
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.1",
|
||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
"**/*.{js,jsx,ts,tsx,json,css}": [
|
||||
"biome check --fix"
|
||||
],
|
||||
"src-tauri/**/*.rs": [
|
||||
|
||||
Generated
+1214
-1435
File diff suppressed because it is too large
Load Diff
Generated
+786
-386
File diff suppressed because it is too large
Load Diff
+22
-9
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.7.0"
|
||||
version = "0.9.2"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -14,6 +14,7 @@ default-run = "donutbrowser"
|
||||
# This seems to be only an issue on Windows, see https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "donutbrowser"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
doctest = false
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
@@ -30,22 +31,28 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
directories = "6"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sysinfo = "0.35"
|
||||
tokio = { version = "1", features = ["full", "sync"] }
|
||||
sysinfo = "0.36"
|
||||
lazy_static = "1.4"
|
||||
base64 = "0.22"
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
zip = "4"
|
||||
tar = "0"
|
||||
bzip2 = "0"
|
||||
flate2 = "1"
|
||||
lzma-rs = "0"
|
||||
msi-extract = "0"
|
||||
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
url = "2.5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
zip = "4"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation="0.10"
|
||||
core-foundation = "0.10"
|
||||
objc2 = "0.6.1"
|
||||
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
||||
|
||||
@@ -71,11 +78,17 @@ hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Integration test configuration
|
||||
[[test]]
|
||||
name = "nodecar_integration"
|
||||
path = "tests/nodecar_integration.rs"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` points to the filesystem
|
||||
default = [ "custom-protocol" ]
|
||||
default = ["custom-protocol"]
|
||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||
# DO NOT remove this
|
||||
custom-protocol = [ "tauri/custom-protocol" ]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
@@ -7,7 +7,7 @@ Exec=donutbrowser %u
|
||||
Icon=donutbrowser
|
||||
StartupNotify=true
|
||||
NoDisplay=false
|
||||
Categories=Network;WebBrowser;Productivity;
|
||||
Categories=Network;WebBrowser;
|
||||
MimeType=x-scheme-handler/http;x-scheme-handler/https;text/html;application/xhtml+xml;
|
||||
StartupWMClass=donutbrowser
|
||||
Keywords=browser;web;internet;productivity;
|
||||
@@ -28,5 +28,11 @@
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
<key>com.apple.security.automation.apple-events</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.usb</key>
|
||||
<true/>
|
||||
<key>com.apple.security.inherit</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+270
-258
@@ -34,6 +34,12 @@ pub enum PreReleaseKind {
|
||||
impl VersionComponent {
|
||||
pub fn parse(version: &str) -> Self {
|
||||
let version = version.trim();
|
||||
// Normalize common tag prefixes like 'v1.2.3' -> '1.2.3'
|
||||
let version = if version.starts_with('v') || version.starts_with('V') {
|
||||
&version[1..]
|
||||
} else {
|
||||
version
|
||||
};
|
||||
|
||||
// Handle special case for Zen Browser twilight releases
|
||||
if version.to_lowercase() == "twilight" {
|
||||
@@ -218,8 +224,11 @@ pub fn sort_versions(versions: &mut [String]) {
|
||||
// Helper function to sort GitHub releases
|
||||
pub fn sort_github_releases(releases: &mut [GithubRelease]) {
|
||||
releases.sort_by(|a, b| {
|
||||
let version_a = VersionComponent::parse(&a.tag_name);
|
||||
let version_b = VersionComponent::parse(&b.tag_name);
|
||||
// Normalize tags like "v1.81.9" -> "1.81.9" for correct ordering
|
||||
let tag_a = a.tag_name.trim_start_matches('v');
|
||||
let tag_b = b.tag_name.trim_start_matches('v');
|
||||
let version_a = VersionComponent::parse(tag_a);
|
||||
let version_b = VersionComponent::parse(tag_b);
|
||||
version_b.cmp(&version_a) // Descending order (newest first)
|
||||
});
|
||||
}
|
||||
@@ -242,12 +251,22 @@ pub fn is_browser_version_nightly(
|
||||
version.to_lowercase() == "twilight"
|
||||
}
|
||||
"brave" => {
|
||||
// For Brave Browser, only releases titled "Release" are stable, everything else is nightly
|
||||
// For Brave Browser, only releases whose name starts with "Release" (case-insensitive) are stable.
|
||||
if let Some(name) = release_name {
|
||||
!name.starts_with("Release")
|
||||
} else {
|
||||
true
|
||||
let normalized = name.trim_start().to_ascii_lowercase();
|
||||
return !normalized.starts_with("release");
|
||||
}
|
||||
|
||||
// Fallback: try cached GitHub releases
|
||||
if let Some(releases) = ApiClient::instance().get_cached_github_releases("brave") {
|
||||
if let Some(found) = releases.iter().find(|r| r.tag_name == version) {
|
||||
let normalized = found.name.trim_start().to_ascii_lowercase();
|
||||
return !normalized.starts_with("release");
|
||||
}
|
||||
}
|
||||
|
||||
// Last resort: when no name available, treat as nightly (non-Release)
|
||||
true
|
||||
}
|
||||
"firefox" | "firefox-developer" => {
|
||||
// For Firefox, use the category from the API response to determine stability
|
||||
@@ -295,7 +314,7 @@ pub struct BrowserRelease {
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct CachedVersionData {
|
||||
versions: Vec<String>,
|
||||
releases: Vec<BrowserRelease>,
|
||||
timestamp: u64,
|
||||
}
|
||||
|
||||
@@ -315,7 +334,7 @@ pub struct ApiClient {
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
firefox_api_base: "https://product-details.mozilla.org/1.0".to_string(),
|
||||
@@ -327,6 +346,69 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
async fn fetch_github_releases_multiple_pages(
|
||||
&self,
|
||||
base_releases_url: &str,
|
||||
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut all_releases: Vec<GithubRelease> = Vec::new();
|
||||
|
||||
// For now, only fetch 1 page
|
||||
for page in 1..=1 {
|
||||
let url = format!("{base_releases_url}?per_page=100&page={page}");
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
// If the first page fails, propagate error; otherwise stop pagination
|
||||
if page == 1 {
|
||||
return Err(
|
||||
format!(
|
||||
"GitHub API returned status for page {}: {}",
|
||||
page,
|
||||
response.status()
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let text = response.text().await?;
|
||||
let mut page_releases: Vec<GithubRelease> = serde_json::from_str(&text).map_err(|e| {
|
||||
eprintln!("Failed to parse GitHub API response (page {page}): {e}");
|
||||
eprintln!(
|
||||
"Response text (first 500 chars): {}",
|
||||
if text.len() > 500 {
|
||||
&text[..500]
|
||||
} else {
|
||||
&text
|
||||
}
|
||||
);
|
||||
format!("Failed to parse GitHub API response: {e}")
|
||||
})?;
|
||||
|
||||
if page_releases.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
all_releases.append(&mut page_releases);
|
||||
}
|
||||
|
||||
Ok(all_releases)
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static ApiClient {
|
||||
&API_CLIENT
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_with_base_urls(
|
||||
firefox_api_base: String,
|
||||
@@ -370,7 +452,7 @@ impl ApiClient {
|
||||
current_time - timestamp < cache_duration
|
||||
}
|
||||
|
||||
pub fn load_cached_versions(&self, browser: &str) -> Option<Vec<String>> {
|
||||
pub fn load_cached_versions(&self, browser: &str) -> Option<Vec<BrowserRelease>> {
|
||||
let cache_dir = Self::get_cache_dir().ok()?;
|
||||
let cache_file = cache_dir.join(format!("{browser}_versions.json"));
|
||||
|
||||
@@ -379,11 +461,27 @@ impl ApiClient {
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(&cache_file).ok()?;
|
||||
let cached_data: CachedVersionData = serde_json::from_str(&content).ok()?;
|
||||
if let Ok(cached) = serde_json::from_str::<CachedVersionData>(&content) {
|
||||
// Always return cached releases regardless of age - they're always valid
|
||||
println!("Using cached versions for {browser}");
|
||||
return Some(cached.releases);
|
||||
}
|
||||
|
||||
// Always return cached versions regardless of age - they're always valid
|
||||
println!("Using cached versions for {browser}");
|
||||
Some(cached_data.versions)
|
||||
// Backward compatibility: legacy caches stored just an array of version strings
|
||||
if let Ok(legacy_versions) = serde_json::from_str::<Vec<String>>(&content) {
|
||||
println!("Using legacy cached versions for {browser}; upgrading in-memory");
|
||||
let releases: Vec<BrowserRelease> = legacy_versions
|
||||
.into_iter()
|
||||
.map(|version| BrowserRelease {
|
||||
is_prerelease: is_browser_version_nightly(browser, &version, None),
|
||||
version,
|
||||
date: "".to_string(),
|
||||
})
|
||||
.collect();
|
||||
return Some(releases);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub fn is_cache_expired(&self, browser: &str) -> bool {
|
||||
@@ -414,19 +512,19 @@ impl ApiClient {
|
||||
pub fn save_cached_versions(
|
||||
&self,
|
||||
browser: &str,
|
||||
versions: &[String],
|
||||
releases: &[BrowserRelease],
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let cache_dir = Self::get_cache_dir()?;
|
||||
let cache_file = cache_dir.join(format!("{browser}_versions.json"));
|
||||
|
||||
let cached_data = CachedVersionData {
|
||||
versions: versions.to_vec(),
|
||||
releases: releases.to_vec(),
|
||||
timestamp: Self::get_current_timestamp(),
|
||||
};
|
||||
|
||||
let content = serde_json::to_string_pretty(&cached_data)?;
|
||||
fs::write(&cache_file, content)?;
|
||||
println!("Cached {} versions for {}", versions.len(), browser);
|
||||
println!("Cached {} versions for {}", releases.len(), browser);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -446,6 +544,11 @@ impl ApiClient {
|
||||
Some(cached_data.releases)
|
||||
}
|
||||
|
||||
/// Public accessor for cached GitHub releases (used by other modules for classification)
|
||||
pub fn get_cached_github_releases(&self, browser: &str) -> Option<Vec<GithubRelease>> {
|
||||
self.load_cached_github_releases(browser)
|
||||
}
|
||||
|
||||
fn save_cached_github_releases(
|
||||
&self,
|
||||
browser: &str,
|
||||
@@ -471,19 +574,8 @@ impl ApiClient {
|
||||
) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Check cache first (unless bypassing)
|
||||
if !no_caching {
|
||||
if let Some(cached_versions) = self.load_cached_versions("firefox") {
|
||||
return Ok(
|
||||
cached_versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
BrowserRelease {
|
||||
version: version.clone(),
|
||||
date: "".to_string(), // Cache doesn't store dates
|
||||
is_prerelease: is_browser_version_nightly("firefox", &version, None),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if let Some(cached_releases) = self.load_cached_versions("firefox") {
|
||||
return Ok(cached_releases);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -529,12 +621,9 @@ impl ApiClient {
|
||||
version_b.cmp(&version_a)
|
||||
});
|
||||
|
||||
// Extract versions for caching
|
||||
let versions: Vec<String> = releases.iter().map(|r| r.version.clone()).collect();
|
||||
|
||||
// Cache the results (unless bypassing cache)
|
||||
if !no_caching {
|
||||
if let Err(e) = self.save_cached_versions("firefox", &versions) {
|
||||
if let Err(e) = self.save_cached_versions("firefox", &releases) {
|
||||
eprintln!("Failed to cache Firefox versions: {e}");
|
||||
}
|
||||
}
|
||||
@@ -548,19 +637,8 @@ impl ApiClient {
|
||||
) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Check cache first (unless bypassing)
|
||||
if !no_caching {
|
||||
if let Some(cached_versions) = self.load_cached_versions("firefox-developer") {
|
||||
return Ok(
|
||||
cached_versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
BrowserRelease {
|
||||
version: version.clone(),
|
||||
date: "".to_string(), // Cache doesn't store dates
|
||||
is_prerelease: is_browser_version_nightly("firefox-developer", &version, None),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if let Some(cached_releases) = self.load_cached_versions("firefox-developer") {
|
||||
return Ok(cached_releases);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -612,12 +690,9 @@ impl ApiClient {
|
||||
version_b.cmp(&version_a)
|
||||
});
|
||||
|
||||
// Extract versions for caching
|
||||
let versions: Vec<String> = releases.iter().map(|r| r.version.clone()).collect();
|
||||
|
||||
// Cache the results (unless bypassing cache)
|
||||
if !no_caching {
|
||||
if let Err(e) = self.save_cached_versions("firefox-developer", &versions) {
|
||||
if let Err(e) = self.save_cached_versions("firefox-developer", &releases) {
|
||||
eprintln!("Failed to cache Firefox Developer versions: {e}");
|
||||
}
|
||||
}
|
||||
@@ -636,43 +711,12 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
println!("Fetching Mullvad releases from GitHub API...");
|
||||
let url = format!(
|
||||
"{}/repos/mullvad/mullvad-browser/releases?per_page=100",
|
||||
println!("Fetching Mullvad releases from GitHub API");
|
||||
let base_url = format!(
|
||||
"{}/repos/mullvad/mullvad-browser/releases",
|
||||
self.github_api_base
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("GitHub API returned status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
// Get the response text first for better error reporting
|
||||
let response_text = response.text().await?;
|
||||
|
||||
// Try to parse the JSON with better error handling
|
||||
let releases: Vec<GithubRelease> = match serde_json::from_str(&response_text) {
|
||||
Ok(releases) => releases,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to parse GitHub API response for Mullvad releases:");
|
||||
eprintln!("Error: {e}");
|
||||
eprintln!(
|
||||
"Response text (first 500 chars): {}",
|
||||
if response_text.len() > 500 {
|
||||
&response_text[..500]
|
||||
} else {
|
||||
&response_text
|
||||
}
|
||||
);
|
||||
return Err(format!("Failed to parse GitHub API response: {e}").into());
|
||||
}
|
||||
};
|
||||
let releases = self.fetch_github_releases_multiple_pages(&base_url).await?;
|
||||
|
||||
let mut releases: Vec<GithubRelease> = releases
|
||||
.into_iter()
|
||||
@@ -706,43 +750,13 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
println!("Fetching Zen releases from GitHub API...");
|
||||
let url = format!(
|
||||
"{}/repos/zen-browser/desktop/releases?per_page=100",
|
||||
println!("Fetching Zen releases from GitHub API");
|
||||
let base_url = format!(
|
||||
"{}/repos/zen-browser/desktop/releases",
|
||||
self.github_api_base
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("GitHub API returned status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
// Get the response text first for better error reporting
|
||||
let response_text = response.text().await?;
|
||||
|
||||
// Try to parse the JSON with better error handling
|
||||
let mut releases: Vec<GithubRelease> = match serde_json::from_str(&response_text) {
|
||||
Ok(releases) => releases,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to parse GitHub API response for Zen releases:");
|
||||
eprintln!("Error: {e}");
|
||||
eprintln!(
|
||||
"Response text (first 500 chars): {}",
|
||||
if response_text.len() > 500 {
|
||||
&response_text[..500]
|
||||
} else {
|
||||
&response_text
|
||||
}
|
||||
);
|
||||
return Err(format!("Failed to parse GitHub API response: {e}").into());
|
||||
}
|
||||
};
|
||||
let mut releases: Vec<GithubRelease> =
|
||||
self.fetch_github_releases_multiple_pages(&base_url).await?;
|
||||
|
||||
// Check for twilight updates and mark alpha releases
|
||||
for release in &mut releases {
|
||||
@@ -787,55 +801,25 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
println!("Fetching Brave releases from GitHub API...");
|
||||
let url = format!(
|
||||
"{}/repos/brave/brave-browser/releases?per_page=100",
|
||||
println!("Fetching Brave releases from GitHub API");
|
||||
let base_url = format!(
|
||||
"{}/repos/brave/brave-browser/releases",
|
||||
self.github_api_base
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("GitHub API returned status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
// Get the response text first for better error reporting
|
||||
let response_text = response.text().await?;
|
||||
|
||||
// Try to parse the JSON with better error handling
|
||||
let releases: Vec<GithubRelease> = match serde_json::from_str(&response_text) {
|
||||
Ok(releases) => releases,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to parse GitHub API response for Brave releases:");
|
||||
eprintln!("Error: {e}");
|
||||
eprintln!(
|
||||
"Response text (first 500 chars): {}",
|
||||
if response_text.len() > 500 {
|
||||
&response_text[..500]
|
||||
} else {
|
||||
&response_text
|
||||
}
|
||||
);
|
||||
return Err(format!("Failed to parse GitHub API response: {e}").into());
|
||||
}
|
||||
};
|
||||
let releases: Vec<GithubRelease> = self.fetch_github_releases_multiple_pages(&base_url).await?;
|
||||
|
||||
// Get platform info to filter appropriate releases
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
let (os, _) = Self::get_platform_info();
|
||||
|
||||
// Filter releases that have assets compatible with the current platform
|
||||
let mut filtered_releases: Vec<GithubRelease> = releases
|
||||
.into_iter()
|
||||
.filter_map(|mut release| {
|
||||
// Check if this release has compatible assets for the current platform
|
||||
let has_compatible_asset = Self::has_compatible_brave_asset(&release.assets, &os, &arch);
|
||||
let has_compatible_asset = Self::has_compatible_brave_asset(&release.assets, &os);
|
||||
|
||||
if has_compatible_asset {
|
||||
println!("release.name: {:?}", release.name);
|
||||
// Use the centralized nightly detection function
|
||||
release.is_nightly =
|
||||
is_browser_version_nightly("brave", &release.tag_name, Some(&release.name));
|
||||
@@ -849,11 +833,8 @@ impl ApiClient {
|
||||
// Sort releases using the new version sorting system
|
||||
sort_github_releases(&mut filtered_releases);
|
||||
|
||||
// Cache the results (unless bypassing cache)
|
||||
if !no_caching {
|
||||
if let Err(e) = self.save_cached_github_releases("brave", &filtered_releases) {
|
||||
eprintln!("Failed to cache Brave releases: {e}");
|
||||
}
|
||||
if let Err(e) = self.save_cached_github_releases("brave", &filtered_releases) {
|
||||
eprintln!("Failed to cache Brave releases: {e}");
|
||||
}
|
||||
|
||||
Ok(filtered_releases)
|
||||
@@ -885,11 +866,7 @@ impl ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn has_compatible_brave_asset(
|
||||
assets: &[crate::browser::GithubAsset],
|
||||
os: &str,
|
||||
arch: &str,
|
||||
) -> bool {
|
||||
fn has_compatible_brave_asset(assets: &[crate::browser::GithubAsset], os: &str) -> bool {
|
||||
match os {
|
||||
"windows" => {
|
||||
// For Windows, look for standalone setup EXE (not the auto-updater one)
|
||||
@@ -906,12 +883,9 @@ impl ApiClient {
|
||||
}) || assets.iter().any(|asset| asset.name.ends_with(".dmg"))
|
||||
}
|
||||
"linux" => {
|
||||
// For Linux, be strict about architecture matching - only allow assets that explicitly match the current architecture
|
||||
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
|
||||
|
||||
if assets.iter().any(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
|
||||
name.contains("lin")
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
@@ -975,19 +949,8 @@ impl ApiClient {
|
||||
) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Check cache first (unless bypassing)
|
||||
if !no_caching {
|
||||
if let Some(cached_versions) = self.load_cached_versions("chromium") {
|
||||
return Ok(
|
||||
cached_versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
BrowserRelease {
|
||||
version: version.clone(),
|
||||
date: "".to_string(), // Cache doesn't store dates
|
||||
is_prerelease: false, // Chromium versions are generally stable builds
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if let Some(cached_releases) = self.load_cached_versions("chromium") {
|
||||
return Ok(cached_releases);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1006,23 +969,24 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Convert to BrowserRelease objects
|
||||
let releases: Vec<BrowserRelease> = versions
|
||||
.into_iter()
|
||||
.map(|version| BrowserRelease {
|
||||
version: version.clone(),
|
||||
date: "".to_string(),
|
||||
is_prerelease: false,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Cache the results (unless bypassing cache)
|
||||
if !no_caching {
|
||||
if let Err(e) = self.save_cached_versions("chromium", &versions) {
|
||||
if let Err(e) = self.save_cached_versions("chromium", &releases) {
|
||||
eprintln!("Failed to cache Chromium versions: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(
|
||||
versions
|
||||
.into_iter()
|
||||
.map(|version| BrowserRelease {
|
||||
version: version.clone(),
|
||||
date: "".to_string(),
|
||||
is_prerelease: false,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
Ok(releases)
|
||||
}
|
||||
|
||||
pub async fn fetch_camoufox_releases_with_caching(
|
||||
@@ -1040,43 +1004,9 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
println!("Fetching Camoufox releases from GitHub API...");
|
||||
let url = format!(
|
||||
"{}/repos/daijro/camoufox/releases?per_page=100",
|
||||
self.github_api_base
|
||||
);
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("GitHub API returned status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
// Get the response text first for better error reporting
|
||||
let response_text = response.text().await?;
|
||||
|
||||
// Try to parse the JSON with better error handling
|
||||
let releases: Vec<GithubRelease> = match serde_json::from_str(&response_text) {
|
||||
Ok(releases) => releases,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to parse GitHub API response for Camoufox releases:");
|
||||
eprintln!("Error: {e}");
|
||||
eprintln!(
|
||||
"Response text (first 500 chars): {}",
|
||||
if response_text.len() > 500 {
|
||||
&response_text[..500]
|
||||
} else {
|
||||
&response_text
|
||||
}
|
||||
);
|
||||
return Err(format!("Failed to parse GitHub API response: {e}").into());
|
||||
}
|
||||
};
|
||||
println!("Fetching Camoufox releases from GitHub API");
|
||||
let base_url = format!("{}/repos/daijro/camoufox/releases", self.github_api_base);
|
||||
let releases: Vec<GithubRelease> = self.fetch_github_releases_multiple_pages(&base_url).await?;
|
||||
|
||||
println!(
|
||||
"Fetched {} total Camoufox releases from GitHub",
|
||||
@@ -1153,19 +1083,8 @@ impl ApiClient {
|
||||
) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Check cache first (unless bypassing)
|
||||
if !no_caching {
|
||||
if let Some(cached_versions) = self.load_cached_versions("tor-browser") {
|
||||
return Ok(
|
||||
cached_versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
BrowserRelease {
|
||||
version: version.clone(),
|
||||
date: "".to_string(), // Cache doesn't store dates
|
||||
is_prerelease: is_browser_version_nightly("tor-browser", &version, None),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
);
|
||||
if let Some(cached_releases) = self.load_cached_versions("tor-browser") {
|
||||
return Ok(cached_releases);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1221,25 +1140,24 @@ impl ApiClient {
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
// Convert to BrowserRelease objects
|
||||
let releases: Vec<BrowserRelease> = version_strings
|
||||
.into_iter()
|
||||
.map(|version| BrowserRelease {
|
||||
version: version.clone(),
|
||||
date: "".to_string(), // TOR archive doesn't provide structured dates
|
||||
is_prerelease: false, // Assume all archived versions are stable
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Cache the results (unless bypassing cache)
|
||||
if !no_caching {
|
||||
if let Err(e) = self.save_cached_versions("tor-browser", &version_strings) {
|
||||
if let Err(e) = self.save_cached_versions("tor-browser", &releases) {
|
||||
eprintln!("Failed to cache TOR versions: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(
|
||||
version_strings
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
BrowserRelease {
|
||||
version: version.clone(),
|
||||
date: "".to_string(), // TOR archive doesn't provide structured dates
|
||||
is_prerelease: false, // Assume all archived versions are stable
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
Ok(releases)
|
||||
}
|
||||
|
||||
async fn check_tor_version_has_macos(
|
||||
@@ -1336,6 +1254,11 @@ impl ApiClient {
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref API_CLIENT: ApiClient = ApiClient::new();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -1669,11 +1592,22 @@ mod tests {
|
||||
"name": "Release v1.81.9 (Chromium 137.0.7151.104)",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"draft": false,
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.9-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
|
||||
"size": 200000000
|
||||
},
|
||||
{
|
||||
"name": "brave-browser-1.81.9-linux-amd64.zip",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-linux-amd64.zip",
|
||||
"size": 180000000
|
||||
},
|
||||
{
|
||||
"name": "BraveBrowserStandaloneSetup.exe",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-setup.exe",
|
||||
"size": 150000000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1950,6 +1884,84 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mullvad_pagination_two_pages() {
|
||||
let server = setup_mock_server().await;
|
||||
let client = create_test_client(&server);
|
||||
|
||||
// Page 1 response with Link: rel="next" header
|
||||
let mock_page1 = r#"[
|
||||
{
|
||||
"tag_name": "100.0",
|
||||
"name": "Mullvad Browser 100.0",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-07-01T00:00:00Z",
|
||||
"assets": [
|
||||
{ "name": "mullvad-browser-macos-100.0.dmg", "browser_download_url": "https://example.com/100.0.dmg", "size": 1 }
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
// Page 2 response
|
||||
let mock_page2 = r#"[
|
||||
{
|
||||
"tag_name": "99.0",
|
||||
"name": "Mullvad Browser 99.0",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-06-01T00:00:00Z",
|
||||
"assets": [
|
||||
{ "name": "mullvad-browser-macos-99.0.dmg", "browser_download_url": "https://example.com/99.0.dmg", "size": 1 }
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
// Mock page 1
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(query_param("per_page", "100"))
|
||||
.and(query_param("page", "1"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_page1)
|
||||
.insert_header("content-type", "application/json")
|
||||
.insert_header(
|
||||
"link",
|
||||
format!(
|
||||
"<{}?per_page=100&page=2>; rel=\"next\", <{}?per_page=100&page=2>; rel=\"last\"",
|
||||
server.uri().to_string() + "/repos/mullvad/mullvad-browser/releases",
|
||||
server.uri().to_string() + "/repos/mullvad/mullvad-browser/releases"
|
||||
),
|
||||
),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
// Mock page 2
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(query_param("per_page", "100"))
|
||||
.and(query_param("page", "2"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_page2)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let result = client.fetch_mullvad_releases_with_caching(true).await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let releases = result.unwrap();
|
||||
// We currently only fetch 1 page intentionally; ensure we at least got page 1
|
||||
assert_eq!(
|
||||
releases.len(),
|
||||
1,
|
||||
"Should fetch only the first page of results"
|
||||
);
|
||||
assert_eq!(releases[0].tag_name, "100.0");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_camoufox_beta_version_parsing() {
|
||||
// Test specific Camoufox beta versions that are causing issues
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+104
-71
@@ -1,6 +1,6 @@
|
||||
use crate::api_client::is_browser_version_nightly;
|
||||
use crate::browser_runner::{BrowserProfile, BrowserRunner};
|
||||
use crate::browser_version_service::{BrowserVersionInfo, BrowserVersionService};
|
||||
use crate::browser_version_manager::{BrowserVersionInfo, BrowserVersionManager};
|
||||
use crate::profile::BrowserProfile;
|
||||
use crate::settings_manager::SettingsManager;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -29,20 +29,22 @@ pub struct AutoUpdateState {
|
||||
}
|
||||
|
||||
pub struct AutoUpdater {
|
||||
version_service: BrowserVersionService,
|
||||
browser_runner: BrowserRunner,
|
||||
settings_manager: SettingsManager,
|
||||
version_service: &'static BrowserVersionManager,
|
||||
settings_manager: &'static SettingsManager,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
pub fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
version_service: BrowserVersionService::new(),
|
||||
browser_runner: BrowserRunner::new(),
|
||||
settings_manager: SettingsManager::new(),
|
||||
version_service: BrowserVersionManager::instance(),
|
||||
settings_manager: SettingsManager::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static AutoUpdater {
|
||||
&AUTO_UPDATER
|
||||
}
|
||||
|
||||
/// Check for updates for all profiles
|
||||
pub async fn check_for_updates(
|
||||
&self,
|
||||
@@ -51,8 +53,8 @@ impl AutoUpdater {
|
||||
let mut browser_versions: HashMap<String, Vec<BrowserVersionInfo>> = HashMap::new();
|
||||
|
||||
// Group profiles by browser
|
||||
let profiles = self
|
||||
.browser_runner
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
|
||||
@@ -101,7 +103,7 @@ impl AutoUpdater {
|
||||
if let Some(update) = self.check_profile_update(&profile, &versions)? {
|
||||
// Apply chromium threshold logic
|
||||
if browser == "chromium" {
|
||||
// For chromium, only show notifications if there are 200+ new versions
|
||||
// For chromium, only show notifications if there are 400+ new versions
|
||||
let current_version = &profile.version.parse::<u32>().unwrap();
|
||||
let new_version = &update.new_version.parse::<u32>().unwrap();
|
||||
|
||||
@@ -109,11 +111,11 @@ impl AutoUpdater {
|
||||
println!(
|
||||
"Current version: {current_version}, New version: {new_version}, Result: {result}"
|
||||
);
|
||||
if result > 200 {
|
||||
if result > 400 {
|
||||
notifications.push(update);
|
||||
} else {
|
||||
println!(
|
||||
"Skipping chromium update notification: only {result} new versions (need 50+)"
|
||||
"Skipping chromium update notification: only {result} new versions (need 400+)"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
@@ -294,8 +296,8 @@ impl AutoUpdater {
|
||||
browser: &str,
|
||||
new_version: &str,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let profiles = self
|
||||
.browser_runner
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
@@ -312,10 +314,7 @@ impl AutoUpdater {
|
||||
// Check if this is an update (newer version)
|
||||
if self.is_version_newer(new_version, &profile.version) {
|
||||
// Update the profile version
|
||||
match self
|
||||
.browser_runner
|
||||
.update_profile_version(&profile.name, new_version)
|
||||
{
|
||||
match profile_manager.update_profile_version(&profile.name, new_version) {
|
||||
Ok(_) => {
|
||||
updated_profiles.push(profile.name);
|
||||
}
|
||||
@@ -361,21 +360,23 @@ impl AutoUpdater {
|
||||
&self,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Load current profiles
|
||||
let profiles = self
|
||||
.browser_runner
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to load profiles: {e}"))?;
|
||||
|
||||
// Load registry
|
||||
let mut registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::load()
|
||||
.map_err(|e| format!("Failed to load browser registry: {e}"))?;
|
||||
// Get registry instance
|
||||
let registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::instance();
|
||||
|
||||
// Get active browser versions
|
||||
// Get active browser versions (all profiles)
|
||||
let active_versions = registry.get_active_browser_versions(&profiles);
|
||||
|
||||
// Cleanup unused binaries
|
||||
// Get running browser versions (only running profiles)
|
||||
let running_versions = registry.get_running_browser_versions(&profiles);
|
||||
|
||||
// Cleanup unused binaries (but keep running ones)
|
||||
let cleaned_up = registry
|
||||
.cleanup_unused_binaries(&active_versions)
|
||||
.cleanup_unused_binaries(&active_versions, &running_versions)
|
||||
.map_err(|e| format!("Failed to cleanup unused binaries: {e}"))?;
|
||||
|
||||
// Save updated registry
|
||||
@@ -427,7 +428,7 @@ impl AutoUpdater {
|
||||
.join("auto_update_state.json")
|
||||
}
|
||||
|
||||
fn load_auto_update_state(
|
||||
pub fn load_auto_update_state(
|
||||
&self,
|
||||
) -> Result<AutoUpdateState, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let state_file = self.get_auto_update_state_file();
|
||||
@@ -441,7 +442,7 @@ impl AutoUpdater {
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
fn save_auto_update_state(
|
||||
pub fn save_auto_update_state(
|
||||
&self,
|
||||
state: &AutoUpdateState,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -460,7 +461,7 @@ impl AutoUpdater {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, String> {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
let notifications = updater
|
||||
.check_for_updates()
|
||||
.await
|
||||
@@ -471,7 +472,7 @@ pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, Stri
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
updater
|
||||
.is_browser_disabled(&browser)
|
||||
.map_err(|e| format!("Failed to check browser status: {e}"))
|
||||
@@ -479,7 +480,7 @@ pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, Str
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dismiss_update_notification(notification_id: String) -> Result<(), String> {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
updater
|
||||
.dismiss_update_notification(¬ification_id)
|
||||
.map_err(|e| format!("Failed to dismiss notification: {e}"))
|
||||
@@ -490,7 +491,7 @@ pub async fn complete_browser_update_with_auto_update(
|
||||
browser: String,
|
||||
new_version: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
updater
|
||||
.complete_browser_update_with_auto_update(&browser, &new_version)
|
||||
.await
|
||||
@@ -499,7 +500,7 @@ pub async fn complete_browser_update_with_auto_update(
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates_with_progress(app_handle: tauri::AppHandle) {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
updater.check_for_updates_with_progress(&app_handle).await;
|
||||
}
|
||||
|
||||
@@ -518,6 +519,7 @@ mod tests {
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -531,7 +533,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_compare_versions() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
|
||||
assert_eq!(
|
||||
updater.compare_versions("1.0.0", "1.0.0"),
|
||||
@@ -557,7 +559,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_version_newer() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
|
||||
assert!(updater.is_version_newer("1.0.1", "1.0.0"));
|
||||
assert!(updater.is_version_newer("2.0.0", "1.9.9"));
|
||||
@@ -567,7 +569,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_camoufox_beta_version_comparison() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
|
||||
// Test the exact user-reported scenario: 135.0.1beta24 vs 135.0beta22
|
||||
assert!(
|
||||
@@ -601,7 +603,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_beta_version_ordering_comprehensive() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
|
||||
// Test various beta version patterns that could appear in camoufox
|
||||
let test_cases = vec![
|
||||
@@ -629,7 +631,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_check_profile_update_stable_to_stable() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
let profile = create_test_profile("test", "firefox", "1.0.0");
|
||||
let versions = vec![
|
||||
create_test_version_info("1.0.1", false), // stable, newer
|
||||
@@ -647,7 +649,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_check_profile_update_alpha_to_alpha() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
let profile = create_test_profile("test", "firefox", "1.0.0-alpha");
|
||||
let versions = vec![
|
||||
create_test_version_info("1.0.1", false), // stable, should be included
|
||||
@@ -666,7 +668,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_check_profile_update_no_update_available() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
let profile = create_test_profile("test", "firefox", "1.0.0");
|
||||
let versions = vec![
|
||||
create_test_version_info("0.9.0", false), // older
|
||||
@@ -679,7 +681,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_group_update_notifications() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
let notifications = vec![
|
||||
UpdateNotification {
|
||||
id: "firefox_1.0.0_to_1.1.0_profile1".to_string(),
|
||||
@@ -777,13 +779,15 @@ mod tests {
|
||||
let state_file = test_settings_manager
|
||||
.get_settings_dir()
|
||||
.join("auto_update_state.json");
|
||||
std::fs::create_dir_all(test_settings_manager.get_settings_dir()).unwrap();
|
||||
let json = serde_json::to_string_pretty(&state).unwrap();
|
||||
std::fs::write(&state_file, json).unwrap();
|
||||
std::fs::create_dir_all(test_settings_manager.get_settings_dir())
|
||||
.expect("Failed to create settings directory");
|
||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize state");
|
||||
std::fs::write(&state_file, json).expect("Failed to write state file");
|
||||
|
||||
// Load state
|
||||
let content = std::fs::read_to_string(&state_file).unwrap();
|
||||
let loaded_state: AutoUpdateState = serde_json::from_str(&content).unwrap();
|
||||
let content = std::fs::read_to_string(&state_file).expect("Failed to read state file");
|
||||
let loaded_state: AutoUpdateState =
|
||||
serde_json::from_str(&content).expect("Failed to deserialize state");
|
||||
|
||||
assert_eq!(loaded_state.disabled_browsers.len(), 1);
|
||||
assert!(loaded_state.disabled_browsers.contains("firefox"));
|
||||
@@ -821,11 +825,15 @@ mod tests {
|
||||
let state_file = test_settings_manager
|
||||
.get_settings_dir()
|
||||
.join("auto_update_state.json");
|
||||
std::fs::create_dir_all(test_settings_manager.get_settings_dir()).unwrap();
|
||||
std::fs::create_dir_all(test_settings_manager.get_settings_dir())
|
||||
.expect("Failed to create settings directory");
|
||||
|
||||
// Initially not disabled (empty state file means default state)
|
||||
let state = AutoUpdateState::default();
|
||||
assert!(!state.disabled_browsers.contains("firefox"));
|
||||
assert!(
|
||||
!state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should not be disabled initially"
|
||||
);
|
||||
|
||||
// Start update (should disable)
|
||||
let mut state = AutoUpdateState::default();
|
||||
@@ -833,27 +841,41 @@ mod tests {
|
||||
state
|
||||
.auto_update_downloads
|
||||
.insert("firefox-1.1.0".to_string());
|
||||
let json = serde_json::to_string_pretty(&state).unwrap();
|
||||
std::fs::write(&state_file, json).unwrap();
|
||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize state");
|
||||
std::fs::write(&state_file, json).expect("Failed to write state file");
|
||||
|
||||
// Check that it's disabled
|
||||
let content = std::fs::read_to_string(&state_file).unwrap();
|
||||
let loaded_state: AutoUpdateState = serde_json::from_str(&content).unwrap();
|
||||
assert!(loaded_state.disabled_browsers.contains("firefox"));
|
||||
assert!(loaded_state.auto_update_downloads.contains("firefox-1.1.0"));
|
||||
let content = std::fs::read_to_string(&state_file).expect("Failed to read state file");
|
||||
let loaded_state: AutoUpdateState =
|
||||
serde_json::from_str(&content).expect("Failed to deserialize state");
|
||||
assert!(
|
||||
loaded_state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should be disabled"
|
||||
);
|
||||
assert!(
|
||||
loaded_state.auto_update_downloads.contains("firefox-1.1.0"),
|
||||
"Firefox download should be tracked"
|
||||
);
|
||||
|
||||
// Complete update (should enable)
|
||||
let mut state = loaded_state;
|
||||
state.disabled_browsers.remove("firefox");
|
||||
state.auto_update_downloads.remove("firefox-1.1.0");
|
||||
let json = serde_json::to_string_pretty(&state).unwrap();
|
||||
std::fs::write(&state_file, json).unwrap();
|
||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize final state");
|
||||
std::fs::write(&state_file, json).expect("Failed to write final state file");
|
||||
|
||||
// Check that it's enabled again
|
||||
let content = std::fs::read_to_string(&state_file).unwrap();
|
||||
let final_state: AutoUpdateState = serde_json::from_str(&content).unwrap();
|
||||
assert!(!final_state.disabled_browsers.contains("firefox"));
|
||||
assert!(!final_state.auto_update_downloads.contains("firefox-1.1.0"));
|
||||
let content = std::fs::read_to_string(&state_file).expect("Failed to read final state file");
|
||||
let final_state: AutoUpdateState =
|
||||
serde_json::from_str(&content).expect("Failed to deserialize final state");
|
||||
assert!(
|
||||
!final_state.disabled_browsers.contains("firefox"),
|
||||
"Firefox should be enabled again"
|
||||
);
|
||||
assert!(
|
||||
!final_state.auto_update_downloads.contains("firefox-1.1.0"),
|
||||
"Firefox download should not be tracked anymore"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -861,7 +883,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Create a temporary directory for testing
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
// Create a mock settings manager that uses the temp directory
|
||||
struct TestSettingsManager {
|
||||
@@ -895,20 +917,31 @@ mod tests {
|
||||
let state_file = test_settings_manager
|
||||
.get_settings_dir()
|
||||
.join("auto_update_state.json");
|
||||
std::fs::create_dir_all(test_settings_manager.get_settings_dir()).unwrap();
|
||||
let json = serde_json::to_string_pretty(&state).unwrap();
|
||||
std::fs::write(&state_file, json).unwrap();
|
||||
std::fs::create_dir_all(test_settings_manager.get_settings_dir())
|
||||
.expect("Failed to create settings directory");
|
||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize initial state");
|
||||
std::fs::write(&state_file, json).expect("Failed to write initial state file");
|
||||
|
||||
// Dismiss notification (remove from pending updates)
|
||||
state
|
||||
.pending_updates
|
||||
.retain(|n| n.id != "test_notification");
|
||||
let json = serde_json::to_string_pretty(&state).unwrap();
|
||||
std::fs::write(&state_file, json).unwrap();
|
||||
let json = serde_json::to_string_pretty(&state).expect("Failed to serialize updated state");
|
||||
std::fs::write(&state_file, json).expect("Failed to write updated state file");
|
||||
|
||||
// Check that it's removed
|
||||
let content = std::fs::read_to_string(&state_file).unwrap();
|
||||
let loaded_state: AutoUpdateState = serde_json::from_str(&content).unwrap();
|
||||
assert_eq!(loaded_state.pending_updates.len(), 0);
|
||||
let content = std::fs::read_to_string(&state_file).expect("Failed to read updated state file");
|
||||
let loaded_state: AutoUpdateState =
|
||||
serde_json::from_str(&content).expect("Failed to deserialize updated state");
|
||||
assert_eq!(
|
||||
loaded_state.pending_updates.len(),
|
||||
0,
|
||||
"Pending updates should be empty after dismissal"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref AUTO_UPDATER: AutoUpdater = AutoUpdater::new();
|
||||
}
|
||||
|
||||
+239
-88
@@ -168,17 +168,25 @@ mod linux {
|
||||
install_dir: &Path,
|
||||
browser_type: &BrowserType,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
// Expected structure: install_dir/<browser>/<binary>
|
||||
// Expected structure examples:
|
||||
// - Firefox/Firefox Developer on Linux often extract to: install_dir/firefox/firefox
|
||||
// - Some archives may extract directly under: install_dir/firefox or install_dir/firefox-bin
|
||||
// - For some flavors we may have: install_dir/<browser_type>/<binary>
|
||||
let browser_subdir = install_dir.join(browser_type.as_str());
|
||||
|
||||
// Try firefox first (preferred), then firefox-bin
|
||||
// Try common firefox executable locations (nested and flat)
|
||||
let possible_executables = match browser_type {
|
||||
BrowserType::Firefox | BrowserType::FirefoxDeveloper => {
|
||||
vec![
|
||||
browser_subdir.join("firefox"),
|
||||
browser_subdir.join("firefox-bin"),
|
||||
]
|
||||
}
|
||||
BrowserType::Firefox | BrowserType::FirefoxDeveloper => vec![
|
||||
// Nested "firefox/firefox" or "firefox/firefox-bin"
|
||||
install_dir.join("firefox").join("firefox"),
|
||||
install_dir.join("firefox").join("firefox-bin"),
|
||||
// Flat under version directory
|
||||
install_dir.join("firefox"),
|
||||
install_dir.join("firefox-bin"),
|
||||
// Under a subdirectory matching the browser type
|
||||
browser_subdir.join("firefox"),
|
||||
browser_subdir.join("firefox-bin"),
|
||||
],
|
||||
BrowserType::MullvadBrowser => {
|
||||
vec![
|
||||
browser_subdir.join("firefox"),
|
||||
@@ -191,15 +199,20 @@ mod linux {
|
||||
}
|
||||
BrowserType::TorBrowser => {
|
||||
vec![
|
||||
browser_subdir.join("firefox"),
|
||||
// Common Tor Browser launchers
|
||||
browser_subdir.join("tor-browser"),
|
||||
// Firefox-based binaries
|
||||
browser_subdir.join("firefox"),
|
||||
browser_subdir.join("firefox-bin"),
|
||||
// Sometimes packaged similarly to Firefox
|
||||
install_dir.join("firefox").join("firefox"),
|
||||
install_dir.join("firefox").join("firefox-bin"),
|
||||
]
|
||||
}
|
||||
BrowserType::Camoufox => {
|
||||
vec![
|
||||
browser_subdir.join("camoufox"),
|
||||
browser_subdir.join("camoufox-bin"),
|
||||
install_dir.join("camoufox-bin"),
|
||||
install_dir.join("camoufox"),
|
||||
]
|
||||
}
|
||||
_ => vec![],
|
||||
@@ -213,9 +226,9 @@ mod linux {
|
||||
|
||||
Err(
|
||||
format!(
|
||||
"Firefox executable not found in {}/{}",
|
||||
"Executable not found for {} in {}",
|
||||
browser_type.as_str(),
|
||||
install_dir.display(),
|
||||
browser_type.as_str()
|
||||
)
|
||||
.into(),
|
||||
)
|
||||
@@ -253,18 +266,21 @@ mod linux {
|
||||
}
|
||||
|
||||
pub fn is_firefox_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
|
||||
// Expected structure: install_dir/<browser>/<binary>
|
||||
// Expected structure (most common):
|
||||
// install_dir/<browser>/<binary>
|
||||
// However, Firefox Developer tarballs often extract to a "firefox" subfolder
|
||||
// rather than "firefox-developer". Support both layouts.
|
||||
let browser_subdir = install_dir.join(browser_type.as_str());
|
||||
|
||||
if !browser_subdir.exists() || !browser_subdir.is_dir() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let possible_executables = match browser_type {
|
||||
BrowserType::Firefox | BrowserType::FirefoxDeveloper => {
|
||||
vec![
|
||||
// Preferred: executable inside a subdirectory named after the browser type
|
||||
browser_subdir.join("firefox-bin"),
|
||||
browser_subdir.join("firefox"),
|
||||
// Fallback: executable inside a generic "firefox" subdirectory
|
||||
install_dir.join("firefox").join("firefox-bin"),
|
||||
install_dir.join("firefox").join("firefox"),
|
||||
]
|
||||
}
|
||||
BrowserType::MullvadBrowser => {
|
||||
@@ -286,8 +302,8 @@ mod linux {
|
||||
}
|
||||
BrowserType::Camoufox => {
|
||||
vec![
|
||||
browser_subdir.join("camoufox-bin"),
|
||||
browser_subdir.join("camoufox"),
|
||||
install_dir.join("camoufox-bin"),
|
||||
install_dir.join("camoufox"),
|
||||
]
|
||||
}
|
||||
_ => vec![],
|
||||
@@ -789,17 +805,33 @@ impl Browser for CamoufoxBrowser {
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function to create browser instances
|
||||
pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
|
||||
match browser_type {
|
||||
BrowserType::MullvadBrowser
|
||||
| BrowserType::Firefox
|
||||
| BrowserType::FirefoxDeveloper
|
||||
| BrowserType::Zen
|
||||
| BrowserType::TorBrowser => Box::new(FirefoxBrowser::new(browser_type)),
|
||||
BrowserType::Chromium | BrowserType::Brave => Box::new(ChromiumBrowser::new(browser_type)),
|
||||
BrowserType::Camoufox => Box::new(CamoufoxBrowser::new()),
|
||||
pub struct BrowserFactory;
|
||||
|
||||
impl BrowserFactory {
|
||||
fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static BrowserFactory {
|
||||
&BROWSER_FACTORY
|
||||
}
|
||||
|
||||
pub fn create_browser(&self, browser_type: BrowserType) -> Box<dyn Browser> {
|
||||
match browser_type {
|
||||
BrowserType::MullvadBrowser
|
||||
| BrowserType::Firefox
|
||||
| BrowserType::FirefoxDeveloper
|
||||
| BrowserType::Zen
|
||||
| BrowserType::TorBrowser => Box::new(FirefoxBrowser::new(browser_type)),
|
||||
BrowserType::Chromium | BrowserType::Brave => Box::new(ChromiumBrowser::new(browser_type)),
|
||||
BrowserType::Camoufox => Box::new(CamoufoxBrowser::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function to create browser instances (kept for backward compatibility)
|
||||
pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
|
||||
BrowserFactory::instance().create_browser(browser_type)
|
||||
}
|
||||
|
||||
// Add GithubRelease and GithubAsset structs to browser.rs if they don't already exist
|
||||
@@ -877,38 +909,55 @@ mod tests {
|
||||
assert_eq!(BrowserType::TorBrowser.as_str(), "tor-browser");
|
||||
assert_eq!(BrowserType::Camoufox.as_str(), "camoufox");
|
||||
|
||||
// Test from_str
|
||||
// Test from_str - use expect with descriptive messages instead of unwrap
|
||||
assert_eq!(
|
||||
BrowserType::from_str("mullvad-browser").unwrap(),
|
||||
BrowserType::from_str("mullvad-browser").expect("mullvad-browser should be valid"),
|
||||
BrowserType::MullvadBrowser
|
||||
);
|
||||
assert_eq!(
|
||||
BrowserType::from_str("firefox").unwrap(),
|
||||
BrowserType::from_str("firefox").expect("firefox should be valid"),
|
||||
BrowserType::Firefox
|
||||
);
|
||||
assert_eq!(
|
||||
BrowserType::from_str("firefox-developer").unwrap(),
|
||||
BrowserType::from_str("firefox-developer").expect("firefox-developer should be valid"),
|
||||
BrowserType::FirefoxDeveloper
|
||||
);
|
||||
assert_eq!(
|
||||
BrowserType::from_str("chromium").unwrap(),
|
||||
BrowserType::from_str("chromium").expect("chromium should be valid"),
|
||||
BrowserType::Chromium
|
||||
);
|
||||
assert_eq!(BrowserType::from_str("brave").unwrap(), BrowserType::Brave);
|
||||
assert_eq!(BrowserType::from_str("zen").unwrap(), BrowserType::Zen);
|
||||
assert_eq!(
|
||||
BrowserType::from_str("tor-browser").unwrap(),
|
||||
BrowserType::from_str("brave").expect("brave should be valid"),
|
||||
BrowserType::Brave
|
||||
);
|
||||
assert_eq!(
|
||||
BrowserType::from_str("zen").expect("zen should be valid"),
|
||||
BrowserType::Zen
|
||||
);
|
||||
assert_eq!(
|
||||
BrowserType::from_str("tor-browser").expect("tor-browser should be valid"),
|
||||
BrowserType::TorBrowser
|
||||
);
|
||||
assert_eq!(
|
||||
BrowserType::from_str("camoufox").unwrap(),
|
||||
BrowserType::from_str("camoufox").expect("camoufox should be valid"),
|
||||
BrowserType::Camoufox
|
||||
);
|
||||
|
||||
// Test invalid browser type
|
||||
assert!(BrowserType::from_str("invalid").is_err());
|
||||
assert!(BrowserType::from_str("").is_err());
|
||||
assert!(BrowserType::from_str("Firefox").is_err()); // Case sensitive
|
||||
// Test invalid browser type - these should properly fail
|
||||
let invalid_result = BrowserType::from_str("invalid");
|
||||
assert!(
|
||||
invalid_result.is_err(),
|
||||
"Invalid browser type should return error"
|
||||
);
|
||||
|
||||
let empty_result = BrowserType::from_str("");
|
||||
assert!(empty_result.is_err(), "Empty string should return error");
|
||||
|
||||
let case_sensitive_result = BrowserType::from_str("Firefox");
|
||||
assert!(
|
||||
case_sensitive_result.is_err(),
|
||||
"Case sensitive check should fail"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -917,9 +966,12 @@ mod tests {
|
||||
let browser = FirefoxBrowser::new(BrowserType::Firefox);
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None)
|
||||
.unwrap();
|
||||
.expect("Failed to create launch args for Firefox");
|
||||
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
|
||||
assert!(!args.contains(&"-no-remote".to_string()));
|
||||
assert!(
|
||||
!args.contains(&"-no-remote".to_string()),
|
||||
"Firefox should not use -no-remote"
|
||||
);
|
||||
|
||||
let args = browser
|
||||
.create_launch_args(
|
||||
@@ -927,7 +979,7 @@ mod tests {
|
||||
None,
|
||||
Some("https://example.com".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
.expect("Failed to create launch args for Firefox with URL");
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-profile", "/path/to/profile", "https://example.com"]
|
||||
@@ -937,23 +989,26 @@ mod tests {
|
||||
let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser);
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None)
|
||||
.unwrap();
|
||||
.expect("Failed to create launch args for Mullvad Browser");
|
||||
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
|
||||
|
||||
// Test Tor Browser (should use -no-remote)
|
||||
let browser = FirefoxBrowser::new(BrowserType::TorBrowser);
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None)
|
||||
.unwrap();
|
||||
.expect("Failed to create launch args for Tor Browser");
|
||||
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
|
||||
|
||||
// Test Zen Browser (should not use -no-remote)
|
||||
let browser = FirefoxBrowser::new(BrowserType::Zen);
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None)
|
||||
.unwrap();
|
||||
.expect("Failed to create launch args for Zen Browser");
|
||||
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
|
||||
assert!(!args.contains(&"-no-remote".to_string()));
|
||||
assert!(
|
||||
!args.contains(&"-no-remote".to_string()),
|
||||
"Zen Browser should not use -no-remote"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -961,15 +1016,27 @@ mod tests {
|
||||
let browser = ChromiumBrowser::new(BrowserType::Chromium);
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None)
|
||||
.unwrap();
|
||||
.expect("Failed to create launch args for Chromium");
|
||||
|
||||
// Test that basic required arguments are present
|
||||
assert!(args.contains(&"--user-data-dir=/path/to/profile".to_string()));
|
||||
assert!(args.contains(&"--no-default-browser-check".to_string()));
|
||||
assert!(
|
||||
args.contains(&"--user-data-dir=/path/to/profile".to_string()),
|
||||
"Chromium args should contain user-data-dir"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--no-default-browser-check".to_string()),
|
||||
"Chromium args should contain no-default-browser-check"
|
||||
);
|
||||
|
||||
// Test that automatic update disabling arguments are present
|
||||
assert!(args.contains(&"--disable-background-mode".to_string()));
|
||||
assert!(args.contains(&"--disable-component-update".to_string()));
|
||||
assert!(
|
||||
args.contains(&"--disable-background-mode".to_string()),
|
||||
"Chromium args should contain disable-background-mode"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--disable-component-update".to_string()),
|
||||
"Chromium args should contain disable-component-update"
|
||||
);
|
||||
|
||||
let args_with_url = browser
|
||||
.create_launch_args(
|
||||
@@ -977,11 +1044,17 @@ mod tests {
|
||||
None,
|
||||
Some("https://example.com".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(args_with_url.contains(&"https://example.com".to_string()));
|
||||
.expect("Failed to create launch args for Chromium with URL");
|
||||
assert!(
|
||||
args_with_url.contains(&"https://example.com".to_string()),
|
||||
"Chromium args should contain the URL"
|
||||
);
|
||||
|
||||
// Verify URL is at the end
|
||||
assert_eq!(args_with_url.last().unwrap(), "https://example.com");
|
||||
assert_eq!(
|
||||
args_with_url.last().expect("Args should not be empty"),
|
||||
"https://example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1014,16 +1087,45 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_version_downloaded_check() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
let binaries_dir = temp_dir.path();
|
||||
|
||||
// Create a mock Firefox browser installation with new path structure: binaries/<browser>/<version>/
|
||||
let browser_dir = binaries_dir.join("firefox").join("139.0");
|
||||
fs::create_dir_all(&browser_dir).unwrap();
|
||||
fs::create_dir_all(&browser_dir).expect("Failed to create browser directory");
|
||||
|
||||
// Create a mock .app directory
|
||||
let app_dir = browser_dir.join("Firefox.app");
|
||||
fs::create_dir_all(&app_dir).unwrap();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Create a mock .app directory for macOS
|
||||
let app_dir = browser_dir.join("Firefox.app");
|
||||
fs::create_dir_all(&app_dir).expect("Failed to create Firefox.app directory");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Create a mock firefox subdirectory and executable for Linux
|
||||
let firefox_subdir = browser_dir.join("firefox");
|
||||
fs::create_dir_all(&firefox_subdir).expect("Failed to create firefox subdirectory");
|
||||
let executable_path = firefox_subdir.join("firefox");
|
||||
fs::write(&executable_path, "mock executable").expect("Failed to write mock executable");
|
||||
|
||||
// Set executable permissions on Linux
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut permissions = executable_path
|
||||
.metadata()
|
||||
.expect("Failed to get file metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&executable_path, permissions)
|
||||
.expect("Failed to set executable permissions");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Create a mock firefox.exe for Windows
|
||||
let executable_path = browser_dir.join("firefox.exe");
|
||||
fs::write(&executable_path, "mock executable").expect("Failed to write mock executable");
|
||||
}
|
||||
|
||||
let browser = FirefoxBrowser::new(BrowserType::Firefox);
|
||||
assert!(browser.is_version_downloaded("139.0", binaries_dir));
|
||||
@@ -1031,36 +1133,76 @@ mod tests {
|
||||
|
||||
// Test with Chromium browser with new path structure
|
||||
let chromium_dir = binaries_dir.join("chromium").join("1465660");
|
||||
fs::create_dir_all(&chromium_dir).unwrap();
|
||||
let chromium_app_dir = chromium_dir.join("Chromium.app");
|
||||
fs::create_dir_all(chromium_app_dir.join("Contents").join("MacOS")).unwrap();
|
||||
fs::create_dir_all(&chromium_dir).expect("Failed to create chromium directory");
|
||||
|
||||
// Create a mock executable
|
||||
let executable_path = chromium_app_dir
|
||||
.join("Contents")
|
||||
.join("MacOS")
|
||||
.join("Chromium");
|
||||
fs::write(&executable_path, "mock executable").unwrap();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let chromium_app_dir = chromium_dir.join("Chromium.app");
|
||||
fs::create_dir_all(chromium_app_dir.join("Contents").join("MacOS"))
|
||||
.expect("Failed to create Chromium.app structure");
|
||||
|
||||
// Create a mock executable
|
||||
let executable_path = chromium_app_dir
|
||||
.join("Contents")
|
||||
.join("MacOS")
|
||||
.join("Chromium");
|
||||
fs::write(&executable_path, "mock executable")
|
||||
.expect("Failed to write mock Chromium executable");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Create a mock chromium executable for Linux
|
||||
let executable_path = chromium_dir.join("chromium");
|
||||
fs::write(&executable_path, "mock executable")
|
||||
.expect("Failed to write mock chromium executable");
|
||||
|
||||
// Set executable permissions on Linux
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let mut permissions = executable_path
|
||||
.metadata()
|
||||
.expect("Failed to get chromium metadata")
|
||||
.permissions();
|
||||
permissions.set_mode(0o755);
|
||||
fs::set_permissions(&executable_path, permissions)
|
||||
.expect("Failed to set chromium permissions");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Create a mock chromium.exe for Windows
|
||||
let executable_path = chromium_dir.join("chromium.exe");
|
||||
fs::write(&executable_path, "mock executable").expect("Failed to write mock chromium.exe");
|
||||
}
|
||||
|
||||
let chromium_browser = ChromiumBrowser::new(BrowserType::Chromium);
|
||||
assert!(chromium_browser.is_version_downloaded("1465660", binaries_dir));
|
||||
assert!(!chromium_browser.is_version_downloaded("1465661", binaries_dir));
|
||||
assert!(
|
||||
chromium_browser.is_version_downloaded("1465660", binaries_dir),
|
||||
"Chromium version should be detected as downloaded"
|
||||
);
|
||||
assert!(
|
||||
!chromium_browser.is_version_downloaded("1465661", binaries_dir),
|
||||
"Non-existent Chromium version should not be detected as downloaded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_downloaded_no_app_directory() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
let binaries_dir = temp_dir.path();
|
||||
|
||||
// Create browser directory but no .app directory with new path structure
|
||||
// Create browser directory but no proper executable structure
|
||||
let browser_dir = binaries_dir.join("firefox").join("139.0");
|
||||
fs::create_dir_all(&browser_dir).unwrap();
|
||||
fs::create_dir_all(&browser_dir).expect("Failed to create browser directory");
|
||||
|
||||
// Create some other files but no .app
|
||||
fs::write(browser_dir.join("readme.txt"), "Some content").unwrap();
|
||||
// Create some other files but no proper executable structure
|
||||
fs::write(browser_dir.join("readme.txt"), "Some content").expect("Failed to write readme file");
|
||||
|
||||
let browser = FirefoxBrowser::new(BrowserType::Firefox);
|
||||
assert!(!browser.is_version_downloaded("139.0", binaries_dir));
|
||||
assert!(
|
||||
!browser.is_version_downloaded("139.0", binaries_dir),
|
||||
"Firefox version should not be detected without proper executable structure"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1085,15 +1227,24 @@ mod tests {
|
||||
};
|
||||
|
||||
// Test that it can be serialized (implements Serialize)
|
||||
let json = serde_json::to_string(&proxy).unwrap();
|
||||
assert!(json.contains("127.0.0.1"));
|
||||
assert!(json.contains("8080"));
|
||||
assert!(json.contains("http"));
|
||||
let json = serde_json::to_string(&proxy).expect("Failed to serialize proxy settings");
|
||||
assert!(json.contains("127.0.0.1"), "JSON should contain host IP");
|
||||
assert!(json.contains("8080"), "JSON should contain port number");
|
||||
assert!(json.contains("http"), "JSON should contain proxy type");
|
||||
|
||||
// Test that it can be deserialized (implements Deserialize)
|
||||
let deserialized: ProxySettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.proxy_type, proxy.proxy_type);
|
||||
assert_eq!(deserialized.host, proxy.host);
|
||||
assert_eq!(deserialized.port, proxy.port);
|
||||
let deserialized: ProxySettings =
|
||||
serde_json::from_str(&json).expect("Failed to deserialize proxy settings");
|
||||
assert_eq!(
|
||||
deserialized.proxy_type, proxy.proxy_type,
|
||||
"Proxy type should match"
|
||||
);
|
||||
assert_eq!(deserialized.host, proxy.host, "Host should match");
|
||||
assert_eq!(deserialized.port, proxy.port, "Port should match");
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref BROWSER_FACTORY: BrowserFactory = BrowserFactory::new();
|
||||
}
|
||||
|
||||
+570
-2557
File diff suppressed because it is too large
Load Diff
+270
-672
File diff suppressed because it is too large
Load Diff
+378
-497
@@ -1,86 +1,41 @@
|
||||
use crate::browser_runner::BrowserProfile;
|
||||
use crate::profile::BrowserProfile;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CamoufoxConfig {
|
||||
pub os: Option<Vec<String>>,
|
||||
pub proxy: Option<String>,
|
||||
pub screen_max_width: Option<u32>,
|
||||
pub screen_max_height: Option<u32>,
|
||||
pub screen_min_width: Option<u32>,
|
||||
pub screen_min_height: Option<u32>,
|
||||
pub geoip: Option<serde_json::Value>, // Can be String or bool
|
||||
pub block_images: Option<bool>,
|
||||
pub block_webrtc: Option<bool>,
|
||||
pub block_webgl: Option<bool>,
|
||||
pub disable_coop: Option<bool>,
|
||||
pub geoip: Option<serde_json::Value>, // Can be String or bool
|
||||
pub country: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub latitude: Option<f64>,
|
||||
pub longitude: Option<f64>,
|
||||
pub humanize: Option<bool>,
|
||||
pub humanize_duration: Option<f64>,
|
||||
pub headless: Option<bool>,
|
||||
pub locale: Option<Vec<String>>,
|
||||
pub addons: Option<Vec<String>>,
|
||||
pub fonts: Option<Vec<String>>,
|
||||
pub custom_fonts_only: Option<bool>,
|
||||
pub exclude_addons: Option<Vec<String>>,
|
||||
pub screen_min_width: Option<u32>,
|
||||
pub screen_max_width: Option<u32>,
|
||||
pub screen_min_height: Option<u32>,
|
||||
pub screen_max_height: Option<u32>,
|
||||
pub window_width: Option<u32>,
|
||||
pub window_height: Option<u32>,
|
||||
pub ff_version: Option<u32>,
|
||||
pub main_world_eval: Option<bool>,
|
||||
pub webgl_vendor: Option<String>,
|
||||
pub webgl_renderer: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub enable_cache: Option<bool>,
|
||||
pub virtual_display: Option<String>,
|
||||
pub debug: Option<bool>,
|
||||
pub additional_args: Option<Vec<String>>,
|
||||
pub env_vars: Option<HashMap<String, String>>,
|
||||
pub firefox_prefs: Option<HashMap<String, serde_json::Value>>,
|
||||
pub executable_path: Option<String>,
|
||||
pub fingerprint: Option<String>, // JSON string of the complete fingerprint config
|
||||
}
|
||||
|
||||
impl Default for CamoufoxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
os: None,
|
||||
proxy: None,
|
||||
screen_max_width: None,
|
||||
screen_max_height: None,
|
||||
screen_min_width: None,
|
||||
screen_min_height: None,
|
||||
geoip: Some(serde_json::Value::Bool(true)),
|
||||
block_images: None,
|
||||
block_webrtc: None,
|
||||
block_webgl: None,
|
||||
disable_coop: None,
|
||||
geoip: None,
|
||||
country: None,
|
||||
timezone: None,
|
||||
latitude: None,
|
||||
longitude: None,
|
||||
humanize: None,
|
||||
humanize_duration: None,
|
||||
headless: None,
|
||||
locale: None,
|
||||
addons: None,
|
||||
fonts: None,
|
||||
custom_fonts_only: None,
|
||||
exclude_addons: None,
|
||||
screen_min_width: None,
|
||||
screen_max_width: None,
|
||||
screen_min_height: None,
|
||||
screen_max_height: None,
|
||||
window_width: None,
|
||||
window_height: None,
|
||||
ff_version: None,
|
||||
main_world_eval: None,
|
||||
webgl_vendor: None,
|
||||
webgl_renderer: None,
|
||||
proxy: None,
|
||||
enable_cache: Some(true), // Cache enabled by default
|
||||
virtual_display: None,
|
||||
debug: None,
|
||||
additional_args: None,
|
||||
env_vars: None,
|
||||
firefox_prefs: None,
|
||||
executable_path: None,
|
||||
fingerprint: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -89,519 +44,445 @@ impl Default for CamoufoxConfig {
|
||||
#[allow(non_snake_case)]
|
||||
pub struct CamoufoxLaunchResult {
|
||||
pub id: String,
|
||||
pub pid: Option<u32>,
|
||||
#[serde(alias = "executable_path")]
|
||||
pub executablePath: String,
|
||||
#[serde(alias = "process_id")]
|
||||
pub processId: Option<u32>,
|
||||
#[serde(alias = "profile_path")]
|
||||
pub profilePath: String,
|
||||
pub profilePath: Option<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct CamoufoxLauncher {
|
||||
app_handle: AppHandle,
|
||||
#[derive(Debug)]
|
||||
struct CamoufoxInstance {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
process_id: Option<u32>,
|
||||
profile_path: Option<String>,
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
impl CamoufoxLauncher {
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
struct CamoufoxNodecarLauncherInner {
|
||||
instances: HashMap<String, CamoufoxInstance>,
|
||||
}
|
||||
|
||||
pub struct CamoufoxNodecarLauncher {
|
||||
inner: Arc<AsyncMutex<CamoufoxNodecarLauncherInner>>,
|
||||
}
|
||||
|
||||
impl CamoufoxNodecarLauncher {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(AsyncMutex::new(CamoufoxNodecarLauncherInner {
|
||||
instances: HashMap::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
/// Launch Camoufox browser with the specified configuration
|
||||
pub fn instance() -> &'static CamoufoxNodecarLauncher {
|
||||
&CAMOUFOX_NODECAR_LAUNCHER
|
||||
}
|
||||
|
||||
/// Generate Camoufox fingerprint configuration during profile creation
|
||||
pub async fn generate_fingerprint_config(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
config: &CamoufoxConfig,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut config_args = vec!["camoufox".to_string(), "generate-config".to_string()];
|
||||
|
||||
// Always ensure executable_path is set to the user's binary location
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
path.clone()
|
||||
} else {
|
||||
// Use the browser runner helper with the real profile
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
browser_runner
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
config_args.extend(["--executable-path".to_string(), executable_path]);
|
||||
|
||||
// Pass existing fingerprint if provided (for advanced form partial fingerprints)
|
||||
if let Some(fingerprint) = &config.fingerprint {
|
||||
config_args.extend(["--fingerprint".to_string(), fingerprint.clone()]);
|
||||
}
|
||||
|
||||
if let Some(serde_json::Value::Bool(true)) = &config.geoip {
|
||||
config_args.push("--geoip".to_string());
|
||||
}
|
||||
|
||||
// Add proxy if provided (can be passed directly during fingerprint generation)
|
||||
if let Some(proxy) = &config.proxy {
|
||||
config_args.extend(["--proxy".to_string(), proxy.clone()]);
|
||||
}
|
||||
|
||||
// Add screen dimensions if provided
|
||||
if let Some(max_width) = config.screen_max_width {
|
||||
config_args.extend(["--max-width".to_string(), max_width.to_string()]);
|
||||
}
|
||||
|
||||
if let Some(max_height) = config.screen_max_height {
|
||||
config_args.extend(["--max-height".to_string(), max_height.to_string()]);
|
||||
}
|
||||
|
||||
if let Some(min_width) = config.screen_min_width {
|
||||
config_args.extend(["--min-width".to_string(), min_width.to_string()]);
|
||||
}
|
||||
|
||||
if let Some(min_height) = config.screen_min_height {
|
||||
config_args.extend(["--min-height".to_string(), min_height.to_string()]);
|
||||
}
|
||||
|
||||
// Add block_* options
|
||||
if let Some(block_images) = config.block_images {
|
||||
if block_images {
|
||||
config_args.push("--block-images".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(block_webrtc) = config.block_webrtc {
|
||||
if block_webrtc {
|
||||
config_args.push("--block-webrtc".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(block_webgl) = config.block_webgl {
|
||||
if block_webgl {
|
||||
config_args.push("--block-webgl".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Execute config generation command
|
||||
let mut config_sidecar = self.get_nodecar_sidecar(app_handle)?;
|
||||
for arg in &config_args {
|
||||
config_sidecar = config_sidecar.arg(arg);
|
||||
}
|
||||
|
||||
let config_output = config_sidecar.output().await?;
|
||||
if !config_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&config_output.stderr);
|
||||
return Err(format!("Failed to generate camoufox fingerprint config: {stderr}").into());
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&config_output.stdout).to_string())
|
||||
}
|
||||
|
||||
/// Get the nodecar sidecar command
|
||||
fn get_nodecar_sidecar(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
) -> Result<tauri_plugin_shell::process::Command, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let shell = app_handle.shell();
|
||||
let sidecar_command = shell
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?;
|
||||
Ok(sidecar_command)
|
||||
}
|
||||
|
||||
/// Launch Camoufox browser using nodecar sidecar
|
||||
pub async fn launch_camoufox(
|
||||
&self,
|
||||
executable_path: &str,
|
||||
app_handle: &AppHandle,
|
||||
profile: &crate::profile::BrowserProfile,
|
||||
profile_path: &str,
|
||||
config: &CamoufoxConfig,
|
||||
url: Option<&str>,
|
||||
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Launching Camoufox with executable: {executable_path}");
|
||||
println!("Profile path: {profile_path}");
|
||||
println!("URL: {url:?}");
|
||||
let custom_config = if let Some(existing_fingerprint) = &config.fingerprint {
|
||||
println!("Using existing fingerprint from profile metadata");
|
||||
existing_fingerprint.clone()
|
||||
} else {
|
||||
return Err("No fingerprint provided".into());
|
||||
};
|
||||
|
||||
// Use Tauri's sidecar to call nodecar
|
||||
let mut sidecar = self
|
||||
.app_handle
|
||||
.shell()
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
|
||||
.arg("camoufox")
|
||||
.arg("launch")
|
||||
.arg("--executable-path")
|
||||
.arg(executable_path)
|
||||
.arg("--profile-path")
|
||||
.arg(profile_path);
|
||||
// Always ensure executable_path is set to the user's binary location
|
||||
let executable_path = if let Some(path) = &config.executable_path {
|
||||
path.clone()
|
||||
} else {
|
||||
// Use the browser runner helper with the real profile
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
browser_runner
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
|
||||
.to_string_lossy()
|
||||
.to_string()
|
||||
};
|
||||
|
||||
// Build nodecar command arguments
|
||||
let mut args = vec!["camoufox".to_string(), "start".to_string()];
|
||||
|
||||
// Add profile path - ensure it's an absolute path
|
||||
let absolute_profile_path = std::path::Path::new(profile_path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf())
|
||||
.to_string_lossy()
|
||||
.to_string();
|
||||
args.extend(["--profile-path".to_string(), absolute_profile_path]);
|
||||
|
||||
// Add URL if provided
|
||||
if let Some(url) = url {
|
||||
sidecar = sidecar.arg("--url").arg(url);
|
||||
args.extend(["--url".to_string(), url.to_string()]);
|
||||
}
|
||||
|
||||
// Add configuration options
|
||||
if let Some(os_list) = &config.os {
|
||||
sidecar = sidecar.arg("--os").arg(os_list.join(","));
|
||||
}
|
||||
// Always add the executable path
|
||||
args.extend(["--executable-path".to_string(), executable_path]);
|
||||
|
||||
if config.block_images.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--block-images");
|
||||
}
|
||||
|
||||
if config.block_webrtc.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--block-webrtc");
|
||||
}
|
||||
|
||||
if config.block_webgl.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--block-webgl");
|
||||
}
|
||||
|
||||
if config.disable_coop.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--disable-coop");
|
||||
}
|
||||
|
||||
if let Some(geoip) = &config.geoip {
|
||||
match geoip {
|
||||
serde_json::Value::String(s) => {
|
||||
sidecar = sidecar.arg("--geoip").arg(s);
|
||||
}
|
||||
serde_json::Value::Bool(b) => {
|
||||
sidecar = sidecar
|
||||
.arg("--geoip")
|
||||
.arg(if *b { "auto" } else { "false" });
|
||||
}
|
||||
_ => {
|
||||
sidecar = sidecar.arg("--geoip").arg(geoip.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(country) = &config.country {
|
||||
sidecar = sidecar.arg("--country").arg(country);
|
||||
}
|
||||
|
||||
if let Some(timezone) = &config.timezone {
|
||||
sidecar = sidecar.arg("--timezone").arg(timezone);
|
||||
}
|
||||
|
||||
if let Some(latitude) = config.latitude {
|
||||
if let Some(longitude) = config.longitude {
|
||||
sidecar = sidecar.arg("--latitude").arg(latitude.to_string());
|
||||
sidecar = sidecar.arg("--longitude").arg(longitude.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(humanize) = config.humanize {
|
||||
if humanize {
|
||||
if let Some(duration) = config.humanize_duration {
|
||||
sidecar = sidecar.arg("--humanize").arg(duration.to_string());
|
||||
} else {
|
||||
sidecar = sidecar.arg("--humanize");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.headless.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--headless");
|
||||
}
|
||||
|
||||
if let Some(locale_list) = &config.locale {
|
||||
sidecar = sidecar.arg("--locale").arg(locale_list.join(","));
|
||||
}
|
||||
|
||||
if let Some(addons_list) = &config.addons {
|
||||
sidecar = sidecar.arg("--addons").arg(addons_list.join(","));
|
||||
}
|
||||
|
||||
if let Some(fonts_list) = &config.fonts {
|
||||
sidecar = sidecar.arg("--fonts").arg(fonts_list.join(","));
|
||||
}
|
||||
|
||||
if config.custom_fonts_only.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--custom-fonts-only");
|
||||
}
|
||||
|
||||
if let Some(exclude_addons_list) = &config.exclude_addons {
|
||||
sidecar = sidecar
|
||||
.arg("--exclude-addons")
|
||||
.arg(exclude_addons_list.join(","));
|
||||
}
|
||||
|
||||
// Screen size configuration
|
||||
if let Some(width) = config.screen_min_width {
|
||||
sidecar = sidecar.arg("--screen-min-width").arg(width.to_string());
|
||||
}
|
||||
|
||||
if let Some(width) = config.screen_max_width {
|
||||
sidecar = sidecar.arg("--screen-max-width").arg(width.to_string());
|
||||
}
|
||||
|
||||
if let Some(height) = config.screen_min_height {
|
||||
sidecar = sidecar.arg("--screen-min-height").arg(height.to_string());
|
||||
}
|
||||
|
||||
if let Some(height) = config.screen_max_height {
|
||||
sidecar = sidecar.arg("--screen-max-height").arg(height.to_string());
|
||||
}
|
||||
|
||||
if let Some(width) = config.window_width {
|
||||
sidecar = sidecar.arg("--window-width").arg(width.to_string());
|
||||
}
|
||||
|
||||
if let Some(height) = config.window_height {
|
||||
sidecar = sidecar.arg("--window-height").arg(height.to_string());
|
||||
}
|
||||
|
||||
// Advanced options
|
||||
if let Some(ff_version) = config.ff_version {
|
||||
sidecar = sidecar.arg("--ff-version").arg(ff_version.to_string());
|
||||
}
|
||||
|
||||
if config.main_world_eval.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--main-world-eval");
|
||||
}
|
||||
|
||||
if let Some(vendor) = &config.webgl_vendor {
|
||||
if let Some(renderer) = &config.webgl_renderer {
|
||||
sidecar = sidecar.arg("--webgl-vendor").arg(vendor);
|
||||
sidecar = sidecar.arg("--webgl-renderer").arg(renderer);
|
||||
}
|
||||
}
|
||||
// Always add the generated custom config
|
||||
args.extend(["--custom-config".to_string(), custom_config]);
|
||||
|
||||
// Add proxy if provided
|
||||
if let Some(proxy) = &config.proxy {
|
||||
sidecar = sidecar.arg("--proxy").arg(proxy);
|
||||
args.extend(["--proxy".to_string(), proxy.clone()]);
|
||||
}
|
||||
|
||||
// Cache is enabled by default, only add flag if disabled
|
||||
if !config.enable_cache.unwrap_or(true) {
|
||||
sidecar = sidecar.arg("--disable-cache");
|
||||
// Add headless flag for tests
|
||||
if std::env::var("CAMOUFOX_HEADLESS").is_ok() {
|
||||
args.push("--headless".to_string());
|
||||
}
|
||||
|
||||
if let Some(virtual_display) = &config.virtual_display {
|
||||
sidecar = sidecar.arg("--virtual-display").arg(virtual_display);
|
||||
// Get the nodecar sidecar command
|
||||
let mut sidecar_command = self.get_nodecar_sidecar(app_handle)?;
|
||||
|
||||
// Add all arguments to the sidecar command
|
||||
for arg in &args {
|
||||
sidecar_command = sidecar_command.arg(arg);
|
||||
}
|
||||
|
||||
if config.debug.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--debug");
|
||||
}
|
||||
// Execute nodecar sidecar command
|
||||
println!("Executing nodecar command with args: {args:?}");
|
||||
let output = sidecar_command.output().await?;
|
||||
|
||||
if let Some(args) = &config.additional_args {
|
||||
sidecar = sidecar.arg("--args").arg(args.join(","));
|
||||
}
|
||||
|
||||
if let Some(env_vars) = &config.env_vars {
|
||||
let env_json = serde_json::to_string(env_vars)
|
||||
.map_err(|e| format!("Failed to serialize environment variables: {e}"))?;
|
||||
sidecar = sidecar.arg("--env").arg(env_json);
|
||||
}
|
||||
|
||||
if let Some(firefox_prefs) = &config.firefox_prefs {
|
||||
let prefs_json = serde_json::to_string(firefox_prefs)
|
||||
.map_err(|e| format!("Failed to serialize Firefox preferences: {e}"))?;
|
||||
sidecar = sidecar.arg("--firefox-prefs").arg(prefs_json);
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
println!("Executing nodecar command...");
|
||||
let output = sidecar
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute nodecar command: {e}"))?;
|
||||
|
||||
// Check the command status first
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout_msg = String::from_utf8_lossy(&output.stdout);
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to launch Camoufox: Command failed with status {:?}\nstderr: {}\nstdout: {}",
|
||||
output.status, error_msg, stdout_msg
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("nodecar camoufox failed - stdout: {stdout}, stderr: {stderr}");
|
||||
return Err(format!("nodecar camoufox failed: {stderr}").into());
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("Nodecar stdout: {stdout}");
|
||||
println!("nodecar camoufox output: {stdout}");
|
||||
|
||||
// Try to parse the JSON response
|
||||
let result: CamoufoxLaunchResult = serde_json::from_str(&stdout)
|
||||
.map_err(|e| format!("Failed to parse nodecar response as JSON: {e}\nResponse: {stdout}"))?;
|
||||
// Parse the JSON output
|
||||
let launch_result: CamoufoxLaunchResult = serde_json::from_str(&stdout)
|
||||
.map_err(|e| format!("Failed to parse nodecar output as JSON: {e}\nOutput was: {stdout}"))?;
|
||||
|
||||
println!("Successfully launched Camoufox with ID: {}", result.id);
|
||||
// Store the instance
|
||||
let instance = CamoufoxInstance {
|
||||
id: launch_result.id.clone(),
|
||||
process_id: launch_result.processId,
|
||||
profile_path: launch_result.profilePath.clone(),
|
||||
url: launch_result.url.clone(),
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.instances.insert(launch_result.id.clone(), instance);
|
||||
}
|
||||
|
||||
Ok(launch_result)
|
||||
}
|
||||
|
||||
/// Stop a Camoufox process by ID
|
||||
pub async fn stop_camoufox(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
id: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Stopping Camoufox process with ID: {id}");
|
||||
|
||||
// First, we need to find the process to get its executable and profile paths
|
||||
let processes = self.list_camoufox_processes().await?;
|
||||
let target_process = processes.iter().find(|p| p.id == id);
|
||||
|
||||
if let Some(process) = target_process {
|
||||
println!(
|
||||
"Found process to stop: executable={}, profile={}",
|
||||
process.executablePath, process.profilePath
|
||||
);
|
||||
|
||||
let sidecar = self
|
||||
.app_handle
|
||||
.shell()
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
|
||||
.arg("camoufox")
|
||||
.arg("stop")
|
||||
.arg("--executable-path")
|
||||
.arg(&process.executablePath)
|
||||
.arg("--profile-path")
|
||||
.arg(&process.profilePath)
|
||||
.arg("--id")
|
||||
.arg(id);
|
||||
|
||||
let output = sidecar
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute nodecar stop command: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout_msg = String::from_utf8_lossy(&output.stdout);
|
||||
println!("Failed to stop Camoufox process - stderr: {error_msg}, stdout: {stdout_msg}");
|
||||
return Err(format!("Failed to stop Camoufox process: {error_msg}").into());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
println!("Stop command result: {stdout}");
|
||||
|
||||
// Parse the JSON response which contains a "success" field
|
||||
let response: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.map_err(|e| format!("Failed to parse stop response as JSON: {e}\nResponse: {stdout}"))?;
|
||||
|
||||
let success = response
|
||||
.get("success")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| {
|
||||
format!("Invalid response format - missing or invalid 'success' field: {stdout}")
|
||||
})?;
|
||||
|
||||
if success {
|
||||
println!("Successfully stopped Camoufox process: {id}");
|
||||
} else {
|
||||
println!("Failed to stop Camoufox process: {id} (process may not exist)");
|
||||
}
|
||||
|
||||
Ok(success)
|
||||
} else {
|
||||
println!("Camoufox process with ID {id} not found in running processes");
|
||||
// If we can't find the process, it might already be stopped
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// List all Camoufox processes
|
||||
pub async fn list_camoufox_processes(
|
||||
&self,
|
||||
) -> Result<Vec<CamoufoxLaunchResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Listing Camoufox processes...");
|
||||
|
||||
// For the list command, we need to provide dummy executable-path and profile-path
|
||||
// even though they're not used by the list action
|
||||
let sidecar = self
|
||||
.app_handle
|
||||
.shell()
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
|
||||
// Get the nodecar sidecar command
|
||||
let sidecar_command = self
|
||||
.get_nodecar_sidecar(app_handle)?
|
||||
.arg("camoufox")
|
||||
.arg("list")
|
||||
.arg("--executable-path")
|
||||
.arg("/dummy/path") // Dummy path since list doesn't use it
|
||||
.arg("--profile-path")
|
||||
.arg("/dummy/profile"); // Dummy path since list doesn't use it
|
||||
.arg("stop")
|
||||
.arg("--id")
|
||||
.arg(id);
|
||||
|
||||
let output = sidecar
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute nodecar list command: {e}"))?;
|
||||
// Execute nodecar stop command
|
||||
let output = sidecar_command.output().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to list Camoufox processes: {error_msg}").into());
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("List command result: {stdout}");
|
||||
let result: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.map_err(|e| format!("Failed to parse nodecar stop output: {e}"))?;
|
||||
|
||||
// Parse the response as an array of process info
|
||||
let processes: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse list response: {e}"))?;
|
||||
let success = result
|
||||
.get("success")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Convert to CamoufoxLaunchResult format
|
||||
let mut results = Vec::new();
|
||||
for process in processes {
|
||||
// Handle both camelCase and snake_case formats from nodecar
|
||||
let id = process.get("id").and_then(|v| v.as_str());
|
||||
|
||||
// Try both formats for executable path
|
||||
let executable_path = process
|
||||
.get("executable_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| process.get("executablePath").and_then(|v| v.as_str()));
|
||||
|
||||
// Try both formats for profile path
|
||||
let profile_path = process
|
||||
.get("profile_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| process.get("profilePath").and_then(|v| v.as_str()));
|
||||
|
||||
if let (Some(id), Some(executable_path), Some(profile_path)) =
|
||||
(id, executable_path, profile_path)
|
||||
{
|
||||
let pid = process
|
||||
.get("pid")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
|
||||
let url = process
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
results.push(CamoufoxLaunchResult {
|
||||
id: id.to_string(),
|
||||
pid,
|
||||
executablePath: executable_path.to_string(),
|
||||
profilePath: profile_path.to_string(),
|
||||
url,
|
||||
});
|
||||
} else {
|
||||
println!("Skipping malformed process entry: {process:?}");
|
||||
}
|
||||
if success {
|
||||
// Remove from our tracking
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.instances.remove(id);
|
||||
}
|
||||
|
||||
println!("Parsed {} valid Camoufox processes", results.len());
|
||||
Ok(results)
|
||||
Ok(success)
|
||||
}
|
||||
|
||||
/// Find Camoufox process by profile path (for integration with browser_runner)
|
||||
/// Find Camoufox server by profile path (for integration with browser_runner)
|
||||
pub async fn find_camoufox_by_profile(
|
||||
&self,
|
||||
profile_path: &str,
|
||||
) -> Result<Option<CamoufoxLaunchResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Looking for Camoufox process with profile path: {profile_path}");
|
||||
// First clean up any dead instances
|
||||
self.cleanup_dead_instances().await?;
|
||||
|
||||
let processes = self.list_camoufox_processes().await?;
|
||||
println!("Found {} running Camoufox processes", processes.len());
|
||||
let inner = self.inner.lock().await;
|
||||
|
||||
for process in &processes {
|
||||
println!(
|
||||
"Checking process with profile path: {}",
|
||||
process.profilePath
|
||||
);
|
||||
}
|
||||
|
||||
// Convert both paths to canonical form for comparison
|
||||
// Convert paths to canonical form for comparison
|
||||
let target_path = std::path::Path::new(profile_path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
||||
|
||||
for process in &processes {
|
||||
println!(
|
||||
"Comparing target path: {} with process path: {}",
|
||||
target_path.display(),
|
||||
process.profilePath
|
||||
);
|
||||
for (id, instance) in inner.instances.iter() {
|
||||
if let Some(instance_profile_path) = &instance.profile_path {
|
||||
let instance_path = std::path::Path::new(instance_profile_path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(instance_profile_path).to_path_buf());
|
||||
|
||||
// Try multiple comparison methods
|
||||
let process_path = std::path::Path::new(&process.profilePath)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(&process.profilePath).to_path_buf());
|
||||
|
||||
// Method 1: Canonical path comparison
|
||||
if process_path == target_path {
|
||||
println!("Found match using canonical path comparison");
|
||||
return Ok(Some(process.clone()));
|
||||
}
|
||||
|
||||
// Method 2: Direct string comparison
|
||||
if process.profilePath == profile_path {
|
||||
println!("Found match using direct string comparison");
|
||||
return Ok(Some(process.clone()));
|
||||
}
|
||||
|
||||
// Method 3: Compare as strings after canonicalization
|
||||
if process_path.to_string_lossy() == target_path.to_string_lossy() {
|
||||
println!("Found match using canonical string comparison");
|
||||
return Ok(Some(process.clone()));
|
||||
}
|
||||
|
||||
// Method 4: Compare file names if full paths don't match
|
||||
if let (Some(process_file), Some(target_file)) =
|
||||
(process_path.file_name(), target_path.file_name())
|
||||
{
|
||||
if process_file == target_file {
|
||||
// If the parent directories also match, it's likely the same profile
|
||||
if let (Some(process_parent), Some(target_parent)) =
|
||||
(process_path.parent(), target_path.parent())
|
||||
{
|
||||
if process_parent == target_parent {
|
||||
println!("Found match using parent directory and file name comparison");
|
||||
return Ok(Some(process.clone()));
|
||||
if instance_path == target_path {
|
||||
// Verify the server is actually running by checking the process
|
||||
if let Some(process_id) = instance.process_id {
|
||||
if self.is_server_running(process_id).await {
|
||||
// Found running Camoufox instance
|
||||
return Ok(Some(CamoufoxLaunchResult {
|
||||
id: id.clone(),
|
||||
processId: instance.process_id,
|
||||
profilePath: instance.profile_path.clone(),
|
||||
url: instance.url.clone(),
|
||||
}));
|
||||
} else {
|
||||
// Camoufox instance found but process is not running
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 5: Check if either path contains the other (for symlinks or different representations)
|
||||
let process_path_str = process_path.to_string_lossy();
|
||||
let target_path_str = target_path.to_string_lossy();
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
if process_path_str.contains(target_path_str.as_ref())
|
||||
|| target_path_str.contains(process_path_str.as_ref())
|
||||
{
|
||||
println!("Found match using path containment check");
|
||||
return Ok(Some(process.clone()));
|
||||
/// Check if servers are still alive and clean up dead instances
|
||||
pub async fn cleanup_dead_instances(
|
||||
&self,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut dead_instances = Vec::new();
|
||||
let mut instances_to_remove = Vec::new();
|
||||
|
||||
{
|
||||
let inner = self.inner.lock().await;
|
||||
|
||||
for (id, instance) in inner.instances.iter() {
|
||||
if let Some(process_id) = instance.process_id {
|
||||
// Check if the process is still alive
|
||||
if !self.is_server_running(process_id).await {
|
||||
// Process is dead
|
||||
// Camoufox instance is no longer running
|
||||
dead_instances.push(id.clone());
|
||||
instances_to_remove.push(id.clone());
|
||||
}
|
||||
} else {
|
||||
// No process_id means it's likely a dead instance
|
||||
// Camoufox instance has no PID, marking as dead
|
||||
dead_instances.push(id.clone());
|
||||
instances_to_remove.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("No matching Camoufox process found for profile path: {profile_path}");
|
||||
Ok(None)
|
||||
// Remove dead instances
|
||||
if !instances_to_remove.is_empty() {
|
||||
let mut inner = self.inner.lock().await;
|
||||
for id in &instances_to_remove {
|
||||
inner.instances.remove(id);
|
||||
// Removed dead Camoufox instance
|
||||
}
|
||||
}
|
||||
|
||||
Ok(dead_instances)
|
||||
}
|
||||
|
||||
/// Check if a Camoufox server is running with the given process ID
|
||||
async fn is_server_running(&self, process_id: u32) -> bool {
|
||||
// Check if the process is still running
|
||||
use sysinfo::{Pid, System};
|
||||
|
||||
let system = System::new_all();
|
||||
if let Some(process) = system.process(Pid::from(process_id as usize)) {
|
||||
// Check if this is actually a Camoufox process by looking at the command line
|
||||
let cmd = process.cmd();
|
||||
let is_camoufox = cmd.iter().any(|arg| {
|
||||
let arg_str = arg.to_str().unwrap_or("");
|
||||
arg_str.contains("camoufox-worker") || arg_str.contains("camoufox")
|
||||
});
|
||||
|
||||
if is_camoufox {
|
||||
// Found running Camoufox process
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn launch_camoufox_profile(
|
||||
app_handle: AppHandle,
|
||||
profile: BrowserProfile,
|
||||
config: CamoufoxConfig,
|
||||
url: Option<String>,
|
||||
) -> Result<CamoufoxLaunchResult, String> {
|
||||
let launcher = CamoufoxLauncher::new(app_handle);
|
||||
impl CamoufoxNodecarLauncher {
|
||||
pub async fn launch_camoufox_profile_nodecar(
|
||||
&self,
|
||||
app_handle: AppHandle,
|
||||
profile: BrowserProfile,
|
||||
config: CamoufoxConfig,
|
||||
url: Option<String>,
|
||||
) -> Result<CamoufoxLaunchResult, String> {
|
||||
// Get profile path
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
let profiles_dir = browser_runner.get_profiles_dir();
|
||||
let profile_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_path_str = profile_path.to_string_lossy();
|
||||
|
||||
// Get the executable path for Camoufox
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::new();
|
||||
let binaries_dir = browser_runner.get_binaries_dir();
|
||||
let browser_dir = binaries_dir.join("camoufox").join(&profile.version);
|
||||
// Check if there's already a running instance for this profile
|
||||
if let Ok(Some(existing)) = self.find_camoufox_by_profile(&profile_path_str).await {
|
||||
// If there's an existing instance, stop it first to avoid conflicts
|
||||
let _ = self.stop_camoufox(&app_handle, &existing.id).await;
|
||||
}
|
||||
|
||||
// Get executable path
|
||||
let browser = crate::browser::create_browser(crate::browser::BrowserType::Camoufox);
|
||||
let executable_path = browser
|
||||
.get_executable_path(&browser_dir)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
|
||||
// Clean up any dead instances before launching
|
||||
let _ = self.cleanup_dead_instances().await;
|
||||
|
||||
// Get profile path
|
||||
let profiles_dir = browser_runner.get_profiles_dir();
|
||||
let profile_path = profile.get_profile_data_path(&profiles_dir);
|
||||
|
||||
launcher
|
||||
.launch_camoufox(
|
||||
&executable_path.to_string_lossy(),
|
||||
&profile_path.to_string_lossy(),
|
||||
&config,
|
||||
url.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch Camoufox: {e}"))
|
||||
self
|
||||
.launch_camoufox(
|
||||
&app_handle,
|
||||
&profile,
|
||||
&profile_path_str,
|
||||
&config,
|
||||
url.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch Camoufox via nodecar: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let default_config = CamoufoxConfig::default();
|
||||
|
||||
// Verify defaults
|
||||
assert_eq!(default_config.geoip, Some(serde_json::Value::Bool(true)));
|
||||
assert_eq!(default_config.proxy, None);
|
||||
assert_eq!(default_config.fingerprint, None);
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref CAMOUFOX_NODECAR_LAUNCHER: CamoufoxNodecarLauncher = CamoufoxNodecarLauncher::new();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,77 @@
|
||||
use tauri::command;
|
||||
|
||||
pub struct DefaultBrowser;
|
||||
|
||||
impl DefaultBrowser {
|
||||
fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static DefaultBrowser {
|
||||
&DEFAULT_BROWSER
|
||||
}
|
||||
|
||||
pub async fn is_default_browser(&self) -> Result<bool, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::is_default_browser();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::is_default_browser();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::is_default_browser();
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
Err("Unsupported platform".to_string())
|
||||
}
|
||||
|
||||
pub async fn set_as_default_browser(&self) -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::set_as_default_browser();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::set_as_default_browser();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::set_as_default_browser();
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
Err("Unsupported platform".to_string())
|
||||
}
|
||||
|
||||
pub async fn open_url_with_profile(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_name: String,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let runner = crate::browser_runner::BrowserRunner::instance();
|
||||
|
||||
// Get the profile by name
|
||||
let profiles = runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
|
||||
|
||||
println!("Opening URL '{url}' with profile '{profile_name}'");
|
||||
|
||||
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
|
||||
runner
|
||||
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
println!("Failed to open URL with profile '{profile_name}': {e}");
|
||||
format!("Failed to open URL with profile: {e}")
|
||||
})?;
|
||||
|
||||
println!("Successfully opened URL '{url}' with profile '{profile_name}'");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use core_foundation::base::OSStatus;
|
||||
@@ -482,34 +554,21 @@ mod linux {
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref DEFAULT_BROWSER: DefaultBrowser = DefaultBrowser::new();
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn is_default_browser() -> Result<bool, String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::is_default_browser();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::is_default_browser();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::is_default_browser();
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
Err("Unsupported platform".to_string())
|
||||
let default_browser = DefaultBrowser::instance();
|
||||
default_browser.is_default_browser().await
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub async fn set_as_default_browser() -> Result<(), String> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::set_as_default_browser();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::set_as_default_browser();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::set_as_default_browser();
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
Err("Unsupported platform".to_string())
|
||||
let default_browser = DefaultBrowser::instance();
|
||||
default_browser.set_as_default_browser().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -518,139 +577,8 @@ pub async fn open_url_with_profile(
|
||||
profile_name: String,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
|
||||
let runner = BrowserRunner::new();
|
||||
|
||||
// Get the profile by name
|
||||
let profiles = runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
|
||||
|
||||
println!("Opening URL '{url}' with profile '{profile_name}'");
|
||||
|
||||
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
|
||||
runner
|
||||
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
|
||||
let default_browser = DefaultBrowser::instance();
|
||||
default_browser
|
||||
.open_url_with_profile(app_handle, profile_name, url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
println!("Failed to open URL with profile '{profile_name}': {e}");
|
||||
format!("Failed to open URL with profile: {e}")
|
||||
})?;
|
||||
|
||||
println!("Successfully opened URL '{url}' with profile '{profile_name}'");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn smart_open_url(
|
||||
app_handle: tauri::AppHandle,
|
||||
url: String,
|
||||
_is_startup: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
|
||||
let runner = BrowserRunner::new();
|
||||
|
||||
// Get all profiles
|
||||
let profiles = runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
if profiles.is_empty() {
|
||||
return Err("no_profiles".to_string());
|
||||
}
|
||||
|
||||
println!(
|
||||
"URL opening - Total profiles: {}, checking for running profiles",
|
||||
profiles.len()
|
||||
);
|
||||
|
||||
// Check for running profiles and find the first one that can handle URLs
|
||||
for profile in &profiles {
|
||||
// Check if this profile is running
|
||||
let is_running = runner
|
||||
.check_browser_status(app_handle.clone(), profile)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
if is_running {
|
||||
println!(
|
||||
"Found running profile '{}', attempting to open URL",
|
||||
profile.name
|
||||
);
|
||||
|
||||
// For TOR browser: Check if any other TOR browser is running
|
||||
if profile.browser == "tor-browser" {
|
||||
let mut other_tor_running = false;
|
||||
for p in &profiles {
|
||||
if p.browser == "tor-browser"
|
||||
&& p.name != profile.name
|
||||
&& runner
|
||||
.check_browser_status(app_handle.clone(), p)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
other_tor_running = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if other_tor_running {
|
||||
continue; // Skip this one, can't have multiple TOR instances
|
||||
}
|
||||
}
|
||||
|
||||
// For Mullvad browser: Check if any other Mullvad browser is running
|
||||
if profile.browser == "mullvad-browser" {
|
||||
let mut other_mullvad_running = false;
|
||||
for p in &profiles {
|
||||
if p.browser == "mullvad-browser"
|
||||
&& p.name != profile.name
|
||||
&& runner
|
||||
.check_browser_status(app_handle.clone(), p)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
other_mullvad_running = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if other_mullvad_running {
|
||||
continue; // Skip this one, can't have multiple Mullvad instances
|
||||
}
|
||||
}
|
||||
|
||||
// Try to open the URL with this running profile
|
||||
match runner
|
||||
.launch_or_open_url(app_handle.clone(), profile, Some(url.clone()), None)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
println!(
|
||||
"Successfully opened URL '{}' with running profile '{}'",
|
||||
url, profile.name
|
||||
);
|
||||
return Ok(format!("opened_with_profile:{}", profile.name));
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Failed to open URL with running profile '{}': {}",
|
||||
profile.name, e
|
||||
);
|
||||
// Continue to try other profiles or show selector
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("No suitable running profiles found, showing profile selector");
|
||||
|
||||
// No suitable running profile found, show the profile selector
|
||||
Err("show_selector".to_string())
|
||||
}
|
||||
|
||||
+115
-382
@@ -1,13 +1,12 @@
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs::File;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::browser::BrowserType;
|
||||
use crate::browser_version_service::DownloadInfo;
|
||||
use crate::browser_version_manager::DownloadInfo;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DownloadProgress {
|
||||
@@ -23,22 +22,26 @@ pub struct DownloadProgress {
|
||||
|
||||
pub struct Downloader {
|
||||
client: Client,
|
||||
api_client: ApiClient,
|
||||
api_client: &'static ApiClient,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
pub fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_client: ApiClient::new(),
|
||||
api_client: ApiClient::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static Downloader {
|
||||
&DOWNLOADER
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_with_api_client(api_client: ApiClient) -> Self {
|
||||
pub fn new_with_api_client(_api_client: ApiClient) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_client,
|
||||
api_client: ApiClient::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -393,43 +396,104 @@ impl Downloader {
|
||||
let is_twilight =
|
||||
browser_type == BrowserType::Zen && version.to_lowercase().contains("twilight");
|
||||
|
||||
// Emit initial progress
|
||||
// Determine if we have a partial file to resume
|
||||
let mut existing_size: u64 = 0;
|
||||
if let Ok(meta) = std::fs::metadata(&file_path) {
|
||||
existing_size = meta.len();
|
||||
}
|
||||
|
||||
// Build request, add Range only if we have bytes
|
||||
let mut request = self
|
||||
.client
|
||||
.get(&download_url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
);
|
||||
|
||||
if existing_size > 0 {
|
||||
request = request.header("Range", format!("bytes={existing_size}-"));
|
||||
}
|
||||
|
||||
// Start download (or resume)
|
||||
let response = request.send().await?;
|
||||
|
||||
// Check if the response is successful
|
||||
if !(response.status().is_success() || response.status().as_u16() == 206) {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
// Determine total size
|
||||
let mut total_size = response.content_length();
|
||||
|
||||
// If resuming (206) and Content-Range is present, parse total
|
||||
if response.status().as_u16() == 206 {
|
||||
if let Some(content_range) = response.headers().get(reqwest::header::CONTENT_RANGE) {
|
||||
if let Ok(cr) = content_range.to_str() {
|
||||
// Format: bytes start-end/total
|
||||
if let Some((_, total_str)) = cr.split('/').collect::<Vec<_>>().split_first() {
|
||||
if let Some(total_str) = total_str.first() {
|
||||
if let Ok(total) = total_str.parse::<u64>() {
|
||||
total_size = Some(total);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(len) = response.headers().get(reqwest::header::CONTENT_LENGTH) {
|
||||
// Fallback: total = existing + incoming length
|
||||
if let Ok(len_str) = len.to_str() {
|
||||
if let Ok(incoming) = len_str.parse::<u64>() {
|
||||
total_size = Some(existing_size + incoming);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if existing_size > 0 && response.status().is_success() {
|
||||
// Server ignored range or we asked from 0; if 200 and existing file has content, start fresh
|
||||
// Truncate existing file so we don't append duplicate bytes
|
||||
let _ = std::fs::remove_file(&file_path);
|
||||
existing_size = 0;
|
||||
}
|
||||
|
||||
let mut downloaded = existing_size;
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut last_update = start_time;
|
||||
|
||||
// Emit initial progress AFTER we've established total size and resume state
|
||||
let initial_percentage = if let Some(total) = total_size {
|
||||
if total > 0 {
|
||||
(existing_size as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let initial_stage = if is_twilight {
|
||||
"downloading (twilight rolling release)".to_string()
|
||||
} else {
|
||||
"downloading".to_string()
|
||||
};
|
||||
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_type.as_str().to_string(),
|
||||
version: version.to_string(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 0.0,
|
||||
downloaded_bytes: existing_size,
|
||||
total_bytes: total_size,
|
||||
percentage: initial_percentage,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: if is_twilight {
|
||||
"downloading (twilight rolling release)".to_string()
|
||||
} else {
|
||||
"downloading".to_string()
|
||||
},
|
||||
stage: initial_stage,
|
||||
};
|
||||
|
||||
let _ = app_handle.emit("download-progress", &progress);
|
||||
|
||||
// Start download
|
||||
let response = self
|
||||
.client
|
||||
.get(&download_url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
// Check if the response is successful
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let total_size = response.content_length();
|
||||
let mut downloaded = 0u64;
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut last_update = start_time;
|
||||
|
||||
let mut file = File::create(&file_path)?;
|
||||
// Open file in append mode (resuming) or create new
|
||||
use std::fs::OpenOptions;
|
||||
let mut file = OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&file_path)?;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
@@ -442,13 +506,19 @@ impl Downloader {
|
||||
// Update progress every 100ms to avoid too many events
|
||||
if now.duration_since(last_update).as_millis() >= 100 {
|
||||
let elapsed = start_time.elapsed().as_secs_f64();
|
||||
// Compute speed based only on bytes downloaded in this session to avoid inflated values when resuming
|
||||
let downloaded_since_start = downloaded.saturating_sub(existing_size);
|
||||
let speed = if elapsed > 0.0 {
|
||||
downloaded as f64 / elapsed
|
||||
downloaded_since_start as f64 / elapsed
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let percentage = if let Some(total) = total_size {
|
||||
(downloaded as f64 / total as f64) * 100.0
|
||||
if total > 0 {
|
||||
(downloaded as f64 / total as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
@@ -489,10 +559,10 @@ mod tests {
|
||||
use super::*;
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::browser::BrowserType;
|
||||
use crate::browser_version_service::DownloadInfo;
|
||||
use crate::browser_version_manager::DownloadInfo;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::{method, path, query_param};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
async fn setup_mock_server() -> MockServer {
|
||||
@@ -510,153 +580,10 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_brave_download_url() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.9",
|
||||
"name": "Brave Release 1.81.9",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.9-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
|
||||
"size": 200000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(query_param("per_page", "100"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Brave, "v1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/brave-1.81.9-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_zen_download_url() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "1.11b",
|
||||
"name": "Zen Browser 1.11b",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "zen.macos-universal.dmg",
|
||||
"browser_download_url": "https://example.com/zen-1.11b-universal.dmg",
|
||||
"size": 120000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/zen-browser/desktop/releases"))
|
||||
.and(query_param("per_page", "100"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "zen-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Zen, "1.11b", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/zen-1.11b-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_mullvad_download_url() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "14.5a6",
|
||||
"name": "Mullvad Browser 14.5a6",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-macos-14.5a6.dmg",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg",
|
||||
"size": 100000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(query_param("per_page", "100"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "mullvad-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::MullvadBrowser, "14.5a6", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/mullvad-14.5a6.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_firefox_download_url() {
|
||||
let server = setup_mock_server().await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
@@ -717,106 +644,6 @@ mod tests {
|
||||
assert_eq!(url, download_info.url);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_brave_version_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.8",
|
||||
"name": "Brave Release 1.81.8",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.8-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg",
|
||||
"size": 200000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(query_param("per_page", "100"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Brave, "v1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Brave version v1.81.9 not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_zen_asset_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "1.11b",
|
||||
"name": "Zen Browser 1.11b",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "zen.linux-universal.tar.bz2",
|
||||
"browser_download_url": "https://example.com/zen-1.11b-linux.tar.bz2",
|
||||
"size": 150000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/zen-browser/desktop/releases"))
|
||||
.and(query_param("per_page", "100"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "zen-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Zen, "1.11b", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No compatible asset found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_with_progress() {
|
||||
let server = setup_mock_server().await;
|
||||
@@ -909,105 +736,6 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_mullvad_asset_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "14.5a6",
|
||||
"name": "Mullvad Browser 14.5a6",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-linux-14.5a6.tar.xz",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a6.tar.xz",
|
||||
"size": 80000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(query_param("per_page", "100"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "mullvad-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::MullvadBrowser, "14.5a6", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No compatible asset found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_brave_version_with_v_prefix() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.9",
|
||||
"name": "Brave Release 1.81.9",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.9-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg",
|
||||
"size": 200000000
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(query_param("per_page", "100"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
// Test with version without v prefix
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Brave, "1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/brave-1.81.9-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_chunked_response() {
|
||||
let server = setup_mock_server().await;
|
||||
@@ -1058,3 +786,8 @@ mod tests {
|
||||
assert_eq!(downloaded_content.len(), test_content.len());
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref DOWNLOADER: Downloader = Downloader::new();
|
||||
}
|
||||
|
||||
@@ -3,40 +3,48 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DownloadedBrowserInfo {
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
pub download_date: u64,
|
||||
pub file_path: PathBuf,
|
||||
pub verified: bool,
|
||||
pub actual_version: Option<String>, // For browsers like Chromium where we track the actual version
|
||||
pub file_size: Option<u64>, // For tracking file size changes (useful for rolling releases)
|
||||
#[serde(default)] // Add default value (false) for backwards compatibility
|
||||
pub is_rolling_release: bool, // True for Zen's twilight releases and other rolling releases
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
pub struct DownloadedBrowsersRegistry {
|
||||
struct RegistryData {
|
||||
pub browsers: HashMap<String, HashMap<String, DownloadedBrowserInfo>>, // browser -> version -> info
|
||||
}
|
||||
|
||||
pub struct DownloadedBrowsersRegistry {
|
||||
data: Mutex<RegistryData>,
|
||||
}
|
||||
|
||||
impl DownloadedBrowsersRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
data: Mutex::new(RegistryData::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
pub fn instance() -> &'static DownloadedBrowsersRegistry {
|
||||
&DOWNLOADED_BROWSERS_REGISTRY
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let registry_path = Self::get_registry_path()?;
|
||||
|
||||
if !registry_path.exists() {
|
||||
return Ok(Self::new());
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(®istry_path)?;
|
||||
let registry: DownloadedBrowsersRegistry = serde_json::from_str(&content)?;
|
||||
Ok(registry)
|
||||
let registry_data: RegistryData = serde_json::from_str(&content)?;
|
||||
|
||||
let mut data = self.data.lock().unwrap();
|
||||
*data = registry_data;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -47,7 +55,8 @@ impl DownloadedBrowsersRegistry {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let content = serde_json::to_string_pretty(self)?;
|
||||
let data = self.data.lock().unwrap();
|
||||
let content = serde_json::to_string_pretty(&*data)?;
|
||||
fs::write(®istry_path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -65,146 +74,157 @@ impl DownloadedBrowsersRegistry {
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn add_browser(&mut self, info: DownloadedBrowserInfo) {
|
||||
self
|
||||
pub fn add_browser(&self, info: DownloadedBrowserInfo) {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
data
|
||||
.browsers
|
||||
.entry(info.browser.clone())
|
||||
.or_default()
|
||||
.insert(info.version.clone(), info);
|
||||
}
|
||||
|
||||
pub fn remove_browser(&mut self, browser: &str, version: &str) -> Option<DownloadedBrowserInfo> {
|
||||
self.browsers.get_mut(browser)?.remove(version)
|
||||
pub fn remove_browser(&self, browser: &str, version: &str) -> Option<DownloadedBrowserInfo> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
data.browsers.get_mut(browser)?.remove(version)
|
||||
}
|
||||
|
||||
pub fn is_browser_downloaded(&self, browser: &str, version: &str) -> bool {
|
||||
self
|
||||
let data = self.data.lock().unwrap();
|
||||
data
|
||||
.browsers
|
||||
.get(browser)
|
||||
.and_then(|versions| versions.get(version))
|
||||
.map(|info| info.verified)
|
||||
.unwrap_or(false)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn get_downloaded_versions(&self, browser: &str) -> Vec<String> {
|
||||
self
|
||||
let data = self.data.lock().unwrap();
|
||||
data
|
||||
.browsers
|
||||
.get(browser)
|
||||
.map(|versions| {
|
||||
versions
|
||||
.iter()
|
||||
.filter(|(_, info)| info.verified)
|
||||
.map(|(version, _)| version.clone())
|
||||
.collect()
|
||||
})
|
||||
.map(|versions| versions.keys().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn mark_download_started(&mut self, browser: &str, version: &str, file_path: PathBuf) {
|
||||
let is_rolling = Self::is_rolling_release(browser, version);
|
||||
pub fn mark_download_started(&self, browser: &str, version: &str, file_path: PathBuf) {
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: browser.to_string(),
|
||||
version: version.to_string(),
|
||||
download_date: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
file_path,
|
||||
verified: false,
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: is_rolling,
|
||||
};
|
||||
self.add_browser(info);
|
||||
}
|
||||
|
||||
pub fn mark_download_completed_with_actual_version(
|
||||
&mut self,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
actual_version: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(info) = self
|
||||
pub fn mark_download_completed(&self, browser: &str, version: &str) -> Result<(), String> {
|
||||
let data = self.data.lock().unwrap();
|
||||
if data
|
||||
.browsers
|
||||
.get_mut(browser)
|
||||
.and_then(|versions| versions.get_mut(version))
|
||||
.get(browser)
|
||||
.and_then(|versions| versions.get(version))
|
||||
.is_some()
|
||||
{
|
||||
info.verified = true;
|
||||
info.actual_version = actual_version;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Browser {browser}:{version} not found in registry"))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_rolling_release(browser: &str, version: &str) -> bool {
|
||||
// Check if this is a rolling release like twilight
|
||||
browser == "zen" && version.to_lowercase() == "twilight"
|
||||
}
|
||||
|
||||
pub fn cleanup_failed_download(
|
||||
&mut self,
|
||||
&self,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if let Some(info) = self.remove_browser(browser, version) {
|
||||
// Clean up any files that might have been left behind
|
||||
// Clean up extracted binaries but preserve downloaded archives
|
||||
if info.file_path.exists() {
|
||||
if info.file_path.is_dir() {
|
||||
fs::remove_dir_all(&info.file_path)?;
|
||||
// Allowed archive extensions to preserve
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
|
||||
for entry in fs::read_dir(&info.file_path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(&path)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For files, preserve if they look like downloaded archives/installers
|
||||
let keep = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|name| {
|
||||
// Match suffixes (handles multi-part extensions like .tar.xz)
|
||||
archive_exts
|
||||
.iter()
|
||||
.any(|ext| name.to_lowercase().ends_with(&ext.to_lowercase()))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if !keep {
|
||||
fs::remove_file(&path)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
fs::remove_file(&info.file_path)?;
|
||||
// It's a file. If it's not an archive, remove it; otherwise preserve it.
|
||||
let file_name = info
|
||||
.file_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
let is_archive = archive_exts
|
||||
.iter()
|
||||
.any(|ext| file_name.to_lowercase().ends_with(&ext.to_lowercase()));
|
||||
if !is_archive {
|
||||
fs::remove_file(&info.file_path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Also clean up the browser directory if it exists
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let mut browser_dir = base_dirs.data_local_dir().to_path_buf();
|
||||
browser_dir.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
browser_dir.push("binaries");
|
||||
browser_dir.push(browser);
|
||||
browser_dir.push(version);
|
||||
|
||||
if browser_dir.exists() {
|
||||
fs::remove_dir_all(&browser_dir)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find and remove unused browser binaries that are not referenced by any active profiles
|
||||
pub fn cleanup_unused_binaries(
|
||||
&mut self,
|
||||
&self,
|
||||
active_profiles: &[(String, String)], // (browser, version) pairs
|
||||
running_profiles: &[(String, String)], // (browser, version) pairs for running profiles
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let active_set: std::collections::HashSet<(String, String)> =
|
||||
active_profiles.iter().cloned().collect();
|
||||
let running_set: std::collections::HashSet<(String, String)> =
|
||||
running_profiles.iter().cloned().collect();
|
||||
let mut cleaned_up = Vec::new();
|
||||
|
||||
// Collect all downloaded browsers that are not in active profiles
|
||||
let mut to_remove = Vec::new();
|
||||
for (browser, versions) in &self.browsers {
|
||||
for (version, info) in versions {
|
||||
// Only remove verified downloads that are not used by any active profile
|
||||
if info.verified && !active_set.contains(&(browser.clone(), version.clone())) {
|
||||
// Double-check that this browser+version is truly not in use
|
||||
// by looking for exact matches in the active profiles
|
||||
let is_in_use = active_profiles
|
||||
.iter()
|
||||
.any(|(active_browser, active_version)| {
|
||||
active_browser == browser && active_version == version
|
||||
});
|
||||
{
|
||||
let data = self.data.lock().unwrap();
|
||||
for (browser, versions) in &data.browsers {
|
||||
for version in versions.keys() {
|
||||
let browser_version = (browser.clone(), version.clone());
|
||||
|
||||
if !is_in_use {
|
||||
to_remove.push((browser.clone(), version.clone()));
|
||||
println!("Marking for removal: {browser} {version} (not used by any profile)");
|
||||
} else {
|
||||
// Don't remove if it's used by any active profile
|
||||
if active_set.contains(&browser_version) {
|
||||
println!("Keeping: {browser} {version} (in use by profile)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't remove if it's currently running (even if not in active profiles)
|
||||
if running_set.contains(&browser_version) {
|
||||
println!("Keeping: {browser} {version} (currently running)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark for removal
|
||||
to_remove.push(browser_version);
|
||||
println!("Marking for removal: {browser} {version} (not used by any profile)");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,7 +251,7 @@ impl DownloadedBrowsersRegistry {
|
||||
/// Get all browsers and versions referenced by active profiles
|
||||
pub fn get_active_browser_versions(
|
||||
&self,
|
||||
profiles: &[crate::browser_runner::BrowserProfile],
|
||||
profiles: &[crate::profile::BrowserProfile],
|
||||
) -> Vec<(String, String)> {
|
||||
profiles
|
||||
.iter()
|
||||
@@ -241,22 +261,25 @@ impl DownloadedBrowsersRegistry {
|
||||
|
||||
/// Verify that all registered browsers actually exist on disk and clean up stale entries
|
||||
pub fn verify_and_cleanup_stale_entries(
|
||||
&mut self,
|
||||
&self,
|
||||
browser_runner: &crate::browser_runner::BrowserRunner,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use crate::browser::{create_browser, BrowserType};
|
||||
let mut cleaned_up = Vec::new();
|
||||
let binaries_dir = browser_runner.get_binaries_dir();
|
||||
|
||||
let browsers_to_check: Vec<(String, String)> = self
|
||||
.browsers
|
||||
.iter()
|
||||
.flat_map(|(browser, versions)| {
|
||||
versions
|
||||
.keys()
|
||||
.map(|version| (browser.clone(), version.clone()))
|
||||
})
|
||||
.collect();
|
||||
let browsers_to_check: Vec<(String, String)> = {
|
||||
let data = self.data.lock().unwrap();
|
||||
data
|
||||
.browsers
|
||||
.iter()
|
||||
.flat_map(|(browser, versions)| {
|
||||
versions
|
||||
.keys()
|
||||
.map(|version| (browser.clone(), version.clone()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
for (browser_str, version) in browsers_to_check {
|
||||
if let Ok(browser_type) = BrowserType::from_str(&browser_str) {
|
||||
@@ -277,6 +300,163 @@ impl DownloadedBrowsersRegistry {
|
||||
|
||||
Ok(cleaned_up)
|
||||
}
|
||||
|
||||
/// Get all browsers and versions that are currently running
|
||||
pub fn get_running_browser_versions(
|
||||
&self,
|
||||
profiles: &[crate::profile::BrowserProfile],
|
||||
) -> Vec<(String, String)> {
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.process_id.is_some())
|
||||
.map(|profile| (profile.browser.clone(), profile.version.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Scan the binaries directory and sync with registry
|
||||
/// This ensures the registry reflects what's actually on disk
|
||||
pub fn sync_with_binaries_directory(
|
||||
&self,
|
||||
binaries_dir: &std::path::Path,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut changes = Vec::new();
|
||||
|
||||
if !binaries_dir.exists() {
|
||||
return Ok(changes);
|
||||
}
|
||||
|
||||
// Scan for actual browser directories
|
||||
for browser_entry in fs::read_dir(binaries_dir)? {
|
||||
let browser_entry = browser_entry?;
|
||||
let browser_path = browser_entry.path();
|
||||
|
||||
if !browser_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let browser_name = browser_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if browser_name.is_empty() || browser_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan for version directories within this browser
|
||||
for version_entry in fs::read_dir(&browser_path)? {
|
||||
let version_entry = version_entry?;
|
||||
let version_path = version_entry.path();
|
||||
|
||||
if !version_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let version_name = version_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if version_name.is_empty() || version_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only add to registry if this looks like a valid installed browser, not just an archive
|
||||
if !self.is_browser_downloaded(browser_name, version_name) {
|
||||
if let Ok(browser_type) = crate::browser::BrowserType::from_str(browser_name) {
|
||||
let browser = crate::browser::create_browser(browser_type);
|
||||
if browser.is_version_downloaded(version_name, binaries_dir) {
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: browser_name.to_string(),
|
||||
version: version_name.to_string(),
|
||||
file_path: version_path.clone(),
|
||||
};
|
||||
self.add_browser(info);
|
||||
changes.push(format!("Added {browser_name} {version_name} to registry"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changes.is_empty() {
|
||||
self.save()?;
|
||||
}
|
||||
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// Comprehensive cleanup that removes unused binaries and syncs registry
|
||||
pub fn comprehensive_cleanup(
|
||||
&self,
|
||||
binaries_dir: &std::path::Path,
|
||||
active_profiles: &[(String, String)],
|
||||
running_profiles: &[(String, String)],
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cleanup_results = Vec::new();
|
||||
|
||||
// First, sync registry with actual binaries on disk
|
||||
let sync_results = self.sync_with_binaries_directory(binaries_dir)?;
|
||||
cleanup_results.extend(sync_results);
|
||||
|
||||
// Then perform the regular cleanup
|
||||
let regular_cleanup = self.cleanup_unused_binaries(active_profiles, running_profiles)?;
|
||||
cleanup_results.extend(regular_cleanup);
|
||||
|
||||
// Finally, verify and cleanup stale entries
|
||||
let stale_cleanup = self.verify_and_cleanup_stale_entries_simple(binaries_dir)?;
|
||||
cleanup_results.extend(stale_cleanup);
|
||||
|
||||
if !cleanup_results.is_empty() {
|
||||
self.save()?;
|
||||
}
|
||||
|
||||
Ok(cleanup_results)
|
||||
}
|
||||
|
||||
/// Simplified version of verify_and_cleanup_stale_entries that doesn't need BrowserRunner
|
||||
pub fn verify_and_cleanup_stale_entries_simple(
|
||||
&self,
|
||||
binaries_dir: &std::path::Path,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cleaned_up = Vec::new();
|
||||
let mut browsers_to_remove = Vec::new();
|
||||
|
||||
{
|
||||
let data = self.data.lock().unwrap();
|
||||
for (browser_str, versions) in &data.browsers {
|
||||
for version in versions.keys() {
|
||||
// Check if the browser directory actually exists
|
||||
let browser_dir = binaries_dir.join(browser_str).join(version);
|
||||
if !browser_dir.exists() {
|
||||
browsers_to_remove.push((browser_str.clone(), version.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale entries
|
||||
for (browser_str, version) in browsers_to_remove {
|
||||
if let Some(_removed) = self.remove_browser(&browser_str, &version) {
|
||||
cleaned_up.push(format!(
|
||||
"Removed stale registry entry for {browser_str} {version}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cleaned_up)
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref DOWNLOADED_BROWSERS_REGISTRY: DownloadedBrowsersRegistry = {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
if let Err(e) = registry.load() {
|
||||
eprintln!("Warning: Failed to load downloaded browsers registry: {e}");
|
||||
}
|
||||
registry
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -286,21 +466,17 @@ mod tests {
|
||||
#[test]
|
||||
fn test_registry_creation() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
assert!(registry.browsers.is_empty());
|
||||
let data = registry.data.lock().unwrap();
|
||||
assert!(data.browsers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_and_get_browser() {
|
||||
let mut registry = DownloadedBrowsersRegistry::new();
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
download_date: 1234567890,
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: false,
|
||||
};
|
||||
|
||||
registry.add_browser(info.clone());
|
||||
@@ -312,39 +488,24 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_get_downloaded_versions() {
|
||||
let mut registry = DownloadedBrowsersRegistry::new();
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
let info1 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
download_date: 1234567890,
|
||||
file_path: PathBuf::from("/test/path1"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: false,
|
||||
};
|
||||
|
||||
let info2 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "140.0".to_string(),
|
||||
download_date: 1234567891,
|
||||
file_path: PathBuf::from("/test/path2"),
|
||||
verified: false, // Not verified, should not be included
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: false,
|
||||
};
|
||||
|
||||
let info3 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "141.0".to_string(),
|
||||
download_date: 1234567892,
|
||||
file_path: PathBuf::from("/test/path3"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: false,
|
||||
};
|
||||
|
||||
registry.add_browser(info1);
|
||||
@@ -352,63 +513,74 @@ mod tests {
|
||||
registry.add_browser(info3);
|
||||
|
||||
let versions = registry.get_downloaded_versions("firefox");
|
||||
assert_eq!(versions.len(), 2);
|
||||
assert_eq!(versions.len(), 3);
|
||||
assert!(versions.contains(&"139.0".to_string()));
|
||||
assert!(versions.contains(&"140.0".to_string()));
|
||||
assert!(versions.contains(&"141.0".to_string()));
|
||||
assert!(!versions.contains(&"140.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_download_lifecycle() {
|
||||
let mut registry = DownloadedBrowsersRegistry::new();
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
// Mark download started
|
||||
registry.mark_download_started("firefox", "139.0", PathBuf::from("/test/path"));
|
||||
|
||||
// Should not be considered downloaded yet
|
||||
assert!(!registry.is_browser_downloaded("firefox", "139.0"));
|
||||
// Should be considered downloaded immediately
|
||||
assert!(
|
||||
registry.is_browser_downloaded("firefox", "139.0"),
|
||||
"Browser should be considered downloaded after marking as started"
|
||||
);
|
||||
|
||||
// Mark as completed
|
||||
registry
|
||||
.mark_download_completed_with_actual_version("firefox", "139.0", Some("139.0".to_string()))
|
||||
.unwrap();
|
||||
.mark_download_completed("firefox", "139.0")
|
||||
.expect("Failed to mark download as completed");
|
||||
|
||||
// Now should be considered downloaded
|
||||
assert!(registry.is_browser_downloaded("firefox", "139.0"));
|
||||
// Should still be considered downloaded
|
||||
assert!(
|
||||
registry.is_browser_downloaded("firefox", "139.0"),
|
||||
"Browser should still be considered downloaded after completion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_browser() {
|
||||
let mut registry = DownloadedBrowsersRegistry::new();
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
download_date: 1234567890,
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: false,
|
||||
};
|
||||
|
||||
registry.add_browser(info);
|
||||
assert!(registry.is_browser_downloaded("firefox", "139.0"));
|
||||
assert!(
|
||||
registry.is_browser_downloaded("firefox", "139.0"),
|
||||
"Browser should be downloaded after adding"
|
||||
);
|
||||
|
||||
let removed = registry.remove_browser("firefox", "139.0");
|
||||
assert!(removed.is_some());
|
||||
assert!(!registry.is_browser_downloaded("firefox", "139.0"));
|
||||
assert!(
|
||||
removed.is_some(),
|
||||
"Remove operation should return the removed browser info"
|
||||
);
|
||||
assert!(
|
||||
!registry.is_browser_downloaded("firefox", "139.0"),
|
||||
"Browser should not be downloaded after removal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_twilight_rolling_release() {
|
||||
let mut registry = DownloadedBrowsersRegistry::new();
|
||||
fn test_twilight_download() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
// Mark twilight download started
|
||||
registry.mark_download_started("zen", "twilight", PathBuf::from("/test/zen-twilight"));
|
||||
|
||||
// Check that it's marked as rolling release
|
||||
let zen_versions = ®istry.browsers["zen"];
|
||||
let twilight_info = &zen_versions["twilight"];
|
||||
assert!(twilight_info.is_rolling_release);
|
||||
// Check that it's registered
|
||||
assert!(
|
||||
registry.is_browser_downloaded("zen", "twilight"),
|
||||
"Zen twilight version should be registered as downloaded"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+805
-749
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,11 @@ pub struct GeoIPDownloadProgress {
|
||||
pub stage: String, // "downloading", "extracting", "completed"
|
||||
pub percentage: f64,
|
||||
pub message: String,
|
||||
// Extra fields to mirror browser download progress payload
|
||||
pub downloaded_bytes: Option<u64>,
|
||||
pub total_bytes: Option<u64>,
|
||||
pub speed_bytes_per_sec: Option<f64>,
|
||||
pub eta_seconds: Option<f64>,
|
||||
}
|
||||
|
||||
pub struct GeoIPDownloader {
|
||||
@@ -21,12 +26,22 @@ pub struct GeoIPDownloader {
|
||||
}
|
||||
|
||||
impl GeoIPDownloader {
|
||||
pub fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static GeoIPDownloader {
|
||||
&GEOIP_DOWNLOADER
|
||||
}
|
||||
|
||||
/// Create a new downloader with custom client (for testing)
|
||||
#[cfg(test)]
|
||||
pub fn new_with_client(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to determine base directories")?;
|
||||
|
||||
@@ -81,6 +96,10 @@ impl GeoIPDownloader {
|
||||
stage: "downloading".to_string(),
|
||||
percentage: 0.0,
|
||||
message: "Starting GeoIP database download".to_string(),
|
||||
downloaded_bytes: Some(0),
|
||||
total_bytes: None,
|
||||
speed_bytes_per_sec: Some(0.0),
|
||||
eta_seconds: None,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -112,26 +131,51 @@ impl GeoIPDownloader {
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
let mut downloaded = 0;
|
||||
let mut downloaded: u64 = 0;
|
||||
let mut file = fs::File::create(&mmdb_path).await?;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use std::time::Instant;
|
||||
let start_time = Instant::now();
|
||||
let mut last_update = Instant::now();
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
downloaded += chunk.len() as u64;
|
||||
file.write_all(&chunk).await?;
|
||||
|
||||
if total_size > 0 {
|
||||
let percentage = (downloaded as f64 / total_size as f64) * 100.0;
|
||||
let now = Instant::now();
|
||||
if now.duration_since(last_update).as_millis() >= 100 {
|
||||
let elapsed = start_time.elapsed().as_secs_f64();
|
||||
let speed = if elapsed > 0.0 {
|
||||
downloaded as f64 / elapsed
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let percentage = if total_size > 0 {
|
||||
(downloaded as f64 / total_size as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let eta = if speed > 0.0 && total_size > 0 {
|
||||
Some((total_size.saturating_sub(downloaded)) as f64 / speed)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let _ = app_handle.emit(
|
||||
"geoip-download-progress",
|
||||
GeoIPDownloadProgress {
|
||||
stage: "downloading".to_string(),
|
||||
percentage,
|
||||
message: format!("Downloaded {downloaded} / {total_size} bytes"),
|
||||
downloaded_bytes: Some(downloaded),
|
||||
total_bytes: Some(total_size),
|
||||
speed_bytes_per_sec: Some(speed),
|
||||
eta_seconds: eta,
|
||||
},
|
||||
);
|
||||
last_update = now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,6 +188,10 @@ impl GeoIPDownloader {
|
||||
stage: "completed".to_string(),
|
||||
percentage: 100.0,
|
||||
message: "GeoIP database download completed".to_string(),
|
||||
downloaded_bytes: Some(downloaded),
|
||||
total_bytes: Some(total_size),
|
||||
speed_bytes_per_sec: Some(0.0),
|
||||
eta_seconds: Some(0.0),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -169,3 +217,144 @@ impl GeoIPDownloader {
|
||||
Ok(releases)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::browser::GithubRelease;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn create_mock_release() -> GithubRelease {
|
||||
GithubRelease {
|
||||
tag_name: "v1.0.0".to_string(),
|
||||
name: "Test Release".to_string(),
|
||||
body: Some("Test release body".to_string()),
|
||||
published_at: "2023-01-01T00:00:00Z".to_string(),
|
||||
created_at: Some("2023-01-01T00:00:00Z".to_string()),
|
||||
html_url: Some("https://example.com/release".to_string()),
|
||||
tarball_url: Some("https://example.com/tarball".to_string()),
|
||||
zipball_url: Some("https://example.com/zipball".to_string()),
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
is_nightly: false,
|
||||
id: Some(1),
|
||||
node_id: Some("test_node_id".to_string()),
|
||||
target_commitish: None,
|
||||
assets: vec![crate::browser::GithubAsset {
|
||||
id: Some(1),
|
||||
node_id: Some("test_asset_node_id".to_string()),
|
||||
name: "GeoLite2-City.mmdb".to_string(),
|
||||
label: None,
|
||||
content_type: Some("application/octet-stream".to_string()),
|
||||
state: Some("uploaded".to_string()),
|
||||
size: 1024,
|
||||
download_count: Some(0),
|
||||
created_at: Some("2023-01-01T00:00:00Z".to_string()),
|
||||
updated_at: Some("2023-01-01T00:00:00Z".to_string()),
|
||||
browser_download_url: "https://example.com/GeoLite2-City.mmdb".to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_geoip_releases_success() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let releases = vec![create_mock_release()];
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/repos/{MMDB_REPO}/releases")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&releases))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let client = Client::builder()
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
let downloader = GeoIPDownloader::new_with_client(client);
|
||||
|
||||
// Override the URL for testing
|
||||
let url = format!("{}/repos/{}/releases", mock_server.uri(), MMDB_REPO);
|
||||
let response = downloader
|
||||
.client
|
||||
.get(&url)
|
||||
.header("User-Agent", "Mozilla/5.0 (compatible; donutbrowser)")
|
||||
.send()
|
||||
.await
|
||||
.expect("Request should succeed");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
|
||||
let fetched_releases: Vec<GithubRelease> = response.json().await.expect("Should parse JSON");
|
||||
assert_eq!(fetched_releases.len(), 1);
|
||||
assert_eq!(fetched_releases[0].tag_name, "v1.0.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_city_mmdb_asset() {
|
||||
let downloader = GeoIPDownloader::new();
|
||||
let release = create_mock_release();
|
||||
|
||||
let asset_url = downloader.find_city_mmdb_asset(&release);
|
||||
assert!(asset_url.is_some());
|
||||
assert_eq!(asset_url.unwrap(), "https://example.com/GeoLite2-City.mmdb");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_city_mmdb_asset_not_found() {
|
||||
let downloader = GeoIPDownloader::new();
|
||||
let mut release = create_mock_release();
|
||||
release.assets[0].name = "wrong-file.txt".to_string();
|
||||
|
||||
let asset_url = downloader.find_city_mmdb_asset(&release);
|
||||
assert!(asset_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_cache_dir() {
|
||||
let cache_dir = GeoIPDownloader::get_cache_dir();
|
||||
assert!(cache_dir.is_ok());
|
||||
|
||||
let path = cache_dir.unwrap();
|
||||
assert!(path.to_string_lossy().contains("camoufox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_mmdb_file_path() {
|
||||
let mmdb_path = GeoIPDownloader::get_mmdb_file_path();
|
||||
assert!(mmdb_path.is_ok());
|
||||
|
||||
let path = mmdb_path.unwrap();
|
||||
assert!(path.to_string_lossy().ends_with("GeoLite2-City.mmdb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_geoip_database_available() {
|
||||
// Test that the function works correctly regardless of file system state
|
||||
let is_available = GeoIPDownloader::is_geoip_database_available();
|
||||
|
||||
// The function should return a boolean value (either true or false)
|
||||
// The function should return a boolean value - we just verify it doesn't panic
|
||||
// and returns the expected result based on file existence
|
||||
|
||||
// Verify the function logic by checking if the path resolution works
|
||||
let mmdb_path_result = GeoIPDownloader::get_mmdb_file_path();
|
||||
assert!(
|
||||
mmdb_path_result.is_ok(),
|
||||
"Should be able to get MMDB file path"
|
||||
);
|
||||
|
||||
let mmdb_path = mmdb_path_result.unwrap();
|
||||
let expected_available = mmdb_path.exists();
|
||||
assert_eq!(
|
||||
is_available, expected_available,
|
||||
"Function result should match actual file existence"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref GEOIP_DOWNLOADER: GeoIPDownloader = GeoIPDownloader::new();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GroupWithCount {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct GroupsData {
|
||||
groups: Vec<ProfileGroup>,
|
||||
}
|
||||
|
||||
pub struct GroupManager {
|
||||
base_dirs: BaseDirs,
|
||||
data_dir_override: Option<PathBuf>,
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
// 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 load_groups_data(&self) -> Result<GroupsData, Box<dyn std::error::Error>> {
|
||||
let groups_file = self.get_groups_file_path();
|
||||
|
||||
if !groups_file.exists() {
|
||||
return Ok(GroupsData { groups: Vec::new() });
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(groups_file)?;
|
||||
let groups_data: GroupsData = serde_json::from_str(&content)?;
|
||||
Ok(groups_data)
|
||||
}
|
||||
|
||||
fn save_groups_data(&self, groups_data: &GroupsData) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let groups_file = self.get_groups_file_path();
|
||||
|
||||
// Ensure the parent directory exists
|
||||
if let Some(parent) = groups_file.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(groups_data)?;
|
||||
fs::write(groups_file, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_groups(&self) -> Result<Vec<ProfileGroup>, Box<dyn std::error::Error>> {
|
||||
let groups_data = self.load_groups_data()?;
|
||||
Ok(groups_data.groups)
|
||||
}
|
||||
|
||||
pub fn create_group(&self, name: String) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
// Check if group with this name already exists
|
||||
if groups_data.groups.iter().any(|g| g.name == name) {
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
}
|
||||
|
||||
let group = ProfileGroup {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
};
|
||||
|
||||
groups_data.groups.push(group.clone());
|
||||
self.save_groups_data(&groups_data)?;
|
||||
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
pub fn update_group(
|
||||
&self,
|
||||
id: String,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
// Check if another group with this name already exists
|
||||
if groups_data
|
||||
.groups
|
||||
.iter()
|
||||
.any(|g| g.name == name && g.id != id)
|
||||
{
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
}
|
||||
|
||||
let group = groups_data
|
||||
.groups
|
||||
.iter_mut()
|
||||
.find(|g| g.id == id)
|
||||
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
|
||||
|
||||
group.name = name;
|
||||
let updated_group = group.clone();
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
Ok(updated_group)
|
||||
}
|
||||
|
||||
pub fn delete_group(&self, id: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
let initial_len = groups_data.groups.len();
|
||||
groups_data.groups.retain(|g| g.id != id);
|
||||
|
||||
if groups_data.groups.len() == initial_len {
|
||||
return Err(format!("Group with id '{id}' not found").into());
|
||||
}
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_groups_with_profile_counts(
|
||||
&self,
|
||||
profiles: &[crate::profile::BrowserProfile],
|
||||
) -> Result<Vec<GroupWithCount>, Box<dyn std::error::Error>> {
|
||||
let groups = self.get_all_groups()?;
|
||||
let mut group_counts = HashMap::new();
|
||||
|
||||
// Count profiles in each group
|
||||
for profile in profiles {
|
||||
if let Some(group_id) = &profile.group_id {
|
||||
*group_counts.entry(group_id.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Create result including all groups (even those with 0 count)
|
||||
let mut result = Vec::new();
|
||||
for group in groups {
|
||||
let count = group_counts.get(&group.id).copied().unwrap_or(0);
|
||||
result.push(GroupWithCount {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
count,
|
||||
});
|
||||
}
|
||||
|
||||
// Add default group count (profiles without group_id), always include even if 0
|
||||
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
|
||||
let default_group = GroupWithCount {
|
||||
id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
count: default_count,
|
||||
};
|
||||
// Insert at the beginning for consistent ordering with UI expectations
|
||||
result.insert(0, default_group);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref GROUP_MANAGER: Mutex<GroupManager> = Mutex::new(GroupManager::new());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_group_manager() -> (GroupManager, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
// Set up a temporary home directory for testing
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
|
||||
// Use per-test isolated data directory without relying on global env vars
|
||||
let data_override = temp_dir.path().join("donutbrowser_test_data");
|
||||
let manager = GroupManager::with_data_dir_override(&data_override);
|
||||
(manager, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_manager_creation() {
|
||||
let (_manager, _temp_dir) = create_test_group_manager();
|
||||
// Test passes if no panic occurs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_get_groups() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Initially should have no groups
|
||||
let groups = manager
|
||||
.get_all_groups()
|
||||
.expect("Should be able to get groups");
|
||||
assert!(groups.is_empty(), "Should start with no groups");
|
||||
|
||||
// Create a group
|
||||
let group_name = "Test Group".to_string();
|
||||
let created_group = manager
|
||||
.create_group(group_name.clone())
|
||||
.expect("Should create group successfully");
|
||||
|
||||
assert_eq!(
|
||||
created_group.name, group_name,
|
||||
"Created group should have correct name"
|
||||
);
|
||||
assert!(
|
||||
!created_group.id.is_empty(),
|
||||
"Created group should have an ID"
|
||||
);
|
||||
|
||||
// Verify group was saved
|
||||
let groups = manager
|
||||
.get_all_groups()
|
||||
.expect("Should be able to get groups");
|
||||
assert_eq!(groups.len(), 1, "Should have one group");
|
||||
assert_eq!(
|
||||
groups[0].name, group_name,
|
||||
"Retrieved group should have correct name"
|
||||
);
|
||||
assert_eq!(
|
||||
groups[0].id, created_group.id,
|
||||
"Retrieved group should have correct ID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_duplicate_group_fails() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
let group_name = "Duplicate Group".to_string();
|
||||
|
||||
// Create first group
|
||||
let _first_group = manager
|
||||
.create_group(group_name.clone())
|
||||
.expect("Should create first group");
|
||||
|
||||
// Try to create duplicate group
|
||||
let result = manager.create_group(group_name.clone());
|
||||
assert!(result.is_err(), "Should fail to create duplicate group");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("already exists"),
|
||||
"Error should mention group already exists"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_group() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Create a group
|
||||
let original_name = "Original Name".to_string();
|
||||
let created_group = manager
|
||||
.create_group(original_name)
|
||||
.expect("Should create group");
|
||||
|
||||
// Update the group
|
||||
let new_name = "Updated Name".to_string();
|
||||
let updated_group = manager
|
||||
.update_group(created_group.id.clone(), new_name.clone())
|
||||
.expect("Should update group successfully");
|
||||
|
||||
assert_eq!(
|
||||
updated_group.name, new_name,
|
||||
"Updated group should have new name"
|
||||
);
|
||||
assert_eq!(
|
||||
updated_group.id, created_group.id,
|
||||
"Updated group should keep same ID"
|
||||
);
|
||||
|
||||
// Verify update was persisted
|
||||
let groups = manager.get_all_groups().expect("Should get groups");
|
||||
assert_eq!(groups.len(), 1, "Should still have one group");
|
||||
assert_eq!(
|
||||
groups[0].name, new_name,
|
||||
"Persisted group should have updated name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_nonexistent_group_fails() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
let result = manager.update_group("nonexistent-id".to_string(), "New Name".to_string());
|
||||
assert!(result.is_err(), "Should fail to update nonexistent group");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("not found"),
|
||||
"Error should mention group not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_group() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Create a group
|
||||
let group_name = "To Delete".to_string();
|
||||
let created_group = manager
|
||||
.create_group(group_name)
|
||||
.expect("Should create group");
|
||||
|
||||
// Verify group exists
|
||||
let groups = manager.get_all_groups().expect("Should get groups");
|
||||
assert_eq!(groups.len(), 1, "Should have one group");
|
||||
|
||||
// Delete the group
|
||||
manager
|
||||
.delete_group(created_group.id)
|
||||
.expect("Should delete group successfully");
|
||||
|
||||
// Verify group was deleted
|
||||
let groups = manager.get_all_groups().expect("Should get groups");
|
||||
assert!(groups.is_empty(), "Should have no groups after deletion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_group_fails() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
let result = manager.delete_group("nonexistent-id".to_string());
|
||||
assert!(result.is_err(), "Should fail to delete nonexistent group");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("not found"),
|
||||
"Error should mention group not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_groups_with_profile_counts() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Create test groups
|
||||
let group1 = manager
|
||||
.create_group("Group 1".to_string())
|
||||
.expect("Should create group 1");
|
||||
let _group2 = manager
|
||||
.create_group("Group 2".to_string())
|
||||
.expect("Should create group 2");
|
||||
|
||||
// Create mock profiles
|
||||
let profiles = vec![
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "Profile 1".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: Some(group1.id.clone()),
|
||||
},
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "Profile 2".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: Some(group1.id.clone()),
|
||||
},
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "Profile 3".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None, // Default group
|
||||
},
|
||||
];
|
||||
|
||||
let groups_with_counts = manager
|
||||
.get_groups_with_profile_counts(&profiles)
|
||||
.expect("Should get groups with counts");
|
||||
|
||||
// Should have default group + group1 + group2 (group2 has 0 profiles but should still appear)
|
||||
assert_eq!(
|
||||
groups_with_counts.len(),
|
||||
3,
|
||||
"Should include all groups, even those with 0 profiles"
|
||||
);
|
||||
|
||||
// Check default group
|
||||
let default_group = groups_with_counts
|
||||
.iter()
|
||||
.find(|g| g.id == "default")
|
||||
.expect("Should have default group");
|
||||
assert_eq!(
|
||||
default_group.count, 1,
|
||||
"Default group should have 1 profile"
|
||||
);
|
||||
|
||||
// Check group1
|
||||
let group1_with_count = groups_with_counts
|
||||
.iter()
|
||||
.find(|g| g.id == group1.id)
|
||||
.expect("Should have group1");
|
||||
assert_eq!(group1_with_count.count, 2, "Group1 should have 2 profiles");
|
||||
|
||||
// Check that group2 exists with 0 profiles
|
||||
let group2_with_count = groups_with_counts
|
||||
.iter()
|
||||
.find(|g| g.name == "Group 2")
|
||||
.expect("Should have group2 present even with 0 profiles");
|
||||
assert_eq!(group2_with_count.count, 0, "Group2 should have 0 profiles");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get groups with counts
|
||||
pub fn get_groups_with_counts(profiles: &[crate::profile::BrowserProfile]) -> Vec<GroupWithCount> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.get_groups_with_profile_counts(profiles)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_groups() -> Result<Vec<ProfileGroup>, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.get_all_groups()
|
||||
.map_err(|e| format!("Failed to get profile groups: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_groups_with_profile_counts() -> Result<Vec<GroupWithCount>, String> {
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
Ok(get_groups_with_counts(&profiles))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_profile_group(name: String) -> Result<ProfileGroup, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.create_group(name)
|
||||
.map_err(|e| format!("Failed to create group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_profile_group(group_id: String, name: String) -> Result<ProfileGroup, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.update_group(group_id, name)
|
||||
.map_err(|e| format!("Failed to update group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_profile_group(group_id: String) -> Result<(), String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.delete_group(group_id)
|
||||
.map_err(|e| format!("Failed to delete group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assign_profiles_to_group(
|
||||
profile_names: Vec<String>,
|
||||
group_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
profile_manager
|
||||
.assign_profiles_to_group(profile_names, group_id)
|
||||
.map_err(|e| format!("Failed to assign profiles to group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_selected_profiles(profile_names: Vec<String>) -> Result<(), String> {
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
profile_manager
|
||||
.delete_multiple_profiles(profile_names)
|
||||
.map_err(|e| format!("Failed to delete profiles: {e}"))
|
||||
}
|
||||
+190
-93
@@ -12,30 +12,32 @@ mod app_auto_updater;
|
||||
mod auto_updater;
|
||||
mod browser;
|
||||
mod browser_runner;
|
||||
mod browser_version_service;
|
||||
mod browser_version_manager;
|
||||
mod camoufox;
|
||||
mod default_browser;
|
||||
mod download;
|
||||
mod downloaded_browsers;
|
||||
mod extraction;
|
||||
mod geoip_downloader;
|
||||
|
||||
mod group_manager;
|
||||
mod platform_browser;
|
||||
mod profile;
|
||||
mod profile_importer;
|
||||
mod proxy_manager;
|
||||
mod settings_manager;
|
||||
mod system_utils;
|
||||
mod theme_detector;
|
||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||
mod version_updater;
|
||||
|
||||
extern crate lazy_static;
|
||||
|
||||
use browser_runner::{
|
||||
check_browser_exists, check_browser_status, check_missing_binaries, create_browser_profile_new,
|
||||
delete_profile, download_browser, ensure_all_binaries_exist, fetch_browser_versions_cached_first,
|
||||
fetch_browser_versions_with_count, fetch_browser_versions_with_count_cached_first,
|
||||
get_browser_release_types, get_downloaded_browser_versions, get_supported_browsers,
|
||||
is_browser_supported_on_platform, kill_browser_profile, launch_browser_profile,
|
||||
list_browser_profiles, rename_profile, update_profile_proxy, update_profile_version,
|
||||
check_browser_exists, check_browser_status, check_missing_binaries, check_missing_geoip_database,
|
||||
create_browser_profile_new, delete_profile, download_browser, ensure_all_binaries_exist,
|
||||
fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
|
||||
fetch_browser_versions_with_count_cached_first, get_downloaded_browser_versions,
|
||||
get_supported_browsers, is_browser_supported_on_platform, kill_browser_profile,
|
||||
launch_browser_profile, list_browser_profiles, rename_profile, update_camoufox_config,
|
||||
update_profile_proxy,
|
||||
};
|
||||
|
||||
use settings_manager::{
|
||||
@@ -43,9 +45,7 @@ use settings_manager::{
|
||||
save_app_settings, save_table_sorting_settings, should_show_settings_on_startup,
|
||||
};
|
||||
|
||||
use default_browser::{
|
||||
is_default_browser, open_url_with_profile, set_as_default_browser, smart_open_url,
|
||||
};
|
||||
use default_browser::{is_default_browser, open_url_with_profile, set_as_default_browser};
|
||||
|
||||
use version_updater::{
|
||||
get_version_update_status, get_version_updater, trigger_manual_version_update,
|
||||
@@ -62,9 +62,16 @@ use app_auto_updater::{
|
||||
|
||||
use profile_importer::{detect_existing_profiles, import_browser_profile};
|
||||
|
||||
use theme_detector::get_system_theme;
|
||||
// use theme_detector::get_system_theme;
|
||||
|
||||
use system_utils::{get_system_locale, get_system_timezone};
|
||||
use group_manager::{
|
||||
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
|
||||
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
|
||||
};
|
||||
|
||||
use geoip_downloader::GeoIPDownloader;
|
||||
|
||||
use browser_version_manager::get_browser_release_types;
|
||||
|
||||
// Trait to extend WebviewWindow with transparent titlebar functionality
|
||||
pub trait WindowExt {
|
||||
@@ -111,6 +118,35 @@ impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn warm_up_nodecar(app: tauri::AppHandle) -> Result<(), String> {
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Use sidecar to execute a fast, harmless command that ensures the binary is loaded
|
||||
let cmd = app
|
||||
.shell()
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
|
||||
.arg("help");
|
||||
|
||||
let exec_future = async { cmd.output().await };
|
||||
match timeout(Duration::from_secs(120), exec_future).await {
|
||||
Ok(Ok(_output)) => {
|
||||
let duration = start_time.elapsed();
|
||||
println!(
|
||||
"Nodecar warm-up (frontend-triggered) completed in {:.2}s",
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
Ok(Err(e)) => Err(format!("Failed to execute nodecar for warm-up: {e}")),
|
||||
Err(_) => Err("Nodecar warm-up timed out after 120s".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), String> {
|
||||
println!("handle_url_open called with URL: {url}");
|
||||
@@ -137,49 +173,6 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bool, String> {
|
||||
println!("check_and_handle_startup_url called");
|
||||
|
||||
let pending_urls = {
|
||||
let mut pending = PENDING_URLS.lock().unwrap();
|
||||
let urls = pending.clone();
|
||||
pending.clear(); // Clear after getting them
|
||||
urls
|
||||
};
|
||||
|
||||
println!("Found {} pending URLs", pending_urls.len());
|
||||
|
||||
if !pending_urls.is_empty() {
|
||||
println!(
|
||||
"Handling {} pending URLs from frontend request",
|
||||
pending_urls.len()
|
||||
);
|
||||
|
||||
// Ensure the main window is visible and focused
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
|
||||
// Give the window a moment to become visible
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
for url in pending_urls {
|
||||
println!("Emitting show-profile-selector event for URL: {url}");
|
||||
if let Err(e) = app_handle.emit("show-profile-selector", url.clone()) {
|
||||
eprintln!("Failed to emit URL event: {e}");
|
||||
return Err(format!("Failed to emit URL event: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_stored_proxy(
|
||||
name: String,
|
||||
@@ -214,14 +207,17 @@ async fn delete_stored_proxy(proxy_id: String) -> Result<(), String> {
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_camoufox_config(
|
||||
profile_name: String,
|
||||
config: crate::camoufox::CamoufoxConfig,
|
||||
) -> Result<(), String> {
|
||||
let browser_runner = browser_runner::BrowserRunner::new();
|
||||
browser_runner
|
||||
.update_camoufox_config(&profile_name, config)
|
||||
.map_err(|e| format!("Failed to update Camoufox config: {e}"))
|
||||
async fn is_geoip_database_available() -> Result<bool, String> {
|
||||
Ok(GeoIPDownloader::is_geoip_database_available())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||
let downloader = GeoIPDownloader::instance();
|
||||
downloader
|
||||
.download_geoip_database(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download GeoIP database: {e}"))
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@@ -268,26 +264,6 @@ pub fn run() {
|
||||
}
|
||||
}
|
||||
|
||||
// Migrate profiles to UUID format if needed (async)
|
||||
println!("Checking for profile migration...");
|
||||
let browser_runner = browser_runner::BrowserRunner::new();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match browser_runner.migrate_profiles_to_uuid().await {
|
||||
Ok(migrated) => {
|
||||
if !migrated.is_empty() {
|
||||
println!(
|
||||
"Successfully migrated {} profiles: {:?}",
|
||||
migrated.len(),
|
||||
migrated
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Warning: Failed to migrate profiles: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Set up deep link handler
|
||||
let handle = app.handle().clone();
|
||||
|
||||
@@ -374,10 +350,47 @@ pub fn run() {
|
||||
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
|
||||
});
|
||||
|
||||
// Handle any pending URLs that were received before the window was ready
|
||||
let handle_pending = handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Wait a bit for the window to be fully ready
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
|
||||
let pending_urls = {
|
||||
let mut pending = PENDING_URLS.lock().unwrap();
|
||||
let urls = pending.clone();
|
||||
pending.clear();
|
||||
urls
|
||||
};
|
||||
|
||||
for url in pending_urls {
|
||||
println!("Processing pending URL: {url}");
|
||||
if let Err(e) = handle_url_open(handle_pending.clone(), url).await {
|
||||
eprintln!("Failed to handle pending URL: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start periodic cleanup task for unused binaries
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(43200)); // Every 12 hours
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
if let Err(e) = browser_runner.cleanup_unused_binaries_internal() {
|
||||
eprintln!("Periodic cleanup failed: {e}");
|
||||
} else {
|
||||
println!("Periodic cleanup completed successfully");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let app_handle_update = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Starting app update check at startup...");
|
||||
let updater = app_auto_updater::AppAutoUpdater::new();
|
||||
let updater = app_auto_updater::AppAutoUpdater::instance();
|
||||
match updater.check_for_updates().await {
|
||||
Ok(Some(update_info)) => {
|
||||
println!(
|
||||
@@ -400,6 +413,84 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
// Start Camoufox cleanup task
|
||||
let _app_handle_cleanup = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let launcher = crate::camoufox::CamoufoxNodecarLauncher::instance();
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match launcher.cleanup_dead_instances().await {
|
||||
Ok(_dead_instances) => {
|
||||
// Cleanup completed silently
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error during Camoufox cleanup: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Check and download GeoIP database at startup if needed
|
||||
let app_handle_geoip = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Wait a bit for the app to fully initialize
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
match browser_runner.check_missing_geoip_database() {
|
||||
Ok(true) => {
|
||||
println!("GeoIP database is missing for Camoufox profiles, downloading at startup...");
|
||||
let geoip_downloader = GeoIPDownloader::instance();
|
||||
if let Err(e) = geoip_downloader
|
||||
.download_geoip_database(&app_handle_geoip)
|
||||
.await
|
||||
{
|
||||
eprintln!("Failed to download GeoIP database at startup: {e}");
|
||||
} else {
|
||||
println!("GeoIP database downloaded successfully at startup");
|
||||
}
|
||||
}
|
||||
Ok(false) => {
|
||||
// No Camoufox profiles or GeoIP database already available
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to check GeoIP database status at startup: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start proxy cleanup task for dead browser processes
|
||||
let app_handle_proxy_cleanup = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(30));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match crate::proxy_manager::PROXY_MANAGER
|
||||
.cleanup_dead_proxies(app_handle_proxy_cleanup.clone())
|
||||
.await
|
||||
{
|
||||
Ok(dead_pids) => {
|
||||
if !dead_pids.is_empty() {
|
||||
println!(
|
||||
"Cleaned up proxies for {} dead browser processes",
|
||||
dead_pids.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error during proxy cleanup: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Nodecar warm-up is now triggered from the frontend to allow UI blocking overlay
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -417,7 +508,6 @@ pub fn run() {
|
||||
get_downloaded_browser_versions,
|
||||
get_browser_release_types,
|
||||
update_profile_proxy,
|
||||
update_profile_version,
|
||||
check_browser_status,
|
||||
kill_browser_profile,
|
||||
rename_profile,
|
||||
@@ -430,8 +520,6 @@ pub fn run() {
|
||||
is_default_browser,
|
||||
open_url_with_profile,
|
||||
set_as_default_browser,
|
||||
smart_open_url,
|
||||
check_and_handle_startup_url,
|
||||
trigger_manual_version_update,
|
||||
get_version_update_status,
|
||||
check_for_browser_updates,
|
||||
@@ -441,18 +529,27 @@ pub fn run() {
|
||||
check_for_app_updates,
|
||||
check_for_app_updates_manual,
|
||||
download_and_install_app_update,
|
||||
get_system_theme,
|
||||
// get_system_theme, // removed
|
||||
detect_existing_profiles,
|
||||
import_browser_profile,
|
||||
check_missing_binaries,
|
||||
check_missing_geoip_database,
|
||||
ensure_all_binaries_exist,
|
||||
create_stored_proxy,
|
||||
get_stored_proxies,
|
||||
update_stored_proxy,
|
||||
delete_stored_proxy,
|
||||
update_camoufox_config,
|
||||
get_system_locale,
|
||||
get_system_timezone,
|
||||
get_profile_groups,
|
||||
get_groups_with_profile_counts,
|
||||
create_profile_group,
|
||||
update_profile_group,
|
||||
delete_profile_group,
|
||||
assign_profiles_to_group,
|
||||
delete_selected_profiles,
|
||||
is_geoip_database_available,
|
||||
download_geoip_database,
|
||||
warm_up_nodecar,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
pub mod manager;
|
||||
pub mod types;
|
||||
|
||||
pub use manager::ProfileManager;
|
||||
pub use types::BrowserProfile;
|
||||
@@ -0,0 +1,34 @@
|
||||
use crate::camoufox::CamoufoxConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BrowserProfile {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub proxy_id: Option<String>, // Reference to stored proxy
|
||||
#[serde(default)]
|
||||
pub process_id: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub last_launch: Option<u64>,
|
||||
#[serde(default = "default_release_type")]
|
||||
pub release_type: String, // "stable" or "nightly"
|
||||
#[serde(default)]
|
||||
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
|
||||
#[serde(default)]
|
||||
pub group_id: Option<String>, // Reference to profile group
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
"stable".to_string()
|
||||
}
|
||||
|
||||
impl BrowserProfile {
|
||||
/// Get the path to the profile data directory (profiles/{uuid}/profile)
|
||||
pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf {
|
||||
profiles_dir.join(self.id.to_string()).join("profile")
|
||||
}
|
||||
}
|
||||
+228
-156
@@ -17,17 +17,19 @@ pub struct DetectedProfile {
|
||||
|
||||
pub struct ProfileImporter {
|
||||
base_dirs: BaseDirs,
|
||||
browser_runner: BrowserRunner,
|
||||
}
|
||||
|
||||
impl ProfileImporter {
|
||||
pub fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
browser_runner: BrowserRunner::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static ProfileImporter {
|
||||
&PROFILE_IMPORTER
|
||||
}
|
||||
|
||||
/// Detect existing browser profiles on the system
|
||||
pub fn detect_existing_profiles(
|
||||
&self,
|
||||
@@ -49,14 +51,11 @@ impl ProfileImporter {
|
||||
// Detect Chromium profiles
|
||||
detected_profiles.extend(self.detect_chromium_profiles()?);
|
||||
|
||||
// Detect Mullvad Browser profiles
|
||||
detected_profiles.extend(self.detect_mullvad_browser_profiles()?);
|
||||
|
||||
// Detect Zen Browser profiles
|
||||
detected_profiles.extend(self.detect_zen_browser_profiles()?);
|
||||
|
||||
// Detect TOR Browser profiles
|
||||
detected_profiles.extend(self.detect_tor_browser_profiles()?);
|
||||
// NOTE: Mullvad and Tor Browser profile imports are no longer supported.
|
||||
// We intentionally do not detect these profiles to avoid offering them in the UI.
|
||||
|
||||
// Remove duplicates based on path
|
||||
let mut seen_paths = HashSet::new();
|
||||
@@ -240,45 +239,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Mullvad Browser profiles
|
||||
fn detect_mullvad_browser_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mullvad_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/MullvadBrowser/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Primary location in AppData\Roaming
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let mullvad_dir = app_data.join("MullvadBrowser/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
|
||||
|
||||
// Also check common installation locations
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let mullvad_local_dir = local_app_data.join("MullvadBrowser/Profiles");
|
||||
if mullvad_local_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_local_dir, "mullvad-browser")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mullvad_dir = self.base_dirs.home_dir().join(".mullvad-browser");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Zen Browser profiles
|
||||
fn detect_zen_browser_profiles(
|
||||
&self,
|
||||
@@ -310,107 +270,6 @@ impl ProfileImporter {
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect TOR Browser profiles
|
||||
fn detect_tor_browser_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// TOR Browser on macOS is typically in Applications
|
||||
let tor_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/TorBrowser-Data/Browser/profile.default");
|
||||
|
||||
if tor_dir.exists() {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: "tor-browser".to_string(),
|
||||
name: "TOR Browser - Default Profile".to_string(),
|
||||
path: tor_dir.to_string_lossy().to_string(),
|
||||
description: "Default TOR Browser profile".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Check common TOR Browser installation locations on Windows
|
||||
let possible_paths = [
|
||||
// Default installation in user directory
|
||||
(
|
||||
"Desktop",
|
||||
"Desktop/Tor Browser/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
),
|
||||
// AppData locations
|
||||
(
|
||||
"AppData/Roaming",
|
||||
"TorBrowser/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
),
|
||||
(
|
||||
"AppData/Local",
|
||||
"TorBrowser/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
),
|
||||
];
|
||||
|
||||
let home_dir = self.base_dirs.home_dir();
|
||||
|
||||
for (location_name, relative_path) in &possible_paths {
|
||||
let tor_dir = home_dir.join(relative_path);
|
||||
if tor_dir.exists() {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: "tor-browser".to_string(),
|
||||
name: format!("TOR Browser - {} Profile", location_name),
|
||||
path: tor_dir.to_string_lossy().to_string(),
|
||||
description: format!("TOR Browser profile from {}", location_name),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Also check AppData directories if available
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let tor_app_data =
|
||||
app_data.join("TorBrowser/Browser/TorBrowser/Data/Browser/profile.default");
|
||||
if tor_app_data.exists() {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: "tor-browser".to_string(),
|
||||
name: "TOR Browser - AppData Profile".to_string(),
|
||||
path: tor_app_data.to_string_lossy().to_string(),
|
||||
description: "TOR Browser profile from AppData".to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Common TOR Browser locations on Linux
|
||||
let possible_paths = [
|
||||
".local/share/torbrowser/tbb/x86_64/tor-browser_en-US/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
"tor-browser_en-US/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
".tor-browser/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
"Downloads/tor-browser_en-US/Browser/TorBrowser/Data/Browser/profile.default",
|
||||
];
|
||||
|
||||
let home_dir = self.base_dirs.home_dir();
|
||||
|
||||
for relative_path in &possible_paths {
|
||||
let tor_dir = home_dir.join(relative_path);
|
||||
if tor_dir.exists() {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: "tor-browser".to_string(),
|
||||
name: "TOR Browser - Default Profile".to_string(),
|
||||
path: tor_dir.to_string_lossy().to_string(),
|
||||
description: "TOR Browser profile".to_string(),
|
||||
});
|
||||
break; // Only add the first one found to avoid duplicates
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Scan Firefox-style profiles directory
|
||||
fn scan_firefox_profiles_dir(
|
||||
&self,
|
||||
@@ -645,6 +504,11 @@ impl ProfileImporter {
|
||||
browser_type: &str,
|
||||
new_profile_name: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Disable imports for Mullvad and Tor browsers
|
||||
if browser_type == "mullvad-browser" || browser_type == "tor-browser" {
|
||||
return Err("Importing Mullvad Browser or Tor Browser profiles is not supported".into());
|
||||
}
|
||||
|
||||
// Validate that source path exists
|
||||
let source_path = Path::new(source_path);
|
||||
if !source_path.exists() {
|
||||
@@ -656,7 +520,7 @@ impl ProfileImporter {
|
||||
.map_err(|_| format!("Invalid browser type: {browser_type}"))?;
|
||||
|
||||
// Check if a profile with this name already exists
|
||||
let existing_profiles = self.browser_runner.list_profiles()?;
|
||||
let existing_profiles = BrowserRunner::instance().list_profiles()?;
|
||||
if existing_profiles
|
||||
.iter()
|
||||
.any(|p| p.name.to_lowercase() == new_profile_name.to_lowercase())
|
||||
@@ -666,7 +530,7 @@ impl ProfileImporter {
|
||||
|
||||
// Generate UUID for new profile and create the directory structure
|
||||
let profile_id = uuid::Uuid::new_v4();
|
||||
let profiles_dir = self.browser_runner.get_profiles_dir();
|
||||
let profiles_dir = BrowserRunner::instance().get_profiles_dir();
|
||||
let new_profile_uuid_dir = profiles_dir.join(profile_id.to_string());
|
||||
let new_profile_data_dir = new_profile_uuid_dir.join("profile");
|
||||
|
||||
@@ -680,7 +544,7 @@ impl ProfileImporter {
|
||||
// We need to find a suitable version for this browser type
|
||||
let available_versions = self.get_default_version_for_browser(browser_type)?;
|
||||
|
||||
let profile = crate::browser_runner::BrowserProfile {
|
||||
let profile = crate::profile::BrowserProfile {
|
||||
id: profile_id,
|
||||
name: new_profile_name.to_string(),
|
||||
browser: browser_type.to_string(),
|
||||
@@ -690,10 +554,11 @@ impl ProfileImporter {
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None,
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
self.browser_runner.save_profile(&profile)?;
|
||||
BrowserRunner::instance().save_profile(&profile)?;
|
||||
|
||||
println!(
|
||||
"Successfully imported profile '{}' from '{}'",
|
||||
@@ -710,8 +575,7 @@ impl ProfileImporter {
|
||||
browser_type: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Check if any version of the browser is downloaded
|
||||
let registry =
|
||||
crate::downloaded_browsers::DownloadedBrowsersRegistry::load().unwrap_or_default();
|
||||
let registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::instance();
|
||||
let downloaded_versions = registry.get_downloaded_versions(browser_type);
|
||||
|
||||
if let Some(version) = downloaded_versions.first() {
|
||||
@@ -754,7 +618,7 @@ impl ProfileImporter {
|
||||
// Tauri commands
|
||||
#[tauri::command]
|
||||
pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String> {
|
||||
let importer = ProfileImporter::new();
|
||||
let importer = ProfileImporter::instance();
|
||||
importer
|
||||
.detect_existing_profiles()
|
||||
.map_err(|e| format!("Failed to detect existing profiles: {e}"))
|
||||
@@ -766,8 +630,216 @@ pub async fn import_browser_profile(
|
||||
browser_type: String,
|
||||
new_profile_name: String,
|
||||
) -> Result<(), String> {
|
||||
let importer = ProfileImporter::new();
|
||||
let importer = ProfileImporter::instance();
|
||||
importer
|
||||
.import_profile(&source_path, &browser_type, &new_profile_name)
|
||||
.map_err(|e| format!("Failed to import profile: {e}"))
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref PROFILE_IMPORTER: ProfileImporter = ProfileImporter::new();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_profile_importer() -> (ProfileImporter, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
// Set up a temporary home directory for testing
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
|
||||
let importer = ProfileImporter::new();
|
||||
(importer, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_profile_importer_creation() {
|
||||
let (_importer, _temp_dir) = create_test_profile_importer();
|
||||
// Test passes if no panic occurs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_browser_display_name() {
|
||||
let (importer, _temp_dir) = create_test_profile_importer();
|
||||
|
||||
assert_eq!(importer.get_browser_display_name("firefox"), "Firefox");
|
||||
assert_eq!(
|
||||
importer.get_browser_display_name("firefox-developer"),
|
||||
"Firefox Developer"
|
||||
);
|
||||
assert_eq!(
|
||||
importer.get_browser_display_name("chromium"),
|
||||
"Chrome/Chromium"
|
||||
);
|
||||
assert_eq!(importer.get_browser_display_name("brave"), "Brave");
|
||||
assert_eq!(
|
||||
importer.get_browser_display_name("mullvad-browser"),
|
||||
"Mullvad Browser"
|
||||
);
|
||||
assert_eq!(importer.get_browser_display_name("zen"), "Zen Browser");
|
||||
assert_eq!(
|
||||
importer.get_browser_display_name("tor-browser"),
|
||||
"Tor Browser"
|
||||
);
|
||||
assert_eq!(
|
||||
importer.get_browser_display_name("unknown"),
|
||||
"Unknown Browser"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_detect_existing_profiles_no_panic() {
|
||||
let (importer, _temp_dir) = create_test_profile_importer();
|
||||
|
||||
// This should not panic even if no browser profiles exist
|
||||
let result = importer.detect_existing_profiles();
|
||||
assert!(result.is_ok(), "detect_existing_profiles should not fail");
|
||||
|
||||
let _profiles = result.unwrap();
|
||||
// We can't assert specific profiles since they depend on the system
|
||||
// but we can verify the result is a valid Vec
|
||||
// We can't assert specific profiles since they depend on the system
|
||||
// but we can verify the result is a valid Vec (length check is always true for Vec, but shows intent)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_firefox_profiles_dir_nonexistent() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
|
||||
let nonexistent_dir = temp_dir.path().join("nonexistent");
|
||||
let result = importer.scan_firefox_profiles_dir(&nonexistent_dir, "firefox");
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Should handle nonexistent directory gracefully"
|
||||
);
|
||||
let profiles = result.unwrap();
|
||||
assert!(
|
||||
profiles.is_empty(),
|
||||
"Should return empty vector for nonexistent directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_scan_chrome_profiles_dir_nonexistent() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
|
||||
let nonexistent_dir = temp_dir.path().join("nonexistent");
|
||||
let result = importer.scan_chrome_profiles_dir(&nonexistent_dir, "chromium");
|
||||
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Should handle nonexistent directory gracefully"
|
||||
);
|
||||
let profiles = result.unwrap();
|
||||
assert!(
|
||||
profiles.is_empty(),
|
||||
"Should return empty vector for nonexistent directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_firefox_profiles_ini_empty() {
|
||||
let (importer, _temp_dir) = create_test_profile_importer();
|
||||
|
||||
let empty_content = "";
|
||||
let profiles_dir = Path::new("/tmp");
|
||||
let result = importer.parse_firefox_profiles_ini(empty_content, profiles_dir, "firefox");
|
||||
|
||||
assert!(result.is_ok(), "Should handle empty profiles.ini");
|
||||
let profiles = result.unwrap();
|
||||
assert!(
|
||||
profiles.is_empty(),
|
||||
"Should return empty vector for empty content"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_firefox_profiles_ini_valid() {
|
||||
let (importer, temp_dir) = create_test_profile_importer();
|
||||
|
||||
// Create a mock profile directory
|
||||
let profiles_dir = temp_dir.path().join("profiles");
|
||||
let profile_dir = profiles_dir.join("test.profile");
|
||||
fs::create_dir_all(&profile_dir).expect("Should create profile directory");
|
||||
|
||||
// Create a prefs.js file to make it look like a valid profile
|
||||
let prefs_file = profile_dir.join("prefs.js");
|
||||
fs::write(&prefs_file, "// Firefox preferences").expect("Should create prefs.js");
|
||||
|
||||
let profiles_ini_content = r#"
|
||||
[Profile0]
|
||||
Name=Test Profile
|
||||
IsRelative=1
|
||||
Path=test.profile
|
||||
"#;
|
||||
|
||||
let result =
|
||||
importer.parse_firefox_profiles_ini(profiles_ini_content, &profiles_dir, "firefox");
|
||||
|
||||
assert!(result.is_ok(), "Should parse valid profiles.ini");
|
||||
let profiles = result.unwrap();
|
||||
assert_eq!(profiles.len(), 1, "Should find one profile");
|
||||
assert_eq!(profiles[0].name, "Firefox - Test Profile");
|
||||
assert_eq!(profiles[0].browser, "firefox");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_copy_directory_recursive() {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
// Create source directory structure
|
||||
let source_dir = temp_dir.path().join("source");
|
||||
let source_subdir = source_dir.join("subdir");
|
||||
fs::create_dir_all(&source_subdir).expect("Should create source directories");
|
||||
|
||||
// Create some test files
|
||||
let source_file1 = source_dir.join("file1.txt");
|
||||
let source_file2 = source_subdir.join("file2.txt");
|
||||
fs::write(&source_file1, "content1").expect("Should create file1");
|
||||
fs::write(&source_file2, "content2").expect("Should create file2");
|
||||
|
||||
// Create destination directory
|
||||
let dest_dir = temp_dir.path().join("dest");
|
||||
|
||||
// Copy recursively
|
||||
let result = ProfileImporter::copy_directory_recursive(&source_dir, &dest_dir);
|
||||
assert!(result.is_ok(), "Should copy directory successfully");
|
||||
|
||||
// Verify files were copied
|
||||
let dest_file1 = dest_dir.join("file1.txt");
|
||||
let dest_file2 = dest_dir.join("subdir").join("file2.txt");
|
||||
|
||||
assert!(dest_file1.exists(), "file1.txt should be copied");
|
||||
assert!(dest_file2.exists(), "file2.txt should be copied");
|
||||
|
||||
let content1 = fs::read_to_string(&dest_file1).expect("Should read file1");
|
||||
let content2 = fs::read_to_string(&dest_file2).expect("Should read file2");
|
||||
|
||||
assert_eq!(content1, "content1", "file1 content should match");
|
||||
assert_eq!(content2, "content2", "file2 content should match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_default_version_for_browser_no_versions() {
|
||||
let (importer, _temp_dir) = create_test_profile_importer();
|
||||
|
||||
// This should fail since no versions are downloaded in test environment
|
||||
let result = importer.get_default_version_for_browser("firefox");
|
||||
assert!(
|
||||
result.is_err(),
|
||||
"Should fail when no versions are available"
|
||||
);
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("No downloaded versions found"),
|
||||
"Error should mention no versions found"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+240
-157
@@ -18,6 +18,8 @@ pub struct ProxyInfo {
|
||||
pub upstream_port: u16,
|
||||
pub upstream_type: String,
|
||||
pub local_port: u16,
|
||||
// Optional profile name to which this proxy instance is logically tied
|
||||
pub profile_name: Option<String>,
|
||||
}
|
||||
|
||||
// Stored proxy configuration with name and ID for reuse
|
||||
@@ -51,7 +53,9 @@ pub struct ProxyManager {
|
||||
active_proxies: Mutex<HashMap<u32, ProxyInfo>>, // Maps browser process ID to proxy info
|
||||
// Store proxy info by profile name for persistence across browser restarts
|
||||
profile_proxies: Mutex<HashMap<String, ProxySettings>>, // Maps profile name to proxy settings
|
||||
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
|
||||
// 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,
|
||||
}
|
||||
|
||||
@@ -61,6 +65,7 @@ impl ProxyManager {
|
||||
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,
|
||||
};
|
||||
@@ -249,39 +254,118 @@ impl ProxyManager {
|
||||
}
|
||||
|
||||
// Start a proxy for given proxy settings and associate it with a browser process ID
|
||||
// If proxy_settings is None, starts a direct proxy for traffic monitoring
|
||||
pub async fn start_proxy(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
proxy_settings: &ProxySettings,
|
||||
proxy_settings: Option<&ProxySettings>,
|
||||
browser_pid: u32,
|
||||
profile_name: Option<&str>,
|
||||
) -> Result<ProxySettings, String> {
|
||||
// Check if we already have a proxy for this browser
|
||||
// First, proactively cleanup any dead proxies so we don't accidentally reuse stale ones
|
||||
let _ = self.cleanup_dead_proxies(app_handle.clone()).await;
|
||||
|
||||
// If we have a previous proxy tied to this profile, and the upstream settings are changing,
|
||||
// stop it before starting a new one so the change takes effect immediately.
|
||||
if let Some(name) = profile_name {
|
||||
// Check if we have an active proxy recorded for this profile
|
||||
let maybe_existing_id = {
|
||||
let map = self.profile_active_proxy_ids.lock().unwrap();
|
||||
map.get(name).cloned()
|
||||
};
|
||||
|
||||
if let Some(existing_id) = maybe_existing_id {
|
||||
// Find the existing proxy info
|
||||
let existing_info = {
|
||||
let proxies = self.active_proxies.lock().unwrap();
|
||||
proxies.values().find(|p| p.id == existing_id).cloned()
|
||||
};
|
||||
|
||||
if let Some(existing) = existing_info {
|
||||
let desired_type = proxy_settings
|
||||
.map(|p| p.proxy_type.as_str())
|
||||
.unwrap_or("DIRECT");
|
||||
let desired_host = proxy_settings.map(|p| p.host.as_str()).unwrap_or("DIRECT");
|
||||
let desired_port = proxy_settings.map(|p| p.port).unwrap_or(0);
|
||||
|
||||
let is_same_upstream = existing.upstream_type == desired_type
|
||||
&& existing.upstream_host == desired_host
|
||||
&& existing.upstream_port == desired_port;
|
||||
|
||||
if !is_same_upstream {
|
||||
// Stop the previous proxy tied to this profile (best effort)
|
||||
// We don't know the original PID mapping that created it; iterate to find its key
|
||||
let pid_to_stop = {
|
||||
let proxies = self.active_proxies.lock().unwrap();
|
||||
proxies.iter().find_map(|(pid, info)| {
|
||||
if info.id == existing_id {
|
||||
Some(*pid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
};
|
||||
if let Some(pid) = pid_to_stop {
|
||||
let _ = self.stop_proxy(app_handle.clone(), pid).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Check if we already have a proxy for this browser PID. If it exists but the upstream
|
||||
// settings don't match the newly requested ones, stop it and create a new proxy so that
|
||||
// changes take effect immediately.
|
||||
let mut needs_restart = false;
|
||||
{
|
||||
let proxies = self.active_proxies.lock().unwrap();
|
||||
if let Some(proxy) = proxies.get(&browser_pid) {
|
||||
return Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||
port: proxy.local_port,
|
||||
username: None,
|
||||
password: None,
|
||||
});
|
||||
if let Some(existing) = proxies.get(&browser_pid) {
|
||||
let desired_type = proxy_settings
|
||||
.map(|p| p.proxy_type.as_str())
|
||||
.unwrap_or("DIRECT");
|
||||
let desired_host = proxy_settings.map(|p| p.host.as_str()).unwrap_or("DIRECT");
|
||||
let desired_port = proxy_settings.map(|p| p.port).unwrap_or(0);
|
||||
|
||||
let is_same_upstream = existing.upstream_type == desired_type
|
||||
&& existing.upstream_host == desired_host
|
||||
&& existing.upstream_port == desired_port;
|
||||
|
||||
if is_same_upstream {
|
||||
// Reuse existing local proxy
|
||||
return Ok(ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: existing.local_port,
|
||||
username: None,
|
||||
password: None,
|
||||
});
|
||||
} else {
|
||||
// Upstream changed; we must restart the local proxy so that traffic is routed correctly
|
||||
needs_restart = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if needs_restart {
|
||||
// Best-effort stop of the old proxy for this PID before starting a new one
|
||||
let _ = self.stop_proxy(app_handle.clone(), browser_pid).await;
|
||||
}
|
||||
|
||||
// Check if we have a preferred port for this profile
|
||||
let preferred_port = if let Some(name) = profile_name {
|
||||
let profile_proxies = self.profile_proxies.lock().unwrap();
|
||||
profile_proxies.get(name).and_then(|settings| {
|
||||
profile_proxies.get(name).and_then(|_settings| {
|
||||
// Find existing proxy with same settings to reuse port
|
||||
let active_proxies = self.active_proxies.lock().unwrap();
|
||||
active_proxies
|
||||
.values()
|
||||
.find(|p| {
|
||||
p.upstream_host == settings.host
|
||||
&& p.upstream_port == settings.port
|
||||
&& p.upstream_type == settings.proxy_type
|
||||
if let Some(proxy_settings) = proxy_settings {
|
||||
p.upstream_host == proxy_settings.host
|
||||
&& p.upstream_port == proxy_settings.port
|
||||
&& p.upstream_type == proxy_settings.proxy_type
|
||||
} else {
|
||||
p.upstream_type == "DIRECT"
|
||||
}
|
||||
})
|
||||
.map(|p| p.local_port)
|
||||
})
|
||||
@@ -295,20 +379,25 @@ impl ProxyManager {
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create sidecar: {e}"))?
|
||||
.arg("proxy")
|
||||
.arg("start")
|
||||
.arg("--host")
|
||||
.arg(&proxy_settings.host)
|
||||
.arg("--proxy-port")
|
||||
.arg(proxy_settings.port.to_string())
|
||||
.arg("--type")
|
||||
.arg(&proxy_settings.proxy_type);
|
||||
.arg("start");
|
||||
|
||||
// Add credentials if provided
|
||||
if let Some(username) = &proxy_settings.username {
|
||||
nodecar = nodecar.arg("--username").arg(username);
|
||||
}
|
||||
if let Some(password) = &proxy_settings.password {
|
||||
nodecar = nodecar.arg("--password").arg(password);
|
||||
// Add upstream proxy settings if provided, otherwise create direct proxy
|
||||
if let Some(proxy_settings) = proxy_settings {
|
||||
nodecar = nodecar
|
||||
.arg("--host")
|
||||
.arg(&proxy_settings.host)
|
||||
.arg("--proxy-port")
|
||||
.arg(proxy_settings.port.to_string())
|
||||
.arg("--type")
|
||||
.arg(&proxy_settings.proxy_type);
|
||||
|
||||
// Add credentials if provided
|
||||
if let Some(username) = &proxy_settings.username {
|
||||
nodecar = nodecar.arg("--username").arg(username);
|
||||
}
|
||||
if let Some(password) = &proxy_settings.password {
|
||||
nodecar = nodecar.arg("--password").arg(password);
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a preferred port, use it
|
||||
@@ -349,10 +438,15 @@ impl ProxyManager {
|
||||
let proxy_info = ProxyInfo {
|
||||
id: id.to_string(),
|
||||
local_url,
|
||||
upstream_host: proxy_settings.host.clone(),
|
||||
upstream_port: proxy_settings.port,
|
||||
upstream_type: proxy_settings.proxy_type.clone(),
|
||||
upstream_host: proxy_settings
|
||||
.map(|p| p.host.clone())
|
||||
.unwrap_or_else(|| "DIRECT".to_string()),
|
||||
upstream_port: proxy_settings.map(|p| p.port).unwrap_or(0),
|
||||
upstream_type: proxy_settings
|
||||
.map(|p| p.proxy_type.clone())
|
||||
.unwrap_or_else(|| "DIRECT".to_string()),
|
||||
local_port,
|
||||
profile_name: profile_name.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
// Store the proxy info
|
||||
@@ -363,8 +457,13 @@ impl ProxyManager {
|
||||
|
||||
// Store the profile proxy info for persistence
|
||||
if let Some(name) = profile_name {
|
||||
let mut profile_proxies = self.profile_proxies.lock().unwrap();
|
||||
profile_proxies.insert(name.to_string(), proxy_settings.clone());
|
||||
if let Some(proxy_settings) = proxy_settings {
|
||||
let mut profile_proxies = self.profile_proxies.lock().unwrap();
|
||||
profile_proxies.insert(name.to_string(), proxy_settings.clone());
|
||||
}
|
||||
// Also record the active proxy id for this profile for quick cleanup on changes
|
||||
let mut map = self.profile_active_proxy_ids.lock().unwrap();
|
||||
map.insert(name.to_string(), proxy_info.id.clone());
|
||||
}
|
||||
|
||||
// Return proxy settings for the browser
|
||||
@@ -383,10 +482,10 @@ impl ProxyManager {
|
||||
app_handle: tauri::AppHandle,
|
||||
browser_pid: u32,
|
||||
) -> Result<(), String> {
|
||||
let proxy_id = {
|
||||
let (proxy_id, profile_name): (String, Option<String>) = {
|
||||
let mut proxies = self.active_proxies.lock().unwrap();
|
||||
match proxies.remove(&browser_pid) {
|
||||
Some(proxy) => proxy.id,
|
||||
Some(proxy) => (proxy.id, proxy.profile_name.clone()),
|
||||
None => return Ok(()), // No proxy to stop
|
||||
}
|
||||
};
|
||||
@@ -399,7 +498,7 @@ impl ProxyManager {
|
||||
.arg("proxy")
|
||||
.arg("stop")
|
||||
.arg("--id")
|
||||
.arg(proxy_id);
|
||||
.arg(&proxy_id);
|
||||
|
||||
let output = nodecar.output().await.unwrap();
|
||||
|
||||
@@ -409,29 +508,19 @@ impl ProxyManager {
|
||||
// We still return Ok since we've already removed the proxy from our tracking
|
||||
}
|
||||
|
||||
// Clear profile-to-proxy mapping if it references this proxy
|
||||
if let Some(name) = profile_name {
|
||||
let mut map = self.profile_active_proxy_ids.lock().unwrap();
|
||||
if let Some(current_id) = map.get(&name) {
|
||||
if current_id == &proxy_id {
|
||||
map.remove(&name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Get proxy settings for a browser process ID
|
||||
#[allow(dead_code)]
|
||||
pub fn get_proxy_settings(&self, browser_pid: u32) -> Option<ProxySettings> {
|
||||
let proxies = self.active_proxies.lock().unwrap();
|
||||
proxies.get(&browser_pid).map(|proxy| ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||
port: proxy.local_port,
|
||||
username: None,
|
||||
password: None,
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored proxy info for a profile
|
||||
#[allow(dead_code)]
|
||||
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<ProxySettings> {
|
||||
let profile_proxies = self.profile_proxies.lock().unwrap();
|
||||
profile_proxies.get(profile_name).cloned()
|
||||
}
|
||||
|
||||
// Update the PID mapping for an existing proxy
|
||||
pub fn update_proxy_pid(&self, old_pid: u32, new_pid: u32) -> Result<(), String> {
|
||||
let mut proxies = self.active_proxies.lock().unwrap();
|
||||
@@ -442,6 +531,35 @@ impl ProxyManager {
|
||||
Err(format!("No proxy found for PID {old_pid}"))
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a process is still running
|
||||
fn is_process_running(&self, pid: u32) -> bool {
|
||||
use sysinfo::{Pid, System};
|
||||
let system = System::new_all();
|
||||
system.process(Pid::from(pid as usize)).is_some()
|
||||
}
|
||||
|
||||
// Clean up proxies for dead browser processes
|
||||
pub async fn cleanup_dead_proxies(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Vec<u32>, String> {
|
||||
let dead_pids = {
|
||||
let proxies = self.active_proxies.lock().unwrap();
|
||||
proxies
|
||||
.keys()
|
||||
.filter(|&&pid| pid != 0 && !self.is_process_running(pid)) // Skip temporary PID 0
|
||||
.copied()
|
||||
.collect::<Vec<u32>>()
|
||||
};
|
||||
|
||||
for dead_pid in &dead_pids {
|
||||
println!("Cleaning up proxy for dead browser process PID: {dead_pid}");
|
||||
let _ = self.stop_proxy(app_handle.clone(), *dead_pid).await;
|
||||
}
|
||||
|
||||
Ok(dead_pids)
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance of the proxy manager
|
||||
@@ -476,8 +594,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
let nodecar_dir = project_root.join("nodecar");
|
||||
let nodecar_dist = nodecar_dir.join("dist");
|
||||
let nodecar_binary = nodecar_dist.join("nodecar");
|
||||
let nodecar_binary = nodecar_dir.join("nodecar-bin");
|
||||
|
||||
// Check if binary already exists
|
||||
if nodecar_binary.exists() {
|
||||
@@ -531,70 +648,6 @@ mod tests {
|
||||
Ok(nodecar_binary)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_manager_profile_persistence() {
|
||||
let proxy_manager = ProxyManager::new();
|
||||
|
||||
let proxy_settings = ProxySettings {
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 1080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
// Test profile proxy info storage
|
||||
{
|
||||
let mut profile_proxies = proxy_manager.profile_proxies.lock().unwrap();
|
||||
profile_proxies.insert("test_profile".to_string(), proxy_settings.clone());
|
||||
}
|
||||
|
||||
// Test retrieval
|
||||
let retrieved = proxy_manager.get_profile_proxy_info("test_profile");
|
||||
assert!(retrieved.is_some());
|
||||
let retrieved = retrieved.unwrap();
|
||||
assert_eq!(retrieved.proxy_type, "socks5");
|
||||
assert_eq!(retrieved.host, "127.0.0.1");
|
||||
assert_eq!(retrieved.port, 1080);
|
||||
|
||||
// Test non-existent profile
|
||||
let non_existent = proxy_manager.get_profile_proxy_info("non_existent");
|
||||
assert!(non_existent.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_manager_active_proxy_tracking() {
|
||||
let proxy_manager = ProxyManager::new();
|
||||
|
||||
let proxy_info = ProxyInfo {
|
||||
id: "test_proxy_123".to_string(),
|
||||
local_url: "http://localhost:8080".to_string(),
|
||||
upstream_host: "proxy.example.com".to_string(),
|
||||
upstream_port: 3128,
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: 8080,
|
||||
};
|
||||
|
||||
let browser_pid = 54321u32;
|
||||
|
||||
// Add active proxy
|
||||
{
|
||||
let mut active_proxies = proxy_manager.active_proxies.lock().unwrap();
|
||||
active_proxies.insert(browser_pid, proxy_info.clone());
|
||||
}
|
||||
|
||||
// Test retrieval of proxy settings
|
||||
let proxy_settings = proxy_manager.get_proxy_settings(browser_pid);
|
||||
assert!(proxy_settings.is_some());
|
||||
let settings = proxy_settings.unwrap();
|
||||
assert!(settings.host == "127.0.0.1");
|
||||
assert!(settings.port == 8080);
|
||||
|
||||
// Test non-existent browser PID
|
||||
let non_existent = proxy_manager.get_proxy_settings(99999);
|
||||
assert!(non_existent.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proxy_settings_validation() {
|
||||
// Test valid proxy settings
|
||||
@@ -606,8 +659,23 @@ mod tests {
|
||||
password: Some("pass".to_string()),
|
||||
};
|
||||
|
||||
assert!(!valid_settings.host.is_empty());
|
||||
assert!(valid_settings.port > 0);
|
||||
assert!(
|
||||
!valid_settings.host.is_empty(),
|
||||
"Valid settings should have non-empty host"
|
||||
);
|
||||
assert!(
|
||||
valid_settings.port > 0,
|
||||
"Valid settings should have positive port"
|
||||
);
|
||||
assert_eq!(valid_settings.proxy_type, "http", "Proxy type should match");
|
||||
assert!(
|
||||
valid_settings.username.is_some(),
|
||||
"Username should be present"
|
||||
);
|
||||
assert!(
|
||||
valid_settings.password.is_some(),
|
||||
"Password should be present"
|
||||
);
|
||||
|
||||
// Test proxy settings with empty values
|
||||
let empty_settings = ProxySettings {
|
||||
@@ -618,7 +686,16 @@ mod tests {
|
||||
password: None,
|
||||
};
|
||||
|
||||
assert!(empty_settings.host.is_empty());
|
||||
assert!(
|
||||
empty_settings.host.is_empty(),
|
||||
"Empty settings should have empty host"
|
||||
);
|
||||
assert_eq!(
|
||||
empty_settings.port, 0,
|
||||
"Empty settings should have zero port"
|
||||
);
|
||||
assert!(empty_settings.username.is_none(), "Username should be None");
|
||||
assert!(empty_settings.password.is_none(), "Password should be None");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -640,6 +717,7 @@ mod tests {
|
||||
upstream_port: 3128,
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: (8000 + i) as u16,
|
||||
profile_name: None,
|
||||
};
|
||||
|
||||
// Add proxy
|
||||
@@ -648,10 +726,6 @@ mod tests {
|
||||
active_proxies.insert(browser_pid, proxy_info);
|
||||
}
|
||||
|
||||
// Read proxy
|
||||
let settings = pm.get_proxy_settings(browser_pid);
|
||||
assert!(settings.is_some());
|
||||
|
||||
browser_pid
|
||||
});
|
||||
handles.push(handle);
|
||||
@@ -714,7 +788,7 @@ mod tests {
|
||||
.arg("http");
|
||||
|
||||
// Set a timeout for the command
|
||||
let output = tokio::time::timeout(Duration::from_secs(10), async { cmd.output() }).await??;
|
||||
let output = tokio::time::timeout(Duration::from_secs(60), async { cmd.output() }).await??;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
@@ -730,7 +804,7 @@ mod tests {
|
||||
|
||||
// Wait for proxy worker to start
|
||||
println!("Waiting for proxy worker to start...");
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
// Test that the local port is listening
|
||||
let mut port_test = Command::new("nc");
|
||||
@@ -751,7 +825,7 @@ mod tests {
|
||||
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
||||
|
||||
let stop_output =
|
||||
tokio::time::timeout(Duration::from_secs(5), async { stop_cmd.output() }).await??;
|
||||
tokio::time::timeout(Duration::from_secs(60), async { stop_cmd.output() }).await??;
|
||||
|
||||
assert!(stop_output.status.success());
|
||||
|
||||
@@ -780,7 +854,7 @@ mod tests {
|
||||
};
|
||||
|
||||
// Test command arguments match expected format
|
||||
let _expected_args = [
|
||||
let expected_args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
@@ -796,11 +870,37 @@ mod tests {
|
||||
];
|
||||
|
||||
// This test verifies the argument structure without actually running the command
|
||||
assert_eq!(proxy_settings.host, "proxy.example.com");
|
||||
assert_eq!(proxy_settings.port, 8080);
|
||||
assert_eq!(proxy_settings.proxy_type, "http");
|
||||
assert_eq!(proxy_settings.username.as_ref().unwrap(), "user");
|
||||
assert_eq!(proxy_settings.password.as_ref().unwrap(), "pass");
|
||||
assert_eq!(
|
||||
proxy_settings.host, "proxy.example.com",
|
||||
"Host should match expected value"
|
||||
);
|
||||
assert_eq!(
|
||||
proxy_settings.port, 8080,
|
||||
"Port should match expected value"
|
||||
);
|
||||
assert_eq!(
|
||||
proxy_settings.proxy_type, "http",
|
||||
"Proxy type should match expected value"
|
||||
);
|
||||
assert_eq!(
|
||||
proxy_settings.username.as_ref().unwrap(),
|
||||
"user",
|
||||
"Username should match expected value"
|
||||
);
|
||||
assert_eq!(
|
||||
proxy_settings.password.as_ref().unwrap(),
|
||||
"pass",
|
||||
"Password should match expected value"
|
||||
);
|
||||
|
||||
// Verify expected args structure
|
||||
assert_eq!(expected_args[0], "proxy", "First arg should be 'proxy'");
|
||||
assert_eq!(expected_args[1], "start", "Second arg should be 'start'");
|
||||
assert_eq!(expected_args[2], "--host", "Third arg should be '--host'");
|
||||
assert_eq!(
|
||||
expected_args[3], "proxy.example.com",
|
||||
"Fourth arg should be host value"
|
||||
);
|
||||
}
|
||||
|
||||
// Test the CLI detachment specifically - ensure the CLI exits properly
|
||||
@@ -826,13 +926,6 @@ mod tests {
|
||||
match output {
|
||||
Ok(Ok(cmd_output)) => {
|
||||
let execution_time = start_time.elapsed();
|
||||
println!("CLI completed in {execution_time:?}");
|
||||
|
||||
// Should complete very quickly if properly detached
|
||||
assert!(
|
||||
execution_time < Duration::from_secs(3),
|
||||
"CLI took too long ({execution_time:?}), should exit immediately after starting worker"
|
||||
);
|
||||
|
||||
if cmd_output.status.success() {
|
||||
let stdout = String::from_utf8(cmd_output.stdout)?;
|
||||
@@ -876,17 +969,7 @@ mod tests {
|
||||
.arg("--type")
|
||||
.arg("http");
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??;
|
||||
let execution_time = start_time.elapsed();
|
||||
|
||||
// Command should complete very quickly if properly detached
|
||||
assert!(
|
||||
execution_time < Duration::from_secs(5),
|
||||
"CLI command took {execution_time:?}, should complete in under 5 seconds for proper detachment"
|
||||
);
|
||||
|
||||
println!("CLI detachment test: command completed in {execution_time:?}");
|
||||
let output = tokio::time::timeout(Duration::from_secs(60), async { cmd.output() }).await??;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
@@ -928,7 +1011,7 @@ mod tests {
|
||||
.arg("--password")
|
||||
.arg("pass word!"); // Contains space and special character
|
||||
|
||||
let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??;
|
||||
let output = tokio::time::timeout(Duration::from_secs(60), async { cmd.output() }).await??;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
@@ -25,8 +25,6 @@ impl Default for TableSortingSettings {
|
||||
pub struct AppSettings {
|
||||
#[serde(default)]
|
||||
pub set_as_default_browser: bool,
|
||||
#[serde(default)]
|
||||
pub show_settings_on_startup: bool,
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String, // "light", "dark", or "system"
|
||||
}
|
||||
@@ -39,7 +37,6 @@ impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
set_as_default_browser: false,
|
||||
show_settings_on_startup: true,
|
||||
theme: "system".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -50,12 +47,16 @@ pub struct SettingsManager {
|
||||
}
|
||||
|
||||
impl SettingsManager {
|
||||
pub fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static SettingsManager {
|
||||
&SETTINGS_MANAGER
|
||||
}
|
||||
|
||||
pub fn get_settings_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
@@ -147,19 +148,14 @@ impl SettingsManager {
|
||||
}
|
||||
|
||||
pub fn should_show_settings_on_startup(&self) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let settings = self.load_settings()?;
|
||||
|
||||
// Show prompt if:
|
||||
// 1. User wants to see the prompt
|
||||
// 2. Donut Browser is not set as default
|
||||
// 3. User hasn't explicitly disabled the default browser setting
|
||||
Ok(settings.show_settings_on_startup && !settings.set_as_default_browser)
|
||||
// Always return false - we don't show settings on startup anymore
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_settings() -> Result<AppSettings, String> {
|
||||
let manager = SettingsManager::new();
|
||||
let manager = SettingsManager::instance();
|
||||
manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))
|
||||
@@ -167,7 +163,7 @@ pub async fn get_app_settings() -> Result<AppSettings, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_app_settings(settings: AppSettings) -> Result<(), String> {
|
||||
let manager = SettingsManager::new();
|
||||
let manager = SettingsManager::instance();
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))
|
||||
@@ -175,7 +171,7 @@ pub async fn save_app_settings(settings: AppSettings) -> Result<(), String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn should_show_settings_on_startup() -> Result<bool, String> {
|
||||
let manager = SettingsManager::new();
|
||||
let manager = SettingsManager::instance();
|
||||
manager
|
||||
.should_show_settings_on_startup()
|
||||
.map_err(|e| format!("Failed to check prompt setting: {e}"))
|
||||
@@ -183,7 +179,7 @@ pub async fn should_show_settings_on_startup() -> Result<bool, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
|
||||
let manager = SettingsManager::new();
|
||||
let manager = SettingsManager::instance();
|
||||
manager
|
||||
.load_table_sorting()
|
||||
.map_err(|e| format!("Failed to load table sorting settings: {e}"))
|
||||
@@ -191,7 +187,7 @@ pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Result<(), String> {
|
||||
let manager = SettingsManager::new();
|
||||
let manager = SettingsManager::instance();
|
||||
manager
|
||||
.save_table_sorting(&sorting)
|
||||
.map_err(|e| format!("Failed to save table sorting settings: {e}"))
|
||||
@@ -201,20 +197,261 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
|
||||
pub async fn clear_all_version_cache_and_refetch(
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let api_client = ApiClient::new();
|
||||
let api_client = ApiClient::instance();
|
||||
|
||||
// Clear all cache first
|
||||
api_client
|
||||
.clear_all_cache()
|
||||
.map_err(|e| format!("Failed to clear version cache: {e}"))?;
|
||||
|
||||
// Disable all browsers during the update process
|
||||
let auto_updater = crate::auto_updater::AutoUpdater::instance();
|
||||
let supported_browsers =
|
||||
crate::browser_version_manager::BrowserVersionManager::instance().get_supported_browsers();
|
||||
|
||||
// Load current state and disable all browsers
|
||||
let mut state = auto_updater
|
||||
.load_auto_update_state()
|
||||
.map_err(|e| format!("Failed to load auto update state: {e}"))?;
|
||||
for browser in &supported_browsers {
|
||||
state.disabled_browsers.insert(browser.clone());
|
||||
}
|
||||
auto_updater
|
||||
.save_auto_update_state(&state)
|
||||
.map_err(|e| format!("Failed to save auto update state: {e}"))?;
|
||||
|
||||
let updater = version_updater::get_version_updater();
|
||||
let updater_guard = updater.lock().await;
|
||||
|
||||
updater_guard
|
||||
let result = updater_guard
|
||||
.trigger_manual_update(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to trigger version update: {e}"))?;
|
||||
.map_err(|e| format!("Failed to trigger version update: {e}"));
|
||||
|
||||
// Re-enable all browsers after the update completes (regardless of success/failure)
|
||||
let mut final_state = auto_updater.load_auto_update_state().unwrap_or_default();
|
||||
for browser in &supported_browsers {
|
||||
final_state.disabled_browsers.remove(browser);
|
||||
}
|
||||
if let Err(e) = auto_updater.save_auto_update_state(&final_state) {
|
||||
eprintln!("Warning: Failed to re-enable browsers after cache clear: {e}");
|
||||
}
|
||||
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref SETTINGS_MANAGER: SettingsManager = SettingsManager::new();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_settings_manager() -> (SettingsManager, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
// Set up a temporary home directory for testing
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
|
||||
let manager = SettingsManager::new();
|
||||
(manager, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_manager_creation() {
|
||||
let (_manager, _temp_dir) = create_test_settings_manager();
|
||||
// Test passes if no panic occurs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_app_settings() {
|
||||
let default_settings = AppSettings::default();
|
||||
|
||||
assert!(
|
||||
!default_settings.set_as_default_browser,
|
||||
"Default should not set as default browser"
|
||||
);
|
||||
assert_eq!(
|
||||
default_settings.theme, "system",
|
||||
"Default theme should be system"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_table_sorting_settings() {
|
||||
let default_sorting = TableSortingSettings::default();
|
||||
|
||||
assert_eq!(
|
||||
default_sorting.column, "name",
|
||||
"Default sort column should be name"
|
||||
);
|
||||
assert_eq!(
|
||||
default_sorting.direction, "asc",
|
||||
"Default sort direction should be asc"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_settings_nonexistent_file() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
|
||||
let result = manager.load_settings();
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Should handle nonexistent settings file gracefully"
|
||||
);
|
||||
|
||||
let settings = result.unwrap();
|
||||
assert!(
|
||||
!settings.set_as_default_browser,
|
||||
"Should return default settings"
|
||||
);
|
||||
assert_eq!(settings.theme, "system", "Should return default theme");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_settings() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
|
||||
let test_settings = AppSettings {
|
||||
set_as_default_browser: true,
|
||||
theme: "dark".to_string(),
|
||||
};
|
||||
|
||||
// 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");
|
||||
|
||||
let loaded_settings = load_result.unwrap();
|
||||
assert!(
|
||||
loaded_settings.set_as_default_browser,
|
||||
"Loaded settings should match saved"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded_settings.theme, "dark",
|
||||
"Loaded theme should match saved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_table_sorting_nonexistent_file() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
|
||||
let result = manager.load_table_sorting();
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Should handle nonexistent sorting file gracefully"
|
||||
);
|
||||
|
||||
let sorting = result.unwrap();
|
||||
assert_eq!(sorting.column, "name", "Should return default sorting");
|
||||
assert_eq!(sorting.direction, "asc", "Should return default direction");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_save_and_load_table_sorting() {
|
||||
let (manager, _temp_dir) = 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");
|
||||
|
||||
let loaded_sorting = load_result.unwrap();
|
||||
assert_eq!(
|
||||
loaded_sorting.column, "browser",
|
||||
"Loaded column should match saved"
|
||||
);
|
||||
assert_eq!(
|
||||
loaded_sorting.direction, "desc",
|
||||
"Loaded direction should match saved"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_show_settings_on_startup() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
|
||||
let result = manager.should_show_settings_on_startup();
|
||||
assert!(result.is_ok(), "Should not fail");
|
||||
|
||||
let should_show = result.unwrap();
|
||||
assert!(
|
||||
!should_show,
|
||||
"Should always return false as per implementation"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_corrupted_settings_file() {
|
||||
let (manager, _temp_dir) = 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(),
|
||||
"Should handle corrupted settings file gracefully"
|
||||
);
|
||||
|
||||
let settings = result.unwrap();
|
||||
assert!(
|
||||
!settings.set_as_default_browser,
|
||||
"Should return default settings for corrupted file"
|
||||
);
|
||||
assert_eq!(
|
||||
settings.theme, "system",
|
||||
"Should return default theme for corrupted file"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_settings_file_paths() {
|
||||
let (manager, _temp_dir) = create_test_settings_manager();
|
||||
|
||||
let settings_dir = manager.get_settings_dir();
|
||||
let settings_file = manager.get_settings_file();
|
||||
let sorting_file = manager.get_table_sorting_file();
|
||||
|
||||
assert!(
|
||||
settings_dir.to_string_lossy().contains("settings"),
|
||||
"Settings dir should contain 'settings'"
|
||||
);
|
||||
assert!(
|
||||
settings_file
|
||||
.to_string_lossy()
|
||||
.ends_with("app_settings.json"),
|
||||
"Settings file should end with app_settings.json"
|
||||
);
|
||||
assert!(
|
||||
sorting_file
|
||||
.to_string_lossy()
|
||||
.ends_with("table_sorting.json"),
|
||||
"Sorting file should end with table_sorting.json"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,331 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SystemLocale {
|
||||
pub locale: String,
|
||||
pub language: String,
|
||||
pub country: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SystemTimezone {
|
||||
pub timezone: String,
|
||||
pub offset: String,
|
||||
}
|
||||
|
||||
pub struct SystemUtils;
|
||||
|
||||
impl SystemUtils {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Detect the system's locale settings
|
||||
pub fn detect_system_locale(&self) -> SystemLocale {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::detect_system_locale();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::detect_system_locale();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::detect_system_locale();
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
return SystemLocale {
|
||||
locale: "en-US".to_string(),
|
||||
language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
/// Detect the system's timezone settings
|
||||
pub fn detect_system_timezone(&self) -> SystemTimezone {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::detect_system_timezone();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::detect_system_timezone();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::detect_system_timezone();
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
return SystemTimezone {
|
||||
timezone: "UTC".to_string(),
|
||||
offset: "+00:00".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use super::*;
|
||||
|
||||
pub fn detect_system_locale() -> SystemLocale {
|
||||
// Try to get the system locale from macOS
|
||||
if let Ok(output) = Command::new("defaults")
|
||||
.args(["read", "-g", "AppleLocale"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let locale_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
return parse_locale(&locale_str);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to environment variables
|
||||
detect_locale_from_env()
|
||||
}
|
||||
|
||||
pub fn detect_system_timezone() -> SystemTimezone {
|
||||
// Try to get timezone from macOS system
|
||||
if let Ok(output) = Command::new("date").arg("+%Z").output() {
|
||||
if output.status.success() {
|
||||
let tz_abbr = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
|
||||
// Get the full timezone name
|
||||
if let Ok(tz_output) = Command::new("systemsetup").args(["-gettimezone"]).output() {
|
||||
if tz_output.status.success() {
|
||||
let tz_full = String::from_utf8_lossy(&tz_output.stdout);
|
||||
if let Some(tz_name) = tz_full.strip_prefix("Time Zone: ") {
|
||||
let tz_clean = tz_name.trim().to_string();
|
||||
if !tz_clean.is_empty() {
|
||||
return SystemTimezone {
|
||||
timezone: tz_clean,
|
||||
offset: tz_abbr,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to reading /etc/localtime link
|
||||
detect_timezone_from_files()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
|
||||
pub fn detect_system_locale() -> SystemLocale {
|
||||
// Try to get locale from locale command
|
||||
if let Ok(output) = Command::new("locale").output() {
|
||||
if output.status.success() {
|
||||
let output_str = String::from_utf8_lossy(&output.stdout);
|
||||
for line in output_str.lines() {
|
||||
if line.starts_with("LANG=") {
|
||||
let locale_value = line.strip_prefix("LANG=").unwrap_or("");
|
||||
let locale_clean = locale_value.trim_matches('"');
|
||||
return parse_locale(locale_clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to environment variables
|
||||
detect_locale_from_env()
|
||||
}
|
||||
|
||||
pub fn detect_system_timezone() -> SystemTimezone {
|
||||
// Try to read /etc/timezone first (Debian/Ubuntu)
|
||||
if let Ok(tz_content) = std::fs::read_to_string("/etc/timezone") {
|
||||
let tz_name = tz_content.trim().to_string();
|
||||
if !tz_name.is_empty() {
|
||||
return SystemTimezone {
|
||||
timezone: tz_name,
|
||||
offset: get_timezone_offset(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try timedatectl (systemd systems)
|
||||
if let Ok(output) = Command::new("timedatectl")
|
||||
.args(["show", "--property=Timezone", "--value"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let tz_name = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !tz_name.is_empty() {
|
||||
return SystemTimezone {
|
||||
timezone: tz_name,
|
||||
offset: get_timezone_offset(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to reading /etc/localtime symlink
|
||||
detect_timezone_from_files()
|
||||
}
|
||||
|
||||
fn get_timezone_offset() -> String {
|
||||
if let Ok(output) = Command::new("date").arg("+%z").output() {
|
||||
if output.status.success() {
|
||||
return String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
}
|
||||
}
|
||||
"+00:00".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows {
|
||||
use super::*;
|
||||
|
||||
pub fn detect_system_locale() -> SystemLocale {
|
||||
// Try to get locale from Windows registry/powershell
|
||||
if let Ok(output) = Command::new("powershell")
|
||||
.args([
|
||||
"-Command",
|
||||
"Get-Culture | Select-Object -ExpandProperty Name",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let locale_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
return parse_locale(&locale_str);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to environment variables
|
||||
detect_locale_from_env()
|
||||
}
|
||||
|
||||
pub fn detect_system_timezone() -> SystemTimezone {
|
||||
// Try to get timezone from Windows
|
||||
if let Ok(output) = Command::new("powershell")
|
||||
.args([
|
||||
"-Command",
|
||||
"Get-TimeZone | Select-Object -ExpandProperty Id",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let tz_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !tz_id.is_empty() {
|
||||
return SystemTimezone {
|
||||
timezone: tz_id,
|
||||
offset: get_windows_timezone_offset(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
SystemTimezone {
|
||||
timezone: "UTC".to_string(),
|
||||
offset: "+00:00".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_windows_timezone_offset() -> String {
|
||||
if let Ok(output) = Command::new("powershell")
|
||||
.args([
|
||||
"-Command",
|
||||
"Get-TimeZone | Select-Object -ExpandProperty BaseUtcOffset",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let offset_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
// Convert Windows offset format to standard format
|
||||
if let Some(colon_pos) = offset_str.find(':') {
|
||||
let hours = &offset_str[..colon_pos];
|
||||
let minutes = &offset_str[colon_pos + 1..];
|
||||
if let (Ok(h), Ok(m)) = (hours.parse::<i32>(), minutes.parse::<i32>()) {
|
||||
return format!("{:+03d}:{:02d}", h, m);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"+00:00".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// Helper functions used across platforms
|
||||
fn parse_locale(locale_str: &str) -> SystemLocale {
|
||||
// Remove encoding suffix if present (e.g., "en_US.UTF-8" -> "en_US")
|
||||
let locale_base = locale_str.split('.').next().unwrap_or(locale_str);
|
||||
|
||||
// Split language and country (e.g., "en_US" -> ["en", "US"])
|
||||
let parts: Vec<&str> = locale_base.split(&['_', '-']).collect();
|
||||
|
||||
let language = parts.first().unwrap_or(&"en").to_string();
|
||||
let country = parts.get(1).unwrap_or(&"US").to_string();
|
||||
|
||||
// Convert to standard format (e.g., "en-US")
|
||||
let standard_locale = if parts.len() >= 2 {
|
||||
format!("{}-{}", language, country.to_uppercase())
|
||||
} else {
|
||||
format!("{language}-US")
|
||||
};
|
||||
|
||||
SystemLocale {
|
||||
locale: standard_locale,
|
||||
language,
|
||||
country: country.to_uppercase(),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_locale_from_env() -> SystemLocale {
|
||||
// Check environment variables in order of preference
|
||||
let env_vars = ["LANG", "LC_ALL", "LC_CTYPE", "LANGUAGE"];
|
||||
|
||||
for var in &env_vars {
|
||||
if let Ok(value) = std::env::var(var) {
|
||||
if !value.is_empty() {
|
||||
return parse_locale(&value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
SystemLocale {
|
||||
locale: "en-US".to_string(),
|
||||
language: "en".to_string(),
|
||||
country: "US".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_timezone_from_files() -> SystemTimezone {
|
||||
// Try to read timezone from /etc/localtime symlink
|
||||
if let Ok(link_target) = std::fs::read_link("/etc/localtime") {
|
||||
if let Some(tz_path) = link_target.to_str() {
|
||||
// Extract timezone name from path like /usr/share/zoneinfo/America/New_York
|
||||
if let Some(zoneinfo_pos) = tz_path.find("zoneinfo/") {
|
||||
let tz_name = &tz_path[zoneinfo_pos + 9..];
|
||||
if !tz_name.is_empty() {
|
||||
return SystemTimezone {
|
||||
timezone: tz_name.to_string(),
|
||||
offset: "+00:00".to_string(), // Could be improved with actual offset calculation
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
SystemTimezone {
|
||||
timezone: "UTC".to_string(),
|
||||
offset: "+00:00".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tauri command to get system locale
|
||||
#[tauri::command]
|
||||
pub async fn get_system_locale() -> Result<SystemLocale, String> {
|
||||
let utils = SystemUtils::new();
|
||||
Ok(utils.detect_system_locale())
|
||||
}
|
||||
|
||||
/// Tauri command to get system timezone
|
||||
#[tauri::command]
|
||||
pub async fn get_system_timezone() -> Result<SystemTimezone, String> {
|
||||
let utils = SystemUtils::new();
|
||||
Ok(utils.detect_system_timezone())
|
||||
}
|
||||
@@ -1,539 +0,0 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SystemTheme {
|
||||
pub theme: String, // "light", "dark", or "unknown"
|
||||
}
|
||||
|
||||
pub struct ThemeDetector;
|
||||
|
||||
impl ThemeDetector {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Detect the system theme preference
|
||||
pub fn detect_system_theme(&self) -> SystemTheme {
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::detect_system_theme();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::detect_system_theme();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::detect_system_theme();
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
return SystemTheme {
|
||||
theme: "unknown".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
|
||||
pub fn detect_system_theme() -> SystemTheme {
|
||||
// Try multiple methods in order of preference
|
||||
|
||||
// 1. Try GNOME/GTK settings via gsettings
|
||||
if let Ok(theme) = detect_gnome_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 2. Try KDE Plasma settings via kreadconfig5/kreadconfig6
|
||||
if let Ok(theme) = detect_kde_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 3. Try XFCE settings via xfconf-query
|
||||
if let Ok(theme) = detect_xfce_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 4. Try looking at current GTK theme name
|
||||
if let Ok(theme) = detect_gtk_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 5. Try dconf directly (fallback for GNOME-based systems)
|
||||
if let Ok(theme) = detect_dconf_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 6. Try environment variables
|
||||
if let Ok(theme) = detect_env_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 7. Try freedesktop portal
|
||||
if let Ok(theme) = detect_portal_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 8. Try looking at system color scheme files
|
||||
if let Ok(theme) = detect_system_files_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// Fallback to unknown
|
||||
SystemTheme {
|
||||
theme: "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_gnome_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Check if gsettings is available
|
||||
if !is_command_available("gsettings") {
|
||||
return Err("gsettings not available".into());
|
||||
}
|
||||
|
||||
// Try GNOME color scheme first (modern way)
|
||||
if let Ok(output) = Command::new("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
match scheme.as_str() {
|
||||
"'prefer-dark'" => return Ok("dark".to_string()),
|
||||
"'prefer-light'" => return Ok("light".to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to GTK theme name detection
|
||||
if let Ok(output) = Command::new("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "gtk-theme"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme_name = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.trim_matches('\'')
|
||||
.to_lowercase();
|
||||
|
||||
if theme_name.contains("dark") || theme_name.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect GNOME theme".into())
|
||||
}
|
||||
|
||||
fn detect_kde_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Try KDE Plasma 6 first
|
||||
if is_command_available("kreadconfig6") {
|
||||
if let Ok(output) = Command::new("kreadconfig6")
|
||||
.args([
|
||||
"--file",
|
||||
"kdeglobals",
|
||||
"--group",
|
||||
"KDE",
|
||||
"--key",
|
||||
"LookAndFeelPackage",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if theme.contains("dark") || theme.contains("breezedark") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme.contains("light") || theme.contains("breeze") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try color scheme as well
|
||||
if let Ok(output) = Command::new("kreadconfig6")
|
||||
.args([
|
||||
"--file",
|
||||
"kdeglobals",
|
||||
"--group",
|
||||
"General",
|
||||
"--key",
|
||||
"ColorScheme",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let scheme = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if scheme.contains("dark") || scheme.contains("breezedark") {
|
||||
return Ok("dark".to_string());
|
||||
} else if scheme.contains("light") || scheme.contains("breeze") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try KDE Plasma 5 as fallback
|
||||
if is_command_available("kreadconfig5") {
|
||||
if let Ok(output) = Command::new("kreadconfig5")
|
||||
.args([
|
||||
"--file",
|
||||
"kdeglobals",
|
||||
"--group",
|
||||
"KDE",
|
||||
"--key",
|
||||
"LookAndFeelPackage",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if theme.contains("dark") || theme.contains("breezedark") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme.contains("light") || theme.contains("breeze") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect KDE theme".into())
|
||||
}
|
||||
|
||||
fn detect_xfce_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
if !is_command_available("xfconf-query") {
|
||||
return Err("xfconf-query not available".into());
|
||||
}
|
||||
|
||||
// Check XFCE theme
|
||||
if let Ok(output) = Command::new("xfconf-query")
|
||||
.args(["-c", "xsettings", "-p", "/Net/ThemeName"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if theme.contains("dark") || theme.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme.contains("light") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check XFCE window manager theme as backup
|
||||
if let Ok(output) = Command::new("xfconf-query")
|
||||
.args(["-c", "xfwm4", "-p", "/general/theme"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if theme.contains("dark") || theme.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme.contains("light") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect XFCE theme".into())
|
||||
}
|
||||
|
||||
fn detect_gtk_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Try to read GTK3 settings file
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let gtk3_settings = std::path::Path::new(&home).join(".config/gtk-3.0/settings.ini");
|
||||
if gtk3_settings.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(gtk3_settings) {
|
||||
for line in content.lines() {
|
||||
if line.starts_with("gtk-theme-name=") {
|
||||
let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase();
|
||||
if theme_name.contains("dark") || theme_name.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try GTK4 settings
|
||||
let gtk4_settings = std::path::Path::new(&home).join(".config/gtk-4.0/settings.ini");
|
||||
if gtk4_settings.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(gtk4_settings) {
|
||||
for line in content.lines() {
|
||||
if line.starts_with("gtk-theme-name=") {
|
||||
let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase();
|
||||
if theme_name.contains("dark") || theme_name.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect GTK theme".into())
|
||||
}
|
||||
|
||||
fn detect_dconf_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
if !is_command_available("dconf") {
|
||||
return Err("dconf not available".into());
|
||||
}
|
||||
|
||||
// Try reading color scheme directly from dconf
|
||||
if let Ok(output) = Command::new("dconf")
|
||||
.args(["read", "/org/gnome/desktop/interface/color-scheme"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
match scheme.as_str() {
|
||||
"'prefer-dark'" => return Ok("dark".to_string()),
|
||||
"'prefer-light'" => return Ok("light".to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try reading GTK theme from dconf
|
||||
if let Ok(output) = Command::new("dconf")
|
||||
.args(["read", "/org/gnome/desktop/interface/gtk-theme"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme_name = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.trim_matches('\'')
|
||||
.to_lowercase();
|
||||
|
||||
if theme_name.contains("dark") || theme_name.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect dconf theme".into())
|
||||
}
|
||||
|
||||
fn detect_env_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Check common environment variables
|
||||
if let Ok(theme) = std::env::var("GTK_THEME") {
|
||||
let theme_lower = theme.to_lowercase();
|
||||
if theme_lower.contains("dark") || theme_lower.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_lower.contains("light") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(theme) = std::env::var("QT_STYLE_OVERRIDE") {
|
||||
let theme_lower = theme.to_lowercase();
|
||||
if theme_lower.contains("dark") || theme_lower.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_lower.contains("light") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect theme from environment".into())
|
||||
}
|
||||
|
||||
fn detect_portal_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
if !is_command_available("busctl") {
|
||||
return Err("busctl not available".into());
|
||||
}
|
||||
|
||||
// Try to query the color scheme via org.freedesktop.portal.Settings
|
||||
if let Ok(output) = Command::new("busctl")
|
||||
.args([
|
||||
"--user",
|
||||
"call",
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
"org.freedesktop.portal.Settings",
|
||||
"Read",
|
||||
"ss",
|
||||
"org.freedesktop.appearance",
|
||||
"color-scheme",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let response = String::from_utf8_lossy(&output.stdout);
|
||||
// Parse DBus response - look for preference values
|
||||
if response.contains(" 1 ") {
|
||||
return Ok("dark".to_string());
|
||||
} else if response.contains(" 2 ") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect portal theme".into())
|
||||
}
|
||||
|
||||
fn detect_system_files_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Check if we're in a dark terminal (heuristic)
|
||||
if let Ok(term) = std::env::var("TERM") {
|
||||
let term_lower = term.to_lowercase();
|
||||
if term_lower.contains("dark") || term_lower.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we can determine from desktop session
|
||||
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
|
||||
let desktop_lower = desktop.to_lowercase();
|
||||
// Some desktops default to dark
|
||||
if desktop_lower.contains("i3") || desktop_lower.contains("sway") {
|
||||
// Window managers often use dark themes by default
|
||||
return Ok("dark".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect theme from system files".into())
|
||||
}
|
||||
|
||||
fn is_command_available(command: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(command)
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use super::*;
|
||||
|
||||
pub fn detect_system_theme() -> SystemTheme {
|
||||
// macOS theme detection using osascript
|
||||
if let Ok(output) = Command::new("osascript")
|
||||
.args([
|
||||
"-e",
|
||||
"tell application \"System Events\" to tell appearance preferences to get dark mode",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let result = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let result = result.trim();
|
||||
match result {
|
||||
"true" => {
|
||||
return SystemTheme {
|
||||
theme: "dark".to_string(),
|
||||
}
|
||||
}
|
||||
"false" => {
|
||||
return SystemTheme {
|
||||
theme: "light".to_string(),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback method using defaults
|
||||
if let Ok(output) = Command::new("defaults")
|
||||
.args(["read", "-g", "AppleInterfaceStyle"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let style = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let style = style.trim();
|
||||
if style.to_lowercase() == "dark" {
|
||||
return SystemTheme {
|
||||
theme: "dark".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to light if we can't determine
|
||||
SystemTheme {
|
||||
theme: "light".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows {
|
||||
use super::*;
|
||||
|
||||
pub fn detect_system_theme() -> SystemTheme {
|
||||
// Windows theme detection via registry
|
||||
// This is a simplified implementation - you might want to use winreg crate for better registry access
|
||||
if let Ok(output) = Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
|
||||
"/v",
|
||||
"AppsUseLightTheme",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let result = String::from_utf8_lossy(&output.stdout);
|
||||
if result.contains("0x0") {
|
||||
return SystemTheme {
|
||||
theme: "dark".to_string(),
|
||||
};
|
||||
} else if result.contains("0x1") {
|
||||
return SystemTheme {
|
||||
theme: "light".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to light if we can't determine
|
||||
SystemTheme {
|
||||
theme: "light".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Command to expose this functionality to the frontend
|
||||
#[tauri::command]
|
||||
pub fn get_system_theme() -> SystemTheme {
|
||||
let detector = ThemeDetector::new();
|
||||
detector.detect_system_theme()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_theme_detector_creation() {
|
||||
let detector = ThemeDetector::new();
|
||||
let theme = detector.detect_system_theme();
|
||||
|
||||
// Should return a valid theme string
|
||||
assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_system_theme_command() {
|
||||
let theme = get_system_theme();
|
||||
assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown"));
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ use tokio::sync::Mutex;
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::auto_updater::AutoUpdater;
|
||||
use crate::browser_version_service::BrowserVersionService;
|
||||
use crate::browser_version_manager::BrowserVersionManager;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct VersionUpdateProgress {
|
||||
@@ -41,22 +41,22 @@ impl Default for BackgroundUpdateState {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
last_update_time: 0,
|
||||
update_interval_hours: 3,
|
||||
update_interval_hours: 12,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub struct VersionUpdater {
|
||||
version_service: BrowserVersionService,
|
||||
auto_updater: AutoUpdater,
|
||||
version_service: &'static BrowserVersionManager,
|
||||
auto_updater: &'static AutoUpdater,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
}
|
||||
|
||||
impl VersionUpdater {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
version_service: BrowserVersionService::new(),
|
||||
auto_updater: AutoUpdater::new(),
|
||||
version_service: BrowserVersionManager::instance(),
|
||||
auto_updater: AutoUpdater::instance(),
|
||||
app_handle: None,
|
||||
}
|
||||
}
|
||||
@@ -519,31 +519,138 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_should_run_background_update_logic() {
|
||||
// Note: This test uses the shared state file, so results may vary
|
||||
// depending on previous test runs. This is expected behavior.
|
||||
// Create isolated test states to avoid interference
|
||||
let current_time = VersionUpdater::get_current_timestamp();
|
||||
|
||||
// Test with recent update (should not update)
|
||||
let recent_state = BackgroundUpdateState {
|
||||
last_update_time: VersionUpdater::get_current_timestamp() - 60, // 1 minute ago
|
||||
last_update_time: current_time - 60, // 1 minute ago
|
||||
update_interval_hours: 3,
|
||||
};
|
||||
VersionUpdater::save_background_update_state(&recent_state).unwrap();
|
||||
assert!(!VersionUpdater::should_run_background_update());
|
||||
|
||||
// Save and test recent state
|
||||
let save_result = VersionUpdater::save_background_update_state(&recent_state);
|
||||
assert!(save_result.is_ok(), "Should save recent state successfully");
|
||||
|
||||
let should_update_recent = VersionUpdater::should_run_background_update();
|
||||
assert!(
|
||||
!should_update_recent,
|
||||
"Should not update when last update was recent"
|
||||
);
|
||||
|
||||
// Test with old update (should update)
|
||||
let old_state = BackgroundUpdateState {
|
||||
last_update_time: VersionUpdater::get_current_timestamp() - (4 * 60 * 60), // 4 hours ago
|
||||
last_update_time: current_time - (4 * 60 * 60), // 4 hours ago
|
||||
update_interval_hours: 3,
|
||||
};
|
||||
VersionUpdater::save_background_update_state(&old_state).unwrap();
|
||||
assert!(VersionUpdater::should_run_background_update());
|
||||
|
||||
// Save and test old state
|
||||
let save_result = VersionUpdater::save_background_update_state(&old_state);
|
||||
assert!(save_result.is_ok(), "Should save old state successfully");
|
||||
|
||||
let should_update_old = VersionUpdater::should_run_background_update();
|
||||
assert!(should_update_old, "Should update when last update was old");
|
||||
|
||||
// Test with never updated (should update)
|
||||
let never_updated_state = BackgroundUpdateState {
|
||||
last_update_time: 0,
|
||||
update_interval_hours: 3,
|
||||
};
|
||||
|
||||
let save_result = VersionUpdater::save_background_update_state(&never_updated_state);
|
||||
assert!(
|
||||
save_result.is_ok(),
|
||||
"Should save never updated state successfully"
|
||||
);
|
||||
|
||||
let should_update_never = VersionUpdater::should_run_background_update();
|
||||
assert!(
|
||||
should_update_never,
|
||||
"Should update when never updated before"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_cache_dir_creation() {
|
||||
// This should not panic and should create the directory if it doesn't exist
|
||||
let cache_dir = VersionUpdater::get_cache_dir().unwrap();
|
||||
assert!(cache_dir.exists());
|
||||
assert!(cache_dir.is_dir());
|
||||
let cache_dir_result = VersionUpdater::get_cache_dir();
|
||||
assert!(
|
||||
cache_dir_result.is_ok(),
|
||||
"Should successfully get cache directory"
|
||||
);
|
||||
|
||||
let cache_dir = cache_dir_result.unwrap();
|
||||
assert!(
|
||||
cache_dir.exists(),
|
||||
"Cache directory should exist after creation"
|
||||
);
|
||||
assert!(cache_dir.is_dir(), "Cache directory should be a directory");
|
||||
|
||||
// Verify the path contains expected components
|
||||
let path_str = cache_dir.to_string_lossy();
|
||||
assert!(
|
||||
path_str.contains("version_cache"),
|
||||
"Path should contain version_cache"
|
||||
);
|
||||
|
||||
// Test that calling it again returns the same directory
|
||||
let cache_dir2 = VersionUpdater::get_cache_dir().unwrap();
|
||||
assert_eq!(
|
||||
cache_dir, cache_dir2,
|
||||
"Multiple calls should return same directory"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_version_updater_creation() {
|
||||
let updater = VersionUpdater::new();
|
||||
|
||||
// Should have valid references to services
|
||||
assert!(
|
||||
!std::ptr::eq(updater.version_service as *const _, std::ptr::null()),
|
||||
"Version service should not be null"
|
||||
);
|
||||
assert!(
|
||||
!std::ptr::eq(updater.auto_updater as *const _, std::ptr::null()),
|
||||
"Auto updater should not be null"
|
||||
);
|
||||
assert!(
|
||||
updater.app_handle.is_none(),
|
||||
"App handle should initially be None"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_current_timestamp() {
|
||||
let timestamp1 = VersionUpdater::get_current_timestamp();
|
||||
|
||||
// Should be a reasonable timestamp (after year 2020)
|
||||
assert!(
|
||||
timestamp1 > 1577836800,
|
||||
"Timestamp should be after 2020-01-01"
|
||||
); // 2020-01-01 00:00:00 UTC
|
||||
|
||||
// Should be before year 2100
|
||||
assert!(
|
||||
timestamp1 < 4102444800,
|
||||
"Timestamp should be before 2100-01-01"
|
||||
); // 2100-01-01 00:00:00 UTC
|
||||
|
||||
// Wait a tiny bit and check it increases
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
let timestamp2 = VersionUpdater::get_current_timestamp();
|
||||
assert!(timestamp2 >= timestamp1, "Timestamp should not decrease");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_version_updater_singleton() {
|
||||
let updater1 = get_version_updater();
|
||||
let updater2 = get_version_updater();
|
||||
|
||||
// Should return the same Arc instance
|
||||
assert!(
|
||||
Arc::ptr_eq(&updater1, &updater2),
|
||||
"Should return same singleton instance"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut Browser",
|
||||
"version": "0.7.0",
|
||||
"version": "0.9.2",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1 @@
|
||||
Hello, World!
|
||||
Binary file not shown.
@@ -0,0 +1,133 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Utility functions for integration tests
|
||||
pub struct TestUtils;
|
||||
|
||||
impl TestUtils {
|
||||
/// Build the nodecar binary if it doesn't exist
|
||||
pub async fn ensure_nodecar_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR")?;
|
||||
let project_root = PathBuf::from(cargo_manifest_dir)
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
let nodecar_dir = project_root.join("nodecar");
|
||||
let nodecar_binary = nodecar_dir.join("nodecar-bin");
|
||||
|
||||
// Check if binary already exists
|
||||
if nodecar_binary.exists() {
|
||||
return Ok(nodecar_binary);
|
||||
}
|
||||
|
||||
println!("Building nodecar binary for integration tests...");
|
||||
|
||||
// Install dependencies
|
||||
let install_status = Command::new("pnpm")
|
||||
.args(["install", "--frozen-lockfile"])
|
||||
.current_dir(&nodecar_dir)
|
||||
.status()?;
|
||||
|
||||
if !install_status.success() {
|
||||
return Err("Failed to install nodecar dependencies".into());
|
||||
}
|
||||
|
||||
// Build the binary
|
||||
let build_status = Command::new("pnpm")
|
||||
.args(["run", "build"])
|
||||
.current_dir(&nodecar_dir)
|
||||
.status()?;
|
||||
|
||||
if !build_status.success() {
|
||||
return Err("Failed to build nodecar binary".into());
|
||||
}
|
||||
|
||||
if !nodecar_binary.exists() {
|
||||
return Err("Nodecar binary was not created successfully".into());
|
||||
}
|
||||
|
||||
Ok(nodecar_binary)
|
||||
}
|
||||
|
||||
/// Execute a nodecar command with timeout
|
||||
pub async fn execute_nodecar_command(
|
||||
binary_path: &PathBuf,
|
||||
args: &[&str],
|
||||
) -> Result<std::process::Output, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cmd = Command::new(binary_path);
|
||||
cmd.args(args);
|
||||
|
||||
let output = tokio::process::Command::from(cmd).output().await?;
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Check if a port is available
|
||||
pub async fn is_port_available(port: u16) -> bool {
|
||||
tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Wait for a port to become available or occupied
|
||||
pub async fn wait_for_port_state(port: u16, should_be_occupied: bool, timeout_secs: u64) -> bool {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed().as_secs() < timeout_secs {
|
||||
let is_available = Self::is_port_available(port).await;
|
||||
|
||||
if should_be_occupied && !is_available {
|
||||
return true; // Port is occupied as expected
|
||||
} else if !should_be_occupied && is_available {
|
||||
return true; // Port is available as expected
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Create a temporary directory for test files
|
||||
pub fn create_temp_dir() -> Result<tempfile::TempDir, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(tempfile::tempdir()?)
|
||||
}
|
||||
|
||||
/// Clean up specific nodecar processes by IDs (for targeted test cleanup)
|
||||
pub async fn cleanup_specific_processes(
|
||||
nodecar_path: &PathBuf,
|
||||
proxy_ids: &[String],
|
||||
camoufox_ids: &[String],
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Cleaning up specific test processes...");
|
||||
|
||||
// Stop specific proxies
|
||||
for proxy_id in proxy_ids {
|
||||
let stop_args = ["proxy", "stop", "--id", proxy_id];
|
||||
if let Ok(output) = Self::execute_nodecar_command(nodecar_path, &stop_args).await {
|
||||
if output.status.success() {
|
||||
println!("Stopped test proxy: {proxy_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Stop specific camoufox instances
|
||||
for camoufox_id in camoufox_ids {
|
||||
let stop_args = ["camoufox", "stop", "--id", camoufox_id];
|
||||
if let Ok(output) = Self::execute_nodecar_command(nodecar_path, &stop_args).await {
|
||||
if output.status.success() {
|
||||
println!("Stopped test camoufox instance: {camoufox_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Give processes time to clean up
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
|
||||
println!("Test process cleanup completed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,999 @@
|
||||
mod common;
|
||||
use common::TestUtils;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Setup function to ensure clean state before tests
|
||||
async fn setup_test() -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = TestUtils::ensure_nodecar_binary().await?;
|
||||
|
||||
// Only clean up test-specific processes, not all processes
|
||||
// This prevents interfering with actual app usage during testing
|
||||
println!("Setting up test environment...");
|
||||
|
||||
Ok(nodecar_path)
|
||||
}
|
||||
|
||||
/// Helper to track and cleanup specific test resources
|
||||
struct TestResourceTracker {
|
||||
proxy_ids: Vec<String>,
|
||||
camoufox_ids: Vec<String>,
|
||||
nodecar_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl TestResourceTracker {
|
||||
fn new(nodecar_path: std::path::PathBuf) -> Self {
|
||||
Self {
|
||||
proxy_ids: Vec::new(),
|
||||
camoufox_ids: Vec::new(),
|
||||
nodecar_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn track_proxy(&mut self, proxy_id: String) {
|
||||
self.proxy_ids.push(proxy_id);
|
||||
}
|
||||
|
||||
fn track_camoufox(&mut self, camoufox_id: String) {
|
||||
self.camoufox_ids.push(camoufox_id);
|
||||
}
|
||||
|
||||
async fn cleanup_all(&self) {
|
||||
// Use targeted cleanup to only stop test-specific processes
|
||||
let _ = TestUtils::cleanup_specific_processes(
|
||||
&self.nodecar_path,
|
||||
&self.proxy_ids,
|
||||
&self.camoufox_ids,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestResourceTracker {
|
||||
fn drop(&mut self) {
|
||||
// Ensure cleanup happens even if test panics
|
||||
let proxy_ids = self.proxy_ids.clone();
|
||||
let camoufox_ids = self.camoufox_ids.clone();
|
||||
let nodecar_path = self.nodecar_path.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
let _ = TestUtils::cleanup_specific_processes(&nodecar_path, &proxy_ids, &camoufox_ids).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Integration tests for nodecar proxy functionality
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_proxy_lifecycle() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Test proxy start with a known working upstream
|
||||
let args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"httpbin.org",
|
||||
"--proxy-port",
|
||||
"80",
|
||||
"--type",
|
||||
"http",
|
||||
];
|
||||
|
||||
println!("Starting proxy with nodecar...");
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
tracker.cleanup_all().await;
|
||||
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Verify proxy configuration structure
|
||||
assert!(config["id"].is_string(), "Proxy ID should be a string");
|
||||
assert!(
|
||||
config["localPort"].is_number(),
|
||||
"Local port should be a number"
|
||||
);
|
||||
assert!(
|
||||
config["localUrl"].is_string(),
|
||||
"Local URL should be a string"
|
||||
);
|
||||
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
let local_port = config["localPort"].as_u64().unwrap() as u16;
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
println!("Proxy started with ID: {proxy_id} on port: {local_port}");
|
||||
|
||||
// Wait for the proxy to start listening
|
||||
let is_listening = TestUtils::wait_for_port_state(local_port, true, 10).await;
|
||||
assert!(
|
||||
is_listening,
|
||||
"Proxy should be listening on the assigned port"
|
||||
);
|
||||
|
||||
// Test stopping the proxy
|
||||
let stop_args = ["proxy", "stop", "--id", &proxy_id];
|
||||
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
|
||||
|
||||
assert!(stop_output.status.success(), "Proxy stop should succeed");
|
||||
|
||||
let port_available = TestUtils::wait_for_port_state(local_port, false, 5).await;
|
||||
assert!(
|
||||
port_available,
|
||||
"Port should be available after stopping proxy"
|
||||
);
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test proxy with authentication
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_proxy_with_auth() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"httpbin.org",
|
||||
"--proxy-port",
|
||||
"80",
|
||||
"--type",
|
||||
"http",
|
||||
"--username",
|
||||
"testuser",
|
||||
"--password",
|
||||
"testpass",
|
||||
];
|
||||
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
// Verify upstream URL contains encoded credentials
|
||||
if let Some(upstream_url) = config["upstreamUrl"].as_str() {
|
||||
assert!(
|
||||
upstream_url.contains("testuser"),
|
||||
"Upstream URL should contain username"
|
||||
);
|
||||
// Password might be encoded, so we check for the presence of auth info
|
||||
assert!(
|
||||
upstream_url.contains("@"),
|
||||
"Upstream URL should contain auth separator"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test proxy list functionality
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_proxy_list() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Start a proxy first
|
||||
let start_args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"httpbin.org",
|
||||
"--proxy-port",
|
||||
"80",
|
||||
"--type",
|
||||
"http",
|
||||
];
|
||||
|
||||
let start_output = TestUtils::execute_nodecar_command(&nodecar_path, &start_args).await?;
|
||||
|
||||
if start_output.status.success() {
|
||||
let stdout = String::from_utf8(start_output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
// Test list command
|
||||
let list_args = ["proxy", "list"];
|
||||
let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args).await?;
|
||||
|
||||
assert!(list_output.status.success(), "Proxy list should succeed");
|
||||
|
||||
let list_stdout = String::from_utf8(list_output.stdout)?;
|
||||
let proxy_list: Value = serde_json::from_str(&list_stdout)?;
|
||||
|
||||
assert!(proxy_list.is_array(), "Proxy list should be an array");
|
||||
|
||||
let proxies = proxy_list.as_array().unwrap();
|
||||
assert!(
|
||||
!proxies.is_empty(),
|
||||
"Should have at least one proxy in the list"
|
||||
);
|
||||
|
||||
// Find our proxy in the list
|
||||
let found_proxy = proxies.iter().find(|p| p["id"].as_str() == Some(&proxy_id));
|
||||
assert!(found_proxy.is_some(), "Started proxy should be in the list");
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox functionality
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_lifecycle() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let temp_dir = TestUtils::create_temp_dir()?;
|
||||
let profile_path = temp_dir.path().join("test_profile");
|
||||
|
||||
let args = [
|
||||
"camoufox",
|
||||
"start",
|
||||
"--profile-path",
|
||||
profile_path.to_str().unwrap(),
|
||||
"--headless",
|
||||
];
|
||||
|
||||
println!("Starting Camoufox with nodecar...");
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// If Camoufox is not installed or times out, skip the test
|
||||
if stderr.contains("not installed")
|
||||
|| stderr.contains("not found")
|
||||
|| stderr.contains("timeout")
|
||||
|| stdout.contains("timeout")
|
||||
{
|
||||
println!("Skipping Camoufox test - Camoufox not available or timed out");
|
||||
tracker.cleanup_all().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
return Err(format!("Camoufox start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Verify Camoufox configuration structure
|
||||
assert!(config["id"].is_string(), "Camoufox ID should be a string");
|
||||
|
||||
let camoufox_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_camoufox(camoufox_id.clone());
|
||||
println!("Camoufox started with ID: {camoufox_id}");
|
||||
|
||||
// Test stopping Camoufox
|
||||
let stop_args = ["camoufox", "stop", "--id", &camoufox_id];
|
||||
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
|
||||
|
||||
assert!(stop_output.status.success(), "Camoufox stop should succeed");
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox with URL opening
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_with_url() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let temp_dir = TestUtils::create_temp_dir()?;
|
||||
let profile_path = temp_dir.path().join("test_profile_url");
|
||||
|
||||
let args = [
|
||||
"camoufox",
|
||||
"start",
|
||||
"--profile-path",
|
||||
profile_path.to_str().unwrap(),
|
||||
"--url",
|
||||
"https://httpbin.org/get",
|
||||
"--headless",
|
||||
];
|
||||
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
let camoufox_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_camoufox(camoufox_id.clone());
|
||||
|
||||
// Verify URL is set
|
||||
if let Some(url) = config["url"].as_str() {
|
||||
assert_eq!(
|
||||
url, "https://httpbin.org/get",
|
||||
"URL should match what was provided"
|
||||
);
|
||||
}
|
||||
|
||||
// Test stopping Camoufox explicitly
|
||||
let stop_args = ["camoufox", "stop", "--id", &camoufox_id];
|
||||
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
|
||||
assert!(stop_output.status.success(), "Camoufox stop should succeed");
|
||||
} else {
|
||||
println!("Skipping Camoufox URL test - likely not installed");
|
||||
tracker.cleanup_all().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox list functionality
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_list() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Test list command (should work even without Camoufox installed)
|
||||
let list_args = ["camoufox", "list"];
|
||||
let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args).await?;
|
||||
|
||||
assert!(list_output.status.success(), "Camoufox list should succeed");
|
||||
|
||||
let list_stdout = String::from_utf8(list_output.stdout)?;
|
||||
let camoufox_list: Value = serde_json::from_str(&list_stdout)?;
|
||||
|
||||
assert!(camoufox_list.is_array(), "Camoufox list should be an array");
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox process tracking and management
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_process_tracking(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let temp_dir = TestUtils::create_temp_dir()?;
|
||||
let profile_path = temp_dir.path().join("test_profile_tracking");
|
||||
|
||||
// Start multiple Camoufox instances
|
||||
let mut instance_ids: Vec<String> = Vec::new();
|
||||
|
||||
for i in 0..2 {
|
||||
let instance_profile_path = format!("{}_instance_{}", profile_path.to_str().unwrap(), i);
|
||||
let args = [
|
||||
"camoufox",
|
||||
"start",
|
||||
"--profile-path",
|
||||
&instance_profile_path,
|
||||
"--headless",
|
||||
];
|
||||
|
||||
println!("Starting Camoufox instance {i}...");
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// If Camoufox is not installed, skip the test
|
||||
if stderr.contains("not installed") || stderr.contains("not found") {
|
||||
println!("Skipping Camoufox process tracking test - Camoufox not installed");
|
||||
tracker.cleanup_all().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
return Err(
|
||||
format!("Camoufox instance {i} start failed - stdout: {stdout}, stderr: {stderr}").into(),
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
let camoufox_id = config["id"].as_str().unwrap().to_string();
|
||||
instance_ids.push(camoufox_id.clone());
|
||||
tracker.track_camoufox(camoufox_id.clone());
|
||||
println!("Camoufox instance {i} started with ID: {camoufox_id}");
|
||||
}
|
||||
|
||||
// Verify all instances are tracked
|
||||
let list_args = ["camoufox", "list"];
|
||||
let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args).await?;
|
||||
|
||||
assert!(list_output.status.success(), "Camoufox list should succeed");
|
||||
|
||||
let list_stdout = String::from_utf8(list_output.stdout)?;
|
||||
println!("Camoufox list output: {list_stdout}");
|
||||
let instances: Value = serde_json::from_str(&list_stdout)?;
|
||||
|
||||
let instances_array = instances.as_array().unwrap();
|
||||
println!("Found {} instances in list", instances_array.len());
|
||||
|
||||
// Verify our instances are in the list
|
||||
for instance_id in &instance_ids {
|
||||
let instance_found = instances_array
|
||||
.iter()
|
||||
.any(|i| i["id"].as_str() == Some(instance_id));
|
||||
if !instance_found {
|
||||
println!("Instance {instance_id} not found in list. Available instances:");
|
||||
for instance in instances_array {
|
||||
if let Some(id) = instance["id"].as_str() {
|
||||
println!(" - {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
instance_found,
|
||||
"Camoufox instance {instance_id} should be found in list"
|
||||
);
|
||||
}
|
||||
|
||||
// Stop all instances individually
|
||||
for instance_id in &instance_ids {
|
||||
println!("Stopping Camoufox instance: {instance_id}");
|
||||
let stop_args = ["camoufox", "stop", "--id", instance_id];
|
||||
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
|
||||
|
||||
if stop_output.status.success() {
|
||||
let stop_stdout = String::from_utf8(stop_output.stdout)?;
|
||||
if let Ok(stop_result) = serde_json::from_str::<Value>(&stop_stdout) {
|
||||
let success = stop_result["success"].as_bool().unwrap_or(false);
|
||||
if !success {
|
||||
println!("Warning: Stop command returned success=false for instance {instance_id}");
|
||||
}
|
||||
} else {
|
||||
println!("Warning: Could not parse stop result for instance {instance_id}");
|
||||
}
|
||||
} else {
|
||||
println!("Warning: Stop command failed for instance {instance_id}");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all instances are removed
|
||||
let list_output_after = TestUtils::execute_nodecar_command(&nodecar_path, &list_args).await?;
|
||||
|
||||
let instances_after: Value = serde_json::from_str(&String::from_utf8(list_output_after.stdout)?)?;
|
||||
let instances_after_array = instances_after.as_array().unwrap();
|
||||
|
||||
for instance_id in &instance_ids {
|
||||
let instance_still_exists = instances_after_array
|
||||
.iter()
|
||||
.any(|i| i["id"].as_str() == Some(instance_id));
|
||||
assert!(
|
||||
!instance_still_exists,
|
||||
"Stopped Camoufox instance {instance_id} should not be found in list"
|
||||
);
|
||||
}
|
||||
|
||||
println!("Camoufox process tracking test completed successfully");
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox with various configuration options
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_configuration_options(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let temp_dir = TestUtils::create_temp_dir()?;
|
||||
let profile_path = temp_dir.path().join("test_profile_config");
|
||||
|
||||
let args = [
|
||||
"camoufox",
|
||||
"start",
|
||||
"--profile-path",
|
||||
profile_path.to_str().unwrap(),
|
||||
"--block-images",
|
||||
"--max-width",
|
||||
"1920",
|
||||
"--max-height",
|
||||
"1080",
|
||||
"--headless",
|
||||
];
|
||||
|
||||
println!("Starting Camoufox with configuration options...");
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// If Camoufox is not installed, skip the test
|
||||
if stderr.contains("not installed") || stderr.contains("not found") {
|
||||
println!("Skipping Camoufox configuration test - Camoufox not installed");
|
||||
tracker.cleanup_all().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
return Err(
|
||||
format!("Camoufox with config start failed - stdout: {stdout}, stderr: {stderr}").into(),
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
let camoufox_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_camoufox(camoufox_id.clone());
|
||||
println!("Camoufox with configuration started with ID: {camoufox_id}");
|
||||
|
||||
// Verify configuration was applied by checking the profile path
|
||||
if let Some(returned_profile_path) = config["profilePath"].as_str() {
|
||||
assert!(
|
||||
returned_profile_path.contains("test_profile_config"),
|
||||
"Profile path should match what was provided"
|
||||
);
|
||||
}
|
||||
|
||||
// Test stopping Camoufox explicitly
|
||||
let stop_args = ["camoufox", "stop", "--id", &camoufox_id];
|
||||
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
|
||||
|
||||
assert!(stop_output.status.success(), "Camoufox stop should succeed");
|
||||
|
||||
println!("Camoufox configuration test completed successfully");
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox generate-config command with basic options
|
||||
#[ignore = "CI is rate limited for camoufox download"]
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_generate_config_basic(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let args = [
|
||||
"camoufox",
|
||||
"generate-config",
|
||||
"--max-width",
|
||||
"1920",
|
||||
"--max-height",
|
||||
"1080",
|
||||
"--block-images",
|
||||
];
|
||||
|
||||
println!("Testing Camoufox config generation with basic options...");
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
return Err(
|
||||
format!("Camoufox generate-config failed - stdout: {stdout}, stderr: {stderr}").into(),
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
println!("Generated config output: {stdout}");
|
||||
|
||||
// Parse the generated config as JSON
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Verify the config contains expected properties
|
||||
assert!(
|
||||
config.is_object(),
|
||||
"Generated config should be a JSON object"
|
||||
);
|
||||
|
||||
// Check for some expected fingerprint properties
|
||||
assert!(
|
||||
config.get("screen.width").is_some(),
|
||||
"Config should contain screen.width"
|
||||
);
|
||||
assert!(
|
||||
config.get("screen.height").is_some(),
|
||||
"Config should contain screen.height"
|
||||
);
|
||||
assert!(
|
||||
config.get("navigator.userAgent").is_some(),
|
||||
"Config should contain navigator.userAgent"
|
||||
);
|
||||
|
||||
println!("Camoufox generate-config basic test completed successfully");
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox generate-config command with custom fingerprint
|
||||
#[ignore = "CI is rate limited for camoufox download"]
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_generate_config_custom_fingerprint(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Create a custom fingerprint JSON
|
||||
let custom_fingerprint = r#"{
|
||||
"screen.width": 1440,
|
||||
"screen.height": 900,
|
||||
"navigator.userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/140.0",
|
||||
"navigator.platform": "TestPlatform",
|
||||
"timezone": "America/New_York",
|
||||
"locale:language": "en",
|
||||
"locale:region": "US"
|
||||
}"#;
|
||||
|
||||
let args = [
|
||||
"camoufox",
|
||||
"generate-config",
|
||||
"--fingerprint",
|
||||
custom_fingerprint,
|
||||
"--block-webrtc",
|
||||
];
|
||||
|
||||
println!("Testing Camoufox config generation with custom fingerprint...");
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
return Err(
|
||||
format!("Camoufox generate-config with custom fingerprint failed - stdout: {stdout}, stderr: {stderr}").into(),
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
// Parse the generated config as JSON
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Verify the config contains expected properties
|
||||
assert!(
|
||||
config.is_object(),
|
||||
"Generated config should be a JSON object"
|
||||
);
|
||||
|
||||
// Check that our custom values are preserved
|
||||
assert_eq!(
|
||||
config.get("screen.width").and_then(|v| v.as_u64()),
|
||||
Some(1440),
|
||||
"Custom screen width should be preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
config.get("screen.height").and_then(|v| v.as_u64()),
|
||||
Some(900),
|
||||
"Custom screen height should be preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
config.get("navigator.userAgent").and_then(|v| v.as_str()),
|
||||
Some("Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:135.0) Gecko/20100101 Firefox/140.0"),
|
||||
"Custom user agent should be preserved"
|
||||
);
|
||||
assert_eq!(
|
||||
config.get("timezone").and_then(|v| v.as_str()),
|
||||
Some("America/New_York"),
|
||||
"Custom timezone should be preserved"
|
||||
);
|
||||
|
||||
println!("Camoufox generate-config custom fingerprint test completed successfully");
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test nodecar command validation
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_command_validation() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Test invalid command
|
||||
let invalid_args = ["invalid", "command"];
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &invalid_args).await?;
|
||||
|
||||
assert!(!output.status.success(), "Invalid command should fail");
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test concurrent proxy operations
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_concurrent_proxies() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Start multiple proxies concurrently
|
||||
let mut handles = vec![];
|
||||
|
||||
for i in 0..3 {
|
||||
let nodecar_path_clone = nodecar_path.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"httpbin.org",
|
||||
"--proxy-port",
|
||||
"80",
|
||||
"--type",
|
||||
"http",
|
||||
];
|
||||
|
||||
TestUtils::execute_nodecar_command(&nodecar_path_clone, &args).await
|
||||
});
|
||||
handles.push((i, handle));
|
||||
}
|
||||
|
||||
// Wait for all proxies to start
|
||||
for (i, handle) in handles {
|
||||
match handle.await.map_err(|e| format!("Join error: {e}"))? {
|
||||
Ok(output) if output.status.success() => {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
println!("Proxy {i} started successfully");
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
println!("Proxy {i} failed to start: {stderr}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Proxy {i} error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test proxy with different upstream types
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_proxy_types() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let test_cases = vec![
|
||||
("http", "httpbin.org", "80"),
|
||||
("https", "httpbin.org", "443"),
|
||||
];
|
||||
|
||||
for (proxy_type, host, port) in test_cases {
|
||||
println!("Testing {proxy_type} proxy to {host}:{port}");
|
||||
|
||||
let args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
host,
|
||||
"--proxy-port",
|
||||
port,
|
||||
"--type",
|
||||
proxy_type,
|
||||
];
|
||||
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
println!("{proxy_type} proxy test passed");
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
println!("{proxy_type} proxy test failed: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test direct proxy (no upstream) functionality
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_direct_proxy() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Test starting a direct proxy (no upstream)
|
||||
let args = ["proxy", "start"];
|
||||
|
||||
println!("Starting direct proxy with nodecar...");
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args).await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
tracker.cleanup_all().await;
|
||||
return Err(format!("Direct proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Verify proxy configuration structure
|
||||
assert!(config["id"].is_string(), "Proxy ID should be a string");
|
||||
assert!(
|
||||
config["localPort"].is_number(),
|
||||
"Local port should be a number"
|
||||
);
|
||||
assert!(
|
||||
config["localUrl"].is_string(),
|
||||
"Local URL should be a string"
|
||||
);
|
||||
assert_eq!(
|
||||
config["upstreamUrl"].as_str().unwrap(),
|
||||
"DIRECT",
|
||||
"Upstream URL should be DIRECT"
|
||||
);
|
||||
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
let local_port = config["localPort"].as_u64().unwrap() as u16;
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
println!("Direct proxy started with ID: {proxy_id} on port: {local_port}");
|
||||
|
||||
// Wait for the proxy to start listening
|
||||
let is_listening = TestUtils::wait_for_port_state(local_port, true, 10).await;
|
||||
assert!(
|
||||
is_listening,
|
||||
"Direct proxy should be listening on the assigned port"
|
||||
);
|
||||
|
||||
// Test stopping the proxy
|
||||
let stop_args = ["proxy", "stop", "--id", &proxy_id];
|
||||
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args).await?;
|
||||
|
||||
assert!(
|
||||
stop_output.status.success(),
|
||||
"Direct proxy stop should succeed"
|
||||
);
|
||||
|
||||
let port_available = TestUtils::wait_for_port_state(local_port, false, 5).await;
|
||||
assert!(
|
||||
port_available,
|
||||
"Port should be available after stopping direct proxy"
|
||||
);
|
||||
|
||||
println!("Direct proxy test completed successfully");
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test SOCKS5 proxy chaining - create two proxies where the second uses the first as upstream
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_socks5_proxy_chaining() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Step 1: Start a SOCKS5 proxy with a known working upstream (httpbin.org)
|
||||
let socks5_args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"httpbin.org",
|
||||
"--proxy-port",
|
||||
"80",
|
||||
"--type",
|
||||
"http", // Use HTTP upstream for the first proxy
|
||||
];
|
||||
|
||||
println!("Starting first proxy with HTTP upstream...");
|
||||
let socks5_output = TestUtils::execute_nodecar_command(&nodecar_path, &socks5_args).await?;
|
||||
|
||||
if !socks5_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&socks5_output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&socks5_output.stdout);
|
||||
tracker.cleanup_all().await;
|
||||
return Err(format!("First proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let socks5_stdout = String::from_utf8(socks5_output.stdout)?;
|
||||
let socks5_config: Value = serde_json::from_str(&socks5_stdout)?;
|
||||
|
||||
let socks5_proxy_id = socks5_config["id"].as_str().unwrap().to_string();
|
||||
let socks5_local_port = socks5_config["localPort"].as_u64().unwrap() as u16;
|
||||
tracker.track_proxy(socks5_proxy_id.clone());
|
||||
|
||||
println!("First proxy started with ID: {socks5_proxy_id} on port: {socks5_local_port}");
|
||||
|
||||
// Step 2: Start a second proxy that uses the first proxy as upstream
|
||||
let http_proxy_args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--upstream",
|
||||
&format!("http://127.0.0.1:{socks5_local_port}"),
|
||||
];
|
||||
|
||||
println!("Starting second proxy with first proxy as upstream...");
|
||||
let http_output = TestUtils::execute_nodecar_command(&nodecar_path, &http_proxy_args).await?;
|
||||
|
||||
if !http_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&http_output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&http_output.stdout);
|
||||
tracker.cleanup_all().await;
|
||||
return Err(
|
||||
format!("Second proxy with chained upstream failed - stdout: {stdout}, stderr: {stderr}")
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let http_stdout = String::from_utf8(http_output.stdout)?;
|
||||
let http_config: Value = serde_json::from_str(&http_stdout)?;
|
||||
|
||||
let http_proxy_id = http_config["id"].as_str().unwrap().to_string();
|
||||
let http_local_port = http_config["localPort"].as_u64().unwrap() as u16;
|
||||
tracker.track_proxy(http_proxy_id.clone());
|
||||
|
||||
println!(
|
||||
"Second proxy started with ID: {http_proxy_id} on port: {http_local_port} (chained through first proxy)"
|
||||
);
|
||||
|
||||
// Verify both proxies are listening by waiting for them to be occupied
|
||||
let socks5_listening = TestUtils::wait_for_port_state(socks5_local_port, true, 5).await;
|
||||
let http_listening = TestUtils::wait_for_port_state(http_local_port, true, 5).await;
|
||||
|
||||
assert!(
|
||||
socks5_listening,
|
||||
"First proxy should be listening on port {socks5_local_port}"
|
||||
);
|
||||
assert!(
|
||||
http_listening,
|
||||
"Second proxy should be listening on port {http_local_port}"
|
||||
);
|
||||
|
||||
// Clean up both proxies
|
||||
let stop_http_args = ["proxy", "stop", "--id", &http_proxy_id];
|
||||
let stop_socks5_args = ["proxy", "stop", "--id", &socks5_proxy_id];
|
||||
|
||||
let http_stop_result = TestUtils::execute_nodecar_command(&nodecar_path, &stop_http_args).await;
|
||||
let socks5_stop_result =
|
||||
TestUtils::execute_nodecar_command(&nodecar_path, &stop_socks5_args).await;
|
||||
|
||||
// Verify cleanup
|
||||
assert!(
|
||||
http_stop_result.is_ok() && http_stop_result.unwrap().status.success(),
|
||||
"Second proxy stop should succeed"
|
||||
);
|
||||
assert!(
|
||||
socks5_stop_result.is_ok() && socks5_stop_result.unwrap().status.success(),
|
||||
"First proxy stop should succeed"
|
||||
);
|
||||
|
||||
let http_port_available = TestUtils::wait_for_port_state(http_local_port, false, 5).await;
|
||||
let socks5_port_available = TestUtils::wait_for_port_state(socks5_local_port, false, 5).await;
|
||||
|
||||
assert!(
|
||||
http_port_available,
|
||||
"Second proxy port should be available after stopping"
|
||||
);
|
||||
assert!(
|
||||
socks5_port_available,
|
||||
"First proxy port should be available after stopping"
|
||||
);
|
||||
|
||||
println!("Proxy chaining test completed successfully");
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
+1
-1
@@ -27,9 +27,9 @@ export default function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden`}
|
||||
>
|
||||
<CustomThemeProvider>
|
||||
<WindowDragArea />
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<Toaster />
|
||||
<WindowDragArea />
|
||||
</CustomThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+351
-228
@@ -4,40 +4,25 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { ChangeVersionDialog } from "@/components/change-version-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
||||
import { GroupBadges } from "@/components/group-badges";
|
||||
import { GroupManagementDialog } from "@/components/group-management-dialog";
|
||||
import HomeHeader from "@/components/home-header";
|
||||
import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
||||
import { PermissionDialog } from "@/components/permission-dialog";
|
||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
|
||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { ProxySettingsDialog } from "@/components/proxy-settings-dialog";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import { sleep } from "@/lib/utils";
|
||||
import type { BrowserProfile, CamoufoxConfig } from "@/types";
|
||||
import { showErrorToast, showToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, CamoufoxConfig, GroupWithCount } from "@/types";
|
||||
|
||||
type BrowserTypeString =
|
||||
| "mullvad-browser"
|
||||
@@ -55,32 +40,45 @@ interface PendingUrl {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [proxyDialogOpen, setProxyDialogOpen] = useState(false);
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [changeVersionDialogOpen, setChangeVersionDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
|
||||
const [proxyManagementDialogOpen, setProxyManagementDialogOpen] =
|
||||
useState(false);
|
||||
const [camoufoxConfigDialogOpen, setCamoufoxConfigDialogOpen] =
|
||||
useState(false);
|
||||
const [groupManagementDialogOpen, setGroupManagementDialogOpen] =
|
||||
useState(false);
|
||||
const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] =
|
||||
useState(false);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string>("default");
|
||||
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
|
||||
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
|
||||
const [currentProfileForProxy, setCurrentProfileForProxy] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [currentProfileForVersionChange, setCurrentProfileForVersionChange] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
||||
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
|
||||
const [groups, setGroups] = useState<GroupWithCount[]>([]);
|
||||
const [areGroupsLoading, setGroupsLoading] = useState(true);
|
||||
const [currentPermissionType, setCurrentPermissionType] =
|
||||
useState<PermissionType>("microphone");
|
||||
const [proxyDataReloadTrigger, setProxyDataReloadTrigger] = useState(0);
|
||||
const [showBulkDeleteConfirmation, setShowBulkDeleteConfirmation] =
|
||||
useState(false);
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
const handleSelectGroup = useCallback((groupId: string) => {
|
||||
setSelectedGroupId(groupId);
|
||||
setSelectedProfiles([]);
|
||||
}, []);
|
||||
|
||||
// Check for missing binaries and offer to download them
|
||||
const checkMissingBinaries = useCallback(async () => {
|
||||
try {
|
||||
@@ -88,8 +86,18 @@ export default function Home() {
|
||||
"check_missing_binaries",
|
||||
);
|
||||
|
||||
if (missingBinaries.length > 0) {
|
||||
console.log("Found missing binaries:", missingBinaries);
|
||||
// Also check for missing GeoIP database
|
||||
const missingGeoIP = await invoke<boolean>(
|
||||
"check_missing_geoip_database",
|
||||
);
|
||||
|
||||
if (missingBinaries.length > 0 || missingGeoIP) {
|
||||
if (missingBinaries.length > 0) {
|
||||
console.log("Found missing binaries:", missingBinaries);
|
||||
}
|
||||
if (missingGeoIP) {
|
||||
console.log("Found missing GeoIP database for Camoufox");
|
||||
}
|
||||
|
||||
// Group missing binaries by browser type to avoid concurrent downloads
|
||||
const browserMap = new Map<string, string[]>();
|
||||
@@ -104,34 +112,45 @@ export default function Home() {
|
||||
}
|
||||
|
||||
// Show a toast notification about missing binaries and auto-download them
|
||||
const missingList = Array.from(browserMap.entries())
|
||||
let missingList = Array.from(browserMap.entries())
|
||||
.map(([browser, versions]) => `${browser}: ${versions.join(", ")}`)
|
||||
.join(", ");
|
||||
|
||||
console.log(`Downloading missing binaries: ${missingList}`);
|
||||
if (missingGeoIP) {
|
||||
if (missingList) {
|
||||
missingList += ", GeoIP database for Camoufox";
|
||||
} else {
|
||||
missingList = "GeoIP database for Camoufox";
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Downloading missing components: ${missingList}`);
|
||||
|
||||
try {
|
||||
// Download missing binaries sequentially by browser type to prevent conflicts
|
||||
// Download missing binaries and GeoIP database sequentially to prevent conflicts
|
||||
const downloaded = await invoke<string[]>(
|
||||
"ensure_all_binaries_exist",
|
||||
);
|
||||
if (downloaded.length > 0) {
|
||||
console.log(
|
||||
"Successfully downloaded missing binaries:",
|
||||
"Successfully downloaded missing components:",
|
||||
downloaded,
|
||||
);
|
||||
}
|
||||
} catch (downloadError) {
|
||||
console.error("Failed to download missing binaries:", downloadError);
|
||||
console.error(
|
||||
"Failed to download missing components:",
|
||||
downloadError,
|
||||
);
|
||||
setError(
|
||||
`Failed to download missing binaries: ${JSON.stringify(
|
||||
`Failed to download missing components: ${JSON.stringify(
|
||||
downloadError,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to check missing binaries:", err);
|
||||
console.error("Failed to check missing components:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -151,33 +170,37 @@ export default function Home() {
|
||||
}
|
||||
}, [checkMissingBinaries]);
|
||||
|
||||
// Trigger proxy data reload in ProfilesDataTable
|
||||
const triggerProxyDataReload = useCallback(() => {
|
||||
setProxyDataReloadTrigger((prev) => prev + 1);
|
||||
}, []);
|
||||
const [processingUrls, setProcessingUrls] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleUrlOpen = useCallback(async (url: string) => {
|
||||
try {
|
||||
// Use smart profile selection
|
||||
const result = await invoke<string>("smart_open_url", {
|
||||
url,
|
||||
});
|
||||
console.log("Smart URL opening succeeded:", result);
|
||||
// URL was handled successfully, no need to show selector
|
||||
} catch (error: unknown) {
|
||||
console.log(
|
||||
"Smart URL opening failed or requires profile selection:",
|
||||
error,
|
||||
);
|
||||
const handleUrlOpen = useCallback(
|
||||
async (url: string) => {
|
||||
// Prevent duplicate processing of the same URL
|
||||
if (processingUrls.has(url)) {
|
||||
console.log("URL already being processed:", url);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show profile selector for manual selection
|
||||
// Replace any existing pending URL with the new one
|
||||
setPendingUrls([{ id: Date.now().toString(), url }]);
|
||||
}
|
||||
}, []);
|
||||
setProcessingUrls((prev) => new Set(prev).add(url));
|
||||
|
||||
// Version updater for handling version fetching progress events and auto-updates
|
||||
useVersionUpdater();
|
||||
try {
|
||||
console.log("URL received for opening:", url);
|
||||
|
||||
// Always show profile selector for manual selection - never auto-open
|
||||
// Replace any existing pending URL with the new one
|
||||
setPendingUrls([{ id: Date.now().toString(), url }]);
|
||||
} finally {
|
||||
// Remove URL from processing set after a short delay to prevent rapid duplicates
|
||||
setTimeout(() => {
|
||||
setProcessingUrls((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(url);
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
[processingUrls],
|
||||
);
|
||||
|
||||
// Auto-update functionality - use the existing hook for compatibility
|
||||
const updateNotifications = useUpdateNotifications(loadProfiles);
|
||||
@@ -191,17 +214,6 @@ export default function Home() {
|
||||
);
|
||||
setProfiles(profileList);
|
||||
|
||||
// TODO: remove after a few version bumps, needed to properly display migrated profiles
|
||||
setTimeout(async () => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const profiles = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
setProfiles(profiles);
|
||||
}
|
||||
await sleep(500);
|
||||
}, 0);
|
||||
|
||||
// Check for updates after loading profiles
|
||||
await checkForUpdates();
|
||||
await checkMissingBinaries();
|
||||
@@ -213,17 +225,23 @@ export default function Home() {
|
||||
|
||||
useAppUpdateNotifications();
|
||||
|
||||
// For some reason, app.deep_link().get_current() is not working properly
|
||||
// Check for startup URLs but only process them once
|
||||
const [hasCheckedStartupUrl, setHasCheckedStartupUrl] = useState(false);
|
||||
const checkCurrentUrl = useCallback(async () => {
|
||||
if (hasCheckedStartupUrl) return;
|
||||
|
||||
try {
|
||||
const currentUrl = await getCurrent();
|
||||
if (currentUrl && currentUrl.length > 0) {
|
||||
console.log("Startup URL detected:", currentUrl[0]);
|
||||
void handleUrlOpen(currentUrl[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check current URL:", error);
|
||||
} finally {
|
||||
setHasCheckedStartupUrl(true);
|
||||
}
|
||||
}, [handleUrlOpen]);
|
||||
}, [handleUrlOpen, hasCheckedStartupUrl]);
|
||||
|
||||
const checkStartupPrompt = useCallback(async () => {
|
||||
// Only check once during app startup to prevent reopening after dismissing notifications
|
||||
@@ -236,13 +254,35 @@ export default function Home() {
|
||||
if (shouldShow) {
|
||||
setSettingsDialogOpen(true);
|
||||
}
|
||||
setHasCheckedStartupPrompt(true);
|
||||
} catch (error) {
|
||||
console.error("Failed to check startup prompt:", error);
|
||||
} finally {
|
||||
setHasCheckedStartupPrompt(true);
|
||||
}
|
||||
}, [hasCheckedStartupPrompt]);
|
||||
|
||||
// Warm up nodecar at startup and block UI until complete
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
await invoke("warm_up_nodecar");
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
`Initialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsInitializing(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkAllPermissions = useCallback(async () => {
|
||||
try {
|
||||
// Wait for permissions to be initialized before checking
|
||||
@@ -279,19 +319,6 @@ export default function Home() {
|
||||
}
|
||||
}, [isMicrophoneAccessGranted, isCameraAccessGranted]);
|
||||
|
||||
const checkStartupUrls = useCallback(async () => {
|
||||
try {
|
||||
const hasStartupUrl = await invoke<boolean>(
|
||||
"check_and_handle_startup_url",
|
||||
);
|
||||
if (hasStartupUrl) {
|
||||
console.log("Handled startup URL successfully");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check startup URLs:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const listenForUrlEvents = useCallback(async () => {
|
||||
try {
|
||||
// Listen for URL open events from the deep link handler (when app is already running)
|
||||
@@ -303,7 +330,7 @@ export default function Home() {
|
||||
// Listen for show profile selector events
|
||||
await listen<string>("show-profile-selector", (event) => {
|
||||
console.log("Received show profile selector request:", event.payload);
|
||||
setPendingUrls([{ id: Date.now().toString(), url: event.payload }]);
|
||||
void handleUrlOpen(event.payload);
|
||||
});
|
||||
|
||||
// Listen for show create profile dialog events
|
||||
@@ -317,21 +344,30 @@ export default function Home() {
|
||||
);
|
||||
setCreateProfileDialogOpen(true);
|
||||
});
|
||||
|
||||
// Listen for custom logo click events
|
||||
const handleLogoUrlEvent = (event: CustomEvent) => {
|
||||
console.log("Received logo URL event:", event.detail);
|
||||
void handleUrlOpen(event.detail);
|
||||
};
|
||||
|
||||
window.addEventListener(
|
||||
"url-open-request",
|
||||
handleLogoUrlEvent as EventListener,
|
||||
);
|
||||
|
||||
// Return cleanup function
|
||||
return () => {
|
||||
window.removeEventListener(
|
||||
"url-open-request",
|
||||
handleLogoUrlEvent as EventListener,
|
||||
);
|
||||
};
|
||||
} catch (error) {
|
||||
console.error("Failed to setup URL listener:", error);
|
||||
}
|
||||
}, [handleUrlOpen]);
|
||||
|
||||
const openProxyDialog = useCallback((profile: BrowserProfile | null) => {
|
||||
setCurrentProfileForProxy(profile);
|
||||
setProxyDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const openChangeVersionDialog = useCallback((profile: BrowserProfile) => {
|
||||
setCurrentProfileForVersionChange(profile);
|
||||
setChangeVersionDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfigureCamoufox = useCallback((profile: BrowserProfile) => {
|
||||
setCurrentProfileForCamoufoxConfig(profile);
|
||||
setCamoufoxConfigDialogOpen(true);
|
||||
@@ -356,28 +392,20 @@ export default function Home() {
|
||||
[loadProfiles],
|
||||
);
|
||||
|
||||
const handleSaveProxy = useCallback(
|
||||
async (proxyId: string | null) => {
|
||||
setProxyDialogOpen(false);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (currentProfileForProxy) {
|
||||
await invoke("update_profile_proxy", {
|
||||
profileName: currentProfileForProxy.name,
|
||||
proxyId: proxyId,
|
||||
});
|
||||
}
|
||||
await loadProfiles();
|
||||
// Trigger proxy data reload in the table
|
||||
triggerProxyDataReload();
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to update proxy settings:", err);
|
||||
setError(`Failed to update proxy settings: ${JSON.stringify(err)}`);
|
||||
}
|
||||
},
|
||||
[currentProfileForProxy, loadProfiles, triggerProxyDataReload],
|
||||
);
|
||||
const loadGroups = useCallback(async () => {
|
||||
setGroupsLoading(true);
|
||||
try {
|
||||
const groupsWithCounts = await invoke<GroupWithCount[]>(
|
||||
"get_groups_with_profile_counts",
|
||||
);
|
||||
setGroups(groupsWithCounts);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups with counts:", err);
|
||||
setGroups([]);
|
||||
} finally {
|
||||
setGroupsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreateProfile = useCallback(
|
||||
async (profileData: {
|
||||
@@ -387,6 +415,7 @@ export default function Home() {
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
groupId?: string;
|
||||
}) => {
|
||||
setError(null);
|
||||
|
||||
@@ -400,12 +429,15 @@ export default function Home() {
|
||||
releaseType: profileData.releaseType,
|
||||
proxyId: profileData.proxyId,
|
||||
camoufoxConfig: profileData.camoufoxConfig,
|
||||
groupId:
|
||||
profileData.groupId ||
|
||||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
|
||||
},
|
||||
);
|
||||
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
// Trigger proxy data reload in the table
|
||||
triggerProxyDataReload();
|
||||
} catch (error) {
|
||||
setError(
|
||||
`Failed to create profile: ${
|
||||
@@ -415,7 +447,7 @@ export default function Home() {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[loadProfiles, triggerProxyDataReload],
|
||||
[loadProfiles, loadGroups, selectedGroupId],
|
||||
);
|
||||
|
||||
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
|
||||
@@ -433,6 +465,9 @@ export default function Home() {
|
||||
const currentRunning = runningProfilesRef.current.has(profile.name);
|
||||
|
||||
if (isRunning !== currentRunning) {
|
||||
console.log(
|
||||
`Profile ${profile.name} (${profile.browser}) status changed: ${currentRunning} -> ${isRunning}`,
|
||||
);
|
||||
setRunningProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isRunning) {
|
||||
@@ -510,10 +545,11 @@ export default function Home() {
|
||||
console.log("Profile deletion command completed successfully");
|
||||
|
||||
// Give a small delay to ensure file system operations complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Reload profiles to ensure UI is updated
|
||||
// Reload profiles and groups to ensure UI is updated
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
|
||||
console.log("Profile deleted and profiles reloaded successfully");
|
||||
} catch (err: unknown) {
|
||||
@@ -522,7 +558,7 @@ export default function Home() {
|
||||
setError(`Failed to delete profile: ${errorMessage}`);
|
||||
}
|
||||
},
|
||||
[loadProfiles],
|
||||
[loadProfiles, loadGroups],
|
||||
);
|
||||
|
||||
const handleRenameProfile = useCallback(
|
||||
@@ -554,17 +590,87 @@ export default function Home() {
|
||||
[loadProfiles],
|
||||
);
|
||||
|
||||
const handleDeleteSelectedProfiles = useCallback(
|
||||
async (profileNames: string[]) => {
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("delete_selected_profiles", { profileNames });
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete selected profiles:", err);
|
||||
setError(`Failed to delete selected profiles: ${JSON.stringify(err)}`);
|
||||
}
|
||||
},
|
||||
[loadProfiles, loadGroups],
|
||||
);
|
||||
|
||||
const handleAssignProfilesToGroup = useCallback((profileNames: string[]) => {
|
||||
setSelectedProfilesForGroup(profileNames);
|
||||
setGroupAssignmentDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
setShowBulkDeleteConfirmation(true);
|
||||
}, [selectedProfiles]);
|
||||
|
||||
const confirmBulkDelete = useCallback(async () => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
|
||||
setIsBulkDeleting(true);
|
||||
try {
|
||||
await invoke("delete_selected_profiles", {
|
||||
profileNames: selectedProfiles,
|
||||
});
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
setSelectedProfiles([]);
|
||||
setShowBulkDeleteConfirmation(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete selected profiles:", error);
|
||||
setError(`Failed to delete selected profiles: ${JSON.stringify(error)}`);
|
||||
} finally {
|
||||
setIsBulkDeleting(false);
|
||||
}
|
||||
}, [selectedProfiles, loadProfiles, loadGroups]);
|
||||
|
||||
const handleBulkGroupAssignment = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
handleAssignProfilesToGroup(selectedProfiles);
|
||||
setSelectedProfiles([]);
|
||||
}, [selectedProfiles, handleAssignProfilesToGroup]);
|
||||
|
||||
const handleGroupAssignmentComplete = useCallback(async () => {
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
setGroupAssignmentDialogOpen(false);
|
||||
setSelectedProfilesForGroup([]);
|
||||
}, [loadProfiles, loadGroups]);
|
||||
|
||||
const handleGroupManagementComplete = useCallback(async () => {
|
||||
await loadGroups();
|
||||
}, [loadGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfilesWithUpdateCheck();
|
||||
void loadGroups();
|
||||
|
||||
// Check for startup default browser prompt
|
||||
void checkStartupPrompt();
|
||||
|
||||
// Listen for URL open events
|
||||
void listenForUrlEvents();
|
||||
// Listen for URL open events and get cleanup function
|
||||
const setupListeners = async () => {
|
||||
const cleanup = await listenForUrlEvents();
|
||||
return cleanup;
|
||||
};
|
||||
|
||||
let cleanup: (() => void) | undefined;
|
||||
setupListeners().then((cleanupFn) => {
|
||||
cleanup = cleanupFn;
|
||||
});
|
||||
|
||||
// Check for startup URLs (when app was launched as default browser)
|
||||
void checkStartupUrls();
|
||||
void checkCurrentUrl();
|
||||
|
||||
// Set up periodic update checks (every 30 minutes)
|
||||
@@ -577,16 +683,52 @@ export default function Home() {
|
||||
|
||||
return () => {
|
||||
clearInterval(updateInterval);
|
||||
if (cleanup) {
|
||||
cleanup();
|
||||
}
|
||||
};
|
||||
}, [
|
||||
loadProfilesWithUpdateCheck,
|
||||
checkForUpdates,
|
||||
checkCurrentUrl,
|
||||
checkStartupPrompt,
|
||||
listenForUrlEvents,
|
||||
checkStartupUrls,
|
||||
checkCurrentUrl,
|
||||
loadGroups,
|
||||
]);
|
||||
|
||||
// Show deprecation warning for unsupported profiles (with names)
|
||||
useEffect(() => {
|
||||
if (profiles.length === 0) return;
|
||||
|
||||
const deprecatedProfiles = profiles.filter(
|
||||
(p) =>
|
||||
["tor-browser", "mullvad-browser"].includes(p.browser) ||
|
||||
(p.release_type === "nightly" && p.browser !== "firefox-developer"),
|
||||
);
|
||||
|
||||
if (deprecatedProfiles.length > 0) {
|
||||
const deprecatedNames = deprecatedProfiles.map((p) => p.name).join(", ");
|
||||
|
||||
// Use a stable id to avoid duplicate toasts on re-renders
|
||||
showToast({
|
||||
id: "deprecated-profiles-warning",
|
||||
type: "error",
|
||||
title: "Some profiles will be deprecated soon",
|
||||
description: `The following profiles will be deprecated soon: ${deprecatedNames}. Tor Browser, Mullvad Browser, and nightly profiles (except Firefox Developers Edition) will be removed in upcoming versions. Please check GitHub for migration instructions.`,
|
||||
duration: 15000,
|
||||
action: {
|
||||
label: "Learn more",
|
||||
onClick: () => {
|
||||
const event = new CustomEvent("url-open-request", {
|
||||
detail: "https://github.com/zhom/donutbrowser/discussions/66",
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [profiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profiles.length === 0) return;
|
||||
|
||||
@@ -621,95 +763,55 @@ export default function Home() {
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
|
||||
<main className="flex flex-col row-start-2 gap-8 items-center w-full max-w-3xl">
|
||||
<Card className="w-full">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>Profiles</CardTitle>
|
||||
<div className="flex gap-2 items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoKebabHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<GoGear className="mr-2 w-4 h-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setProxyManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FiWifi className="mr-2 w-4 h-4" />
|
||||
Proxies
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setImportProfileDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FaDownload className="mr-2 w-4 h-4" />
|
||||
Import Profile
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreateProfileDialogOpen(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Create a new profile</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ProfilesDataTable
|
||||
data={profiles}
|
||||
onLaunchProfile={launchProfile}
|
||||
onKillProfile={handleKillProfile}
|
||||
onProxySettings={openProxyDialog}
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
onRenameProfile={handleRenameProfile}
|
||||
onChangeVersion={openChangeVersionDialog}
|
||||
onConfigureCamoufox={handleConfigureCamoufox}
|
||||
runningProfiles={runningProfiles}
|
||||
isUpdating={isUpdating}
|
||||
onReloadProxyData={
|
||||
proxyDataReloadTrigger > 0 ? triggerProxyDataReload : undefined
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<main className="flex flex-col row-start-2 gap-6 items-center w-full max-w-3xl">
|
||||
<div className="w-full">
|
||||
<HomeHeader
|
||||
selectedProfiles={selectedProfiles}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||
onGroupManagementDialogOpen={setGroupManagementDialogOpen}
|
||||
onImportProfileDialogOpen={setImportProfileDialogOpen}
|
||||
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
|
||||
onSettingsDialogOpen={setSettingsDialogOpen}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4 w-full">
|
||||
<GroupBadges
|
||||
selectedGroupId={selectedGroupId}
|
||||
onGroupSelect={handleSelectGroup}
|
||||
groups={groups}
|
||||
isLoading={areGroupsLoading}
|
||||
/>
|
||||
<ProfilesDataTable
|
||||
data={profiles}
|
||||
onLaunchProfile={launchProfile}
|
||||
onKillProfile={handleKillProfile}
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
onRenameProfile={handleRenameProfile}
|
||||
onConfigureCamoufox={handleConfigureCamoufox}
|
||||
runningProfiles={runningProfiles}
|
||||
isUpdating={isUpdating}
|
||||
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
|
||||
onAssignProfilesToGroup={handleAssignProfilesToGroup}
|
||||
selectedGroupId={selectedGroupId}
|
||||
selectedProfiles={selectedProfiles}
|
||||
onSelectedProfilesChange={setSelectedProfiles}
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<ProxySettingsDialog
|
||||
isOpen={proxyDialogOpen}
|
||||
onClose={() => {
|
||||
setProxyDialogOpen(false);
|
||||
}}
|
||||
onSave={handleSaveProxy}
|
||||
initialProxyId={currentProfileForProxy?.proxy_id}
|
||||
browserType={currentProfileForProxy?.browser}
|
||||
/>
|
||||
{isInitializing && (
|
||||
<div className="fixed inset-0 z-[100000] backdrop-blur-sm bg-black/30 flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl p-6 shadow-xl border border-black/10 dark:border-white/10 w-[320px] text-center">
|
||||
<div className="text-lg font-medium">Initializing</div>
|
||||
<div className="mt-1 mb-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Please don't close the app
|
||||
</div>
|
||||
<div className="mx-auto mb-4 w-8 h-8 rounded-full border-2 border-gray-300 animate-spin border-t-gray-900 dark:border-gray-700 dark:border-t-white" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<CreateProfileDialog
|
||||
isOpen={createProfileDialogOpen}
|
||||
@@ -717,6 +819,7 @@ export default function Home() {
|
||||
setCreateProfileDialogOpen(false);
|
||||
}}
|
||||
onCreateProfile={handleCreateProfile}
|
||||
selectedGroupId={selectedGroupId}
|
||||
/>
|
||||
|
||||
<SettingsDialog
|
||||
@@ -726,15 +829,6 @@ export default function Home() {
|
||||
}}
|
||||
/>
|
||||
|
||||
<ChangeVersionDialog
|
||||
isOpen={changeVersionDialogOpen}
|
||||
onClose={() => {
|
||||
setChangeVersionDialogOpen(false);
|
||||
}}
|
||||
profile={currentProfileForVersionChange}
|
||||
onVersionChanged={() => void loadProfiles()}
|
||||
/>
|
||||
|
||||
<ImportProfileDialog
|
||||
isOpen={importProfileDialogOpen}
|
||||
onClose={() => {
|
||||
@@ -760,6 +854,7 @@ export default function Home() {
|
||||
);
|
||||
}}
|
||||
url={pendingUrl.url}
|
||||
isUpdating={isUpdating}
|
||||
runningProfiles={runningProfiles}
|
||||
/>
|
||||
))}
|
||||
@@ -781,6 +876,34 @@ export default function Home() {
|
||||
profile={currentProfileForCamoufoxConfig}
|
||||
onSave={handleSaveCamoufoxConfig}
|
||||
/>
|
||||
|
||||
<GroupManagementDialog
|
||||
isOpen={groupManagementDialogOpen}
|
||||
onClose={() => {
|
||||
setGroupManagementDialogOpen(false);
|
||||
}}
|
||||
onGroupManagementComplete={handleGroupManagementComplete}
|
||||
/>
|
||||
|
||||
<GroupAssignmentDialog
|
||||
isOpen={groupAssignmentDialogOpen}
|
||||
onClose={() => {
|
||||
setGroupAssignmentDialogOpen(false);
|
||||
}}
|
||||
selectedProfiles={selectedProfilesForGroup}
|
||||
onAssignmentComplete={handleGroupAssignmentComplete}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => setShowBulkDeleteConfirmation(false)}
|
||||
onConfirm={confirmBulkDelete}
|
||||
title="Delete Selected Profiles"
|
||||
description={`This action cannot be undone. This will permanently delete ${selectedProfiles.length} profile${selectedProfiles.length !== 1 ? "s" : ""} and all associated data.`}
|
||||
confirmButtonText={`Delete ${selectedProfiles.length} Profile${selectedProfiles.length !== 1 ? "s" : ""}`}
|
||||
isLoading={isBulkDeleting}
|
||||
profileNames={selectedProfiles}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import { LuCheckCheck, LuCog, LuRefreshCw } from "react-icons/lu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface AppUpdateToastProps {
|
||||
updateInfo: AppUpdateInfo;
|
||||
@@ -16,26 +17,20 @@ interface AppUpdateToastProps {
|
||||
|
||||
function getStageIcon(stage?: string, isUpdating?: boolean) {
|
||||
if (!isUpdating) {
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5 text-blue-500" />;
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5" />;
|
||||
}
|
||||
|
||||
switch (stage) {
|
||||
case "downloading":
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5 text-blue-500" />;
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5" />;
|
||||
case "extracting":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
|
||||
);
|
||||
return <LuRefreshCw className="flex-shrink-0 w-5 h-5 animate-spin" />;
|
||||
case "installing":
|
||||
return (
|
||||
<LuCog className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
|
||||
);
|
||||
return <LuCog className="flex-shrink-0 w-5 h-5 animate-spin" />;
|
||||
case "completed":
|
||||
return <LuCheckCheck className="flex-shrink-0 w-5 h-5 text-green-500" />;
|
||||
return <LuCheckCheck className="flex-shrink-0 w-5 h-5" />;
|
||||
default:
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
|
||||
);
|
||||
return <LuRefreshCw className="flex-shrink-0 w-5 h-5 animate-spin" />;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,7 +73,7 @@ export function AppUpdateToast({
|
||||
updateProgress.stage === "completed");
|
||||
|
||||
return (
|
||||
<div className="flex items-start p-4 w-full max-w-md bg-white rounded-lg border border-gray-200 shadow-lg dark:bg-gray-800 dark:border-gray-700">
|
||||
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="mr-3 mt-0.5">
|
||||
{getStageIcon(updateProgress?.stage, isUpdating)}
|
||||
</div>
|
||||
@@ -133,9 +128,9 @@ export function AppUpdateToast({
|
||||
{updateProgress.eta && ` • ${updateProgress.eta} remaining`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
className="bg-primary h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${updateProgress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
@@ -144,58 +139,38 @@ export function AppUpdateToast({
|
||||
|
||||
{/* Other stage progress (with visual indicators) */}
|
||||
{showOtherStageProgress && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{updateProgress.message}
|
||||
</p>
|
||||
|
||||
<div className="mt-2 space-y-1">
|
||||
{/* Progress indicator for non-downloading stages */}
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all duration-500 ${
|
||||
updateProgress.stage === "completed"
|
||||
? "bg-green-500 w-full"
|
||||
: "bg-blue-500 w-full animate-pulse"
|
||||
: "bg-primary w-full animate-pulse"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{updateProgress.stage === "extracting" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Preparing update files...
|
||||
</p>
|
||||
)}
|
||||
{updateProgress.stage === "installing" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Installing new version...
|
||||
</p>
|
||||
)}
|
||||
{updateProgress.stage === "completed" && (
|
||||
<p className="text-xs text-green-600 dark:text-green-400">
|
||||
Update completed! Restarting application...
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isUpdating && (
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
<Button
|
||||
<RippleButton
|
||||
onClick={() => void handleUpdateClick()}
|
||||
size="sm"
|
||||
className="flex gap-2 items-center text-xs"
|
||||
>
|
||||
<FaDownload className="w-3 h-3" />
|
||||
Update Now
|
||||
</Button>
|
||||
<Button
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onDismiss}
|
||||
size="sm"
|
||||
className="text-xs"
|
||||
>
|
||||
Later
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,17 +9,10 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { BrowserProfile, CamoufoxConfig } from "@/types";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface CamoufoxConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -29,43 +21,6 @@ interface CamoufoxConfigDialogProps {
|
||||
onSave: (profile: BrowserProfile, config: CamoufoxConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
const osOptions = [
|
||||
{ value: "windows", label: "Windows" },
|
||||
{ value: "macos", label: "macOS" },
|
||||
{ value: "linux", label: "Linux" },
|
||||
];
|
||||
|
||||
const timezoneOptions = [
|
||||
{ value: "America/New_York", label: "America/New_York" },
|
||||
{ value: "America/Los_Angeles", label: "America/Los_Angeles" },
|
||||
{ value: "Europe/London", label: "Europe/London" },
|
||||
{ value: "Europe/Paris", label: "Europe/Paris" },
|
||||
{ value: "Asia/Tokyo", label: "Asia/Tokyo" },
|
||||
{ value: "Asia/Shanghai", label: "Asia/Shanghai" },
|
||||
{ value: "Australia/Sydney", label: "Australia/Sydney" },
|
||||
];
|
||||
|
||||
const localeOptions = [
|
||||
{ value: "en-US", label: "English (US)" },
|
||||
{ value: "en-GB", label: "English (UK)" },
|
||||
{ value: "fr-FR", label: "French" },
|
||||
{ value: "de-DE", label: "German" },
|
||||
{ value: "es-ES", label: "Spanish" },
|
||||
{ value: "it-IT", label: "Italian" },
|
||||
{ value: "ja-JP", label: "Japanese" },
|
||||
{ value: "zh-CN", label: "Chinese (Simplified)" },
|
||||
];
|
||||
|
||||
const getCurrentOS = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
const userAgent = window.navigator.userAgent;
|
||||
if (userAgent.includes("Win")) return "windows";
|
||||
if (userAgent.includes("Mac")) return "macos";
|
||||
if (userAgent.includes("Linux")) return "linux";
|
||||
}
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
export function CamoufoxConfigDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -73,8 +28,7 @@ export function CamoufoxConfigDialog({
|
||||
onSave,
|
||||
}: CamoufoxConfigDialogProps) {
|
||||
const [config, setConfig] = useState<CamoufoxConfig>({
|
||||
enable_cache: true,
|
||||
os: [getCurrentOS()],
|
||||
geoip: true,
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
@@ -83,8 +37,7 @@ export function CamoufoxConfigDialog({
|
||||
if (profile && profile.browser === "camoufox") {
|
||||
setConfig(
|
||||
profile.camoufox_config || {
|
||||
enable_cache: true,
|
||||
os: [getCurrentOS()],
|
||||
geoip: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -97,12 +50,31 @@ export function CamoufoxConfigDialog({
|
||||
const handleSave = async () => {
|
||||
if (!profile) return;
|
||||
|
||||
// Validate fingerprint JSON if it exists
|
||||
if (config.fingerprint) {
|
||||
try {
|
||||
JSON.parse(config.fingerprint);
|
||||
} catch (_error) {
|
||||
const { toast } = await import("sonner");
|
||||
toast.error("Invalid fingerprint configuration", {
|
||||
description:
|
||||
"The fingerprint configuration contains invalid JSON. Please check your advanced settings.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(profile, config);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save camoufox config:", error);
|
||||
const { toast } = await import("sonner");
|
||||
toast.error("Failed to save configuration", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
@@ -113,8 +85,7 @@ export function CamoufoxConfigDialog({
|
||||
if (profile && profile.browser === "camoufox") {
|
||||
setConfig(
|
||||
profile.camoufox_config || {
|
||||
enable_cache: true,
|
||||
os: [getCurrentOS()],
|
||||
geoip: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -125,376 +96,38 @@ export function CamoufoxConfigDialog({
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the selected OS for warning
|
||||
const selectedOS = config.os?.[0];
|
||||
const currentOS = getCurrentOS();
|
||||
const showOSWarning =
|
||||
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
|
||||
// No OS warning needed anymore since we removed OS selection
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>
|
||||
Configure Camoufox Settings - {profile.name}
|
||||
Configure Fingerprint Settings - {profile.name}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 pr-6 h-[350px]">
|
||||
<div className="py-4 space-y-6">
|
||||
{/* Operating System */}
|
||||
<div className="space-y-3">
|
||||
<Label>Operating System Fingerprint</Label>
|
||||
<Select
|
||||
value={selectedOS || ""}
|
||||
onValueChange={(value: string) => updateConfig("os", [value])}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select OS" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{osOptions.map((os) => (
|
||||
<SelectItem key={os.value} value={os.value}>
|
||||
{os.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{showOSWarning && (
|
||||
<div className="p-3 bg-amber-50 rounded-md border border-amber-200">
|
||||
<p className="text-sm text-amber-800">
|
||||
⚠️ Warning: Spoofing OS features is detectable by advanced
|
||||
anti-bot systems. Some platform-specific APIs and behaviors
|
||||
cannot be fully replicated.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Blocking Options */}
|
||||
<div className="space-y-3">
|
||||
<Label>Privacy & Blocking</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="block-images"
|
||||
checked={config.block_images || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig("block_images", checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="block-images">Block Images</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="block-webrtc"
|
||||
checked={config.block_webrtc || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig("block_webrtc", checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="block-webrtc">Block WebRTC</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="block-webgl"
|
||||
checked={config.block_webgl || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig("block_webgl", checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="block-webgl">Block WebGL</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Geolocation */}
|
||||
<div className="space-y-3">
|
||||
<Label>Geolocation</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="country">Country</Label>
|
||||
<Input
|
||||
id="country"
|
||||
value={config.country || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig("country", e.target.value || undefined)
|
||||
}
|
||||
placeholder="e.g., US, GB, DE"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Timezone</Label>
|
||||
<Select
|
||||
value={config.timezone || "auto"}
|
||||
onValueChange={(value) =>
|
||||
updateConfig(
|
||||
"timezone",
|
||||
value === "auto" ? undefined : value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select timezone" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">Auto</SelectItem>
|
||||
{timezoneOptions.map((tz) => (
|
||||
<SelectItem key={tz.value} value={tz.value}>
|
||||
{tz.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="latitude">Latitude</Label>
|
||||
<Input
|
||||
id="latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={config.latitude || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
"latitude",
|
||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 40.7128"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="longitude">Longitude</Label>
|
||||
<Input
|
||||
id="longitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={config.longitude || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
"longitude",
|
||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., -74.0060"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Localization */}
|
||||
<div className="space-y-3">
|
||||
<Label>Locale</Label>
|
||||
<Select
|
||||
value={config.locale?.[0] || ""}
|
||||
onValueChange={(value) =>
|
||||
updateConfig("locale", value ? [value] : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select locale" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{localeOptions.map((locale) => (
|
||||
<SelectItem key={locale.value} value={locale.value}>
|
||||
{locale.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Screen Resolution */}
|
||||
<div className="space-y-3">
|
||||
<Label>Screen Resolution</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="screen-min-width">Min Width</Label>
|
||||
<Input
|
||||
id="screen-min-width"
|
||||
type="number"
|
||||
value={config.screen_min_width || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
"screen_min_width",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1024"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="screen-max-width">Max Width</Label>
|
||||
<Input
|
||||
id="screen-max-width"
|
||||
type="number"
|
||||
value={config.screen_max_width || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
"screen_max_width",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1920"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="screen-min-height">Min Height</Label>
|
||||
<Input
|
||||
id="screen-min-height"
|
||||
type="number"
|
||||
value={config.screen_min_height || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
"screen_min_height",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 768"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="screen-max-height">Max Height</Label>
|
||||
<Input
|
||||
id="screen-max-height"
|
||||
type="number"
|
||||
value={config.screen_max_height || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
"screen_max_height",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1080"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Window Size */}
|
||||
<div className="space-y-3">
|
||||
<Label>Window Size</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="window-width">Width</Label>
|
||||
<Input
|
||||
id="window-width"
|
||||
type="number"
|
||||
value={config.window_width || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
"window_width",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1366"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="window-height">Height</Label>
|
||||
<Input
|
||||
id="window-height"
|
||||
type="number"
|
||||
value={config.window_height || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
"window_height",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 768"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Options */}
|
||||
<div className="space-y-3">
|
||||
<Label>Advanced Options</Label>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="enable-cache"
|
||||
checked={config.enable_cache || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig("enable_cache", checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="enable-cache">Enable Browser Cache</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="main-world-eval"
|
||||
checked={config.main_world_eval || false}
|
||||
onCheckedChange={(checked) =>
|
||||
updateConfig("main_world_eval", checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="main-world-eval">
|
||||
Enable Main World Evaluation
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WebGL Settings */}
|
||||
<div className="space-y-3">
|
||||
<Label>WebGL Settings</Label>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webgl-vendor">WebGL Vendor</Label>
|
||||
<Input
|
||||
id="webgl-vendor"
|
||||
value={config.webgl_vendor || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig("webgl_vendor", e.target.value || undefined)
|
||||
}
|
||||
placeholder="e.g., Intel Inc."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="webgl-renderer">WebGL Renderer</Label>
|
||||
<Input
|
||||
id="webgl-renderer"
|
||||
value={config.webgl_renderer || ""}
|
||||
onChange={(e) =>
|
||||
updateConfig(
|
||||
"webgl_renderer",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., Intel Iris OpenGL Engine"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Debug Options */}
|
||||
<div className="space-y-3">
|
||||
<Label>Debug Options</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="debug"
|
||||
checked={config.debug || false}
|
||||
onCheckedChange={(checked) => updateConfig("debug", checked)}
|
||||
/>
|
||||
<Label htmlFor="debug">Enable Debug Mode</Label>
|
||||
</div>
|
||||
</div>
|
||||
<ScrollArea className="flex-1 h-[400px]">
|
||||
<div className="py-4">
|
||||
<SharedCamoufoxConfigForm
|
||||
config={config}
|
||||
onConfigChange={updateConfig}
|
||||
forceAdvanced={true}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isSaving}
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
>
|
||||
Save
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,307 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { ReleaseTypeSelector } from "@/components/release-type-selector";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useBrowserDownload } from "@/hooks/use-browser-download";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import type { BrowserProfile, BrowserReleaseTypes } from "@/types";
|
||||
|
||||
interface ChangeVersionDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
onVersionChanged: () => void;
|
||||
}
|
||||
|
||||
export function ChangeVersionDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
profile,
|
||||
onVersionChanged,
|
||||
}: ChangeVersionDialogProps) {
|
||||
const [selectedReleaseType, setSelectedReleaseType] = useState<
|
||||
"stable" | "nightly" | null
|
||||
>(null);
|
||||
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>({});
|
||||
const [isLoadingReleaseTypes, setIsLoadingReleaseTypes] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [showDowngradeWarning, setShowDowngradeWarning] = useState(false);
|
||||
const [acknowledgeDowngrade, setAcknowledgeDowngrade] = useState(false);
|
||||
|
||||
const {
|
||||
downloadedVersions,
|
||||
isDownloading,
|
||||
loadDownloadedVersions,
|
||||
downloadBrowser,
|
||||
isVersionDownloaded,
|
||||
} = useBrowserDownload();
|
||||
|
||||
const loadReleaseTypes = useCallback(async (browser: string) => {
|
||||
setIsLoadingReleaseTypes(true);
|
||||
try {
|
||||
const releaseTypes = await invoke<BrowserReleaseTypes>(
|
||||
"get_browser_release_types",
|
||||
{ browserStr: browser },
|
||||
);
|
||||
setReleaseTypes(releaseTypes);
|
||||
} catch (error) {
|
||||
console.error("Failed to load release types:", error);
|
||||
} finally {
|
||||
setIsLoadingReleaseTypes(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
profile &&
|
||||
selectedReleaseType &&
|
||||
selectedReleaseType !== profile.release_type
|
||||
) {
|
||||
// For simplicity, we'll show downgrade warning when switching from stable to nightly
|
||||
// since nightly versions might be considered "downgrades" in terms of stability
|
||||
const isDowngrade =
|
||||
profile.release_type === "stable" && selectedReleaseType === "nightly";
|
||||
setShowDowngradeWarning(isDowngrade);
|
||||
|
||||
if (!isDowngrade) {
|
||||
setAcknowledgeDowngrade(false);
|
||||
}
|
||||
}
|
||||
}, [selectedReleaseType, profile]);
|
||||
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!profile || !selectedReleaseType) return;
|
||||
|
||||
const version =
|
||||
selectedReleaseType === "stable"
|
||||
? releaseTypes.stable
|
||||
: releaseTypes.nightly;
|
||||
if (!version) return;
|
||||
|
||||
await downloadBrowser(profile.browser, version);
|
||||
}, [profile, selectedReleaseType, downloadBrowser, releaseTypes]);
|
||||
|
||||
const handleVersionChange = useCallback(async () => {
|
||||
if (!profile || !selectedReleaseType) return;
|
||||
|
||||
const version =
|
||||
selectedReleaseType === "stable"
|
||||
? releaseTypes.stable
|
||||
: releaseTypes.nightly;
|
||||
if (!version) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
await invoke("update_profile_version", {
|
||||
profileName: profile.name,
|
||||
version,
|
||||
});
|
||||
onVersionChanged();
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to update profile version:", error);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [profile, selectedReleaseType, releaseTypes, onVersionChanged, onClose]);
|
||||
|
||||
const selectedVersion =
|
||||
selectedReleaseType === "stable"
|
||||
? releaseTypes.stable
|
||||
: releaseTypes.nightly;
|
||||
|
||||
const canUpdate =
|
||||
profile &&
|
||||
selectedReleaseType &&
|
||||
selectedReleaseType !== profile.release_type &&
|
||||
selectedVersion &&
|
||||
isVersionDownloaded(selectedVersion) &&
|
||||
(!showDowngradeWarning || acknowledgeDowngrade);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && profile) {
|
||||
// Set current release type based on profile
|
||||
setSelectedReleaseType(profile.release_type as "stable" | "nightly");
|
||||
setAcknowledgeDowngrade(false);
|
||||
void loadReleaseTypes(profile.browser);
|
||||
void loadDownloadedVersions(profile.browser);
|
||||
}
|
||||
}, [isOpen, profile, loadDownloadedVersions, loadReleaseTypes]);
|
||||
|
||||
if (!profile) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change Release Type</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Profile:</Label>
|
||||
<div className="p-2 text-sm rounded bg-muted">{profile.name}</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">Current Release:</Label>
|
||||
<div className="p-2 text-sm capitalize rounded bg-muted">
|
||||
{profile.release_type} ({profile.version})
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!releaseTypes.stable && !releaseTypes.nightly ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
No releases are available for{" "}
|
||||
{getBrowserDisplayName(profile.browser)}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : !releaseTypes.stable || !releaseTypes.nightly ? (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Only {profile.release_type} releases are available for{" "}
|
||||
{getBrowserDisplayName(profile.browser)}.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<div className="grid gap-2">
|
||||
<Label>New Release Type</Label>
|
||||
{isLoadingReleaseTypes ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading release types...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{selectedReleaseType &&
|
||||
selectedReleaseType !== profile.release_type &&
|
||||
selectedVersion &&
|
||||
!isVersionDownloaded(selectedVersion) && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
You must download{" "}
|
||||
{getBrowserDisplayName(profile.browser)}{" "}
|
||||
{selectedVersion} before switching to this release
|
||||
type. Use the download button above to get the
|
||||
latest version.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ReleaseTypeSelector
|
||||
selectedReleaseType={selectedReleaseType}
|
||||
onReleaseTypeSelect={setSelectedReleaseType}
|
||||
availableReleaseTypes={releaseTypes}
|
||||
browser={profile.browser}
|
||||
isDownloading={isDownloading}
|
||||
onDownload={() => {
|
||||
void handleDownload();
|
||||
}}
|
||||
placeholder="Select release type..."
|
||||
downloadedVersions={downloadedVersions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-2">
|
||||
<Label>New Release Type</Label>
|
||||
{isLoadingReleaseTypes ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading release types...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{selectedReleaseType &&
|
||||
selectedReleaseType !== profile.release_type &&
|
||||
selectedVersion &&
|
||||
!isVersionDownloaded(selectedVersion) && (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
You must download{" "}
|
||||
{getBrowserDisplayName(profile.browser)}{" "}
|
||||
{selectedVersion} before switching to this release
|
||||
type. Use the download button above to get the latest
|
||||
version.
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<ReleaseTypeSelector
|
||||
selectedReleaseType={selectedReleaseType}
|
||||
onReleaseTypeSelect={setSelectedReleaseType}
|
||||
availableReleaseTypes={releaseTypes}
|
||||
browser={profile.browser}
|
||||
isDownloading={isDownloading}
|
||||
onDownload={() => {
|
||||
void handleDownload();
|
||||
}}
|
||||
placeholder="Select release type..."
|
||||
downloadedVersions={downloadedVersions}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Downgrade Warning */}
|
||||
{showDowngradeWarning && (
|
||||
<Alert className="border-orange-700">
|
||||
<LuTriangleAlert className="w-4 h-4 text-orange-700" />
|
||||
<AlertTitle className="text-orange-700">
|
||||
Stability Warning
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-orange-700">
|
||||
You are about to switch from stable to nightly releases. Nightly
|
||||
versions may be less stable and could contain bugs or incomplete
|
||||
features.
|
||||
<div className="flex items-center mt-3 space-x-2">
|
||||
<Checkbox
|
||||
id="acknowledge-downgrade"
|
||||
checked={acknowledgeDowngrade}
|
||||
onCheckedChange={(checked) => {
|
||||
setAcknowledgeDowngrade(checked as boolean);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="acknowledge-downgrade" className="text-sm">
|
||||
I understand the risks and want to proceed
|
||||
</Label>
|
||||
</div>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
isLoading={isUpdating}
|
||||
onClick={() => {
|
||||
void handleVersionChange();
|
||||
}}
|
||||
disabled={!canUpdate}
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update Release Type"}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface CreateGroupDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onGroupCreated: (group: ProfileGroup) => void;
|
||||
}
|
||||
|
||||
export function CreateGroupDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onGroupCreated,
|
||||
}: CreateGroupDialogProps) {
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!groupName.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newGroup = await invoke<ProfileGroup>("create_profile_group", {
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group created successfully");
|
||||
onGroupCreated(newGroup);
|
||||
setGroupName("");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to create group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to create group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [groupName, onGroupCreated, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setGroupName("");
|
||||
setError(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new group to organize your browser profiles.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && groupName.trim()) {
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isCreating}
|
||||
>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isCreating}
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!groupName.trim()}
|
||||
>
|
||||
Create
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -27,6 +28,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useBrowserDownload } from "@/hooks/use-browser-download";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
type BrowserTypeString =
|
||||
| "mullvad-browser"
|
||||
@@ -48,89 +50,82 @@ interface CreateProfileDialogProps {
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
groupId?: string;
|
||||
}) => Promise<void>;
|
||||
selectedGroupId?: string;
|
||||
}
|
||||
|
||||
interface BrowserOption {
|
||||
value: BrowserTypeString;
|
||||
label: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
const browserOptions: BrowserOption[] = [
|
||||
{
|
||||
value: "firefox",
|
||||
label: "Firefox",
|
||||
description: "Mozilla's main web browser",
|
||||
},
|
||||
{
|
||||
value: "firefox-developer",
|
||||
label: "Firefox Developer Edition",
|
||||
description: "Browser for developers with cutting-edge features",
|
||||
},
|
||||
{
|
||||
value: "chromium",
|
||||
label: "Chromium",
|
||||
description: "Open-source version of Chrome",
|
||||
},
|
||||
{
|
||||
value: "brave",
|
||||
label: "Brave",
|
||||
description: "Privacy-focused browser with ad blocking",
|
||||
},
|
||||
{
|
||||
value: "zen",
|
||||
label: "Zen Browser",
|
||||
description: "Beautiful, customizable Firefox-based browser",
|
||||
},
|
||||
{
|
||||
value: "mullvad-browser",
|
||||
label: "Mullvad Browser",
|
||||
description: "Privacy browser by Mullvad VPN",
|
||||
},
|
||||
{
|
||||
value: "tor-browser",
|
||||
label: "Tor Browser",
|
||||
description: "Browse anonymously through the Tor network",
|
||||
},
|
||||
];
|
||||
|
||||
const getCurrentOS = () => {
|
||||
if (typeof window !== "undefined") {
|
||||
const userAgent = window.navigator.userAgent;
|
||||
if (userAgent.includes("Win")) return "windows";
|
||||
if (userAgent.includes("Mac")) return "macos";
|
||||
if (userAgent.includes("Linux")) return "linux";
|
||||
}
|
||||
return "unknown";
|
||||
};
|
||||
|
||||
export function CreateProfileDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreateProfile,
|
||||
selectedGroupId,
|
||||
}: CreateProfileDialogProps) {
|
||||
const [profileName, setProfileName] = useState("");
|
||||
const [activeTab, setActiveTab] = useState("regular");
|
||||
const [activeTab, setActiveTab] = useState("anti-detect");
|
||||
|
||||
// Regular browser states
|
||||
const [selectedBrowser, setSelectedBrowser] = useState<BrowserTypeString>();
|
||||
const [selectedBrowser, setSelectedBrowser] =
|
||||
useState<BrowserTypeString | null>("camoufox");
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (value === "regular") {
|
||||
setSelectedBrowser(null);
|
||||
} else if (value === "anti-detect") {
|
||||
setSelectedBrowser("camoufox");
|
||||
}
|
||||
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
// Camoufox anti-detect states
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({
|
||||
enable_cache: true, // Cache enabled by default
|
||||
os: [getCurrentOS()], // Default to current OS
|
||||
geoip: true, // Default to automatic geoip
|
||||
});
|
||||
|
||||
// Common states
|
||||
const [availableReleaseTypes, setAvailableReleaseTypes] =
|
||||
useState<BrowserReleaseTypes>({});
|
||||
const [camoufoxReleaseTypes, setCamoufoxReleaseTypes] =
|
||||
useState<BrowserReleaseTypes>({});
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
|
||||
const loadingBrowserRef = useRef<string | null>(null);
|
||||
|
||||
// Use the browser download hook
|
||||
const {
|
||||
@@ -158,24 +153,61 @@ export function CreateProfileDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkAndDownloadGeoIPDatabase = useCallback(async () => {
|
||||
try {
|
||||
const isAvailable = await invoke<boolean>("is_geoip_database_available");
|
||||
if (!isAvailable) {
|
||||
console.log("GeoIP database not available, downloading...");
|
||||
await invoke("download_geoip_database");
|
||||
console.log("GeoIP database downloaded successfully");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check/download GeoIP database:", error);
|
||||
// Don't show error to user as this is not critical for profile creation
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadReleaseTypes = useCallback(
|
||||
async (browser: string) => {
|
||||
// Set loading state
|
||||
loadingBrowserRef.current = browser;
|
||||
|
||||
try {
|
||||
const releaseTypes = await invoke<BrowserReleaseTypes>(
|
||||
const rawReleaseTypes = await invoke<BrowserReleaseTypes>(
|
||||
"get_browser_release_types",
|
||||
{ browserStr: browser },
|
||||
);
|
||||
|
||||
if (browser === "camoufox") {
|
||||
setCamoufoxReleaseTypes(releaseTypes);
|
||||
} else {
|
||||
setAvailableReleaseTypes(releaseTypes);
|
||||
}
|
||||
// Only update state if this browser is still the one we're loading
|
||||
if (loadingBrowserRef.current === browser) {
|
||||
// Filter to enforce stable-only creation, except Firefox Developer (nightly-only)
|
||||
if (browser === "camoufox") {
|
||||
const filtered: BrowserReleaseTypes = {};
|
||||
if (rawReleaseTypes.stable)
|
||||
filtered.stable = rawReleaseTypes.stable;
|
||||
setReleaseTypes(filtered);
|
||||
} else if (browser === "firefox-developer") {
|
||||
const filtered: BrowserReleaseTypes = {};
|
||||
if (rawReleaseTypes.nightly)
|
||||
filtered.nightly = rawReleaseTypes.nightly;
|
||||
setReleaseTypes(filtered);
|
||||
} else {
|
||||
const filtered: BrowserReleaseTypes = {};
|
||||
if (rawReleaseTypes.stable)
|
||||
filtered.stable = rawReleaseTypes.stable;
|
||||
setReleaseTypes(filtered);
|
||||
}
|
||||
|
||||
// Load downloaded versions for this browser
|
||||
await loadDownloadedVersions(browser);
|
||||
// Load downloaded versions for this browser
|
||||
await loadDownloadedVersions(browser);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load release types for ${browser}:`, error);
|
||||
} finally {
|
||||
// Clear loading state only if we're still loading this browser
|
||||
if (loadingBrowserRef.current === browser) {
|
||||
loadingBrowserRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[loadDownloadedVersions],
|
||||
@@ -188,28 +220,59 @@ export function CreateProfileDialog({
|
||||
void loadStoredProxies();
|
||||
// Load camoufox release types when dialog opens
|
||||
void loadReleaseTypes("camoufox");
|
||||
// Check and download GeoIP database if needed for Camoufox
|
||||
void checkAndDownloadGeoIPDatabase();
|
||||
}
|
||||
}, [isOpen, loadSupportedBrowsers, loadStoredProxies, loadReleaseTypes]);
|
||||
}, [
|
||||
isOpen,
|
||||
loadSupportedBrowsers,
|
||||
loadStoredProxies,
|
||||
loadReleaseTypes,
|
||||
checkAndDownloadGeoIPDatabase,
|
||||
]);
|
||||
|
||||
// Load release types when browser selection changes
|
||||
useEffect(() => {
|
||||
if (selectedBrowser) {
|
||||
// Cancel any previous loading
|
||||
loadingBrowserRef.current = null;
|
||||
// Clear previous release types immediately to prevent showing stale data
|
||||
setReleaseTypes({});
|
||||
void loadReleaseTypes(selectedBrowser);
|
||||
}
|
||||
}, [selectedBrowser, loadReleaseTypes]);
|
||||
|
||||
const handleDownload = async (browserStr: string) => {
|
||||
const releaseTypes =
|
||||
browserStr === "camoufox" ? camoufoxReleaseTypes : availableReleaseTypes;
|
||||
const latestStableVersion = releaseTypes.stable;
|
||||
// Helper function to get the best available version respecting rules
|
||||
const getBestAvailableVersion = useCallback(
|
||||
(browserType?: string) => {
|
||||
if (!releaseTypes) return null;
|
||||
|
||||
if (!latestStableVersion) {
|
||||
console.error("No stable version available for download");
|
||||
// Firefox Developer Edition: nightly-only
|
||||
if (browserType === "firefox-developer" && releaseTypes.nightly) {
|
||||
return {
|
||||
version: releaseTypes.nightly,
|
||||
releaseType: "nightly" as const,
|
||||
};
|
||||
}
|
||||
// All others: stable-only
|
||||
if (releaseTypes.stable) {
|
||||
return { version: releaseTypes.stable, releaseType: "stable" as const };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[releaseTypes],
|
||||
);
|
||||
|
||||
const handleDownload = async (browserStr: string) => {
|
||||
const bestVersion = getBestAvailableVersion(browserStr);
|
||||
|
||||
if (!bestVersion) {
|
||||
console.error("No version available for download");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadBrowser(browserStr, latestStableVersion);
|
||||
await downloadBrowser(browserStr, bestVersion.version);
|
||||
} catch (error) {
|
||||
console.error("Failed to download browser:", error);
|
||||
}
|
||||
@@ -226,35 +289,41 @@ export function CreateProfileDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the latest stable version by default
|
||||
const latestStableVersion = availableReleaseTypes.stable;
|
||||
if (!latestStableVersion) {
|
||||
console.error("No stable version available");
|
||||
// Use the best available version (stable preferred, nightly as fallback)
|
||||
const bestVersion = getBestAvailableVersion(selectedBrowser);
|
||||
if (!bestVersion) {
|
||||
console.error("No version available");
|
||||
return;
|
||||
}
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: selectedBrowser,
|
||||
version: latestStableVersion,
|
||||
releaseType: "stable",
|
||||
version: bestVersion.version,
|
||||
releaseType: bestVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
});
|
||||
} else {
|
||||
// Anti-detect tab - always use Camoufox with latest version
|
||||
const latestCamoufoxVersion = camoufoxReleaseTypes.stable;
|
||||
if (!latestCamoufoxVersion) {
|
||||
// Anti-detect tab - always use Camoufox with best available version
|
||||
const bestCamoufoxVersion = getBestAvailableVersion("camoufox");
|
||||
if (!bestCamoufoxVersion) {
|
||||
console.error("No Camoufox version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
// We don't need to generate it here during profile creation
|
||||
const finalCamoufoxConfig = { ...camoufoxConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "camoufox" as BrowserTypeString,
|
||||
version: latestCamoufoxVersion,
|
||||
releaseType: "stable",
|
||||
version: bestCamoufoxVersion.version,
|
||||
releaseType: bestCamoufoxVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
camoufoxConfig,
|
||||
camoufoxConfig: finalCamoufoxConfig,
|
||||
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -267,218 +336,306 @@ export function CreateProfileDialog({
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Cancel any ongoing loading
|
||||
loadingBrowserRef.current = null;
|
||||
|
||||
// Reset all states
|
||||
setProfileName("");
|
||||
setSelectedBrowser(undefined);
|
||||
setSelectedBrowser(null);
|
||||
setSelectedProxyId(undefined);
|
||||
setReleaseTypes({});
|
||||
setCamoufoxConfig({
|
||||
enable_cache: true,
|
||||
os: [getCurrentOS()], // Reset to current OS
|
||||
geoip: true, // Reset to automatic geoip
|
||||
});
|
||||
setActiveTab("regular");
|
||||
setActiveTab("anti-detect");
|
||||
onClose();
|
||||
};
|
||||
|
||||
const isCreateDisabled = () => {
|
||||
if (!profileName.trim()) return true;
|
||||
|
||||
if (activeTab === "regular") {
|
||||
return !selectedBrowser || !availableReleaseTypes.stable;
|
||||
} else {
|
||||
// For anti-detect, we need camoufox to be available
|
||||
return !camoufoxReleaseTypes.stable;
|
||||
}
|
||||
};
|
||||
|
||||
const updateCamoufoxConfig = (key: keyof CamoufoxConfig, value: unknown) => {
|
||||
setCamoufoxConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Check if browser version is downloaded and available
|
||||
const isBrowserVersionAvailable = (browserStr: string) => {
|
||||
const releaseTypes =
|
||||
browserStr === "camoufox" ? camoufoxReleaseTypes : availableReleaseTypes;
|
||||
const latestStableVersion = releaseTypes.stable;
|
||||
return latestStableVersion && isVersionDownloaded(latestStableVersion);
|
||||
};
|
||||
const isBrowserVersionAvailable = useCallback(
|
||||
(browserStr: string) => {
|
||||
const bestVersion = getBestAvailableVersion(browserStr);
|
||||
return bestVersion && isVersionDownloaded(bestVersion.version);
|
||||
},
|
||||
[isVersionDownloaded, getBestAvailableVersion],
|
||||
);
|
||||
|
||||
// Get the selected OS for warning
|
||||
const selectedOS = camoufoxConfig.os?.[0];
|
||||
const currentOS = getCurrentOS();
|
||||
const _showOSWarning =
|
||||
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
|
||||
// Check if browser is currently downloading
|
||||
const isBrowserCurrentlyDownloading = useCallback(
|
||||
(browserStr: string) => {
|
||||
return isBrowserDownloading(browserStr);
|
||||
},
|
||||
[isBrowserDownloading],
|
||||
);
|
||||
|
||||
const isCreateDisabled = useMemo(() => {
|
||||
if (!profileName.trim()) return true;
|
||||
if (!selectedBrowser) return true;
|
||||
if (isBrowserCurrentlyDownloading(selectedBrowser)) return true;
|
||||
if (!isBrowserVersionAvailable(selectedBrowser)) return true;
|
||||
|
||||
return false;
|
||||
}, [
|
||||
profileName,
|
||||
selectedBrowser,
|
||||
isBrowserCurrentlyDownloading,
|
||||
isBrowserVersionAvailable,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
selectedBrowser,
|
||||
selectedBrowser && isBrowserCurrentlyDownloading(selectedBrowser),
|
||||
selectedBrowser && isBrowserVersionAvailable(selectedBrowser),
|
||||
selectedBrowser && getBestAvailableVersion(selectedBrowser),
|
||||
);
|
||||
}, [
|
||||
selectedBrowser,
|
||||
isBrowserCurrentlyDownloading,
|
||||
isBrowserVersionAvailable,
|
||||
getBestAvailableVersion,
|
||||
]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogContent className="w-full max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Create New Profile</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
onValueChange={handleTabChange}
|
||||
className="flex flex-col flex-1 w-full min-h-0"
|
||||
>
|
||||
<TabsList className="grid flex-shrink-0 grid-cols-2 w-full">
|
||||
<TabsTrigger value="regular">Regular Browsers</TabsTrigger>
|
||||
<TabsList
|
||||
className="grid flex-shrink-0 grid-cols-2 w-full"
|
||||
defaultValue="anti-detect"
|
||||
>
|
||||
<TabsTrigger value="anti-detect">Anti-Detect</TabsTrigger>
|
||||
<TabsTrigger value="regular">Regular</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<ScrollArea className="flex-1 pr-6 h-[350px]">
|
||||
<div className="py-4 space-y-6">
|
||||
{/* Profile Name - Common to both tabs */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
placeholder="Enter profile name"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="regular" className="mt-0 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Browser</Label>
|
||||
<Combobox
|
||||
options={browserOptions
|
||||
.filter((browser) =>
|
||||
supportedBrowsers.includes(browser.value),
|
||||
)
|
||||
.map((browser) => {
|
||||
const IconComponent = getBrowserIcon(browser.value);
|
||||
return {
|
||||
value: browser.value,
|
||||
label: browser.label,
|
||||
icon: IconComponent,
|
||||
};
|
||||
})}
|
||||
value={selectedBrowser || ""}
|
||||
onValueChange={(value) =>
|
||||
setSelectedBrowser(value as BrowserTypeString)
|
||||
}
|
||||
placeholder="Select a browser..."
|
||||
searchPlaceholder="Search browsers..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
{!isBrowserVersionAvailable(selectedBrowser) &&
|
||||
availableReleaseTypes.stable && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Latest stable version (
|
||||
{availableReleaseTypes.stable}) needs to be
|
||||
downloaded
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload(selectedBrowser)}
|
||||
isLoading={isBrowserDownloading(selectedBrowser)}
|
||||
size="sm"
|
||||
disabled={isBrowserDownloading(selectedBrowser)}
|
||||
>
|
||||
Download
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{isBrowserVersionAvailable(selectedBrowser) && (
|
||||
<div className="text-sm text-green-600">
|
||||
✓ Latest stable version (
|
||||
{availableReleaseTypes.stable}) is available
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
{/* Anti-Detect Description */}
|
||||
<div className="p-3 text-center bg-blue-50 rounded-md border border-blue-200 dark:bg-blue-950 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
Powered by Camoufox
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Camoufox Download Status */}
|
||||
{!isBrowserVersionAvailable("camoufox") &&
|
||||
camoufoxReleaseTypes.stable && (
|
||||
<div className="flex gap-3 items-center p-3 bg-amber-50 rounded-md border border-amber-200">
|
||||
<p className="text-sm text-amber-800">
|
||||
Camoufox version ({camoufoxReleaseTypes.stable}) needs
|
||||
to be downloaded
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload("camoufox")}
|
||||
isLoading={isBrowserDownloading("camoufox")}
|
||||
size="sm"
|
||||
disabled={isBrowserDownloading("camoufox")}
|
||||
>
|
||||
Download
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{isBrowserVersionAvailable("camoufox") && (
|
||||
<div className="p-3 text-sm text-green-600 bg-green-50 rounded-md border border-green-200">
|
||||
✓ Camoufox version ({camoufoxReleaseTypes.stable}) is
|
||||
available
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
<ScrollArea className="flex-1 h-[330px] overflow-y-hidden">
|
||||
<div className="flex flex-col justify-center items-center w-full">
|
||||
<div className="py-4 space-y-6 w-full max-w-md">
|
||||
{/* Profile Name - Common to both tabs */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
placeholder="Enter profile name"
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Proxy Selection - Common to both tabs - Compact without card */}
|
||||
{storedProxies.length > 0 && (
|
||||
<TabsContent value="regular" className="mt-0 space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Browser</Label>
|
||||
<Combobox
|
||||
options={browserOptions
|
||||
.filter(
|
||||
(browser) =>
|
||||
supportedBrowsers.includes(browser.value) &&
|
||||
browser.value !== "mullvad-browser" &&
|
||||
browser.value !== "tor-browser",
|
||||
)
|
||||
.map((browser) => {
|
||||
const IconComponent = getBrowserIcon(browser.value);
|
||||
return {
|
||||
value: browser.value,
|
||||
label: browser.label,
|
||||
icon: IconComponent,
|
||||
};
|
||||
})}
|
||||
value={selectedBrowser || ""}
|
||||
onValueChange={(value) =>
|
||||
setSelectedBrowser(value as BrowserTypeString)
|
||||
}
|
||||
placeholder="Select a browser..."
|
||||
searchPlaceholder="Search browsers..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
{!isBrowserCurrentlyDownloading(selectedBrowser) &&
|
||||
!isBrowserVersionAvailable(selectedBrowser) &&
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(selectedBrowser);
|
||||
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload(selectedBrowser)}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
)}
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
)}
|
||||
>
|
||||
Download
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{!isBrowserCurrentlyDownloading(selectedBrowser) &&
|
||||
isBrowserVersionAvailable(selectedBrowser) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(selectedBrowser);
|
||||
return `✓ Latest version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading(selectedBrowser) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(selectedBrowser);
|
||||
return `Downloading version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Camoufox Download Status */}
|
||||
{!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
!isBrowserVersionAvailable("camoufox") &&
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Camoufox version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload("camoufox")}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
size="sm"
|
||||
disabled={isBrowserCurrentlyDownloading("camoufox")}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? "Downloading..."
|
||||
: "Download"}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
isBrowserVersionAvailable("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `✓ Camoufox version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Downloading Camoufox version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
isCreating
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Proxy Selection - Common to both tabs - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<Label>Proxy</Label>
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedProxyId(value === "none" ? undefined : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name} ({proxy.proxy_settings.proxy_type}://
|
||||
{proxy.proxy_settings.host}:
|
||||
{proxy.proxy_settings.port})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowProxyForm(true)}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedProxyId(value === "none" ? undefined : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies available. Add one to route this profile's
|
||||
traffic.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
onClick={handleCreate}
|
||||
isLoading={isCreating}
|
||||
disabled={isCreateDisabled()}
|
||||
disabled={isCreateDisabled}
|
||||
>
|
||||
Create Profile
|
||||
Create
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
<ProxyFormDialog
|
||||
isOpen={showProxyForm}
|
||||
onClose={() => setShowProxyForm(false)}
|
||||
onSave={(proxy) => {
|
||||
setStoredProxies((prev) => [...prev, proxy]);
|
||||
setSelectedProxyId(proxy.id);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
+47
-144
@@ -47,20 +47,23 @@
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
/** biome-ignore-all lint/suspicious/noExplicitAny: TODO */
|
||||
|
||||
import {
|
||||
LuCheckCheck,
|
||||
LuDownload,
|
||||
LuRefreshCw,
|
||||
LuRocket,
|
||||
LuTriangleAlert,
|
||||
} from "react-icons/lu";
|
||||
import type { ExternalToast } from "sonner";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface BaseToastProps {
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
action?: ExternalToast["action"];
|
||||
}
|
||||
|
||||
interface LoadingToastProps extends BaseToastProps {
|
||||
@@ -111,16 +114,6 @@ interface TwilightUpdateToastProps extends BaseToastProps {
|
||||
hasUpdate?: boolean;
|
||||
}
|
||||
|
||||
interface AppUpdateToastProps extends BaseToastProps {
|
||||
type: "app-update";
|
||||
stage?: "downloading" | "extracting" | "installing" | "completed";
|
||||
progress?: {
|
||||
percentage: number;
|
||||
speed?: string;
|
||||
eta?: string;
|
||||
};
|
||||
}
|
||||
|
||||
type ToastProps =
|
||||
| LoadingToastProps
|
||||
| SuccessToastProps
|
||||
@@ -128,92 +121,57 @@ type ToastProps =
|
||||
| DownloadToastProps
|
||||
| VersionUpdateToastProps
|
||||
| FetchingToastProps
|
||||
| TwilightUpdateToastProps
|
||||
| AppUpdateToastProps;
|
||||
| TwilightUpdateToastProps;
|
||||
|
||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />;
|
||||
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />;
|
||||
case "error":
|
||||
return <LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-red-500" />;
|
||||
return (
|
||||
<LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-foreground" />
|
||||
);
|
||||
case "download":
|
||||
if (stage === "completed") {
|
||||
return (
|
||||
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />
|
||||
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-foreground" />
|
||||
);
|
||||
}
|
||||
return <LuDownload className="flex-shrink-0 w-4 h-4 text-blue-500" />;
|
||||
case "app-update":
|
||||
if (stage === "completed") {
|
||||
return (
|
||||
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />
|
||||
);
|
||||
} else if (stage === "downloading") {
|
||||
return <LuDownload className="flex-shrink-0 w-4 h-4 text-blue-500" />;
|
||||
} else if (stage === "installing") {
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
|
||||
);
|
||||
return <LuDownload className="flex-shrink-0 w-4 h-4 text-foreground" />;
|
||||
|
||||
case "version-update":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "fetching":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "twilight-update":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-purple-500 animate-spin" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 animate-spin text-foreground" />
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 animate-spin border-foreground border-t-transparent" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function UnifiedToast(props: ToastProps) {
|
||||
const { title, description, type } = props;
|
||||
const { title, description, type, action } = props;
|
||||
const stage = "stage" in props ? props.stage : undefined;
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
|
||||
// Check if this is an auto-update toast
|
||||
const isAutoUpdate = title.includes("update started");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-start p-3 w-96 rounded-lg border shadow-lg ${
|
||||
isAutoUpdate
|
||||
? "bg-emerald-50 border-emerald-200 dark:bg-emerald-950 dark:border-emerald-800"
|
||||
: "bg-white border-gray-200 dark:bg-gray-800 dark:border-gray-700"
|
||||
}`}
|
||||
data-toast-type={isAutoUpdate ? "auto-update" : "default"}
|
||||
>
|
||||
<div className="mr-3 mt-0.5">
|
||||
{isAutoUpdate ? (
|
||||
<LuRocket className="flex-shrink-0 w-4 h-4 text-emerald-500" />
|
||||
) : (
|
||||
getToastIcon(type, stage)
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p
|
||||
className={`text-sm font-medium leading-tight ${
|
||||
isAutoUpdate
|
||||
? "text-emerald-900 dark:text-emerald-100"
|
||||
: "text-gray-900 dark:text-white"
|
||||
}`}
|
||||
>
|
||||
<p className="text-sm font-semibold leading-tight text-foreground">
|
||||
{title}
|
||||
</p>
|
||||
|
||||
@@ -224,82 +182,41 @@ export function UnifiedToast(props: ToastProps) {
|
||||
stage === "downloading" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="flex-1 min-w-0 text-xs text-gray-600 dark:text-gray-300">
|
||||
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
{progress.speed && ` • ${progress.speed} MB/s`}
|
||||
{progress.eta && ` • ${progress.eta} remaining`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div className="w-full bg-muted rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* App update progress */}
|
||||
{type === "app-update" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
{/* Download progress with percentage */}
|
||||
{progress &&
|
||||
"percentage" in progress &&
|
||||
stage === "downloading" && (
|
||||
<>
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="flex-1 min-w-0 text-xs text-gray-600 dark:text-gray-300">
|
||||
{progress.percentage.toFixed(1)}%
|
||||
{progress.speed && ` • ${progress.speed} MB/s`}
|
||||
{progress.eta && ` • ${progress.eta} remaining`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Progress indicator for other stages */}
|
||||
{(stage === "extracting" ||
|
||||
stage === "installing" ||
|
||||
stage === "completed") && (
|
||||
<div className="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5">
|
||||
<div
|
||||
className={`h-1.5 rounded-full transition-all duration-500 ${
|
||||
stage === "completed"
|
||||
? "bg-green-500 w-full"
|
||||
: "bg-blue-500 w-full animate-pulse"
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version update progress */}
|
||||
{type === "version-update" &&
|
||||
progress &&
|
||||
"current_browser" in progress && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{progress.current_browser && (
|
||||
<>Looking for updates for {progress.current_browser}</>
|
||||
)}
|
||||
</p>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 min-w-0">
|
||||
<div className="flex-1 bg-muted rounded-full h-1.5 min-w-0">
|
||||
<div
|
||||
className="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
className="bg-foreground h-1.5 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${(progress.current / progress.total) * 100}%`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-8 text-xs text-right text-gray-500 whitespace-nowrap dark:text-gray-400 shrink-0">
|
||||
<span className="w-8 text-xs text-right whitespace-nowrap text-muted-foreground shrink-0">
|
||||
{progress.current}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
@@ -309,13 +226,13 @@ export function UnifiedToast(props: ToastProps) {
|
||||
{/* Twilight update progress */}
|
||||
{type === "twilight-update" && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{"hasUpdate" in props && props.hasUpdate
|
||||
? "New twilight build available for download"
|
||||
: "Checking for twilight updates..."}
|
||||
</p>
|
||||
{props.browserName && (
|
||||
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
{props.browserName} • Rolling Release
|
||||
</p>
|
||||
)}
|
||||
@@ -324,13 +241,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p
|
||||
className={`mt-1 text-xs leading-tight ${
|
||||
isAutoUpdate
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
<p className="mt-1 text-xs leading-tight text-muted-foreground">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
@@ -339,43 +250,35 @@ export function UnifiedToast(props: ToastProps) {
|
||||
{type === "download" && !description && (
|
||||
<>
|
||||
{stage === "extracting" && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Extracting browser files...
|
||||
</p>
|
||||
)}
|
||||
{stage === "verifying" && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Verifying browser files...
|
||||
</p>
|
||||
)}
|
||||
{stage === "downloading (twilight rolling release)" && (
|
||||
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
<p className="mt-1 text-xs text-muted-foreground">
|
||||
Downloading rolling release build...
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stage-specific descriptions for app updates */}
|
||||
{type === "app-update" && !description && (
|
||||
<>
|
||||
{stage === "extracting" && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Preparing update files...
|
||||
</p>
|
||||
)}
|
||||
{stage === "installing" && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Installing new version...
|
||||
</p>
|
||||
)}
|
||||
{stage === "completed" && (
|
||||
<p className="mt-1 text-xs text-green-600 dark:text-green-400">
|
||||
Update completed! Restarting application...
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{action &&
|
||||
"onClick" in (action as any) &&
|
||||
"label" in (action as any) && (
|
||||
<div className="mt-2 w-full">
|
||||
<RippleButton
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
onClick={(action as any).onClick}
|
||||
>
|
||||
{(action as any).label}
|
||||
</RippleButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { LoadingButton } from "./loading-button";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface DeleteConfirmationDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmButtonText?: string;
|
||||
isLoading?: boolean;
|
||||
profileNames?: string[];
|
||||
}
|
||||
|
||||
export function DeleteConfirmationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmButtonText = "Delete",
|
||||
isLoading = false,
|
||||
profileNames,
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
{profileNames && profileNames.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Profiles to be deleted:
|
||||
</p>
|
||||
<div className="bg-muted rounded-md p-3 max-h-32 overflow-y-auto">
|
||||
<ul className="space-y-1">
|
||||
{profileNames.map((name) => (
|
||||
<li key={name} className="text-sm text-muted-foreground">
|
||||
• {name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
onClick={() => void handleConfirm()}
|
||||
isLoading={isLoading}
|
||||
>
|
||||
{confirmButtonText}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { BrowserProfile, ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface DeleteGroupDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
group: ProfileGroup | null;
|
||||
onGroupDeleted: () => void;
|
||||
}
|
||||
|
||||
export function DeleteGroupDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
group,
|
||||
onGroupDeleted,
|
||||
}: DeleteGroupDialogProps) {
|
||||
const [associatedProfiles, setAssociatedProfiles] = useState<
|
||||
BrowserProfile[]
|
||||
>([]);
|
||||
const [deleteAction, setDeleteAction] = useState<"move" | "delete">("move");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAssociatedProfiles = useCallback(async () => {
|
||||
if (!group) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const allProfiles = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
const groupProfiles = allProfiles.filter(
|
||||
(profile) => profile.group_id === group.id,
|
||||
);
|
||||
setAssociatedProfiles(groupProfiles);
|
||||
} catch (err) {
|
||||
console.error("Failed to load associated profiles:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load profiles");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [group]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && group) {
|
||||
void loadAssociatedProfiles();
|
||||
}
|
||||
}, [isOpen, group, loadAssociatedProfiles]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!group) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (deleteAction === "delete" && associatedProfiles.length > 0) {
|
||||
// Delete all associated profiles first
|
||||
const profileNames = associatedProfiles.map((p) => p.name);
|
||||
await invoke("delete_selected_profiles", { profileNames });
|
||||
} else if (deleteAction === "move" && associatedProfiles.length > 0) {
|
||||
// Move profiles to default group (null group_id)
|
||||
const profileNames = associatedProfiles.map((p) => p.name);
|
||||
await invoke("assign_profiles_to_group", {
|
||||
profileNames,
|
||||
groupId: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the group
|
||||
await invoke("delete_profile_group", { groupId: group.id });
|
||||
|
||||
toast.success("Group deleted successfully");
|
||||
onGroupDeleted();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to delete group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
setDeleteAction("move");
|
||||
setAssociatedProfiles([]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the group
|
||||
"{group?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading associated profiles...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{associatedProfiles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Associated Profiles ({associatedProfiles.length})
|
||||
</Label>
|
||||
<ScrollArea className="h-32 w-full border rounded-md p-3">
|
||||
<div className="space-y-1">
|
||||
{associatedProfiles.map((profile) => (
|
||||
<div key={profile.id} className="text-sm">
|
||||
• {profile.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>What should happen to these profiles?</Label>
|
||||
<RadioGroup
|
||||
value={deleteAction}
|
||||
onValueChange={(value) =>
|
||||
setDeleteAction(value as "move" | "delete")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="move" id="move" />
|
||||
<Label htmlFor="move" className="text-sm">
|
||||
Move profiles to Default group
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="delete" id="delete" />
|
||||
<Label
|
||||
htmlFor="delete"
|
||||
className="text-sm text-red-600"
|
||||
>
|
||||
Delete profiles along with the group
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{associatedProfiles.length === 0 && !isLoading && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This group has no associated profiles.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isDeleting}
|
||||
>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
isLoading={isDeleting}
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete Group
|
||||
{deleteAction === "delete" &&
|
||||
associatedProfiles.length > 0 &&
|
||||
" & Profiles"}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface EditGroupDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
group: ProfileGroup | null;
|
||||
onGroupUpdated: (group: ProfileGroup) => void;
|
||||
}
|
||||
|
||||
export function EditGroupDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
group,
|
||||
onGroupUpdated,
|
||||
}: EditGroupDialogProps) {
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (group) {
|
||||
setGroupName(group.name);
|
||||
} else {
|
||||
setGroupName("");
|
||||
}
|
||||
setError(null);
|
||||
}, [group]);
|
||||
|
||||
const handleUpdate = useCallback(async () => {
|
||||
if (!group || !groupName.trim()) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updatedGroup = await invoke<ProfileGroup>("update_profile_group", {
|
||||
groupId: group.id,
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group updated successfully");
|
||||
onGroupUpdated(updatedGroup);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to update group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to update group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [group, groupName, onGroupUpdated, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the name of the group "{group?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && groupName.trim()) {
|
||||
void handleUpdate();
|
||||
}
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isUpdating}
|
||||
>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isUpdating}
|
||||
onClick={() => void handleUpdate()}
|
||||
disabled={!groupName.trim() || groupName === group?.name}
|
||||
>
|
||||
Update Group
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { toast } from "sonner";
|
||||
import { CreateGroupDialog } from "@/components/create-group-dialog";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface GroupAssignmentDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedProfiles: string[];
|
||||
onAssignmentComplete: () => void;
|
||||
}
|
||||
|
||||
export function GroupAssignmentDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedProfiles,
|
||||
onAssignmentComplete,
|
||||
}: GroupAssignmentDialogProps) {
|
||||
const [groups, setGroups] = useState<ProfileGroup[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const groupList = await invoke<ProfileGroup[]>("get_profile_groups");
|
||||
setGroups(groupList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAssign = useCallback(async () => {
|
||||
setIsAssigning(true);
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("assign_profiles_to_group", {
|
||||
profileNames: selectedProfiles,
|
||||
groupId: selectedGroupId,
|
||||
});
|
||||
|
||||
const groupName = selectedGroupId
|
||||
? groups.find((g) => g.id === selectedGroupId)?.name || "Unknown Group"
|
||||
: "Default";
|
||||
|
||||
toast.success(
|
||||
`Successfully assigned ${selectedProfiles.length} profile(s) to ${groupName}`,
|
||||
);
|
||||
onAssignmentComplete();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to assign profiles to group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to assign profiles to group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsAssigning(false);
|
||||
}
|
||||
}, [
|
||||
selectedProfiles,
|
||||
selectedGroupId,
|
||||
groups,
|
||||
onAssignmentComplete,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadGroups();
|
||||
setSelectedGroupId(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, loadGroups]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign to Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedProfiles.length} selected profile(s) to a group.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Selected Profiles:</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileName) => (
|
||||
<li key={profileName} className="truncate">
|
||||
• {profileName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label htmlFor="group-select">Assign to Group:</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 px-2 text-xs"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Create Group
|
||||
</RippleButton>
|
||||
</div>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGroupId || "default"}
|
||||
onValueChange={(value) => {
|
||||
setSelectedGroupId(value === "default" ? null : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a group" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default (No Group)</SelectItem>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={onClose}
|
||||
disabled={isAssigning}
|
||||
>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isAssigning}
|
||||
onClick={() => void handleAssign()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Assign
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
<CreateGroupDialog
|
||||
isOpen={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
onGroupCreated={(group) => {
|
||||
setGroups((prev) => [...prev, group]);
|
||||
setSelectedGroupId(group.id);
|
||||
setCreateDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
|
||||
interface GroupBadgesProps {
|
||||
selectedGroupId: string | null;
|
||||
onGroupSelect: (groupId: string) => void;
|
||||
refreshTrigger?: number;
|
||||
groups: GroupWithCount[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function GroupBadges({
|
||||
selectedGroupId,
|
||||
onGroupSelect,
|
||||
groups,
|
||||
isLoading,
|
||||
}: GroupBadgesProps) {
|
||||
if (isLoading && !groups.length) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<div className="flex items-center gap-2 px-4.5 py-1.5 text-xs">
|
||||
Loading groups...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{groups.map((group) => (
|
||||
<Badge
|
||||
key={group.id}
|
||||
variant={selectedGroupId === group.id ? "default" : "secondary"}
|
||||
className="flex gap-2 items-center px-3 py-1 transition-colors cursor-pointer dark:hover:bg-primary/60 hover:bg-primary/80"
|
||||
onClick={() => {
|
||||
onGroupSelect(selectedGroupId === group.id ? "default" : group.id);
|
||||
}}
|
||||
>
|
||||
<span>{group.name}</span>
|
||||
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
|
||||
{group.count}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||
import { CreateGroupDialog } from "@/components/create-group-dialog";
|
||||
import { DeleteGroupDialog } from "@/components/delete-group-dialog";
|
||||
import { EditGroupDialog } from "@/components/edit-group-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface GroupManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onGroupManagementComplete: () => void;
|
||||
}
|
||||
|
||||
export function GroupManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onGroupManagementComplete,
|
||||
}: GroupManagementDialogProps) {
|
||||
const [groups, setGroups] = useState<ProfileGroup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Dialog states
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState<ProfileGroup | null>(null);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const groupList = await invoke<ProfileGroup[]>("get_profile_groups");
|
||||
setGroups(groupList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleGroupCreated = useCallback(
|
||||
(newGroup: ProfileGroup) => {
|
||||
setGroups((prev) => [...prev, newGroup]);
|
||||
onGroupManagementComplete();
|
||||
},
|
||||
[onGroupManagementComplete],
|
||||
);
|
||||
|
||||
const handleGroupUpdated = useCallback(
|
||||
(updatedGroup: ProfileGroup) => {
|
||||
setGroups((prev) =>
|
||||
prev.map((group) =>
|
||||
group.id === updatedGroup.id ? updatedGroup : group,
|
||||
),
|
||||
);
|
||||
onGroupManagementComplete();
|
||||
},
|
||||
[onGroupManagementComplete],
|
||||
);
|
||||
|
||||
const handleGroupDeleted = useCallback(() => {
|
||||
void loadGroups();
|
||||
onGroupManagementComplete();
|
||||
}, [loadGroups, onGroupManagementComplete]);
|
||||
|
||||
const handleEditGroup = useCallback((group: ProfileGroup) => {
|
||||
setSelectedGroup(group);
|
||||
setEditDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteGroup = useCallback((group: ProfileGroup) => {
|
||||
setSelectedGroup(group);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadGroups();
|
||||
}
|
||||
}, [isOpen, loadGroups]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Profile Groups</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create, edit, and delete profile groups. Profiles without a group
|
||||
will appear in the "Default" group.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Groups</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No groups created yet. Create your first group using the button
|
||||
above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
{group.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreateGroupDialog
|
||||
isOpen={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
onGroupCreated={handleGroupCreated}
|
||||
/>
|
||||
|
||||
<EditGroupDialog
|
||||
isOpen={editDialogOpen}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
group={selectedGroup}
|
||||
onGroupUpdated={handleGroupUpdated}
|
||||
/>
|
||||
|
||||
<DeleteGroupDialog
|
||||
isOpen={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
group={selectedGroup}
|
||||
onGroupDeleted={handleGroupDeleted}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import { LuTrash2, LuUsers } from "react-icons/lu";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Button } from "./ui/button";
|
||||
import { CardTitle } from "./ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type Props = {
|
||||
selectedProfiles: string[];
|
||||
onBulkGroupAssignment: () => void;
|
||||
onBulkDelete: () => void;
|
||||
onSettingsDialogOpen: (open: boolean) => void;
|
||||
onProxyManagementDialogOpen: (open: boolean) => void;
|
||||
onGroupManagementDialogOpen: (open: boolean) => void;
|
||||
onImportProfileDialogOpen: (open: boolean) => void;
|
||||
onCreateProfileDialogOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const HomeHeader = ({
|
||||
selectedProfiles,
|
||||
onBulkGroupAssignment,
|
||||
onBulkDelete,
|
||||
onSettingsDialogOpen,
|
||||
onProxyManagementDialogOpen,
|
||||
onGroupManagementDialogOpen,
|
||||
onImportProfileDialogOpen,
|
||||
onCreateProfileDialogOpen,
|
||||
}: Props) => {
|
||||
const handleLogoClick = () => {
|
||||
// Trigger the same URL handling logic as if the URL came from the system
|
||||
const event = new CustomEvent("url-open-request", {
|
||||
detail: "https://donutbrowser.com",
|
||||
});
|
||||
window.dispatchEvent(event);
|
||||
};
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex gap-3 items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 cursor-pointer"
|
||||
title="Open donutbrowser.com"
|
||||
onClick={handleLogoClick}
|
||||
>
|
||||
<Logo className="w-10 h-10 transition-transform duration-300 ease-out will-change-transform hover:scale-110" />
|
||||
</button>
|
||||
{selectedProfiles.length > 0 ? (
|
||||
<div className="flex gap-3 items-center">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedProfiles.length} profile
|
||||
{selectedProfiles.length !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onBulkGroupAssignment}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUsers className="w-4 h-4" />
|
||||
Assign to Group
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onBulkDelete}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
Delete Selected
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CardTitle>Donut</CardTitle>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoKebabHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onSettingsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<GoGear className="mr-2 w-4 h-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onProxyManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FiWifi className="mr-2 w-4 h-4" />
|
||||
Proxies
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onGroupManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<LuUsers className="mr-2 w-4 h-4" />
|
||||
Groups
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onImportProfileDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FaDownload className="mr-2 w-4 h-4" />
|
||||
Import Profile
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onCreateProfileDialogOpen(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Create a new profile</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeHeader;
|
||||
File diff suppressed because one or more lines are too long
@@ -26,6 +26,7 @@ import {
|
||||
import { useBrowserSupport } from "@/hooks/use-browser-support";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type { DetectedProfile } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ImportProfileDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -63,6 +64,11 @@ export function ImportProfileDialog({
|
||||
const { supportedBrowsers, isLoading: isLoadingSupport } =
|
||||
useBrowserSupport();
|
||||
|
||||
// Exclude browsers that are no longer supported for import
|
||||
const importableBrowsers = supportedBrowsers.filter(
|
||||
(b) => b !== "mullvad-browser" && b !== "tor-browser",
|
||||
);
|
||||
|
||||
const loadDetectedProfiles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -242,7 +248,7 @@ export function ImportProfileDialog({
|
||||
);
|
||||
if (profile) {
|
||||
const browserName = getBrowserDisplayName(profile.browser);
|
||||
const defaultName = `Imported ${browserName} Profile`;
|
||||
const defaultName = `Old ${browserName}`;
|
||||
setAutoDetectProfileName(defaultName);
|
||||
}
|
||||
}
|
||||
@@ -268,7 +274,7 @@ export function ImportProfileDialog({
|
||||
<div className="overflow-y-auto flex-1 space-y-6 min-h-0">
|
||||
{/* Mode Selection */}
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
<RippleButton
|
||||
variant={importMode === "auto-detect" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setImportMode("auto-detect");
|
||||
@@ -277,8 +283,8 @@ export function ImportProfileDialog({
|
||||
disabled={isLoading}
|
||||
>
|
||||
Auto-Detect
|
||||
</Button>
|
||||
<Button
|
||||
</RippleButton>
|
||||
<RippleButton
|
||||
variant={importMode === "manual" ? "default" : "outline"}
|
||||
onClick={() => {
|
||||
setImportMode("manual");
|
||||
@@ -287,7 +293,7 @@ export function ImportProfileDialog({
|
||||
disabled={isLoading}
|
||||
>
|
||||
Manual Import
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{/* Auto-Detect Mode */}
|
||||
@@ -409,7 +415,7 @@ export function ImportProfileDialog({
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedBrowsers.map((browser) => {
|
||||
{importableBrowsers.map((browser) => {
|
||||
const IconComponent = getBrowserIcon(browser);
|
||||
return (
|
||||
<SelectItem key={browser} value={browser}>
|
||||
@@ -479,9 +485,9 @@ export function ImportProfileDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</RippleButton>
|
||||
{importMode === "auto-detect" ? (
|
||||
<LoadingButton
|
||||
isLoading={isImporting}
|
||||
@@ -494,7 +500,7 @@ export function ImportProfileDialog({
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
Import Profile
|
||||
Import
|
||||
</LoadingButton>
|
||||
) : (
|
||||
<LoadingButton
|
||||
@@ -508,7 +514,7 @@ export function ImportProfileDialog({
|
||||
!manualProfileName.trim()
|
||||
}
|
||||
>
|
||||
Import Profile
|
||||
Import
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { LuLoaderCircle } from "react-icons/lu";
|
||||
import { type ButtonProps, Button as UIButton } from "./ui/button";
|
||||
import {
|
||||
type RippleButtonProps as ButtonProps,
|
||||
RippleButton as UIButton,
|
||||
} from "./ui/ripple";
|
||||
|
||||
type Props = ButtonProps & {
|
||||
isLoading: boolean;
|
||||
@@ -7,7 +10,11 @@ type Props = ButtonProps & {
|
||||
};
|
||||
export const LoadingButton = ({ isLoading, ...props }: Props) => {
|
||||
return (
|
||||
<UIButton className="grid place-items-center" {...props}>
|
||||
<UIButton
|
||||
className="grid place-items-center"
|
||||
{...props}
|
||||
disabled={props.disabled || isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<LuLoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
|
||||
@@ -0,0 +1,537 @@
|
||||
/** biome-ignore-all lint/a11y/noStaticElementInteractions: temporary suppress until in active use */
|
||||
/** biome-ignore-all lint/a11y/useKeyWithClickEvents: temporary suppress until in active use */
|
||||
"use client";
|
||||
|
||||
import { Command as CommandPrimitive, useCommandState } from "cmdk";
|
||||
import * as React from "react";
|
||||
import { forwardRef, useEffect } from "react";
|
||||
import { LuX } from "react-icons/lu";
|
||||
import { cn } from "../lib/utils";
|
||||
import { Badge } from "./ui/badge";
|
||||
import { Command, CommandGroup, CommandItem, CommandList } from "./ui/command";
|
||||
|
||||
export interface Option {
|
||||
value: string;
|
||||
label?: string;
|
||||
disable?: boolean;
|
||||
/** fixed option that can't be removed. */
|
||||
fixed?: boolean;
|
||||
/** Group the options by providing key. */
|
||||
[key: string]: string | boolean | undefined;
|
||||
}
|
||||
interface GroupOption {
|
||||
[key: string]: Option[];
|
||||
}
|
||||
|
||||
interface MultipleSelectorProps {
|
||||
value?: Option[];
|
||||
defaultOptions?: Option[];
|
||||
/** manually controlled options */
|
||||
options?: Option[];
|
||||
placeholder?: string;
|
||||
/** Loading component. */
|
||||
loadingIndicator?: React.ReactNode;
|
||||
/** Empty component. */
|
||||
emptyIndicator?: React.ReactNode;
|
||||
/** Debounce time for async search. Only work with `onSearch`. */
|
||||
delay?: number;
|
||||
/**
|
||||
* Only work with `onSearch` prop. Trigger search when `onFocus`.
|
||||
* For example, when user click on the input, it will trigger the search to get initial options.
|
||||
**/
|
||||
triggerSearchOnFocus?: boolean;
|
||||
/** async search */
|
||||
onSearch?: (value: string) => Promise<Option[]>;
|
||||
onChange?: (options: Option[]) => void;
|
||||
/** Limit the maximum number of selected options. */
|
||||
maxSelected?: number;
|
||||
/** When the number of selected options exceeds the limit, the onMaxSelected will be called. */
|
||||
onMaxSelected?: (maxLimit: number) => void;
|
||||
/** Hide the placeholder when there are options selected. */
|
||||
hidePlaceholderWhenSelected?: boolean;
|
||||
disabled?: boolean;
|
||||
/** Group the options base on provided key. */
|
||||
groupBy?: string;
|
||||
className?: string;
|
||||
badgeClassName?: string;
|
||||
/**
|
||||
* First item selected is a default behavior by cmdk. That is why the default is true.
|
||||
* This is a workaround solution by add a dummy item.
|
||||
*
|
||||
* @reference: https://github.com/pacocoursey/cmdk/issues/171
|
||||
*/
|
||||
selectFirstItem?: boolean;
|
||||
/** Allow user to create option when there is no option matched. */
|
||||
creatable?: boolean;
|
||||
/** Props of `Command` */
|
||||
commandProps?: React.ComponentPropsWithoutRef<typeof Command>;
|
||||
/** Props of `CommandInput` */
|
||||
inputProps?: Omit<
|
||||
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>,
|
||||
"value" | "placeholder" | "disabled"
|
||||
>;
|
||||
}
|
||||
|
||||
export interface MultipleSelectorRef {
|
||||
selectedValue: Option[];
|
||||
input: HTMLInputElement;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line react-refresh/only-export-components
|
||||
export function useDebounce<T>(value: T, delay?: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = React.useState<T>(value);
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setTimeout(() => setDebouncedValue(value), delay || 500);
|
||||
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [value, delay]);
|
||||
|
||||
return debouncedValue;
|
||||
}
|
||||
|
||||
function transToGroupOption(options: Option[], groupBy?: string) {
|
||||
if (options.length === 0) {
|
||||
return {};
|
||||
}
|
||||
if (!groupBy) {
|
||||
return {
|
||||
"": options,
|
||||
};
|
||||
}
|
||||
|
||||
const groupOption: GroupOption = {};
|
||||
options.forEach((option) => {
|
||||
const key = (option[groupBy] as string) || "";
|
||||
if (!groupOption[key]) {
|
||||
groupOption[key] = [option];
|
||||
} else {
|
||||
groupOption[key]?.push(option);
|
||||
}
|
||||
});
|
||||
return groupOption;
|
||||
}
|
||||
|
||||
function removePickedOption(groupOption: GroupOption, picked: Option[]) {
|
||||
const cloneOption = JSON.parse(JSON.stringify(groupOption)) as GroupOption;
|
||||
|
||||
for (const [key, value] of Object.entries(cloneOption)) {
|
||||
cloneOption[key] = value.filter(
|
||||
(val) => !picked.find((p) => p.value === val.value),
|
||||
);
|
||||
}
|
||||
return cloneOption;
|
||||
}
|
||||
|
||||
function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
|
||||
for (const [, value] of Object.entries(groupOption)) {
|
||||
if (
|
||||
value.some((option) => targetOption.find((p) => p.value === option.value))
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* The `CommandEmpty` of shadcn/ui will cause the cmdk empty not rendering correctly.
|
||||
* So we create one and copy the `Empty` implementation from `cmdk`.
|
||||
*
|
||||
* @reference: https://github.com/hsuanyi-chou/shadcn-ui-expansions/issues/34#issuecomment-1949561607
|
||||
**/
|
||||
const CommandEmpty = forwardRef<
|
||||
HTMLDivElement,
|
||||
React.ComponentProps<typeof CommandPrimitive.Empty>
|
||||
>(({ className, ...props }, forwardedRef) => {
|
||||
const render = useCommandState((state) => state.filtered.count === 0);
|
||||
|
||||
if (!render) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={forwardedRef}
|
||||
className={cn("py-6 text-sm text-center", className)}
|
||||
cmdk-empty=""
|
||||
role="presentation"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
CommandEmpty.displayName = "CommandEmpty";
|
||||
|
||||
const MultipleSelector = React.forwardRef<
|
||||
MultipleSelectorRef,
|
||||
MultipleSelectorProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
value,
|
||||
onChange,
|
||||
placeholder,
|
||||
defaultOptions: arrayDefaultOptions = [],
|
||||
options: arrayOptions,
|
||||
delay,
|
||||
onSearch,
|
||||
loadingIndicator,
|
||||
emptyIndicator,
|
||||
maxSelected = Number.MAX_SAFE_INTEGER,
|
||||
onMaxSelected,
|
||||
hidePlaceholderWhenSelected,
|
||||
disabled,
|
||||
groupBy,
|
||||
className,
|
||||
badgeClassName,
|
||||
selectFirstItem = true,
|
||||
creatable = false,
|
||||
triggerSearchOnFocus = false,
|
||||
commandProps,
|
||||
inputProps,
|
||||
}: MultipleSelectorProps,
|
||||
ref: React.Ref<MultipleSelectorRef>,
|
||||
) => {
|
||||
const inputRef = React.useRef<HTMLInputElement>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const [isLoading, setIsLoading] = React.useState(false);
|
||||
|
||||
const [selected, setSelected] = React.useState<Option[]>(value || []);
|
||||
const [options, setOptions] = React.useState<GroupOption>(
|
||||
transToGroupOption(arrayDefaultOptions, groupBy),
|
||||
);
|
||||
const [inputValue, setInputValue] = React.useState("");
|
||||
const debouncedSearchTerm = useDebounce(inputValue, delay || 500);
|
||||
|
||||
React.useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
selectedValue: [...selected],
|
||||
input: inputRef.current as HTMLInputElement,
|
||||
focus: () => inputRef.current?.focus(),
|
||||
}),
|
||||
[selected],
|
||||
);
|
||||
|
||||
const handleUnselect = React.useCallback(
|
||||
(option: Option) => {
|
||||
const newOptions = selected.filter((s) => s.value !== option.value);
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
},
|
||||
[onChange, selected],
|
||||
);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(e: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
const input = inputRef.current;
|
||||
if (input) {
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (input.value === "" && selected.length > 0) {
|
||||
const lastSelectOption = selected[selected.length - 1];
|
||||
// If last item is fixed, we should not remove it.
|
||||
if (!lastSelectOption?.fixed) {
|
||||
// biome-ignore lint/style/noNonNullAssertion: false positive
|
||||
handleUnselect(selected.at(-1)!);
|
||||
}
|
||||
}
|
||||
}
|
||||
// This is not a default behavior of the <input /> field
|
||||
if (e.key === "Escape") {
|
||||
input.blur();
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleUnselect, selected],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setSelected(value);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
useEffect(() => {
|
||||
/** If `onSearch` is provided, do not trigger options updated. */
|
||||
if (!arrayOptions || onSearch) {
|
||||
return;
|
||||
}
|
||||
const newOption = transToGroupOption(arrayOptions || [], groupBy);
|
||||
if (JSON.stringify(newOption) !== JSON.stringify(options)) {
|
||||
setOptions(newOption);
|
||||
}
|
||||
}, [arrayOptions, groupBy, onSearch, options]);
|
||||
|
||||
useEffect(() => {
|
||||
const doSearch = async () => {
|
||||
setIsLoading(true);
|
||||
const res = await onSearch?.(debouncedSearchTerm);
|
||||
setOptions(transToGroupOption(res || [], groupBy));
|
||||
setIsLoading(false);
|
||||
};
|
||||
|
||||
const exec = async () => {
|
||||
if (!onSearch || !open) return;
|
||||
|
||||
if (triggerSearchOnFocus) {
|
||||
await doSearch();
|
||||
}
|
||||
|
||||
if (debouncedSearchTerm) {
|
||||
await doSearch();
|
||||
}
|
||||
};
|
||||
|
||||
void exec();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]);
|
||||
|
||||
const CreatableItem = () => {
|
||||
if (!creatable) return undefined;
|
||||
if (
|
||||
isOptionsExist(options, [{ value: inputValue, label: inputValue }]) ||
|
||||
selected.find((s) => s.value === inputValue)
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const Item = (
|
||||
<CommandItem
|
||||
value={inputValue}
|
||||
className="cursor-pointer"
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={(value: string) => {
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
setInputValue("");
|
||||
const newOptions = [...selected, { value, label: value }];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}}
|
||||
>
|
||||
{`Create "${inputValue}"`}
|
||||
</CommandItem>
|
||||
);
|
||||
|
||||
// For normal creatable
|
||||
if (!onSearch && inputValue.length > 0) {
|
||||
return Item;
|
||||
}
|
||||
|
||||
// For async search creatable. avoid showing creatable item before loading at first.
|
||||
if (onSearch && debouncedSearchTerm.length > 0 && !isLoading) {
|
||||
return Item;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const EmptyItem = React.useCallback(() => {
|
||||
if (!emptyIndicator) return undefined;
|
||||
|
||||
// For async search that showing emptyIndicator
|
||||
if (onSearch && !creatable && Object.keys(options).length === 0) {
|
||||
return (
|
||||
<CommandItem value="-" disabled>
|
||||
{emptyIndicator}
|
||||
</CommandItem>
|
||||
);
|
||||
}
|
||||
|
||||
return <CommandEmpty>{emptyIndicator}</CommandEmpty>;
|
||||
}, [creatable, emptyIndicator, onSearch, options]);
|
||||
|
||||
const selectables = React.useMemo<GroupOption>(
|
||||
() => removePickedOption(options, selected),
|
||||
[options, selected],
|
||||
);
|
||||
|
||||
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
|
||||
const commandFilter = React.useCallback(() => {
|
||||
if (commandProps?.filter) {
|
||||
return commandProps.filter;
|
||||
}
|
||||
|
||||
if (creatable) {
|
||||
return (value: string, search: string) => {
|
||||
return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
|
||||
};
|
||||
}
|
||||
// Using default filter in `cmdk`. We don't have to provide it.
|
||||
return undefined;
|
||||
}, [creatable, commandProps?.filter]);
|
||||
|
||||
return (
|
||||
<Command
|
||||
{...commandProps}
|
||||
onKeyDown={(e) => {
|
||||
handleKeyDown(e);
|
||||
commandProps?.onKeyDown?.(e);
|
||||
}}
|
||||
className={cn(
|
||||
"h-auto overflow-visible bg-transparent",
|
||||
commandProps?.className,
|
||||
)}
|
||||
shouldFilter={
|
||||
commandProps?.shouldFilter !== undefined
|
||||
? commandProps.shouldFilter
|
||||
: !onSearch
|
||||
} // When onSearch is provided, we don't want to filter the options. You can still override it.
|
||||
filter={commandFilter()}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"min-h-10 rounded-md border border-input text-sm ring-offset-background focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2",
|
||||
{
|
||||
"px-3 py-2": selected.length !== 0,
|
||||
"cursor-text": !disabled && selected.length !== 0,
|
||||
},
|
||||
className,
|
||||
)}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selected.map((option) => {
|
||||
return (
|
||||
<Badge
|
||||
key={option.value}
|
||||
className={cn(
|
||||
"data-[disabled]:bg-muted-foreground data-[disabled]:text-muted data-[disabled]:hover:bg-muted-foreground",
|
||||
"data-[fixed]:bg-muted-foreground data-[fixed]:text-muted data-[fixed]:hover:bg-muted-foreground",
|
||||
badgeClassName,
|
||||
)}
|
||||
data-fixed={option.fixed}
|
||||
data-disabled={disabled || undefined}
|
||||
>
|
||||
{option.label ?? option.value}
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
(disabled || option.fixed) && "hidden",
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
handleUnselect(option);
|
||||
}
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onClick={() => handleUnselect(option)}
|
||||
>
|
||||
<LuX className="w-3 h-3 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
{/* Avoid having the "Search" Icon */}
|
||||
<CommandPrimitive.Input
|
||||
{...inputProps}
|
||||
ref={inputRef}
|
||||
value={inputValue}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => {
|
||||
setInputValue(value);
|
||||
inputProps?.onValueChange?.(value);
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setOpen(false);
|
||||
inputProps?.onBlur?.(event);
|
||||
}}
|
||||
onFocus={(event) => {
|
||||
setOpen(true);
|
||||
if (triggerSearchOnFocus && onSearch) {
|
||||
onSearch(debouncedSearchTerm);
|
||||
}
|
||||
inputProps?.onFocus?.(event);
|
||||
}}
|
||||
placeholder={
|
||||
hidePlaceholderWhenSelected && selected.length !== 0
|
||||
? ""
|
||||
: placeholder
|
||||
}
|
||||
className={cn(
|
||||
"flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
|
||||
{
|
||||
"w-full": hidePlaceholderWhenSelected,
|
||||
"px-3 py-2": selected.length === 0,
|
||||
"ml-1": selected.length !== 0,
|
||||
},
|
||||
inputProps?.className,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
{open && (
|
||||
<CommandList className="absolute top-1 z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in">
|
||||
{isLoading ? (
|
||||
loadingIndicator
|
||||
) : (
|
||||
<>
|
||||
{EmptyItem()}
|
||||
{CreatableItem()}
|
||||
{!selectFirstItem && (
|
||||
<CommandItem value="-" className="hidden" />
|
||||
)}
|
||||
{Object.entries(selectables).map(([key, dropdowns]) => (
|
||||
<CommandGroup
|
||||
key={key}
|
||||
heading={key}
|
||||
className="overflow-auto h-full"
|
||||
>
|
||||
{dropdowns.map((option) => {
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disable}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}}
|
||||
onSelect={() => {
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
setInputValue("");
|
||||
const newOptions = [...selected, option];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}}
|
||||
className={cn(
|
||||
"cursor-pointer",
|
||||
option.disable &&
|
||||
"cursor-default text-muted-foreground",
|
||||
)}
|
||||
>
|
||||
{option.label ?? option.value}
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</CommandList>
|
||||
)}
|
||||
</div>
|
||||
</Command>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
MultipleSelector.displayName = "MultipleSelector";
|
||||
export default MultipleSelector;
|
||||
@@ -3,7 +3,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -15,6 +14,7 @@ import {
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface PermissionDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -148,9 +148,9 @@ export function PermissionDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
{isCurrentPermissionGranted ? "Done" : "Cancel"}
|
||||
</Button>
|
||||
</RippleButton>
|
||||
|
||||
{!isCurrentPermissionGranted && (
|
||||
<LoadingButton
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ import { LuCopy } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -27,12 +26,15 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type { BrowserProfile, StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProfileSelectorDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
isUpdating: (browser: string) => boolean;
|
||||
url?: string;
|
||||
runningProfiles?: Set<string>;
|
||||
}
|
||||
@@ -42,12 +44,28 @@ export function ProfileSelectorDialog({
|
||||
onClose,
|
||||
url,
|
||||
runningProfiles = new Set(),
|
||||
isUpdating,
|
||||
}: ProfileSelectorDialogProps) {
|
||||
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
|
||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const [launchingProfiles, setLaunchingProfiles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [stoppingProfiles, _setStoppingProfiles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
// Use shared browser state hook
|
||||
const browserState = useBrowserState(
|
||||
profiles,
|
||||
runningProfiles,
|
||||
isUpdating,
|
||||
launchingProfiles,
|
||||
stoppingProfiles,
|
||||
);
|
||||
|
||||
// Helper function to check if a profile has a proxy
|
||||
const hasProxy = useCallback(
|
||||
@@ -59,50 +77,6 @@ export function ProfileSelectorDialog({
|
||||
[storedProxies],
|
||||
);
|
||||
|
||||
// Helper function to determine if a profile can be used for opening links
|
||||
const canUseProfileForLinks = useCallback(
|
||||
(
|
||||
profile: BrowserProfile,
|
||||
allProfiles: BrowserProfile[],
|
||||
runningProfiles: Set<string>,
|
||||
): boolean => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
|
||||
// For TOR browser: Check if any TOR browser is running
|
||||
if (profile.browser === "tor-browser") {
|
||||
const runningTorProfiles = allProfiles.filter(
|
||||
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name),
|
||||
);
|
||||
|
||||
// If no TOR browser is running, allow any TOR profile
|
||||
if (runningTorProfiles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If TOR browser(s) are running, only allow the running one(s)
|
||||
return isRunning;
|
||||
}
|
||||
|
||||
// For Mullvad browser: Check if any Mullvad browser is running
|
||||
if (profile.browser === "mullvad-browser") {
|
||||
const runningMullvadProfiles = allProfiles.filter(
|
||||
(p) => p.browser === "mullvad-browser" && runningProfiles.has(p.name),
|
||||
);
|
||||
|
||||
// If no Mullvad browser is running, allow any Mullvad profile
|
||||
if (runningMullvadProfiles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If Mullvad browser(s) are running, only allow the running one(s)
|
||||
return isRunning;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -124,58 +98,37 @@ export function ProfileSelectorDialog({
|
||||
// First, try to find a running profile that can be used for opening links
|
||||
const runningAvailableProfile = profileList.find((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
// Simple check without browserState dependency
|
||||
return (
|
||||
isRunning &&
|
||||
canUseProfileForLinks(profile, profileList, runningProfiles)
|
||||
profile.browser !== "tor-browser" &&
|
||||
profile.browser !== "mullvad-browser"
|
||||
);
|
||||
});
|
||||
|
||||
if (runningAvailableProfile) {
|
||||
setSelectedProfile(runningAvailableProfile.name);
|
||||
} else {
|
||||
// If no running profile is suitable, find the first profile that can be used for opening links
|
||||
const availableProfile = profileList.find((profile) => {
|
||||
return canUseProfileForLinks(profile, profileList, runningProfiles);
|
||||
});
|
||||
|
||||
if (availableProfile) {
|
||||
setSelectedProfile(availableProfile.name);
|
||||
} else {
|
||||
// If no suitable profile found, still select the first one to show UI
|
||||
setSelectedProfile(profileList[0].name);
|
||||
}
|
||||
setSelectedProfile(profileList[0].name);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load profiles:", error);
|
||||
} catch (err) {
|
||||
console.error("Failed to load profiles:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [runningProfiles, canUseProfileForLinks]);
|
||||
}, [runningProfiles]);
|
||||
|
||||
// Helper function to get tooltip content for profiles
|
||||
const getProfileTooltipContent = (profile: BrowserProfile): string => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
|
||||
if (
|
||||
profile.browser === "tor-browser" ||
|
||||
profile.browser === "mullvad-browser"
|
||||
) {
|
||||
// If another TOR/Mullvad profile is running, this one is not available
|
||||
return "Only 1 instance can run at a time";
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
return "URL will open in a new tab in the existing browser window";
|
||||
}
|
||||
|
||||
return "";
|
||||
// Helper function to get tooltip content for profiles - now uses shared hook
|
||||
const getProfileTooltipContent = (profile: BrowserProfile): string | null => {
|
||||
return browserState.getProfileTooltipContent(profile);
|
||||
};
|
||||
|
||||
const handleOpenUrl = useCallback(async () => {
|
||||
if (!selectedProfile || !url) return;
|
||||
|
||||
setIsLaunching(true);
|
||||
setLaunchingProfiles((prev) => new Set(prev).add(selectedProfile));
|
||||
try {
|
||||
await invoke("open_url_with_profile", {
|
||||
profileName: selectedProfile,
|
||||
@@ -186,6 +139,11 @@ export function ProfileSelectorDialog({
|
||||
console.error("Failed to open URL with profile:", error);
|
||||
} finally {
|
||||
setIsLaunching(false);
|
||||
setLaunchingProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(selectedProfile);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}, [selectedProfile, url, onClose]);
|
||||
|
||||
@@ -211,16 +169,12 @@ export function ProfileSelectorDialog({
|
||||
// Check if the selected profile can be used for opening links
|
||||
const canOpenWithSelectedProfile = () => {
|
||||
if (!selectedProfileData) return false;
|
||||
return canUseProfileForLinks(
|
||||
selectedProfileData,
|
||||
profiles,
|
||||
runningProfiles,
|
||||
);
|
||||
return browserState.canUseProfileForLinks(selectedProfileData);
|
||||
};
|
||||
|
||||
// Get tooltip content for disabled profiles
|
||||
const getTooltipContent = () => {
|
||||
if (!selectedProfileData) return "";
|
||||
if (!selectedProfileData) return null;
|
||||
return getProfileTooltipContent(selectedProfileData);
|
||||
};
|
||||
|
||||
@@ -242,7 +196,7 @@ export function ProfileSelectorDialog({
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-sm font-medium">Opening URL:</Label>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleCopyUrl()}
|
||||
@@ -250,7 +204,7 @@ export function ProfileSelectorDialog({
|
||||
>
|
||||
<LuCopy className="w-3 h-3" />
|
||||
Copy
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</div>
|
||||
<div className="p-2 text-sm break-all rounded bg-muted">
|
||||
{url}
|
||||
@@ -285,65 +239,65 @@ export function ProfileSelectorDialog({
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const canUseForLinks = canUseProfileForLinks(
|
||||
profile,
|
||||
profiles,
|
||||
runningProfiles,
|
||||
);
|
||||
const canUseForLinks =
|
||||
browserState.canUseProfileForLinks(profile);
|
||||
const tooltipContent = getProfileTooltipContent(profile);
|
||||
|
||||
return (
|
||||
<Tooltip key={profile.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem
|
||||
value={profile.name}
|
||||
disabled={!canUseForLinks}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
!canUseForLinks ? "opacity-50" : ""
|
||||
}`}
|
||||
<div>
|
||||
<SelectItem
|
||||
value={profile.name}
|
||||
disabled={!canUseForLinks}
|
||||
className="cursor-pointer"
|
||||
>
|
||||
<div className="flex gap-3 items-center px-2 py-1 rounded-lg cursor-pointer hover:bg-accent">
|
||||
<div className="flex gap-2 items-center">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon(
|
||||
profile.browser,
|
||||
);
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-4 h-4" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<div className="font-medium">
|
||||
{profile.name}
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
!canUseForLinks ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 items-center px-2 py-1 rounded-lg">
|
||||
<div className="flex gap-2 items-center">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon(
|
||||
profile.browser,
|
||||
);
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-4 h-4" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<div className="font-medium">
|
||||
{profile.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getBrowserDisplayName(profile.browser)}
|
||||
</Badge>
|
||||
{hasProxy(profile) && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Proxy
|
||||
</Badge>
|
||||
)}
|
||||
{isRunning && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Running
|
||||
</Badge>
|
||||
)}
|
||||
{!canUseForLinks && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
>
|
||||
Unavailable
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getBrowserDisplayName(profile.browser)}
|
||||
</Badge>
|
||||
{hasProxy(profile) && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Proxy
|
||||
</Badge>
|
||||
)}
|
||||
{isRunning && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Running
|
||||
</Badge>
|
||||
)}
|
||||
{!canUseForLinks && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
>
|
||||
Unavailable
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectItem>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
{tooltipContent && (
|
||||
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||
@@ -358,12 +312,12 @@ export function ProfileSelectorDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
<RippleButton variant="outline" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>
|
||||
</RippleButton>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<span className="inline-flex">
|
||||
<LoadingButton
|
||||
isLoading={isLaunching}
|
||||
onClick={() => void handleOpenUrl()}
|
||||
@@ -375,7 +329,7 @@ export function ProfileSelectorDialog({
|
||||
>
|
||||
Open
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{getTooltipContent() && (
|
||||
<TooltipContent>{getTooltipContent()}</TooltipContent>
|
||||
|
||||
@@ -4,7 +4,6 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -22,6 +21,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProxyFormData {
|
||||
name: string;
|
||||
@@ -264,13 +264,13 @@ export function ProxyFormDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FiEdit2, FiPlus, FiTrash2, FiWifi } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,7 +20,9 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { trimName } from "@/lib/name-utils";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProxyManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -33,6 +37,7 @@ export function ProxyManagementDialog({
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
|
||||
const [proxyUsage, setProxyUsage] = useState<Record<string, number>>({});
|
||||
|
||||
const loadStoredProxies = useCallback(async () => {
|
||||
try {
|
||||
@@ -47,11 +52,44 @@ export function ProxyManagementDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadProxyUsage = useCallback(async () => {
|
||||
try {
|
||||
const profiles = await invoke<Array<{ proxy_id?: string }>>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const p of profiles) {
|
||||
if (p.proxy_id) counts[p.proxy_id] = (counts[p.proxy_id] ?? 0) + 1;
|
||||
}
|
||||
setProxyUsage(counts);
|
||||
} catch (_err) {
|
||||
// ignore non-critical errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadStoredProxies();
|
||||
void loadProxyUsage();
|
||||
}
|
||||
}, [isOpen, loadStoredProxies]);
|
||||
}, [isOpen, loadStoredProxies, loadProxyUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const setup = async () => {
|
||||
try {
|
||||
unlisten = await listen("profile-updated", () => {
|
||||
void loadProxyUsage();
|
||||
});
|
||||
} catch (_err) {
|
||||
// ignore non-critical errors
|
||||
}
|
||||
};
|
||||
if (isOpen) void setup();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [isOpen, loadProxyUsage]);
|
||||
|
||||
const handleDeleteProxy = useCallback(async (proxy: StoredProxy) => {
|
||||
if (
|
||||
@@ -102,10 +140,6 @@ export function ProxyManagementDialog({
|
||||
setEditingProxy(null);
|
||||
}, []);
|
||||
|
||||
const trimName = useCallback((name: string) => {
|
||||
return name.length > 30 ? `${name.substring(0, 30)}...` : name;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
@@ -127,13 +161,13 @@ export function ProxyManagementDialog({
|
||||
profiles
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
<RippleButton
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<FiPlus className="w-4 h-4" />
|
||||
Create Proxy
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</div>
|
||||
|
||||
{/* Proxy List - Scrollable */}
|
||||
@@ -153,10 +187,10 @@ export function ProxyManagementDialog({
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Create your first proxy configuration to get started
|
||||
</p>
|
||||
<Button variant="outline" onClick={handleCreateProxy}>
|
||||
<RippleButton variant="outline" onClick={handleCreateProxy}>
|
||||
<FiPlus className="mr-2 w-4 h-4" />
|
||||
Create First Proxy
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto pr-2 space-y-2 h-full">
|
||||
@@ -185,6 +219,11 @@ export function ProxyManagementDialog({
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-2">
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 gap-1 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -224,7 +263,7 @@ export function ProxyManagementDialog({
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
<RippleButton onClick={onClose}>Close</RippleButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,280 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FiPlus } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { StoredProxy } from "@/types";
|
||||
|
||||
interface ProxySettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (proxyId: string | null) => void;
|
||||
initialProxyId?: string | null;
|
||||
browserType?: string;
|
||||
}
|
||||
|
||||
export function ProxySettingsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
initialProxyId,
|
||||
browserType,
|
||||
}: ProxySettingsDialogProps) {
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(
|
||||
initialProxyId || null,
|
||||
);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
|
||||
// Helper to determine if proxy should be disabled for the selected browser
|
||||
const isProxyDisabled = browserType === "tor-browser";
|
||||
|
||||
const loadStoredProxies = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(proxies);
|
||||
} catch (error) {
|
||||
console.error("Failed to load stored proxies:", error);
|
||||
toast.error("Failed to load proxies");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadStoredProxies();
|
||||
if (isProxyDisabled) {
|
||||
setSelectedProxyId(null);
|
||||
}
|
||||
}
|
||||
}, [isOpen, isProxyDisabled, loadStoredProxies]);
|
||||
|
||||
const handleCreateProxy = useCallback(() => {
|
||||
setShowProxyForm(true);
|
||||
}, []);
|
||||
|
||||
const handleProxySaved = useCallback((savedProxy: StoredProxy) => {
|
||||
setStoredProxies((prev) => {
|
||||
const existingIndex = prev.findIndex((p) => p.id === savedProxy.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing proxy
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = savedProxy;
|
||||
return updated;
|
||||
} else {
|
||||
// Add new proxy
|
||||
return [...prev, savedProxy];
|
||||
}
|
||||
});
|
||||
setSelectedProxyId(savedProxy.id);
|
||||
setShowProxyForm(false);
|
||||
}, []);
|
||||
|
||||
const handleProxyFormClose = useCallback(() => {
|
||||
setShowProxyForm(false);
|
||||
}, []);
|
||||
|
||||
const handleSave = () => {
|
||||
onSave(selectedProxyId);
|
||||
};
|
||||
|
||||
const hasChanged = () => {
|
||||
return selectedProxyId !== initialProxyId;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Proxy Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-6 py-4">
|
||||
{isProxyDisabled && (
|
||||
<div className="p-4 bg-yellow-50 rounded-md border border-yellow-200 dark:bg-yellow-900/20 dark:border-yellow-800">
|
||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||
Tor Browser has its own built-in proxy system and doesn't
|
||||
support additional proxy configuration.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isProxyDisabled && (
|
||||
<>
|
||||
{/* Proxy Selection */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-base font-medium">
|
||||
Select Proxy
|
||||
</Label>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<FiPlus className="w-4 h-4" />
|
||||
Create New
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Create a new proxy configuration</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto p-2 space-y-2 h-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedProxyId(null)}
|
||||
asChild
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full bg-card cursor-pointer transition-colors",
|
||||
selectedProxyId === null
|
||||
? "ring-2 ring-blue-500"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4 w-full">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
id="no-proxy"
|
||||
name="proxy-selection"
|
||||
checked={selectedProxyId === null}
|
||||
onChange={() => setSelectedProxyId(null)}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Label
|
||||
htmlFor="no-proxy"
|
||||
className="font-medium cursor-pointer"
|
||||
>
|
||||
No Proxy
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Button>
|
||||
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
</p>
|
||||
) : (
|
||||
storedProxies.map((proxy) => (
|
||||
<Button
|
||||
key={proxy.id}
|
||||
variant="ghost"
|
||||
onClick={() => setSelectedProxyId(proxy.id)}
|
||||
asChild
|
||||
>
|
||||
<Card
|
||||
className={cn(
|
||||
"w-full bg-card cursor-pointer transition-colors",
|
||||
selectedProxyId === proxy.id
|
||||
? "ring-2 ring-blue-500"
|
||||
: "",
|
||||
)}
|
||||
>
|
||||
<CardContent className="p-4 w-full">
|
||||
<div className="flex items-center space-x-3">
|
||||
<input
|
||||
type="radio"
|
||||
id={`proxy-${proxy.id}`}
|
||||
name="proxy-selection"
|
||||
checked={selectedProxyId === proxy.id}
|
||||
onChange={() => setSelectedProxyId(proxy.id)}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Label
|
||||
htmlFor={`proxy-${proxy.id}`}
|
||||
className="font-medium cursor-pointer"
|
||||
>
|
||||
{proxy.name}
|
||||
</Label>
|
||||
<Badge variant="outline">
|
||||
{proxy.proxy_settings.proxy_type.toUpperCase()}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
|
||||
{!loading && storedProxies.length === 0 && (
|
||||
<div className="py-4 text-center">
|
||||
<p className="mb-2 text-sm text-muted-foreground">
|
||||
No saved proxies available.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCreateProxy}
|
||||
>
|
||||
<FiPlus className="mr-2 w-4 h-4" />
|
||||
Create First Proxy
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanged()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ProxyFormDialog
|
||||
isOpen={showProxyForm}
|
||||
onClose={handleProxyFormClose}
|
||||
onSave={handleProxySaved}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,6 @@ import { useState } from "react";
|
||||
import { LuCheck, LuChevronsUpDown, LuDownload } from "react-icons/lu";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
@@ -19,12 +18,12 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserReleaseTypes } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ReleaseTypeSelectorProps {
|
||||
selectedReleaseType: "stable" | "nightly" | null;
|
||||
onReleaseTypeSelect: (releaseType: "stable" | "nightly" | null) => void;
|
||||
availableReleaseTypes: BrowserReleaseTypes;
|
||||
browser: string;
|
||||
isDownloading: boolean;
|
||||
onDownload: () => void;
|
||||
placeholder?: string;
|
||||
@@ -36,7 +35,6 @@ export function ReleaseTypeSelector({
|
||||
selectedReleaseType,
|
||||
onReleaseTypeSelect,
|
||||
availableReleaseTypes,
|
||||
browser,
|
||||
isDownloading,
|
||||
onDownload,
|
||||
placeholder = "Select release type...",
|
||||
@@ -49,7 +47,7 @@ export function ReleaseTypeSelector({
|
||||
...(availableReleaseTypes.stable
|
||||
? [{ type: "stable" as const, version: availableReleaseTypes.stable }]
|
||||
: []),
|
||||
...(availableReleaseTypes.nightly && browser !== "chromium"
|
||||
...(availableReleaseTypes.nightly
|
||||
? [{ type: "nightly" as const, version: availableReleaseTypes.nightly }]
|
||||
: []),
|
||||
];
|
||||
@@ -85,7 +83,7 @@ export function ReleaseTypeSelector({
|
||||
{showDropdown ? (
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen} modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={popoverOpen}
|
||||
@@ -93,7 +91,7 @@ export function ReleaseTypeSelector({
|
||||
>
|
||||
{selectedDisplayText}
|
||||
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</RippleButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
@@ -159,11 +157,6 @@ export function ReleaseTypeSelector({
|
||||
<span className="text-sm font-medium capitalize">
|
||||
{releaseOptions[0].type}
|
||||
</span>
|
||||
{releaseOptions[0].type === "nightly" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Nightly
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{releaseOptions[0].version}
|
||||
</Badge>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,11 +25,17 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import {
|
||||
dismissToast,
|
||||
showErrorToast,
|
||||
showSuccessToast,
|
||||
showUnifiedVersionUpdateToast,
|
||||
} from "@/lib/toast-utils";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
show_settings_on_startup: boolean;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
@@ -39,6 +45,15 @@ interface PermissionInfo {
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface VersionUpdateProgress {
|
||||
current_browser: string;
|
||||
total_browsers: number;
|
||||
completed_browsers: number;
|
||||
new_versions_found: number;
|
||||
browser_new_versions: number;
|
||||
status: string; // "updating", "completed", "error"
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
@@ -47,12 +62,10 @@ interface SettingsDialogProps {
|
||||
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
show_settings_on_startup: true,
|
||||
theme: "system",
|
||||
});
|
||||
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
show_settings_on_startup: true,
|
||||
theme: "system",
|
||||
});
|
||||
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
|
||||
@@ -183,11 +196,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
setIsClearingCache(true);
|
||||
try {
|
||||
await invoke("clear_all_version_cache_and_refetch");
|
||||
showSuccessToast("Cache cleared successfully", {
|
||||
description:
|
||||
"All browser version cache has been cleared and browsers are being refreshed.",
|
||||
duration: 4000,
|
||||
});
|
||||
// Don't show immediate success toast - let the version update progress events handle it
|
||||
} catch (error) {
|
||||
console.error("Failed to clear cache:", error);
|
||||
showErrorToast("Failed to clear cache", {
|
||||
@@ -256,9 +265,83 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
checkDefaultBrowserStatus().catch(console.error);
|
||||
}, 500); // Check every 500ms
|
||||
|
||||
// Cleanup interval on component unmount or dialog close
|
||||
// Listen for version update progress events
|
||||
let unlistenFn: (() => void) | null = null;
|
||||
const setupVersionUpdateListener = async () => {
|
||||
try {
|
||||
unlistenFn = await listen<VersionUpdateProgress>(
|
||||
"version-update-progress",
|
||||
(event) => {
|
||||
const progress = event.payload;
|
||||
|
||||
if (progress.status === "updating") {
|
||||
// Show unified progress toast
|
||||
const currentBrowserName = progress.current_browser
|
||||
? getBrowserDisplayName(progress.current_browser)
|
||||
: undefined;
|
||||
|
||||
showUnifiedVersionUpdateToast(
|
||||
"Checking for browser updates...",
|
||||
{
|
||||
description: currentBrowserName
|
||||
? `Fetching ${currentBrowserName} release information...`
|
||||
: "Initializing version check...",
|
||||
progress: {
|
||||
current: progress.completed_browsers,
|
||||
total: progress.total_browsers,
|
||||
found: progress.new_versions_found,
|
||||
current_browser: currentBrowserName,
|
||||
},
|
||||
},
|
||||
);
|
||||
} else if (progress.status === "completed") {
|
||||
dismissToast("unified-version-update");
|
||||
|
||||
if (progress.new_versions_found > 0) {
|
||||
showSuccessToast("Browser versions updated successfully", {
|
||||
duration: 5000,
|
||||
description:
|
||||
"Auto-downloads will start shortly for available updates.",
|
||||
});
|
||||
} else {
|
||||
showSuccessToast("No new browser versions found", {
|
||||
duration: 3000,
|
||||
description: "All browser versions are up to date",
|
||||
});
|
||||
}
|
||||
} else if (progress.status === "error") {
|
||||
dismissToast("unified-version-update");
|
||||
|
||||
showErrorToast("Failed to update browser versions", {
|
||||
duration: 6000,
|
||||
description: "Check your internet connection and try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to setup version update progress listener:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
setupVersionUpdateListener();
|
||||
|
||||
// Cleanup interval and listener on component unmount or dialog close
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
if (unlistenFn) {
|
||||
try {
|
||||
unlistenFn();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to cleanup version update progress listener:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
|
||||
@@ -290,10 +373,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
]);
|
||||
|
||||
// Check if settings have changed (excluding default browser setting)
|
||||
const hasChanges =
|
||||
settings.show_settings_on_startup !==
|
||||
originalSettings.show_settings_on_startup ||
|
||||
settings.theme !== originalSettings.theme;
|
||||
const hasChanges = settings.theme !== originalSettings.theme;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
@@ -362,29 +442,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Startup Behavior Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Startup Behavior</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="show-settings"
|
||||
checked={settings.show_settings_on_startup}
|
||||
onCheckedChange={(checked) => {
|
||||
updateSetting("show_settings_on_startup", checked as boolean);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="show-settings" className="text-sm">
|
||||
Show settings on app startup
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, the settings dialog will be shown when the app
|
||||
starts.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Permissions Section - Only show on macOS */}
|
||||
{isMacOS && (
|
||||
<div className="space-y-4">
|
||||
@@ -472,9 +529,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
isLoading={isSaving}
|
||||
onClick={() => {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,15 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface AppSettings {
|
||||
show_settings_on_startup: boolean;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
interface SystemTheme {
|
||||
set_as_default_browser: boolean;
|
||||
theme: string;
|
||||
}
|
||||
|
||||
@@ -17,40 +12,10 @@ interface CustomThemeProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
// Helper function to detect system dark mode preference
|
||||
function getSystemTheme(): string {
|
||||
if (typeof window !== "undefined") {
|
||||
const isDarkMode = window.matchMedia(
|
||||
"(prefers-color-scheme: dark)",
|
||||
).matches;
|
||||
return isDarkMode ? "dark" : "light";
|
||||
}
|
||||
return "light";
|
||||
}
|
||||
|
||||
// Function to get native system theme (fallback to CSS media query)
|
||||
async function getNativeSystemTheme(): Promise<string> {
|
||||
try {
|
||||
const systemTheme = await invoke<SystemTheme>("get_system_theme");
|
||||
if (systemTheme.theme === "dark" || systemTheme.theme === "light") {
|
||||
return systemTheme.theme;
|
||||
}
|
||||
// Fallback to CSS media query if native detection returns "unknown"
|
||||
return getSystemTheme();
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
"Failed to get native system theme, falling back to CSS media query:",
|
||||
error,
|
||||
);
|
||||
// Fallback to CSS media query
|
||||
return getSystemTheme();
|
||||
}
|
||||
}
|
||||
|
||||
export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [defaultTheme, setDefaultTheme] = useState<string>("system");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [_mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
@@ -59,30 +24,24 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
useEffect(() => {
|
||||
const loadTheme = async () => {
|
||||
try {
|
||||
// Lazy import to avoid pulling Tauri API on SSR
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const settings = await invoke<AppSettings>("get_app_settings");
|
||||
setDefaultTheme(settings.theme);
|
||||
} catch (error) {
|
||||
console.error("Failed to load theme settings:", error);
|
||||
// For first-time users, detect system preference and apply it
|
||||
const systemTheme = await getNativeSystemTheme();
|
||||
console.log(
|
||||
"First-time user detected, applying system theme:",
|
||||
systemTheme,
|
||||
);
|
||||
|
||||
// Save the detected theme as the default
|
||||
try {
|
||||
await invoke("save_app_settings", {
|
||||
settings: {
|
||||
show_settings_on_startup: true,
|
||||
theme: "system",
|
||||
auto_updates_enabled: true,
|
||||
},
|
||||
});
|
||||
} catch (saveError) {
|
||||
console.error("Failed to save initial theme settings:", saveError);
|
||||
if (
|
||||
settings?.theme === "light" ||
|
||||
settings?.theme === "dark" ||
|
||||
settings?.theme === "system"
|
||||
) {
|
||||
setDefaultTheme(settings.theme);
|
||||
} else {
|
||||
setDefaultTheme("system");
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
// Failed to load settings; fall back to system (handled by next-themes)
|
||||
console.warn(
|
||||
"Failed to load theme settings; defaulting to system:",
|
||||
error,
|
||||
);
|
||||
setDefaultTheme("system");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
@@ -92,73 +51,9 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
void loadTheme();
|
||||
}, []);
|
||||
|
||||
// Monitor system theme changes when using "system" theme
|
||||
useEffect(() => {
|
||||
if (!mounted || defaultTheme !== "system") {
|
||||
return;
|
||||
}
|
||||
|
||||
const checkSystemTheme = async () => {
|
||||
try {
|
||||
const currentSystemTheme = await getNativeSystemTheme();
|
||||
// Force re-evaluation by toggling the theme
|
||||
const html = document.documentElement;
|
||||
|
||||
// Apply the system theme class
|
||||
if (currentSystemTheme === "dark") {
|
||||
if (!html.classList.contains("dark")) {
|
||||
html.classList.add("dark");
|
||||
html.classList.remove("light");
|
||||
}
|
||||
} else {
|
||||
if (
|
||||
!html.classList.contains("light") ||
|
||||
html.classList.contains("dark")
|
||||
) {
|
||||
html.classList.add("light");
|
||||
html.classList.remove("dark");
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to check system theme:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Check system theme every 2 seconds when using system theme
|
||||
const intervalId = setInterval(() => void checkSystemTheme(), 2000);
|
||||
|
||||
// Initial check
|
||||
void checkSystemTheme();
|
||||
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [mounted, defaultTheme]);
|
||||
|
||||
if (isLoading) {
|
||||
// Use a consistent loading screen that doesn't depend on system theme during SSR
|
||||
// This prevents hydration mismatch by ensuring server and client render the same initially
|
||||
let loadingBgColor = "bg-white";
|
||||
let spinnerColor = "border-gray-900";
|
||||
|
||||
// Only apply system theme detection after component is mounted (client-side only)
|
||||
if (mounted) {
|
||||
// Use CSS media query for loading screen since async call would complicate this
|
||||
const systemTheme = getSystemTheme();
|
||||
loadingBgColor = systemTheme === "dark" ? "bg-gray-900" : "bg-white";
|
||||
spinnerColor =
|
||||
systemTheme === "dark" ? "border-white" : "border-gray-900";
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex fixed inset-0 justify-center items-center ${loadingBgColor}`}
|
||||
>
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full border-2 animate-spin ${spinnerColor} border-t-transparent`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
// Keep UI simple during initial settings load to avoid flicker
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user