mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-03 09:05:14 +02:00
Compare commits
215 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3bec00a2cd | |||
| 3b78971df8 | |||
| 5f9a716f62 | |||
| 4d07984d99 | |||
| 188e14e5b5 | |||
| bc1b9e9757 | |||
| e742e5fdfa | |||
| 9ce7757cb2 | |||
| 3ca454a2c5 | |||
| 689ac8e3ca | |||
| 0e1c5dcfb6 | |||
| f22a9f3557 | |||
| 5a76fe3221 | |||
| 5edad9b97c | |||
| 38556fc504 | |||
| 703ca2c50b | |||
| 198046fca9 | |||
| fdcce5c86a | |||
| 1cd1c7b59d | |||
| d803361fca | |||
| 2f6f20eb29 | |||
| 59272e0cff | |||
| cac2273ad3 | |||
| 1691a7a06b | |||
| 5a4718fba6 | |||
| 336543d06e | |||
| 73cc6c2ac5 | |||
| f4c96ec0c6 | |||
| f84b3c2812 | |||
| 29603076f7 | |||
| 76bcb73b39 | |||
| 51983bf3a5 | |||
| eda83cf439 | |||
| 7b6ea00838 | |||
| d8f07ddb11 | |||
| 1b0ebbc666 | |||
| d377809c77 | |||
| fbf36b49df | |||
| 341751c9b2 | |||
| eea227d853 | |||
| 29b6aed475 | |||
| 050f8b5353 | |||
| 8793de8c87 | |||
| 7408ec876c | |||
| fc8c358088 | |||
| b11495e3b9 | |||
| 11567ca50e | |||
| 1c2d5b3774 | |||
| 852066ef41 | |||
| 9622d85e73 | |||
| 4e2b87c5f1 | |||
| 2099dadbc0 | |||
| 00e4eb2715 | |||
| 33bc4476a4 | |||
| 0ad8988f7e | |||
| 2b3aaf1e92 | |||
| 5a10e0b696 | |||
| 9e48ddbf3e | |||
| bcbb2c1d42 | |||
| 391bfdabdc | |||
| 7b2dc84b5b | |||
| ddc09726f4 | |||
| e1451d3fbb | |||
| b18df6499f | |||
| c5c2563a4e | |||
| 8475f42821 | |||
| f51aa9ed85 | |||
| 3d3a3b3816 | |||
| e090881917 | |||
| b46976f47d | |||
| 39a978682c | |||
| 38e58e604b | |||
| ffcff2ce7c | |||
| c8ea31f85d | |||
| 7ac6e21dbc | |||
| 7533993909 | |||
| 8176f45e41 | |||
| f55a3f7155 | |||
| 7d74ac09d9 | |||
| d314fa1f71 | |||
| 968969cf1e | |||
| a7a3d99881 | |||
| 80cd2e4e7f | |||
| 6361a039bc | |||
| 8005ec90b6 | |||
| cdf30b7baa | |||
| fadef414fe | |||
| e1c55233f7 | |||
| 801a2b5732 | |||
| abe5c691ce | |||
| 2f9a17c6e0 | |||
| fcdb80f75a | |||
| 7568e7998d | |||
| e0f4f93c30 | |||
| d142b7f79b | |||
| dc5553a5d3 | |||
| 07445ff95b | |||
| 6ecbc39e46 | |||
| 67849c00d5 | |||
| bdf71e4ef8 | |||
| 2d2ebba40e | |||
| 2caac5bf4c | |||
| a816fbb140 | |||
| c954668ed1 | |||
| 2db27b5ffd | |||
| 845e9f28ad | |||
| ee8c6dcc85 | |||
| 08453fe9a6 | |||
| b486f00875 | |||
| 703154b30f | |||
| 130f8b86d1 | |||
| 607ed66e29 | |||
| 9570b6d605 | |||
| 2d92cbb0e5 | |||
| 251016609f | |||
| bddf796946 | |||
| 8d793a6868 | |||
| 469f161293 | |||
| 9756e64319 | |||
| 800544ede9 | |||
| aa2228a8aa | |||
| 432e5bff90 | |||
| f4b60eb6c7 | |||
| 30122c5781 | |||
| b71d84fda4 | |||
| 859af72724 | |||
| 0360a89ceb | |||
| cb6f744d6b | |||
| 575d7f80b1 | |||
| d05b69ff3d | |||
| 54abb11129 | |||
| 04c690c750 | |||
| 9a4be86e95 | |||
| 6d013d86aa | |||
| 769fbf9d75 | |||
| 6e62abc601 | |||
| 8848fa8130 | |||
| 1f0ecbe36e | |||
| f83f2033fe | |||
| 821cd4ea82 | |||
| d3a63c37bf | |||
| 95cd2426c3 | |||
| 5a3fb7b2b0 | |||
| 767a0701ce | |||
| ec61d51c07 | |||
| 545c518a55 | |||
| c99eee2c21 | |||
| 7f3683cc2e | |||
| baac3a533a | |||
| 5cd1774ffc | |||
| cb87641890 | |||
| 3df5ac671b | |||
| 390f79f97b | |||
| c4dc2ed50c | |||
| 3b7315cc0d | |||
| bbd0f5df0c | |||
| 8e7982bdf8 | |||
| 9ac662aee8 | |||
| 2394716ea3 | |||
| 06992f8b9a | |||
| 5a454e3647 | |||
| 3f91d92d8b | |||
| c586046542 | |||
| 42f63172fb | |||
| f717600fcb | |||
| c807ea5596 | |||
| df2c1316d4 | |||
| 1fdc552dc7 | |||
| ab563f81fa | |||
| d17545bd05 | |||
| 29c329b432 | |||
| 4d7bbe719f | |||
| 5b869a6115 | |||
| c4b1745a0f | |||
| ac293f6204 | |||
| 7f3a3287d6 | |||
| dd9347d429 | |||
| 615d7b8c8a | |||
| 5c26ab5c33 | |||
| dccaf6c7de | |||
| bf5b2886f5 | |||
| bbc12bcc03 | |||
| 6d437f30e1 | |||
| 4b0ab6b732 | |||
| a802895491 | |||
| a57f90899b | |||
| 3ebc714b23 | |||
| 1acd4781b5 | |||
| a5b9afafcb | |||
| 0c8dd5ace5 | |||
| e8c3188657 | |||
| e6f0b2b9e9 | |||
| 394406e134 | |||
| b0ca14c184 | |||
| eea94ad360 | |||
| 2b678ed04d | |||
| dff201ddec | |||
| 743ad59348 | |||
| d43e9ef21b | |||
| 7515cbacd6 | |||
| f41172e822 | |||
| 25ce691bbc | |||
| b945ee7088 | |||
| eb3589b4c0 | |||
| b71b9a00ca | |||
| 6535b37c98 | |||
| bc72a837e2 | |||
| fb84068d30 | |||
| 5024eab062 | |||
| 8137f9bf8d | |||
| e2547c6ec7 | |||
| d8d59d2bd5 | |||
| b84350eb13 | |||
| 383cef916c | |||
| 743bc059be |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
Don't leave comments that don't add value
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
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
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
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.
|
||||
+1
-20
@@ -1,4 +1,5 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
# Frontend dependencies (root package.json)
|
||||
- package-ecosystem: "npm"
|
||||
@@ -13,30 +14,10 @@ updates:
|
||||
frontend-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: "eslint"
|
||||
versions: ">= 9"
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
include: "scope"
|
||||
|
||||
# Nodecar dependencies
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/nodecar"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "saturday"
|
||||
time: "09:00"
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
groups:
|
||||
nodecar-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "deps(nodecar)"
|
||||
include: "scope"
|
||||
|
||||
# Rust dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/src-tauri"
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
name: "CodeQL"
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
- cron: "16 13 * * 5"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.language }})
|
||||
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
|
||||
permissions:
|
||||
security-events: write
|
||||
packages: read
|
||||
actions: read
|
||||
contents: read
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: actions
|
||||
build-mode: none
|
||||
- language: javascript-typescript
|
||||
build-mode: none
|
||||
# - language: rust
|
||||
# build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Install system dependencies (Rust only)
|
||||
if: matrix.language == 'rust'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
- name: Install dependencies from lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install rust dependencies
|
||||
if: matrix.language == 'rust'
|
||||
working-directory: ./src-tauri
|
||||
run: |
|
||||
cargo build
|
||||
|
||||
- name: Build nodecar sidecar
|
||||
if: matrix.language == 'rust'
|
||||
shell: bash
|
||||
working-directory: ./nodecar
|
||||
run: |
|
||||
pnpm run build:linux-x64
|
||||
|
||||
- name: Copy nodecar binary to Tauri binaries
|
||||
if: matrix.language == 'rust'
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b1e4dc3db58c9601794e22a9f6d28d45461b9dbf #v3.29.0
|
||||
with:
|
||||
queries: security-extended
|
||||
languages: ${{ matrix.language }}
|
||||
build-mode: ${{ matrix.build-mode }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@b1e4dc3db58c9601794e22a9f6d28d45461b9dbf #v3.29.0
|
||||
with:
|
||||
category: "/language:${{matrix.language}}"
|
||||
@@ -0,0 +1,21 @@
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
contrib-readme-job:
|
||||
runs-on: ubuntu-latest
|
||||
name: Automatically update the contributors list in the README
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Contribute List
|
||||
uses: akhilmhdh/contributors-readme-action@1ff4c56187458b34cd602aee93e897344ce34bfc #v2.3.10
|
||||
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@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -32,29 +32,53 @@ jobs:
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
lint-rust:
|
||||
name: Lint Rust
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
codeql:
|
||||
name: CodeQL
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
packages: read
|
||||
actions: read
|
||||
|
||||
spellcheck:
|
||||
name: Spell Check
|
||||
uses: ./.github/workflows/spellcheck.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
dependabot-automerge:
|
||||
name: Dependabot Automerge
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
needs: [security-scan, lint-js, lint-rust]
|
||||
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v2
|
||||
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@v2
|
||||
uses: ridedott/merge-me-action@338053c6f9b9311a6be80208f6f0723981e40627 #v2.10.122
|
||||
secrets: inherit
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.SECRET_DEPENDABOT_GITHUB_TOKEN }}
|
||||
PRESET: DEPENDABOT_MINOR
|
||||
MERGE_METHOD: SQUASH
|
||||
PRESET: DEPENDABOT_MINOR
|
||||
MAXIMUM_RETRIES: 5
|
||||
timeout-minutes: 10
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
name: Greetings
|
||||
|
||||
on: [pull_request_target, issues]
|
||||
|
||||
jobs:
|
||||
greeting:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/first-interaction@34f15e814fe48ac9312ccf29db4e74fa767cbab7 #v1.3.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."
|
||||
@@ -0,0 +1,173 @@
|
||||
name: Issue Validation
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
models: read
|
||||
|
||||
jobs:
|
||||
validate-issue:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Get issue templates
|
||||
id: get-templates
|
||||
run: |
|
||||
# Read the issue templates
|
||||
if [ -f ".github/ISSUE_TEMPLATE/01-bug-report.md" ]; then
|
||||
echo "bug-template-exists=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
if [ -f ".github/ISSUE_TEMPLATE/02-feature-request.md" ]; then
|
||||
echo "feature-template-exists=true" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Create issue analysis prompt
|
||||
id: create-prompt
|
||||
env:
|
||||
ISSUE_TITLE: ${{ github.event.issue.title }}
|
||||
ISSUE_BODY: ${{ github.event.issue.body }}
|
||||
ISSUE_LABELS: ${{ join(github.event.issue.labels.*.name, ', ') }}
|
||||
run: |
|
||||
cat > issue_analysis.txt << EOF
|
||||
## Issue Content to Analyze:
|
||||
|
||||
**Title:** $ISSUE_TITLE
|
||||
|
||||
**Body:**
|
||||
$ISSUE_BODY
|
||||
|
||||
**Labels:** $ISSUE_LABELS
|
||||
EOF
|
||||
|
||||
- name: Validate issue with AI
|
||||
id: validate
|
||||
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
|
||||
with:
|
||||
prompt-file: issue_analysis.txt
|
||||
system-prompt: |
|
||||
You are an issue validation assistant for Donut Browser, an browser orchestrator.
|
||||
|
||||
Analyze the provided issue content and determine if it contains sufficient information based on these requirements:
|
||||
|
||||
**For Bug Reports, the issue should include:**
|
||||
1. Clear description of the problem
|
||||
2. Steps to reproduce the issue (numbered list preferred)
|
||||
3. Expected vs actual behavior
|
||||
4. Environment information (OS, browser version, etc.)
|
||||
5. Error messages, stack traces, or screenshots if applicable
|
||||
|
||||
**For Feature Requests, the issue should include:**
|
||||
1. Clear description of the requested feature
|
||||
2. Use case or problem it solves
|
||||
3. Proposed solution or how it should work
|
||||
4. Priority level or importance
|
||||
|
||||
**General Requirements for all issues:**
|
||||
1. Descriptive title
|
||||
2. Sufficient detail to understand and act upon
|
||||
3. Professional tone and clear communication
|
||||
|
||||
Respond in JSON format with the following structure:
|
||||
```json
|
||||
{
|
||||
"is_valid": true|false,
|
||||
"issue_type": "bug_report"|"feature_request"|"other",
|
||||
"missing_info": [
|
||||
"List of missing required information"
|
||||
],
|
||||
"suggestions": [
|
||||
"Specific suggestions for improvement"
|
||||
],
|
||||
"overall_assessment": "Brief assessment of the issue quality"
|
||||
}
|
||||
```
|
||||
|
||||
Be constructive and helpful in your feedback. If the issue is incomplete, provide specific guidance on what's needed.
|
||||
model: gpt-4o
|
||||
|
||||
- name: Parse validation result and take action
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Get the AI response
|
||||
VALIDATION_RESULT='${{ steps.validate.outputs.response }}'
|
||||
|
||||
# Extract JSON from the response (handle potential markdown formatting)
|
||||
JSON_RESULT=$(echo "$VALIDATION_RESULT" | sed -n '/```json/,/```/p' | sed '1d;$d' || echo "$VALIDATION_RESULT")
|
||||
|
||||
# Parse JSON fields
|
||||
IS_VALID=$(echo "$JSON_RESULT" | jq -r '.is_valid // false')
|
||||
ISSUE_TYPE=$(echo "$JSON_RESULT" | jq -r '.issue_type // "other"')
|
||||
MISSING_INFO=$(echo "$JSON_RESULT" | jq -r '.missing_info[]? // empty' | sed 's/^/- /')
|
||||
SUGGESTIONS=$(echo "$JSON_RESULT" | jq -r '.suggestions[]? // empty' | sed 's/^/- /')
|
||||
ASSESSMENT=$(echo "$JSON_RESULT" | jq -r '.overall_assessment // "No assessment provided"')
|
||||
|
||||
echo "Issue validation result: $IS_VALID"
|
||||
echo "Issue type: $ISSUE_TYPE"
|
||||
|
||||
if [ "$IS_VALID" = "false" ]; then
|
||||
# Create a comment asking for more information
|
||||
cat > comment.md << EOF
|
||||
## 🤖 Issue Validation
|
||||
|
||||
Thank you for submitting this issue! However, it appears that some required information might be missing to help us better understand and address your concern.
|
||||
|
||||
**Issue Type Detected:** \`$ISSUE_TYPE\`
|
||||
|
||||
**Assessment:** $ASSESSMENT
|
||||
|
||||
### 📋 Missing Information:
|
||||
$MISSING_INFO
|
||||
|
||||
### 💡 Suggestions for Improvement:
|
||||
$SUGGESTIONS
|
||||
|
||||
### 📝 How to Provide Additional Information:
|
||||
|
||||
Please edit your original issue description to include the missing information. Here are our issue templates for reference:
|
||||
|
||||
- **Bug Report Template:** [View Template](.github/ISSUE_TEMPLATE/01-bug-report.md)
|
||||
- **Feature Request Template:** [View Template](.github/ISSUE_TEMPLATE/02-feature-request.md)
|
||||
|
||||
### 🔧 Quick Tips:
|
||||
- For **bug reports**: Include step-by-step reproduction instructions, your environment details, and any error messages
|
||||
- For **feature requests**: Describe the use case, expected behavior, and why this feature would be valuable
|
||||
- Add **screenshots** or **logs** when applicable
|
||||
|
||||
Once you've updated the issue with the missing information, feel free to remove this comment or reply to let us know you've made the updates.
|
||||
|
||||
---
|
||||
*This validation was performed automatically to ensure we have all the information needed to help you effectively.*
|
||||
EOF
|
||||
|
||||
# Post the comment
|
||||
gh issue comment ${{ github.event.issue.number }} --body-file comment.md
|
||||
|
||||
# Add a label to indicate validation needed
|
||||
gh issue edit ${{ github.event.issue.number }} --add-label "needs-info"
|
||||
|
||||
echo "✅ Validation comment posted and 'needs-info' label added"
|
||||
else
|
||||
echo "✅ Issue contains sufficient information"
|
||||
|
||||
# Add appropriate labels based on issue type
|
||||
case "$ISSUE_TYPE" in
|
||||
"bug_report")
|
||||
gh issue edit ${{ github.event.issue.number }} --add-label "bug"
|
||||
;;
|
||||
"feature_request")
|
||||
gh issue edit ${{ github.event.issue.number }} --add-label "enhancement"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
rm -f issue_analysis.txt comment.md
|
||||
@@ -16,6 +16,9 @@ on:
|
||||
- ".github/workflows/lint-rs.yml"
|
||||
- ".github/workflows/osv.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
@@ -31,13 +34,13 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
|
||||
- name: Set up Node.js v22
|
||||
uses: actions/setup-node@v4
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
@@ -45,10 +48,5 @@ jobs:
|
||||
- name: Install dependencies from lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install nodecar dependencies
|
||||
working-directory: ./nodecar
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run lint step
|
||||
run: pnpm run lint:js
|
||||
|
||||
@@ -24,6 +24,9 @@ on:
|
||||
- "tsconfig.json"
|
||||
- "biome.json"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
strategy:
|
||||
@@ -39,20 +42,21 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt, clippy
|
||||
|
||||
- name: Install cargo-audit
|
||||
@@ -67,11 +71,6 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install nodecar dependencies
|
||||
working-directory: ./nodecar
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build nodecar binary
|
||||
shell: bash
|
||||
working-directory: ./nodecar
|
||||
|
||||
@@ -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@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
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@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -16,16 +16,20 @@ jobs:
|
||||
name: Lint JavaScript/TypeScript
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
lint-rust:
|
||||
name: Lint Rust
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
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@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Generate Release Notes
|
||||
|
||||
on:
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
models: read
|
||||
|
||||
jobs:
|
||||
generate-release-notes:
|
||||
runs-on: ubuntu-latest
|
||||
if: startsWith(github.ref, 'refs/tags/v') && !github.event.release.prerelease
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history to compare with previous release
|
||||
|
||||
- name: Get previous release tag
|
||||
id: get-previous-tag
|
||||
run: |
|
||||
# Get the previous release tag (excluding the current one)
|
||||
CURRENT_TAG="${{ github.ref_name }}"
|
||||
PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "$CURRENT_TAG" | head -n 1)
|
||||
|
||||
if [ -z "$PREVIOUS_TAG" ]; then
|
||||
echo "No previous release found, using initial commit"
|
||||
PREVIOUS_TAG=$(git rev-list --max-parents=0 HEAD)
|
||||
fi
|
||||
|
||||
echo "current-tag=$CURRENT_TAG" >> $GITHUB_OUTPUT
|
||||
echo "previous-tag=$PREVIOUS_TAG" >> $GITHUB_OUTPUT
|
||||
echo "Previous release: $PREVIOUS_TAG"
|
||||
echo "Current release: $CURRENT_TAG"
|
||||
|
||||
- name: Get commit messages between releases
|
||||
id: get-commits
|
||||
run: |
|
||||
# Get commit messages between previous and current release
|
||||
PREVIOUS_TAG="${{ steps.get-previous-tag.outputs.previous-tag }}"
|
||||
CURRENT_TAG="${{ steps.get-previous-tag.outputs.current-tag }}"
|
||||
|
||||
# Get commit log with detailed format
|
||||
COMMIT_LOG=$(git log --pretty=format:"- %s (%h by %an)" $PREVIOUS_TAG..$CURRENT_TAG --no-merges)
|
||||
|
||||
# Get changed files summary
|
||||
CHANGED_FILES=$(git diff --name-status $PREVIOUS_TAG..$CURRENT_TAG | head -20)
|
||||
|
||||
# Save to files for AI processing
|
||||
echo "$COMMIT_LOG" > commits.txt
|
||||
echo "$CHANGED_FILES" > changes.txt
|
||||
|
||||
echo "commits-file=commits.txt" >> $GITHUB_OUTPUT
|
||||
echo "changes-file=changes.txt" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Generate release notes with AI
|
||||
id: generate-notes
|
||||
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
|
||||
with:
|
||||
prompt-file: commits.txt
|
||||
system-prompt: |
|
||||
You are an expert technical writer tasked with generating comprehensive release notes for Donut Browser, a powerful browser orchestrator.
|
||||
|
||||
Analyze the provided commit messages and generate well-structured release notes following this format:
|
||||
|
||||
## What's New in ${{ steps.get-previous-tag.outputs.current-tag }}
|
||||
|
||||
[Brief 1-2 sentence overview of the release]
|
||||
|
||||
### ✨ New Features
|
||||
[List new features with brief descriptions]
|
||||
|
||||
### 🐛 Bug Fixes
|
||||
[List bug fixes]
|
||||
|
||||
### 🔧 Improvements
|
||||
[List improvements and enhancements]
|
||||
|
||||
### 📚 Documentation
|
||||
[List documentation updates if any]
|
||||
|
||||
### 🔄 Dependencies
|
||||
[List dependency updates if any]
|
||||
|
||||
### 🛠️ Developer Experience
|
||||
[List development-related changes if any]
|
||||
|
||||
Guidelines:
|
||||
- Use clear, user-friendly language
|
||||
- Group related commits logically
|
||||
- Omit minor commits like formatting, typos unless significant
|
||||
- Focus on user-facing changes
|
||||
- Use emojis sparingly and consistently
|
||||
- Keep descriptions concise but informative
|
||||
- If commits are unclear, infer the purpose from the context
|
||||
|
||||
The application is a desktop app built with Tauri + Next.js that helps users manage multiple browser profiles with proxy support.
|
||||
model: gpt-4o
|
||||
|
||||
- name: Update release with generated notes
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
# Get the generated release notes
|
||||
RELEASE_NOTES="${{ steps.generate-notes.outputs.response }}"
|
||||
|
||||
# Update the release with the generated notes
|
||||
gh api --method PATCH /repos/${{ github.repository }}/releases/${{ github.event.release.id }} \
|
||||
--field body="$RELEASE_NOTES"
|
||||
|
||||
echo "✅ Release notes updated successfully!"
|
||||
|
||||
- name: Cleanup
|
||||
run: |
|
||||
rm -f commits.txt changes.txt
|
||||
@@ -13,7 +13,7 @@ env:
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -31,14 +31,35 @@ jobs:
|
||||
name: Lint JavaScript/TypeScript
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
lint-rust:
|
||||
name: Lint Rust
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
codeql:
|
||||
name: CodeQL
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
packages: read
|
||||
actions: read
|
||||
|
||||
spellcheck:
|
||||
name: Spell Check
|
||||
uses: ./.github/workflows/spellcheck.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
release:
|
||||
needs: [security-scan, lint-js, lint-rust]
|
||||
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -69,14 +90,13 @@ jobs:
|
||||
target: "aarch64-unknown-linux-gnu"
|
||||
pkg_target: "latest-linux-arm64"
|
||||
nodecar_script: "build:linux-arm64"
|
||||
# Future platforms can be added here:
|
||||
# - platform: "windows-latest"
|
||||
# args: "--target x86_64-pc-windows-msvc"
|
||||
# arch: "x86_64"
|
||||
# target: "x86_64-pc-windows-msvc"
|
||||
# pkg_target: "latest-win-x64"
|
||||
# nodecar_script: "build:win-x64"
|
||||
# - platform: "windows-latest"
|
||||
# - platform: "windows-11-arm"
|
||||
# args: "--target aarch64-pc-windows-msvc"
|
||||
# arch: "aarch64"
|
||||
# target: "aarch64-pc-windows-msvc"
|
||||
@@ -85,19 +105,20 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install dependencies (Ubuntu only)
|
||||
@@ -107,18 +128,13 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install nodecar dependencies
|
||||
working-directory: ./nodecar
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build nodecar sidecar
|
||||
shell: bash
|
||||
working-directory: ./nodecar
|
||||
@@ -139,7 +155,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
@@ -150,3 +166,10 @@ jobs:
|
||||
releaseDraft: false
|
||||
prerelease: false
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
- name: Commit CHANGELOG.md
|
||||
uses: stefanzweifel/git-auto-commit-action@778341af668090896ca464160c2def5d1d1a3eb0 #v6.0.1
|
||||
with:
|
||||
branch: main
|
||||
commit_message: "docs: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
|
||||
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@e69cc6c86b31f1e7e23935bbe7031b50e51082de" # v2.0.2
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -30,14 +30,35 @@ jobs:
|
||||
name: Lint JavaScript/TypeScript
|
||||
uses: ./.github/workflows/lint-js.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
lint-rust:
|
||||
name: Lint Rust
|
||||
uses: ./.github/workflows/lint-rs.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
codeql:
|
||||
name: CodeQL
|
||||
uses: ./.github/workflows/codeql.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
packages: read
|
||||
actions: read
|
||||
|
||||
spellcheck:
|
||||
name: Spell Check
|
||||
uses: ./.github/workflows/spellcheck.yml
|
||||
secrets: inherit
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
rolling-release:
|
||||
needs: [security-scan, lint-js, lint-rust]
|
||||
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -68,22 +89,35 @@ jobs:
|
||||
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"
|
||||
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"
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v4
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@stable
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
|
||||
- name: Install dependencies (Ubuntu only)
|
||||
@@ -93,18 +127,13 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@v2
|
||||
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install nodecar dependencies
|
||||
working-directory: ./nodecar
|
||||
run: |
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build nodecar sidecar
|
||||
shell: bash
|
||||
working-directory: ./nodecar
|
||||
@@ -124,21 +153,26 @@ jobs:
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- name: Get commit hash
|
||||
id: commit
|
||||
run: echo "hash=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||
- name: Generate nightly timestamp
|
||||
id: timestamp
|
||||
shell: bash
|
||||
run: |
|
||||
TIMESTAMP=$(date -u +"%Y-%m-%d")
|
||||
COMMIT_HASH=$(echo "${GITHUB_SHA}" | cut -c1-7)
|
||||
echo "timestamp=${TIMESTAMP}-${COMMIT_HASH}" >> $GITHUB_OUTPUT
|
||||
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_TAG: "nightly-${{ steps.commit.outputs.hash }}"
|
||||
GITHUB_REF_NAME: "nightly-${{ steps.commit.outputs.hash }}"
|
||||
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
GITHUB_REF_NAME: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
with:
|
||||
tagName: "nightly-${{ steps.commit.outputs.hash }}"
|
||||
releaseName: "Donut Browser Nightly (Build ${{ steps.commit.outputs.hash }})"
|
||||
releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.commit.outputs.hash }}"
|
||||
tagName: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
releaseName: "Donut Browser Nightly (Build ${{ steps.timestamp.outputs.timestamp }})"
|
||||
releaseBody: "⚠️ **Nightly Release** - This is an automatically generated pre-release build from the latest main branch. Use with caution.\n\nCommit: ${{ github.sha }}\nBuild: ${{ steps.timestamp.outputs.timestamp }}"
|
||||
releaseDraft: false
|
||||
prerelease: true
|
||||
args: ${{ matrix.args }}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
name: Spell Check
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
|
||||
env:
|
||||
RUST_BACKTRACE: 1
|
||||
CARGO_TERM_COLOR: always
|
||||
CLICOLOR: 1
|
||||
|
||||
jobs:
|
||||
spelling:
|
||||
name: Spell Check with Typos
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@392b78fe18a52790c53f42456e46124f77346842 #v1.34.0
|
||||
@@ -0,0 +1,21 @@
|
||||
name: Mark stale issues and pull requests
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "35 23 * * *"
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: "This issue has been inactive for 60 days. Please respond to keep it open."
|
||||
stale-pr-message: "This pull request has been inactive for 60 days. Please respond to keep it open."
|
||||
stale-issue-label: "stale"
|
||||
stale-pr-label: "stale"
|
||||
@@ -46,7 +46,4 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
# eslint
|
||||
.eslintcache
|
||||
|
||||
!**/.gitkeep
|
||||
Vendored
+47
-2
@@ -1,30 +1,46 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"ahooks",
|
||||
"akhilmhdh",
|
||||
"appimage",
|
||||
"appindicator",
|
||||
"applescript",
|
||||
"autoconfig",
|
||||
"autologin",
|
||||
"biomejs",
|
||||
"camoufox",
|
||||
"cdylib",
|
||||
"CFURL",
|
||||
"checkin",
|
||||
"CLICOLOR",
|
||||
"clippy",
|
||||
"cmdk",
|
||||
"codegen",
|
||||
"CTYPE",
|
||||
"datareporting",
|
||||
"devedition",
|
||||
"doesn",
|
||||
"donutbrowser",
|
||||
"dpkg",
|
||||
"dtolnay",
|
||||
"dyld",
|
||||
"elif",
|
||||
"errorlevel",
|
||||
"esac",
|
||||
"esbuild",
|
||||
"eslintcache",
|
||||
"frontmost",
|
||||
"geoip",
|
||||
"gettimezone",
|
||||
"gifs",
|
||||
"gsettings",
|
||||
"healthreport",
|
||||
"hkcu",
|
||||
"icns",
|
||||
"idletime",
|
||||
"Inno",
|
||||
"KHTML",
|
||||
"launchservices",
|
||||
"letterboxing",
|
||||
"libatk",
|
||||
"libayatana",
|
||||
"libcairo",
|
||||
@@ -33,16 +49,27 @@
|
||||
"libpango",
|
||||
"librsvg",
|
||||
"libwebkit",
|
||||
"libxdo",
|
||||
"localtime",
|
||||
"mmdb",
|
||||
"mountpoint",
|
||||
"msiexec",
|
||||
"msvc",
|
||||
"msys",
|
||||
"Mullvad",
|
||||
"mullvadbrowser",
|
||||
"nodecar",
|
||||
"nodemon",
|
||||
"norestart",
|
||||
"NSIS",
|
||||
"ntlm",
|
||||
"objc",
|
||||
"orhun",
|
||||
"osascript",
|
||||
"peerconnection",
|
||||
"pixbuf",
|
||||
"plasmohq",
|
||||
"prefs",
|
||||
"propertylist",
|
||||
"reqwest",
|
||||
"ridedott",
|
||||
@@ -53,21 +80,39 @@
|
||||
"shadcn",
|
||||
"signon",
|
||||
"sonner",
|
||||
"splitn",
|
||||
"sspi",
|
||||
"staticlib",
|
||||
"stefanzweifel",
|
||||
"subdirs",
|
||||
"subkey",
|
||||
"SUPPRESSMSGBOXES",
|
||||
"swatinem",
|
||||
"sysinfo",
|
||||
"systempreferences",
|
||||
"systemsetup",
|
||||
"taskkill",
|
||||
"tasklist",
|
||||
"tauri",
|
||||
"TERX",
|
||||
"timedatectl",
|
||||
"titlebar",
|
||||
"Torbrowser",
|
||||
"trackingprotection",
|
||||
"turbopack",
|
||||
"udeps",
|
||||
"unlisten",
|
||||
"unminimize",
|
||||
"unrs",
|
||||
"urlencoding",
|
||||
"vercel",
|
||||
"VERYSILENT",
|
||||
"webgl",
|
||||
"webrtc",
|
||||
"winreg",
|
||||
"wiremock",
|
||||
"xattr",
|
||||
"zhom"
|
||||
"zhom",
|
||||
"zoneinfo"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# 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
|
||||
- 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.
|
||||
+1
-1
@@ -23,6 +23,6 @@ Examples of unacceptable behavior by participants include:
|
||||
|
||||
## Enforcement
|
||||
|
||||
Violations of the Code of Conduct may be reported by pinging @zhom on Github. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
|
||||
Violations of the Code of Conduct may be reported to contact at donutbrowser dot com. All reports will be reviewed and investigated and will result in a response that is deemed necessary and appropriate to the circumstances. Further details of specific enforcement policies may be posted separately.
|
||||
|
||||
We hold the right and responsibility to remove comments or other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any members for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
@@ -149,43 +149,6 @@ Refs #00000
|
||||
|
||||
- Ensure that "Allow edits from maintainers" option is checked
|
||||
|
||||
## Types of Contributions
|
||||
|
||||
### Bug Reports
|
||||
|
||||
When filing bug reports, please include:
|
||||
|
||||
- Clear description of the issue
|
||||
- Steps to reproduce
|
||||
- Expected vs actual behavior
|
||||
- Environment details (OS, version, etc.)
|
||||
- Screenshots or error logs if applicable
|
||||
|
||||
### Feature Requests
|
||||
|
||||
When suggesting new features:
|
||||
|
||||
- Explain the use case and why it's valuable
|
||||
- Describe the desired behavior
|
||||
- Consider alternatives you've thought of
|
||||
- Check if it aligns with our roadmap
|
||||
|
||||
### Code Contributions
|
||||
|
||||
- Bug fixes
|
||||
- New features
|
||||
- Performance improvements
|
||||
- Documentation updates
|
||||
- Test coverage improvements
|
||||
|
||||
### Documentation
|
||||
|
||||
- README improvements
|
||||
- Code comments
|
||||
- API documentation
|
||||
- Tutorial content
|
||||
- Translation work
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Donut Browser is built with:
|
||||
|
||||
@@ -1,15 +1,47 @@
|
||||
# Donut Browser
|
||||
<div align="center">
|
||||
<img src="assets/logo.png" alt="Donut Browser Logo" width="150">
|
||||
<h1>Donut Browser</h1>
|
||||
<strong>A powerful browser orchestrator that puts you in control of your browsing experience. 🍩</strong>
|
||||
</div>
|
||||
<br>
|
||||
|
||||

|
||||
<p align="center">
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/releases/latest" target="_blank"><img alt="GitHub release" src="https://img.shields.io/github/v/release/zhom/donutbrowser">
|
||||
</a>
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/issues" target="_blank">
|
||||
<img src="https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=flat" alt="PRs Welcome">
|
||||
</a>
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
|
||||
<img src="https://img.shields.io/badge/license-AGPL--3.0-blue.svg" alt="License">
|
||||
</a>
|
||||
<a href="https://app.codacy.com/gh/zhom/donutbrowser/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade">
|
||||
<img src="https://app.codacy.com/project/badge/Grade/b9c9beafc92d4bc8bc7c5b42c6c4ba81"/>
|
||||
</a>
|
||||
<a href="https://app.fossa.com/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser?ref=badge_shield&issueType=security" alt="FOSSA Status">
|
||||
<img src="https://app.fossa.com/api/projects/git%2Bgithub.com%2Fzhom%2Fdonutbrowser.svg?type=shield&issueType=security"/>
|
||||
</a>
|
||||
<a style="text-decoration: none;" href="https://github.com/zhom/donutbrowser/stargazers" target="_blank">
|
||||
<img src="https://img.shields.io/github/stars/zhom/donutbrowser?style=social" alt="GitHub stars">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
> **A powerful browser orchestrator that puts you in control of your browsing experience. 🍩**
|
||||
## Donut Browser
|
||||
|
||||
[](https://github.com/zhom/donutbrowser/releases/latest)
|
||||
[](https://github.com/zhom/donutbrowser/issues)
|
||||
[](https://github.com/zhom/donutbrowser/blob/main/LICENSE)
|
||||
[](https://github.com/zhom/donutbrowser/stargazers)
|
||||
> A free and open source browser orchestrator 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" />
|
||||
<img alt="Preview" src="assets/preview.png" />
|
||||
</picture>
|
||||
|
||||
## Features
|
||||
|
||||
- Create unlimited number of local browser profiles completely isolated from each other
|
||||
- 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
|
||||
- Set Donut Browser as your default browser to control in which profile to open links
|
||||
|
||||
## Download
|
||||
|
||||
@@ -28,8 +60,6 @@ The app can be downloaded from the [releases page](https://github.com/zhom/donut
|
||||
|
||||
### Contributing
|
||||
|
||||
> Donut Browser is built with [Tauri](https://v2.tauri.app/).
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Issues
|
||||
@@ -45,7 +75,31 @@ Have questions or want to contribute? We'd love to hear from you!
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://www.star-history.com/#zhom/donutbrowser&Date)
|
||||
<a href="https://www.star-history.com/#zhom/donutbrowser&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=zhom/donutbrowser&type=Date" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Contributors
|
||||
|
||||
<!-- readme: collaborators,contributors -start -->
|
||||
<table>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center">
|
||||
<a href="https://github.com/zhom">
|
||||
<img src="https://avatars.githubusercontent.com/u/2717306?v=4" width="100;" alt="zhom"/>
|
||||
<br />
|
||||
<sub><b>zhom</b></sub>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tbody>
|
||||
</table>
|
||||
<!-- readme: collaborators,contributors -end -->
|
||||
|
||||
## Contact
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 523 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 163 KiB After Width: | Height: | Size: 540 KiB |
+3
-17
@@ -1,22 +1,18 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/1.9.4/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
"useIgnoreFile": false
|
||||
},
|
||||
"files": {
|
||||
"ignoreUnknown": false,
|
||||
"ignore": []
|
||||
"ignoreUnknown": false
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2
|
||||
},
|
||||
"organizeImports": {
|
||||
"enabled": true
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
@@ -25,17 +21,7 @@
|
||||
"useHookAtTopLevel": "error"
|
||||
},
|
||||
"nursery": {
|
||||
"useGoogleFontDisplay": "error",
|
||||
"noDocumentImportInPage": "error",
|
||||
"noHeadElement": "error",
|
||||
"noHeadImportInDocument": "error",
|
||||
"noImgElement": "off",
|
||||
"useComponentExportOnlyModules": {
|
||||
"level": "error",
|
||||
"options": {
|
||||
"allowExportNames": ["metadata", "badgeVariants", "buttonVariants"]
|
||||
}
|
||||
}
|
||||
"useUniqueElementIds": "off"
|
||||
},
|
||||
"a11y": {
|
||||
"useSemanticElements": "off"
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
tseslint.configs.strictTypeChecked,
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
{
|
||||
// Disabled rules taken from https://biomejs.dev/linter/rules-sources for ones that
|
||||
// are already handled by Prettier and TypeScript or are not needed
|
||||
rules: {
|
||||
// eslint-plugin-jsx-a11y rules - some disabled for performance/specific project needs
|
||||
"jsx-a11y/alt-text": "off",
|
||||
"jsx-a11y/anchor-has-content": "off",
|
||||
"jsx-a11y/anchor-is-valid": "off",
|
||||
"jsx-a11y/aria-activedescendant-has-tabindex": "off",
|
||||
"jsx-a11y/aria-props": "off",
|
||||
"jsx-a11y/aria-proptypes": "off",
|
||||
"jsx-a11y/aria-role": "off",
|
||||
"jsx-a11y/aria-unsupported-elements": "off",
|
||||
"jsx-a11y/autocomplete-valid": "off",
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"jsx-a11y/heading-has-content": "off",
|
||||
"jsx-a11y/html-has-lang": "off",
|
||||
"jsx-a11y/iframe-has-title": "off",
|
||||
"jsx-a11y/img-redundant-alt": "off",
|
||||
"jsx-a11y/interactive-supports-focus": "off",
|
||||
"jsx-a11y/label-has-associated-control": "off",
|
||||
"jsx-a11y/lang": "off",
|
||||
"jsx-a11y/media-has-caption": "off",
|
||||
"jsx-a11y/mouse-events-have-key-events": "off",
|
||||
"jsx-a11y/no-access-key": "off",
|
||||
"jsx-a11y/no-aria-hidden-on-focusable": "off",
|
||||
"jsx-a11y/no-autofocus": "off",
|
||||
"jsx-a11y/no-distracting-elements": "off",
|
||||
"jsx-a11y/no-interactive-element-to-noninteractive-role": "off",
|
||||
"jsx-a11y/no-noninteractive-element-to-interactive-role": "off",
|
||||
"jsx-a11y/no-noninteractive-tabindex": "off",
|
||||
"jsx-a11y/no-redundant-roles": "off",
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"jsx-a11y/role-has-required-aria-props": "off",
|
||||
"jsx-a11y/role-supports-aria-props": "off",
|
||||
"jsx-a11y/scope": "off",
|
||||
"jsx-a11y/tabindex-no-positive": "off",
|
||||
// eslint-plugin-react rules - some disabled for performance/specific project needs
|
||||
"react/button-has-type": "off",
|
||||
"react/jsx-boolean-value": "off",
|
||||
"react/jsx-curly-brace-presence": "off",
|
||||
"react/jsx-fragments": "off",
|
||||
"react/jsx-key": "off",
|
||||
"react/jsx-no-comment-textnodes": "off",
|
||||
"react/jsx-no-duplicate-props": "off",
|
||||
"react/jsx-no-target-blank": "off",
|
||||
"react/jsx-no-useless-fragment": "off",
|
||||
"react/no-array-index-key": "off",
|
||||
"react/no-children-prop": "off",
|
||||
"react/no-danger": "off",
|
||||
"react/no-danger-with-children": "off",
|
||||
"react/void-dom-elements-no-children": "off",
|
||||
// eslint-plugin-react-hooks rules - disabled for specific project needs
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
// typescript-eslint rules - some handled by TypeScript compiler or disabled for project needs
|
||||
"@typescript-eslint/adjacent-overload-signatures": "off",
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/ban-types": "off",
|
||||
"@typescript-eslint/consistent-type-exports": "off",
|
||||
"@typescript-eslint/consistent-type-imports": "off",
|
||||
"@typescript-eslint/default-param-last": "off",
|
||||
"@typescript-eslint/dot-notation": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-member-accessibility": "off",
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/no-dupe-class-members": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-extra-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-extraneous-class": "off",
|
||||
"@typescript-eslint/no-inferrable-types": "off",
|
||||
"@typescript-eslint/no-invalid-void-type": "off",
|
||||
"@typescript-eslint/no-loss-of-precision": "off",
|
||||
"@typescript-eslint/no-misused-new": "off",
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-redeclare": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-restricted-imports": "off",
|
||||
"@typescript-eslint/no-restricted-types": "off",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "off",
|
||||
"@typescript-eslint/no-unsafe-declaration-merging": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "off",
|
||||
"@typescript-eslint/no-useless-empty-export": "off",
|
||||
"@typescript-eslint/only-throw-error": "off",
|
||||
"@typescript-eslint/parameter-properties": "off",
|
||||
"@typescript-eslint/prefer-as-const": "off",
|
||||
"@typescript-eslint/prefer-enum-initializers": "off",
|
||||
"@typescript-eslint/prefer-for-of": "off",
|
||||
"@typescript-eslint/prefer-function-type": "off",
|
||||
"@typescript-eslint/prefer-literal-enum-member": "off",
|
||||
"@typescript-eslint/prefer-namespace-keyword": "off",
|
||||
"@typescript-eslint/prefer-optional-chain": "off",
|
||||
"@typescript-eslint/require-await": "off",
|
||||
// Custom rules
|
||||
"@typescript-eslint/restrict-template-expressions": [
|
||||
"error",
|
||||
{
|
||||
allowNumber: true,
|
||||
allowBoolean: true,
|
||||
allowNever: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
export default eslintConfig;
|
||||
@@ -21,5 +21,8 @@ if [ -z "$TARGET_TRIPLE" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy the file
|
||||
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
|
||||
# Copy the file with target triple suffix
|
||||
cp "dist/nodecar${EXT}" "../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}"
|
||||
@@ -1,131 +0,0 @@
|
||||
import { FlatCompat } from "@eslint/eslintrc";
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const compat = new FlatCompat({
|
||||
baseDirectory: import.meta.dirname,
|
||||
});
|
||||
|
||||
const eslintConfig = tseslint.config(
|
||||
eslint.configs.recommended,
|
||||
...compat.extends("next/core-web-vitals"),
|
||||
{
|
||||
// Disabled rules taken from https://biomejs.dev/linter/rules-sources for ones that
|
||||
// are already handled by Prettier and TypeScript or are not needed
|
||||
rules: {
|
||||
// eslint-plugin-jsx-a11y rules - some disabled for performance/specific project needs
|
||||
"jsx-a11y/alt-text": "off",
|
||||
"jsx-a11y/anchor-has-content": "off",
|
||||
"jsx-a11y/anchor-is-valid": "off",
|
||||
"jsx-a11y/aria-activedescendant-has-tabindex": "off",
|
||||
"jsx-a11y/aria-props": "off",
|
||||
"jsx-a11y/aria-proptypes": "off",
|
||||
"jsx-a11y/aria-role": "off",
|
||||
"jsx-a11y/aria-unsupported-elements": "off",
|
||||
"jsx-a11y/autocomplete-valid": "off",
|
||||
"jsx-a11y/click-events-have-key-events": "off",
|
||||
"jsx-a11y/heading-has-content": "off",
|
||||
"jsx-a11y/html-has-lang": "off",
|
||||
"jsx-a11y/iframe-has-title": "off",
|
||||
"jsx-a11y/img-redundant-alt": "off",
|
||||
"jsx-a11y/interactive-supports-focus": "off",
|
||||
"jsx-a11y/label-has-associated-control": "off",
|
||||
"jsx-a11y/lang": "off",
|
||||
"jsx-a11y/media-has-caption": "off",
|
||||
"jsx-a11y/mouse-events-have-key-events": "off",
|
||||
"jsx-a11y/no-access-key": "off",
|
||||
"jsx-a11y/no-aria-hidden-on-focusable": "off",
|
||||
"jsx-a11y/no-autofocus": "off",
|
||||
"jsx-a11y/no-distracting-elements": "off",
|
||||
"jsx-a11y/no-interactive-element-to-noninteractive-role": "off",
|
||||
"jsx-a11y/no-noninteractive-element-to-interactive-role": "off",
|
||||
"jsx-a11y/no-noninteractive-tabindex": "off",
|
||||
"jsx-a11y/no-redundant-roles": "off",
|
||||
"jsx-a11y/no-static-element-interactions": "off",
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"jsx-a11y/role-has-required-aria-props": "off",
|
||||
"jsx-a11y/role-supports-aria-props": "off",
|
||||
"jsx-a11y/scope": "off",
|
||||
"jsx-a11y/tabindex-no-positive": "off",
|
||||
// eslint-plugin-react rules - some disabled for performance/specific project needs
|
||||
"react/button-has-type": "off",
|
||||
"react/jsx-boolean-value": "off",
|
||||
"react/jsx-curly-brace-presence": "off",
|
||||
"react/jsx-fragments": "off",
|
||||
"react/jsx-key": "off",
|
||||
"react/jsx-no-comment-textnodes": "off",
|
||||
"react/jsx-no-duplicate-props": "off",
|
||||
"react/jsx-no-target-blank": "off",
|
||||
"react/jsx-no-useless-fragment": "off",
|
||||
"react/no-array-index-key": "off",
|
||||
"react/no-children-prop": "off",
|
||||
"react/no-danger": "off",
|
||||
"react/no-danger-with-children": "off",
|
||||
"react/void-dom-elements-no-children": "off",
|
||||
// eslint-plugin-react-hooks rules - disabled for specific project needs
|
||||
"react-hooks/exhaustive-deps": "off",
|
||||
"react-hooks/rules-of-hooks": "off",
|
||||
// typescript-eslint rules - some handled by TypeScript compiler or disabled for project needs
|
||||
"@typescript-eslint/adjacent-overload-signatures": "off",
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/ban-types": "off",
|
||||
"@typescript-eslint/consistent-type-exports": "off",
|
||||
"@typescript-eslint/consistent-type-imports": "off",
|
||||
"@typescript-eslint/default-param-last": "off",
|
||||
"@typescript-eslint/dot-notation": "off",
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-member-accessibility": "off",
|
||||
"@typescript-eslint/naming-convention": "off",
|
||||
"@typescript-eslint/no-dupe-class-members": "off",
|
||||
"@typescript-eslint/no-empty-function": "off",
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-extra-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-extraneous-class": "off",
|
||||
"@typescript-eslint/no-inferrable-types": "off",
|
||||
"@typescript-eslint/no-invalid-void-type": "off",
|
||||
"@typescript-eslint/no-loss-of-precision": "off",
|
||||
"@typescript-eslint/no-misused-new": "off",
|
||||
"@typescript-eslint/no-namespace": "off",
|
||||
"@typescript-eslint/no-non-null-assertion": "off",
|
||||
"@typescript-eslint/no-redeclare": "off",
|
||||
"@typescript-eslint/no-require-imports": "off",
|
||||
"@typescript-eslint/no-restricted-imports": "off",
|
||||
"@typescript-eslint/no-restricted-types": "off",
|
||||
"@typescript-eslint/no-this-alias": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-constraint": "off",
|
||||
"@typescript-eslint/no-unsafe-declaration-merging": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off",
|
||||
"@typescript-eslint/no-use-before-define": "off",
|
||||
"@typescript-eslint/no-useless-constructor": "off",
|
||||
"@typescript-eslint/no-useless-empty-export": "off",
|
||||
"@typescript-eslint/only-throw-error": "off",
|
||||
"@typescript-eslint/parameter-properties": "off",
|
||||
"@typescript-eslint/prefer-as-const": "off",
|
||||
"@typescript-eslint/prefer-enum-initializers": "off",
|
||||
"@typescript-eslint/prefer-for-of": "off",
|
||||
"@typescript-eslint/prefer-function-type": "off",
|
||||
"@typescript-eslint/prefer-literal-enum-member": "off",
|
||||
"@typescript-eslint/prefer-namespace-keyword": "off",
|
||||
"@typescript-eslint/prefer-optional-chain": "off",
|
||||
"@typescript-eslint/require-await": "off",
|
||||
// Custom rules
|
||||
"@typescript-eslint/restrict-template-expressions": [
|
||||
"error",
|
||||
{
|
||||
allowNumber: true,
|
||||
allowBoolean: true,
|
||||
allowNever: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
export default eslintConfig;
|
||||
+10
-4
@@ -2,11 +2,12 @@
|
||||
"name": "nodecar",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/index.ts",
|
||||
"main": "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": "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",
|
||||
@@ -20,14 +21,19 @@
|
||||
"author": "",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/node": "^24.0.10",
|
||||
"@yao-pkg/pkg": "^6.5.1",
|
||||
"camoufox-js": "^0.6.0",
|
||||
"commander": "^14.0.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"dotenv": "^17.0.1",
|
||||
"get-port": "^7.1.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"proxy-chain": "^2.5.9",
|
||||
"tmp": "^0.2.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tmp": "^0.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,503 @@
|
||||
import { spawn } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
|
||||
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;
|
||||
};
|
||||
window?: [number, number];
|
||||
|
||||
// Fingerprint
|
||||
fingerprint?: any;
|
||||
|
||||
// Version and mode
|
||||
ff_version?: number;
|
||||
headless?: boolean;
|
||||
main_world_eval?: boolean;
|
||||
|
||||
// Custom executable path
|
||||
executable_path?: string;
|
||||
|
||||
// Firefox preferences
|
||||
firefox_user_prefs?: Record<string, any>;
|
||||
|
||||
// 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;
|
||||
};
|
||||
}
|
||||
|
||||
// Store for active Camoufox processes
|
||||
const activeCamoufoxProcesses = new Map<string, CamoufoxConfig>();
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load Camoufox configs: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process is still running
|
||||
*/
|
||||
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 = {},
|
||||
url?: string,
|
||||
): Promise<CamoufoxConfig> {
|
||||
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");
|
||||
}
|
||||
|
||||
const config: CamoufoxConfig = {
|
||||
id,
|
||||
pid: child.pid,
|
||||
executablePath,
|
||||
profilePath,
|
||||
url,
|
||||
options,
|
||||
};
|
||||
|
||||
// Save configuration
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
// Handle process exit
|
||||
child.on("exit", (code, signal) => {
|
||||
console.log(
|
||||
`Camoufox process ${child.pid} exited with code ${code}, signal ${signal}`,
|
||||
);
|
||||
deleteCamoufoxConfig(id);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`Camoufox process error: ${error}`);
|
||||
deleteCamoufoxConfig(id);
|
||||
});
|
||||
|
||||
// Detach the child process so it can continue running independently
|
||||
child.unref();
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a Camoufox process by ID
|
||||
*/
|
||||
export async function stopCamoufox(id: string): Promise<boolean> {
|
||||
const config = activeCamoufoxProcesses.get(id) || loadCamoufoxConfig(id);
|
||||
|
||||
if (!config || !config.pid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
if (isProcessRunning(config.pid)) {
|
||||
process.kill(config.pid, "SIGTERM");
|
||||
|
||||
// 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");
|
||||
}
|
||||
}
|
||||
|
||||
deleteCamoufoxConfig(id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to stop Camoufox process: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Camoufox processes
|
||||
*/
|
||||
export function listCamoufoxProcesses(): any[] {
|
||||
loadAllCamoufoxConfigs();
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Load existing configurations on module initialization
|
||||
loadAllCamoufoxConfigs();
|
||||
+349
-23
@@ -1,8 +1,13 @@
|
||||
import { program } from "commander";
|
||||
import {
|
||||
launchCamoufox,
|
||||
listCamoufoxProcesses,
|
||||
stopCamoufox,
|
||||
} from "./camoufox-launcher";
|
||||
import {
|
||||
startProxyProcess,
|
||||
stopProxyProcess,
|
||||
stopAllProxyProcesses,
|
||||
stopProxyProcess,
|
||||
} from "./proxy-runner";
|
||||
import { listProxyConfigs } from "./proxy-storage";
|
||||
import { runProxyWorker } from "./proxy-worker";
|
||||
@@ -11,79 +16,127 @@ import { runProxyWorker } from "./proxy-worker";
|
||||
program
|
||||
.command("proxy")
|
||||
.argument("<action>", "start, stop, or list proxies")
|
||||
.option(
|
||||
"-u, --upstream <url>",
|
||||
"upstream proxy URL (protocol://[username:password@]host:port)"
|
||||
)
|
||||
.option("--host <host>", "upstream proxy host")
|
||||
.option("--proxy-port <port>", "upstream proxy port", Number.parseInt)
|
||||
.option("--type <type>", "proxy type (http, https, socks4, socks5)")
|
||||
.option("--username <username>", "proxy username")
|
||||
.option("--password <password>", "proxy password")
|
||||
.option(
|
||||
"-p, --port <number>",
|
||||
"local port to use (random if not specified)",
|
||||
Number.parseInt
|
||||
Number.parseInt,
|
||||
)
|
||||
.option("--ignore-certificate", "ignore certificate errors for HTTPS proxies")
|
||||
.option("--id <id>", "proxy ID for stop command")
|
||||
.option(
|
||||
"-u, --upstream <url>",
|
||||
"upstream proxy URL (protocol://[username:password@]host:port)",
|
||||
)
|
||||
.description("manage proxy servers")
|
||||
.action(
|
||||
async (
|
||||
action: string,
|
||||
options: {
|
||||
upstream?: string;
|
||||
host?: string;
|
||||
proxyPort?: number;
|
||||
type?: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
port?: number;
|
||||
ignoreCertificate?: boolean;
|
||||
id?: string;
|
||||
}
|
||||
upstream?: string;
|
||||
},
|
||||
) => {
|
||||
if (action === "start") {
|
||||
if (!options.upstream) {
|
||||
console.error("Error: Upstream proxy URL is required");
|
||||
console.log(
|
||||
"Example: proxy start -u http://username:password@proxy.example.com:8080"
|
||||
let upstreamUrl: string;
|
||||
|
||||
// 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";
|
||||
const auth =
|
||||
options.username && options.password
|
||||
? `${encodeURIComponent(options.username)}:${encodeURIComponent(
|
||||
options.password,
|
||||
)}@`
|
||||
: "";
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
const config = await startProxyProcess(options.upstream, {
|
||||
const config = await startProxyProcess(upstreamUrl, {
|
||||
port: options.port,
|
||||
ignoreProxyCertificate: options.ignoreCertificate,
|
||||
});
|
||||
console.log(JSON.stringify(config));
|
||||
|
||||
// Output the configuration as JSON for the Rust side to parse
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
id: config.id,
|
||||
localPort: config.localPort,
|
||||
localUrl: config.localUrl,
|
||||
upstreamUrl: config.upstreamUrl,
|
||||
}),
|
||||
);
|
||||
|
||||
// Exit successfully to allow the process to detach
|
||||
process.exit(0);
|
||||
} catch (error: unknown) {
|
||||
console.error(`Failed to start proxy: ${JSON.stringify(error)}`);
|
||||
console.error(
|
||||
`Failed to start proxy: ${
|
||||
error instanceof Error ? error.message : JSON.stringify(error)
|
||||
}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (action === "stop") {
|
||||
if (options.id) {
|
||||
const stopped = await stopProxyProcess(options.id);
|
||||
console.log(`{
|
||||
"success": ${stopped}}`);
|
||||
console.log(JSON.stringify({ success: stopped }));
|
||||
} else if (options.upstream) {
|
||||
// Find proxies with this upstream URL
|
||||
const configs = listProxyConfigs().filter(
|
||||
(config) => config.upstreamUrl === options.upstream
|
||||
(config) => config.upstreamUrl === options.upstream,
|
||||
);
|
||||
|
||||
if (configs.length === 0) {
|
||||
console.error(`No proxies found for ${options.upstream}`);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const config of configs) {
|
||||
const stopped = await stopProxyProcess(config.id);
|
||||
console.log(`{
|
||||
"success": ${stopped}}`);
|
||||
console.log(JSON.stringify({ success: stopped }));
|
||||
}
|
||||
} else {
|
||||
await stopAllProxyProcesses();
|
||||
console.log(`{
|
||||
"success": true}`);
|
||||
console.log(JSON.stringify({ success: true }));
|
||||
}
|
||||
process.exit(0);
|
||||
} else if (action === "list") {
|
||||
const configs = listProxyConfigs();
|
||||
console.log(JSON.stringify(configs));
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error("Invalid action. Use 'start', 'stop', or 'list'");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Command for proxy worker (internal use)
|
||||
@@ -101,4 +154,277 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Command for Camoufox browser orchestrator
|
||||
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")
|
||||
.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")
|
||||
.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("--headless", "run in headless mode")
|
||||
|
||||
// Localization
|
||||
.option("--locale <locale>", "locale(s) to use (comma-separated)")
|
||||
|
||||
// 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)",
|
||||
)
|
||||
|
||||
// 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)
|
||||
|
||||
// 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")
|
||||
|
||||
// Proxy
|
||||
.option(
|
||||
"--proxy <proxy>",
|
||||
"proxy URL (protocol://[username:password@]host:port)",
|
||||
)
|
||||
|
||||
// Cache and performance
|
||||
.option("--disable-cache", "disable browser cache (cache enabled by default)")
|
||||
|
||||
// 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)")
|
||||
|
||||
// Firefox preferences
|
||||
.option("--firefox-prefs <prefs>", "Firefox user preferences (JSON string)")
|
||||
|
||||
.description("launch and manage Camoufox browser orchestrator instances")
|
||||
.action(async (action: string, options: any) => {
|
||||
try {
|
||||
if (action === "launch") {
|
||||
// Validate required options
|
||||
if (!options.executablePath || !options.profilePath) {
|
||||
console.error(
|
||||
"Error: --executable-path and --profile-path are required for launch",
|
||||
);
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
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",
|
||||
);
|
||||
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'",
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`Camoufox command failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
+38
-26
@@ -3,12 +3,12 @@ import path from "node:path";
|
||||
import getPort from "get-port";
|
||||
import {
|
||||
type ProxyConfig,
|
||||
saveProxyConfig,
|
||||
getProxyConfig,
|
||||
deleteProxyConfig,
|
||||
isProcessRunning,
|
||||
generateProxyId,
|
||||
getProxyConfig,
|
||||
isProcessRunning,
|
||||
listProxyConfigs,
|
||||
saveProxyConfig,
|
||||
} from "./proxy-storage";
|
||||
|
||||
/**
|
||||
@@ -19,50 +19,53 @@ import {
|
||||
*/
|
||||
export async function startProxyProcess(
|
||||
upstreamUrl: string,
|
||||
options: { port?: number; ignoreProxyCertificate?: boolean } = {}
|
||||
options: { port?: number; ignoreProxyCertificate?: boolean } = {},
|
||||
): Promise<ProxyConfig> {
|
||||
// Generate a unique ID for this proxy
|
||||
const id = generateProxyId();
|
||||
|
||||
// Get a random available port if not specified
|
||||
const port = options.port || (await getPort());
|
||||
const port = options.port ?? (await getPort());
|
||||
|
||||
// Create the proxy configuration
|
||||
const config: ProxyConfig = {
|
||||
id,
|
||||
upstreamUrl,
|
||||
localPort: port,
|
||||
ignoreProxyCertificate: options.ignoreProxyCertificate || false,
|
||||
ignoreProxyCertificate: options.ignoreProxyCertificate ?? false,
|
||||
};
|
||||
|
||||
// Save the configuration before starting the process
|
||||
saveProxyConfig(config);
|
||||
|
||||
// Build the command arguments
|
||||
const args = ["proxy-worker", "start", "--id", id];
|
||||
const args = [
|
||||
path.join(__dirname, "index.js"),
|
||||
"proxy-worker",
|
||||
"start",
|
||||
"--id",
|
||||
id,
|
||||
];
|
||||
|
||||
// Spawn the process
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[path.join(__dirname, "index.js"), ...args],
|
||||
{
|
||||
detached: true,
|
||||
stdio: "ignore",
|
||||
}
|
||||
);
|
||||
// Spawn the process with proper detachment
|
||||
const child = spawn(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: ["ignore", "ignore", "ignore"], // Completely ignore all stdio
|
||||
cwd: process.cwd(),
|
||||
});
|
||||
|
||||
// Unref the child to allow the parent to exit independently
|
||||
child.unref();
|
||||
|
||||
// Store the process ID
|
||||
// Store the process ID and local URL
|
||||
config.pid = child.pid;
|
||||
config.localUrl = `http://localhost:${port}`;
|
||||
config.localUrl = `http://127.0.0.1:${port}`;
|
||||
|
||||
// Update the configuration with the process ID
|
||||
saveProxyConfig(config);
|
||||
|
||||
// Wait a bit to ensure the proxy has started
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
// Give the worker a moment to start before returning
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
return config;
|
||||
}
|
||||
@@ -75,7 +78,9 @@ export async function startProxyProcess(
|
||||
export async function stopProxyProcess(id: string): Promise<boolean> {
|
||||
const config = getProxyConfig(id);
|
||||
|
||||
if (!config || !config.pid) {
|
||||
if (!config?.pid) {
|
||||
// Try to delete the config anyway in case it exists without a PID
|
||||
deleteProxyConfig(id);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -83,10 +88,16 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
|
||||
// Check if the process is running
|
||||
if (isProcessRunning(config.pid)) {
|
||||
// Send SIGTERM to the process
|
||||
process.kill(config.pid);
|
||||
process.kill(config.pid, "SIGTERM");
|
||||
|
||||
// Wait a bit to ensure the process has terminated
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// If still running, send SIGKILL
|
||||
if (isProcessRunning(config.pid)) {
|
||||
process.kill(config.pid, "SIGKILL");
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the configuration
|
||||
@@ -95,6 +106,8 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error stopping proxy ${id}:`, error);
|
||||
// Delete the configuration even if stopping failed
|
||||
deleteProxyConfig(id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -106,7 +119,6 @@ export async function stopProxyProcess(id: string): Promise<boolean> {
|
||||
export async function stopAllProxyProcesses(): Promise<void> {
|
||||
const configs = listProxyConfigs();
|
||||
|
||||
for (const config of configs) {
|
||||
await stopProxyProcess(config.id);
|
||||
}
|
||||
const stopPromises = configs.map((config) => stopProxyProcess(config.id));
|
||||
await Promise.all(stopPromises);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import tmp from "tmp";
|
||||
|
||||
// Define the proxy configuration type
|
||||
export interface ProxyConfig {
|
||||
id: string;
|
||||
upstreamUrl: string;
|
||||
@@ -12,10 +11,8 @@ export interface ProxyConfig {
|
||||
pid?: number;
|
||||
}
|
||||
|
||||
// Path to store proxy configurations
|
||||
const STORAGE_DIR = path.join(os.tmpdir(), "donutbrowser", "proxies");
|
||||
const STORAGE_DIR = path.join(tmp.tmpdir, "donutbrowser", "proxies");
|
||||
|
||||
// Ensure storage directory exists
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
@@ -88,7 +85,7 @@ export function listProxyConfigs(): ProxyConfig[] {
|
||||
try {
|
||||
const content = fs.readFileSync(
|
||||
path.join(STORAGE_DIR, file),
|
||||
"utf-8"
|
||||
"utf-8",
|
||||
);
|
||||
return JSON.parse(content) as ProxyConfig;
|
||||
} catch (error) {
|
||||
@@ -111,14 +108,18 @@ export function listProxyConfigs(): ProxyConfig[] {
|
||||
export function updateProxyConfig(config: ProxyConfig): boolean {
|
||||
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
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(
|
||||
`Config ${config.id} was deleted while the app was running`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.error(`Error updating proxy config ${config.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
@@ -135,7 +136,7 @@ export function isProcessRunning(pid: number): boolean {
|
||||
// but checks if it exists
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
+41
-17
@@ -1,5 +1,5 @@
|
||||
import { Server } from "proxy-chain";
|
||||
import { getProxyConfig } from "./proxy-storage";
|
||||
import { getProxyConfig, updateProxyConfig } from "./proxy-storage";
|
||||
|
||||
/**
|
||||
* Run a proxy server as a worker process
|
||||
@@ -8,44 +8,68 @@ import { getProxyConfig } from "./proxy-storage";
|
||||
export async function runProxyWorker(id: string): Promise<void> {
|
||||
// Get the proxy configuration
|
||||
const config = getProxyConfig(id);
|
||||
|
||||
|
||||
if (!config) {
|
||||
console.error(`Proxy configuration ${id} not found`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
// Create a new proxy server
|
||||
const server = new Server({
|
||||
port: config.localPort,
|
||||
host: "localhost",
|
||||
host: "127.0.0.1",
|
||||
prepareRequestFunction: () => {
|
||||
return {
|
||||
upstreamProxyUrl: config.upstreamUrl,
|
||||
ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate || false,
|
||||
ignoreUpstreamProxyCertificate: config.ignoreProxyCertificate ?? false,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// Handle process termination
|
||||
process.on("SIGTERM", async () => {
|
||||
console.log(`Proxy worker ${id} received SIGTERM, shutting down...`);
|
||||
await server.close(true);
|
||||
|
||||
// Handle process termination gracefully
|
||||
const gracefulShutdown = async (signal: string) => {
|
||||
console.log(`Proxy worker ${id} received ${signal}, shutting down...`);
|
||||
try {
|
||||
await server.close(true);
|
||||
console.log(`Proxy worker ${id} shut down successfully`);
|
||||
} catch (error) {
|
||||
console.error(`Error during shutdown for proxy ${id}:`, error);
|
||||
}
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => void gracefulShutdown("SIGTERM"));
|
||||
process.on("SIGINT", () => void gracefulShutdown("SIGINT"));
|
||||
|
||||
// Handle uncaught exceptions
|
||||
process.on("uncaughtException", (error) => {
|
||||
console.error(`Uncaught exception in proxy worker ${id}:`, error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on("SIGINT", async () => {
|
||||
console.log(`Proxy worker ${id} received SIGINT, shutting down...`);
|
||||
await server.close(true);
|
||||
process.exit(0);
|
||||
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error(`Unhandled rejection in proxy worker ${id}:`, reason);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
|
||||
// Start the server
|
||||
try {
|
||||
await server.listen();
|
||||
|
||||
// Update the config with the actual port (in case it was auto-assigned)
|
||||
config.localPort = server.port;
|
||||
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
|
||||
}, 60000);
|
||||
} catch (error) {
|
||||
console.error(`Failed to start proxy worker ${id}:`, error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
import {
|
||||
startProxyProcess,
|
||||
stopProxyProcess,
|
||||
stopAllProxyProcesses
|
||||
} from "./proxy-runner";
|
||||
import { listProxyConfigs } from "./proxy-storage";
|
||||
|
||||
// Type definitions
|
||||
interface ProxyOptions {
|
||||
port?: number;
|
||||
ignoreProxyCertificate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a local proxy server that forwards to an upstream proxy
|
||||
* @param upstreamProxyUrl The upstream proxy URL (protocol://[username:password@]host:port)
|
||||
* @param options Optional configuration
|
||||
* @returns Promise resolving to the local proxy URL
|
||||
*/
|
||||
export async function startProxy(
|
||||
upstreamProxyUrl: string,
|
||||
options: ProxyOptions = {}
|
||||
): Promise<string> {
|
||||
const config = await startProxyProcess(upstreamProxyUrl, {
|
||||
port: options.port,
|
||||
ignoreProxyCertificate: options.ignoreProxyCertificate,
|
||||
});
|
||||
|
||||
return config.localUrl || `http://localhost:${config.localPort}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a specific proxy by its upstream URL
|
||||
* @param upstreamProxyUrl The upstream proxy URL to stop
|
||||
* @returns Promise resolving to true if proxy was found and stopped, false otherwise
|
||||
*/
|
||||
export async function stopProxy(upstreamProxyUrl: string): Promise<boolean> {
|
||||
// Find all proxies with this upstream URL
|
||||
const configs = listProxyConfigs().filter(
|
||||
config => config.upstreamUrl === upstreamProxyUrl
|
||||
);
|
||||
|
||||
if (configs.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Stop all matching proxies
|
||||
let success = true;
|
||||
for (const config of configs) {
|
||||
const stopped = await stopProxyProcess(config.id);
|
||||
if (!stopped) {
|
||||
success = false;
|
||||
}
|
||||
}
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of all active proxy upstream URLs
|
||||
* @returns Array of upstream proxy URLs
|
||||
*/
|
||||
export function getActiveProxies(): string[] {
|
||||
return listProxyConfigs().map(config => config.upstreamUrl);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all active proxies
|
||||
* @returns Promise that resolves when all proxies are stopped
|
||||
*/
|
||||
export async function stopAllProxies(): Promise<void> {
|
||||
await stopAllProxyProcesses();
|
||||
}
|
||||
+28
-32
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.3.0",
|
||||
"version": "0.7.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -11,15 +11,16 @@
|
||||
"test": "pnpm test:rust",
|
||||
"test:rust": "cd src-tauri && cargo test",
|
||||
"lint": "pnpm lint:js && pnpm lint:rust",
|
||||
"lint:js": "biome check src/ && tsc --noEmit && next lint",
|
||||
"lint:js": "biome check src/ && tsc --noEmit",
|
||||
"lint:rust": "cd src-tauri && cargo clippy --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||
"tauri": "tauri",
|
||||
"shadcn:add": "pnpm dlx shadcn@latest add",
|
||||
"prepare": "husky && husky install",
|
||||
"format:rust": "cd src-tauri && cargo clippy --fix --allow-dirty --all-targets --all-features -- -D warnings -D clippy::all && cargo fmt --all",
|
||||
"format:js": "biome check src/ --fix",
|
||||
"format:js": "biome check src/ --write --unsafe",
|
||||
"format": "pnpm format:js && pnpm format:rust",
|
||||
"cargo": "cd src-tauri && cargo",
|
||||
"unused-exports:js": "ts-unused-exports tsconfig.json",
|
||||
"check-unused-commands": "cd src-tauri && cargo run --bin check_unused_commands"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -32,51 +33,46 @@
|
||||
"@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.5.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.2.2",
|
||||
"@tauri-apps/plugin-fs": "~2.3.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.7",
|
||||
"@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/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.3",
|
||||
"next": "^15.3.5",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.5",
|
||||
"tailwind-merge": "^3.3.0"
|
||||
"sonner": "^2.0.6",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "1.9.4",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.28.0",
|
||||
"@next/eslint-plugin-next": "^15.3.3",
|
||||
"@tailwindcss/postcss": "^4.1.8",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@types/node": "^22.15.30",
|
||||
"@types/react": "^19.1.6",
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tauri-apps/cli": "^2.6.2",
|
||||
"@types/node": "^24.0.13",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@typescript-eslint/eslint-plugin": "^8.33.1",
|
||||
"@typescript-eslint/parser": "^8.33.1",
|
||||
"@vitejs/plugin-react": "^4.5.1",
|
||||
"eslint": "^9.28.0",
|
||||
"eslint-config-next": "^15.3.3",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.0",
|
||||
"tailwindcss": "^4.1.8",
|
||||
"tw-animate-css": "^1.3.4",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.33.1"
|
||||
"lint-staged": "^16.1.2",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.1",
|
||||
"lint-staged": {
|
||||
"src/**/*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
"biome check --fix",
|
||||
"eslint --cache --fix"
|
||||
"**/*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
"biome check --fix"
|
||||
],
|
||||
"src-tauri/**/*.rs": [
|
||||
"cd src-tauri && cargo fmt --all",
|
||||
|
||||
Generated
+2121
-3363
File diff suppressed because it is too large
Load Diff
+4
-4
@@ -1,9 +1,9 @@
|
||||
packages:
|
||||
- "nodecar"
|
||||
|
||||
- nodecar
|
||||
onlyBuiltDependencies:
|
||||
- "@biomejs/biome"
|
||||
- "@tailwindcss/oxide"
|
||||
- '@biomejs/biome'
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
- sharp
|
||||
- sqlite3
|
||||
- unrs-resolver
|
||||
|
||||
Generated
+447
-275
File diff suppressed because it is too large
Load Diff
+28
-3
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.3.0"
|
||||
version = "0.7.2"
|
||||
description = "Simple Yet Powerful Browser Orchestrator"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -27,25 +27,50 @@ tauri-plugin-fs = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
tauri-plugin-deep-link = "2"
|
||||
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"
|
||||
lazy_static = "1.4"
|
||||
base64 = "0.22"
|
||||
zip = "4"
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
uuid = { version = "1.0", features = ["v4", "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"
|
||||
objc2 = "0.6.1"
|
||||
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.55"
|
||||
windows = { version = "0.61", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_System_Threading",
|
||||
"Win32_System_Diagnostics_Debug",
|
||||
"Win32_System_SystemInformation",
|
||||
"Win32_Security",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Registry",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13.0"
|
||||
tokio-test = "0.4.4"
|
||||
wiremock = "0.6"
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["fs", "trace"] }
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
|
||||
+29
-19
@@ -2,47 +2,57 @@
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>Donut Browser needs camera access to enable camera functionality in web browsers. Each website will still ask for your permission individually.</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>Donut Browser needs microphone access to enable microphone functionality in web browsers. Each website will still ask for your permission individually.</string>
|
||||
<key>NSLocalNetworkUsageDescription</key>
|
||||
<string>Donut Browser has proxy functionality that requires local network access. You can deny this functionality if you don't plan on setting proxies for browser profiles.</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>Donut Browser</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>Donut Browser</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>com.donutbrowser</string>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>com.donutbrowser</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>donutbrowser</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>0.3.0</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>CFBundleSignature</key>
|
||||
<string>????</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2025 Donut Browser</string>
|
||||
<key>CFBundleDocumentTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleTypeName</key>
|
||||
<string>HTML document</string>
|
||||
<key>CFBundleTypeRole</key>
|
||||
<string>Viewer</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Default</string>
|
||||
<key>LSItemContentTypes</key>
|
||||
<array>
|
||||
<string>public.html</string>
|
||||
<string>public.xhtml</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>Web Browser</string>
|
||||
<string>Web site URL</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>http</string>
|
||||
<string>https</string>
|
||||
</array>
|
||||
<key>CFBundleURLIconFile</key>
|
||||
<string>icon.icns</string>
|
||||
<key>LSHandlerRank</key>
|
||||
<string>Owner</string>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSApplicationCategoryType</key>
|
||||
<string>public.app-category.productivity</string>
|
||||
<key>NSHumanReadableCopyright</key>
|
||||
<string>Copyright © 2025 Donut Browser</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>10.13</string>
|
||||
</dict>
|
||||
</plist>
|
||||
@@ -1,14 +1,3 @@
|
||||
function FindProxyForURL(url, host) {
|
||||
const proxyString = "{{proxy_url}}";
|
||||
|
||||
// Split the proxy string to get the credentials part
|
||||
const parts = proxyString.split(" ")[1].split("@");
|
||||
if (parts.length > 1) {
|
||||
const credentials = parts[0];
|
||||
const encodedCredentials = encodeURIComponent(credentials);
|
||||
// Replace the original credentials with encoded ones
|
||||
return proxyString.replace(credentials, encodedCredentials);
|
||||
}
|
||||
|
||||
return proxyString;
|
||||
return "{{proxy_url}}";
|
||||
}
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ fn main() {
|
||||
let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_else(|_| "0.1.0".to_string());
|
||||
println!("cargo:rustc-env=BUILD_VERSION=v{version}");
|
||||
} else if let Ok(commit_hash) = std::env::var("GITHUB_SHA") {
|
||||
// For nightly builds, use commit hash
|
||||
// For nightly builds, use timestamp format or fallback to commit hash
|
||||
let short_hash = &commit_hash[0..7.min(commit_hash.len())];
|
||||
println!("cargo:rustc-env=BUILD_VERSION=nightly-{short_hash}");
|
||||
} else {
|
||||
|
||||
@@ -19,7 +19,16 @@
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-stdin-write",
|
||||
"deep-link:default",
|
||||
"deep-link:allow-register",
|
||||
"deep-link:allow-unregister",
|
||||
"deep-link:allow-is-registered",
|
||||
"deep-link:allow-get-current",
|
||||
"dialog:default",
|
||||
"dialog:allow-open"
|
||||
"dialog:allow-open",
|
||||
"macos-permissions:default",
|
||||
"macos-permissions:allow-request-microphone-permission",
|
||||
"macos-permissions:allow-request-camera-permission",
|
||||
"macos-permissions:allow-check-microphone-permission",
|
||||
"macos-permissions:allow-check-camera-permission"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -12,5 +12,21 @@
|
||||
<true/>
|
||||
<key>com.apple.security.files.downloads.read-write</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.camera</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-output</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.microphone</key>
|
||||
<true/>
|
||||
<key>com.apple.security.device.audio-input</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-jit</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
|
||||
<true/>
|
||||
<key>com.apple.security.cs.disable-library-validation</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</plist>
|
||||
+410
-101
@@ -9,21 +9,21 @@ use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use crate::browser::GithubRelease;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct VersionComponent {
|
||||
major: u32,
|
||||
minor: u32,
|
||||
patch: u32,
|
||||
pre_release: Option<PreRelease>,
|
||||
pub struct VersionComponent {
|
||||
pub major: u32,
|
||||
pub minor: u32,
|
||||
pub patch: u32,
|
||||
pub pre_release: Option<PreRelease>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct PreRelease {
|
||||
kind: PreReleaseKind,
|
||||
number: Option<u32>,
|
||||
pub struct PreRelease {
|
||||
pub kind: PreReleaseKind,
|
||||
pub number: Option<u32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
|
||||
enum PreReleaseKind {
|
||||
pub enum PreReleaseKind {
|
||||
Alpha,
|
||||
Beta,
|
||||
RC,
|
||||
@@ -32,7 +32,7 @@ enum PreReleaseKind {
|
||||
}
|
||||
|
||||
impl VersionComponent {
|
||||
fn parse(version: &str) -> Self {
|
||||
pub fn parse(version: &str) -> Self {
|
||||
let version = version.trim();
|
||||
|
||||
// Handle special case for Zen Browser twilight releases
|
||||
@@ -229,10 +229,45 @@ pub fn is_nightly_version(version: &str) -> bool {
|
||||
version_comp.pre_release.is_some()
|
||||
}
|
||||
|
||||
// Browser-specific alpha version detection for Zen Browser
|
||||
pub fn is_zen_nightly_version(version: &str) -> bool {
|
||||
// For Zen Browser, only "twilight" is considered alpha/pre-release
|
||||
version.to_lowercase() == "twilight"
|
||||
/// Centralized function to determine if a browser version/release is nightly/prerelease
|
||||
/// This is the single source of truth for nightly detection across the entire codebase
|
||||
pub fn is_browser_version_nightly(
|
||||
browser: &str,
|
||||
version: &str,
|
||||
release_name: Option<&str>,
|
||||
) -> bool {
|
||||
match browser {
|
||||
"zen" => {
|
||||
// For Zen Browser, only "twilight" is considered nightly
|
||||
version.to_lowercase() == "twilight"
|
||||
}
|
||||
"brave" => {
|
||||
// For Brave Browser, only releases titled "Release" are stable, everything else is nightly
|
||||
if let Some(name) = release_name {
|
||||
!name.starts_with("Release")
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
"firefox" | "firefox-developer" => {
|
||||
// For Firefox, use the category from the API response to determine stability
|
||||
// This will be handled in the API parsing, so this fallback is for cached versions
|
||||
is_nightly_version(version)
|
||||
}
|
||||
"mullvad-browser" | "tor-browser" => is_nightly_version(version),
|
||||
"chromium" => {
|
||||
// Chromium builds are generally stable snapshots
|
||||
false
|
||||
}
|
||||
"camoufox" => {
|
||||
// For Camoufox, beta versions are actually the stable releases
|
||||
false
|
||||
}
|
||||
_ => {
|
||||
// Default fallback
|
||||
is_nightly_version(version)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -256,7 +291,6 @@ pub struct BrowserRelease {
|
||||
pub version: String,
|
||||
pub date: String,
|
||||
pub is_prerelease: bool,
|
||||
pub download_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
@@ -278,7 +312,6 @@ pub struct ApiClient {
|
||||
github_api_base: String,
|
||||
chromium_api_base: String,
|
||||
tor_archive_base: String,
|
||||
mozilla_download_base: String,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
@@ -291,7 +324,6 @@ impl ApiClient {
|
||||
chromium_api_base: "https://commondatastorage.googleapis.com/chromium-browser-snapshots"
|
||||
.to_string(),
|
||||
tor_archive_base: "https://archive.torproject.org/tor-package-archive/torbrowser".to_string(),
|
||||
mozilla_download_base: "https://download.mozilla.org".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +334,6 @@ impl ApiClient {
|
||||
github_api_base: String,
|
||||
chromium_api_base: String,
|
||||
tor_archive_base: String,
|
||||
mozilla_download_base: String,
|
||||
) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
@@ -311,7 +342,6 @@ impl ApiClient {
|
||||
github_api_base,
|
||||
chromium_api_base,
|
||||
tor_archive_base,
|
||||
mozilla_download_base,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -449,11 +479,7 @@ impl ApiClient {
|
||||
BrowserRelease {
|
||||
version: version.clone(),
|
||||
date: "".to_string(), // Cache doesn't store dates
|
||||
is_prerelease: is_nightly_version(&version),
|
||||
download_url: Some(format!(
|
||||
"{}/?product=firefox-{}&os=osx&lang=en-US",
|
||||
self.mozilla_download_base, version
|
||||
)),
|
||||
is_prerelease: is_browser_version_nightly("firefox", &version, None),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
@@ -489,10 +515,6 @@ impl ApiClient {
|
||||
version: release.version.clone(),
|
||||
date: release.date,
|
||||
is_prerelease: !is_stable,
|
||||
download_url: Some(format!(
|
||||
"{}/?product=firefox-{}&os=osx&lang=en-US",
|
||||
self.mozilla_download_base, release.version
|
||||
)),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -534,11 +556,7 @@ impl ApiClient {
|
||||
BrowserRelease {
|
||||
version: version.clone(),
|
||||
date: "".to_string(), // Cache doesn't store dates
|
||||
is_prerelease: is_nightly_version(&version),
|
||||
download_url: Some(format!(
|
||||
"{}/?product=devedition-{}&os=osx&lang=en-US",
|
||||
self.mozilla_download_base, version
|
||||
)),
|
||||
is_prerelease: is_browser_version_nightly("firefox-developer", &version, None),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
@@ -580,10 +598,6 @@ impl ApiClient {
|
||||
version: release.version.clone(),
|
||||
date: release.date,
|
||||
is_prerelease: !is_stable,
|
||||
download_url: Some(format!(
|
||||
"{}/?product=devedition-{}&os=osx&lang=en-US",
|
||||
self.mozilla_download_base, release.version
|
||||
)),
|
||||
})
|
||||
} else {
|
||||
None
|
||||
@@ -627,15 +641,39 @@ impl ApiClient {
|
||||
"{}/repos/mullvad/mullvad-browser/releases?per_page=100",
|
||||
self.github_api_base
|
||||
);
|
||||
let releases = self
|
||||
|
||||
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?
|
||||
.json::<Vec<GithubRelease>>()
|
||||
.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 mut releases: Vec<GithubRelease> = releases
|
||||
.into_iter()
|
||||
.map(|mut release| {
|
||||
@@ -673,19 +711,44 @@ impl ApiClient {
|
||||
"{}/repos/zen-browser/desktop/releases?per_page=100",
|
||||
self.github_api_base
|
||||
);
|
||||
let mut releases = self
|
||||
|
||||
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?
|
||||
.json::<Vec<GithubRelease>>()
|
||||
.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());
|
||||
}
|
||||
};
|
||||
|
||||
// Check for twilight updates and mark alpha releases
|
||||
for release in &mut releases {
|
||||
// Use browser-specific alpha detection for Zen Browser - only "twilight" is nightly
|
||||
release.is_nightly = is_zen_nightly_version(&release.tag_name);
|
||||
release.is_nightly =
|
||||
is_browser_version_nightly("zen", &release.tag_name, Some(&release.name));
|
||||
|
||||
// Check for twilight update if this is a twilight release
|
||||
if release.tag_name.to_lowercase() == "twilight" {
|
||||
@@ -729,15 +792,39 @@ impl ApiClient {
|
||||
"{}/repos/brave/brave-browser/releases?per_page=100",
|
||||
self.github_api_base
|
||||
);
|
||||
let releases = self
|
||||
|
||||
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?
|
||||
.json::<Vec<GithubRelease>>()
|
||||
.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());
|
||||
}
|
||||
};
|
||||
|
||||
// Get platform info to filter appropriate releases
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
|
||||
@@ -749,9 +836,9 @@ impl ApiClient {
|
||||
let has_compatible_asset = Self::has_compatible_brave_asset(&release.assets, &os, &arch);
|
||||
|
||||
if has_compatible_asset {
|
||||
// Set is_nightly based on the release name
|
||||
// Stable releases start with "Release", everything else is nightly
|
||||
release.is_nightly = !release.name.starts_with("Release");
|
||||
// Use the centralized nightly detection function
|
||||
release.is_nightly =
|
||||
is_browser_version_nightly("brave", &release.tag_name, Some(&release.name));
|
||||
Some(release)
|
||||
} else {
|
||||
None
|
||||
@@ -773,6 +860,31 @@ impl ApiClient {
|
||||
}
|
||||
|
||||
/// Check if a Brave release has compatible assets for the given platform and architecture
|
||||
fn has_compatible_camoufox_asset(
|
||||
&self,
|
||||
assets: &[crate::browser::GithubAsset],
|
||||
os: &str,
|
||||
arch: &str,
|
||||
) -> bool {
|
||||
let (os_name, arch_name) = match (os, arch) {
|
||||
("windows", "x64") => ("win", "x86_64"),
|
||||
("windows", "arm64") => ("win", "arm64"),
|
||||
("linux", "x64") => ("lin", "x86_64"),
|
||||
("linux", "arm64") => ("lin", "arm64"),
|
||||
("macos", "x64") => ("mac", "x86_64"),
|
||||
("macos", "arm64") => ("mac", "arm64"),
|
||||
_ => return false,
|
||||
};
|
||||
|
||||
// Look for assets matching the pattern: camoufox-{version}-{release}-{os}.{arch}.zip
|
||||
assets.iter().any(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.starts_with("camoufox-")
|
||||
&& name.contains(&format!("-{os_name}.{arch_name}.zip"))
|
||||
&& name.ends_with(".zip")
|
||||
})
|
||||
}
|
||||
|
||||
fn has_compatible_brave_asset(
|
||||
assets: &[crate::browser::GithubAsset],
|
||||
os: &str,
|
||||
@@ -794,22 +906,17 @@ impl ApiClient {
|
||||
}) || assets.iter().any(|asset| asset.name.ends_with(".dmg"))
|
||||
}
|
||||
"linux" => {
|
||||
// For Linux, check for architecture-specific packages (prefer ZIP for stable releases)
|
||||
// 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" };
|
||||
|
||||
assets.iter().any(|asset| {
|
||||
if assets.iter().any(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
|
||||
}) || assets.iter().any(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains(arch_pattern) && (name.ends_with(".deb") || name.ends_with(".rpm"))
|
||||
}) || assets.iter().any(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.ends_with(".zip")
|
||||
}) || assets.iter().any(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.ends_with(".deb") || name.ends_with(".rpm")
|
||||
})
|
||||
}) {
|
||||
return true;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
@@ -877,7 +984,6 @@ impl ApiClient {
|
||||
version: version.clone(),
|
||||
date: "".to_string(), // Cache doesn't store dates
|
||||
is_prerelease: false, // Chromium versions are generally stable builds
|
||||
download_url: None,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
@@ -914,12 +1020,133 @@ impl ApiClient {
|
||||
version: version.clone(),
|
||||
date: "".to_string(),
|
||||
is_prerelease: false,
|
||||
download_url: None,
|
||||
})
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn fetch_camoufox_releases_with_caching(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Check cache first (unless bypassing)
|
||||
if !no_caching {
|
||||
if let Some(cached_releases) = self.load_cached_github_releases("camoufox") {
|
||||
println!(
|
||||
"Using cached Camoufox releases, count: {}",
|
||||
cached_releases.len()
|
||||
);
|
||||
return Ok(cached_releases);
|
||||
}
|
||||
}
|
||||
|
||||
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!(
|
||||
"Fetched {} total Camoufox releases from GitHub",
|
||||
releases.len()
|
||||
);
|
||||
|
||||
// Get platform info to filter appropriate releases
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
println!("Filtering for platform: {os}/{arch}");
|
||||
|
||||
// Filter releases that have assets compatible with the current platform
|
||||
let mut compatible_releases: Vec<GithubRelease> = releases
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter_map(|(i, release)| {
|
||||
let has_compatible = self.has_compatible_camoufox_asset(&release.assets, &os, &arch);
|
||||
if !has_compatible {
|
||||
println!(
|
||||
"Release {} ({}) has no compatible assets for {}/{}",
|
||||
i, release.tag_name, os, arch
|
||||
);
|
||||
println!(
|
||||
" Available assets: {:?}",
|
||||
release.assets.iter().map(|a| &a.name).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
if has_compatible {
|
||||
Some(release)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
println!(
|
||||
"After platform filtering: {} compatible releases",
|
||||
compatible_releases.len()
|
||||
);
|
||||
|
||||
// Sort by version (latest first) with debugging
|
||||
println!(
|
||||
"Before sorting: {:?}",
|
||||
compatible_releases
|
||||
.iter()
|
||||
.map(|r| &r.tag_name)
|
||||
.take(10)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
sort_github_releases(&mut compatible_releases);
|
||||
println!(
|
||||
"After sorting: {:?}",
|
||||
compatible_releases
|
||||
.iter()
|
||||
.map(|r| &r.tag_name)
|
||||
.take(10)
|
||||
.collect::<Vec<_>>()
|
||||
);
|
||||
|
||||
// Cache the results (unless bypassing cache)
|
||||
if !no_caching {
|
||||
if let Err(e) = self.save_cached_github_releases("camoufox", &compatible_releases) {
|
||||
eprintln!("Failed to cache Camoufox releases: {e}");
|
||||
} else {
|
||||
println!("Cached {} Camoufox releases", compatible_releases.len());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(compatible_releases)
|
||||
}
|
||||
|
||||
pub async fn fetch_tor_releases_with_caching(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
@@ -934,11 +1161,7 @@ impl ApiClient {
|
||||
BrowserRelease {
|
||||
version: version.clone(),
|
||||
date: "".to_string(), // Cache doesn't store dates
|
||||
is_prerelease: is_nightly_version(&version),
|
||||
download_url: Some(format!(
|
||||
"{}/{version}/tor-browser-macos-{version}.dmg",
|
||||
self.tor_archive_base
|
||||
)),
|
||||
is_prerelease: is_browser_version_nightly("tor-browser", &version, None),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
@@ -1013,10 +1236,6 @@ impl ApiClient {
|
||||
version: version.clone(),
|
||||
date: "".to_string(), // TOR archive doesn't provide structured dates
|
||||
is_prerelease: false, // Assume all archived versions are stable
|
||||
download_url: Some(format!(
|
||||
"{}/{version}/tor-browser-macos-{version}.dmg",
|
||||
self.tor_archive_base
|
||||
)),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
@@ -1065,13 +1284,11 @@ impl ApiClient {
|
||||
struct TwilightInfo {
|
||||
file_size: u64,
|
||||
last_updated: u64,
|
||||
download_url: String,
|
||||
}
|
||||
|
||||
let current_info = TwilightInfo {
|
||||
file_size: asset.size,
|
||||
last_updated: Self::get_current_timestamp(),
|
||||
download_url: asset.browser_download_url.clone(),
|
||||
};
|
||||
|
||||
if !twilight_cache_file.exists() {
|
||||
@@ -1137,7 +1354,6 @@ mod tests {
|
||||
base_url.clone(), // github_api_base
|
||||
base_url.clone(), // chromium_api_base
|
||||
base_url.clone(), // tor_archive_base
|
||||
base_url.clone(), // mozilla_download_base
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1317,12 +1533,6 @@ mod tests {
|
||||
let releases = result.unwrap();
|
||||
assert!(!releases.is_empty());
|
||||
assert_eq!(releases[0].version, "139.0");
|
||||
assert!(releases[0].download_url.is_some());
|
||||
assert!(releases[0]
|
||||
.download_url
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains(&server.uri()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1365,12 +1575,6 @@ mod tests {
|
||||
let releases = result.unwrap();
|
||||
assert!(!releases.is_empty());
|
||||
assert_eq!(releases[0].version, "140.0b1");
|
||||
assert!(releases[0].download_url.is_some());
|
||||
assert!(releases[0]
|
||||
.download_url
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains(&server.uri()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1462,7 +1666,7 @@ mod tests {
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.9",
|
||||
"name": "Brave Release 1.81.9",
|
||||
"name": "Release v1.81.9 (Chromium 137.0.7151.104)",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
@@ -1495,7 +1699,7 @@ mod tests {
|
||||
let releases = result.unwrap();
|
||||
assert!(!releases.is_empty());
|
||||
assert_eq!(releases[0].tag_name, "v1.81.9");
|
||||
assert!(releases[0].is_nightly);
|
||||
assert!(!releases[0].is_nightly); // "Release v1.81.9 (Chromium 137.0.7151.104)" starts with "Release" so it should be stable
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1615,12 +1819,6 @@ mod tests {
|
||||
let releases = result.unwrap();
|
||||
assert!(!releases.is_empty());
|
||||
assert_eq!(releases[0].version, "14.0.4");
|
||||
assert!(releases[0].download_url.is_some());
|
||||
assert!(releases[0]
|
||||
.download_url
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.contains(&server.uri()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1693,13 +1891,13 @@ mod tests {
|
||||
#[test]
|
||||
fn test_is_zen_nightly_version() {
|
||||
// Only "twilight" should be considered nightly for Zen Browser
|
||||
assert!(is_zen_nightly_version("twilight"));
|
||||
assert!(is_zen_nightly_version("TWILIGHT")); // Case insensitive
|
||||
assert!(is_browser_version_nightly("zen", "twilight", None));
|
||||
assert!(is_browser_version_nightly("zen", "TWILIGHT", None)); // Case insensitive
|
||||
|
||||
// Versions with "b" should NOT be considered nightly for Zen Browser
|
||||
assert!(!is_zen_nightly_version("1.12.8b"));
|
||||
assert!(!is_zen_nightly_version("1.0.0b1"));
|
||||
assert!(!is_zen_nightly_version("2.0.0"));
|
||||
assert!(!is_browser_version_nightly("zen", "1.12.8b", None));
|
||||
assert!(!is_browser_version_nightly("zen", "1.0.0b1", None));
|
||||
assert!(!is_browser_version_nightly("zen", "2.0.0", None));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1751,4 +1949,115 @@ mod tests {
|
||||
let result = client.fetch_zen_releases_with_caching(true).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_camoufox_beta_version_parsing() {
|
||||
// Test specific Camoufox beta versions that are causing issues
|
||||
let v22 = VersionComponent::parse("135.0.5beta22");
|
||||
let v24 = VersionComponent::parse("135.0.5beta24");
|
||||
|
||||
println!("v22: {v22:?}");
|
||||
println!("v24: {v24:?}");
|
||||
|
||||
// v24 should be greater than v22
|
||||
assert!(
|
||||
v24 > v22,
|
||||
"135.0.5beta24 should be greater than 135.0.5beta22"
|
||||
);
|
||||
|
||||
// Test other beta version combinations
|
||||
let v1 = VersionComponent::parse("135.0.5beta1");
|
||||
let v2 = VersionComponent::parse("135.0.5beta2");
|
||||
assert!(v2 > v1, "135.0.5beta2 should be greater than 135.0.5beta1");
|
||||
|
||||
// Test sorting of multiple versions
|
||||
let mut versions = vec![
|
||||
"135.0.5beta22".to_string(),
|
||||
"135.0.5beta24".to_string(),
|
||||
"135.0.5beta23".to_string(),
|
||||
"135.0.5beta21".to_string(),
|
||||
];
|
||||
|
||||
sort_versions(&mut versions);
|
||||
|
||||
println!("Sorted versions: {versions:?}");
|
||||
|
||||
// Should be sorted from newest to oldest
|
||||
assert_eq!(versions[0], "135.0.5beta24");
|
||||
assert_eq!(versions[1], "135.0.5beta23");
|
||||
assert_eq!(versions[2], "135.0.5beta22");
|
||||
assert_eq!(versions[3], "135.0.5beta21");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_camoufox_user_reported_versions() {
|
||||
// Test the exact versions reported by the user: 135.0.1beta24 vs 135.0beta22
|
||||
let v22 = VersionComponent::parse("135.0beta22");
|
||||
let v24 = VersionComponent::parse("135.0.1beta24");
|
||||
|
||||
println!("User reported v22: {v22:?}");
|
||||
println!("User reported v24: {v24:?}");
|
||||
|
||||
// 135.0.1beta24 should be greater than 135.0beta22 (newer patch version)
|
||||
assert!(
|
||||
v24 > v22,
|
||||
"135.0.1beta24 should be greater than 135.0beta22, but got: v24={v24:?} vs v22={v22:?}"
|
||||
);
|
||||
|
||||
// Test sorting of the exact user-reported versions
|
||||
let mut versions = vec!["135.0beta22".to_string(), "135.0.1beta24".to_string()];
|
||||
|
||||
sort_versions(&mut versions);
|
||||
|
||||
println!("User reported sorted versions: {versions:?}");
|
||||
|
||||
// Should be sorted from newest to oldest
|
||||
assert_eq!(
|
||||
versions[0], "135.0.1beta24",
|
||||
"135.0.1beta24 should be first (newest)"
|
||||
);
|
||||
assert_eq!(
|
||||
versions[1], "135.0beta22",
|
||||
"135.0beta22 should be second (older)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_camoufox_version_classification() {
|
||||
// Test that Camoufox beta versions are now correctly classified as stable (not nightly)
|
||||
assert!(
|
||||
!is_browser_version_nightly("camoufox", "135.0beta22", None),
|
||||
"135.0beta22 should be classified as stable for Camoufox"
|
||||
);
|
||||
assert!(
|
||||
!is_browser_version_nightly("camoufox", "135.0.1beta24", None),
|
||||
"135.0.1beta24 should be classified as stable for Camoufox"
|
||||
);
|
||||
|
||||
// Test with release names too - beta releases should be stable
|
||||
assert!(
|
||||
!is_browser_version_nightly("camoufox", "135.0beta22", Some("Release Beta 22")),
|
||||
"Release with 'Beta' in name should be classified as stable for Camoufox"
|
||||
);
|
||||
|
||||
// Test that stable versions are not classified as nightly
|
||||
assert!(
|
||||
!is_browser_version_nightly("camoufox", "135.0", None),
|
||||
"135.0 should be classified as stable"
|
||||
);
|
||||
assert!(
|
||||
!is_browser_version_nightly("camoufox", "135.0.1", None),
|
||||
"135.0.1 should be classified as stable"
|
||||
);
|
||||
|
||||
// Test alpha and RC versions are still considered nightly
|
||||
assert!(
|
||||
!is_browser_version_nightly("camoufox", "136.0alpha1", None),
|
||||
"136.0alpha1 should not be classified as nightly/prerelease"
|
||||
);
|
||||
assert!(
|
||||
!is_browser_version_nightly("camoufox", "136.0rc1", None),
|
||||
"136.0rc1 should not be classified as nightly/prerelease"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,15 @@ pub struct AppUpdateInfo {
|
||||
pub published_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct AppUpdateProgress {
|
||||
pub stage: String, // "downloading", "extracting", "installing", "completed"
|
||||
pub percentage: Option<f64>,
|
||||
pub speed: Option<String>, // MB/s
|
||||
pub eta: Option<String>, // estimated time remaining
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub struct AppAutoUpdater {
|
||||
client: Client,
|
||||
}
|
||||
@@ -98,9 +107,7 @@ impl AppAutoUpdater {
|
||||
// For stable builds, look for stable releases (semver format)
|
||||
let stable_releases: Vec<&AppRelease> = releases
|
||||
.iter()
|
||||
.filter(|release| {
|
||||
release.tag_name.starts_with('v') && !release.tag_name.starts_with("nightly-")
|
||||
})
|
||||
.filter(|release| release.tag_name.starts_with('v'))
|
||||
.collect();
|
||||
println!("Found {} stable releases", stable_releases.len());
|
||||
stable_releases
|
||||
@@ -152,7 +159,7 @@ impl AppAutoUpdater {
|
||||
async fn fetch_app_releases(
|
||||
&self,
|
||||
) -> Result<Vec<AppRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = "https://api.github.com/repos/zhom/donutbrowser/releases";
|
||||
let url = "https://api.github.com/repos/zhom/donutbrowser/releases?per_page=100";
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
@@ -311,21 +318,48 @@ impl AppAutoUpdater {
|
||||
.to_string();
|
||||
|
||||
// Emit download start event
|
||||
let _ = app_handle.emit("app-update-progress", "Downloading update...");
|
||||
let _ = app_handle.emit(
|
||||
"app-update-progress",
|
||||
AppUpdateProgress {
|
||||
stage: "downloading".to_string(),
|
||||
percentage: Some(0.0),
|
||||
speed: None,
|
||||
eta: None,
|
||||
message: "Starting download...".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Download the update
|
||||
// Download the update with progress tracking
|
||||
let download_path = self
|
||||
.download_update(&update_info.download_url, &temp_dir, &filename)
|
||||
.download_update_with_progress(&update_info.download_url, &temp_dir, &filename, app_handle)
|
||||
.await?;
|
||||
|
||||
// Emit extraction start event
|
||||
let _ = app_handle.emit("app-update-progress", "Preparing update...");
|
||||
let _ = app_handle.emit(
|
||||
"app-update-progress",
|
||||
AppUpdateProgress {
|
||||
stage: "extracting".to_string(),
|
||||
percentage: None,
|
||||
speed: None,
|
||||
eta: None,
|
||||
message: "Preparing update...".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Extract the update
|
||||
let extracted_app_path = self.extract_update(&download_path, &temp_dir).await?;
|
||||
|
||||
// Emit installation start event
|
||||
let _ = app_handle.emit("app-update-progress", "Installing update...");
|
||||
let _ = app_handle.emit(
|
||||
"app-update-progress",
|
||||
AppUpdateProgress {
|
||||
stage: "installing".to_string(),
|
||||
percentage: None,
|
||||
speed: None,
|
||||
eta: None,
|
||||
message: "Installing update...".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Install the update (overwrite current app)
|
||||
self.install_update(&extracted_app_path).await?;
|
||||
@@ -334,7 +368,16 @@ impl AppAutoUpdater {
|
||||
let _ = fs::remove_dir_all(&temp_dir);
|
||||
|
||||
// Emit completion event
|
||||
let _ = app_handle.emit("app-update-progress", "Update completed. Restarting...");
|
||||
let _ = app_handle.emit(
|
||||
"app-update-progress",
|
||||
AppUpdateProgress {
|
||||
stage: "completed".to_string(),
|
||||
percentage: Some(100.0),
|
||||
speed: None,
|
||||
eta: None,
|
||||
message: "Update completed. Restarting...".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Restart the application
|
||||
self.restart_application().await?;
|
||||
@@ -342,12 +385,13 @@ impl AppAutoUpdater {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Download the update file
|
||||
async fn download_update(
|
||||
/// Download the update file with progress tracking
|
||||
async fn download_update_with_progress(
|
||||
&self,
|
||||
download_url: &str,
|
||||
dest_dir: &Path,
|
||||
filename: &str,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let file_path = dest_dir.join(filename);
|
||||
|
||||
@@ -362,15 +406,75 @@ impl AppAutoUpdater {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
let mut file = fs::File::create(&file_path)?;
|
||||
let mut stream = response.bytes_stream();
|
||||
let mut downloaded = 0u64;
|
||||
let start_time = std::time::Instant::now();
|
||||
let mut last_update = std::time::Instant::now();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk?;
|
||||
file.write_all(&chunk)?;
|
||||
downloaded += chunk.len() as u64;
|
||||
|
||||
// Update progress every 100ms to avoid overwhelming the UI
|
||||
if last_update.elapsed().as_millis() > 100 {
|
||||
let elapsed = start_time.elapsed().as_secs_f64();
|
||||
let percentage = if total_size > 0 {
|
||||
(downloaded as f64 / total_size as f64) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let speed = if elapsed > 0.0 {
|
||||
downloaded as f64 / elapsed / 1024.0 / 1024.0 // MB/s
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let eta = if total_size > 0 && speed > 0.0 {
|
||||
let remaining_bytes = total_size - downloaded;
|
||||
let remaining_seconds = (remaining_bytes as f64 / 1024.0 / 1024.0) / speed;
|
||||
if remaining_seconds < 60.0 {
|
||||
format!("{}s", remaining_seconds as u32)
|
||||
} else {
|
||||
let minutes = remaining_seconds as u32 / 60;
|
||||
let seconds = remaining_seconds as u32 % 60;
|
||||
format!("{minutes}m {seconds}s")
|
||||
}
|
||||
} else {
|
||||
"Unknown".to_string()
|
||||
};
|
||||
|
||||
let _ = app_handle.emit(
|
||||
"app-update-progress",
|
||||
AppUpdateProgress {
|
||||
stage: "downloading".to_string(),
|
||||
percentage: Some(percentage),
|
||||
speed: Some(format!("{speed:.1}")),
|
||||
eta: Some(eta),
|
||||
message: "Downloading update...".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
last_update = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
|
||||
// Emit final download completion
|
||||
let _ = app_handle.emit(
|
||||
"app-update-progress",
|
||||
AppUpdateProgress {
|
||||
stage: "downloading".to_string(),
|
||||
percentage: Some(100.0),
|
||||
speed: None,
|
||||
eta: None,
|
||||
message: "Download completed".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
@@ -398,6 +502,30 @@ impl AppAutoUpdater {
|
||||
Err("DMG extraction is only supported on macOS".into())
|
||||
}
|
||||
}
|
||||
"msi" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// For MSI files on Windows, we need to run the installer
|
||||
// MSI files can't be extracted like archives, they need to be executed
|
||||
// Return the path to the MSI file itself for installation
|
||||
Ok(archive_path.to_path_buf())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Err("MSI installation is only supported on Windows".into())
|
||||
}
|
||||
}
|
||||
"exe" => {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// For exe installers on Windows, return the path for execution
|
||||
Ok(archive_path.to_path_buf())
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
Err("EXE installation is only supported on Windows".into())
|
||||
}
|
||||
}
|
||||
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
|
||||
_ => Err(format!("Unsupported archive format: {extension}").into()),
|
||||
}
|
||||
@@ -406,71 +534,282 @@ impl AppAutoUpdater {
|
||||
/// Install the update by replacing the current app
|
||||
async fn install_update(
|
||||
&self,
|
||||
new_app_path: &Path,
|
||||
installer_path: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get the current application bundle path
|
||||
let current_app_path = self.get_current_app_path()?;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Get the current application bundle path
|
||||
let current_app_path = self.get_current_app_path()?;
|
||||
|
||||
// Create a backup of the current app
|
||||
let backup_path = current_app_path.with_extension("app.backup");
|
||||
if backup_path.exists() {
|
||||
fs::remove_dir_all(&backup_path)?;
|
||||
// Create a backup of the current app
|
||||
let backup_path = current_app_path.with_extension("app.backup");
|
||||
if backup_path.exists() {
|
||||
fs::remove_dir_all(&backup_path)?;
|
||||
}
|
||||
|
||||
// Move current app to backup
|
||||
fs::rename(¤t_app_path, &backup_path)?;
|
||||
|
||||
// Move new app to current location
|
||||
fs::rename(installer_path, ¤t_app_path)?;
|
||||
|
||||
// Remove quarantine attributes from the new app
|
||||
let _ = Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
current_app_path.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-cr", current_app_path.to_str().unwrap()])
|
||||
.output();
|
||||
|
||||
// Clean up backup after successful installation
|
||||
let _ = fs::remove_dir_all(&backup_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Move current app to backup
|
||||
fs::rename(¤t_app_path, &backup_path)?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let extension = installer_path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
// Move new app to current location
|
||||
fs::rename(new_app_path, ¤t_app_path)?;
|
||||
println!("Installing Windows update with extension: {extension}");
|
||||
|
||||
// Remove quarantine attributes from the new app
|
||||
let _ = Command::new("xattr")
|
||||
.args([
|
||||
"-dr",
|
||||
"com.apple.quarantine",
|
||||
current_app_path.to_str().unwrap(),
|
||||
])
|
||||
.output();
|
||||
match extension {
|
||||
"msi" => {
|
||||
// Install MSI silently with enhanced error handling
|
||||
println!("Running MSI installer: {}", installer_path.display());
|
||||
|
||||
let _ = Command::new("xattr")
|
||||
.args(["-cr", current_app_path.to_str().unwrap()])
|
||||
.output();
|
||||
let mut cmd = Command::new("msiexec");
|
||||
cmd.args([
|
||||
"/i",
|
||||
installer_path.to_str().unwrap(),
|
||||
"/quiet",
|
||||
"/norestart",
|
||||
"REBOOT=ReallySuppress",
|
||||
"/l*v", // Enable verbose logging
|
||||
&format!("{}.log", installer_path.to_str().unwrap()),
|
||||
]);
|
||||
|
||||
// Clean up backup after successful installation
|
||||
let _ = fs::remove_dir_all(&backup_path);
|
||||
let output = cmd.output()?;
|
||||
|
||||
Ok(())
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
let exit_code = output.status.code().unwrap_or(-1);
|
||||
|
||||
// Try to read the log file for more details
|
||||
let log_path = format!("{}.log", installer_path.to_str().unwrap());
|
||||
let log_content = fs::read_to_string(&log_path).unwrap_or_default();
|
||||
|
||||
println!("MSI installation failed with exit code: {exit_code}");
|
||||
println!("Error output: {error_msg}");
|
||||
if !log_content.is_empty() {
|
||||
println!(
|
||||
"Log file content (last 500 chars): {}",
|
||||
&log_content
|
||||
.chars()
|
||||
.rev()
|
||||
.take(500)
|
||||
.collect::<String>()
|
||||
.chars()
|
||||
.rev()
|
||||
.collect::<String>()
|
||||
);
|
||||
}
|
||||
|
||||
return Err(
|
||||
format!("MSI installation failed (exit code {exit_code}): {error_msg}").into(),
|
||||
);
|
||||
}
|
||||
|
||||
println!("MSI installation completed successfully");
|
||||
}
|
||||
"exe" => {
|
||||
// Run exe installer silently with multiple fallback options
|
||||
println!("Running EXE installer: {}", installer_path.display());
|
||||
|
||||
// Try NSIS silent flag first (most common for Tauri)
|
||||
let mut success = false;
|
||||
let mut last_error = String::new();
|
||||
|
||||
// NSIS installer flags (used by Tauri)
|
||||
let nsis_args = vec![
|
||||
vec!["/S"], // Standard NSIS silent flag
|
||||
vec!["/VERYSILENT", "/SUPPRESSMSGBOXES", "/NORESTART"], // Inno Setup flags
|
||||
vec!["/quiet"], // Generic quiet flag
|
||||
vec!["/silent"], // Alternative silent flag
|
||||
];
|
||||
|
||||
for args in nsis_args {
|
||||
println!("Trying installer with args: {:?}", args);
|
||||
let output = Command::new(installer_path).args(&args).output();
|
||||
|
||||
match output {
|
||||
Ok(output) if output.status.success() => {
|
||||
println!(
|
||||
"EXE installation completed successfully with args: {:?}",
|
||||
args
|
||||
);
|
||||
success = true;
|
||||
break;
|
||||
}
|
||||
Ok(output) => {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
last_error = format!(
|
||||
"Exit code {}: {}",
|
||||
output.status.code().unwrap_or(-1),
|
||||
error_msg
|
||||
);
|
||||
println!("Installer failed with args {:?}: {}", args, last_error);
|
||||
}
|
||||
Err(e) => {
|
||||
last_error = format!("Failed to execute installer: {e}");
|
||||
println!(
|
||||
"Failed to execute installer with args {:?}: {}",
|
||||
args, last_error
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !success {
|
||||
return Err(
|
||||
format!(
|
||||
"EXE installation failed after trying multiple methods. Last error: {last_error}"
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
"zip" => {
|
||||
// Handle ZIP files by extracting and replacing the current executable
|
||||
println!("Handling ZIP update: {}", installer_path.display());
|
||||
|
||||
let temp_extract_dir = installer_path.parent().unwrap().join("extracted");
|
||||
fs::create_dir_all(&temp_extract_dir)?;
|
||||
|
||||
// Extract ZIP file
|
||||
let extractor = crate::extraction::Extractor::new();
|
||||
let extracted_path = extractor
|
||||
.extract_zip(installer_path, &temp_extract_dir)
|
||||
.await?;
|
||||
|
||||
// Find the executable in the extracted files
|
||||
let current_exe = self.get_current_app_path()?;
|
||||
let current_exe_name = current_exe.file_name().unwrap();
|
||||
|
||||
// Look for the new executable
|
||||
let new_exe_path =
|
||||
if extracted_path.is_file() && extracted_path.file_name() == Some(current_exe_name) {
|
||||
extracted_path
|
||||
} else {
|
||||
// Search in extracted directory
|
||||
let mut found_exe = None;
|
||||
if let Ok(entries) = fs::read_dir(&extracted_path) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.file_name() == Some(current_exe_name) {
|
||||
found_exe = Some(path);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
found_exe.ok_or("Could not find executable in ZIP file")?
|
||||
};
|
||||
|
||||
// Create backup of current executable
|
||||
let backup_path = current_exe.with_extension("exe.backup");
|
||||
if backup_path.exists() {
|
||||
fs::remove_file(&backup_path)?;
|
||||
}
|
||||
fs::copy(¤t_exe, &backup_path)?;
|
||||
|
||||
// Replace current executable
|
||||
fs::copy(&new_exe_path, ¤t_exe)?;
|
||||
|
||||
// Clean up
|
||||
let _ = fs::remove_dir_all(&temp_extract_dir);
|
||||
|
||||
println!("ZIP update completed successfully");
|
||||
}
|
||||
_ => {
|
||||
return Err(format!("Unsupported installer format: {extension}").into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// For Linux, we would handle different package formats here
|
||||
// This implementation would depend on the specific package type
|
||||
Err("Linux auto-update installation not yet implemented".into())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
Err("Auto-update installation not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the current application bundle path
|
||||
fn get_current_app_path(&self) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get the current executable path
|
||||
let exe_path = std::env::current_exe()?;
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Get the current executable path
|
||||
let exe_path = std::env::current_exe()?;
|
||||
|
||||
// Navigate up to find the .app bundle
|
||||
let mut current = exe_path.as_path();
|
||||
while let Some(parent) = current.parent() {
|
||||
if parent.extension().is_some_and(|ext| ext == "app") {
|
||||
return Ok(parent.to_path_buf());
|
||||
// Navigate up to find the .app bundle
|
||||
let mut current = exe_path.as_path();
|
||||
while let Some(parent) = current.parent() {
|
||||
if parent.extension().is_some_and(|ext| ext == "app") {
|
||||
return Ok(parent.to_path_buf());
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
current = parent;
|
||||
|
||||
Err("Could not find application bundle".into())
|
||||
}
|
||||
|
||||
Err("Could not find application bundle".into())
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// On Windows, just return the current executable path
|
||||
std::env::current_exe().map_err(|e| e.into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// On Linux, return the current executable path
|
||||
std::env::current_exe().map_err(|e| e.into())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
Err("Platform not supported".into())
|
||||
}
|
||||
}
|
||||
|
||||
/// Restart the application
|
||||
async fn restart_application(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let app_path = self.get_current_app_path()?;
|
||||
let current_pid = std::process::id();
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let app_path = self.get_current_app_path()?;
|
||||
let current_pid = std::process::id();
|
||||
|
||||
// Create a temporary restart script
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_path = temp_dir.join("donut_restart.sh");
|
||||
// Create a temporary restart script
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_path = temp_dir.join("donut_restart.sh");
|
||||
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
# Wait for the current process to exit
|
||||
while kill -0 {} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
@@ -485,37 +824,146 @@ open "{}"
|
||||
# Clean up this script
|
||||
rm "{}"
|
||||
"#,
|
||||
current_pid,
|
||||
app_path.to_str().unwrap(),
|
||||
script_path.to_str().unwrap()
|
||||
);
|
||||
current_pid,
|
||||
app_path.to_str().unwrap(),
|
||||
script_path.to_str().unwrap()
|
||||
);
|
||||
|
||||
// Write the script to file
|
||||
fs::write(&script_path, script_content)?;
|
||||
// Write the script to file
|
||||
fs::write(&script_path, script_content)?;
|
||||
|
||||
// Make the script executable
|
||||
let _ = Command::new("chmod")
|
||||
.args(["+x", script_path.to_str().unwrap()])
|
||||
.output();
|
||||
// Make the script executable
|
||||
let _ = Command::new("chmod")
|
||||
.args(["+x", script_path.to_str().unwrap()])
|
||||
.output();
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(script_path.to_str().unwrap());
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(script_path.to_str().unwrap());
|
||||
|
||||
// Detach the process completely
|
||||
#[cfg(unix)]
|
||||
{
|
||||
// Detach the process completely
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
|
||||
let _child = cmd.spawn()?;
|
||||
|
||||
// Give the script a moment to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Exit the current process
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
let _child = cmd.spawn()?;
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let app_path = self.get_current_app_path()?;
|
||||
let current_pid = std::process::id();
|
||||
|
||||
// Give the script a moment to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
// Create a temporary restart batch script
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_path = temp_dir.join("donut_restart.bat");
|
||||
|
||||
// Exit the current process
|
||||
std::process::exit(0);
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"@echo off
|
||||
rem Wait for the current process to exit
|
||||
:wait_loop
|
||||
tasklist /fi "PID eq {}" >nul 2>&1
|
||||
if %errorlevel% equ 0 (
|
||||
timeout /t 1 /nobreak >nul
|
||||
goto wait_loop
|
||||
)
|
||||
|
||||
rem Wait a bit more to ensure clean exit
|
||||
timeout /t 2 /nobreak >nul
|
||||
|
||||
rem Start the new application
|
||||
start "" "{}"
|
||||
|
||||
rem Clean up this script
|
||||
del "%~f0"
|
||||
"#,
|
||||
current_pid,
|
||||
app_path.to_str().unwrap()
|
||||
);
|
||||
|
||||
// Write the script to file
|
||||
fs::write(&script_path, script_content)?;
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("cmd");
|
||||
cmd.args(["/C", script_path.to_str().unwrap()]);
|
||||
|
||||
// Start the process detached
|
||||
let _child = cmd.spawn()?;
|
||||
|
||||
// Give the script a moment to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Exit the current process
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let app_path = self.get_current_app_path()?;
|
||||
let current_pid = std::process::id();
|
||||
|
||||
// Create a temporary restart script
|
||||
let temp_dir = std::env::temp_dir();
|
||||
let script_path = temp_dir.join("donut_restart.sh");
|
||||
|
||||
// Create the restart script content
|
||||
let script_content = format!(
|
||||
r#"#!/bin/bash
|
||||
# Wait for the current process to exit
|
||||
while kill -0 {} 2>/dev/null; do
|
||||
sleep 0.5
|
||||
done
|
||||
|
||||
# Wait a bit more to ensure clean exit
|
||||
sleep 1
|
||||
|
||||
# Start the new application
|
||||
"{}" &
|
||||
|
||||
# Clean up this script
|
||||
rm "{}"
|
||||
"#,
|
||||
current_pid,
|
||||
app_path.to_str().unwrap(),
|
||||
script_path.to_str().unwrap()
|
||||
);
|
||||
|
||||
// Write the script to file
|
||||
fs::write(&script_path, script_content)?;
|
||||
|
||||
// Make the script executable
|
||||
let _ = Command::new("chmod")
|
||||
.args(["+x", script_path.to_str().unwrap()])
|
||||
.output();
|
||||
|
||||
// Execute the restart script in the background
|
||||
let mut cmd = Command::new("bash");
|
||||
cmd.arg(script_path.to_str().unwrap());
|
||||
|
||||
// Detach the process completely
|
||||
use std::os::unix::process::CommandExt;
|
||||
cmd.process_group(0);
|
||||
|
||||
let _child = cmd.spawn()?;
|
||||
|
||||
// Give the script a moment to start
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
|
||||
// Exit the current process
|
||||
std::process::exit(0);
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
{
|
||||
Err("Application restart not supported on this platform".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+204
-137
@@ -1,3 +1,4 @@
|
||||
use crate::api_client::is_browser_version_nightly;
|
||||
use crate::browser_runner::{BrowserProfile, BrowserRunner};
|
||||
use crate::browser_version_service::{BrowserVersionInfo, BrowserVersionService};
|
||||
use crate::settings_manager::SettingsManager;
|
||||
@@ -5,6 +6,7 @@ use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tauri::Emitter;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct UpdateNotification {
|
||||
@@ -45,32 +47,32 @@ impl AutoUpdater {
|
||||
pub async fn check_for_updates(
|
||||
&self,
|
||||
) -> Result<Vec<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Check if auto-updates are enabled
|
||||
let settings = self
|
||||
.settings_manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
if !settings.auto_updates_enabled {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let mut notifications = Vec::new();
|
||||
let mut browser_versions: HashMap<String, Vec<BrowserVersionInfo>> = HashMap::new();
|
||||
|
||||
// Group profiles by browser
|
||||
let profiles = self
|
||||
.browser_runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let mut notifications = Vec::new();
|
||||
let mut browser_versions: HashMap<String, Vec<BrowserVersionInfo>> = HashMap::new();
|
||||
|
||||
// Group profiles by browser type
|
||||
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
|
||||
|
||||
for profile in profiles {
|
||||
// Only check supported browsers
|
||||
if !self
|
||||
.version_service
|
||||
.is_browser_supported(&profile.browser)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
browser_profiles
|
||||
.entry(profile.browser.clone())
|
||||
.or_default()
|
||||
.push(profile);
|
||||
}
|
||||
|
||||
// Check each browser type
|
||||
for (browser, profiles) in browser_profiles {
|
||||
// Get cached versions first, then try to fetch if needed
|
||||
let versions = if let Some(cached) = self
|
||||
@@ -97,7 +99,26 @@ impl AutoUpdater {
|
||||
// Check each profile for updates
|
||||
for profile in profiles {
|
||||
if let Some(update) = self.check_profile_update(&profile, &versions)? {
|
||||
notifications.push(update);
|
||||
// Apply chromium threshold logic
|
||||
if browser == "chromium" {
|
||||
// For chromium, only show notifications if there are 200+ new versions
|
||||
let current_version = &profile.version.parse::<u32>().unwrap();
|
||||
let new_version = &update.new_version.parse::<u32>().unwrap();
|
||||
|
||||
let result = new_version - current_version;
|
||||
println!(
|
||||
"Current version: {current_version}, New version: {new_version}, Result: {result}"
|
||||
);
|
||||
if result > 200 {
|
||||
notifications.push(update);
|
||||
} else {
|
||||
println!(
|
||||
"Skipping chromium update notification: only {result} new versions (need 50+)"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
notifications.push(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +126,93 @@ impl AutoUpdater {
|
||||
Ok(notifications)
|
||||
}
|
||||
|
||||
pub async fn check_for_updates_with_progress(&self, app_handle: &tauri::AppHandle) {
|
||||
println!("Starting auto-update check with progress...");
|
||||
|
||||
// Check for browser updates and trigger auto-downloads
|
||||
match self.check_for_updates().await {
|
||||
Ok(update_notifications) => {
|
||||
if !update_notifications.is_empty() {
|
||||
println!(
|
||||
"Found {} browser updates to auto-download",
|
||||
update_notifications.len()
|
||||
);
|
||||
|
||||
// Trigger automatic downloads for each update
|
||||
for notification in update_notifications {
|
||||
println!(
|
||||
"Auto-downloading {} version {}",
|
||||
notification.browser, notification.new_version
|
||||
);
|
||||
|
||||
// Clone app_handle for the async task
|
||||
let app_handle_clone = app_handle.clone();
|
||||
let browser = notification.browser.clone();
|
||||
let new_version = notification.new_version.clone();
|
||||
let notification_id = notification.id.clone();
|
||||
let affected_profiles = notification.affected_profiles.clone();
|
||||
|
||||
// Spawn async task to handle the download and auto-update
|
||||
tokio::spawn(async move {
|
||||
// First, check if browser already exists
|
||||
match crate::browser_runner::is_browser_downloaded(
|
||||
browser.clone(),
|
||||
new_version.clone(),
|
||||
) {
|
||||
true => {
|
||||
println!("Browser {browser} {new_version} already downloaded, proceeding to auto-update profiles");
|
||||
|
||||
// Browser already exists, go straight to profile update
|
||||
match crate::auto_updater::complete_browser_update_with_auto_update(
|
||||
browser.clone(),
|
||||
new_version.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated_profiles) => {
|
||||
println!(
|
||||
"Auto-update completed for {} profiles: {:?}",
|
||||
updated_profiles.len(),
|
||||
updated_profiles
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to complete auto-update for {browser}: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
false => {
|
||||
println!("Downloading browser {browser} version {new_version}...");
|
||||
|
||||
// Emit the auto-update event to trigger frontend handling
|
||||
let auto_update_event = serde_json::json!({
|
||||
"browser": browser,
|
||||
"new_version": new_version,
|
||||
"notification_id": notification_id,
|
||||
"affected_profiles": affected_profiles
|
||||
});
|
||||
|
||||
if let Err(e) =
|
||||
app_handle_clone.emit("browser-auto-update-available", &auto_update_event)
|
||||
{
|
||||
eprintln!("Failed to emit auto-update event for {browser}: {e}");
|
||||
} else {
|
||||
println!("Emitted auto-update event for {browser}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
} else {
|
||||
println!("No browser updates needed");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to check for browser updates: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if a specific profile has an available update
|
||||
fn check_profile_update(
|
||||
&self,
|
||||
@@ -112,16 +220,15 @@ impl AutoUpdater {
|
||||
available_versions: &[BrowserVersionInfo],
|
||||
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let current_version = &profile.version;
|
||||
let is_current_stable = !self.is_nightly_version(current_version);
|
||||
let is_current_nightly = is_browser_version_nightly(&profile.browser, current_version, None);
|
||||
|
||||
// Find the best available update
|
||||
let best_update = available_versions
|
||||
.iter()
|
||||
.filter(|v| {
|
||||
// Only consider versions newer than current
|
||||
self.is_version_newer(&v.version, current_version) &&
|
||||
// Respect version type preference
|
||||
is_current_stable != v.is_prerelease
|
||||
self.is_version_newer(&v.version, current_version)
|
||||
&& is_browser_version_nightly(&profile.browser, &v.version, None) == is_current_nightly
|
||||
})
|
||||
.max_by(|a, b| self.compare_versions(&a.version, &b.version));
|
||||
|
||||
@@ -181,43 +288,6 @@ impl AutoUpdater {
|
||||
result
|
||||
}
|
||||
|
||||
/// Mark download as auto-update
|
||||
pub fn mark_auto_update_download(
|
||||
&self,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut state = self.load_auto_update_state()?;
|
||||
let download_key = format!("{browser}-{version}");
|
||||
state.auto_update_downloads.insert(download_key);
|
||||
self.save_auto_update_state(&state)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove auto-update download tracking
|
||||
pub fn remove_auto_update_download(
|
||||
&self,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut state = self.load_auto_update_state()?;
|
||||
let download_key = format!("{browser}-{version}");
|
||||
state.auto_update_downloads.remove(&download_key);
|
||||
self.save_auto_update_state(&state)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check if download is marked as auto-update
|
||||
pub fn is_auto_update_download(
|
||||
&self,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let state = self.load_auto_update_state()?;
|
||||
let download_key = format!("{browser}-{version}");
|
||||
Ok(state.auto_update_downloads.contains(&download_key))
|
||||
}
|
||||
|
||||
/// Automatically update all affected profile versions after browser download
|
||||
pub async fn auto_update_profile_versions(
|
||||
&self,
|
||||
@@ -278,16 +348,9 @@ impl AutoUpdater {
|
||||
state.auto_update_downloads.remove(&download_key);
|
||||
self.save_auto_update_state(&state)?;
|
||||
|
||||
// Check if auto-delete of unused binaries is enabled and perform cleanup
|
||||
let settings = self
|
||||
.settings_manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
if settings.auto_delete_unused_binaries {
|
||||
// Perform cleanup in the background - don't fail the update if cleanup fails
|
||||
if let Err(e) = self.cleanup_unused_binaries_internal() {
|
||||
eprintln!("Warning: Failed to cleanup unused binaries after auto-update: {e}");
|
||||
}
|
||||
// Always perform cleanup after auto-update - don't fail the update if cleanup fails
|
||||
if let Err(e) = self.cleanup_unused_binaries_internal() {
|
||||
eprintln!("Warning: Failed to cleanup unused binaries after auto-update: {e}");
|
||||
}
|
||||
|
||||
Ok(updated_profiles)
|
||||
@@ -343,34 +406,18 @@ impl AutoUpdater {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
fn is_nightly_version(&self, version: &str) -> bool {
|
||||
version.contains("alpha")
|
||||
|| version.contains("beta")
|
||||
|| version.contains("rc")
|
||||
|| version.contains("a")
|
||||
|| version.contains("b")
|
||||
|| version.contains("dev")
|
||||
}
|
||||
|
||||
fn is_version_newer(&self, version1: &str, version2: &str) -> bool {
|
||||
self.compare_versions(version1, version2) == std::cmp::Ordering::Greater
|
||||
// Use the proper VersionComponent comparison from api_client.rs
|
||||
let version_a = crate::api_client::VersionComponent::parse(version1);
|
||||
let version_b = crate::api_client::VersionComponent::parse(version2);
|
||||
version_a > version_b
|
||||
}
|
||||
|
||||
fn compare_versions(&self, version1: &str, version2: &str) -> std::cmp::Ordering {
|
||||
// Basic semantic version comparison
|
||||
let v1_parts = self.parse_version(version1);
|
||||
let v2_parts = self.parse_version(version2);
|
||||
|
||||
v1_parts.cmp(&v2_parts)
|
||||
}
|
||||
|
||||
fn parse_version(&self, version: &str) -> Vec<u32> {
|
||||
version
|
||||
.split(&['.', 'a', 'b', '-', '_'][..])
|
||||
.filter_map(|part| part.parse::<u32>().ok())
|
||||
.collect()
|
||||
// Use the proper VersionComponent comparison from api_client.rs
|
||||
let version_a = crate::api_client::VersionComponent::parse(version1);
|
||||
let version_b = crate::api_client::VersionComponent::parse(version2);
|
||||
version_a.cmp(&version_b)
|
||||
}
|
||||
|
||||
fn get_auto_update_state_file(&self) -> PathBuf {
|
||||
@@ -451,27 +498,9 @@ pub async fn complete_browser_update_with_auto_update(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn mark_auto_update_download(browser: String, version: String) -> Result<(), String> {
|
||||
pub async fn check_for_updates_with_progress(app_handle: tauri::AppHandle) {
|
||||
let updater = AutoUpdater::new();
|
||||
updater
|
||||
.mark_auto_update_download(&browser, &version)
|
||||
.map_err(|e| format!("Failed to mark auto-update download: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn remove_auto_update_download(browser: String, version: String) -> Result<(), String> {
|
||||
let updater = AutoUpdater::new();
|
||||
updater
|
||||
.remove_auto_update_download(&browser, &version)
|
||||
.map_err(|e| format!("Failed to remove auto-update download: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_auto_update_download(browser: String, version: String) -> Result<bool, String> {
|
||||
let updater = AutoUpdater::new();
|
||||
updater
|
||||
.is_auto_update_download(&browser, &version)
|
||||
.map_err(|e| format!("Failed to check auto-update download: {e}"))
|
||||
updater.check_for_updates_with_progress(&app_handle).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -480,13 +509,15 @@ mod tests {
|
||||
|
||||
fn create_test_profile(name: &str, browser: &str, version: &str) -> BrowserProfile {
|
||||
BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: name.to_string(),
|
||||
browser: browser.to_string(),
|
||||
version: version.to_string(),
|
||||
profile_path: format!("/tmp/{name}"),
|
||||
process_id: None,
|
||||
proxy: None,
|
||||
proxy_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -498,21 +529,6 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_nightly_version() {
|
||||
let updater = AutoUpdater::new();
|
||||
|
||||
assert!(updater.is_nightly_version("1.0.0-alpha"));
|
||||
assert!(updater.is_nightly_version("1.0.0-beta"));
|
||||
assert!(updater.is_nightly_version("1.0.0-rc"));
|
||||
assert!(updater.is_nightly_version("1.0.0a1"));
|
||||
assert!(updater.is_nightly_version("1.0.0b1"));
|
||||
assert!(updater.is_nightly_version("1.0.0-dev"));
|
||||
|
||||
assert!(!updater.is_nightly_version("1.0.0"));
|
||||
assert!(!updater.is_nightly_version("1.2.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compare_versions() {
|
||||
let updater = AutoUpdater::new();
|
||||
@@ -549,6 +565,68 @@ mod tests {
|
||||
assert!(!updater.is_version_newer("1.0.0", "1.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_camoufox_beta_version_comparison() {
|
||||
let updater = AutoUpdater::new();
|
||||
|
||||
// Test the exact user-reported scenario: 135.0.1beta24 vs 135.0beta22
|
||||
assert!(
|
||||
updater.is_version_newer("135.0.1beta24", "135.0beta22"),
|
||||
"135.0.1beta24 should be newer than 135.0beta22"
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
updater.compare_versions("135.0.1beta24", "135.0beta22"),
|
||||
std::cmp::Ordering::Greater,
|
||||
"135.0.1beta24 should compare as greater than 135.0beta22"
|
||||
);
|
||||
|
||||
// Test other camoufox beta version combinations
|
||||
assert!(
|
||||
updater.is_version_newer("135.0.5beta24", "135.0.5beta22"),
|
||||
"135.0.5beta24 should be newer than 135.0.5beta22"
|
||||
);
|
||||
|
||||
assert!(
|
||||
updater.is_version_newer("135.0.1beta1", "135.0beta1"),
|
||||
"135.0.1beta1 should be newer than 135.0beta1 due to patch version"
|
||||
);
|
||||
|
||||
// Test that older versions are not considered newer
|
||||
assert!(
|
||||
!updater.is_version_newer("135.0beta22", "135.0.1beta24"),
|
||||
"135.0beta22 should NOT be newer than 135.0.1beta24"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_beta_version_ordering_comprehensive() {
|
||||
let updater = AutoUpdater::new();
|
||||
|
||||
// Test various beta version patterns that could appear in camoufox
|
||||
let test_cases = vec![
|
||||
("135.0.1beta24", "135.0beta22", true), // User reported case
|
||||
("135.0.5beta24", "135.0.5beta22", true), // Same patch, different beta
|
||||
("135.1beta1", "135.0beta99", true), // Higher minor beats beta number
|
||||
("136.0beta1", "135.9.9beta99", true), // Higher major beats everything
|
||||
("135.0.1beta1", "135.0beta1", true), // Patch version matters
|
||||
("135.0beta22", "135.0.1beta24", false), // Reverse of user case
|
||||
];
|
||||
|
||||
for (newer, older, should_be_newer) in test_cases {
|
||||
let result = updater.is_version_newer(newer, older);
|
||||
assert_eq!(
|
||||
result,
|
||||
should_be_newer,
|
||||
"Expected {} {} {} but got {}",
|
||||
newer,
|
||||
if should_be_newer { ">" } else { "<=" },
|
||||
older,
|
||||
if result { "true" } else { "false" }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_check_profile_update_stable_to_stable() {
|
||||
let updater = AutoUpdater::new();
|
||||
@@ -833,15 +911,4 @@ mod tests {
|
||||
let loaded_state: AutoUpdateState = serde_json::from_str(&content).unwrap();
|
||||
assert_eq!(loaded_state.pending_updates.len(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_version() {
|
||||
let updater = AutoUpdater::new();
|
||||
|
||||
assert_eq!(updater.parse_version("1.2.3"), vec![1, 2, 3]);
|
||||
assert_eq!(updater.parse_version("1.2.3-alpha"), vec![1, 2, 3]);
|
||||
assert_eq!(updater.parse_version("1.2.3a1"), vec![1, 2, 3, 1]);
|
||||
assert_eq!(updater.parse_version("1.2.3b2"), vec![1, 2, 3, 2]);
|
||||
assert_eq!(updater.parse_version("10.0.0"), vec![10, 0, 0]);
|
||||
}
|
||||
}
|
||||
|
||||
+163
-43
@@ -1,14 +1,13 @@
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProxySettings {
|
||||
pub enabled: bool,
|
||||
pub proxy_type: String, // "http", "https", "socks4", or "socks5"
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
pub username: Option<String>,
|
||||
pub password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
@@ -20,6 +19,7 @@ pub enum BrowserType {
|
||||
Brave,
|
||||
Zen,
|
||||
TorBrowser,
|
||||
Camoufox,
|
||||
}
|
||||
|
||||
impl BrowserType {
|
||||
@@ -32,6 +32,7 @@ impl BrowserType {
|
||||
BrowserType::Brave => "brave",
|
||||
BrowserType::Zen => "zen",
|
||||
BrowserType::TorBrowser => "tor-browser",
|
||||
BrowserType::Camoufox => "camoufox",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +45,7 @@ impl BrowserType {
|
||||
"brave" => Ok(BrowserType::Brave),
|
||||
"zen" => Ok(BrowserType::Zen),
|
||||
"tor-browser" => Ok(BrowserType::TorBrowser),
|
||||
"camoufox" => Ok(BrowserType::Camoufox),
|
||||
_ => Err(format!("Unknown browser type: {s}")),
|
||||
}
|
||||
}
|
||||
@@ -54,7 +56,7 @@ pub trait Browser: Send + Sync {
|
||||
fn create_launch_args(
|
||||
&self,
|
||||
profile_path: &str,
|
||||
_proxy_settings: Option<&ProxySettings>,
|
||||
proxy_settings: Option<&ProxySettings>,
|
||||
url: Option<String>,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>>;
|
||||
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool;
|
||||
@@ -90,6 +92,7 @@ mod macos {
|
||||
|| name.starts_with("mullvad")
|
||||
|| name.starts_with("zen")
|
||||
|| name.starts_with("tor")
|
||||
|| name.starts_with("camoufox")
|
||||
|| name.contains("Browser")
|
||||
})
|
||||
.map(|entry| entry.path())
|
||||
@@ -193,6 +196,12 @@ mod linux {
|
||||
browser_subdir.join("firefox-bin"),
|
||||
]
|
||||
}
|
||||
BrowserType::Camoufox => {
|
||||
vec![
|
||||
browser_subdir.join("camoufox"),
|
||||
browser_subdir.join("camoufox-bin"),
|
||||
]
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
@@ -216,17 +225,13 @@ mod linux {
|
||||
install_dir: &Path,
|
||||
browser_type: &BrowserType,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
// Expected structure: install_dir/<browser>/<binary>
|
||||
let browser_subdir = install_dir.join(browser_type.as_str());
|
||||
|
||||
let possible_executables = match browser_type {
|
||||
BrowserType::Chromium => vec![
|
||||
browser_subdir.join("chromium"),
|
||||
browser_subdir.join("chrome"),
|
||||
],
|
||||
BrowserType::Chromium => vec![install_dir.join("chromium"), install_dir.join("chrome")],
|
||||
BrowserType::Brave => vec![
|
||||
browser_subdir.join("brave"),
|
||||
browser_subdir.join("brave-browser"),
|
||||
install_dir.join("brave"),
|
||||
install_dir.join("brave-browser"),
|
||||
install_dir.join("brave-browser-nightly"),
|
||||
install_dir.join("brave-browser-beta"),
|
||||
],
|
||||
_ => vec![],
|
||||
};
|
||||
@@ -279,6 +284,12 @@ mod linux {
|
||||
browser_subdir.join("firefox"),
|
||||
]
|
||||
}
|
||||
BrowserType::Camoufox => {
|
||||
vec![
|
||||
browser_subdir.join("camoufox-bin"),
|
||||
browser_subdir.join("camoufox"),
|
||||
]
|
||||
}
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
@@ -292,21 +303,13 @@ mod linux {
|
||||
}
|
||||
|
||||
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
|
||||
// Expected structure: install_dir/<browser>/<binary>
|
||||
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::Chromium => vec![
|
||||
browser_subdir.join("chromium"),
|
||||
browser_subdir.join("chrome"),
|
||||
],
|
||||
BrowserType::Chromium => vec![install_dir.join("chromium"), install_dir.join("chrome")],
|
||||
BrowserType::Brave => vec![
|
||||
browser_subdir.join("brave"),
|
||||
browser_subdir.join("brave-browser"),
|
||||
install_dir.join("brave"),
|
||||
install_dir.join("brave-browser"),
|
||||
install_dir.join("brave-browser-nightly"),
|
||||
install_dir.join("brave-browser-beta"),
|
||||
],
|
||||
_ => vec![],
|
||||
};
|
||||
@@ -371,6 +374,7 @@ mod windows {
|
||||
|| name.starts_with("mullvad")
|
||||
|| name.starts_with("zen")
|
||||
|| name.starts_with("tor")
|
||||
|| name.starts_with("camoufox")
|
||||
|| name.contains("browser")
|
||||
{
|
||||
return Ok(path);
|
||||
@@ -449,6 +453,7 @@ mod windows {
|
||||
|| name.starts_with("mullvad")
|
||||
|| name.starts_with("zen")
|
||||
|| name.starts_with("tor")
|
||||
|| name.starts_with("camoufox")
|
||||
|| name.contains("browser")
|
||||
{
|
||||
return true;
|
||||
@@ -545,7 +550,10 @@ impl Browser for FirefoxBrowser {
|
||||
BrowserType::MullvadBrowser | BrowserType::TorBrowser => {
|
||||
args.push("-no-remote".to_string());
|
||||
}
|
||||
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
|
||||
BrowserType::Firefox
|
||||
| BrowserType::FirefoxDeveloper
|
||||
| BrowserType::Zen
|
||||
| BrowserType::Camoufox => {
|
||||
// Don't use -no-remote so we can communicate with existing instances
|
||||
}
|
||||
_ => {}
|
||||
@@ -643,20 +651,16 @@ impl Browser for ChromiumBrowser {
|
||||
"--disable-component-update".to_string(),
|
||||
"--disable-background-timer-throttling".to_string(),
|
||||
"--crash-server-url=".to_string(),
|
||||
"--disable-updater".to_string(),
|
||||
];
|
||||
|
||||
// Add proxy configuration if provided
|
||||
if let Some(proxy) = proxy_settings {
|
||||
if proxy.enabled {
|
||||
let pac_path = Path::new(profile_path).join("proxy.pac");
|
||||
if pac_path.exists() {
|
||||
let pac_content = fs::read(&pac_path)?;
|
||||
let pac_base64 = general_purpose::STANDARD.encode(&pac_content);
|
||||
args.push(format!(
|
||||
"--proxy-pac-url=data:application/x-javascript-config;base64,{pac_base64}"
|
||||
));
|
||||
}
|
||||
}
|
||||
// Apply proxy settings
|
||||
args.push(format!(
|
||||
"--proxy-server=http://{}:{}",
|
||||
proxy.host, proxy.port
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(url) = url {
|
||||
@@ -710,6 +714,81 @@ impl Browser for ChromiumBrowser {
|
||||
}
|
||||
}
|
||||
|
||||
pub struct CamoufoxBrowser;
|
||||
|
||||
impl CamoufoxBrowser {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Browser for CamoufoxBrowser {
|
||||
fn get_executable_path(&self, install_dir: &Path) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::get_firefox_executable_path(install_dir);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::get_firefox_executable_path(install_dir, &BrowserType::Camoufox);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::get_firefox_executable_path(install_dir);
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
Err("Unsupported platform".into())
|
||||
}
|
||||
|
||||
fn create_launch_args(
|
||||
&self,
|
||||
profile_path: &str,
|
||||
_proxy_settings: Option<&ProxySettings>,
|
||||
url: Option<String>,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
// For Camoufox, we handle launching through the camoufox launcher
|
||||
// This method won't be used directly, but we provide basic Firefox args as fallback
|
||||
let mut args = vec![
|
||||
"-profile".to_string(),
|
||||
profile_path.to_string(),
|
||||
"-no-remote".to_string(),
|
||||
];
|
||||
|
||||
if let Some(url) = url {
|
||||
args.push(url);
|
||||
}
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool {
|
||||
let install_dir = binaries_dir.join("camoufox").join(version);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::is_firefox_version_downloaded(&install_dir);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::is_firefox_version_downloaded(&install_dir, &BrowserType::Camoufox);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::is_firefox_version_downloaded(&install_dir);
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
false
|
||||
}
|
||||
|
||||
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>> {
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::prepare_executable(executable_path);
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::prepare_executable(executable_path);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::prepare_executable(executable_path);
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
|
||||
Err("Unsupported platform".into())
|
||||
}
|
||||
}
|
||||
|
||||
// Factory function to create browser instances
|
||||
pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
|
||||
match browser_type {
|
||||
@@ -719,6 +798,7 @@ pub fn create_browser(browser_type: BrowserType) -> Box<dyn Browser> {
|
||||
| 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()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -735,6 +815,24 @@ pub struct GithubRelease {
|
||||
pub is_nightly: bool,
|
||||
#[serde(default)]
|
||||
pub prerelease: bool,
|
||||
#[serde(default)]
|
||||
pub draft: bool,
|
||||
#[serde(default)]
|
||||
pub body: Option<String>,
|
||||
#[serde(default)]
|
||||
pub html_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub id: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub node_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub target_commitish: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub tarball_url: Option<String>,
|
||||
#[serde(default)]
|
||||
pub zipball_url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
@@ -743,6 +841,22 @@ pub struct GithubAsset {
|
||||
pub browser_download_url: String,
|
||||
#[serde(default)]
|
||||
pub size: u64,
|
||||
#[serde(default)]
|
||||
pub download_count: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub id: Option<u64>,
|
||||
#[serde(default)]
|
||||
pub node_id: Option<String>,
|
||||
#[serde(default)]
|
||||
pub label: Option<String>,
|
||||
#[serde(default)]
|
||||
pub content_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub state: Option<String>,
|
||||
#[serde(default)]
|
||||
pub created_at: Option<String>,
|
||||
#[serde(default)]
|
||||
pub updated_at: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -761,6 +875,7 @@ mod tests {
|
||||
assert_eq!(BrowserType::Brave.as_str(), "brave");
|
||||
assert_eq!(BrowserType::Zen.as_str(), "zen");
|
||||
assert_eq!(BrowserType::TorBrowser.as_str(), "tor-browser");
|
||||
assert_eq!(BrowserType::Camoufox.as_str(), "camoufox");
|
||||
|
||||
// Test from_str
|
||||
assert_eq!(
|
||||
@@ -785,6 +900,10 @@ mod tests {
|
||||
BrowserType::from_str("tor-browser").unwrap(),
|
||||
BrowserType::TorBrowser
|
||||
);
|
||||
assert_eq!(
|
||||
BrowserType::from_str("camoufox").unwrap(),
|
||||
BrowserType::Camoufox
|
||||
);
|
||||
|
||||
// Test invalid browser type
|
||||
assert!(BrowserType::from_str("invalid").is_err());
|
||||
@@ -868,23 +987,24 @@ mod tests {
|
||||
#[test]
|
||||
fn test_proxy_settings_creation() {
|
||||
let proxy = ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
assert!(proxy.enabled);
|
||||
assert_eq!(proxy.proxy_type, "http");
|
||||
assert_eq!(proxy.host, "127.0.0.1");
|
||||
assert_eq!(proxy.port, 8080);
|
||||
|
||||
// Test different proxy types
|
||||
let socks_proxy = ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "proxy.example.com".to_string(),
|
||||
port: 1080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
assert_eq!(socks_proxy.proxy_type, "socks5");
|
||||
@@ -957,10 +1077,11 @@ mod tests {
|
||||
#[test]
|
||||
fn test_proxy_settings_serialization() {
|
||||
let proxy = ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
// Test that it can be serialized (implements Serialize)
|
||||
@@ -971,7 +1092,6 @@ mod tests {
|
||||
|
||||
// Test that it can be deserialized (implements Deserialize)
|
||||
let deserialized: ProxySettings = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(deserialized.enabled, proxy.enabled);
|
||||
assert_eq!(deserialized.proxy_type, proxy.proxy_type);
|
||||
assert_eq!(deserialized.host, proxy.host);
|
||||
assert_eq!(deserialized.port, proxy.port);
|
||||
|
||||
+1919
-723
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,12 @@ pub struct BrowserVersionsResult {
|
||||
pub total_versions_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BrowserReleaseTypes {
|
||||
pub stable: Option<String>,
|
||||
pub nightly: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DownloadInfo {
|
||||
pub url: String,
|
||||
@@ -81,6 +87,10 @@ impl BrowserVersionService {
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
"camoufox" => {
|
||||
// Camoufox supports all platforms and architectures according to the JS code
|
||||
Ok(true)
|
||||
}
|
||||
_ => Err(format!("Unknown browser: {browser}").into()),
|
||||
}
|
||||
}
|
||||
@@ -95,6 +105,7 @@ impl BrowserVersionService {
|
||||
"brave",
|
||||
"chromium",
|
||||
"tor-browser",
|
||||
"camoufox",
|
||||
];
|
||||
|
||||
all_browsers
|
||||
@@ -122,7 +133,7 @@ impl BrowserVersionService {
|
||||
.map(|version| {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: crate::api_client::is_nightly_version(&version),
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly(browser, &version, None),
|
||||
date: "".to_string(), // Cache doesn't store dates
|
||||
}
|
||||
})
|
||||
@@ -136,6 +147,67 @@ impl BrowserVersionService {
|
||||
self.api_client.is_cache_expired(browser)
|
||||
}
|
||||
|
||||
/// Get latest stable and nightly versions for a browser (cached first)
|
||||
pub async fn get_browser_release_types(
|
||||
&self,
|
||||
browser: &str,
|
||||
) -> Result<BrowserReleaseTypes, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Try to get from cache first
|
||||
if let Some(cached_versions) = self.get_cached_browser_versions_detailed(browser) {
|
||||
// For Chromium, only return stable since all releases are stable
|
||||
if browser == "chromium" {
|
||||
let latest_stable = cached_versions.first().map(|v| v.version.clone());
|
||||
return Ok(BrowserReleaseTypes {
|
||||
stable: latest_stable,
|
||||
nightly: None,
|
||||
});
|
||||
}
|
||||
|
||||
let latest_stable = cached_versions
|
||||
.iter()
|
||||
.find(|v| !v.is_prerelease)
|
||||
.map(|v| v.version.clone());
|
||||
|
||||
let latest_nightly = cached_versions
|
||||
.iter()
|
||||
.find(|v| v.is_prerelease)
|
||||
.map(|v| v.version.clone());
|
||||
|
||||
return Ok(BrowserReleaseTypes {
|
||||
stable: latest_stable,
|
||||
nightly: latest_nightly,
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback to fetching if not cached
|
||||
// For Chromium, only return stable since all releases are stable
|
||||
if browser == "chromium" {
|
||||
let detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?;
|
||||
let latest_stable = detailed_versions.first().map(|v| v.version.clone());
|
||||
return Ok(BrowserReleaseTypes {
|
||||
stable: latest_stable,
|
||||
nightly: None,
|
||||
});
|
||||
}
|
||||
|
||||
let detailed_versions = self.fetch_browser_versions_detailed(browser, false).await?;
|
||||
|
||||
let latest_stable = detailed_versions
|
||||
.iter()
|
||||
.find(|v| !v.is_prerelease)
|
||||
.map(|v| v.version.clone());
|
||||
|
||||
let latest_nightly = detailed_versions
|
||||
.iter()
|
||||
.find(|v| v.is_prerelease)
|
||||
.map(|v| v.version.clone());
|
||||
|
||||
Ok(BrowserReleaseTypes {
|
||||
stable: latest_stable,
|
||||
nightly: latest_nightly,
|
||||
})
|
||||
}
|
||||
|
||||
/// Fetch browser versions with optional caching
|
||||
pub async fn fetch_browser_versions(
|
||||
&self,
|
||||
@@ -170,6 +242,7 @@ impl BrowserVersionService {
|
||||
"brave" => self.fetch_brave_versions(true).await?,
|
||||
"chromium" => self.fetch_chromium_versions(true).await?,
|
||||
"tor-browser" => self.fetch_tor_versions(true).await?,
|
||||
"camoufox" => self.fetch_camoufox_versions(true).await?,
|
||||
_ => return Err(format!("Unsupported browser: {browser}").into()),
|
||||
};
|
||||
|
||||
@@ -240,7 +313,9 @@ impl BrowserVersionService {
|
||||
} else {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: crate::api_client::is_nightly_version(&version),
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly(
|
||||
"firefox", &version, None,
|
||||
),
|
||||
date: "".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -261,7 +336,11 @@ impl BrowserVersionService {
|
||||
} else {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: crate::api_client::is_nightly_version(&version),
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly(
|
||||
"firefox-developer",
|
||||
&version,
|
||||
None,
|
||||
),
|
||||
date: "".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -293,6 +372,8 @@ impl BrowserVersionService {
|
||||
let releases = self.fetch_zen_releases_detailed(true).await?;
|
||||
merged_versions
|
||||
.into_iter()
|
||||
// Filter out twilight releases at the detailed level too
|
||||
.filter(|version| version.to_lowercase() != "twilight")
|
||||
.map(|version| {
|
||||
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
|
||||
BrowserVersionInfo {
|
||||
@@ -303,7 +384,7 @@ impl BrowserVersionService {
|
||||
} else {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: false, // Zen Browser releases are usually stable
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly("zen", &version, None),
|
||||
date: "".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -324,7 +405,9 @@ impl BrowserVersionService {
|
||||
} else {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: version.contains("beta") || version.contains("dev"),
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly(
|
||||
"brave", &version, None,
|
||||
),
|
||||
date: "".to_string(),
|
||||
}
|
||||
}
|
||||
@@ -360,7 +443,11 @@ impl BrowserVersionService {
|
||||
if let Some(release) = releases.iter().find(|r| r.version == version) {
|
||||
BrowserVersionInfo {
|
||||
version: release.version.clone(),
|
||||
is_prerelease: crate::api_client::is_nightly_version(&version),
|
||||
is_prerelease: crate::api_client::is_browser_version_nightly(
|
||||
"tor-browser",
|
||||
&release.version,
|
||||
None,
|
||||
),
|
||||
date: release.date.clone(),
|
||||
}
|
||||
} else {
|
||||
@@ -373,7 +460,30 @@ impl BrowserVersionService {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => return Err(format!("Unsupported browser: {browser}").into()),
|
||||
"camoufox" => {
|
||||
let releases = self.fetch_camoufox_releases_detailed(true).await?;
|
||||
merged_versions
|
||||
.into_iter()
|
||||
.map(|version| {
|
||||
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
|
||||
BrowserVersionInfo {
|
||||
version: release.tag_name.clone(),
|
||||
is_prerelease: release.is_nightly,
|
||||
date: release.published_at.clone(),
|
||||
}
|
||||
} else {
|
||||
BrowserVersionInfo {
|
||||
version: version.clone(),
|
||||
is_prerelease: false, // Camoufox usually stable releases
|
||||
date: "".to_string(),
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
_ => {
|
||||
return Err(format!("Unsupported browser: {browser}").into());
|
||||
}
|
||||
};
|
||||
|
||||
Ok(detailed_info)
|
||||
@@ -423,11 +533,16 @@ impl BrowserVersionService {
|
||||
|
||||
match browser {
|
||||
"firefox" => {
|
||||
let os_param = match (&os[..], &arch[..]) {
|
||||
("windows", _) => "win64",
|
||||
("linux", "x64") => "linux64",
|
||||
("linux", "arm64") => "linux64-aarch64",
|
||||
("macos", _) => "osx",
|
||||
let (platform_path, filename, is_archive) = match (&os[..], &arch[..]) {
|
||||
("windows", "x64") => ("win64", format!("Firefox Setup {version}.exe"), false),
|
||||
("windows", "arm64") => (
|
||||
"win64-aarch64",
|
||||
format!("Firefox Setup {version}.exe"),
|
||||
false,
|
||||
),
|
||||
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.xz"), true),
|
||||
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.xz"), true),
|
||||
("macos", _) => ("mac", format!("Firefox {version}.dmg"), true),
|
||||
_ => {
|
||||
return Err(
|
||||
format!("Unsupported platform/architecture for Firefox: {os}/{arch}").into(),
|
||||
@@ -435,27 +550,25 @@ impl BrowserVersionService {
|
||||
}
|
||||
};
|
||||
|
||||
let (filename, is_archive) = match os.as_str() {
|
||||
"windows" => (format!("firefox-{version}.exe"), false),
|
||||
"linux" => (format!("firefox-{version}.tar.xz"), true),
|
||||
"macos" => (format!("firefox-{version}.dmg"), true),
|
||||
_ => return Err(format!("Unsupported platform for Firefox: {os}").into()),
|
||||
};
|
||||
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://download.mozilla.org/?product=firefox-{version}&os={os_param}&lang=en-US"
|
||||
"https://download-installer.cdn.mozilla.net/pub/firefox/releases/{version}/{platform_path}/en-US/{filename}"
|
||||
),
|
||||
filename,
|
||||
is_archive,
|
||||
})
|
||||
}
|
||||
"firefox-developer" => {
|
||||
let os_param = match (&os[..], &arch[..]) {
|
||||
("windows", _) => "win64",
|
||||
("linux", "x64") => "linux64",
|
||||
("linux", "arm64") => "linux64-aarch64",
|
||||
("macos", _) => "osx",
|
||||
let (platform_path, filename, is_archive) = match (&os[..], &arch[..]) {
|
||||
("windows", "x64") => ("win64", format!("Firefox Setup {version}.exe"), false),
|
||||
("windows", "arm64") => (
|
||||
"win64-aarch64",
|
||||
format!("Firefox Setup {version}.exe"),
|
||||
false,
|
||||
),
|
||||
("linux", "x64") => ("linux-x86_64", format!("firefox-{version}.tar.xz"), true),
|
||||
("linux", "arm64") => ("linux-aarch64", format!("firefox-{version}.tar.xz"), true),
|
||||
("macos", _) => ("mac", format!("Firefox {version}.dmg"), true),
|
||||
_ => {
|
||||
return Err(
|
||||
format!("Unsupported platform/architecture for Firefox Developer: {os}/{arch}")
|
||||
@@ -464,16 +577,9 @@ impl BrowserVersionService {
|
||||
}
|
||||
};
|
||||
|
||||
let (filename, is_archive) = match os.as_str() {
|
||||
"windows" => (format!("firefox-developer-{version}.exe"), false),
|
||||
"linux" => (format!("firefox-developer-{version}.tar.xz"), true),
|
||||
"macos" => (format!("firefox-developer-{version}.dmg"), true),
|
||||
_ => return Err(format!("Unsupported platform for Firefox Developer: {os}").into()),
|
||||
};
|
||||
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://download.mozilla.org/?product=firefox-devedition-{version}&os={os_param}&lang=en-US"
|
||||
"https://download-installer.cdn.mozilla.net/pub/devedition/releases/{version}/{platform_path}/en-US/{filename}"
|
||||
),
|
||||
filename,
|
||||
is_archive,
|
||||
@@ -560,8 +666,6 @@ impl BrowserVersionService {
|
||||
})
|
||||
}
|
||||
"brave" => {
|
||||
// Brave uses different asset naming conventions
|
||||
// The actual URL will be resolved dynamically in the download service
|
||||
let (filename, is_archive) = match (&os[..], &arch[..]) {
|
||||
("windows", _) => (format!("brave-{version}.exe"), false),
|
||||
("linux", "x64") => (format!("brave-browser-{version}-linux-amd64.zip"), true),
|
||||
@@ -574,7 +678,7 @@ impl BrowserVersionService {
|
||||
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://github.com/brave/brave-browser/releases/download/{version}/brave-placeholder"
|
||||
"https://github.com/brave/brave-browser/releases/download/{version}/{filename}"
|
||||
),
|
||||
filename,
|
||||
is_archive,
|
||||
@@ -650,6 +754,32 @@ impl BrowserVersionService {
|
||||
is_archive,
|
||||
})
|
||||
}
|
||||
"camoufox" => {
|
||||
// Camoufox downloads from GitHub releases with pattern: camoufox-{version}-{release}-{os}.{arch}.zip
|
||||
let (os_name, arch_name) = match (&os[..], &arch[..]) {
|
||||
("windows", "x64") => ("win", "x86_64"),
|
||||
("windows", "arm64") => ("win", "arm64"),
|
||||
("linux", "x64") => ("lin", "x86_64"),
|
||||
("linux", "arm64") => ("lin", "arm64"),
|
||||
("macos", "x64") => ("mac", "x86_64"),
|
||||
("macos", "arm64") => ("mac", "arm64"),
|
||||
_ => {
|
||||
return Err(
|
||||
format!("Unsupported platform/architecture for Camoufox: {os}/{arch}").into(),
|
||||
)
|
||||
}
|
||||
};
|
||||
|
||||
// Note: We provide a placeholder URL here since Camoufox requires dynamic resolution
|
||||
// The actual URL will be resolved in download.rs resolve_download_url
|
||||
Ok(DownloadInfo {
|
||||
url: format!(
|
||||
"https://github.com/daijro/camoufox/releases/download/{version}/camoufox-{{version}}-{{release}}-{os_name}.{arch_name}.zip"
|
||||
),
|
||||
filename: format!("camoufox-{version}-{os_name}.{arch_name}.zip"),
|
||||
is_archive: true,
|
||||
})
|
||||
}
|
||||
_ => Err(format!("Unsupported browser: {browser}").into()),
|
||||
}
|
||||
}
|
||||
@@ -740,7 +870,13 @@ impl BrowserVersionService {
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let releases = self.fetch_zen_releases_detailed(no_caching).await?;
|
||||
Ok(releases.into_iter().map(|r| r.tag_name).collect())
|
||||
Ok(
|
||||
releases
|
||||
.into_iter()
|
||||
.filter(|r| r.tag_name.to_lowercase() != "twilight")
|
||||
.map(|r| r.tag_name)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
async fn fetch_zen_releases_detailed(
|
||||
@@ -806,6 +942,24 @@ impl BrowserVersionService {
|
||||
.fetch_tor_releases_with_caching(no_caching)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn fetch_camoufox_versions(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let releases = self.fetch_camoufox_releases_detailed(no_caching).await?;
|
||||
Ok(releases.into_iter().map(|r| r.tag_name).collect())
|
||||
}
|
||||
|
||||
async fn fetch_camoufox_releases_detailed(
|
||||
&self,
|
||||
no_caching: bool,
|
||||
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self
|
||||
.api_client
|
||||
.fetch_camoufox_releases_with_caching(no_caching)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -826,7 +980,6 @@ mod tests {
|
||||
base_url.clone(), // github_api_base
|
||||
base_url.clone(), // chromium_api_base
|
||||
base_url.clone(), // tor_archive_base
|
||||
base_url.clone(), // mozilla_download_base
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1468,16 +1621,24 @@ mod tests {
|
||||
|
||||
// Test Firefox
|
||||
let firefox_info = service.get_download_info("firefox", "139.0").unwrap();
|
||||
assert_eq!(firefox_info.filename, "firefox-139.0.dmg");
|
||||
assert!(firefox_info.url.contains("firefox-139.0"));
|
||||
assert_eq!(firefox_info.filename, "Firefox 139.0.dmg");
|
||||
assert!(firefox_info
|
||||
.url
|
||||
.contains("download-installer.cdn.mozilla.net"));
|
||||
assert!(firefox_info.url.contains("/pub/firefox/releases/139.0/"));
|
||||
assert!(firefox_info.is_archive);
|
||||
|
||||
// Test Firefox Developer
|
||||
let firefox_dev_info = service
|
||||
.get_download_info("firefox-developer", "139.0b1")
|
||||
.unwrap();
|
||||
assert_eq!(firefox_dev_info.filename, "firefox-developer-139.0b1.dmg");
|
||||
assert!(firefox_dev_info.url.contains("devedition-139.0b1"));
|
||||
assert_eq!(firefox_dev_info.filename, "Firefox 139.0b1.dmg");
|
||||
assert!(firefox_dev_info
|
||||
.url
|
||||
.contains("download-installer.cdn.mozilla.net"));
|
||||
assert!(firefox_dev_info
|
||||
.url
|
||||
.contains("/pub/devedition/releases/139.0b1/"));
|
||||
assert!(firefox_dev_info.is_archive);
|
||||
|
||||
// Test Mullvad Browser
|
||||
@@ -1506,10 +1667,10 @@ mod tests {
|
||||
assert!(chromium_info.url.contains("chrome-mac.zip"));
|
||||
assert!(chromium_info.is_archive);
|
||||
|
||||
// Test Brave
|
||||
// Test Brave - Note: Brave uses dynamic URL resolution, so get_download_info provides a template URL
|
||||
let brave_info = service.get_download_info("brave", "v1.81.9").unwrap();
|
||||
assert_eq!(brave_info.filename, "Brave-Browser-universal.dmg");
|
||||
assert!(brave_info.url.contains("brave-placeholder"));
|
||||
assert_eq!(brave_info.url, "https://github.com/brave/brave-browser/releases/download/v1.81.9/Brave-Browser-universal.dmg");
|
||||
assert!(brave_info.is_archive);
|
||||
|
||||
// Test unsupported browser
|
||||
|
||||
@@ -0,0 +1,607 @@
|
||||
use crate::browser_runner::BrowserProfile;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CamoufoxConfig {
|
||||
pub os: Option<Vec<String>>,
|
||||
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>>,
|
||||
}
|
||||
|
||||
impl Default for CamoufoxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
os: None,
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
pub struct CamoufoxLaunchResult {
|
||||
pub id: String,
|
||||
pub pid: Option<u32>,
|
||||
#[serde(alias = "executable_path")]
|
||||
pub executablePath: String,
|
||||
#[serde(alias = "profile_path")]
|
||||
pub profilePath: String,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct CamoufoxLauncher {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl CamoufoxLauncher {
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
|
||||
/// Launch Camoufox browser with the specified configuration
|
||||
pub async fn launch_camoufox(
|
||||
&self,
|
||||
executable_path: &str,
|
||||
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:?}");
|
||||
|
||||
// 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);
|
||||
|
||||
// Add URL if provided
|
||||
if let Some(url) = url {
|
||||
sidecar = sidecar.arg("--url").arg(url);
|
||||
}
|
||||
|
||||
// Add configuration options
|
||||
if let Some(os_list) = &config.os {
|
||||
sidecar = sidecar.arg("--os").arg(os_list.join(","));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(proxy) = &config.proxy {
|
||||
sidecar = sidecar.arg("--proxy").arg(proxy);
|
||||
}
|
||||
|
||||
// Cache is enabled by default, only add flag if disabled
|
||||
if !config.enable_cache.unwrap_or(true) {
|
||||
sidecar = sidecar.arg("--disable-cache");
|
||||
}
|
||||
|
||||
if let Some(virtual_display) = &config.virtual_display {
|
||||
sidecar = sidecar.arg("--virtual-display").arg(virtual_display);
|
||||
}
|
||||
|
||||
if config.debug.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--debug");
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("Nodecar stdout: {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}"))?;
|
||||
|
||||
println!("Successfully launched Camoufox with ID: {}", result.id);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Stop a Camoufox process by ID
|
||||
pub async fn stop_camoufox(
|
||||
&self,
|
||||
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}"))?
|
||||
.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
|
||||
|
||||
let output = sidecar
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute nodecar list command: {e}"))?;
|
||||
|
||||
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 stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("List command result: {stdout}");
|
||||
|
||||
// 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}"))?;
|
||||
|
||||
// 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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
println!("Parsed {} valid Camoufox processes", results.len());
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Find Camoufox process 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}");
|
||||
|
||||
let processes = self.list_camoufox_processes().await?;
|
||||
println!("Found {} running Camoufox processes", processes.len());
|
||||
|
||||
for process in &processes {
|
||||
println!(
|
||||
"Checking process with profile path: {}",
|
||||
process.profilePath
|
||||
);
|
||||
}
|
||||
|
||||
// Convert both 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
|
||||
);
|
||||
|
||||
// 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()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
println!("No matching Camoufox process found for profile path: {profile_path}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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}"))?;
|
||||
|
||||
// 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}"))
|
||||
}
|
||||
@@ -65,13 +65,282 @@ mod macos {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows {
|
||||
use std::path::Path;
|
||||
use winreg::enums::*;
|
||||
use winreg::RegKey;
|
||||
|
||||
const APP_NAME: &str = "DonutBrowser";
|
||||
const PROG_ID: &str = "DonutBrowser.HTML";
|
||||
|
||||
pub fn is_default_browser() -> Result<bool, String> {
|
||||
// Windows implementation would go here
|
||||
Err("Windows support not implemented yet".to_string())
|
||||
let schemes = ["http", "https"];
|
||||
|
||||
for scheme in schemes {
|
||||
// Check if our browser is set as the default handler for this scheme
|
||||
if !is_default_for_scheme(scheme)? {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn set_as_default_browser() -> Result<(), String> {
|
||||
Err("Windows support not implemented yet".to_string())
|
||||
// Get the current executable path
|
||||
let exe_path = std::env::current_exe()
|
||||
.map_err(|e| format!("Failed to get current executable path: {}", e))?;
|
||||
|
||||
let exe_path_str = exe_path
|
||||
.to_str()
|
||||
.ok_or("Failed to convert executable path to string")?;
|
||||
|
||||
// Verify the executable exists
|
||||
if !Path::new(exe_path_str).exists() {
|
||||
return Err(format!("Executable not found at: {}", exe_path_str));
|
||||
}
|
||||
|
||||
// Register the application
|
||||
register_application(exe_path_str)?;
|
||||
|
||||
// Set as default for HTTP and HTTPS
|
||||
set_default_for_scheme("http")?;
|
||||
set_default_for_scheme("https")?;
|
||||
|
||||
// Register file associations for HTML files
|
||||
register_html_file_association(exe_path_str)?;
|
||||
|
||||
// Notify the system of changes
|
||||
notify_system_of_changes();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_default_for_scheme(scheme: &str) -> Result<bool, String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Check Software\Microsoft\Windows\Shell\Associations\UrlAssociations\{scheme}\UserChoice
|
||||
let path = format!(
|
||||
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
|
||||
scheme
|
||||
);
|
||||
|
||||
match hkcu.open_subkey(&path) {
|
||||
Ok(key) => match key.get_value::<String, _>("ProgId") {
|
||||
Ok(prog_id) => Ok(prog_id == PROG_ID),
|
||||
Err(_) => Ok(false),
|
||||
},
|
||||
Err(_) => Ok(false),
|
||||
}
|
||||
}
|
||||
|
||||
fn register_application(exe_path: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Register in Software\RegisteredApplications
|
||||
let (registered_apps, _) = hkcu
|
||||
.create_subkey("Software\\RegisteredApplications")
|
||||
.map_err(|e| format!("Failed to create RegisteredApplications key: {}", e))?;
|
||||
|
||||
registered_apps
|
||||
.set_value(APP_NAME, &format!("Software\\{}", APP_NAME))
|
||||
.map_err(|e| format!("Failed to set registered application: {}", e))?;
|
||||
|
||||
// Create application key
|
||||
let (app_key, _) = hkcu
|
||||
.create_subkey(&format!("Software\\{}", APP_NAME))
|
||||
.map_err(|e| format!("Failed to create application key: {}", e))?;
|
||||
|
||||
// Set application properties
|
||||
app_key
|
||||
.set_value("ApplicationName", &APP_NAME)
|
||||
.map_err(|e| format!("Failed to set ApplicationName: {}", e))?;
|
||||
|
||||
app_key
|
||||
.set_value(
|
||||
"ApplicationDescription",
|
||||
&"Donut Browser - Simple Yet Powerful Browser Orchestrator",
|
||||
)
|
||||
.map_err(|e| format!("Failed to set ApplicationDescription: {}", e))?;
|
||||
|
||||
app_key
|
||||
.set_value("ApplicationIcon", &format!("{},0", exe_path))
|
||||
.map_err(|e| format!("Failed to set ApplicationIcon: {}", e))?;
|
||||
|
||||
// Create Capabilities key
|
||||
let (capabilities, _) = app_key
|
||||
.create_subkey("Capabilities")
|
||||
.map_err(|e| format!("Failed to create Capabilities key: {}", e))?;
|
||||
|
||||
capabilities
|
||||
.set_value(
|
||||
"ApplicationDescription",
|
||||
&"Donut Browser - Simple Yet Powerful Browser Orchestrator",
|
||||
)
|
||||
.map_err(|e| format!("Failed to set Capabilities description: {}", e))?;
|
||||
|
||||
// Set URL associations
|
||||
let (url_assoc, _) = capabilities
|
||||
.create_subkey("URLAssociations")
|
||||
.map_err(|e| format!("Failed to create URLAssociations key: {}", e))?;
|
||||
|
||||
url_assoc
|
||||
.set_value("http", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set http association: {}", e))?;
|
||||
|
||||
url_assoc
|
||||
.set_value("https", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set https association: {}", e))?;
|
||||
|
||||
// Set file associations
|
||||
let (file_assoc, _) = capabilities
|
||||
.create_subkey("FileAssociations")
|
||||
.map_err(|e| format!("Failed to create FileAssociations key: {}", e))?;
|
||||
|
||||
file_assoc
|
||||
.set_value(".html", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set .html association: {}", e))?;
|
||||
|
||||
file_assoc
|
||||
.set_value(".htm", &PROG_ID)
|
||||
.map_err(|e| format!("Failed to set .htm association: {}", e))?;
|
||||
|
||||
// Register the ProgID
|
||||
register_prog_id(exe_path)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_prog_id(exe_path: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Create ProgID key
|
||||
let (prog_id_key, _) = hkcu
|
||||
.create_subkey(&format!("Software\\Classes\\{}", PROG_ID))
|
||||
.map_err(|e| format!("Failed to create ProgID key: {}", e))?;
|
||||
|
||||
prog_id_key
|
||||
.set_value("", &"Donut Browser Document")
|
||||
.map_err(|e| format!("Failed to set ProgID default value: {}", e))?;
|
||||
|
||||
prog_id_key
|
||||
.set_value("FriendlyTypeName", &"Donut Browser Document")
|
||||
.map_err(|e| format!("Failed to set FriendlyTypeName: {}", e))?;
|
||||
|
||||
// Create DefaultIcon key
|
||||
let (icon_key, _) = prog_id_key
|
||||
.create_subkey("DefaultIcon")
|
||||
.map_err(|e| format!("Failed to create DefaultIcon key: {}", e))?;
|
||||
|
||||
icon_key
|
||||
.set_value("", &format!("{},0", exe_path))
|
||||
.map_err(|e| format!("Failed to set default icon: {}", e))?;
|
||||
|
||||
// Create shell\open\command key
|
||||
let (command_key, _) = prog_id_key
|
||||
.create_subkey("shell\\open\\command")
|
||||
.map_err(|e| format!("Failed to create command key: {}", e))?;
|
||||
|
||||
command_key
|
||||
.set_value("", &format!("\"{}\" \"%1\"", exe_path))
|
||||
.map_err(|e| format!("Failed to set command: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn set_default_for_scheme(scheme: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Set in Software\Microsoft\Windows\CurrentVersion\Explorer\FileExts\.html\UserChoice
|
||||
// Note: On Windows 10+, this might require elevated permissions or user interaction
|
||||
// through the Settings app due to security restrictions
|
||||
|
||||
// Try to set the association in the user's choice
|
||||
let user_choice_path = format!(
|
||||
"Software\\Microsoft\\Windows\\Shell\\Associations\\UrlAssociations\\{}\\UserChoice",
|
||||
scheme
|
||||
);
|
||||
|
||||
// Note: Setting UserChoice directly may not work on Windows 10+ due to hash verification
|
||||
// The user may need to manually set the default browser through Windows Settings
|
||||
match hkcu.create_subkey(&user_choice_path) {
|
||||
Ok((user_choice, _)) => {
|
||||
// Attempt to set the ProgId
|
||||
if let Err(_) = user_choice.set_value("ProgId", &PROG_ID) {
|
||||
// If we can't set UserChoice, that's expected on newer Windows versions
|
||||
// The registration is still valuable for the "Open with" menu
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Expected on newer Windows versions - user must set manually
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn register_html_file_association(_exe_path: &str) -> Result<(), String> {
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
|
||||
// Register .html and .htm file associations
|
||||
for ext in &[".html", ".htm"] {
|
||||
let ext_path = format!("Software\\Classes\\{}", ext);
|
||||
|
||||
match hkcu.create_subkey(&ext_path) {
|
||||
Ok((ext_key, _)) => {
|
||||
// Set the default value to our ProgID
|
||||
let _ = ext_key.set_value("", &PROG_ID);
|
||||
}
|
||||
Err(_) => {
|
||||
// Continue if we can't set the file association
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn notify_system_of_changes() {
|
||||
// Use Windows API to notify the system of association changes
|
||||
// This helps refresh the system's understanding of the changes
|
||||
unsafe {
|
||||
use std::ffi::c_void;
|
||||
|
||||
// Declare the Windows API functions
|
||||
type UINT = u32;
|
||||
type DWORD = u32;
|
||||
type LPARAM = isize;
|
||||
type WPARAM = usize;
|
||||
|
||||
const HWND_BROADCAST: *mut c_void = 0xffff as *mut c_void;
|
||||
const WM_SETTINGCHANGE: UINT = 0x001A;
|
||||
const SMTO_ABORTIFHUNG: UINT = 0x0002;
|
||||
|
||||
// Link to user32.dll functions
|
||||
extern "system" {
|
||||
fn SendMessageTimeoutA(
|
||||
hWnd: *mut c_void,
|
||||
Msg: UINT,
|
||||
wParam: WPARAM,
|
||||
lParam: LPARAM,
|
||||
fuFlags: UINT,
|
||||
uTimeout: UINT,
|
||||
lpdwResult: *mut DWORD,
|
||||
) -> isize;
|
||||
}
|
||||
|
||||
let mut result: DWORD = 0;
|
||||
|
||||
// Notify about file associations change
|
||||
SendMessageTimeoutA(
|
||||
HWND_BROADCAST,
|
||||
WM_SETTINGCHANGE,
|
||||
0,
|
||||
"Software\\Classes\0".as_ptr() as LPARAM,
|
||||
SMTO_ABORTIFHUNG,
|
||||
1000,
|
||||
&mut result,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +535,7 @@ pub async fn open_url_with_profile(
|
||||
|
||||
// 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()))
|
||||
.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}");
|
||||
@@ -336,14 +605,30 @@ pub async fn smart_open_url(
|
||||
}
|
||||
}
|
||||
|
||||
// For Mullvad browser: skip if running (can't open URLs in running Mullvad)
|
||||
// For Mullvad browser: Check if any other Mullvad browser is running
|
||||
if profile.browser == "mullvad-browser" {
|
||||
continue;
|
||||
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()))
|
||||
.launch_or_open_url(app_handle.clone(), profile, Some(url.clone()), None)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
|
||||
+88
-41
@@ -79,15 +79,29 @@ impl Downloader {
|
||||
}
|
||||
BrowserType::Zen => {
|
||||
// For Zen, verify the asset exists and handle different naming patterns
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_zen_releases_with_caching(true)
|
||||
.await?;
|
||||
let releases = match self.api_client.fetch_zen_releases_with_caching(true).await {
|
||||
Ok(releases) => releases,
|
||||
Err(e) => {
|
||||
eprintln!("Failed to fetch Zen releases: {e}");
|
||||
return Err(format!("Failed to fetch Zen releases from GitHub API: {e}. This might be due to GitHub API rate limiting or network issues. Please try again later.").into());
|
||||
}
|
||||
};
|
||||
|
||||
let release = releases
|
||||
.iter()
|
||||
.find(|r| r.tag_name == version)
|
||||
.ok_or(format!("Zen version {version} not found"))?;
|
||||
.ok_or_else(|| {
|
||||
format!(
|
||||
"Zen version {} not found. Available versions: {}",
|
||||
version,
|
||||
releases
|
||||
.iter()
|
||||
.take(5)
|
||||
.map(|r| r.tag_name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
// Get platform and architecture info
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
@@ -95,9 +109,17 @@ impl Downloader {
|
||||
// Find the appropriate asset
|
||||
let asset_url = self
|
||||
.find_zen_asset(&release.assets, &os, &arch)
|
||||
.ok_or(format!(
|
||||
"No compatible asset found for Zen version {version} on {os}/{arch}"
|
||||
))?;
|
||||
.ok_or_else(|| {
|
||||
let available_assets: Vec<&str> =
|
||||
release.assets.iter().map(|a| a.name.as_str()).collect();
|
||||
format!(
|
||||
"No compatible asset found for Zen version {} on {}/{}. Available assets: {}",
|
||||
version,
|
||||
os,
|
||||
arch,
|
||||
available_assets.join(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(asset_url)
|
||||
}
|
||||
@@ -125,6 +147,30 @@ impl Downloader {
|
||||
|
||||
Ok(asset_url)
|
||||
}
|
||||
BrowserType::Camoufox => {
|
||||
// For Camoufox, verify the asset exists and find the correct download URL
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_camoufox_releases_with_caching(true)
|
||||
.await?;
|
||||
|
||||
let release = releases
|
||||
.iter()
|
||||
.find(|r| r.tag_name == version)
|
||||
.ok_or(format!("Camoufox version {version} not found"))?;
|
||||
|
||||
// Get platform and architecture info
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
|
||||
// Find the appropriate asset
|
||||
let asset_url = self
|
||||
.find_camoufox_asset(&release.assets, &os, &arch)
|
||||
.ok_or(format!(
|
||||
"No compatible asset found for Camoufox version {version} on {os}/{arch}"
|
||||
))?;
|
||||
|
||||
Ok(asset_url)
|
||||
}
|
||||
_ => {
|
||||
// For other browsers, use the provided URL
|
||||
Ok(download_info.url.clone())
|
||||
@@ -195,40 +241,13 @@ impl Downloader {
|
||||
})
|
||||
}
|
||||
"linux" => {
|
||||
// For Linux, prefer ZIP files matching architecture (new format for stable releases)
|
||||
// For Linux, be strict about architecture matching - same logic as has_compatible_brave_asset
|
||||
let arch_pattern = if arch == "arm64" { "arm64" } else { "amd64" };
|
||||
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to DEB packages
|
||||
assets.iter().find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains(arch_pattern) && name.ends_with(".deb")
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to any ZIP
|
||||
assets.iter().find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.ends_with(".zip")
|
||||
})
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to any DEB
|
||||
assets.iter().find(|asset| asset.name.ends_with(".deb"))
|
||||
})
|
||||
.or_else(|| {
|
||||
// Last fallback to RPM if no ZIP or DEB found
|
||||
assets.iter().find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("x86_64") && name.ends_with(".rpm")
|
||||
})
|
||||
})
|
||||
assets.iter().find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("linux") && name.contains(arch_pattern) && name.ends_with(".zip")
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
@@ -326,6 +345,35 @@ impl Downloader {
|
||||
asset.map(|a| a.browser_download_url.clone())
|
||||
}
|
||||
|
||||
/// Find the appropriate Camoufox asset for the current platform and architecture
|
||||
fn find_camoufox_asset(
|
||||
&self,
|
||||
assets: &[crate::browser::GithubAsset],
|
||||
os: &str,
|
||||
arch: &str,
|
||||
) -> Option<String> {
|
||||
// Camoufox asset naming pattern: camoufox-{version}-{release}-{os}.{arch}.zip
|
||||
let (os_name, arch_name) = match (os, arch) {
|
||||
("windows", "x64") => ("win", "x86_64"),
|
||||
("windows", "arm64") => ("win", "arm64"),
|
||||
("linux", "x64") => ("lin", "x86_64"),
|
||||
("linux", "arm64") => ("lin", "arm64"),
|
||||
("macos", "x64") => ("mac", "x86_64"),
|
||||
("macos", "arm64") => ("mac", "arm64"),
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
// Look for assets matching the pattern
|
||||
let asset = assets.iter().find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.starts_with("camoufox-")
|
||||
&& name.contains(&format!("-{os_name}.{arch_name}.zip"))
|
||||
&& name.ends_with(".zip")
|
||||
});
|
||||
|
||||
asset.map(|a| a.browser_download_url.clone())
|
||||
}
|
||||
|
||||
pub async fn download_browser<R: tauri::Runtime>(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle<R>,
|
||||
@@ -459,7 +507,6 @@ mod tests {
|
||||
base_url.clone(), // github_api_base
|
||||
base_url.clone(), // chromium_api_base
|
||||
base_url.clone(), // tor_archive_base
|
||||
base_url.clone(), // mozilla_download_base
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ impl DownloadedBrowsersRegistry {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn load() -> Result<Self, Box<dyn std::error::Error>> {
|
||||
pub fn load() -> Result<Self, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let registry_path = Self::get_registry_path()?;
|
||||
|
||||
if !registry_path.exists() {
|
||||
@@ -39,7 +39,7 @@ impl DownloadedBrowsersRegistry {
|
||||
Ok(registry)
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub fn save(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let registry_path = Self::get_registry_path()?;
|
||||
|
||||
// Ensure parent directory exists
|
||||
@@ -52,7 +52,7 @@ impl DownloadedBrowsersRegistry {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
@@ -146,7 +146,7 @@ impl DownloadedBrowsersRegistry {
|
||||
&mut self,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
) -> 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
|
||||
if info.file_path.exists() {
|
||||
@@ -180,7 +180,7 @@ impl DownloadedBrowsersRegistry {
|
||||
pub fn cleanup_unused_binaries(
|
||||
&mut self,
|
||||
active_profiles: &[(String, String)], // (browser, version) pairs
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let active_set: std::collections::HashSet<(String, String)> =
|
||||
active_profiles.iter().cloned().collect();
|
||||
let mut cleaned_up = Vec::new();
|
||||
@@ -189,8 +189,22 @@ impl DownloadedBrowsersRegistry {
|
||||
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())) {
|
||||
to_remove.push((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
|
||||
});
|
||||
|
||||
if !is_in_use {
|
||||
to_remove.push((browser.clone(), version.clone()));
|
||||
println!("Marking for removal: {browser} {version} (not used by any profile)");
|
||||
} else {
|
||||
println!("Keeping: {browser} {version} (in use by profile)");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,9 +215,16 @@ impl DownloadedBrowsersRegistry {
|
||||
eprintln!("Failed to cleanup unused binary {browser}:{version}: {e}");
|
||||
} else {
|
||||
cleaned_up.push(format!("{browser} {version}"));
|
||||
println!("Successfully removed unused binary: {browser} {version}");
|
||||
}
|
||||
}
|
||||
|
||||
if cleaned_up.is_empty() {
|
||||
println!("No unused binaries found to clean up");
|
||||
} else {
|
||||
println!("Cleaned up {} unused binaries", cleaned_up.len());
|
||||
}
|
||||
|
||||
Ok(cleaned_up)
|
||||
}
|
||||
|
||||
@@ -217,6 +238,45 @@ impl DownloadedBrowsersRegistry {
|
||||
.map(|profile| (profile.browser.clone(), profile.version.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Verify that all registered browsers actually exist on disk and clean up stale entries
|
||||
pub fn verify_and_cleanup_stale_entries(
|
||||
&mut 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();
|
||||
|
||||
for (browser_str, version) in browsers_to_check {
|
||||
if let Ok(browser_type) = BrowserType::from_str(&browser_str) {
|
||||
let browser = create_browser(browser_type);
|
||||
if !browser.is_version_downloaded(&version, &binaries_dir) {
|
||||
// Files don't exist, remove from registry
|
||||
if let Some(_removed) = self.remove_browser(&browser_str, &version) {
|
||||
cleaned_up.push(format!("{browser_str} {version}"));
|
||||
println!("Removed stale registry entry for {browser_str} {version}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !cleaned_up.is_empty() {
|
||||
self.save()?;
|
||||
}
|
||||
|
||||
Ok(cleaned_up)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
+821
-177
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,171 @@
|
||||
use crate::browser::GithubRelease;
|
||||
use directories::BaseDirs;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tauri::Emitter;
|
||||
use tokio::fs;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const MMDB_REPO: &str = "P3TERX/GeoLite.mmdb";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GeoIPDownloadProgress {
|
||||
pub stage: String, // "downloading", "extracting", "completed"
|
||||
pub percentage: f64,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
pub struct GeoIPDownloader {
|
||||
client: Client,
|
||||
}
|
||||
|
||||
impl GeoIPDownloader {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
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")?;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
let cache_dir = base_dirs
|
||||
.data_local_dir()
|
||||
.join("camoufox")
|
||||
.join("camoufox")
|
||||
.join("Cache");
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let cache_dir = base_dirs.cache_dir().join("camoufox");
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let cache_dir = base_dirs.cache_dir().join("camoufox");
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))]
|
||||
let cache_dir = base_dirs.cache_dir().join("camoufox");
|
||||
|
||||
Ok(cache_dir)
|
||||
}
|
||||
|
||||
fn get_mmdb_file_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(Self::get_cache_dir()?.join("GeoLite2-City.mmdb"))
|
||||
}
|
||||
|
||||
pub fn is_geoip_database_available() -> bool {
|
||||
if let Ok(mmdb_path) = Self::get_mmdb_file_path() {
|
||||
mmdb_path.exists()
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn find_city_mmdb_asset(&self, release: &GithubRelease) -> Option<String> {
|
||||
for asset in &release.assets {
|
||||
if asset.name.ends_with("-City.mmdb") {
|
||||
return Some(asset.browser_download_url.clone());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn download_geoip_database(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Emit initial progress
|
||||
let _ = app_handle.emit(
|
||||
"geoip-download-progress",
|
||||
GeoIPDownloadProgress {
|
||||
stage: "downloading".to_string(),
|
||||
percentage: 0.0,
|
||||
message: "Starting GeoIP database download".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
// Fetch latest release from GitHub
|
||||
let releases = self.fetch_geoip_releases().await?;
|
||||
let latest_release = releases.first().ok_or("No GeoIP database releases found")?;
|
||||
|
||||
let download_url = self
|
||||
.find_city_mmdb_asset(latest_release)
|
||||
.ok_or("No compatible GeoIP database asset found")?;
|
||||
|
||||
// Create cache directory
|
||||
let cache_dir = Self::get_cache_dir()?;
|
||||
fs::create_dir_all(&cache_dir).await?;
|
||||
|
||||
let mmdb_path = Self::get_mmdb_file_path()?;
|
||||
|
||||
// Download the file
|
||||
let response = self.client.get(&download_url).send().await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to download GeoIP database: HTTP {}",
|
||||
response.status()
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let total_size = response.content_length().unwrap_or(0);
|
||||
let mut downloaded = 0;
|
||||
let mut file = fs::File::create(&mmdb_path).await?;
|
||||
let mut stream = response.bytes_stream();
|
||||
|
||||
use futures_util::StreamExt;
|
||||
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 _ = app_handle.emit(
|
||||
"geoip-download-progress",
|
||||
GeoIPDownloadProgress {
|
||||
stage: "downloading".to_string(),
|
||||
percentage,
|
||||
message: format!("Downloaded {downloaded} / {total_size} bytes"),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
file.flush().await?;
|
||||
|
||||
// Emit completion
|
||||
let _ = app_handle.emit(
|
||||
"geoip-download-progress",
|
||||
GeoIPDownloadProgress {
|
||||
stage: "completed".to_string(),
|
||||
percentage: 100.0,
|
||||
message: "GeoIP database download completed".to_string(),
|
||||
},
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn fetch_geoip_releases(
|
||||
&self,
|
||||
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let url = format!("https://api.github.com/repos/{MMDB_REPO}/releases");
|
||||
let response = self
|
||||
.client
|
||||
.get(&url)
|
||||
.header("User-Agent", "Mozilla/5.0 (compatible; donutbrowser)")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(format!("Failed to fetch releases: HTTP {}", response.status()).into());
|
||||
}
|
||||
|
||||
let releases: Vec<GithubRelease> = response.json().await?;
|
||||
Ok(releases)
|
||||
}
|
||||
}
|
||||
+178
-39
@@ -1,4 +1,5 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use std::env;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
@@ -12,25 +13,29 @@ mod auto_updater;
|
||||
mod browser;
|
||||
mod browser_runner;
|
||||
mod browser_version_service;
|
||||
mod camoufox;
|
||||
mod default_browser;
|
||||
mod download;
|
||||
mod downloaded_browsers;
|
||||
mod extraction;
|
||||
mod geoip_downloader;
|
||||
|
||||
mod profile_importer;
|
||||
mod proxy_manager;
|
||||
mod settings_manager;
|
||||
mod system_utils;
|
||||
mod theme_detector;
|
||||
mod version_updater;
|
||||
|
||||
extern crate lazy_static;
|
||||
|
||||
use browser_runner::{
|
||||
check_browser_exists, check_browser_status, create_browser_profile_new, delete_profile,
|
||||
download_browser, 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_profile_proxy,
|
||||
update_profile_version,
|
||||
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,
|
||||
};
|
||||
|
||||
use settings_manager::{
|
||||
@@ -48,8 +53,7 @@ use version_updater::{
|
||||
|
||||
use auto_updater::{
|
||||
check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification,
|
||||
is_auto_update_download, is_browser_disabled_for_update, mark_auto_update_download,
|
||||
remove_auto_update_download,
|
||||
is_browser_disabled_for_update,
|
||||
};
|
||||
|
||||
use app_auto_updater::{
|
||||
@@ -60,6 +64,8 @@ use profile_importer::{detect_existing_profiles, import_browser_profile};
|
||||
|
||||
use theme_detector::get_system_theme;
|
||||
|
||||
use system_utils::{get_system_locale, get_system_timezone};
|
||||
|
||||
// Trait to extend WebviewWindow with transparent titlebar functionality
|
||||
pub trait WindowExt {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -111,20 +117,16 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
|
||||
// Check if the main window exists and is ready
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
// Window is visible, emit event directly
|
||||
println!("Main window is visible, emitting show-profile-selector event");
|
||||
app
|
||||
.emit("show-profile-selector", url.clone())
|
||||
.map_err(|e| format!("Failed to emit URL open event: {e}"))?;
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
} else {
|
||||
// Window not visible yet - add to pending URLs
|
||||
println!("Main window not visible, adding URL to pending list");
|
||||
let mut pending = PENDING_URLS.lock().unwrap();
|
||||
pending.push(url);
|
||||
}
|
||||
println!("Main window exists");
|
||||
|
||||
// Try to show and focus the window first
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
|
||||
app
|
||||
.emit("show-profile-selector", url.clone())
|
||||
.map_err(|e| format!("Failed to emit URL open event: {e}"))?;
|
||||
} else {
|
||||
// Window doesn't exist yet - add to pending URLs
|
||||
println!("Main window doesn't exist, adding URL to pending list");
|
||||
@@ -137,6 +139,8 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
|
||||
#[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();
|
||||
@@ -144,12 +148,24 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
|
||||
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()) {
|
||||
@@ -164,14 +180,71 @@ async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bo
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_stored_proxy(
|
||||
name: String,
|
||||
proxy_settings: crate::browser::ProxySettings,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_stored_proxy(name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to create stored proxy: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>, String> {
|
||||
Ok(crate::proxy_manager::PROXY_MANAGER.get_stored_proxies())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_stored_proxy(
|
||||
proxy_id: String,
|
||||
name: Option<String>,
|
||||
proxy_settings: Option<crate::browser::ProxySettings>,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.update_stored_proxy(&proxy_id, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_stored_proxy(proxy_id: String) -> Result<(), String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.delete_stored_proxy(&proxy_id)
|
||||
.map_err(|e| format!("Failed to delete stored proxy: {e}"))
|
||||
}
|
||||
|
||||
#[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}"))
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let args: Vec<String> = env::args().collect();
|
||||
let startup_url = args.iter().find(|arg| arg.starts_with("http")).cloned();
|
||||
|
||||
if let Some(url) = startup_url.clone() {
|
||||
println!("Found startup URL in command line: {url}");
|
||||
let mut pending = PENDING_URLS.lock().unwrap();
|
||||
pending.push(url.clone());
|
||||
}
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_single_instance::init(|_, args, _cwd| {
|
||||
println!("Single instance triggered with args: {args:?}");
|
||||
}))
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_fs::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_deep_link::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_macos_permissions::init())
|
||||
.setup(|app| {
|
||||
// Create the main window programmatically
|
||||
#[allow(unused_variables)]
|
||||
@@ -179,7 +252,10 @@ pub fn run() {
|
||||
.title("Donut Browser")
|
||||
.inner_size(900.0, 600.0)
|
||||
.resizable(false)
|
||||
.fullscreen(false);
|
||||
.fullscreen(false)
|
||||
.center()
|
||||
.focused(true)
|
||||
.visible(true);
|
||||
|
||||
#[allow(unused_variables)]
|
||||
let window = win_builder.build().unwrap();
|
||||
@@ -192,22 +268,53 @@ 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();
|
||||
|
||||
#[cfg(any(windows, target_os = "linux"))]
|
||||
{
|
||||
// For Windows and Linux, register all deep links at runtime for development
|
||||
app.deep_link().register_all()?;
|
||||
if let Err(e) = app.deep_link().register_all() {
|
||||
eprintln!("Failed to register deep links: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// On macOS, try to register deep links for development builds
|
||||
if let Err(e) = app.deep_link().register_all() {
|
||||
eprintln!(
|
||||
"Note: Deep link registration failed on macOS (this is normal for production): {e}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle deep links - this works for both scenarios:
|
||||
// 1. App is running and URL is opened
|
||||
// 2. App is not running and URL causes app to launch
|
||||
app.deep_link().on_open_url({
|
||||
let handle = handle.clone();
|
||||
move |event| {
|
||||
let urls = event.urls();
|
||||
println!("Deep link event received with {} URLs", urls.len());
|
||||
|
||||
for url in urls {
|
||||
let url_string = url.to_string();
|
||||
println!("Deep link received: {url_string}");
|
||||
@@ -225,25 +332,50 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
if let Some(startup_url) = startup_url {
|
||||
let handle_clone = handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Processing startup URL from command line: {startup_url}");
|
||||
if let Err(e) = handle_url_open(handle_clone, startup_url.clone()).await {
|
||||
eprintln!("Failed to handle startup URL: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize and start background version updater
|
||||
let app_handle = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let version_updater = get_version_updater();
|
||||
let mut updater_guard = version_updater.lock().await;
|
||||
|
||||
// Set the app handle
|
||||
updater_guard.set_app_handle(app_handle).await;
|
||||
{
|
||||
let mut updater_guard = version_updater.lock().await;
|
||||
updater_guard.set_app_handle(app_handle);
|
||||
}
|
||||
|
||||
// Start the background updates
|
||||
updater_guard.start_background_updates().await;
|
||||
// Run startup check without holding the lock
|
||||
{
|
||||
let updater_guard = version_updater.lock().await;
|
||||
if let Err(e) = updater_guard.start_background_updates().await {
|
||||
eprintln!("Failed to start background updates: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start the background update task separately
|
||||
tauri::async_runtime::spawn(async move {
|
||||
version_updater::VersionUpdater::run_background_task().await;
|
||||
});
|
||||
|
||||
let app_handle_auto_updater = app.handle().clone();
|
||||
|
||||
// Start the auto-update check task separately
|
||||
tauri::async_runtime::spawn(async move {
|
||||
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
|
||||
});
|
||||
|
||||
// Check for app updates at startup
|
||||
let app_handle_update = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Add a small delay to ensure the app is fully loaded
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(3)).await;
|
||||
|
||||
println!("Starting app update check at startup...");
|
||||
let updater = app_auto_updater::AppAutoUpdater::new();
|
||||
match updater.check_for_updates().await {
|
||||
@@ -283,6 +415,7 @@ pub fn run() {
|
||||
fetch_browser_versions_cached_first,
|
||||
fetch_browser_versions_with_count_cached_first,
|
||||
get_downloaded_browser_versions,
|
||||
get_browser_release_types,
|
||||
update_profile_proxy,
|
||||
update_profile_version,
|
||||
check_browser_status,
|
||||
@@ -305,15 +438,21 @@ pub fn run() {
|
||||
is_browser_disabled_for_update,
|
||||
dismiss_update_notification,
|
||||
complete_browser_update_with_auto_update,
|
||||
mark_auto_update_download,
|
||||
remove_auto_update_download,
|
||||
is_auto_update_download,
|
||||
check_for_app_updates,
|
||||
check_for_app_updates_manual,
|
||||
download_and_install_app_update,
|
||||
get_system_theme,
|
||||
detect_existing_profiles,
|
||||
import_browser_profile,
|
||||
check_missing_binaries,
|
||||
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,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -55,6 +55,9 @@ impl ProfileImporter {
|
||||
// Detect Zen Browser profiles
|
||||
detected_profiles.extend(self.detect_zen_browser_profiles()?);
|
||||
|
||||
// Detect TOR Browser profiles
|
||||
detected_profiles.extend(self.detect_tor_browser_profiles()?);
|
||||
|
||||
// Remove duplicates based on path
|
||||
let mut seen_paths = HashSet::new();
|
||||
let unique_profiles: Vec<DetectedProfile> = detected_profiles
|
||||
@@ -80,9 +83,16 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(app_data) = self.base_dirs.data_dir() {
|
||||
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
// Primary location in AppData\Roaming
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let firefox_dir = app_data.join("Mozilla/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
|
||||
// Also check AppData\Local for portable installations
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let firefox_local_dir = local_app_data.join("Mozilla/Firefox/Profiles");
|
||||
if firefox_local_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_local_dir, "firefox")?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,12 +127,11 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(app_data) = self.base_dirs.data_dir() {
|
||||
// Firefox Developer Edition on Windows typically uses separate directories
|
||||
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
}
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
// Firefox Developer Edition on Windows typically uses separate directories
|
||||
let firefox_dev_dir = app_data.join("Mozilla/Firefox Developer Edition/Profiles");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,10 +165,9 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
|
||||
let chrome_dir = local_app_data.join("Google/Chrome/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
|
||||
}
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let chrome_dir = local_app_data.join("Google/Chrome/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -186,10 +194,9 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
|
||||
let chromium_dir = local_app_data.join("Chromium/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
|
||||
}
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let chromium_dir = local_app_data.join("Chromium/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -216,10 +223,9 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(local_app_data) = self.base_dirs.data_local_dir() {
|
||||
let brave_dir = local_app_data.join("BraveSoftware/Brave-Browser/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
|
||||
}
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let brave_dir = local_app_data.join("BraveSoftware/Brave-Browser/User Data");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -251,9 +257,16 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(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")?);
|
||||
// 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")?);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -283,10 +296,9 @@ impl ProfileImporter {
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if let Some(app_data) = self.base_dirs.data_dir() {
|
||||
let zen_dir = app_data.join("Zen/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let zen_dir = app_data.join("Zen/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
@@ -298,6 +310,107 @@ 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,
|
||||
@@ -551,28 +664,32 @@ impl ProfileImporter {
|
||||
return Err(format!("Profile with name '{new_profile_name}' already exists").into());
|
||||
}
|
||||
|
||||
// Create the new profile directory
|
||||
let snake_case_name = new_profile_name.to_lowercase().replace(' ', "_");
|
||||
// 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 new_profile_path = profiles_dir.join(&snake_case_name);
|
||||
let new_profile_uuid_dir = profiles_dir.join(profile_id.to_string());
|
||||
let new_profile_data_dir = new_profile_uuid_dir.join("profile");
|
||||
|
||||
create_dir_all(&new_profile_path)?;
|
||||
create_dir_all(&new_profile_uuid_dir)?;
|
||||
create_dir_all(&new_profile_data_dir)?;
|
||||
|
||||
// Copy all files from source to destination
|
||||
Self::copy_directory_recursive(source_path, &new_profile_path)?;
|
||||
// Copy all files from source to destination profile subdirectory
|
||||
Self::copy_directory_recursive(source_path, &new_profile_data_dir)?;
|
||||
|
||||
// Create the profile metadata without overwriting the imported data
|
||||
// We need to find a suitable version for this browser type
|
||||
let available_versions = self.get_default_version_for_browser(browser_type)?;
|
||||
|
||||
let profile = crate::browser_runner::BrowserProfile {
|
||||
id: profile_id,
|
||||
name: new_profile_name.to_string(),
|
||||
browser: browser_type.to_string(),
|
||||
version: available_versions,
|
||||
profile_path: new_profile_path.to_string_lossy().to_string(),
|
||||
proxy: None,
|
||||
proxy_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
@@ -592,7 +709,7 @@ impl ProfileImporter {
|
||||
&self,
|
||||
browser_type: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Try to get a downloaded version first, fallback to a reasonable default
|
||||
// Check if any version of the browser is downloaded
|
||||
let registry =
|
||||
crate::downloaded_browsers::DownloadedBrowsersRegistry::load().unwrap_or_default();
|
||||
let downloaded_versions = registry.get_downloaded_versions(browser_type);
|
||||
@@ -601,17 +718,12 @@ impl ProfileImporter {
|
||||
return Ok(version.clone());
|
||||
}
|
||||
|
||||
// If no downloaded versions, return a sensible default
|
||||
match browser_type {
|
||||
"firefox" => Ok("latest".to_string()),
|
||||
"firefox-developer" => Ok("latest".to_string()),
|
||||
"chromium" => Ok("latest".to_string()),
|
||||
"brave" => Ok("latest".to_string()),
|
||||
"zen" => Ok("latest".to_string()),
|
||||
"mullvad-browser" => Ok("13.5.16".to_string()), // Mullvad Browser common version
|
||||
"tor-browser" => Ok("latest".to_string()),
|
||||
_ => Ok("latest".to_string()),
|
||||
}
|
||||
// If no downloaded versions found, return an error
|
||||
Err(format!(
|
||||
"No downloaded versions found for browser '{}'. Please download a version of {} first before importing profiles.",
|
||||
browser_type,
|
||||
self.get_browser_display_name(browser_type)
|
||||
).into())
|
||||
}
|
||||
|
||||
/// Recursively copy directory contents
|
||||
|
||||
+807
-27
@@ -1,6 +1,9 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
@@ -11,30 +14,245 @@ use crate::browser::ProxySettings;
|
||||
pub struct ProxyInfo {
|
||||
pub id: String,
|
||||
pub local_url: String,
|
||||
pub upstream_url: String,
|
||||
pub upstream_host: String,
|
||||
pub upstream_port: u16,
|
||||
pub upstream_type: String,
|
||||
pub local_port: u16,
|
||||
}
|
||||
|
||||
// Global proxy manager to track active proxies
|
||||
// Stored proxy configuration with name and ID for reuse
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredProxy {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub proxy_settings: ProxySettings,
|
||||
}
|
||||
|
||||
impl StoredProxy {
|
||||
pub fn new(name: String, proxy_settings: ProxySettings) -> Self {
|
||||
Self {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
proxy_settings,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_settings(&mut self, proxy_settings: ProxySettings) {
|
||||
self.proxy_settings = proxy_settings;
|
||||
}
|
||||
|
||||
pub fn update_name(&mut self, name: String) {
|
||||
self.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
// Global proxy manager to track active proxies and stored proxy configurations
|
||||
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, (String, u16)>>, // Maps profile name to (upstream_url, port)
|
||||
profile_proxies: Mutex<HashMap<String, ProxySettings>>, // Maps profile name to proxy settings
|
||||
stored_proxies: Mutex<HashMap<String, StoredProxy>>, // Maps proxy ID to stored proxy
|
||||
base_dirs: BaseDirs,
|
||||
}
|
||||
|
||||
impl ProxyManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
let base_dirs = BaseDirs::new().expect("Failed to get base directories");
|
||||
let manager = Self {
|
||||
active_proxies: Mutex::new(HashMap::new()),
|
||||
profile_proxies: Mutex::new(HashMap::new()),
|
||||
stored_proxies: Mutex::new(HashMap::new()),
|
||||
base_dirs,
|
||||
};
|
||||
|
||||
// Load stored proxies on initialization
|
||||
if let Err(e) = manager.load_stored_proxies() {
|
||||
eprintln!("Warning: Failed to load stored proxies: {e}");
|
||||
}
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
// Start a proxy for a given upstream URL and associate it with a browser process ID
|
||||
// Get the path to the proxies directory
|
||||
fn get_proxies_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("proxies");
|
||||
path
|
||||
}
|
||||
|
||||
// Get the path to a specific proxy file
|
||||
fn get_proxy_file_path(&self, proxy_id: &str) -> PathBuf {
|
||||
self.get_proxies_dir().join(format!("{proxy_id}.json"))
|
||||
}
|
||||
|
||||
// Load stored proxies from disk
|
||||
fn load_stored_proxies(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let proxies_dir = self.get_proxies_dir();
|
||||
|
||||
if !proxies_dir.exists() {
|
||||
return Ok(()); // No proxies directory yet
|
||||
}
|
||||
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
|
||||
// Read all JSON files from the proxies directory
|
||||
for entry in fs::read_dir(&proxies_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.extension().is_some_and(|ext| ext == "json") {
|
||||
let content = fs::read_to_string(&path)?;
|
||||
let proxy: StoredProxy = serde_json::from_str(&content)?;
|
||||
stored_proxies.insert(proxy.id.clone(), proxy);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Save a single proxy to disk
|
||||
fn save_proxy(&self, proxy: &StoredProxy) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let proxies_dir = self.get_proxies_dir();
|
||||
|
||||
// Ensure directory exists
|
||||
fs::create_dir_all(&proxies_dir)?;
|
||||
|
||||
let proxy_file = self.get_proxy_file_path(&proxy.id);
|
||||
let content = serde_json::to_string_pretty(proxy)?;
|
||||
fs::write(&proxy_file, content)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Delete a proxy file from disk
|
||||
fn delete_proxy_file(&self, proxy_id: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let proxy_file = self.get_proxy_file_path(proxy_id);
|
||||
if proxy_file.exists() {
|
||||
fs::remove_file(proxy_file)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Create a new stored proxy
|
||||
pub fn create_stored_proxy(
|
||||
&self,
|
||||
name: String,
|
||||
proxy_settings: ProxySettings,
|
||||
) -> Result<StoredProxy, String> {
|
||||
// Check if name already exists
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
if stored_proxies.values().any(|p| p.name == name) {
|
||||
return Err(format!("Proxy with name '{name}' already exists"));
|
||||
}
|
||||
}
|
||||
|
||||
let stored_proxy = StoredProxy::new(name, proxy_settings);
|
||||
|
||||
{
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.insert(stored_proxy.id.clone(), stored_proxy.clone());
|
||||
}
|
||||
|
||||
if let Err(e) = self.save_proxy(&stored_proxy) {
|
||||
eprintln!("Warning: Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
Ok(stored_proxy)
|
||||
}
|
||||
|
||||
// Get all stored proxies
|
||||
pub fn get_stored_proxies(&self) -> Vec<StoredProxy> {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies.values().cloned().collect()
|
||||
}
|
||||
|
||||
// Get a stored proxy by ID
|
||||
|
||||
// Update a stored proxy
|
||||
pub fn update_stored_proxy(
|
||||
&self,
|
||||
proxy_id: &str,
|
||||
name: Option<String>,
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
) -> Result<StoredProxy, String> {
|
||||
// First, check for conflicts without holding a mutable reference
|
||||
{
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
|
||||
// Check if proxy exists
|
||||
if !stored_proxies.contains_key(proxy_id) {
|
||||
return Err(format!("Proxy with ID '{proxy_id}' not found"));
|
||||
}
|
||||
|
||||
// Check if new name conflicts with existing proxies
|
||||
if let Some(ref new_name) = name {
|
||||
if stored_proxies
|
||||
.values()
|
||||
.any(|p| p.id != proxy_id && p.name == *new_name)
|
||||
{
|
||||
return Err(format!("Proxy with name '{new_name}' already exists"));
|
||||
}
|
||||
}
|
||||
} // Release the lock here
|
||||
|
||||
// Now get mutable access for updates
|
||||
let updated_proxy = {
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
let stored_proxy = stored_proxies.get_mut(proxy_id).unwrap(); // Safe because we checked above
|
||||
|
||||
if let Some(new_name) = name {
|
||||
stored_proxy.update_name(new_name);
|
||||
}
|
||||
|
||||
if let Some(new_settings) = proxy_settings {
|
||||
stored_proxy.update_settings(new_settings);
|
||||
}
|
||||
|
||||
stored_proxy.clone()
|
||||
};
|
||||
|
||||
if let Err(e) = self.save_proxy(&updated_proxy) {
|
||||
eprintln!("Warning: Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
Ok(updated_proxy)
|
||||
}
|
||||
|
||||
// Delete a stored proxy
|
||||
pub fn delete_stored_proxy(&self, proxy_id: &str) -> Result<(), String> {
|
||||
{
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
if stored_proxies.remove(proxy_id).is_none() {
|
||||
return Err(format!("Proxy with ID '{proxy_id}' not found"));
|
||||
}
|
||||
}
|
||||
|
||||
if let Err(e) = self.delete_proxy_file(proxy_id) {
|
||||
eprintln!("Warning: Failed to delete proxy file: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Get proxy settings for a stored proxy ID
|
||||
pub fn get_proxy_settings_by_id(&self, proxy_id: &str) -> Option<ProxySettings> {
|
||||
let stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
stored_proxies
|
||||
.get(proxy_id)
|
||||
.map(|p| p.proxy_settings.clone())
|
||||
}
|
||||
|
||||
// Start a proxy for given proxy settings and associate it with a browser process ID
|
||||
pub async fn start_proxy(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
upstream_url: &str,
|
||||
proxy_settings: &ProxySettings,
|
||||
browser_pid: u32,
|
||||
profile_name: Option<&str>,
|
||||
) -> Result<ProxySettings, String> {
|
||||
@@ -43,10 +261,11 @@ impl ProxyManager {
|
||||
let proxies = self.active_proxies.lock().unwrap();
|
||||
if let Some(proxy) = proxies.get(&browser_pid) {
|
||||
return Ok(ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "http".to_string(),
|
||||
host: "localhost".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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -54,31 +273,62 @@ impl ProxyManager {
|
||||
// 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).map(|(_, port)| *port)
|
||||
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
|
||||
})
|
||||
.map(|p| p.local_port)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Start a new proxy using the nodecar binary
|
||||
// Start a new proxy using the nodecar binary with the correct CLI interface
|
||||
let mut nodecar = app_handle
|
||||
.shell()
|
||||
.sidecar("nodecar")
|
||||
.unwrap()
|
||||
.map_err(|e| format!("Failed to create sidecar: {e}"))?
|
||||
.arg("proxy")
|
||||
.arg("start")
|
||||
.arg("-u")
|
||||
.arg(upstream_url);
|
||||
.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
|
||||
if let Some(port) = preferred_port {
|
||||
nodecar = nodecar.arg("-p").arg(port.to_string());
|
||||
nodecar = nodecar.arg("--port").arg(port.to_string());
|
||||
}
|
||||
|
||||
let output = nodecar.output().await.unwrap();
|
||||
// Execute the command and wait for it to complete
|
||||
// The nodecar binary should start the worker and then exit
|
||||
let output = nodecar
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute nodecar: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Proxy start failed: {stderr}"));
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
return Err(format!(
|
||||
"Proxy start failed - stdout: {stdout}, stderr: {stderr}"
|
||||
));
|
||||
}
|
||||
|
||||
let json_string =
|
||||
@@ -95,15 +345,13 @@ impl ProxyManager {
|
||||
.as_str()
|
||||
.ok_or("Missing local URL")?
|
||||
.to_string();
|
||||
let upstream_url_str = json["upstreamUrl"]
|
||||
.as_str()
|
||||
.ok_or("Missing upstream URL")?
|
||||
.to_string();
|
||||
|
||||
let proxy_info = ProxyInfo {
|
||||
id: id.to_string(),
|
||||
local_url,
|
||||
upstream_url: upstream_url_str.clone(),
|
||||
upstream_host: proxy_settings.host.clone(),
|
||||
upstream_port: proxy_settings.port,
|
||||
upstream_type: proxy_settings.proxy_type.clone(),
|
||||
local_port,
|
||||
};
|
||||
|
||||
@@ -116,15 +364,16 @@ 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(), (upstream_url_str, local_port));
|
||||
profile_proxies.insert(name.to_string(), proxy_settings.clone());
|
||||
}
|
||||
|
||||
// Return proxy settings for the browser
|
||||
Ok(ProxySettings {
|
||||
enabled: true,
|
||||
proxy_type: "http".to_string(),
|
||||
host: "localhost".to_string(),
|
||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||
port: proxy_info.local_port,
|
||||
username: None,
|
||||
password: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -164,24 +413,555 @@ impl ProxyManager {
|
||||
}
|
||||
|
||||
// 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 {
|
||||
enabled: true,
|
||||
proxy_type: "http".to_string(),
|
||||
host: "localhost".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
|
||||
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<(String, u16)> {
|
||||
#[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();
|
||||
if let Some(proxy_info) = proxies.remove(&old_pid) {
|
||||
proxies.insert(new_pid, proxy_info);
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("No proxy found for PID {old_pid}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a singleton instance of the proxy manager
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref PROXY_MANAGER: ProxyManager = ProxyManager::new();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
|
||||
// Mock HTTP server for testing
|
||||
|
||||
use http_body_util::Full;
|
||||
use hyper::body::Bytes;
|
||||
use hyper::server::conn::http1;
|
||||
use hyper::service::service_fn;
|
||||
use hyper::Response;
|
||||
use hyper_util::rt::TokioIo;
|
||||
use tokio::net::TcpListener;
|
||||
|
||||
// Helper function to build nodecar binary for testing
|
||||
async fn ensure_nodecar_binary() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
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_dist = nodecar_dir.join("dist");
|
||||
let nodecar_binary = nodecar_dist.join("nodecar");
|
||||
|
||||
// Check if binary already exists
|
||||
if nodecar_binary.exists() {
|
||||
return Ok(nodecar_binary);
|
||||
}
|
||||
|
||||
// Build the nodecar binary
|
||||
println!("Building nodecar binary for 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());
|
||||
}
|
||||
|
||||
// Determine the target architecture
|
||||
let target = if cfg!(target_arch = "aarch64") && cfg!(target_os = "macos") {
|
||||
"build:mac-aarch64"
|
||||
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "macos") {
|
||||
"build:mac-x86_64"
|
||||
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "linux") {
|
||||
"build:linux-x64"
|
||||
} else if cfg!(target_arch = "aarch64") && cfg!(target_os = "linux") {
|
||||
"build:linux-arm64"
|
||||
} else if cfg!(target_arch = "x86_64") && cfg!(target_os = "windows") {
|
||||
"build:win-x64"
|
||||
} else if cfg!(target_arch = "aarch64") && cfg!(target_os = "windows") {
|
||||
"build:win-arm64"
|
||||
} else {
|
||||
return Err("Unsupported target architecture for nodecar build".into());
|
||||
};
|
||||
|
||||
// Build the binary
|
||||
let build_status = Command::new("pnpm")
|
||||
.args(["run", target])
|
||||
.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)
|
||||
}
|
||||
|
||||
#[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
|
||||
let valid_settings = ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 8080,
|
||||
username: Some("user".to_string()),
|
||||
password: Some("pass".to_string()),
|
||||
};
|
||||
|
||||
assert!(!valid_settings.host.is_empty());
|
||||
assert!(valid_settings.port > 0);
|
||||
|
||||
// Test proxy settings with empty values
|
||||
let empty_settings = ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "".to_string(),
|
||||
port: 0,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
assert!(empty_settings.host.is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_manager_concurrent_access() {
|
||||
use std::sync::Arc;
|
||||
|
||||
let proxy_manager = Arc::new(ProxyManager::new());
|
||||
let mut handles = vec![];
|
||||
|
||||
// Spawn multiple tasks that access the proxy manager concurrently
|
||||
for i in 0..10 {
|
||||
let pm = proxy_manager.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let browser_pid = (1000 + i) as u32;
|
||||
let proxy_info = ProxyInfo {
|
||||
id: format!("proxy_{i}"),
|
||||
local_url: format!("http://127.0.0.1:{}", 8000 + i),
|
||||
upstream_host: "127.0.0.1".to_string(),
|
||||
upstream_port: 3128,
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: (8000 + i) as u16,
|
||||
};
|
||||
|
||||
// Add proxy
|
||||
{
|
||||
let mut active_proxies = pm.active_proxies.lock().unwrap();
|
||||
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);
|
||||
}
|
||||
|
||||
// Wait for all tasks to complete
|
||||
let results: Vec<u32> = futures_util::future::join_all(handles)
|
||||
.await
|
||||
.into_iter()
|
||||
.map(|r| r.unwrap())
|
||||
.collect();
|
||||
|
||||
// Verify all browser PIDs were processed
|
||||
assert_eq!(results.len(), 10);
|
||||
for (i, &browser_pid) in results.iter().enumerate() {
|
||||
assert_eq!(browser_pid, (1000 + i) as u32);
|
||||
}
|
||||
}
|
||||
|
||||
// Integration test that actually builds and uses nodecar binary
|
||||
#[tokio::test]
|
||||
async fn test_proxy_integration_with_real_nodecar() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// This test requires nodecar to be built and available
|
||||
let nodecar_path = ensure_nodecar_binary().await?;
|
||||
|
||||
// Start a mock upstream HTTP server
|
||||
let upstream_listener = TcpListener::bind("127.0.0.1:0").await?;
|
||||
let upstream_addr = upstream_listener.local_addr()?;
|
||||
|
||||
// Spawn upstream server
|
||||
let server_handle = tokio::spawn(async move {
|
||||
while let Ok((stream, _)) = upstream_listener.accept().await {
|
||||
let io = TokioIo::new(stream);
|
||||
tokio::task::spawn(async move {
|
||||
let _ = http1::Builder::new()
|
||||
.serve_connection(
|
||||
io,
|
||||
service_fn(|_req| async {
|
||||
Ok::<_, hyper::Error>(Response::new(Full::new(Bytes::from("Upstream OK"))))
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for server to start
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
|
||||
// Test nodecar proxy start command directly (using the binary itself, not node)
|
||||
let mut cmd = Command::new(&nodecar_path);
|
||||
cmd
|
||||
.arg("proxy")
|
||||
.arg("start")
|
||||
.arg("--host")
|
||||
.arg(upstream_addr.ip().to_string())
|
||||
.arg("--proxy-port")
|
||||
.arg(upstream_addr.port().to_string())
|
||||
.arg("--type")
|
||||
.arg("http");
|
||||
|
||||
// Set a timeout for the command
|
||||
let output = tokio::time::timeout(Duration::from_secs(10), async { cmd.output() }).await??;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Verify proxy configuration
|
||||
assert!(config["id"].is_string());
|
||||
assert!(config["localPort"].is_number());
|
||||
assert!(config["localUrl"].is_string());
|
||||
|
||||
let proxy_id = config["id"].as_str().unwrap();
|
||||
let local_port = config["localPort"].as_u64().unwrap();
|
||||
|
||||
// Wait for proxy worker to start
|
||||
println!("Waiting for proxy worker to start...");
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
|
||||
// Test that the local port is listening
|
||||
let mut port_test = Command::new("nc");
|
||||
port_test
|
||||
.arg("-z")
|
||||
.arg("127.0.0.1")
|
||||
.arg(local_port.to_string());
|
||||
|
||||
let port_output = port_test.output()?;
|
||||
if port_output.status.success() {
|
||||
println!("Proxy is listening on port {local_port}");
|
||||
} else {
|
||||
println!("Warning: Proxy port {local_port} is not listening");
|
||||
}
|
||||
|
||||
// Test stopping the proxy
|
||||
let mut stop_cmd = Command::new(&nodecar_path);
|
||||
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??;
|
||||
|
||||
assert!(stop_output.status.success());
|
||||
|
||||
println!("Integration test passed: nodecar proxy start/stop works correctly");
|
||||
} else {
|
||||
let stderr = String::from_utf8(output.stderr)?;
|
||||
eprintln!("Nodecar failed: {stderr}");
|
||||
return Err(format!("Nodecar command failed: {stderr}").into());
|
||||
}
|
||||
|
||||
// Clean up server
|
||||
server_handle.abort();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Test that validates the command line arguments are constructed correctly
|
||||
#[test]
|
||||
fn test_proxy_command_construction() {
|
||||
let proxy_settings = ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "proxy.example.com".to_string(),
|
||||
port: 8080,
|
||||
username: Some("user".to_string()),
|
||||
password: Some("pass".to_string()),
|
||||
};
|
||||
|
||||
// Test command arguments match expected format
|
||||
let _expected_args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"proxy.example.com",
|
||||
"--proxy-port",
|
||||
"8080",
|
||||
"--type",
|
||||
"http",
|
||||
"--username",
|
||||
"user",
|
||||
"--password",
|
||||
"pass",
|
||||
];
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
// Test the CLI detachment specifically - ensure the CLI exits properly
|
||||
#[tokio::test]
|
||||
async fn test_cli_exits_after_proxy_start() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let nodecar_path = ensure_nodecar_binary().await?;
|
||||
|
||||
// Test that the CLI exits quickly with a mock upstream
|
||||
let mut cmd = Command::new(&nodecar_path);
|
||||
cmd
|
||||
.arg("proxy")
|
||||
.arg("start")
|
||||
.arg("--host")
|
||||
.arg("httpbin.org")
|
||||
.arg("--proxy-port")
|
||||
.arg("80")
|
||||
.arg("--type")
|
||||
.arg("http");
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
let output = tokio::time::timeout(Duration::from_secs(3), async { cmd.output() }).await;
|
||||
|
||||
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)?;
|
||||
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Clean up - try to stop the proxy
|
||||
if let Some(proxy_id) = config["id"].as_str() {
|
||||
let mut stop_cmd = Command::new(&nodecar_path);
|
||||
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
||||
let _ = stop_cmd.output();
|
||||
}
|
||||
}
|
||||
|
||||
println!("CLI detachment test passed - CLI exited in {execution_time:?}");
|
||||
}
|
||||
Ok(Err(e)) => {
|
||||
return Err(format!("Command execution failed: {e}").into());
|
||||
}
|
||||
Err(_) => {
|
||||
return Err("CLI command timed out - this indicates improper detachment".into());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Test that validates proper CLI detachment behavior
|
||||
#[tokio::test]
|
||||
async fn test_cli_detachment_behavior() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let nodecar_path = ensure_nodecar_binary().await?;
|
||||
|
||||
// Test that the CLI command exits quickly even with a real upstream
|
||||
let mut cmd = Command::new(&nodecar_path);
|
||||
cmd
|
||||
.arg("proxy")
|
||||
.arg("start")
|
||||
.arg("--host")
|
||||
.arg("httpbin.org") // Use a known good endpoint
|
||||
.arg("--proxy-port")
|
||||
.arg("80")
|
||||
.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:?}");
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
||||
let proxy_id = config["id"].as_str().unwrap();
|
||||
|
||||
// Clean up
|
||||
let mut stop_cmd = Command::new(&nodecar_path);
|
||||
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
||||
let _ = stop_cmd.output();
|
||||
|
||||
println!("CLI detachment test passed");
|
||||
} else {
|
||||
// Even if the upstream fails, the CLI should still exit quickly
|
||||
println!("CLI command failed but exited quickly as expected");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Test that validates URL encoding for special characters in credentials
|
||||
#[tokio::test]
|
||||
async fn test_proxy_credentials_encoding() -> Result<(), Box<dyn std::error::Error>> {
|
||||
let nodecar_path = ensure_nodecar_binary().await?;
|
||||
|
||||
// Test with credentials that include special characters
|
||||
let mut cmd = Command::new(&nodecar_path);
|
||||
cmd
|
||||
.arg("proxy")
|
||||
.arg("start")
|
||||
.arg("--host")
|
||||
.arg("test.example.com")
|
||||
.arg("--proxy-port")
|
||||
.arg("8080")
|
||||
.arg("--type")
|
||||
.arg("http")
|
||||
.arg("--username")
|
||||
.arg("user@domain.com") // Contains @ symbol
|
||||
.arg("--password")
|
||||
.arg("pass word!"); // Contains space and special character
|
||||
|
||||
let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: serde_json::Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
let upstream_url = config["upstreamUrl"].as_str().unwrap();
|
||||
|
||||
println!("Generated upstream URL: {upstream_url}");
|
||||
|
||||
// Verify that special characters are properly encoded
|
||||
assert!(upstream_url.contains("user%40domain.com"));
|
||||
// The password may be encoded as "pass%20word!" or "pass%20word%21" depending on implementation
|
||||
assert!(upstream_url.contains("pass%20word"));
|
||||
|
||||
println!("URL encoding test passed - special characters handled correctly");
|
||||
|
||||
// Clean up
|
||||
let proxy_id = config["id"].as_str().unwrap();
|
||||
let mut stop_cmd = Command::new(&nodecar_path);
|
||||
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
||||
let _ = stop_cmd.output();
|
||||
} else {
|
||||
// This test might fail if the upstream doesn't exist, but we mainly care about URL construction
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let stderr = String::from_utf8(output.stderr)?;
|
||||
println!("Command failed (expected for non-existent upstream):");
|
||||
println!("Stdout: {stdout}");
|
||||
println!("Stderr: {stderr}");
|
||||
|
||||
// The important thing is that the command completed quickly
|
||||
println!("URL encoding test completed - credentials should be properly encoded");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::fs::{self, create_dir_all};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::browser_version_service::BrowserVersionService;
|
||||
use crate::version_updater;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct TableSortingSettings {
|
||||
@@ -25,40 +25,22 @@ impl Default for TableSortingSettings {
|
||||
pub struct AppSettings {
|
||||
#[serde(default)]
|
||||
pub set_as_default_browser: bool,
|
||||
#[serde(default = "default_show_settings_on_startup")]
|
||||
#[serde(default)]
|
||||
pub show_settings_on_startup: bool,
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String, // "light", "dark", or "system"
|
||||
#[serde(default = "default_auto_updates_enabled")]
|
||||
pub auto_updates_enabled: bool,
|
||||
#[serde(default = "default_auto_delete_unused_binaries")]
|
||||
pub auto_delete_unused_binaries: bool,
|
||||
}
|
||||
|
||||
fn default_show_settings_on_startup() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
"system".to_string()
|
||||
}
|
||||
|
||||
fn default_auto_updates_enabled() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_auto_delete_unused_binaries() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
set_as_default_browser: false,
|
||||
show_settings_on_startup: default_show_settings_on_startup(),
|
||||
theme: default_theme(),
|
||||
auto_updates_enabled: default_auto_updates_enabled(),
|
||||
auto_delete_unused_binaries: default_auto_delete_unused_binaries(),
|
||||
show_settings_on_startup: true,
|
||||
theme: "system".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -216,7 +198,9 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_all_version_cache_and_refetch() -> Result<(), String> {
|
||||
pub async fn clear_all_version_cache_and_refetch(
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let api_client = ApiClient::new();
|
||||
|
||||
// Clear all cache first
|
||||
@@ -224,23 +208,13 @@ pub async fn clear_all_version_cache_and_refetch() -> Result<(), String> {
|
||||
.clear_all_cache()
|
||||
.map_err(|e| format!("Failed to clear version cache: {e}"))?;
|
||||
|
||||
// Trigger auto-fetch for all supported browsers
|
||||
let service = BrowserVersionService::new();
|
||||
let supported_browsers = service.get_supported_browsers();
|
||||
let updater = version_updater::get_version_updater();
|
||||
let updater_guard = updater.lock().await;
|
||||
|
||||
for browser in supported_browsers {
|
||||
// Start background fetch for each browser (don't wait for completion)
|
||||
let service_clone = BrowserVersionService::new();
|
||||
let browser_clone = browser.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = service_clone
|
||||
.fetch_browser_versions_detailed(&browser_clone, false)
|
||||
.await
|
||||
{
|
||||
eprintln!("Background version fetch failed for {browser_clone}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
updater_guard
|
||||
.trigger_manual_update(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to trigger version update: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
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!("{:+03}:{:02}", 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())
|
||||
}
|
||||
+115
-108
@@ -1,4 +1,3 @@
|
||||
use crate::browser_version_service::BrowserVersionService;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
@@ -8,7 +7,10 @@ use std::sync::OnceLock;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
use tauri::Emitter;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::time::{interval, Interval};
|
||||
use tokio::time::interval;
|
||||
|
||||
use crate::auto_updater::AutoUpdater;
|
||||
use crate::browser_version_service::BrowserVersionService;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct VersionUpdateProgress {
|
||||
@@ -46,22 +48,23 @@ impl Default for BackgroundUpdateState {
|
||||
|
||||
pub struct VersionUpdater {
|
||||
version_service: BrowserVersionService,
|
||||
app_handle: Arc<Mutex<Option<tauri::AppHandle>>>,
|
||||
update_interval: Interval,
|
||||
auto_updater: AutoUpdater,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
}
|
||||
|
||||
impl VersionUpdater {
|
||||
pub fn new() -> Self {
|
||||
let mut update_interval = interval(Duration::from_secs(5 * 60)); // Check every 5 minutes
|
||||
update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
Self {
|
||||
version_service: BrowserVersionService::new(),
|
||||
app_handle: Arc::new(Mutex::new(None)),
|
||||
update_interval,
|
||||
auto_updater: AutoUpdater::new(),
|
||||
app_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_app_handle(&mut self, app_handle: tauri::AppHandle) {
|
||||
self.app_handle = Some(app_handle);
|
||||
}
|
||||
|
||||
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let app_name = if cfg!(debug_assertions) {
|
||||
@@ -143,11 +146,6 @@ impl VersionUpdater {
|
||||
should_update
|
||||
}
|
||||
|
||||
pub async fn set_app_handle(&self, app_handle: tauri::AppHandle) {
|
||||
let mut handle = self.app_handle.lock().await;
|
||||
*handle = Some(app_handle);
|
||||
}
|
||||
|
||||
pub async fn check_and_run_startup_update(
|
||||
&self,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
@@ -157,15 +155,10 @@ impl VersionUpdater {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let app_handle = {
|
||||
let handle_guard = self.app_handle.lock().await;
|
||||
handle_guard.clone()
|
||||
};
|
||||
|
||||
if let Some(handle) = app_handle {
|
||||
if let Some(ref app_handle) = self.app_handle {
|
||||
println!("Running startup version update...");
|
||||
|
||||
match self.update_all_browser_versions(&handle).await {
|
||||
match self.update_all_browser_versions(app_handle).await {
|
||||
Ok(_) => {
|
||||
// Update the persistent state after successful update
|
||||
let state = BackgroundUpdateState {
|
||||
@@ -191,7 +184,9 @@ impl VersionUpdater {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn start_background_updates(&mut self) {
|
||||
pub async fn start_background_updates(
|
||||
&self,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!(
|
||||
"Starting background version update service (checking every 5 minutes for 3-hour intervals)"
|
||||
);
|
||||
@@ -201,41 +196,54 @@ impl VersionUpdater {
|
||||
eprintln!("Startup version update failed: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn run_background_task() {
|
||||
let mut update_interval = interval(Duration::from_secs(5 * 60)); // Check every 5 minutes
|
||||
update_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
|
||||
|
||||
loop {
|
||||
self.update_interval.tick().await;
|
||||
update_interval.tick().await;
|
||||
|
||||
// Check if we should run an update based on persistent state
|
||||
if !Self::should_run_background_update() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if we have an app handle
|
||||
let app_handle = {
|
||||
let handle_guard = self.app_handle.lock().await;
|
||||
handle_guard.clone()
|
||||
};
|
||||
println!("Starting background version update...");
|
||||
|
||||
if let Some(handle) = app_handle {
|
||||
println!("Starting background version update...");
|
||||
// Get the updater instance for this update cycle
|
||||
let updater = get_version_updater();
|
||||
let result = {
|
||||
let updater_guard = updater.lock().await;
|
||||
if let Some(ref app_handle) = updater_guard.app_handle {
|
||||
updater_guard.update_all_browser_versions(app_handle).await
|
||||
} else {
|
||||
Err("App handle not available for background update".into())
|
||||
}
|
||||
}; // Release the lock here
|
||||
|
||||
match self.update_all_browser_versions(&handle).await {
|
||||
Ok(_) => {
|
||||
// Update the persistent state after successful update
|
||||
let state = BackgroundUpdateState {
|
||||
last_update_time: Self::get_current_timestamp(),
|
||||
update_interval_hours: 3,
|
||||
};
|
||||
match result {
|
||||
Ok(_) => {
|
||||
// Update the persistent state after successful update
|
||||
let state = BackgroundUpdateState {
|
||||
last_update_time: Self::get_current_timestamp(),
|
||||
update_interval_hours: 3,
|
||||
};
|
||||
|
||||
if let Err(e) = Self::save_background_update_state(&state) {
|
||||
eprintln!("Failed to save background update state: {e}");
|
||||
} else {
|
||||
println!("Background version update completed successfully");
|
||||
}
|
||||
if let Err(e) = Self::save_background_update_state(&state) {
|
||||
eprintln!("Failed to save background update state: {e}");
|
||||
} else {
|
||||
println!("Background version update completed successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Background version update failed: {e}");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Background version update failed: {e}");
|
||||
|
||||
// Emit error event
|
||||
// Try to emit error event if we have an app handle
|
||||
let updater_guard = updater.lock().await;
|
||||
if let Some(ref app_handle) = updater_guard.app_handle {
|
||||
let progress = VersionUpdateProgress {
|
||||
current_browser: "".to_string(),
|
||||
total_browsers: 0,
|
||||
@@ -244,11 +252,9 @@ impl VersionUpdater {
|
||||
browser_new_versions: 0,
|
||||
status: "error".to_string(),
|
||||
};
|
||||
let _ = handle.emit("version-update-progress", &progress);
|
||||
let _ = app_handle.emit("version-update-progress", &progress);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("App handle not available, skipping background update");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -257,107 +263,108 @@ impl VersionUpdater {
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Starting background version update for all browsers");
|
||||
|
||||
let browsers = [
|
||||
"firefox",
|
||||
"firefox-developer",
|
||||
"mullvad-browser",
|
||||
"zen",
|
||||
"brave",
|
||||
"chromium",
|
||||
"tor-browser",
|
||||
];
|
||||
|
||||
let total_browsers = browsers.len();
|
||||
let supported_browsers = self.version_service.get_supported_browsers();
|
||||
let total_browsers = supported_browsers.len();
|
||||
let mut results = Vec::new();
|
||||
let mut total_new_versions = 0;
|
||||
|
||||
// Emit start event
|
||||
let progress = VersionUpdateProgress {
|
||||
current_browser: "".to_string(),
|
||||
// Emit initial progress
|
||||
let initial_progress = VersionUpdateProgress {
|
||||
current_browser: String::new(),
|
||||
total_browsers,
|
||||
completed_browsers: 0,
|
||||
new_versions_found: 0,
|
||||
browser_new_versions: 0,
|
||||
status: "updating".to_string(),
|
||||
};
|
||||
let _ = app_handle.emit("version-update-progress", &progress);
|
||||
|
||||
for (index, browser) in browsers.iter().enumerate() {
|
||||
// Check if individual browser cache is expired before updating
|
||||
if !self.version_service.should_update_cache(browser) {
|
||||
println!("Skipping {browser} - cache is still fresh");
|
||||
if let Err(e) = app_handle.emit("version-update-progress", &initial_progress) {
|
||||
eprintln!("Failed to emit initial progress: {e}");
|
||||
}
|
||||
|
||||
let browser_result = BackgroundUpdateResult {
|
||||
browser: browser.to_string(),
|
||||
new_versions_count: 0,
|
||||
total_versions_count: 0,
|
||||
updated_successfully: true,
|
||||
error: None,
|
||||
};
|
||||
results.push(browser_result);
|
||||
continue;
|
||||
}
|
||||
for (index, browser) in supported_browsers.iter().enumerate() {
|
||||
println!("Updating browser versions for: {browser}");
|
||||
|
||||
println!("Updating versions for browser: {browser}");
|
||||
|
||||
// Emit progress for current browser
|
||||
// Emit progress update for current browser
|
||||
let progress = VersionUpdateProgress {
|
||||
current_browser: browser.to_string(),
|
||||
current_browser: browser.clone(),
|
||||
total_browsers,
|
||||
completed_browsers: index,
|
||||
new_versions_found: total_new_versions,
|
||||
browser_new_versions: 0,
|
||||
status: "updating".to_string(),
|
||||
};
|
||||
let _ = app_handle.emit("version-update-progress", &progress);
|
||||
|
||||
let result = self.update_browser_versions(browser).await;
|
||||
if let Err(e) = app_handle.emit("version-update-progress", &progress) {
|
||||
eprintln!("Failed to emit progress for {browser}: {e}");
|
||||
}
|
||||
|
||||
match result {
|
||||
Ok(new_count) => {
|
||||
total_new_versions += new_count;
|
||||
let browser_result = BackgroundUpdateResult {
|
||||
browser: browser.to_string(),
|
||||
new_versions_count: new_count,
|
||||
total_versions_count: 0, // We'll update this if needed
|
||||
match self.update_browser_versions(browser).await {
|
||||
Ok(new_versions_count) => {
|
||||
results.push(BackgroundUpdateResult {
|
||||
browser: browser.clone(),
|
||||
new_versions_count,
|
||||
total_versions_count: 0, // We don't track total for background updates
|
||||
updated_successfully: true,
|
||||
error: None,
|
||||
};
|
||||
results.push(browser_result);
|
||||
});
|
||||
|
||||
println!("Found {new_count} new versions for {browser}");
|
||||
total_new_versions += new_versions_count;
|
||||
|
||||
// Emit progress update with new versions found
|
||||
let progress = VersionUpdateProgress {
|
||||
current_browser: browser.clone(),
|
||||
total_browsers,
|
||||
completed_browsers: index,
|
||||
new_versions_found: total_new_versions,
|
||||
browser_new_versions: new_versions_count,
|
||||
status: "updating".to_string(),
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle.emit("version-update-progress", &progress) {
|
||||
eprintln!("Failed to emit progress with versions for {browser}: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to update versions for {browser}: {e}");
|
||||
let browser_result = BackgroundUpdateResult {
|
||||
browser: browser.to_string(),
|
||||
results.push(BackgroundUpdateResult {
|
||||
browser: browser.clone(),
|
||||
new_versions_count: 0,
|
||||
total_versions_count: 0,
|
||||
updated_successfully: false,
|
||||
error: Some(e.to_string()),
|
||||
};
|
||||
results.push(browser_result);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Small delay between browsers to avoid overwhelming APIs
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// Emit completion event
|
||||
let progress = VersionUpdateProgress {
|
||||
current_browser: "".to_string(),
|
||||
// Emit completion
|
||||
let final_progress = VersionUpdateProgress {
|
||||
current_browser: String::new(),
|
||||
total_browsers,
|
||||
completed_browsers: total_browsers,
|
||||
new_versions_found: total_new_versions,
|
||||
browser_new_versions: 0,
|
||||
status: "completed".to_string(),
|
||||
};
|
||||
let _ = app_handle.emit("version-update-progress", &progress);
|
||||
|
||||
println!("Background version update completed. Found {total_new_versions} new versions total");
|
||||
if let Err(e) = app_handle.emit("version-update-progress", &final_progress) {
|
||||
eprintln!("Failed to emit completion progress: {e}");
|
||||
}
|
||||
|
||||
// After all version updates are complete, trigger auto-update check
|
||||
if total_new_versions > 0 {
|
||||
println!(
|
||||
"Found {total_new_versions} new versions across all browsers. Checking for auto-updates..."
|
||||
);
|
||||
|
||||
// Trigger auto-update check which will automatically download browsers
|
||||
self
|
||||
.auto_updater
|
||||
.check_for_updates_with_progress(app_handle)
|
||||
.await;
|
||||
} else {
|
||||
println!("No new versions found, skipping auto-update check");
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut Browser",
|
||||
"version": "0.3.0",
|
||||
"version": "0.7.2",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -61,8 +61,9 @@
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"schemes": ["http", "https"],
|
||||
"domains": []
|
||||
"desktop": {
|
||||
"schemes": ["http", "https"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+2
-2
@@ -24,12 +24,12 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden`}
|
||||
>
|
||||
<CustomThemeProvider>
|
||||
<WindowDragArea />
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<Toaster />
|
||||
<WindowDragArea />
|
||||
</CustomThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+359
-105
@@ -1,10 +1,20 @@
|
||||
"use client";
|
||||
|
||||
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 { 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";
|
||||
@@ -21,14 +31,13 @@ import {
|
||||
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 type { BrowserProfile, ProxySettings } from "@/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import { sleep } from "@/lib/utils";
|
||||
import type { BrowserProfile, CamoufoxConfig } from "@/types";
|
||||
|
||||
type BrowserTypeString =
|
||||
| "mullvad-browser"
|
||||
@@ -37,7 +46,8 @@ type BrowserTypeString =
|
||||
| "chromium"
|
||||
| "brave"
|
||||
| "zen"
|
||||
| "tor-browser";
|
||||
| "tor-browser"
|
||||
| "camoufox";
|
||||
|
||||
interface PendingUrl {
|
||||
id: string;
|
||||
@@ -52,12 +62,78 @@ export default function Home() {
|
||||
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 [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 [currentPermissionType, setCurrentPermissionType] =
|
||||
useState<PermissionType>("microphone");
|
||||
const [proxyDataReloadTrigger, setProxyDataReloadTrigger] = useState(0);
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
// Check for missing binaries and offer to download them
|
||||
const checkMissingBinaries = useCallback(async () => {
|
||||
try {
|
||||
const missingBinaries = await invoke<[string, string, string][]>(
|
||||
"check_missing_binaries",
|
||||
);
|
||||
|
||||
if (missingBinaries.length > 0) {
|
||||
console.log("Found missing binaries:", missingBinaries);
|
||||
|
||||
// Group missing binaries by browser type to avoid concurrent downloads
|
||||
const browserMap = new Map<string, string[]>();
|
||||
for (const [profileName, browser, version] of missingBinaries) {
|
||||
if (!browserMap.has(browser)) {
|
||||
browserMap.set(browser, []);
|
||||
}
|
||||
const versions = browserMap.get(browser);
|
||||
if (versions) {
|
||||
versions.push(`${version} (for ${profileName})`);
|
||||
}
|
||||
}
|
||||
|
||||
// Show a toast notification about missing binaries and auto-download them
|
||||
const missingList = Array.from(browserMap.entries())
|
||||
.map(([browser, versions]) => `${browser}: ${versions.join(", ")}`)
|
||||
.join(", ");
|
||||
|
||||
console.log(`Downloading missing binaries: ${missingList}`);
|
||||
|
||||
try {
|
||||
// Download missing binaries sequentially by browser type to prevent conflicts
|
||||
const downloaded = await invoke<string[]>(
|
||||
"ensure_all_binaries_exist",
|
||||
);
|
||||
if (downloaded.length > 0) {
|
||||
console.log(
|
||||
"Successfully downloaded missing binaries:",
|
||||
downloaded,
|
||||
);
|
||||
}
|
||||
} catch (downloadError) {
|
||||
console.error("Failed to download missing binaries:", downloadError);
|
||||
setError(
|
||||
`Failed to download missing binaries: ${JSON.stringify(
|
||||
downloadError,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to check missing binaries:", err);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Simple profiles loader without updates check (for use as callback)
|
||||
const loadProfiles = useCallback(async () => {
|
||||
@@ -65,14 +141,47 @@ export default function Home() {
|
||||
const profileList = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
setProfiles(profileList);
|
||||
setProfiles(
|
||||
profileList.filter((profile) => profile.browser !== "camoufox"),
|
||||
);
|
||||
|
||||
// Check for missing binaries after loading profiles
|
||||
await checkMissingBinaries();
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load profiles:", err);
|
||||
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, [checkMissingBinaries]);
|
||||
|
||||
// Trigger proxy data reload in ProfilesDataTable
|
||||
const triggerProxyDataReload = useCallback(() => {
|
||||
setProxyDataReloadTrigger((prev) => prev + 1);
|
||||
}, []);
|
||||
|
||||
// Auto-update functionality - pass loadProfiles to refresh profiles after updates
|
||||
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,
|
||||
);
|
||||
|
||||
// Show profile selector for manual selection
|
||||
// Replace any existing pending URL with the new one
|
||||
setPendingUrls([{ id: Date.now().toString(), url }]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Version updater for handling version fetching progress events and auto-updates
|
||||
useVersionUpdater();
|
||||
|
||||
// Auto-update functionality - use the existing hook for compatibility
|
||||
const updateNotifications = useUpdateNotifications(loadProfiles);
|
||||
const { checkForUpdates, isUpdating } = updateNotifications;
|
||||
|
||||
@@ -82,44 +191,47 @@ export default function Home() {
|
||||
const profileList = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
setProfiles(profileList);
|
||||
setProfiles(
|
||||
profileList.filter((profile) => profile.browser !== "camoufox"),
|
||||
);
|
||||
|
||||
// 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.filter((profile) => profile.browser !== "camoufox"),
|
||||
);
|
||||
}
|
||||
await sleep(500);
|
||||
}, 0);
|
||||
|
||||
// Check for updates after loading profiles
|
||||
await checkForUpdates();
|
||||
await checkMissingBinaries();
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load profiles:", err);
|
||||
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, [checkForUpdates]);
|
||||
}, [checkForUpdates, checkMissingBinaries]);
|
||||
|
||||
useAppUpdateNotifications();
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfilesWithUpdateCheck();
|
||||
// For some reason, app.deep_link().get_current() is not working properly
|
||||
const checkCurrentUrl = useCallback(async () => {
|
||||
try {
|
||||
const currentUrl = await getCurrent();
|
||||
if (currentUrl && currentUrl.length > 0) {
|
||||
void handleUrlOpen(currentUrl[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check current URL:", error);
|
||||
}
|
||||
}, [handleUrlOpen]);
|
||||
|
||||
// Check for startup default browser prompt
|
||||
void checkStartupPrompt();
|
||||
|
||||
// Listen for URL open events
|
||||
void listenForUrlEvents();
|
||||
|
||||
// Check for startup URLs (when app was launched as default browser)
|
||||
void checkStartupUrls();
|
||||
|
||||
// Set up periodic update checks (every 30 minutes)
|
||||
const updateInterval = setInterval(
|
||||
() => {
|
||||
void checkForUpdates();
|
||||
},
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(updateInterval);
|
||||
};
|
||||
}, [loadProfilesWithUpdateCheck, checkForUpdates]);
|
||||
|
||||
const checkStartupPrompt = async () => {
|
||||
const checkStartupPrompt = useCallback(async () => {
|
||||
// Only check once during app startup to prevent reopening after dismissing notifications
|
||||
if (hasCheckedStartupPrompt) return;
|
||||
|
||||
@@ -135,9 +247,45 @@ export default function Home() {
|
||||
console.error("Failed to check startup prompt:", error);
|
||||
setHasCheckedStartupPrompt(true);
|
||||
}
|
||||
};
|
||||
}, [hasCheckedStartupPrompt]);
|
||||
|
||||
const checkStartupUrls = async () => {
|
||||
const checkAllPermissions = useCallback(async () => {
|
||||
try {
|
||||
// Wait for permissions to be initialized before checking
|
||||
if (!isInitialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if any permissions are not granted - prioritize missing permissions
|
||||
if (!isMicrophoneAccessGranted) {
|
||||
setCurrentPermissionType("microphone");
|
||||
setPermissionDialogOpen(true);
|
||||
} else if (!isCameraAccessGranted) {
|
||||
setCurrentPermissionType("camera");
|
||||
setPermissionDialogOpen(true);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check permissions:", error);
|
||||
}
|
||||
}, [isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized]);
|
||||
|
||||
const checkNextPermission = useCallback(() => {
|
||||
try {
|
||||
if (!isMicrophoneAccessGranted) {
|
||||
setCurrentPermissionType("microphone");
|
||||
setPermissionDialogOpen(true);
|
||||
} else if (!isCameraAccessGranted) {
|
||||
setCurrentPermissionType("camera");
|
||||
setPermissionDialogOpen(true);
|
||||
} else {
|
||||
setPermissionDialogOpen(false);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check next permission:", error);
|
||||
}
|
||||
}, [isMicrophoneAccessGranted, isCameraAccessGranted]);
|
||||
|
||||
const checkStartupUrls = useCallback(async () => {
|
||||
try {
|
||||
const hasStartupUrl = await invoke<boolean>(
|
||||
"check_and_handle_startup_url",
|
||||
@@ -148,9 +296,9 @@ export default function Home() {
|
||||
} catch (error) {
|
||||
console.error("Failed to check startup URLs:", error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const listenForUrlEvents = async () => {
|
||||
const listenForUrlEvents = useCallback(async () => {
|
||||
try {
|
||||
// Listen for URL open events from the deep link handler (when app is already running)
|
||||
await listen<string>("url-open-request", (event) => {
|
||||
@@ -178,27 +326,7 @@ export default function Home() {
|
||||
} catch (error) {
|
||||
console.error("Failed to setup URL listener:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUrlOpen = 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,
|
||||
);
|
||||
|
||||
// Show profile selector for manual selection
|
||||
// Replace any existing pending URL with the new one
|
||||
setPendingUrls([{ id: Date.now().toString(), url }]);
|
||||
}
|
||||
};
|
||||
}, [handleUrlOpen]);
|
||||
|
||||
const openProxyDialog = useCallback((profile: BrowserProfile | null) => {
|
||||
setCurrentProfileForProxy(profile);
|
||||
@@ -210,8 +338,32 @@ export default function Home() {
|
||||
setChangeVersionDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleConfigureCamoufox = useCallback((profile: BrowserProfile) => {
|
||||
setCurrentProfileForCamoufoxConfig(profile);
|
||||
setCamoufoxConfigDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleSaveCamoufoxConfig = useCallback(
|
||||
async (profile: BrowserProfile, config: CamoufoxConfig) => {
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("update_camoufox_config", {
|
||||
profileName: profile.name,
|
||||
config,
|
||||
});
|
||||
await loadProfiles();
|
||||
setCamoufoxConfigDialogOpen(false);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to update camoufox config:", err);
|
||||
setError(`Failed to update camoufox config: ${JSON.stringify(err)}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[loadProfiles],
|
||||
);
|
||||
|
||||
const handleSaveProxy = useCallback(
|
||||
async (proxySettings: ProxySettings) => {
|
||||
async (proxyId: string | null) => {
|
||||
setProxyDialogOpen(false);
|
||||
setError(null);
|
||||
|
||||
@@ -219,16 +371,18 @@ export default function Home() {
|
||||
if (currentProfileForProxy) {
|
||||
await invoke("update_profile_proxy", {
|
||||
profileName: currentProfileForProxy.name,
|
||||
proxy: proxySettings,
|
||||
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],
|
||||
[currentProfileForProxy, loadProfiles, triggerProxyDataReload],
|
||||
);
|
||||
|
||||
const handleCreateProfile = useCallback(
|
||||
@@ -236,29 +390,28 @@ export default function Home() {
|
||||
name: string;
|
||||
browserStr: BrowserTypeString;
|
||||
version: string;
|
||||
proxy?: ProxySettings;
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
}) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const profile = await invoke<BrowserProfile>(
|
||||
const _profile = await invoke<BrowserProfile>(
|
||||
"create_browser_profile_new",
|
||||
{
|
||||
name: profileData.name,
|
||||
browserStr: profileData.browserStr,
|
||||
version: profileData.version,
|
||||
releaseType: profileData.releaseType,
|
||||
proxyId: profileData.proxyId,
|
||||
camoufoxConfig: profileData.camoufoxConfig,
|
||||
},
|
||||
);
|
||||
|
||||
// Update proxy if provided
|
||||
if (profileData.proxy) {
|
||||
await invoke("update_profile_proxy", {
|
||||
profileName: profile.name,
|
||||
proxy: profileData.proxy,
|
||||
});
|
||||
}
|
||||
|
||||
await loadProfiles();
|
||||
// Trigger proxy data reload in the table
|
||||
triggerProxyDataReload();
|
||||
} catch (error) {
|
||||
setError(
|
||||
`Failed to create profile: ${
|
||||
@@ -268,7 +421,7 @@ export default function Home() {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[loadProfiles],
|
||||
[loadProfiles, triggerProxyDataReload],
|
||||
);
|
||||
|
||||
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
|
||||
@@ -340,40 +493,39 @@ export default function Home() {
|
||||
[loadProfiles, checkBrowserStatus, isUpdating],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (profiles.length === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
for (const profile of profiles) {
|
||||
void checkBrowserStatus(profile);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [profiles, checkBrowserStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
runningProfilesRef.current = runningProfiles;
|
||||
}, [runningProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
showErrorToast(error);
|
||||
setError(null);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
const handleDeleteProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
setError(null);
|
||||
console.log("Attempting to delete profile:", profile.name);
|
||||
|
||||
try {
|
||||
// First check if the browser is running for this profile
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
|
||||
if (isRunning) {
|
||||
setError(
|
||||
"Cannot delete profile while browser is running. Please stop the browser first.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to delete the profile
|
||||
await invoke("delete_profile", { profileName: profile.name });
|
||||
console.log("Profile deletion command completed successfully");
|
||||
|
||||
// Give a small delay to ensure file system operations complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Reload profiles to ensure UI is updated
|
||||
await loadProfiles();
|
||||
|
||||
console.log("Profile deleted and profiles reloaded successfully");
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete profile:", err);
|
||||
setError(`Failed to delete profile: ${JSON.stringify(err)}`);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(`Failed to delete profile: ${errorMessage}`);
|
||||
}
|
||||
},
|
||||
[loadProfiles],
|
||||
@@ -408,8 +560,73 @@ export default function Home() {
|
||||
[loadProfiles],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfilesWithUpdateCheck();
|
||||
|
||||
// Check for startup default browser prompt
|
||||
void checkStartupPrompt();
|
||||
|
||||
// Listen for URL open events
|
||||
void listenForUrlEvents();
|
||||
|
||||
// Check for startup URLs (when app was launched as default browser)
|
||||
void checkStartupUrls();
|
||||
void checkCurrentUrl();
|
||||
|
||||
// Set up periodic update checks (every 30 minutes)
|
||||
const updateInterval = setInterval(
|
||||
() => {
|
||||
void checkForUpdates();
|
||||
},
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
|
||||
return () => {
|
||||
clearInterval(updateInterval);
|
||||
};
|
||||
}, [
|
||||
loadProfilesWithUpdateCheck,
|
||||
checkForUpdates,
|
||||
checkCurrentUrl,
|
||||
checkStartupPrompt,
|
||||
listenForUrlEvents,
|
||||
checkStartupUrls,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profiles.length === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
for (const profile of profiles) {
|
||||
void checkBrowserStatus(profile);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [profiles, checkBrowserStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
runningProfilesRef.current = runningProfiles;
|
||||
}, [runningProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
showErrorToast(error);
|
||||
setError(null);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Check permissions when they are initialized
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
void checkAllPermissions();
|
||||
}
|
||||
}, [isInitialized, checkAllPermissions]);
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 gap-8 sm:p-12 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
|
||||
<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>
|
||||
@@ -435,6 +652,14 @@ export default function Home() {
|
||||
<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);
|
||||
@@ -462,7 +687,7 @@ export default function Home() {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
<CardContent>
|
||||
<ProfilesDataTable
|
||||
data={profiles}
|
||||
onLaunchProfile={launchProfile}
|
||||
@@ -471,8 +696,12 @@ export default function Home() {
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
onRenameProfile={handleRenameProfile}
|
||||
onChangeVersion={openChangeVersionDialog}
|
||||
onConfigureCamoufox={handleConfigureCamoufox}
|
||||
runningProfiles={runningProfiles}
|
||||
isUpdating={isUpdating}
|
||||
onReloadProxyData={
|
||||
proxyDataReloadTrigger > 0 ? triggerProxyDataReload : undefined
|
||||
}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -483,8 +712,8 @@ export default function Home() {
|
||||
onClose={() => {
|
||||
setProxyDialogOpen(false);
|
||||
}}
|
||||
onSave={(proxy: ProxySettings) => void handleSaveProxy(proxy)}
|
||||
initialSettings={currentProfileForProxy?.proxy}
|
||||
onSave={handleSaveProxy}
|
||||
initialProxyId={currentProfileForProxy?.proxy_id}
|
||||
browserType={currentProfileForProxy?.browser}
|
||||
/>
|
||||
|
||||
@@ -520,6 +749,13 @@ export default function Home() {
|
||||
onImportComplete={() => void loadProfiles()}
|
||||
/>
|
||||
|
||||
<ProxyManagementDialog
|
||||
isOpen={proxyManagementDialogOpen}
|
||||
onClose={() => {
|
||||
setProxyManagementDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{pendingUrls.map((pendingUrl) => (
|
||||
<ProfileSelectorDialog
|
||||
key={pendingUrl.id}
|
||||
@@ -533,6 +769,24 @@ export default function Home() {
|
||||
runningProfiles={runningProfiles}
|
||||
/>
|
||||
))}
|
||||
|
||||
<PermissionDialog
|
||||
isOpen={permissionDialogOpen}
|
||||
onClose={() => {
|
||||
setPermissionDialogOpen(false);
|
||||
}}
|
||||
permissionType={currentPermissionType}
|
||||
onPermissionGranted={checkNextPermission}
|
||||
/>
|
||||
|
||||
<CamoufoxConfigDialog
|
||||
isOpen={camoufoxConfigDialogOpen}
|
||||
onClose={() => {
|
||||
setCamoufoxConfigDialogOpen(false);
|
||||
}}
|
||||
profile={currentProfileForCamoufoxConfig}
|
||||
onSave={handleSaveCamoufoxConfig}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,26 +1,57 @@
|
||||
"use client";
|
||||
|
||||
import { FaDownload, FaTimes } from "react-icons/fa";
|
||||
import { LuCheckCheck, LuCog, LuRefreshCw } from "react-icons/lu";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import React from "react";
|
||||
import { FaDownload, FaTimes } from "react-icons/fa";
|
||||
import { LuRefreshCw } from "react-icons/lu";
|
||||
|
||||
interface AppUpdateInfo {
|
||||
current_version: string;
|
||||
new_version: string;
|
||||
release_notes: string;
|
||||
download_url: string;
|
||||
is_nightly: boolean;
|
||||
published_at: string;
|
||||
}
|
||||
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
|
||||
|
||||
interface AppUpdateToastProps {
|
||||
updateInfo: AppUpdateInfo;
|
||||
onUpdate: (updateInfo: AppUpdateInfo) => Promise<void>;
|
||||
onDismiss: () => void;
|
||||
isUpdating?: boolean;
|
||||
updateProgress?: string;
|
||||
updateProgress?: AppUpdateProgress | null;
|
||||
}
|
||||
|
||||
function getStageIcon(stage?: string, isUpdating?: boolean) {
|
||||
if (!isUpdating) {
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5 text-blue-500" />;
|
||||
}
|
||||
|
||||
switch (stage) {
|
||||
case "downloading":
|
||||
return <FaDownload className="flex-shrink-0 w-5 h-5 text-blue-500" />;
|
||||
case "extracting":
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
|
||||
);
|
||||
case "installing":
|
||||
return (
|
||||
<LuCog className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
|
||||
);
|
||||
case "completed":
|
||||
return <LuCheckCheck className="flex-shrink-0 w-5 h-5 text-green-500" />;
|
||||
default:
|
||||
return (
|
||||
<LuRefreshCw className="flex-shrink-0 w-5 h-5 text-blue-500 animate-spin" />
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getStageDisplayName(stage?: string) {
|
||||
switch (stage) {
|
||||
case "downloading":
|
||||
return "Downloading";
|
||||
case "extracting":
|
||||
return "Extracting";
|
||||
case "installing":
|
||||
return "Installing";
|
||||
case "completed":
|
||||
return "Completed";
|
||||
default:
|
||||
return "Updating";
|
||||
}
|
||||
}
|
||||
|
||||
export function AppUpdateToast({
|
||||
@@ -34,22 +65,32 @@ export function AppUpdateToast({
|
||||
await onUpdate(updateInfo);
|
||||
};
|
||||
|
||||
const showDownloadProgress =
|
||||
isUpdating &&
|
||||
updateProgress?.stage === "downloading" &&
|
||||
updateProgress.percentage !== undefined;
|
||||
|
||||
const showOtherStageProgress =
|
||||
isUpdating &&
|
||||
updateProgress &&
|
||||
(updateProgress.stage === "extracting" ||
|
||||
updateProgress.stage === "installing" ||
|
||||
updateProgress.stage === "completed");
|
||||
|
||||
return (
|
||||
<div className="flex items-start w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-4 shadow-lg max-w-md">
|
||||
<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="mr-3 mt-0.5">
|
||||
{isUpdating ? (
|
||||
<LuRefreshCw className="h-5 w-5 text-blue-500 animate-spin flex-shrink-0" />
|
||||
) : (
|
||||
<FaDownload className="h-5 w-5 text-blue-500 flex-shrink-0" />
|
||||
)}
|
||||
{getStageIcon(updateProgress?.stage, isUpdating)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex gap-2 justify-between items-start">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold text-foreground text-sm">
|
||||
Donut Browser Update Available
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="text-sm font-semibold text-foreground">
|
||||
{isUpdating
|
||||
? `${getStageDisplayName(updateProgress?.stage)} Donut Browser Update`
|
||||
: "Donut Browser Update Available"}
|
||||
</span>
|
||||
<Badge
|
||||
variant={updateInfo.is_nightly ? "secondary" : "default"}
|
||||
@@ -59,8 +100,14 @@ export function AppUpdateToast({
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Update from {updateInfo.current_version} to{" "}
|
||||
<span className="font-medium">{updateInfo.new_version}</span>
|
||||
{isUpdating ? (
|
||||
updateProgress?.message || "Updating..."
|
||||
) : (
|
||||
<>
|
||||
Update from {updateInfo.current_version} to{" "}
|
||||
<span className="font-medium">{updateInfo.new_version}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -69,27 +116,76 @@ export function AppUpdateToast({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onDismiss}
|
||||
className="h-6 w-6 p-0 shrink-0"
|
||||
className="p-0 w-6 h-6 shrink-0"
|
||||
>
|
||||
<FaTimes className="h-3 w-3" />
|
||||
<FaTimes className="w-3 h-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isUpdating && updateProgress && (
|
||||
<div className="mt-2">
|
||||
<p className="text-xs text-muted-foreground">{updateProgress}</p>
|
||||
{/* Download progress */}
|
||||
{showDownloadProgress && updateProgress && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="flex-1 min-w-0 text-xs text-muted-foreground">
|
||||
{updateProgress.percentage?.toFixed(1)}%
|
||||
{updateProgress.speed && ` • ${updateProgress.speed} MB/s`}
|
||||
{updateProgress.eta && ` • ${updateProgress.eta} remaining`}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-full bg-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: `${updateProgress.percentage}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Other stage progress (with visual indicators) */}
|
||||
{showOtherStageProgress && (
|
||||
<div className="mt-2 space-y-2">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{updateProgress.message}
|
||||
</p>
|
||||
|
||||
{/* Progress indicator for non-downloading stages */}
|
||||
<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 ${
|
||||
updateProgress.stage === "completed"
|
||||
? "bg-green-500 w-full"
|
||||
: "bg-blue-500 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 items-center gap-2 mt-3">
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
<Button
|
||||
onClick={() => void handleUpdateClick()}
|
||||
size="sm"
|
||||
className="flex items-center gap-2 text-xs"
|
||||
className="flex gap-2 items-center text-xs"
|
||||
>
|
||||
<FaDownload className="h-3 w-3" />
|
||||
<FaDownload className="w-3 h-3" />
|
||||
Update Now
|
||||
</Button>
|
||||
<Button
|
||||
@@ -102,21 +198,6 @@ export function AppUpdateToast({
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{updateInfo.release_notes && !isUpdating && (
|
||||
<div className="mt-2">
|
||||
<details className="text-xs">
|
||||
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
|
||||
Release Notes
|
||||
</summary>
|
||||
<div className="mt-1 text-muted-foreground whitespace-pre-wrap max-h-32 overflow-y-auto">
|
||||
{updateInfo.release_notes.length > 200
|
||||
? `${updateInfo.release_notes.substring(0, 200)}...`
|
||||
: updateInfo.release_notes}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
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";
|
||||
|
||||
interface CamoufoxConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
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,
|
||||
profile,
|
||||
onSave,
|
||||
}: CamoufoxConfigDialogProps) {
|
||||
const [config, setConfig] = useState<CamoufoxConfig>({
|
||||
enable_cache: true,
|
||||
os: [getCurrentOS()],
|
||||
});
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
// Initialize config when profile changes
|
||||
useEffect(() => {
|
||||
if (profile && profile.browser === "camoufox") {
|
||||
setConfig(
|
||||
profile.camoufox_config || {
|
||||
enable_cache: true,
|
||||
os: [getCurrentOS()],
|
||||
},
|
||||
);
|
||||
}
|
||||
}, [profile]);
|
||||
|
||||
const updateConfig = (key: keyof CamoufoxConfig, value: unknown) => {
|
||||
setConfig((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!profile) return;
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await onSave(profile, config);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save camoufox config:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
// Reset config to original when closing without saving
|
||||
if (profile && profile.browser === "camoufox") {
|
||||
setConfig(
|
||||
profile.camoufox_config || {
|
||||
enable_cache: true,
|
||||
os: [getCurrentOS()],
|
||||
},
|
||||
);
|
||||
}
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!profile || profile.browser !== "camoufox") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get the selected OS for warning
|
||||
const selectedOS = config.os?.[0];
|
||||
const currentOS = getCurrentOS();
|
||||
const showOSWarning =
|
||||
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
|
||||
|
||||
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}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 pr-6 h-[320px]">
|
||||
<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>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? "Saving..." : "Save Configuration"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,10 @@
|
||||
"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";
|
||||
@@ -12,12 +16,9 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { VersionSelector } from "@/components/version-selector";
|
||||
import { useBrowserDownload } from "@/hooks/use-browser-download";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LuTriangleAlert } from "react-icons/lu";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import type { BrowserProfile, BrowserReleaseTypes } from "@/types";
|
||||
|
||||
interface ChangeVersionDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -32,66 +33,82 @@ export function ChangeVersionDialog({
|
||||
profile,
|
||||
onVersionChanged,
|
||||
}: ChangeVersionDialogProps) {
|
||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||
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 {
|
||||
availableVersions,
|
||||
downloadedVersions,
|
||||
isDownloading,
|
||||
loadVersions,
|
||||
loadDownloadedVersions,
|
||||
downloadBrowser,
|
||||
isVersionDownloaded,
|
||||
} = useBrowserDownload();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && profile) {
|
||||
setSelectedVersion(profile.version);
|
||||
setAcknowledgeDowngrade(false);
|
||||
void loadVersions(profile.browser);
|
||||
void loadDownloadedVersions(profile.browser);
|
||||
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);
|
||||
}
|
||||
}, [isOpen, profile, loadVersions, loadDownloadedVersions]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (profile && selectedVersion) {
|
||||
// Check if this is a downgrade
|
||||
const currentVersionIndex = availableVersions.findIndex(
|
||||
(v) => v.tag_name === profile.version,
|
||||
);
|
||||
const selectedVersionIndex = availableVersions.findIndex(
|
||||
(v) => v.tag_name === selectedVersion,
|
||||
);
|
||||
|
||||
// If selected version has a higher index, it's older (downgrade)
|
||||
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 =
|
||||
currentVersionIndex !== -1 &&
|
||||
selectedVersionIndex !== -1 &&
|
||||
selectedVersionIndex > currentVersionIndex;
|
||||
profile.release_type === "stable" && selectedReleaseType === "nightly";
|
||||
setShowDowngradeWarning(isDowngrade);
|
||||
|
||||
if (!isDowngrade) {
|
||||
setAcknowledgeDowngrade(false);
|
||||
}
|
||||
}
|
||||
}, [selectedVersion, profile, availableVersions]);
|
||||
}, [selectedReleaseType, profile]);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!profile || !selectedVersion) return;
|
||||
await downloadBrowser(profile.browser, selectedVersion);
|
||||
};
|
||||
const handleDownload = useCallback(async () => {
|
||||
if (!profile || !selectedReleaseType) return;
|
||||
|
||||
const handleVersionChange = async () => {
|
||||
if (!profile || !selectedVersion) 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: selectedVersion,
|
||||
version,
|
||||
});
|
||||
onVersionChanged();
|
||||
onClose();
|
||||
@@ -100,66 +117,160 @@ export function ChangeVersionDialog({
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
}, [profile, selectedReleaseType, releaseTypes, onVersionChanged, onClose]);
|
||||
|
||||
const selectedVersion =
|
||||
selectedReleaseType === "stable"
|
||||
? releaseTypes.stable
|
||||
: releaseTypes.nightly;
|
||||
|
||||
const canUpdate =
|
||||
profile &&
|
||||
selectedVersion &&
|
||||
selectedVersion !== profile.version &&
|
||||
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 Browser Version</DialogTitle>
|
||||
<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 bg-muted rounded text-sm">{profile.name}</div>
|
||||
<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 Version:</Label>
|
||||
<div className="p-2 bg-muted rounded text-sm">
|
||||
{profile.version}
|
||||
<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>
|
||||
|
||||
{/* Version Selection */}
|
||||
<div className="grid gap-2">
|
||||
<Label>New Version</Label>
|
||||
<VersionSelector
|
||||
selectedVersion={selectedVersion}
|
||||
onVersionSelect={setSelectedVersion}
|
||||
availableVersions={availableVersions}
|
||||
downloadedVersions={downloadedVersions}
|
||||
isDownloading={isDownloading}
|
||||
onDownload={() => {
|
||||
void handleDownload();
|
||||
}}
|
||||
placeholder="Select version..."
|
||||
/>
|
||||
</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="h-4 w-4 text-orange-700" />
|
||||
<LuTriangleAlert className="w-4 h-4 text-orange-700" />
|
||||
<AlertTitle className="text-orange-700">
|
||||
Downgrade Warning
|
||||
Stability Warning
|
||||
</AlertTitle>
|
||||
<AlertDescription className="text-orange-700">
|
||||
You are about to downgrade from version {profile.version} to{" "}
|
||||
{selectedVersion}. This may lead to compatibility issues, data
|
||||
loss, or unexpected behavior.
|
||||
<div className="flex items-center space-x-2 mt-3">
|
||||
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}
|
||||
@@ -187,7 +298,7 @@ export function ChangeVersionDialog({
|
||||
}}
|
||||
disabled={!canUpdate}
|
||||
>
|
||||
{isUpdating ? "Updating..." : "Update Version"}
|
||||
{isUpdating ? "Updating..." : "Update Release Type"}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -12,6 +15,7 @@ import {
|
||||
} 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,
|
||||
@@ -19,19 +23,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { VersionSelector } from "@/components/version-selector";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { useBrowserDownload } from "@/hooks/use-browser-download";
|
||||
import { useBrowserSupport } from "@/hooks/use-browser-support";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import type { BrowserProfile, ProxySettings } from "@/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { getBrowserIcon, getCurrentOS } from "@/lib/browser-utils";
|
||||
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
|
||||
|
||||
type BrowserTypeString =
|
||||
| "mullvad-browser"
|
||||
@@ -40,7 +35,8 @@ type BrowserTypeString =
|
||||
| "chromium"
|
||||
| "brave"
|
||||
| "zen"
|
||||
| "tor-browser";
|
||||
| "tor-browser"
|
||||
| "camoufox";
|
||||
|
||||
interface CreateProfileDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -49,168 +45,212 @@ interface CreateProfileDialogProps {
|
||||
name: string;
|
||||
browserStr: BrowserTypeString;
|
||||
version: string;
|
||||
proxy?: ProxySettings;
|
||||
releaseType: string;
|
||||
proxyId?: string;
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
}) => Promise<void>;
|
||||
}
|
||||
|
||||
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 IS_ANTI_DETECT_SUPPORTED = false;
|
||||
|
||||
export function CreateProfileDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onCreateProfile,
|
||||
}: CreateProfileDialogProps) {
|
||||
const [profileName, setProfileName] = useState("");
|
||||
const [selectedBrowser, setSelectedBrowser] =
|
||||
useState<BrowserTypeString | null>("mullvad-browser");
|
||||
const [selectedVersion, setSelectedVersion] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState("regular");
|
||||
|
||||
// Regular browser states
|
||||
const [selectedBrowser, setSelectedBrowser] = useState<BrowserTypeString>();
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||
|
||||
// Camoufox anti-detect states
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({
|
||||
enable_cache: true, // Cache enabled by default
|
||||
os: [getCurrentOS()], // Default to current OS
|
||||
});
|
||||
|
||||
// Common states
|
||||
const [availableReleaseTypes, setAvailableReleaseTypes] =
|
||||
useState<BrowserReleaseTypes>({});
|
||||
const [camoufoxReleaseTypes, setCamoufoxReleaseTypes] =
|
||||
useState<BrowserReleaseTypes>({});
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [existingProfiles, setExistingProfiles] = useState<BrowserProfile[]>(
|
||||
[],
|
||||
);
|
||||
|
||||
// Proxy settings
|
||||
const [proxyEnabled, setProxyEnabled] = useState(false);
|
||||
const [proxyType, setProxyType] = useState("http");
|
||||
const [proxyHost, setProxyHost] = useState("");
|
||||
const [proxyPort, setProxyPort] = useState(8080);
|
||||
|
||||
// Use the browser download hook
|
||||
const {
|
||||
availableVersions,
|
||||
downloadedVersions,
|
||||
isDownloading,
|
||||
loadVersions,
|
||||
loadDownloadedVersions,
|
||||
isBrowserDownloading,
|
||||
downloadBrowser,
|
||||
loadDownloadedVersions,
|
||||
isVersionDownloaded,
|
||||
} = useBrowserDownload();
|
||||
|
||||
const {
|
||||
supportedBrowsers,
|
||||
isLoading: isLoadingSupport,
|
||||
isBrowserSupported,
|
||||
} = useBrowserSupport();
|
||||
const loadSupportedBrowsers = useCallback(async () => {
|
||||
try {
|
||||
const browsers = await invoke<string[]>("get_supported_browsers");
|
||||
setSupportedBrowsers(browsers);
|
||||
} catch (error) {
|
||||
console.error("Failed to load supported browsers:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadStoredProxies = useCallback(async () => {
|
||||
try {
|
||||
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(proxies);
|
||||
} catch (error) {
|
||||
console.error("Failed to load stored proxies:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadReleaseTypes = useCallback(
|
||||
async (browser: string) => {
|
||||
try {
|
||||
const releaseTypes = await invoke<BrowserReleaseTypes>(
|
||||
"get_browser_release_types",
|
||||
{ browserStr: browser },
|
||||
);
|
||||
|
||||
if (browser === "camoufox") {
|
||||
setCamoufoxReleaseTypes(releaseTypes);
|
||||
} else {
|
||||
setAvailableReleaseTypes(releaseTypes);
|
||||
}
|
||||
|
||||
// Load downloaded versions for this browser
|
||||
await loadDownloadedVersions(browser);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load release types for ${browser}:`, error);
|
||||
}
|
||||
},
|
||||
[loadDownloadedVersions],
|
||||
);
|
||||
|
||||
// Load data when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadExistingProfiles();
|
||||
void loadSupportedBrowsers();
|
||||
void loadStoredProxies();
|
||||
// Load camoufox release types when dialog opens
|
||||
void loadReleaseTypes("camoufox");
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, [isOpen, loadSupportedBrowsers, loadStoredProxies, loadReleaseTypes]);
|
||||
|
||||
// Load release types when browser selection changes
|
||||
useEffect(() => {
|
||||
if (supportedBrowsers.length > 0) {
|
||||
// Set default browser to first supported browser
|
||||
if (supportedBrowsers.includes("mullvad-browser")) {
|
||||
setSelectedBrowser("mullvad-browser");
|
||||
} else if (supportedBrowsers.length > 0) {
|
||||
setSelectedBrowser(supportedBrowsers[0] as BrowserTypeString);
|
||||
}
|
||||
if (selectedBrowser) {
|
||||
void loadReleaseTypes(selectedBrowser);
|
||||
}
|
||||
}, [supportedBrowsers]);
|
||||
}, [selectedBrowser, loadReleaseTypes]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && selectedBrowser) {
|
||||
// Reset selected version when browser changes
|
||||
setSelectedVersion(null);
|
||||
void loadVersions(selectedBrowser);
|
||||
void loadDownloadedVersions(selectedBrowser);
|
||||
}
|
||||
}, [isOpen, selectedBrowser, loadVersions, loadDownloadedVersions]);
|
||||
const handleDownload = async (browserStr: string) => {
|
||||
const releaseTypes =
|
||||
browserStr === "camoufox" ? camoufoxReleaseTypes : availableReleaseTypes;
|
||||
const latestStableVersion = releaseTypes.stable;
|
||||
|
||||
// Set default version when versions are loaded and no version is selected
|
||||
useEffect(() => {
|
||||
if (availableVersions.length > 0 && selectedBrowser) {
|
||||
// Always reset version when browser changes or versions are loaded
|
||||
// Find the latest stable version (not alpha/beta)
|
||||
const stableVersions = availableVersions.filter((v) => !v.is_nightly);
|
||||
|
||||
if (stableVersions.length > 0) {
|
||||
// Select the first stable version (they're already sorted newest first)
|
||||
setSelectedVersion(stableVersions[0].tag_name);
|
||||
} else if (availableVersions.length > 0) {
|
||||
// If no stable version found, select the first available version
|
||||
setSelectedVersion(availableVersions[0].tag_name);
|
||||
}
|
||||
}
|
||||
}, [availableVersions, selectedBrowser]);
|
||||
|
||||
const loadExistingProfiles = async () => {
|
||||
try {
|
||||
const profiles = await invoke<BrowserProfile[]>("list_browser_profiles");
|
||||
setExistingProfiles(profiles);
|
||||
} catch (error) {
|
||||
console.error("Failed to load existing profiles:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!selectedBrowser || !selectedVersion) return;
|
||||
await downloadBrowser(selectedBrowser, selectedVersion);
|
||||
};
|
||||
|
||||
const validateProfileName = (name: string): string | null => {
|
||||
const trimmedName = name.trim();
|
||||
|
||||
if (!trimmedName) {
|
||||
return "Profile name cannot be empty";
|
||||
}
|
||||
|
||||
// Check for duplicate names (case insensitive)
|
||||
const isDuplicate = existingProfiles.some(
|
||||
(profile) => profile.name.toLowerCase() === trimmedName.toLowerCase(),
|
||||
);
|
||||
|
||||
if (isDuplicate) {
|
||||
return "A profile with this name already exists";
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
// Helper to determine if proxy should be disabled for the selected browser
|
||||
const isProxyDisabled = selectedBrowser === "tor-browser";
|
||||
|
||||
// Update proxy enabled state when browser changes to tor-browser
|
||||
useEffect(() => {
|
||||
if (selectedBrowser === "tor-browser" && proxyEnabled) {
|
||||
setProxyEnabled(false);
|
||||
}
|
||||
}, [selectedBrowser, proxyEnabled]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!profileName.trim() || !selectedBrowser || !selectedVersion) return;
|
||||
|
||||
// Validate profile name
|
||||
const nameError = validateProfileName(profileName);
|
||||
if (nameError) {
|
||||
toast.error(nameError);
|
||||
if (!latestStableVersion) {
|
||||
console.error("No stable version available for download");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadBrowser(browserStr, latestStableVersion);
|
||||
} catch (error) {
|
||||
console.error("Failed to download browser:", error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!profileName.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
const proxy =
|
||||
proxyEnabled && !isProxyDisabled
|
||||
? {
|
||||
enabled: true,
|
||||
proxy_type: proxyType,
|
||||
host: proxyHost,
|
||||
port: proxyPort,
|
||||
}
|
||||
: undefined;
|
||||
if (activeTab === "regular") {
|
||||
if (!selectedBrowser) {
|
||||
console.error("Missing required browser selection");
|
||||
return;
|
||||
}
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: selectedBrowser,
|
||||
version: selectedVersion,
|
||||
proxy,
|
||||
});
|
||||
// Use the latest stable version by default
|
||||
const latestStableVersion = availableReleaseTypes.stable;
|
||||
if (!latestStableVersion) {
|
||||
console.error("No stable version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setProfileName("");
|
||||
setSelectedVersion(null);
|
||||
setProxyEnabled(false);
|
||||
setProxyHost("");
|
||||
setProxyPort(8080);
|
||||
onClose();
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: selectedBrowser,
|
||||
version: latestStableVersion,
|
||||
releaseType: "stable",
|
||||
proxyId: selectedProxyId,
|
||||
});
|
||||
} else {
|
||||
// Anti-detect tab - always use Camoufox with latest version
|
||||
const latestCamoufoxVersion = camoufoxReleaseTypes.stable;
|
||||
if (!latestCamoufoxVersion) {
|
||||
console.error("No Camoufox version available");
|
||||
return;
|
||||
}
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "camoufox" as BrowserTypeString,
|
||||
version: latestCamoufoxVersion,
|
||||
releaseType: "stable",
|
||||
proxyId: selectedProxyId,
|
||||
camoufoxConfig,
|
||||
});
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create profile:", error);
|
||||
} finally {
|
||||
@@ -218,219 +258,227 @@ export function CreateProfileDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const nameError = profileName.trim()
|
||||
? validateProfileName(profileName)
|
||||
: null;
|
||||
const canCreate =
|
||||
profileName.trim() &&
|
||||
selectedBrowser &&
|
||||
selectedVersion &&
|
||||
isVersionDownloaded(selectedVersion) &&
|
||||
(!proxyEnabled || isProxyDisabled || (proxyHost && proxyPort)) &&
|
||||
!nameError;
|
||||
const handleClose = () => {
|
||||
// Reset all states
|
||||
setProfileName("");
|
||||
setSelectedBrowser(undefined);
|
||||
setSelectedProxyId(undefined);
|
||||
setCamoufoxConfig({
|
||||
enable_cache: true,
|
||||
os: [getCurrentOS()], // Reset to current OS
|
||||
});
|
||||
setActiveTab("regular");
|
||||
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);
|
||||
};
|
||||
|
||||
// Get the selected OS for warning
|
||||
const selectedOS = camoufoxConfig.os?.[0];
|
||||
const currentOS = getCurrentOS();
|
||||
const _showOSWarning =
|
||||
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Create New Profile</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
{/* Profile Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
onChange={(e) => {
|
||||
setProfileName(e.target.value);
|
||||
}}
|
||||
placeholder="Enter profile name"
|
||||
className={nameError ? "border-red-500" : ""}
|
||||
/>
|
||||
{nameError && <p className="text-sm text-red-600">{nameError}</p>}
|
||||
</div>
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={setActiveTab}
|
||||
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>
|
||||
<TabsTrigger value="anti-detect">Anti-Detect</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
{/* Browser Selection */}
|
||||
<div className="grid gap-2">
|
||||
<Label>Browser</Label>
|
||||
<Select
|
||||
value={selectedBrowser ?? undefined}
|
||||
onValueChange={(value) => {
|
||||
setSelectedBrowser(value as BrowserTypeString);
|
||||
}}
|
||||
disabled={isLoadingSupport}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingSupport ? "Loading browsers..." : "Select browser"
|
||||
}
|
||||
<ScrollArea className="flex-1 pr-6 h-[320px]">
|
||||
<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"
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{(
|
||||
[
|
||||
"mullvad-browser",
|
||||
"firefox",
|
||||
"firefox-developer",
|
||||
"chromium",
|
||||
"brave",
|
||||
"zen",
|
||||
"tor-browser",
|
||||
] as BrowserTypeString[]
|
||||
).map((browser) => {
|
||||
const isSupported = isBrowserSupported(browser);
|
||||
const displayName = getBrowserDisplayName(browser);
|
||||
</div>
|
||||
|
||||
if (!isSupported) {
|
||||
return (
|
||||
<Tooltip key={browser}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem
|
||||
value={browser}
|
||||
disabled={true}
|
||||
className="opacity-50"
|
||||
>
|
||||
{displayName} (Not supported on this platform)
|
||||
</SelectItem>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
{displayName} is not supported on your current
|
||||
platform or architecture.
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
<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>
|
||||
|
||||
return (
|
||||
<SelectItem key={browser} value={browser}>
|
||||
{displayName}
|
||||
</SelectItem>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Version Selection */}
|
||||
<div className="grid gap-2">
|
||||
<Label>Version</Label>
|
||||
<VersionSelector
|
||||
selectedVersion={selectedVersion}
|
||||
onVersionSelect={setSelectedVersion}
|
||||
availableVersions={availableVersions}
|
||||
downloadedVersions={downloadedVersions}
|
||||
isDownloading={isDownloading}
|
||||
onDownload={() => {
|
||||
void handleDownload();
|
||||
}}
|
||||
placeholder="Select version..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Proxy Settings */}
|
||||
<div className="grid gap-4 pt-4 border-t">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isProxyDisabled ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-2 opacity-50">
|
||||
<Checkbox
|
||||
id="proxy-enabled"
|
||||
checked={false}
|
||||
disabled={true}
|
||||
/>
|
||||
<Label htmlFor="proxy-enabled" className="text-gray-500">
|
||||
Enable Proxy
|
||||
</Label>
|
||||
{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>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Tor Browser has its own built-in proxy system and
|
||||
doesn't support additional proxy configuration
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<>
|
||||
<Checkbox
|
||||
id="proxy-enabled"
|
||||
checked={proxyEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
setProxyEnabled(checked as boolean);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="proxy-enabled">Enable Proxy</Label>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{proxyEnabled && !isProxyDisabled && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>Proxy Type</Label>
|
||||
<Select value={proxyType} onValueChange={setProxyType}>
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
<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">
|
||||
Anti-Detect support is coming soon!
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{IS_ANTI_DETECT_SUPPORTED && (
|
||||
<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}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Proxy Selection - Common to both tabs - Compact without card */}
|
||||
{storedProxies.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<Label>Proxy</Label>
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedProxyId(value === "none" ? undefined : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
<SelectValue placeholder="No proxy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["http", "https", "socks4", "socks5"].map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.toUpperCase()}
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-host">Host</Label>
|
||||
<Input
|
||||
id="proxy-host"
|
||||
value={proxyHost}
|
||||
onChange={(e) => {
|
||||
setProxyHost(e.target.value);
|
||||
}}
|
||||
placeholder="e.g. 127.0.0.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-port">Port</Label>
|
||||
<Input
|
||||
id="proxy-port"
|
||||
type="number"
|
||||
value={proxyPort}
|
||||
onChange={(e) => {
|
||||
setProxyPort(Number.parseInt(e.target.value, 10) || 0);
|
||||
}}
|
||||
placeholder="e.g. 8080"
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
isLoading={isCreating}
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!canCreate}
|
||||
>
|
||||
Create Profile
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
<Button variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
onClick={handleCreate}
|
||||
isLoading={isCreating}
|
||||
disabled={isCreateDisabled()}
|
||||
>
|
||||
Create Profile
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
+163
-32
@@ -10,6 +10,7 @@
|
||||
* - Progress bars for downloads/updates
|
||||
* - Success/error states
|
||||
* - Customizable icons and content
|
||||
* - Auto-update notifications
|
||||
*
|
||||
* Usage Examples:
|
||||
*
|
||||
@@ -23,6 +24,11 @@
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* Auto-update toast:
|
||||
* ```
|
||||
* showAutoUpdateToast("Firefox", "125.0.1");
|
||||
* ```
|
||||
*
|
||||
* Download progress toast:
|
||||
* ```
|
||||
* showToast({
|
||||
@@ -42,11 +48,11 @@
|
||||
* ```
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
LuCheckCheck,
|
||||
LuDownload,
|
||||
LuRefreshCw,
|
||||
LuRocket,
|
||||
LuTriangleAlert,
|
||||
} from "react-icons/lu";
|
||||
|
||||
@@ -90,6 +96,7 @@ interface VersionUpdateToastProps extends BaseToastProps {
|
||||
current: number;
|
||||
total: number;
|
||||
found: number;
|
||||
current_browser?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -104,6 +111,16 @@ 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
|
||||
@@ -111,36 +128,56 @@ type ToastProps =
|
||||
| DownloadToastProps
|
||||
| VersionUpdateToastProps
|
||||
| FetchingToastProps
|
||||
| TwilightUpdateToastProps;
|
||||
| TwilightUpdateToastProps
|
||||
| AppUpdateToastProps;
|
||||
|
||||
function getToastIcon(type: ToastProps["type"], stage?: string) {
|
||||
switch (type) {
|
||||
case "success":
|
||||
return <LuCheckCheck className="h-4 w-4 text-green-500 flex-shrink-0" />;
|
||||
return <LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />;
|
||||
case "error":
|
||||
return <LuTriangleAlert className="h-4 w-4 text-red-500 flex-shrink-0" />;
|
||||
return <LuTriangleAlert className="flex-shrink-0 w-4 h-4 text-red-500" />;
|
||||
case "download":
|
||||
if (stage === "completed") {
|
||||
return (
|
||||
<LuCheckCheck className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
<LuCheckCheck className="flex-shrink-0 w-4 h-4 text-green-500" />
|
||||
);
|
||||
}
|
||||
return <LuDownload className="h-4 w-4 text-blue-500 flex-shrink-0" />;
|
||||
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" />
|
||||
);
|
||||
case "version-update":
|
||||
return (
|
||||
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
|
||||
);
|
||||
case "fetching":
|
||||
return (
|
||||
<LuRefreshCw className="h-4 w-4 text-blue-500 animate-spin flex-shrink-0" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-blue-500 animate-spin" />
|
||||
);
|
||||
case "twilight-update":
|
||||
return (
|
||||
<LuRefreshCw className="h-4 w-4 text-purple-500 animate-spin flex-shrink-0" />
|
||||
<LuRefreshCw className="flex-shrink-0 w-4 h-4 text-purple-500 animate-spin" />
|
||||
);
|
||||
case "loading":
|
||||
return (
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-blue-500 border-t-transparent flex-shrink-0" />
|
||||
<div className="flex-shrink-0 w-4 h-4 rounded-full border-2 border-blue-500 animate-spin border-t-transparent" />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -150,11 +187,33 @@ export function UnifiedToast(props: ToastProps) {
|
||||
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 w-full bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg p-3 shadow-lg">
|
||||
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
||||
<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-1 min-w-0">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white leading-tight">
|
||||
<p
|
||||
className={`text-sm font-medium leading-tight ${
|
||||
isAutoUpdate
|
||||
? "text-emerald-900 dark:text-emerald-100"
|
||||
: "text-gray-900 dark:text-white"
|
||||
}`}
|
||||
>
|
||||
{title}
|
||||
</p>
|
||||
|
||||
@@ -165,7 +224,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
stage === "downloading" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300 min-w-0 flex-1">
|
||||
<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`}
|
||||
@@ -180,28 +239,73 @@ export function UnifiedToast(props: ToastProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Version update progress */}
|
||||
{type === "version-update" && progress && "found" in progress && (
|
||||
{/* App update progress */}
|
||||
{type === "app-update" && (
|
||||
<div className="mt-2 space-y-1">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{progress.found} new versions found so far
|
||||
</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">
|
||||
{/* 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="bg-blue-500 h-1.5 rounded-full transition-all duration-300"
|
||||
style={{
|
||||
width: `${(progress.current / progress.total) * 100}%`,
|
||||
}}
|
||||
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>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap shrink-0 w-8 text-right">
|
||||
{progress.current}/{progress.total}
|
||||
</span>
|
||||
</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">
|
||||
{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="bg-blue-500 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">
|
||||
{progress.current}/{progress.total}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Twilight update progress */}
|
||||
{type === "twilight-update" && (
|
||||
<div className="mt-2">
|
||||
@@ -211,7 +315,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
: "Checking for twilight updates..."}
|
||||
</p>
|
||||
{props.browserName && (
|
||||
<p className="text-xs text-purple-600 dark:text-purple-400 mt-1">
|
||||
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
{props.browserName} • Rolling Release
|
||||
</p>
|
||||
)}
|
||||
@@ -220,7 +324,13 @@ export function UnifiedToast(props: ToastProps) {
|
||||
|
||||
{/* Description */}
|
||||
{description && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300 leading-tight">
|
||||
<p
|
||||
className={`mt-1 text-xs leading-tight ${
|
||||
isAutoUpdate
|
||||
? "text-emerald-700 dark:text-emerald-300"
|
||||
: "text-gray-600 dark:text-gray-300"
|
||||
}`}
|
||||
>
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
@@ -235,7 +345,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
)}
|
||||
{stage === "verifying" && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Verifying installation...
|
||||
Verifying browser files...
|
||||
</p>
|
||||
)}
|
||||
{stage === "downloading (twilight rolling release)" && (
|
||||
@@ -245,6 +355,27 @@ export function UnifiedToast(props: ToastProps) {
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export const ZenBrowser = (props: React.SVGProps<SVGSVGElement>) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width={24}
|
||||
height={24}
|
||||
role="graphics-symbol img"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
{...props}
|
||||
>
|
||||
<path
|
||||
d="M12 8.15c-2.12 0-3.85 1.72-3.85 3.85s1.72 3.85 3.85 3.85 3.85-1.72 3.85-3.85S14.13 8.15 12 8.15m0 6.92c-1.7 0-3.08-1.38-3.08-3.08S10.3 8.91 12 8.91s3.08 1.38 3.08 3.08-1.38 3.08-3.08 3.08"
|
||||
className="b"
|
||||
/>
|
||||
<path
|
||||
d="M12 5.33c-3.68 0-6.67 2.98-6.67 6.67s2.98 6.67 6.67 6.67 6.67-2.98 6.67-6.67S15.69 5.33 12 5.33m0 12.05c-2.97 0-5.38-2.41-5.38-5.38S9.03 6.62 12 6.62s5.38 2.41 5.38 5.38-2.41 5.38-5.38 5.38"
|
||||
className="b"
|
||||
/>
|
||||
<path
|
||||
d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2m0 18.2c-4.53 0-8.21-3.67-8.21-8.2S7.47 3.79 12 3.79s8.21 3.67 8.21 8.21-3.67 8.2-8.21 8.2"
|
||||
className="b"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
@@ -1,5 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { FaFolder } from "react-icons/fa";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -21,11 +26,6 @@ import {
|
||||
import { useBrowserSupport } from "@/hooks/use-browser-support";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type { DetectedProfile } from "@/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { useEffect, useState } from "react";
|
||||
import { FaFolder } from "react-icons/fa";
|
||||
import { toast } from "sonner";
|
||||
|
||||
interface ImportProfileDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -63,13 +63,7 @@ export function ImportProfileDialog({
|
||||
const { supportedBrowsers, isLoading: isLoadingSupport } =
|
||||
useBrowserSupport();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadDetectedProfiles();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadDetectedProfiles = async () => {
|
||||
const loadDetectedProfiles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const profiles = await invoke<DetectedProfile[]>(
|
||||
@@ -96,7 +90,7 @@ export function ImportProfileDialog({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleBrowseFolder = async () => {
|
||||
try {
|
||||
@@ -115,7 +109,7 @@ export function ImportProfileDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const handleAutoDetectImport = async () => {
|
||||
const handleAutoDetectImport = useCallback(async () => {
|
||||
if (!selectedDetectedProfile || !autoDetectProfileName.trim()) {
|
||||
toast.error("Please select a profile and provide a name");
|
||||
return;
|
||||
@@ -148,13 +142,31 @@ export function ImportProfileDialog({
|
||||
console.error("Failed to import profile:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to import profile: ${errorMessage}`);
|
||||
|
||||
// Check if error is about browser not being downloaded
|
||||
if (errorMessage.includes("No downloaded versions found")) {
|
||||
const browserDisplayName = getBrowserDisplayName(profile.browser);
|
||||
toast.error(
|
||||
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
|
||||
{
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to import profile: ${errorMessage}`);
|
||||
}
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
selectedDetectedProfile,
|
||||
autoDetectProfileName,
|
||||
detectedProfiles,
|
||||
onImportComplete,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
const handleManualImport = async () => {
|
||||
const handleManualImport = useCallback(async () => {
|
||||
if (
|
||||
!manualBrowserType ||
|
||||
!manualProfilePath.trim() ||
|
||||
@@ -183,11 +195,29 @@ export function ImportProfileDialog({
|
||||
console.error("Failed to import profile:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to import profile: ${errorMessage}`);
|
||||
|
||||
// Check if error is about browser not being downloaded
|
||||
if (errorMessage.includes("No downloaded versions found")) {
|
||||
const browserDisplayName = getBrowserDisplayName(manualBrowserType);
|
||||
toast.error(
|
||||
`${browserDisplayName} is not installed. Please download ${browserDisplayName} first from the main window, then try importing again.`,
|
||||
{
|
||||
duration: 8000,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.error(`Failed to import profile: ${errorMessage}`);
|
||||
}
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
};
|
||||
}, [
|
||||
manualBrowserType,
|
||||
manualProfilePath,
|
||||
manualProfileName,
|
||||
onImportComplete,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedDetectedProfile(null);
|
||||
@@ -222,6 +252,12 @@ export function ImportProfileDialog({
|
||||
(p) => p.path === selectedDetectedProfile,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadDetectedProfiles();
|
||||
}
|
||||
}, [isOpen, loadDetectedProfiles]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] my-8 flex flex-col">
|
||||
@@ -458,7 +494,7 @@ export function ImportProfileDialog({
|
||||
isLoading
|
||||
}
|
||||
>
|
||||
Import Detected Profile
|
||||
Import Profile
|
||||
</LoadingButton>
|
||||
) : (
|
||||
<LoadingButton
|
||||
@@ -472,7 +508,7 @@ export function ImportProfileDialog({
|
||||
!manualProfileName.trim()
|
||||
}
|
||||
>
|
||||
Import Manual Profile
|
||||
Import Profile
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { LuLoaderCircle } from "react-icons/lu";
|
||||
import { type ButtonProps, Button as UIButton } from "./ui/button";
|
||||
|
||||
type Props = ButtonProps & {
|
||||
isLoading: boolean;
|
||||
"aria-label"?: string;
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
"use client";
|
||||
|
||||
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,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
|
||||
interface PermissionDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
permissionType: PermissionType;
|
||||
onPermissionGranted?: () => void;
|
||||
}
|
||||
|
||||
export function PermissionDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
permissionType,
|
||||
onPermissionGranted,
|
||||
}: PermissionDialogProps) {
|
||||
const [isRequesting, setIsRequesting] = useState(false);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const {
|
||||
requestPermission,
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
} = usePermissions();
|
||||
|
||||
// Check if we're on macOS and close dialog if not
|
||||
useEffect(() => {
|
||||
const userAgent = navigator.userAgent;
|
||||
const isMac = userAgent.includes("Mac");
|
||||
setIsMacOS(isMac);
|
||||
|
||||
// If not macOS, close the dialog as permissions aren't needed
|
||||
if (!isMac) {
|
||||
onClose();
|
||||
}
|
||||
}, [onClose]);
|
||||
|
||||
// Get current permission status
|
||||
const isCurrentPermissionGranted =
|
||||
permissionType === "microphone"
|
||||
? isMicrophoneAccessGranted
|
||||
: isCameraAccessGranted;
|
||||
|
||||
// Auto-close dialog when permission is granted
|
||||
useEffect(() => {
|
||||
if (isCurrentPermissionGranted && isOpen) {
|
||||
onPermissionGranted?.();
|
||||
}
|
||||
}, [isCurrentPermissionGranted, isOpen, onPermissionGranted]);
|
||||
|
||||
const getPermissionIcon = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return <BsMic className="w-8 h-8" />;
|
||||
case "camera":
|
||||
return <BsCamera className="w-8 h-8" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionTitle = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Microphone Access Required";
|
||||
case "camera":
|
||||
return "Camera Access Required";
|
||||
}
|
||||
};
|
||||
|
||||
const getPermissionDescription = (type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Donut Browser needs access to your microphone to enable microphone functionality in web browsers. Each website that wants to use your microphone will still ask for your permission individually.";
|
||||
case "camera":
|
||||
return "Donut Browser needs access to your camera to enable camera functionality in web browsers. Each website that wants to use your camera will still ask for your permission individually.";
|
||||
}
|
||||
};
|
||||
|
||||
const handleRequestPermission = async () => {
|
||||
setIsRequesting(true);
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
showSuccessToast(
|
||||
`${getPermissionTitle(permissionType).replace(
|
||||
" Required",
|
||||
"",
|
||||
)} permission requested`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to request permission:", error);
|
||||
showErrorToast("Failed to request permission");
|
||||
} finally {
|
||||
setIsRequesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Don't render if not macOS
|
||||
if (!isMacOS) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader className="text-center">
|
||||
<div className="flex justify-center items-center mx-auto mb-4 w-16 h-16 bg-blue-100 rounded-full dark:bg-blue-900">
|
||||
{getPermissionIcon(permissionType)}
|
||||
</div>
|
||||
<DialogTitle className="text-xl">
|
||||
{getPermissionTitle(permissionType)}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-base">
|
||||
{getPermissionDescription(permissionType)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isCurrentPermissionGranted && (
|
||||
<div className="p-3 bg-green-50 rounded-lg dark:bg-green-900/20">
|
||||
<p className="text-sm text-green-800 dark:text-green-200">
|
||||
✅ Permission granted! Browsers launched from Donut Browser can
|
||||
now access your {permissionType}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isCurrentPermissionGranted && (
|
||||
<div className="p-3 bg-amber-50 rounded-lg dark:bg-amber-900/20">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200">
|
||||
⚠️ Permission not granted. Click the button below to request
|
||||
access to your {permissionType}.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="gap-2">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
{isCurrentPermissionGranted ? "Done" : "Cancel"}
|
||||
</Button>
|
||||
|
||||
{!isCurrentPermissionGranted && (
|
||||
<LoadingButton
|
||||
isLoading={isRequesting}
|
||||
onClick={() => {
|
||||
handleRequestPermission().catch(console.error);
|
||||
}}
|
||||
className="min-w-24"
|
||||
>
|
||||
Grant Access
|
||||
</LoadingButton>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,18 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
type ColumnDef,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
type SortingState,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as React from "react";
|
||||
import { CiCircleCheck } from "react-icons/ci";
|
||||
import { IoEllipsisHorizontal } from "react-icons/io5";
|
||||
import { LuChevronDown, LuChevronUp } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -18,6 +30,7 @@ import {
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -32,20 +45,13 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useTableSorting } from "@/hooks/use-table-sorting";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import {
|
||||
type ColumnDef,
|
||||
type SortingState,
|
||||
flexRender,
|
||||
getCoreRowModel,
|
||||
getSortedRowModel,
|
||||
useReactTable,
|
||||
} from "@tanstack/react-table";
|
||||
import * as React from "react";
|
||||
import { CiCircleCheck } from "react-icons/ci";
|
||||
import { IoEllipsisHorizontal } from "react-icons/io5";
|
||||
import { LuChevronDown, LuChevronUp } from "react-icons/lu";
|
||||
getBrowserDisplayName,
|
||||
getBrowserIcon,
|
||||
getCurrentOS,
|
||||
} from "@/lib/browser-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserProfile, StoredProxy } from "@/types";
|
||||
import { Input } from "./ui/input";
|
||||
import { Label } from "./ui/label";
|
||||
|
||||
@@ -57,8 +63,10 @@ interface ProfilesDataTableProps {
|
||||
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onRenameProfile: (oldName: string, newName: string) => Promise<void>;
|
||||
onChangeVersion: (profile: BrowserProfile) => void;
|
||||
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
||||
runningProfiles: Set<string>;
|
||||
isUpdating?: (browser: string) => boolean;
|
||||
onReloadProxyData?: () => void | Promise<void>;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -69,8 +77,10 @@ export function ProfilesDataTable({
|
||||
onDeleteProfile,
|
||||
onRenameProfile,
|
||||
onChangeVersion,
|
||||
onConfigureCamoufox,
|
||||
runningProfiles,
|
||||
isUpdating = () => false,
|
||||
onReloadProxyData,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
@@ -84,12 +94,65 @@ export function ProfilesDataTable({
|
||||
React.useState("");
|
||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||
const [isClient, setIsClient] = React.useState(false);
|
||||
const [storedProxies, setStoredProxies] = React.useState<StoredProxy[]>([]);
|
||||
|
||||
// Helper function to check if a profile has a proxy
|
||||
const hasProxy = React.useCallback(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!profile.proxy_id) return false;
|
||||
const proxy = storedProxies.find((p) => p.id === profile.proxy_id);
|
||||
return proxy !== undefined;
|
||||
},
|
||||
[storedProxies],
|
||||
);
|
||||
|
||||
// Helper function to get proxy info for a profile
|
||||
const getProxyInfo = React.useCallback(
|
||||
(profile: BrowserProfile): StoredProxy | null => {
|
||||
if (!profile.proxy_id) return null;
|
||||
return storedProxies.find((p) => p.id === profile.proxy_id) ?? null;
|
||||
},
|
||||
[storedProxies],
|
||||
);
|
||||
|
||||
// Helper function to get proxy name for display
|
||||
const getProxyDisplayName = React.useCallback(
|
||||
(profile: BrowserProfile): string => {
|
||||
if (!profile.proxy_id) return "Disabled";
|
||||
const proxy = storedProxies.find((p) => p.id === profile.proxy_id);
|
||||
return proxy?.name ?? "Unknown Proxy";
|
||||
},
|
||||
[storedProxies],
|
||||
);
|
||||
|
||||
// Ensure we're on the client side to prevent hydration mismatches
|
||||
React.useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
// Load stored proxies
|
||||
const loadStoredProxies = React.useCallback(async () => {
|
||||
try {
|
||||
const proxiesList = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(proxiesList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load stored proxies:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isClient) {
|
||||
void loadStoredProxies();
|
||||
}
|
||||
}, [isClient, loadStoredProxies]);
|
||||
|
||||
// Reload proxy data when requested from parent
|
||||
React.useEffect(() => {
|
||||
if (onReloadProxyData) {
|
||||
void loadStoredProxies();
|
||||
}
|
||||
}, [onReloadProxyData, loadStoredProxies]);
|
||||
|
||||
// Update local sorting state when settings are loaded
|
||||
React.useEffect(() => {
|
||||
if (isLoaded && isClient) {
|
||||
@@ -164,7 +227,7 @@ export function ProfilesDataTable({
|
||||
const isDisabled = shouldDisableTorStart || isBrowserUpdating;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-2 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
@@ -206,21 +269,34 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||
className="p-0 h-auto font-semibold hover:bg-transparent"
|
||||
>
|
||||
Profile
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
|
||||
{isSorted === "desc" && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4" />
|
||||
<LuChevronDown className="ml-2 w-4 h-4" />
|
||||
)}
|
||||
{!isSorted && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
return profile.name.length > 15 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate">{profile.name.slice(0, 15)}...</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{profile.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
profile.name
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "browser",
|
||||
@@ -232,15 +308,15 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||
className="p-0 h-auto font-semibold hover:bg-transparent"
|
||||
>
|
||||
Browser
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
|
||||
{isSorted === "desc" && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4" />
|
||||
<LuChevronDown className="ml-2 w-4 h-4" />
|
||||
)}
|
||||
{!isSorted && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
@@ -248,10 +324,21 @@ export function ProfilesDataTable({
|
||||
cell: ({ row }) => {
|
||||
const browser: string = row.getValue("browser");
|
||||
const IconComponent = getBrowserIcon(browser);
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{IconComponent && <IconComponent className="h-4 w-4" />}
|
||||
<span>{getBrowserDisplayName(browser)}</span>
|
||||
const browserDisplayName = getBrowserDisplayName(browser);
|
||||
return browserDisplayName.length > 15 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex gap-2 items-center">
|
||||
{IconComponent && <IconComponent className="w-4 h-4" />}
|
||||
<span>{browserDisplayName.slice(0, 15)}...</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{browserDisplayName}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="flex gap-2 items-center">
|
||||
{IconComponent && <IconComponent className="w-4 h-4" />}
|
||||
<span>{browserDisplayName}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
@@ -263,67 +350,33 @@ export function ProfilesDataTable({
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "version",
|
||||
header: "Version",
|
||||
},
|
||||
{
|
||||
id: "status",
|
||||
header: ({ column }) => {
|
||||
const isSorted = column.getIsSorted();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="h-auto p-0 font-semibold hover:bg-transparent"
|
||||
>
|
||||
Status
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 h-4 w-4" />}
|
||||
{isSorted === "desc" && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4" />
|
||||
)}
|
||||
{!isSorted && (
|
||||
<LuChevronDown className="ml-2 h-4 w-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
accessorKey: "release_type",
|
||||
header: "Release",
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
const isRunning = isClient && runningProfiles.has(profile.name);
|
||||
const releaseType: string = row.getValue("release_type");
|
||||
const isNightly = releaseType === "nightly";
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<Badge
|
||||
variant={isRunning ? "default" : "secondary"}
|
||||
className="text-xs w-fit"
|
||||
<div className="flex items-center">
|
||||
<span
|
||||
className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
isNightly
|
||||
? "text-yellow-800 bg-yellow-100 dark:bg-yellow-900 dark:text-yellow-200"
|
||||
: "text-green-800 bg-green-100 dark:bg-green-900 dark:text-green-200"
|
||||
}`}
|
||||
>
|
||||
{isClient ? (isRunning ? "Running" : "Stopped") : "Loading..."}
|
||||
</Badge>
|
||||
{isClient && isRunning && profile.process_id && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
PID: {profile.process_id}
|
||||
</span>
|
||||
)}
|
||||
{isNightly ? "Nightly" : "Stable"}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB) => {
|
||||
// If not on client, sort by name only to ensure consistency
|
||||
if (!isClient) {
|
||||
return rowA.original.name.localeCompare(rowB.original.name);
|
||||
}
|
||||
|
||||
const isRunningA = runningProfiles.has(rowA.original.name);
|
||||
const isRunningB = runningProfiles.has(rowB.original.name);
|
||||
|
||||
// Running profiles come first, then stopped ones
|
||||
// Secondary sort by profile name
|
||||
if (isRunningA === isRunningB) {
|
||||
return rowA.original.name.localeCompare(rowB.original.name);
|
||||
}
|
||||
return isRunningA ? -1 : 1;
|
||||
sortingFn: (rowA, rowB, columnId) => {
|
||||
const releaseA: string = rowA.getValue(columnId);
|
||||
const releaseB: string = rowB.getValue(columnId);
|
||||
// Sort with "stable" before "nightly"
|
||||
if (releaseA === "stable" && releaseB === "nightly") return -1;
|
||||
if (releaseA === "nightly" && releaseB === "stable") return 1;
|
||||
return 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -331,26 +384,41 @@ export function ProfilesDataTable({
|
||||
header: "Proxy",
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
const hasProxy = profile.proxy?.enabled;
|
||||
const profileHasProxy = hasProxy(profile);
|
||||
const proxyDisplayName = getProxyDisplayName(profile);
|
||||
const proxyInfo = getProxyInfo(profile);
|
||||
|
||||
const tooltipText =
|
||||
profile.browser === "tor-browser"
|
||||
? "Proxies are not supported for TOR browser"
|
||||
: profileHasProxy && proxyInfo
|
||||
? `${proxyDisplayName}, ${proxyInfo.proxy_settings.proxy_type.toUpperCase()} (${
|
||||
proxyInfo.proxy_settings.host
|
||||
}:${proxyInfo.proxy_settings.port})`
|
||||
: "No proxy configured";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex items-center gap-2">
|
||||
{hasProxy && (
|
||||
<CiCircleCheck className="h-4 w-4 text-green-500" />
|
||||
<div className="flex gap-2 items-center">
|
||||
{profileHasProxy && (
|
||||
<CiCircleCheck className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
|
||||
{proxyDisplayName.length > 10 ? (
|
||||
<span className="text-sm truncate text-muted-foreground">
|
||||
{proxyDisplayName.slice(0, 10)}...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{profile.browser === "tor-browser"
|
||||
? "Not supported"
|
||||
: proxyDisplayName}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{hasProxy ? profile.proxy?.proxy_type : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{hasProxy
|
||||
? `${profile.proxy?.proxy_type.toUpperCase()} proxy enabled (${
|
||||
profile.proxy?.host
|
||||
}:${profile.proxy?.port})`
|
||||
: "No proxy configured"}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
@@ -363,16 +431,16 @@ export function ProfilesDataTable({
|
||||
const isRunning = isClient && runningProfiles.has(profile.name);
|
||||
const isBrowserUpdating = isClient && isUpdating(profile.browser);
|
||||
return (
|
||||
<div className="flex items-center justify-end">
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="h-8 w-8 p-0"
|
||||
className="p-0 w-8 h-8"
|
||||
disabled={!isClient}
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<IoEllipsisHorizontal className="h-4 w-4" />
|
||||
<IoEllipsisHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
@@ -384,16 +452,30 @@ export function ProfilesDataTable({
|
||||
}}
|
||||
disabled={!isClient || isBrowserUpdating}
|
||||
>
|
||||
Configure proxy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onChangeVersion(profile);
|
||||
}}
|
||||
disabled={!isClient || isRunning || isBrowserUpdating}
|
||||
>
|
||||
Change version
|
||||
Configure Proxy
|
||||
</DropdownMenuItem>
|
||||
{profile.browser === "camoufox" && onConfigureCamoufox && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onConfigureCamoufox(profile);
|
||||
}}
|
||||
disabled={!isClient || isBrowserUpdating}
|
||||
>
|
||||
Configure Camoufox
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{!["chromium", "zen", "camoufox"].includes(
|
||||
profile.browser,
|
||||
) && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onChangeVersion(profile);
|
||||
}}
|
||||
disabled={!isClient || isRunning || isBrowserUpdating}
|
||||
>
|
||||
Switch Release
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setProfileToRename(profile);
|
||||
@@ -429,6 +511,10 @@ export function ProfilesDataTable({
|
||||
onKillProfile,
|
||||
onProxySettings,
|
||||
onChangeVersion,
|
||||
onConfigureCamoufox,
|
||||
getProxyInfo,
|
||||
hasProxy,
|
||||
getProxyDisplayName,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -443,9 +529,16 @@ export function ProfilesDataTable({
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
});
|
||||
|
||||
const platform = getCurrentOS();
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="rounded-md border">
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"rounded-md border",
|
||||
platform === "macos" ? "h-[380px]" : "h-[320px]",
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
@@ -494,7 +587,7 @@ export function ProfilesDataTable({
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
<Dialog
|
||||
open={profileToRename !== null}
|
||||
@@ -511,7 +604,7 @@ export function ProfilesDataTable({
|
||||
<DialogTitle>Rename Profile</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-4 items-center gap-4">
|
||||
<div className="grid grid-cols-4 gap-4 items-center">
|
||||
<Label htmlFor="name" className="text-right">
|
||||
Name
|
||||
</Label>
|
||||
@@ -573,6 +666,11 @@ export function ProfilesDataTable({
|
||||
setDeleteConfirmationName(e.target.value);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleDelete();
|
||||
}
|
||||
}}
|
||||
placeholder="Type the profile name here"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
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";
|
||||
@@ -24,11 +28,7 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { LuCopy } from "react-icons/lu";
|
||||
import { toast } from "sonner";
|
||||
import type { BrowserProfile, StoredProxy } from "@/types";
|
||||
|
||||
interface ProfileSelectorDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -47,25 +47,77 @@ export function ProfileSelectorDialog({
|
||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadProfiles();
|
||||
}
|
||||
}, [isOpen]);
|
||||
// Helper function to check if a profile has a proxy
|
||||
const hasProxy = useCallback(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!profile.proxy_id) return false;
|
||||
const proxy = storedProxies.find((p) => p.id === profile.proxy_id);
|
||||
return proxy !== undefined;
|
||||
},
|
||||
[storedProxies],
|
||||
);
|
||||
|
||||
const loadProfiles = async () => {
|
||||
// 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 {
|
||||
const profileList = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
// Load both profiles and stored proxies
|
||||
const [profileList, proxiesList] = await Promise.all([
|
||||
invoke<BrowserProfile[]>("list_browser_profiles"),
|
||||
invoke<StoredProxy[]>("get_stored_proxies"),
|
||||
]);
|
||||
|
||||
// Sort profiles by name
|
||||
profileList.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Don't filter any profiles, show all of them
|
||||
// Set both profiles and proxies
|
||||
setProfiles(profileList);
|
||||
setStoredProxies(proxiesList);
|
||||
|
||||
// Auto-select first available profile for link opening
|
||||
if (profileList.length > 0) {
|
||||
@@ -99,60 +151,20 @@ export function ProfileSelectorDialog({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Helper function to determine if a profile can be used for opening links
|
||||
const canUseProfileForLinks = (
|
||||
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: never allow if running
|
||||
if (profile.browser === "mullvad-browser" && isRunning) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// For other browsers: always allow
|
||||
return true;
|
||||
};
|
||||
}, [runningProfiles, canUseProfileForLinks]);
|
||||
|
||||
// Helper function to get tooltip content for profiles
|
||||
const getProfileTooltipContent = (profile: BrowserProfile): string => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
|
||||
if (profile.browser === "tor-browser") {
|
||||
const runningTorProfiles = profiles.filter(
|
||||
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name),
|
||||
);
|
||||
|
||||
// If another TOR profile is running, this one is not available
|
||||
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 (profile.browser === "mullvad-browser") {
|
||||
if (isRunning) {
|
||||
return "Only launching the browser is supported, opening them in a running browser is not yet available";
|
||||
}
|
||||
return "Only launching the browser is supported, opening them in a running browser is not yet available";
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
return "URL will open in a new tab in the existing browser window";
|
||||
}
|
||||
@@ -160,7 +172,7 @@ export function ProfileSelectorDialog({
|
||||
return "";
|
||||
};
|
||||
|
||||
const handleOpenUrl = async () => {
|
||||
const handleOpenUrl = useCallback(async () => {
|
||||
if (!selectedProfile || !url) return;
|
||||
|
||||
setIsLaunching(true);
|
||||
@@ -175,14 +187,14 @@ export function ProfileSelectorDialog({
|
||||
} finally {
|
||||
setIsLaunching(false);
|
||||
}
|
||||
};
|
||||
}, [selectedProfile, url, onClose]);
|
||||
|
||||
const handleCancel = () => {
|
||||
const handleCancel = useCallback(() => {
|
||||
setSelectedProfile(null);
|
||||
onClose();
|
||||
};
|
||||
}, [onClose]);
|
||||
|
||||
const handleCopyUrl = async () => {
|
||||
const handleCopyUrl = useCallback(async () => {
|
||||
if (!url) return;
|
||||
|
||||
try {
|
||||
@@ -192,12 +204,9 @@ export function ProfileSelectorDialog({
|
||||
console.error("Failed to copy URL:", error);
|
||||
toast.error("Failed to copy URL to clipboard");
|
||||
}
|
||||
};
|
||||
}, [url]);
|
||||
|
||||
const selectedProfileData = profiles.find((p) => p.name === selectedProfile);
|
||||
const isSelectedProfileRunning = selectedProfile
|
||||
? runningProfiles.has(selectedProfile)
|
||||
: false;
|
||||
|
||||
// Check if the selected profile can be used for opening links
|
||||
const canOpenWithSelectedProfile = () => {
|
||||
@@ -215,6 +224,12 @@ export function ProfileSelectorDialog({
|
||||
return getProfileTooltipContent(selectedProfileData);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadProfiles();
|
||||
}
|
||||
}, [isOpen, loadProfiles]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
@@ -225,19 +240,19 @@ export function ProfileSelectorDialog({
|
||||
<div className="grid gap-4 py-4">
|
||||
{url && (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label className="text-sm font-medium">Opening URL:</Label>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void handleCopyUrl()}
|
||||
className="flex items-center gap-2"
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuCopy className="h-3 w-3" />
|
||||
<LuCopy className="w-3 h-3" />
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-2 bg-muted rounded text-sm break-all">
|
||||
<div className="p-2 text-sm break-all rounded bg-muted">
|
||||
{url}
|
||||
</div>
|
||||
</div>
|
||||
@@ -260,86 +275,84 @@ export function ProfileSelectorDialog({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
value={selectedProfile ?? undefined}
|
||||
onValueChange={setSelectedProfile}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a profile" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const canUseForLinks = canUseProfileForLinks(
|
||||
profile,
|
||||
profiles,
|
||||
runningProfiles,
|
||||
);
|
||||
const tooltipContent = getProfileTooltipContent(profile);
|
||||
<Select
|
||||
value={selectedProfile ?? undefined}
|
||||
onValueChange={setSelectedProfile}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Choose a profile" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const canUseForLinks = canUseProfileForLinks(
|
||||
profile,
|
||||
profiles,
|
||||
runningProfiles,
|
||||
);
|
||||
const tooltipContent = getProfileTooltipContent(profile);
|
||||
|
||||
return (
|
||||
<Tooltip key={profile.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem
|
||||
value={profile.name}
|
||||
disabled={!canUseForLinks}
|
||||
return (
|
||||
<Tooltip key={profile.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem
|
||||
value={profile.name}
|
||||
disabled={!canUseForLinks}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
!canUseForLinks ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
!canUseForLinks ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 py-1 px-2 rounded-lg hover:bg-accent cursor-pointer">
|
||||
<div className="flex items-center gap-2">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon(
|
||||
profile.browser,
|
||||
);
|
||||
return IconComponent ? (
|
||||
<IconComponent className="h-4 w-4" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<div className="font-medium">
|
||||
{profile.name}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getBrowserDisplayName(profile.browser)}
|
||||
</Badge>
|
||||
{profile.proxy?.enabled && (
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
{tooltipContent && (
|
||||
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</>
|
||||
<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>
|
||||
</TooltipTrigger>
|
||||
{tooltipContent && (
|
||||
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
"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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { StoredProxy } from "@/types";
|
||||
|
||||
interface ProxyFormData {
|
||||
name: string;
|
||||
proxy_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface ProxyFormDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (proxy: StoredProxy) => void;
|
||||
editingProxy?: StoredProxy | null;
|
||||
}
|
||||
|
||||
export function ProxyFormDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
editingProxy,
|
||||
}: ProxyFormDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState<ProxyFormData>({
|
||||
name: "",
|
||||
proxy_type: "http",
|
||||
host: "",
|
||||
port: 8080,
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
|
||||
const resetForm = useCallback(() => {
|
||||
setFormData({
|
||||
name: "",
|
||||
proxy_type: "http",
|
||||
host: "",
|
||||
port: 8080,
|
||||
username: "",
|
||||
password: "",
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Load editing proxy data when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (editingProxy) {
|
||||
setFormData({
|
||||
name: editingProxy.name,
|
||||
proxy_type: editingProxy.proxy_settings.proxy_type,
|
||||
host: editingProxy.proxy_settings.host,
|
||||
port: editingProxy.proxy_settings.port,
|
||||
username: editingProxy.proxy_settings.username || "",
|
||||
password: editingProxy.proxy_settings.password || "",
|
||||
});
|
||||
} else {
|
||||
resetForm();
|
||||
}
|
||||
}
|
||||
}, [isOpen, editingProxy, resetForm]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!formData.name.trim()) {
|
||||
toast.error("Proxy name is required");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!formData.host.trim() || !formData.port) {
|
||||
toast.error("Host and port are required");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const proxySettings = {
|
||||
proxy_type: formData.proxy_type,
|
||||
host: formData.host.trim(),
|
||||
port: formData.port,
|
||||
username: formData.username.trim() || undefined,
|
||||
password: formData.password.trim() || undefined,
|
||||
};
|
||||
|
||||
let savedProxy: StoredProxy;
|
||||
|
||||
if (editingProxy) {
|
||||
// Update existing proxy
|
||||
savedProxy = await invoke<StoredProxy>("update_stored_proxy", {
|
||||
proxyId: editingProxy.id,
|
||||
name: formData.name.trim(),
|
||||
proxySettings,
|
||||
});
|
||||
toast.success("Proxy updated successfully");
|
||||
} else {
|
||||
// Create new proxy
|
||||
savedProxy = await invoke<StoredProxy>("create_stored_proxy", {
|
||||
name: formData.name.trim(),
|
||||
proxySettings,
|
||||
});
|
||||
toast.success("Proxy created successfully");
|
||||
}
|
||||
|
||||
onSave(savedProxy);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save proxy:", error);
|
||||
const errorMessage =
|
||||
error instanceof Error ? error.message : String(error);
|
||||
toast.error(`Failed to save proxy: ${errorMessage}`);
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, editingProxy, onSave, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
onClose();
|
||||
}
|
||||
}, [isSubmitting, onClose]);
|
||||
|
||||
const isFormValid =
|
||||
formData.name.trim() &&
|
||||
formData.host.trim() &&
|
||||
formData.port > 0 &&
|
||||
formData.port <= 65535;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{editingProxy ? "Edit Proxy" : "Create New Proxy"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-name">Proxy Name</Label>
|
||||
<Input
|
||||
id="proxy-name"
|
||||
value={formData.name}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, name: e.target.value })
|
||||
}
|
||||
placeholder="e.g. Office Proxy, Home VPN, etc."
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label>Proxy Type</Label>
|
||||
<Select
|
||||
value={formData.proxy_type}
|
||||
onValueChange={(value) =>
|
||||
setFormData({ ...formData, proxy_type: value })
|
||||
}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select proxy type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["http", "https", "socks4", "socks5"].map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-host">Host</Label>
|
||||
<Input
|
||||
id="proxy-host"
|
||||
value={formData.host}
|
||||
onChange={(e) =>
|
||||
setFormData({ ...formData, host: e.target.value })
|
||||
}
|
||||
placeholder="e.g. 127.0.0.1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-port">Port</Label>
|
||||
<Input
|
||||
id="proxy-port"
|
||||
type="number"
|
||||
value={formData.port}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
port: parseInt(e.target.value, 10) || 0,
|
||||
})
|
||||
}
|
||||
placeholder="e.g. 8080"
|
||||
min="1"
|
||||
max="65535"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-username">Username (optional)</Label>
|
||||
<Input
|
||||
id="proxy-username"
|
||||
value={formData.username}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
username: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Proxy username"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="proxy-password">Password (optional)</Label>
|
||||
<Input
|
||||
id="proxy-password"
|
||||
type="password"
|
||||
value={formData.password}
|
||||
onChange={(e) =>
|
||||
setFormData({
|
||||
...formData,
|
||||
password: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="Proxy password"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleClose}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
isLoading={isSubmitting}
|
||||
onClick={handleSubmit}
|
||||
disabled={!isFormValid}
|
||||
>
|
||||
{editingProxy ? "Update Proxy" : "Create Proxy"}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
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 { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import type { StoredProxy } from "@/types";
|
||||
|
||||
interface ProxyManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ProxyManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ProxyManagementDialogProps) {
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
|
||||
|
||||
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();
|
||||
}
|
||||
}, [isOpen, loadStoredProxies]);
|
||||
|
||||
const handleDeleteProxy = useCallback(async (proxy: StoredProxy) => {
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete the proxy "${proxy.name}"?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke("delete_stored_proxy", { proxyId: proxy.id });
|
||||
setStoredProxies((prev) => prev.filter((p) => p.id !== proxy.id));
|
||||
toast.success("Proxy deleted successfully");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete proxy:", error);
|
||||
toast.error("Failed to delete proxy");
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreateProxy = useCallback(() => {
|
||||
setEditingProxy(null);
|
||||
setShowProxyForm(true);
|
||||
}, []);
|
||||
|
||||
const handleEditProxy = useCallback((proxy: StoredProxy) => {
|
||||
setEditingProxy(proxy);
|
||||
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];
|
||||
}
|
||||
});
|
||||
setShowProxyForm(false);
|
||||
setEditingProxy(null);
|
||||
}, []);
|
||||
|
||||
const handleProxyFormClose = useCallback(() => {
|
||||
setShowProxyForm(false);
|
||||
setEditingProxy(null);
|
||||
}, []);
|
||||
|
||||
const trimName = useCallback((name: string) => {
|
||||
return name.length > 30 ? `${name.substring(0, 30)}...` : name;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<div className="flex gap-2 items-center">
|
||||
<FiWifi className="w-5 h-5" />
|
||||
<DialogTitle>Proxy Management</DialogTitle>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-col flex-1 gap-4 py-4 min-h-0">
|
||||
{/* Header with Create Button */}
|
||||
<div className="flex flex-shrink-0 justify-between items-center">
|
||||
<div>
|
||||
<h3 className="text-lg font-medium">Stored Proxies</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage your saved proxy configurations for reuse across
|
||||
profiles
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleCreateProxy}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<FiPlus className="w-4 h-4" />
|
||||
Create Proxy
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Proxy List - Scrollable */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
</p>
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
<div className="flex flex-col justify-center items-center h-32 text-center">
|
||||
<FiWifi className="mx-auto mb-4 w-12 h-12 text-muted-foreground" />
|
||||
<p className="mb-2 text-muted-foreground">
|
||||
No proxies configured
|
||||
</p>
|
||||
<p className="mb-4 text-sm text-muted-foreground">
|
||||
Create your first proxy configuration to get started
|
||||
</p>
|
||||
<Button variant="outline" onClick={handleCreateProxy}>
|
||||
<FiPlus className="mr-2 w-4 h-4" />
|
||||
Create First Proxy
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto pr-2 space-y-2 h-full">
|
||||
{storedProxies.map((proxy) => (
|
||||
<div
|
||||
key={proxy.id}
|
||||
className="flex justify-between items-center p-1 rounded border bg-card"
|
||||
>
|
||||
<div className="flex-1 ml-2 min-w-0">
|
||||
{proxy.name.length > 30 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block font-medium truncate text-card-foreground">
|
||||
{trimName(proxy.name)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="text-sm font-medium text-card-foreground">
|
||||
{proxy.name}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-card-foreground">
|
||||
{proxy.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 gap-1 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditProxy(proxy)}
|
||||
>
|
||||
<FiEdit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteProxy(proxy)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<FiTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<Button onClick={onClose}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<ProxyFormDialog
|
||||
isOpen={showProxyForm}
|
||||
onClose={handleProxyFormClose}
|
||||
onSave={handleProxySaved}
|
||||
editingProxy={editingProxy}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,13 @@
|
||||
"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 { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -9,34 +15,20 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
interface ProxySettings {
|
||||
enabled: boolean;
|
||||
proxy_type: string;
|
||||
host: string;
|
||||
port: number;
|
||||
}
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { StoredProxy } from "@/types";
|
||||
|
||||
interface ProxySettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (proxySettings: ProxySettings) => void;
|
||||
initialSettings?: ProxySettings;
|
||||
onSave: (proxyId: string | null) => void;
|
||||
initialProxyId?: string | null;
|
||||
browserType?: string;
|
||||
}
|
||||
|
||||
@@ -44,197 +36,245 @@ export function ProxySettingsDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
initialSettings,
|
||||
initialProxyId,
|
||||
browserType,
|
||||
}: ProxySettingsDialogProps) {
|
||||
const [settings, setSettings] = useState<ProxySettings>({
|
||||
enabled: initialSettings?.enabled ?? false,
|
||||
proxy_type: initialSettings?.proxy_type ?? "http",
|
||||
host: initialSettings?.host ?? "",
|
||||
port: initialSettings?.port ?? 8080,
|
||||
});
|
||||
|
||||
const [initialSettingsState, setInitialSettingsState] =
|
||||
useState<ProxySettings>({
|
||||
enabled: false,
|
||||
proxy_type: "http",
|
||||
host: "",
|
||||
port: 8080,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && initialSettings) {
|
||||
const newSettings = {
|
||||
enabled: initialSettings.enabled,
|
||||
proxy_type: initialSettings.proxy_type,
|
||||
host: initialSettings.host,
|
||||
port: initialSettings.port,
|
||||
};
|
||||
setSettings(newSettings);
|
||||
setInitialSettingsState(newSettings);
|
||||
} else if (isOpen) {
|
||||
const defaultSettings = {
|
||||
enabled: false,
|
||||
proxy_type: "http",
|
||||
host: "",
|
||||
port: 80,
|
||||
};
|
||||
setSettings(defaultSettings);
|
||||
setInitialSettingsState(defaultSettings);
|
||||
}
|
||||
}, [isOpen, initialSettings]);
|
||||
|
||||
const handleSubmit = () => {
|
||||
onSave(settings);
|
||||
};
|
||||
|
||||
// Check if settings have changed
|
||||
const hasChanged = () => {
|
||||
return (
|
||||
settings.enabled !== initialSettingsState.enabled ||
|
||||
settings.proxy_type !== initialSettingsState.proxy_type ||
|
||||
settings.host !== initialSettingsState.host ||
|
||||
settings.port !== initialSettingsState.port
|
||||
);
|
||||
};
|
||||
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";
|
||||
|
||||
// Update proxy enabled state when browser is tor-browser
|
||||
useEffect(() => {
|
||||
if (browserType === "tor-browser" && settings.enabled) {
|
||||
setSettings((prev) => ({ ...prev, enabled: false }));
|
||||
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);
|
||||
}
|
||||
}, [browserType, settings.enabled]);
|
||||
}, []);
|
||||
|
||||
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>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Proxy Settings</DialogTitle>
|
||||
</DialogHeader>
|
||||
<>
|
||||
<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-4 py-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
{isProxyDisabled ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex items-center space-x-2 opacity-50">
|
||||
<Checkbox
|
||||
id="proxy-enabled"
|
||||
checked={false}
|
||||
disabled={true}
|
||||
/>
|
||||
<Label htmlFor="proxy-enabled" className="text-gray-500">
|
||||
Enable Proxy
|
||||
</Label>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>
|
||||
Tor Browser has its own built-in proxy system and
|
||||
doesn't support additional proxy configuration
|
||||
</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<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 && (
|
||||
<>
|
||||
<Checkbox
|
||||
id="proxy-enabled"
|
||||
checked={settings.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
setSettings({ ...settings, enabled: checked as boolean });
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="proxy-enabled">Enable Proxy</Label>
|
||||
{/* 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>
|
||||
|
||||
{settings.enabled && !isProxyDisabled && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>Proxy Type</Label>
|
||||
<Select
|
||||
value={settings.proxy_type}
|
||||
onValueChange={(value) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
proxy_type: value,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select proxy type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{["http", "https", "socks4", "socks5"].map((type) => (
|
||||
<SelectItem key={type} value={type}>
|
||||
{type.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={!hasChanged()}>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="host">Host</Label>
|
||||
<Input
|
||||
id="host"
|
||||
value={settings.host}
|
||||
onChange={(e) => {
|
||||
setSettings({ ...settings, host: e.target.value });
|
||||
}}
|
||||
placeholder="e.g. 127.0.0.1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="port">Port</Label>
|
||||
<Input
|
||||
id="port"
|
||||
type="number"
|
||||
value={settings.port}
|
||||
onChange={(e) => {
|
||||
setSettings({
|
||||
...settings,
|
||||
port: Number.parseInt(e.target.value, 10) || 0,
|
||||
});
|
||||
}}
|
||||
placeholder="e.g. 8080"
|
||||
min="1"
|
||||
max="65535"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={
|
||||
!hasChanged() ||
|
||||
(!isProxyDisabled &&
|
||||
settings.enabled &&
|
||||
(!settings.host || !settings.port))
|
||||
}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<ProxyFormDialog
|
||||
isOpen={showProxyForm}
|
||||
onClose={handleProxyFormClose}
|
||||
onSave={handleProxySaved}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
"use client";
|
||||
|
||||
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,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { BrowserReleaseTypes } from "@/types";
|
||||
|
||||
interface ReleaseTypeSelectorProps {
|
||||
selectedReleaseType: "stable" | "nightly" | null;
|
||||
onReleaseTypeSelect: (releaseType: "stable" | "nightly" | null) => void;
|
||||
availableReleaseTypes: BrowserReleaseTypes;
|
||||
browser: string;
|
||||
isDownloading: boolean;
|
||||
onDownload: () => void;
|
||||
placeholder?: string;
|
||||
showDownloadButton?: boolean;
|
||||
downloadedVersions?: string[];
|
||||
}
|
||||
|
||||
export function ReleaseTypeSelector({
|
||||
selectedReleaseType,
|
||||
onReleaseTypeSelect,
|
||||
availableReleaseTypes,
|
||||
browser,
|
||||
isDownloading,
|
||||
onDownload,
|
||||
placeholder = "Select release type...",
|
||||
showDownloadButton = true,
|
||||
downloadedVersions = [],
|
||||
}: ReleaseTypeSelectorProps) {
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
|
||||
const releaseOptions = [
|
||||
...(availableReleaseTypes.stable
|
||||
? [{ type: "stable" as const, version: availableReleaseTypes.stable }]
|
||||
: []),
|
||||
...(availableReleaseTypes.nightly && browser !== "chromium"
|
||||
? [{ type: "nightly" as const, version: availableReleaseTypes.nightly }]
|
||||
: []),
|
||||
];
|
||||
|
||||
// Only show dropdown if there are multiple release types available
|
||||
const showDropdown = releaseOptions.length > 1;
|
||||
|
||||
// If only one release type is available, auto-select it
|
||||
if (!showDropdown && releaseOptions.length === 1 && !selectedReleaseType) {
|
||||
setTimeout(() => {
|
||||
onReleaseTypeSelect(releaseOptions[0].type);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
const selectedDisplayText = selectedReleaseType
|
||||
? selectedReleaseType === "stable"
|
||||
? "Stable"
|
||||
: "Nightly"
|
||||
: placeholder;
|
||||
|
||||
const selectedVersion =
|
||||
selectedReleaseType === "stable"
|
||||
? availableReleaseTypes.stable
|
||||
: selectedReleaseType === "nightly"
|
||||
? availableReleaseTypes.nightly
|
||||
: null;
|
||||
|
||||
const isVersionDownloaded =
|
||||
selectedVersion && downloadedVersions.includes(selectedVersion);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{showDropdown ? (
|
||||
<Popover open={popoverOpen} onOpenChange={setPopoverOpen} modal={true}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={popoverOpen}
|
||||
className="justify-between w-full"
|
||||
>
|
||||
{selectedDisplayText}
|
||||
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="p-0">
|
||||
<Command>
|
||||
<CommandEmpty>No release types available.</CommandEmpty>
|
||||
<CommandList>
|
||||
<CommandGroup>
|
||||
{releaseOptions.map((option) => {
|
||||
const isDownloaded = downloadedVersions.includes(
|
||||
option.version,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.type}
|
||||
value={option.type}
|
||||
onSelect={(currentValue) => {
|
||||
const selectedType = currentValue as
|
||||
| "stable"
|
||||
| "nightly";
|
||||
onReleaseTypeSelect(
|
||||
selectedType === selectedReleaseType
|
||||
? null
|
||||
: selectedType,
|
||||
);
|
||||
setPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedReleaseType === option.type
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="capitalize">{option.type}</span>
|
||||
{option.type === "nightly" && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Nightly
|
||||
</Badge>
|
||||
)}
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{option.version}
|
||||
</Badge>
|
||||
{isDownloaded && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
) : (
|
||||
// Show a simple display when only one release type is available
|
||||
releaseOptions.length === 1 && (
|
||||
<div className="flex gap-2 justify-center items-center p-3 rounded-md border bg-muted/50">
|
||||
<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>
|
||||
{downloadedVersions.includes(releaseOptions[0].version) && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{showDownloadButton &&
|
||||
selectedReleaseType &&
|
||||
selectedVersion &&
|
||||
!isVersionDownloaded && (
|
||||
<LoadingButton
|
||||
isLoading={isDownloading}
|
||||
onClick={() => {
|
||||
onDownload();
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<LuDownload className="mr-2 w-4 h-4" />
|
||||
{isDownloading ? "Downloading..." : "Download Browser"}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
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";
|
||||
@@ -19,17 +23,20 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { showSuccessToast } from "@/lib/toast-utils";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
show_settings_on_startup: boolean;
|
||||
theme: string;
|
||||
auto_updates_enabled: boolean;
|
||||
auto_delete_unused_binaries: boolean;
|
||||
}
|
||||
|
||||
interface PermissionInfo {
|
||||
permission_type: PermissionType;
|
||||
isGranted: boolean;
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface SettingsDialogProps {
|
||||
@@ -42,42 +49,68 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
set_as_default_browser: false,
|
||||
show_settings_on_startup: true,
|
||||
theme: "system",
|
||||
auto_updates_enabled: true,
|
||||
auto_delete_unused_binaries: true,
|
||||
});
|
||||
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
show_settings_on_startup: true,
|
||||
theme: "system",
|
||||
auto_updates_enabled: true,
|
||||
auto_delete_unused_binaries: true,
|
||||
});
|
||||
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isSettingDefault, setIsSettingDefault] = useState(false);
|
||||
const [isClearingCache, setIsClearingCache] = useState(false);
|
||||
const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
|
||||
const [isLoadingPermissions, setIsLoadingPermissions] = useState(false);
|
||||
const [requestingPermission, setRequestingPermission] =
|
||||
useState<PermissionType | null>(null);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
const {
|
||||
requestPermission,
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
} = usePermissions();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadSettings();
|
||||
void checkDefaultBrowserStatus();
|
||||
|
||||
// Set up interval to check default browser status
|
||||
const intervalId = setInterval(() => {
|
||||
void checkDefaultBrowserStatus();
|
||||
}, 500); // Check every 2 seconds
|
||||
|
||||
// Cleanup interval on component unmount or dialog close
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
const getPermissionIcon = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return <BsMic className="w-4 h-4" />;
|
||||
case "camera":
|
||||
return <BsCamera className="w-4 h-4" />;
|
||||
}
|
||||
}, [isOpen]);
|
||||
}, []);
|
||||
|
||||
const loadSettings = async () => {
|
||||
const getPermissionDisplayName = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Microphone";
|
||||
case "camera":
|
||||
return "Camera";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const getStatusBadge = useCallback((isGranted: boolean) => {
|
||||
if (isGranted) {
|
||||
return (
|
||||
<Badge variant="default" className="text-green-800 bg-green-100">
|
||||
Granted
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return <Badge variant="secondary">Not Granted</Badge>;
|
||||
}, []);
|
||||
|
||||
const getPermissionDescription = useCallback((type: PermissionType) => {
|
||||
switch (type) {
|
||||
case "microphone":
|
||||
return "Access to microphone for browser applications";
|
||||
case "camera":
|
||||
return "Access to camera for browser applications";
|
||||
}
|
||||
}, []);
|
||||
const loadSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const appSettings = await invoke<AppSettings>("get_app_settings");
|
||||
@@ -88,18 +121,53 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const checkDefaultBrowserStatus = async () => {
|
||||
const loadPermissions = useCallback(async () => {
|
||||
setIsLoadingPermissions(true);
|
||||
try {
|
||||
if (!isMacOS) {
|
||||
// On non-macOS platforms, don't show permissions
|
||||
setPermissions([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const permissionList: PermissionInfo[] = [
|
||||
{
|
||||
permission_type: "microphone",
|
||||
isGranted: isMicrophoneAccessGranted,
|
||||
description: getPermissionDescription("microphone"),
|
||||
},
|
||||
{
|
||||
permission_type: "camera",
|
||||
isGranted: isCameraAccessGranted,
|
||||
description: getPermissionDescription("camera"),
|
||||
},
|
||||
];
|
||||
|
||||
setPermissions(permissionList);
|
||||
} catch (error) {
|
||||
console.error("Failed to load permissions:", error);
|
||||
} finally {
|
||||
setIsLoadingPermissions(false);
|
||||
}
|
||||
}, [
|
||||
getPermissionDescription,
|
||||
isCameraAccessGranted,
|
||||
isMacOS,
|
||||
isMicrophoneAccessGranted,
|
||||
]);
|
||||
|
||||
const checkDefaultBrowserStatus = useCallback(async () => {
|
||||
try {
|
||||
const isDefault = await invoke<boolean>("is_default_browser");
|
||||
setIsDefaultBrowser(isDefault);
|
||||
} catch (error) {
|
||||
console.error("Failed to check default browser status:", error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSetDefaultBrowser = async () => {
|
||||
const handleSetDefaultBrowser = useCallback(async () => {
|
||||
setIsSettingDefault(true);
|
||||
try {
|
||||
await invoke("set_as_default_browser");
|
||||
@@ -109,25 +177,46 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
} finally {
|
||||
setIsSettingDefault(false);
|
||||
}
|
||||
};
|
||||
}, [checkDefaultBrowserStatus]);
|
||||
|
||||
const handleClearCache = async () => {
|
||||
const handleClearCache = useCallback(async () => {
|
||||
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",
|
||||
"All browser version cache has been cleared and browsers are being refreshed.",
|
||||
duration: 4000,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to clear cache:", error);
|
||||
showErrorToast("Failed to clear cache", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
duration: 4000,
|
||||
});
|
||||
} finally {
|
||||
setIsClearingCache(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
const handleRequestPermission = useCallback(
|
||||
async (permissionType: PermissionType) => {
|
||||
setRequestingPermission(permissionType);
|
||||
try {
|
||||
await requestPermission(permissionType);
|
||||
showSuccessToast(
|
||||
`${getPermissionDisplayName(permissionType)} access requested`,
|
||||
);
|
||||
} catch (error) {
|
||||
console.error("Failed to request permission:", error);
|
||||
} finally {
|
||||
setRequestingPermission(null);
|
||||
}
|
||||
},
|
||||
[getPermissionDisplayName, requestPermission],
|
||||
);
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke("save_app_settings", { settings });
|
||||
@@ -139,20 +228,72 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
}, [onClose, setTheme, settings]);
|
||||
|
||||
const updateSetting = (key: keyof AppSettings, value: boolean | string) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
const updateSetting = useCallback(
|
||||
(key: keyof AppSettings, value: boolean | string) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSettings().catch(console.error);
|
||||
checkDefaultBrowserStatus().catch(console.error);
|
||||
|
||||
// Check if we're on macOS
|
||||
const userAgent = navigator.userAgent;
|
||||
const isMac = userAgent.includes("Mac");
|
||||
setIsMacOS(isMac);
|
||||
|
||||
if (isMac) {
|
||||
loadPermissions().catch(console.error);
|
||||
}
|
||||
|
||||
// Set up interval to check default browser status
|
||||
const intervalId = setInterval(() => {
|
||||
checkDefaultBrowserStatus().catch(console.error);
|
||||
}, 500); // Check every 500ms
|
||||
|
||||
// Cleanup interval on component unmount or dialog close
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}
|
||||
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
|
||||
|
||||
// Update permissions when the permission states change
|
||||
useEffect(() => {
|
||||
if (isMacOS) {
|
||||
const permissionList: PermissionInfo[] = [
|
||||
{
|
||||
permission_type: "microphone",
|
||||
isGranted: isMicrophoneAccessGranted,
|
||||
description: getPermissionDescription("microphone"),
|
||||
},
|
||||
{
|
||||
permission_type: "camera",
|
||||
isGranted: isCameraAccessGranted,
|
||||
description: getPermissionDescription("camera"),
|
||||
},
|
||||
];
|
||||
setPermissions(permissionList);
|
||||
} else {
|
||||
setPermissions([]);
|
||||
}
|
||||
}, [
|
||||
isMacOS,
|
||||
isMicrophoneAccessGranted,
|
||||
isCameraAccessGranted,
|
||||
getPermissionDescription,
|
||||
]);
|
||||
|
||||
// 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 ||
|
||||
settings.auto_updates_enabled !== originalSettings.auto_updates_enabled ||
|
||||
settings.auto_delete_unused_binaries !==
|
||||
originalSettings.auto_delete_unused_binaries;
|
||||
settings.theme !== originalSettings.theme;
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
@@ -204,7 +345,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
<LoadingButton
|
||||
isLoading={isSettingDefault}
|
||||
onClick={() => {
|
||||
void handleSetDefaultBrowser();
|
||||
handleSetDefaultBrowser().catch(console.error);
|
||||
}}
|
||||
disabled={isDefaultBrowser}
|
||||
variant={isDefaultBrowser ? "outline" : "default"}
|
||||
@@ -221,46 +362,6 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Auto-Update Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Auto-Updates</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="auto-updates"
|
||||
checked={settings.auto_updates_enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
updateSetting("auto_updates_enabled", checked as boolean);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="auto-updates" className="text-sm">
|
||||
Enable automatic browser updates
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="auto-delete-binaries"
|
||||
checked={settings.auto_delete_unused_binaries}
|
||||
onCheckedChange={(checked) => {
|
||||
updateSetting(
|
||||
"auto_delete_unused_binaries",
|
||||
checked as boolean,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Label htmlFor="auto-delete-binaries" className="text-sm">
|
||||
Automatically delete unused browser binaries
|
||||
</Label>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
When enabled, Donut Browser will check for browser updates and
|
||||
notify you when updates are available for your profiles. Unused
|
||||
binaries will be automatically deleted to save disk space.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Startup Behavior Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Startup Behavior</Label>
|
||||
@@ -284,6 +385,69 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Permissions Section - Only show on macOS */}
|
||||
{isMacOS && (
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">
|
||||
System Permissions
|
||||
</Label>
|
||||
|
||||
{isLoadingPermissions ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading permissions...
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{permissions.map((permission) => (
|
||||
<div
|
||||
key={permission.permission_type}
|
||||
className="flex justify-between items-center p-3 rounded-lg border"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
{getPermissionIcon(permission.permission_type)}
|
||||
<div>
|
||||
<div className="text-sm font-medium">
|
||||
{getPermissionDisplayName(
|
||||
permission.permission_type,
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{permission.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusBadge(permission.isGranted)}
|
||||
{!permission.isGranted && (
|
||||
<LoadingButton
|
||||
size="sm"
|
||||
isLoading={
|
||||
requestingPermission ===
|
||||
permission.permission_type
|
||||
}
|
||||
onClick={() => {
|
||||
handleRequestPermission(
|
||||
permission.permission_type,
|
||||
).catch(console.error);
|
||||
}}
|
||||
>
|
||||
Grant
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
These permissions allow browsers launched from Donut Browser to
|
||||
access system resources. Each website will still ask for your
|
||||
permission individually.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Advanced</Label>
|
||||
@@ -291,7 +455,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
<LoadingButton
|
||||
isLoading={isClearingCache}
|
||||
onClick={() => {
|
||||
void handleClearCache();
|
||||
handleClearCache().catch(console.error);
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
@@ -314,7 +478,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
<LoadingButton
|
||||
isLoading={isSaving}
|
||||
onClick={() => {
|
||||
void handleSave();
|
||||
handleSave().catch(console.error);
|
||||
}}
|
||||
disabled={isLoading || !hasChanges}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { CamoufoxConfig } from "@/types";
|
||||
|
||||
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: "America/Chicago", label: "America/Chicago" },
|
||||
{ value: "America/Denver", label: "America/Denver" },
|
||||
{ value: "America/Phoenix", label: "America/Phoenix" },
|
||||
{ value: "America/Toronto", label: "America/Toronto" },
|
||||
{ value: "America/Vancouver", label: "America/Vancouver" },
|
||||
{ value: "Europe/London", label: "Europe/London" },
|
||||
{ value: "Europe/Paris", label: "Europe/Paris" },
|
||||
{ value: "Europe/Berlin", label: "Europe/Berlin" },
|
||||
{ value: "Europe/Rome", label: "Europe/Rome" },
|
||||
{ value: "Europe/Madrid", label: "Europe/Madrid" },
|
||||
{ value: "Europe/Amsterdam", label: "Europe/Amsterdam" },
|
||||
{ value: "Europe/Zurich", label: "Europe/Zurich" },
|
||||
{ value: "Europe/Vienna", label: "Europe/Vienna" },
|
||||
{ value: "Europe/Warsaw", label: "Europe/Warsaw" },
|
||||
{ value: "Europe/Prague", label: "Europe/Prague" },
|
||||
{ value: "Europe/Stockholm", label: "Europe/Stockholm" },
|
||||
{ value: "Europe/Copenhagen", label: "Europe/Copenhagen" },
|
||||
{ value: "Europe/Helsinki", label: "Europe/Helsinki" },
|
||||
{ value: "Europe/Oslo", label: "Europe/Oslo" },
|
||||
{ value: "Europe/Brussels", label: "Europe/Brussels" },
|
||||
{ value: "Europe/Dublin", label: "Europe/Dublin" },
|
||||
{ value: "Europe/Lisbon", label: "Europe/Lisbon" },
|
||||
{ value: "Europe/Athens", label: "Europe/Athens" },
|
||||
{ value: "Europe/Budapest", label: "Europe/Budapest" },
|
||||
{ value: "Europe/Bucharest", label: "Europe/Bucharest" },
|
||||
{ value: "Europe/Sofia", label: "Europe/Sofia" },
|
||||
{ value: "Europe/Kiev", label: "Europe/Kiev" },
|
||||
{ value: "Europe/Moscow", label: "Europe/Moscow" },
|
||||
{ value: "Asia/Tokyo", label: "Asia/Tokyo" },
|
||||
{ value: "Asia/Seoul", label: "Asia/Seoul" },
|
||||
{ value: "Asia/Shanghai", label: "Asia/Shanghai" },
|
||||
{ value: "Asia/Hong_Kong", label: "Asia/Hong_Kong" },
|
||||
{ value: "Asia/Singapore", label: "Asia/Singapore" },
|
||||
{ value: "Asia/Bangkok", label: "Asia/Bangkok" },
|
||||
{ value: "Asia/Jakarta", label: "Asia/Jakarta" },
|
||||
{ value: "Asia/Manila", label: "Asia/Manila" },
|
||||
{ value: "Asia/Kolkata", label: "Asia/Kolkata" },
|
||||
{ value: "Asia/Dubai", label: "Asia/Dubai" },
|
||||
{ value: "Asia/Riyadh", label: "Asia/Riyadh" },
|
||||
{ value: "Asia/Tehran", label: "Asia/Tehran" },
|
||||
{ value: "Asia/Jerusalem", label: "Asia/Jerusalem" },
|
||||
{ value: "Asia/Istanbul", label: "Asia/Istanbul" },
|
||||
{ value: "Australia/Sydney", label: "Australia/Sydney" },
|
||||
{ value: "Australia/Melbourne", label: "Australia/Melbourne" },
|
||||
{ value: "Australia/Brisbane", label: "Australia/Brisbane" },
|
||||
{ value: "Australia/Perth", label: "Australia/Perth" },
|
||||
{ value: "Australia/Adelaide", label: "Australia/Adelaide" },
|
||||
{ value: "Pacific/Auckland", label: "Pacific/Auckland" },
|
||||
{ value: "Pacific/Honolulu", label: "Pacific/Honolulu" },
|
||||
{ value: "Africa/Cairo", label: "Africa/Cairo" },
|
||||
{ value: "Africa/Johannesburg", label: "Africa/Johannesburg" },
|
||||
{ value: "Africa/Lagos", label: "Africa/Lagos" },
|
||||
{ value: "Africa/Nairobi", label: "Africa/Nairobi" },
|
||||
{ value: "America/Sao_Paulo", label: "America/Sao_Paulo" },
|
||||
{ value: "America/Buenos_Aires", label: "America/Buenos_Aires" },
|
||||
{ value: "America/Lima", label: "America/Lima" },
|
||||
{ value: "America/Bogota", label: "America/Bogota" },
|
||||
{ value: "America/Santiago", label: "America/Santiago" },
|
||||
{ value: "America/Caracas", label: "America/Caracas" },
|
||||
{ value: "America/Mexico_City", label: "America/Mexico_City" },
|
||||
];
|
||||
|
||||
const localeOptions = [
|
||||
{ value: "en-US", label: "English (US)" },
|
||||
{ value: "en-GB", label: "English (UK)" },
|
||||
{ value: "en-CA", label: "English (Canada)" },
|
||||
{ value: "en-AU", label: "English (Australia)" },
|
||||
{ value: "fr-FR", label: "French (France)" },
|
||||
{ value: "fr-CA", label: "French (Canada)" },
|
||||
{ value: "de-DE", label: "German (Germany)" },
|
||||
{ value: "de-AT", label: "German (Austria)" },
|
||||
{ value: "de-CH", label: "German (Switzerland)" },
|
||||
{ value: "es-ES", label: "Spanish (Spain)" },
|
||||
{ value: "es-MX", label: "Spanish (Mexico)" },
|
||||
{ value: "es-AR", label: "Spanish (Argentina)" },
|
||||
{ value: "it-IT", label: "Italian (Italy)" },
|
||||
{ value: "it-CH", label: "Italian (Switzerland)" },
|
||||
{ value: "pt-BR", label: "Portuguese (Brazil)" },
|
||||
{ value: "pt-PT", label: "Portuguese (Portugal)" },
|
||||
{ value: "ru-RU", label: "Russian (Russia)" },
|
||||
{ value: "zh-CN", label: "Chinese (Simplified)" },
|
||||
{ value: "zh-TW", label: "Chinese (Traditional)" },
|
||||
{ value: "ja-JP", label: "Japanese (Japan)" },
|
||||
{ value: "ko-KR", label: "Korean (Korea)" },
|
||||
{ value: "ar-SA", label: "Arabic (Saudi Arabia)" },
|
||||
{ value: "ar-EG", label: "Arabic (Egypt)" },
|
||||
{ value: "hi-IN", label: "Hindi (India)" },
|
||||
{ value: "tr-TR", label: "Turkish (Turkey)" },
|
||||
{ value: "pl-PL", label: "Polish (Poland)" },
|
||||
{ value: "nl-NL", label: "Dutch (Netherlands)" },
|
||||
{ value: "nl-BE", label: "Dutch (Belgium)" },
|
||||
{ value: "sv-SE", label: "Swedish (Sweden)" },
|
||||
{ value: "da-DK", label: "Danish (Denmark)" },
|
||||
{ value: "no-NO", label: "Norwegian (Norway)" },
|
||||
{ value: "fi-FI", label: "Finnish (Finland)" },
|
||||
{ value: "he-IL", label: "Hebrew (Israel)" },
|
||||
{ value: "th-TH", label: "Thai (Thailand)" },
|
||||
{ value: "vi-VN", label: "Vietnamese (Vietnam)" },
|
||||
{ value: "id-ID", label: "Indonesian (Indonesia)" },
|
||||
{ value: "ms-MY", label: "Malay (Malaysia)" },
|
||||
{ value: "uk-UA", label: "Ukrainian (Ukraine)" },
|
||||
{ value: "cs-CZ", label: "Czech (Czech Republic)" },
|
||||
{ value: "sk-SK", label: "Slovak (Slovakia)" },
|
||||
{ value: "hu-HU", label: "Hungarian (Hungary)" },
|
||||
{ value: "ro-RO", label: "Romanian (Romania)" },
|
||||
{ value: "bg-BG", label: "Bulgarian (Bulgaria)" },
|
||||
{ value: "hr-HR", label: "Croatian (Croatia)" },
|
||||
{ value: "sr-RS", label: "Serbian (Serbia)" },
|
||||
{ value: "sl-SI", label: "Slovenian (Slovenia)" },
|
||||
{ value: "lt-LT", label: "Lithuanian (Lithuania)" },
|
||||
{ value: "lv-LV", label: "Latvian (Latvia)" },
|
||||
{ value: "et-EE", label: "Estonian (Estonia)" },
|
||||
{ value: "el-GR", label: "Greek (Greece)" },
|
||||
{ value: "ca-ES", label: "Catalan (Spain)" },
|
||||
{ value: "eu-ES", label: "Basque (Spain)" },
|
||||
{ value: "gl-ES", label: "Galician (Spain)" },
|
||||
{ value: "is-IS", label: "Icelandic (Iceland)" },
|
||||
{ value: "mt-MT", label: "Maltese (Malta)" },
|
||||
];
|
||||
|
||||
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";
|
||||
};
|
||||
|
||||
interface SystemLocale {
|
||||
locale: string;
|
||||
language: string;
|
||||
country: string;
|
||||
}
|
||||
|
||||
interface SystemTimezone {
|
||||
timezone: string;
|
||||
offset: string;
|
||||
}
|
||||
|
||||
interface SharedCamoufoxConfigFormProps {
|
||||
config: CamoufoxConfig;
|
||||
onConfigChange: (key: keyof CamoufoxConfig, value: unknown) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function SharedCamoufoxConfigForm({
|
||||
config,
|
||||
onConfigChange,
|
||||
className = "",
|
||||
}: SharedCamoufoxConfigFormProps) {
|
||||
const [systemLocale, setSystemLocale] = useState<SystemLocale | null>(null);
|
||||
const [systemTimezone, setSystemTimezone] = useState<SystemTimezone | null>(
|
||||
null,
|
||||
);
|
||||
const [isLoadingSystemDefaults, setIsLoadingSystemDefaults] = useState(true);
|
||||
|
||||
// Load system defaults on component mount
|
||||
useEffect(() => {
|
||||
const loadSystemDefaults = async () => {
|
||||
try {
|
||||
const [locale, timezone] = await Promise.all([
|
||||
invoke<SystemLocale>("get_system_locale"),
|
||||
invoke<SystemTimezone>("get_system_timezone"),
|
||||
]);
|
||||
setSystemLocale(locale);
|
||||
setSystemTimezone(timezone);
|
||||
} catch (error) {
|
||||
console.error("Failed to load system defaults:", error);
|
||||
// Set fallback defaults
|
||||
setSystemLocale({
|
||||
locale: "en-US",
|
||||
language: "en",
|
||||
country: "US",
|
||||
});
|
||||
setSystemTimezone({
|
||||
timezone: "America/New_York",
|
||||
offset: "-05:00",
|
||||
});
|
||||
} finally {
|
||||
setIsLoadingSystemDefaults(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadSystemDefaults();
|
||||
}, []);
|
||||
|
||||
// Get the selected OS for warning
|
||||
const selectedOS = config.os?.[0];
|
||||
const currentOS = getCurrentOS();
|
||||
const showOSWarning =
|
||||
selectedOS && selectedOS !== currentOS && currentOS !== "unknown";
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
{/* OS Selection */}
|
||||
<div className="space-y-3">
|
||||
<Label>Operating System</Label>
|
||||
<Select
|
||||
value={config.os?.[0] || getCurrentOS()}
|
||||
onValueChange={(value) => onConfigChange("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 && (
|
||||
<p className="text-sm text-yellow-600 dark:text-yellow-400">
|
||||
⚠️ Selected OS ({selectedOS}) differs from your current OS (
|
||||
{currentOS}). This may affect fingerprinting effectiveness.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Privacy & Blocking */}
|
||||
<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) =>
|
||||
onConfigChange("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) =>
|
||||
onConfigChange("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) =>
|
||||
onConfigChange("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) =>
|
||||
onConfigChange("country", e.target.value || undefined)
|
||||
}
|
||||
placeholder={
|
||||
systemLocale
|
||||
? `e.g., ${systemLocale.country}`
|
||||
: "e.g., US, GB, DE"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Timezone</Label>
|
||||
<Select
|
||||
value={config.timezone || "auto"}
|
||||
onValueChange={(value) =>
|
||||
onConfigChange("timezone", value === "auto" ? undefined : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingSystemDefaults ? "Loading..." : "Select timezone"
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
{isLoadingSystemDefaults
|
||||
? "Auto (loading...)"
|
||||
: `Auto (${systemTimezone?.timezone || "UTC"})`}
|
||||
</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) =>
|
||||
onConfigChange(
|
||||
"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) =>
|
||||
onConfigChange(
|
||||
"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) =>
|
||||
onConfigChange("locale", value ? [value] : undefined)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue
|
||||
placeholder={
|
||||
isLoadingSystemDefaults
|
||||
? "Loading..."
|
||||
: `Select locale (system: ${systemLocale?.locale || "unknown"})`
|
||||
}
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{!isLoadingSystemDefaults && systemLocale && (
|
||||
<SelectItem value={systemLocale.locale}>
|
||||
{systemLocale.locale} (System Default)
|
||||
</SelectItem>
|
||||
)}
|
||||
{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) =>
|
||||
onConfigChange(
|
||||
"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) =>
|
||||
onConfigChange(
|
||||
"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) =>
|
||||
onConfigChange(
|
||||
"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) =>
|
||||
onConfigChange(
|
||||
"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) =>
|
||||
onConfigChange(
|
||||
"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) =>
|
||||
onConfigChange(
|
||||
"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) =>
|
||||
onConfigChange("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) =>
|
||||
onConfigChange("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) =>
|
||||
onConfigChange("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) =>
|
||||
onConfigChange("webgl_renderer", e.target.value || undefined)
|
||||
}
|
||||
placeholder="e.g., Intel HD Graphics"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -103,7 +103,6 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
const currentSystemTheme = await getNativeSystemTheme();
|
||||
// Force re-evaluation by toggling the theme
|
||||
const html = document.documentElement;
|
||||
const currentClass = html.className;
|
||||
|
||||
// Apply the system theme class
|
||||
if (currentSystemTheme === "dark") {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import { type VariantProps, cva } from "class-variance-authority";
|
||||
import { cva, type VariantProps } from "class-variance-authority";
|
||||
import type * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -19,6 +19,85 @@ import {
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ComboboxOption {
|
||||
value: string;
|
||||
label: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface ComboboxProps {
|
||||
options: ComboboxOption[];
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
placeholder?: string;
|
||||
searchPlaceholder?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Combobox({
|
||||
options,
|
||||
value,
|
||||
onValueChange,
|
||||
placeholder = "Select option...",
|
||||
searchPlaceholder = "Search...",
|
||||
className,
|
||||
}: ComboboxProps) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={open}
|
||||
className={cn("w-full justify-between", className)}
|
||||
>
|
||||
{value
|
||||
? options.find((option) => option.value === value)?.label
|
||||
: placeholder}
|
||||
<LuChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-full p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder={searchPlaceholder} />
|
||||
<CommandList>
|
||||
<CommandEmpty>No option found.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((option) => (
|
||||
<CommandItem
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
onSelect={(currentValue) => {
|
||||
onValueChange(currentValue === value ? "" : currentValue);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
value === option.value ? "opacity-100" : "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex flex-col">
|
||||
<span>{option.label}</span>
|
||||
{option.description && (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{option.description}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
const frameworks = [
|
||||
{
|
||||
value: "next.js",
|
||||
|
||||
@@ -5,6 +5,7 @@ import type * as React from "react";
|
||||
import { RxCross2 } from "react-icons/rx";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { WindowDragArea } from "../window-drag-area";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
@@ -38,11 +39,13 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[9999] bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
<WindowDragArea />
|
||||
</DialogPrimitive.Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -57,7 +60,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[10000] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -102,7 +105,7 @@ function DialogTitle({
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
className={cn("text-lg font-semibold leading-none", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
@@ -115,7 +118,7 @@ function DialogDescription({
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
className={cn("text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -42,7 +42,7 @@ function DropdownMenuContent({
|
||||
data-slot="dropdown-menu-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -230,7 +230,7 @@ function DropdownMenuSubContent({
|
||||
<DropdownMenuPrimitive.SubContent
|
||||
data-slot="dropdown-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -30,7 +30,7 @@ function PopoverContent({
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -61,7 +61,7 @@ function SelectContent({
|
||||
<SelectPrimitive.Content
|
||||
data-slot="select-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-[50000] max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
||||
position === "popper" &&
|
||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||
className,
|
||||
|
||||
@@ -8,11 +8,11 @@ function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="relative w-full overflow-x-auto"
|
||||
className="overflow-x-auto relative w-full"
|
||||
>
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
className={cn("w-full text-sm caption-bottom", className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@ function TableCaption({
|
||||
return (
|
||||
<caption
|
||||
data-slot="table-caption"
|
||||
className={cn("text-muted-foreground mt-4 text-sm", className)}
|
||||
className={cn("mt-4 text-sm text-muted-foreground", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
"use client";
|
||||
|
||||
import * as TabsPrimitive from "@radix-ui/react-tabs";
|
||||
import * as React from "react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Tabs = TabsPrimitive.Root;
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = TabsPrimitive.List.displayName;
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName;
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -46,13 +46,13 @@ function TooltipContent({
|
||||
data-slot="tooltip-content"
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-[50000] size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
|
||||
</TooltipPrimitive.Content>
|
||||
</TooltipPrimitive.Portal>
|
||||
);
|
||||
|
||||
@@ -1,108 +0,0 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable @typescript-eslint/no-misused-promises */
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import React from "react";
|
||||
import { FaDownload, FaTimes } from "react-icons/fa";
|
||||
import { LuDownload } from "react-icons/lu";
|
||||
|
||||
interface UpdateNotification {
|
||||
id: string;
|
||||
browser: string;
|
||||
current_version: string;
|
||||
new_version: string;
|
||||
affected_profiles: string[];
|
||||
is_stable_update: boolean;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
interface UpdateNotificationProps {
|
||||
notification: UpdateNotification;
|
||||
onUpdate: (browser: string, newVersion: string) => Promise<void>;
|
||||
onDismiss: (notificationId: string) => Promise<void>;
|
||||
isUpdating?: boolean;
|
||||
}
|
||||
|
||||
export function UpdateNotificationComponent({
|
||||
notification,
|
||||
onUpdate,
|
||||
onDismiss,
|
||||
isUpdating = false,
|
||||
}: UpdateNotificationProps) {
|
||||
const browserDisplayName = getBrowserDisplayName(notification.browser);
|
||||
|
||||
const profileText =
|
||||
notification.affected_profiles.length === 1
|
||||
? `profile "${notification.affected_profiles[0]}"`
|
||||
: `${notification.affected_profiles.length} profiles`;
|
||||
|
||||
const handleUpdateClick = async () => {
|
||||
// Dismiss the notification immediately to close the modal
|
||||
await onDismiss(notification.id);
|
||||
// Then start the update process
|
||||
await onUpdate(notification.browser, notification.new_version);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 p-4 max-w-md rounded-lg border shadow-lg bg-background border-border">
|
||||
<div className="flex gap-2 justify-between items-start">
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex gap-2 items-center">
|
||||
<span className="font-semibold text-foreground">
|
||||
{browserDisplayName} Update Available
|
||||
</span>
|
||||
<Badge
|
||||
variant={notification.is_stable_update ? "default" : "secondary"}
|
||||
>
|
||||
{notification.is_stable_update ? "Stable" : "Nightly"}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Update {profileText} from {notification.current_version} to{" "}
|
||||
<span className="font-medium">{notification.new_version}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
await onDismiss(notification.id);
|
||||
}}
|
||||
className="p-0 w-6 h-6 shrink-0"
|
||||
>
|
||||
<FaTimes className="w-3 h-3" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
onClick={handleUpdateClick}
|
||||
disabled={isUpdating}
|
||||
size="sm"
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<FaDownload className="w-3 h-3" />
|
||||
Update
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={async () => {
|
||||
await onDismiss(notification.id);
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
Later
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{notification.affected_profiles.length > 1 && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Affected profiles: {notification.affected_profiles.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useState } from "react";
|
||||
import { LuDownload } from "react-icons/lu";
|
||||
import { LuCheck, LuChevronsUpDown } from "react-icons/lu";
|
||||
import { ScrollArea } from "./ui/scroll-area";
|
||||
|
||||
interface GithubRelease {
|
||||
tag_name: string;
|
||||
assets: Array<{
|
||||
name: string;
|
||||
browser_download_url: string;
|
||||
hash?: string;
|
||||
}>;
|
||||
published_at: string;
|
||||
is_nightly: boolean;
|
||||
}
|
||||
|
||||
interface VersionSelectorProps {
|
||||
selectedVersion: string | null;
|
||||
onVersionSelect: (version: string | null) => void;
|
||||
availableVersions: GithubRelease[];
|
||||
downloadedVersions: string[];
|
||||
isDownloading: boolean;
|
||||
onDownload: () => void;
|
||||
placeholder?: string;
|
||||
showDownloadButton?: boolean;
|
||||
}
|
||||
|
||||
export function VersionSelector({
|
||||
selectedVersion,
|
||||
onVersionSelect,
|
||||
availableVersions,
|
||||
downloadedVersions,
|
||||
isDownloading,
|
||||
onDownload,
|
||||
placeholder = "Select version...",
|
||||
showDownloadButton = true,
|
||||
}: VersionSelectorProps) {
|
||||
const [versionPopoverOpen, setVersionPopoverOpen] = useState(false);
|
||||
|
||||
const isVersionDownloaded = selectedVersion
|
||||
? downloadedVersions.includes(selectedVersion)
|
||||
: false;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Popover
|
||||
open={versionPopoverOpen}
|
||||
onOpenChange={setVersionPopoverOpen}
|
||||
modal={true}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={versionPopoverOpen}
|
||||
className="justify-between w-full"
|
||||
>
|
||||
{selectedVersion ?? placeholder}
|
||||
<LuChevronsUpDown className="ml-2 w-4 h-4 opacity-50 shrink-0" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0">
|
||||
<Command>
|
||||
<CommandInput placeholder="Search versions..." />
|
||||
<CommandEmpty>No versions found.</CommandEmpty>
|
||||
<CommandList>
|
||||
<ScrollArea
|
||||
className={
|
||||
"[&>[data-radix-scroll-area-viewport]]:max-h-[200px]"
|
||||
}
|
||||
>
|
||||
<CommandGroup>
|
||||
{availableVersions.map((version) => {
|
||||
const isDownloaded = downloadedVersions.includes(
|
||||
version.tag_name,
|
||||
);
|
||||
return (
|
||||
<CommandItem
|
||||
key={version.tag_name}
|
||||
value={version.tag_name}
|
||||
onSelect={(currentValue) => {
|
||||
onVersionSelect(
|
||||
currentValue === selectedVersion
|
||||
? null
|
||||
: currentValue,
|
||||
);
|
||||
setVersionPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
<LuCheck
|
||||
className={cn(
|
||||
"mr-2 h-4 w-4",
|
||||
selectedVersion === version.tag_name
|
||||
? "opacity-100"
|
||||
: "opacity-0",
|
||||
)}
|
||||
/>
|
||||
<div className="flex gap-2 items-center">
|
||||
<span>{version.tag_name}</span>
|
||||
{version.is_nightly && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
Nightly
|
||||
</Badge>
|
||||
)}
|
||||
{isDownloaded && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Downloaded
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CommandItem>
|
||||
);
|
||||
})}
|
||||
</CommandGroup>
|
||||
</ScrollArea>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Download Button */}
|
||||
{showDownloadButton && selectedVersion && !isVersionDownloaded && (
|
||||
<LoadingButton
|
||||
isLoading={isDownloading}
|
||||
onClick={() => {
|
||||
onDownload();
|
||||
}}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
>
|
||||
<LuDownload className="mr-2 w-4 h-4" />
|
||||
{isDownloading ? "Downloading..." : "Download Browser"}
|
||||
</LoadingButton>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user