mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 17:27:54 +02:00
Compare commits
345 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5af751a9b2 | |||
| da7f791274 | |||
| f4c33ad96e | |||
| 5b31cfaf32 | |||
| 4997854577 | |||
| a43e41a020 | |||
| b22b4cacf9 | |||
| 7f0df6f943 | |||
| dccf843952 | |||
| fc6ddb7cbf | |||
| 63000c72bd | |||
| 2fd344b9bb | |||
| 44bd34d8f0 | |||
| d3822bdd88 | |||
| ed1132bdc3 | |||
| fcae0623c0 | |||
| fe843e14f1 | |||
| b071e971b3 | |||
| 0b7cf547b3 | |||
| f024ce19ae | |||
| e1d3ff9000 | |||
| e2a168b188 | |||
| af767da32c | |||
| b5dfe1233e | |||
| dddf8e2e39 | |||
| adcb20fab9 | |||
| ff9c633b07 | |||
| 7ca76b1f78 | |||
| 4887a3db4d | |||
| e38cd2e560 | |||
| 7e7b47cae3 | |||
| 13ae170166 | |||
| df78e22650 | |||
| 328e6f16ee | |||
| 40ad32af6d | |||
| f299eeaea5 | |||
| 84142caac9 | |||
| d06dbb6c70 | |||
| cf5b498bd6 | |||
| 3c28a169bd | |||
| 25653e166b | |||
| 0b4263140d | |||
| b500c28b96 | |||
| 7c2be81531 | |||
| b55ef469ed | |||
| 76a206093d | |||
| 3e88dbc30e | |||
| 031823587e | |||
| c7a1ac228c | |||
| 8ede335bed | |||
| b170b8846d | |||
| 632d90a022 | |||
| 3bec00a2cd | |||
| 3b78971df8 | |||
| 5f9a716f62 | |||
| 4d07984d99 | |||
| 188e14e5b5 | |||
| bc1b9e9757 | |||
| e742e5fdfa | |||
| 9ce7757cb2 | |||
| 3ca454a2c5 | |||
| 689ac8e3ca | |||
| 0e1c5dcfb6 | |||
| 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 | |||
| c46f54536b | |||
| 6cbc8627a1 | |||
| a4f4cc2f27 | |||
| 21c4d0a8ab | |||
| 9335149153 | |||
| 6711659231 | |||
| 8a592e3d7d | |||
| beea23307b | |||
| 19b66d006d | |||
| c7a36f6cd0 | |||
| 7404cb3ff8 | |||
| ee91445fe1 | |||
| 77d53c7f32 | |||
| a21f22a916 | |||
| 4aaf2eecbc | |||
| f750e64b81 | |||
| 16fd3e3c5e | |||
| 93eae1d77f | |||
| 82615c24bd | |||
| 0769106a51 | |||
| 18aa3cb87b | |||
| f066105c0f | |||
| 9d8b3629f6 | |||
| 353e149886 | |||
| 2258edbb18 | |||
| 69fff99bbe | |||
| a277b7d8a2 | |||
| 7af3f7e86a | |||
| de9c47241a | |||
| 5747729e30 | |||
| f71bda0fbf | |||
| 67bfb17e5a | |||
| bb2eb65c1e | |||
| f397568785 | |||
| 0da34f04cb | |||
| 6836d73ffa | |||
| 63b890d47f | |||
| aeb6a08fc8 | |||
| c698fff101 | |||
| ccfd1f81f6 | |||
| 4c42099661 | |||
| 4c4aa10d8c | |||
| a1a6ef63e4 | |||
| bd7b9f1d9f | |||
| f93b5daa9b | |||
| f7f45bdc90 | |||
| 48067ee3a7 | |||
| 87bd75aa21 | |||
| cf443061b6 | |||
| ca662d91a1 | |||
| 2963dbc0f9 | |||
| 225ed05d08 | |||
| 97de246ac6 | |||
| b00f62ebec | |||
| 2025a2a690 | |||
| 2f1faa02e4 | |||
| 7a5b807828 | |||
| d0a5c16ce9 | |||
| e2e1ad1582 | |||
| cb61861503 | |||
| 1950ef0098 | |||
| 814875c28e | |||
| b06ca4f11e | |||
| 3ab1ea61e8 | |||
| a0599ecfc1 | |||
| 6c834b3003 | |||
| 269b4dbe77 | |||
| ef00854307 | |||
| 03d915e5c7 | |||
| 91b12e80e5 | |||
| 3af581c4ab | |||
| 7a85edfb8a | |||
| 141a5f06a4 | |||
| 7a3857c06a | |||
| ed26786fdb | |||
| 966268ff05 | |||
| 87ae696d7a | |||
| 7e92b290b6 |
@@ -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
|
||||
---
|
||||
Anytime you change nodecar's code and try to test, recompile it with "cd nodecar && pnpm build".
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
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.
|
||||
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless I have explicitly specified in the request otherwise.
|
||||
@@ -1,14 +1,14 @@
|
||||
# ✨ Pull Request
|
||||
|
||||
### 📓 Referenced Issue
|
||||
## 📓 Referenced Issue
|
||||
|
||||
<!-- Please link the related issue. Use # before the issue number and use the verbs 'fixes', 'resolves' to auto-link it, for eg, Fixes: #<issue-number> -->
|
||||
|
||||
### ℹ️ About the PR
|
||||
## ℹ️ About the PR
|
||||
|
||||
<!-- Please provide a description of your solution if it is not clear in the related issue or if the PR has a breaking change. If there is an interesting topic to discuss or you have questions or there is an issue with Tauri, Rust, or another library that you have used. -->
|
||||
|
||||
### 🔄 Type of Change
|
||||
## 🔄 Type of Change
|
||||
|
||||
<!-- Mark the relevant option with an "x". -->
|
||||
|
||||
@@ -19,11 +19,11 @@
|
||||
- [ ] 🧹 Code cleanup/refactoring
|
||||
- [ ] ⚡ Performance improvement
|
||||
|
||||
### 🖼️ Testing Scenarios / Screenshots
|
||||
## 🖼️ Testing Scenarios / Screenshots
|
||||
|
||||
<!-- Please include screenshots or gif to showcase the final output. Also, try to explain the testing you did to validate your change. -->
|
||||
|
||||
### ✅ Checklist
|
||||
## ✅ Checklist
|
||||
|
||||
<!-- Mark completed items with an "x". -->
|
||||
|
||||
@@ -36,11 +36,11 @@
|
||||
- [ ] New and existing unit tests pass locally with my changes
|
||||
- [ ] Any dependent changes have been merged and published
|
||||
|
||||
### 🧪 How Has This Been Tested?
|
||||
## 🧪 How Has This Been Tested?
|
||||
|
||||
<!-- Please describe the tests that you ran to verify your changes. -->
|
||||
|
||||
### 📱 Platform Testing
|
||||
## 📱 Platform Testing
|
||||
|
||||
<!-- Which platforms have you tested on? -->
|
||||
|
||||
@@ -49,6 +49,6 @@
|
||||
- [ ] Windows (if applicable)
|
||||
- [ ] Linux (if applicable)
|
||||
|
||||
### 📋 Additional Notes
|
||||
## 📋 Additional Notes
|
||||
|
||||
<!-- Any additional information that reviewers should know about this PR. -->
|
||||
|
||||
+30
-7
@@ -1,28 +1,51 @@
|
||||
version: 2
|
||||
|
||||
updates:
|
||||
# Enable version updates for Node.js dependencies
|
||||
# Frontend dependencies (root package.json)
|
||||
- package-ecosystem: "npm"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "saturday"
|
||||
time: "09:00"
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
groups:
|
||||
all:
|
||||
frontend-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
ignore:
|
||||
- dependency-name: "eslint"
|
||||
versions: ">= 9"
|
||||
commit-message:
|
||||
prefix: "deps"
|
||||
include: "scope"
|
||||
|
||||
# Enable version updates for rust
|
||||
# Rust dependencies
|
||||
- package-ecosystem: "cargo"
|
||||
directory: "/src-tauri"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "saturday"
|
||||
time: "09:00"
|
||||
allow:
|
||||
- dependency-type: "all"
|
||||
groups:
|
||||
all:
|
||||
rust-dependencies:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "deps(rust)"
|
||||
include: "scope"
|
||||
|
||||
# GitHub Actions
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "saturday"
|
||||
time: "09:00"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
include: "scope"
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
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 banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- 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/nodecar-bin 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 }}
|
||||
@@ -1,34 +1,84 @@
|
||||
# Automatically squashes and merges Dependabot dependency upgrades if tests pass
|
||||
name: Dependabot Automerge
|
||||
|
||||
name: Dependabot Auto-merge
|
||||
|
||||
on: pull_request_target
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: write
|
||||
checks: read
|
||||
|
||||
jobs:
|
||||
dependabot:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
--skip-git
|
||||
--lockfile=pnpm-lock.yaml
|
||||
--lockfile=src-tauri/Cargo.lock
|
||||
--lockfile=nodecar/pnpm-lock.yaml
|
||||
./
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
lint-js:
|
||||
name: Lint JavaScript/TypeScript
|
||||
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, codeql, spellcheck]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Fetch Dependabot metadata
|
||||
id: dependabot-metadata
|
||||
uses: dependabot/fetch-metadata@v2
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b #v2.4.0
|
||||
secrets: inherit
|
||||
with:
|
||||
compat-lookup: true
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
|
||||
- name: Approve Dependabot PR
|
||||
run: gh pr review --approve "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Auto-merge (squash) Dependabot PR
|
||||
if: ${{ steps.dependabot-metadata.outputs.update-type != 'version-update:semver-major' }}
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{ github.event.pull_request.html_url }}
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Auto-merge minor and patch updates
|
||||
uses: ridedott/merge-me-action@338053c6f9b9311a6be80208f6f0723981e40627 #v2.10.122
|
||||
secrets: inherit
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.SECRET_DEPENDABOT_GITHUB_TOKEN }}
|
||||
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@2d4393e6bc0e2efb2e48fba7e06819c3bf61ffc9 #v2.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-message: "Thank you for your first issue ❤️ If it's a feature request, please make sure it's clear what you want, why you want it, and how important it is to you. If you posted a bug report, please make sure it includes as much detail as possible."
|
||||
pr-message: "Welcome to the community and thank you for your first contribution ❤️ A human will review your PR shortly. Make sure that the pipelines are green, so that the PR is considered ready for a review and could be merged."
|
||||
@@ -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@9693b137b6566bb66055a713613bf4f0493701eb # v1.2.3
|
||||
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
|
||||
@@ -13,6 +13,11 @@ on:
|
||||
paths-ignore:
|
||||
- "src-tauri/**"
|
||||
- "README.md"
|
||||
- ".github/workflows/lint-rs.yml"
|
||||
- ".github/workflows/osv.yml"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -29,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"
|
||||
@@ -43,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 lint
|
||||
run: pnpm run lint:js
|
||||
|
||||
@@ -12,11 +12,20 @@ on:
|
||||
pull_request:
|
||||
paths-ignore:
|
||||
- "src/**"
|
||||
- "nodecar/**"
|
||||
- "package.json"
|
||||
- "package-lock.json"
|
||||
- "yarn.lock"
|
||||
- "pnpm-lock.yaml"
|
||||
- "yarn.lock"
|
||||
- "README.md"
|
||||
- ".github/workflows/lint-js.yml"
|
||||
- ".github/workflows/osv.yml"
|
||||
- "next.config.js"
|
||||
- "tailwind.config.js"
|
||||
- "tsconfig.json"
|
||||
- "biome.json"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -33,22 +42,29 @@ 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
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Install banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install dependencies (Ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -58,11 +74,6 @@ jobs:
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install nodecar dependencies
|
||||
working-directory: ./nodecar
|
||||
run: |
|
||||
pnpm install --ignore-workspace --frozen-lockfile
|
||||
|
||||
- name: Build nodecar binary
|
||||
shell: bash
|
||||
working-directory: ./nodecar
|
||||
@@ -70,7 +81,7 @@ jobs:
|
||||
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
|
||||
pnpm run build:linux-x64
|
||||
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
|
||||
pnpm run build:aarch64
|
||||
pnpm run build:mac-aarch64
|
||||
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
|
||||
pnpm run build:win-x64
|
||||
fi
|
||||
@@ -80,11 +91,11 @@ jobs:
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-aarch64-apple-darwin
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-aarch64-apple-darwin
|
||||
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
|
||||
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-x86_64-pc-windows-msvc.exe
|
||||
cp nodecar/nodecar-bin.exe src-tauri/binaries/nodecar-x86_64-pc-windows-msvc.exe
|
||||
fi
|
||||
|
||||
- name: Create empty 'dist' directory
|
||||
@@ -98,6 +109,10 @@ jobs:
|
||||
run: cargo clippy --all-targets --all-features -- -D warnings -D clippy::all
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Run Rust unit tests
|
||||
- name: Run Rust tests
|
||||
run: cargo test
|
||||
working-directory: src-tauri
|
||||
|
||||
- name: Run cargo audit security check
|
||||
run: cargo audit
|
||||
working-directory: src-tauri
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# This workflow uses actions that are not certified by GitHub.
|
||||
# They are provided by a third-party and are governed by
|
||||
# separate terms of service, privacy policy, and support
|
||||
# documentation.
|
||||
|
||||
# A sample workflow which sets up periodic OSV-Scanner scanning for vulnerabilities,
|
||||
# in addition to a PR check which fails if new vulnerabilities are introduced.
|
||||
#
|
||||
# For more examples and options, including how to ignore specific vulnerabilities,
|
||||
# see https://google.github.io/osv-scanner/github-action/
|
||||
|
||||
# Security vulnerability scanning for Donut Browser
|
||||
# Scans dependencies in package managers (npm/pnpm, Cargo) for known vulnerabilities
|
||||
# Runs on schedule and when dependencies change
|
||||
|
||||
name: Security Vulnerability Scan
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "src-tauri/Cargo.toml"
|
||||
- "src-tauri/Cargo.lock"
|
||||
- "nodecar/package.json"
|
||||
- "nodecar/pnpm-lock.yaml"
|
||||
- ".github/workflows/osv.yml"
|
||||
merge_group:
|
||||
branches: ["main"]
|
||||
schedule:
|
||||
# Run weekly on Tuesdays at 2:20 PM UTC
|
||||
- cron: "20 14 * * 2"
|
||||
push:
|
||||
branches: ["main"]
|
||||
paths:
|
||||
- "package.json"
|
||||
- "pnpm-lock.yaml"
|
||||
- "src-tauri/Cargo.toml"
|
||||
- "src-tauri/Cargo.lock"
|
||||
- "nodecar/package.json"
|
||||
- "nodecar/pnpm-lock.yaml"
|
||||
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
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@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
--skip-git
|
||||
--lockfile=pnpm-lock.yaml
|
||||
--lockfile=src-tauri/Cargo.lock
|
||||
--lockfile=nodecar/pnpm-lock.yaml
|
||||
./
|
||||
|
||||
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@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
--skip-git
|
||||
--lockfile=pnpm-lock.yaml
|
||||
--lockfile=src-tauri/Cargo.lock
|
||||
--lockfile=nodecar/pnpm-lock.yaml
|
||||
./
|
||||
@@ -0,0 +1,54 @@
|
||||
name: Pull Request Checks
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["main"]
|
||||
merge_group:
|
||||
branches: ["main"]
|
||||
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
jobs:
|
||||
lint-js:
|
||||
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@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
--skip-git
|
||||
--lockfile=pnpm-lock.yaml
|
||||
--lockfile=nodecar/pnpm-lock.yaml
|
||||
--lockfile=src-tauri/Cargo.lock
|
||||
./
|
||||
|
||||
pr-status:
|
||||
name: PR Status Check
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint-js, lint-rust, security-scan]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check all jobs succeeded
|
||||
run: |
|
||||
if [[ "${{ needs.lint-js.result }}" != "success" || "${{ needs.lint-rust.result }}" != "success" || "${{ needs.security-scan.result }}" != "success" ]]; then
|
||||
echo "One or more checks failed"
|
||||
exit 1
|
||||
fi
|
||||
echo "All checks passed!"
|
||||
@@ -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@9693b137b6566bb66055a713613bf4f0493701eb # v1.2.3
|
||||
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
|
||||
@@ -11,18 +11,55 @@ env:
|
||||
STABLE_RELEASE: "true"
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
--skip-git
|
||||
--lockfile=pnpm-lock.yaml
|
||||
--lockfile=src-tauri/Cargo.lock
|
||||
--lockfile=nodecar/pnpm-lock.yaml
|
||||
./
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
lint-js:
|
||||
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: [lint-js, lint-rust]
|
||||
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -34,33 +71,32 @@ jobs:
|
||||
arch: "aarch64"
|
||||
target: "aarch64-apple-darwin"
|
||||
pkg_target: "latest-macos-arm64"
|
||||
nodecar_script: "build:aarch64"
|
||||
nodecar_script: "build:mac-aarch64"
|
||||
- platform: "macos-latest"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
arch: "x86_64"
|
||||
target: "x86_64-apple-darwin"
|
||||
pkg_target: "latest-macos-x64"
|
||||
nodecar_script: "build:x86_64"
|
||||
# Future platforms can be added here:
|
||||
# - platform: "ubuntu-20.04"
|
||||
# args: "--target x86_64-unknown-linux-gnu"
|
||||
# arch: "x86_64"
|
||||
# target: "x86_64-unknown-linux-gnu"
|
||||
# pkg_target: "latest-linux-x64"
|
||||
# nodecar_script: "build:linux-x64"
|
||||
# - platform: "ubuntu-20.04"
|
||||
# args: "--target aarch64-unknown-linux-gnu"
|
||||
# arch: "aarch64"
|
||||
# target: "aarch64-unknown-linux-gnu"
|
||||
# pkg_target: "latest-linux-arm64"
|
||||
# nodecar_script: "build:linux-arm64"
|
||||
nodecar_script: "build:mac-x86_64"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: "--target x86_64-unknown-linux-gnu"
|
||||
arch: "x86_64"
|
||||
target: "x86_64-unknown-linux-gnu"
|
||||
pkg_target: "latest-linux-x64"
|
||||
nodecar_script: "build:linux-x64"
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: "--target aarch64-unknown-linux-gnu"
|
||||
arch: "aarch64"
|
||||
target: "aarch64-unknown-linux-gnu"
|
||||
pkg_target: "latest-linux-arm64"
|
||||
nodecar_script: "build:linux-arm64"
|
||||
# - platform: "windows-latest"
|
||||
# args: "--target x86_64-pc-windows-msvc"
|
||||
# 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"
|
||||
@@ -69,40 +105,39 @@ 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)
|
||||
if: matrix.platform == 'ubuntu-20.04'
|
||||
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
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 banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install nodecar dependencies
|
||||
working-directory: ./nodecar
|
||||
run: |
|
||||
pnpm install --ignore-workspace --frozen-lockfile
|
||||
|
||||
- name: Build nodecar sidecar
|
||||
shell: bash
|
||||
working-directory: ./nodecar
|
||||
@@ -114,16 +149,20 @@ jobs:
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
|
||||
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}.exe
|
||||
else
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Download Camoufox for testing
|
||||
run: npx camoufox-js fetch
|
||||
continue-on-error: true
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- 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 }}
|
||||
@@ -134,3 +173,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
|
||||
|
||||
@@ -10,18 +10,55 @@ env:
|
||||
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
--skip-git
|
||||
--lockfile=pnpm-lock.yaml
|
||||
--lockfile=src-tauri/Cargo.lock
|
||||
--lockfile=nodecar/pnpm-lock.yaml
|
||||
./
|
||||
permissions:
|
||||
security-events: write
|
||||
contents: read
|
||||
actions: read
|
||||
|
||||
lint-js:
|
||||
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: [lint-js, lint-rust]
|
||||
needs: [security-scan, lint-js, lint-rust, codeql, spellcheck]
|
||||
permissions:
|
||||
contents: write
|
||||
strategy:
|
||||
@@ -33,44 +70,73 @@ jobs:
|
||||
arch: "aarch64"
|
||||
target: "aarch64-apple-darwin"
|
||||
pkg_target: "latest-macos-arm64"
|
||||
nodecar_script: "build:aarch64"
|
||||
nodecar_script: "build:mac-aarch64"
|
||||
- platform: "macos-latest"
|
||||
args: "--target x86_64-apple-darwin"
|
||||
arch: "x86_64"
|
||||
target: "x86_64-apple-darwin"
|
||||
pkg_target: "latest-macos-x64"
|
||||
nodecar_script: "build:x86_64"
|
||||
nodecar_script: "build:mac-x86_64"
|
||||
- platform: "ubuntu-22.04"
|
||||
args: "--target x86_64-unknown-linux-gnu"
|
||||
arch: "x86_64"
|
||||
target: "x86_64-unknown-linux-gnu"
|
||||
pkg_target: "latest-linux-x64"
|
||||
nodecar_script: "build:linux-x64"
|
||||
- platform: "ubuntu-22.04-arm"
|
||||
args: "--target aarch64-unknown-linux-gnu"
|
||||
arch: "aarch64"
|
||||
target: "aarch64-unknown-linux-gnu"
|
||||
pkg_target: "latest-linux-arm64"
|
||||
nodecar_script: "build:linux-arm64"
|
||||
- platform: "windows-latest"
|
||||
args: "--target x86_64-pc-windows-msvc"
|
||||
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)
|
||||
if: matrix.platform == 'ubuntu-22.04' || matrix.platform == 'ubuntu-22.04-arm'
|
||||
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@v2
|
||||
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
- name: Install banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Install nodecar dependencies
|
||||
working-directory: ./nodecar
|
||||
run: |
|
||||
pnpm install --ignore-workspace --frozen-lockfile
|
||||
|
||||
- name: Build nodecar sidecar
|
||||
shell: bash
|
||||
working-directory: ./nodecar
|
||||
@@ -81,26 +147,39 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}.exe
|
||||
else
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Download Camoufox for testing
|
||||
run: npx camoufox-js fetch
|
||||
continue-on-error: true
|
||||
|
||||
- name: Build frontend
|
||||
run: pnpm build
|
||||
|
||||
- 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"
|
||||
+10
-3
@@ -5,6 +5,10 @@
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# npm/yarn lock files (project uses pnpm only)
|
||||
**/package-lock.json
|
||||
**/yarn.lock
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
@@ -30,8 +34,8 @@ yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# nodecar
|
||||
nodecar/dist
|
||||
nodecar/node_modules
|
||||
**/dist
|
||||
**/node_modules
|
||||
|
||||
# local env files
|
||||
.env*.local
|
||||
@@ -42,4 +46,7 @@ nodecar/node_modules
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
!**/.gitkeep
|
||||
!**/.gitkeep
|
||||
|
||||
# nodecar
|
||||
nodecar/nodecar-bin
|
||||
+1
-1
@@ -1 +1 @@
|
||||
pnpm lint-staged
|
||||
pnpm exec lint-staged
|
||||
|
||||
+1
-1
@@ -1,2 +1,2 @@
|
||||
22
|
||||
23
|
||||
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"biomejs.biome",
|
||||
"streetsidesoftware.code-spell-checker",
|
||||
"usernamehw.errorlens",
|
||||
"heybourn.headwind",
|
||||
"yoavbls.pretty-ts-errors",
|
||||
"rust-lang.rust-analyzer",
|
||||
"bradlc.vscode-tailwindcss"
|
||||
]
|
||||
}
|
||||
Vendored
+152
-1
@@ -1,23 +1,174 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"adwaita",
|
||||
"ahooks",
|
||||
"akhilmhdh",
|
||||
"appimage",
|
||||
"appindicator",
|
||||
"applescript",
|
||||
"asyncio",
|
||||
"autoconfig",
|
||||
"autologin",
|
||||
"biomejs",
|
||||
"breezedark",
|
||||
"browserforge",
|
||||
"busctl",
|
||||
"CAMOU",
|
||||
"camoufox",
|
||||
"cdylib",
|
||||
"certifi",
|
||||
"CFURL",
|
||||
"checkin",
|
||||
"chrono",
|
||||
"CLICOLOR",
|
||||
"clippy",
|
||||
"cmdk",
|
||||
"codegen",
|
||||
"codesign",
|
||||
"CTYPE",
|
||||
"dataclasses",
|
||||
"datareporting",
|
||||
"datas",
|
||||
"dconf",
|
||||
"devedition",
|
||||
"doctest",
|
||||
"doesn",
|
||||
"domcontentloaded",
|
||||
"donutbrowser",
|
||||
"dpkg",
|
||||
"dtolnay",
|
||||
"dyld",
|
||||
"elif",
|
||||
"errorlevel",
|
||||
"esac",
|
||||
"esbuild",
|
||||
"etree",
|
||||
"frontmost",
|
||||
"geoip",
|
||||
"getcwd",
|
||||
"gettimezone",
|
||||
"gifs",
|
||||
"gsettings",
|
||||
"healthreport",
|
||||
"hiddenimports",
|
||||
"hkcu",
|
||||
"hooksconfig",
|
||||
"hookspath",
|
||||
"icns",
|
||||
"idlelib",
|
||||
"idletime",
|
||||
"idna",
|
||||
"Inno",
|
||||
"kdeglobals",
|
||||
"keras",
|
||||
"KHTML",
|
||||
"kreadconfig",
|
||||
"launchservices",
|
||||
"letterboxing",
|
||||
"libatk",
|
||||
"libayatana",
|
||||
"libcairo",
|
||||
"libgdk",
|
||||
"libglib",
|
||||
"libpango",
|
||||
"librsvg",
|
||||
"libwebkit",
|
||||
"libxdo",
|
||||
"localtime",
|
||||
"lxml",
|
||||
"mmdb",
|
||||
"mountpoint",
|
||||
"msiexec",
|
||||
"msvc",
|
||||
"msys",
|
||||
"Mullvad",
|
||||
"mullvadbrowser",
|
||||
"mypy",
|
||||
"noarchive",
|
||||
"noconfirm",
|
||||
"nodecar",
|
||||
"nodemon",
|
||||
"norestart",
|
||||
"NSIS",
|
||||
"ntlm",
|
||||
"numpy",
|
||||
"objc",
|
||||
"orhun",
|
||||
"orjson",
|
||||
"osascript",
|
||||
"pathex",
|
||||
"pathlib",
|
||||
"peerconnection",
|
||||
"pixbuf",
|
||||
"plasmohq",
|
||||
"platformdirs",
|
||||
"prefs",
|
||||
"propertylist",
|
||||
"psutil",
|
||||
"pycache",
|
||||
"pydantic",
|
||||
"pyee",
|
||||
"pyinstaller",
|
||||
"pyoxidizer",
|
||||
"pytest",
|
||||
"pyyaml",
|
||||
"reqwest",
|
||||
"ridedott",
|
||||
"rlib",
|
||||
"rustc",
|
||||
"SARIF",
|
||||
"scipy",
|
||||
"screeninfo",
|
||||
"serde",
|
||||
"setuptools",
|
||||
"shadcn",
|
||||
"showcursor",
|
||||
"shutil",
|
||||
"signon",
|
||||
"signum",
|
||||
"sklearn",
|
||||
"sonner",
|
||||
"splitn",
|
||||
"sspi",
|
||||
"staticlib",
|
||||
"stefanzweifel",
|
||||
"subdirs",
|
||||
"subkey",
|
||||
"SUPPRESSMSGBOXES",
|
||||
"swatinem",
|
||||
"sysinfo",
|
||||
"systempreferences",
|
||||
"turbopack"
|
||||
"systemsetup",
|
||||
"taskkill",
|
||||
"tasklist",
|
||||
"tauri",
|
||||
"TERX",
|
||||
"testuser",
|
||||
"timedatectl",
|
||||
"titlebar",
|
||||
"tkinter",
|
||||
"Torbrowser",
|
||||
"tqdm",
|
||||
"trackingprotection",
|
||||
"turbopack",
|
||||
"turtledemo",
|
||||
"udeps",
|
||||
"unlisten",
|
||||
"unminimize",
|
||||
"unrs",
|
||||
"urlencoding",
|
||||
"urllib",
|
||||
"venv",
|
||||
"vercel",
|
||||
"VERYSILENT",
|
||||
"webgl",
|
||||
"webrtc",
|
||||
"winreg",
|
||||
"wiremock",
|
||||
"xattr",
|
||||
"xfconf",
|
||||
"xsettings",
|
||||
"zhom",
|
||||
"zoneinfo"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# Instructions for AI Agents
|
||||
|
||||
- After your changes, instead of running specific tests or linting specific files, run "pnpm format && pnpm lint && pnpm test". It means that you first format the code, then lint it, then test it, so that no part is broken after your changes.
|
||||
- Don't leave comments that don't add value.
|
||||
- Do not duplicate code unless you have a very good reason to do so. It is important that the same logic is not duplicated multiple times.
|
||||
- Before finishing the task and showing summary, always run "pnpm format && pnpm lint && pnpm test" at the root of the project to ensure that you don't finish with broken application.
|
||||
- Anytime you change nodecar's code and try to test, recompile it with "cd nodecar && pnpm build".
|
||||
- If there is a global singleton of a struct, only use it inside a method while properly initializing it, unless I have explicitly specified in the request otherwise.
|
||||
+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.
|
||||
|
||||
+6
-42
@@ -26,6 +26,7 @@ Ensure you have the following dependencies installed:
|
||||
- Node.js (see `.node-version` for exact version)
|
||||
- pnpm package manager
|
||||
- Latest Rust and Cargo toolchain
|
||||
- [Banderole](https://github.com/zhom/banderole)
|
||||
- [Tauri prerequisites guide](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
## Run Locally
|
||||
@@ -46,12 +47,13 @@ After having the above dependencies installed, proceed through the following ste
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Install nodecar dependencies**
|
||||
4. **Build nodecar**
|
||||
|
||||
Building nodecar requires you to have `banderole` installed.
|
||||
|
||||
```bash
|
||||
cd nodecar
|
||||
pnpm install --ignore-workspace --frozen-lockfile
|
||||
cd ..
|
||||
pnpm build
|
||||
```
|
||||
|
||||
5. **Start the development server**
|
||||
@@ -105,7 +107,6 @@ Make sure the build completes successfully without errors.
|
||||
## Testing
|
||||
|
||||
- Always test your changes on the target platform
|
||||
- Test both development and production builds
|
||||
- Verify that existing functionality still works
|
||||
- Add tests for new features when possible
|
||||
|
||||
@@ -149,50 +150,13 @@ 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:
|
||||
|
||||
- **Frontend**: Next.js React application
|
||||
- **Backend**: Tauri (Rust) for native functionality
|
||||
- **Node.js Sidecar**: `nodecar` binary for proxy support
|
||||
- **Node.js Sidecar**: `nodecar` binary for access to JavaScript ecosystem
|
||||
- **Build System**: GitHub Actions for CI/CD
|
||||
|
||||
Understanding this architecture will help you contribute more effectively.
|
||||
|
||||
@@ -6,15 +6,21 @@
|
||||
<br>
|
||||
|
||||
<p align="center">
|
||||
<a 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 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 href="https://github.com/zhom/donutbrowser/issues" target="_blank">
|
||||
<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 href="https://github.com/zhom/donutbrowser/blob/main/LICENSE" target="_blank">
|
||||
<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://github.com/zhom/donutbrowser/stargazers" target="_blank">
|
||||
<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>
|
||||
@@ -23,17 +29,32 @@
|
||||
|
||||
> 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
|
||||
|
||||
> As of right now, the app is not signed by Apple. You need to have Gatekeeper disabled to run it. The app automatically checks for updates on each launch.
|
||||
> For Linux, .deb and .rpm packages are available as well as standalone .AppImage files.
|
||||
|
||||
The app can be downloaded from the [releases page](https://github.com/zhom/donutbrowser/releases/latest).
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
- ✅ **macOS** (Intel & Apple Silicon)
|
||||
- ✅ **Linux** (x64 & arm64)
|
||||
- 🔄 **Windows** (Planned)
|
||||
- 🔄 **Linux** (Planned)
|
||||
|
||||
## Development
|
||||
|
||||
@@ -52,6 +73,38 @@ Have questions or want to contribute? We'd love to hear from you!
|
||||
- **Issues**: [GitHub Issues](https://github.com/zhom/donutbrowser/issues)
|
||||
- **Discussions**: [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
|
||||
|
||||
## Star History
|
||||
|
||||
<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
|
||||
|
||||
Have an urgent question or want to report a security vulnerability? Send an email to contact at donutbrowser dot com and we'll get back to you as fast as possible.
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the AGPL-3.0 License - see the [LICENSE](LICENSE) file for details.
|
||||
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting Security Issues
|
||||
|
||||
Thanks for helping make Donut Browser safe for everyone! ❤️
|
||||
|
||||
We take the security of Donut Browser seriously. If you believe you have found a security vulnerability in Donut Browser, please report it to us through coordinated disclosure.
|
||||
|
||||
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
|
||||
|
||||
Instead, please send an email to **contact at donutbrowser dot com** with the subject line "Security Vulnerability Report".
|
||||
|
||||
Please include as much of the information listed below as you can to help us better understand and resolve the issue:
|
||||
|
||||
- The type of issue (e.g., buffer overflow, injection attack, privilege escalation, or cross-site scripting)
|
||||
- Full paths of source file(s) related to the manifestation of the issue
|
||||
- The location of the affected source code (tag/branch/commit or direct URL)
|
||||
- Any special configuration required to reproduce the issue
|
||||
- Step-by-step instructions to reproduce the issue
|
||||
- Proof-of-concept or exploit code (if possible)
|
||||
- Impact of the issue, including how an attacker might exploit the issue
|
||||
- Your assessment of the severity level
|
||||
|
||||
This information will help us triage your report more quickly.
|
||||
|
||||
## What to Expect
|
||||
|
||||
- **Response Time**: We will acknowledge receipt of your vulnerability report within 72 hours.
|
||||
- **Investigation**: We will investigate the issue and provide you with updates on our progress.
|
||||
- **Resolution**: We aim to resolve critical security issues as fast as possible, but no longer than in 30 days after the initial report.
|
||||
- **Disclosure**: We will coordinate with you on the timing of any public disclosure.
|
||||
|
||||
## Contact
|
||||
|
||||
For urgent security matters, please contact us at **contact at donutbrowser dot com**.
|
||||
|
||||
For general questions about this security policy, you can also reach out through:
|
||||
|
||||
- [GitHub Issues](https://github.com/zhom/donutbrowser/issues) (for non-security questions only)
|
||||
- [GitHub Discussions](https://github.com/zhom/donutbrowser/discussions)
|
||||
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"
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"config": "tailwind.config.js",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "zinc",
|
||||
"cssVariables": true,
|
||||
@@ -18,4 +18,4 @@
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Executable
+28
@@ -0,0 +1,28 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Determine file extension based on platform
|
||||
if [[ "$OSTYPE" == "msys" || "$OSTYPE" == "win32" || "$OSTYPE" == "cygwin" ]]; then
|
||||
EXT=".exe"
|
||||
else
|
||||
EXT=""
|
||||
fi
|
||||
|
||||
# If architecture provided in the command line, use it to rename the binary in TARGET_TRIPLE
|
||||
if [ -n "$1" ]; then
|
||||
TARGET_TRIPLE="$1"
|
||||
else
|
||||
RUST_INFO=$(rustc -vV)
|
||||
TARGET_TRIPLE=$(echo "$RUST_INFO" | grep -o 'host: [^ ]*' | cut -d' ' -f2)
|
||||
fi
|
||||
|
||||
# Check if target triple was found
|
||||
if [ -z "$TARGET_TRIPLE" ]; then
|
||||
echo "Failed to determine platform target triple" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Copy the file with target triple suffix
|
||||
cp "nodecar-bin" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
|
||||
|
||||
# Also copy a generic version for Tauri to find
|
||||
cp "nodecar-bin" "../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;
|
||||
+23
-16
@@ -2,32 +2,39 @@
|
||||
"name": "nodecar",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "src/index.ts",
|
||||
"main": "dist/index.js",
|
||||
"bin": "dist/index.js",
|
||||
"scripts": {
|
||||
"watch": "nodemon --exec ts-node --esm ./src/index.ts --watch src",
|
||||
"dev": "node --loader ts-node/esm ./src/index.ts",
|
||||
"start": "node --loader ts-node/esm ./src/index.ts",
|
||||
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar",
|
||||
"build:aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar",
|
||||
"build:x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar",
|
||||
"build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar",
|
||||
"build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar",
|
||||
"build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar",
|
||||
"build:win-arm64": "tsc && pkg ./dist/index.js --targets latest-win-arm64 --output dist/nodecar"
|
||||
"start": "tsc && node ./dist/index.js",
|
||||
"test": "tsc && node ./dist/test-proxy.js",
|
||||
"rename-binary": "sh ./copy-binary.sh",
|
||||
"build": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:mac-aarch64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:mac-x86_64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:linux-x64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:linux-arm64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:win-x64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:win-arm64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"packageManager": "pnpm@10.6.1",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^22.15.17",
|
||||
"@yao-pkg/pkg": "^6.4.1",
|
||||
"commander": "^13.1.0",
|
||||
"dotenv": "^16.5.0",
|
||||
"@types/node": "^24.1.0",
|
||||
"camoufox-js": "^0.6.1",
|
||||
"commander": "^14.0.0",
|
||||
"dotenv": "^17.2.0",
|
||||
"get-port": "^7.1.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"proxy-chain": "^2.5.8",
|
||||
"playwright-core": "^1.54.1",
|
||||
"proxy-chain": "^2.5.9",
|
||||
"tmp": "^0.2.3",
|
||||
"ts-node": "^10.9.2",
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/tmp": "^0.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
-1304
File diff suppressed because it is too large
Load Diff
@@ -1,14 +0,0 @@
|
||||
import { execSync } from "child_process";
|
||||
import fs from "fs";
|
||||
|
||||
const ext = process.platform === "win32" ? ".exe" : "";
|
||||
|
||||
const rustInfo = execSync("rustc -vV");
|
||||
const targetTriple = /host: (\S+)/g.exec(rustInfo)[1];
|
||||
if (!targetTriple) {
|
||||
console.error("Failed to determine platform target triple");
|
||||
}
|
||||
fs.renameSync(
|
||||
`dist/nodecar${ext}`,
|
||||
`../src-tauri/binaries/nodecar-${targetTriple}${ext}`
|
||||
);
|
||||
@@ -0,0 +1,228 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import path from "node:path";
|
||||
import type { LaunchOptions } from "camoufox-js/dist/utils.js";
|
||||
import {
|
||||
type CamoufoxConfig,
|
||||
deleteCamoufoxConfig,
|
||||
generateCamoufoxId,
|
||||
getCamoufoxConfig,
|
||||
listCamoufoxConfigs,
|
||||
saveCamoufoxConfig,
|
||||
} from "./camoufox-storage.js";
|
||||
|
||||
/**
|
||||
* Start a Camoufox instance in a separate process
|
||||
* @param options Camoufox launch options
|
||||
* @param profilePath Profile directory path
|
||||
* @param url Optional URL to open
|
||||
* @returns Promise resolving to the Camoufox configuration
|
||||
*/
|
||||
export async function startCamoufoxProcess(
|
||||
options: LaunchOptions = {},
|
||||
profilePath?: string,
|
||||
url?: string,
|
||||
): Promise<CamoufoxConfig> {
|
||||
// Generate a unique ID for this instance
|
||||
const id = generateCamoufoxId();
|
||||
|
||||
// Create the Camoufox configuration
|
||||
const config: CamoufoxConfig = {
|
||||
id,
|
||||
options,
|
||||
profilePath,
|
||||
url,
|
||||
};
|
||||
|
||||
// Save the configuration before starting the process
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
// Build the command arguments
|
||||
const args = [
|
||||
path.join(__dirname, "index.js"),
|
||||
"camoufox-worker",
|
||||
"start",
|
||||
"--id",
|
||||
id,
|
||||
];
|
||||
|
||||
// Spawn the process with proper detachment - similar to proxy implementation
|
||||
const child = spawn(process.execPath, args, {
|
||||
detached: true,
|
||||
stdio: ["ignore", "pipe", "pipe"], // Capture stdout and stderr for startup feedback
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_ENV: "production",
|
||||
// Ensure Camoufox can find its dependencies
|
||||
NODE_PATH: process.env.NODE_PATH || "",
|
||||
},
|
||||
});
|
||||
|
||||
// Wait for the worker to start successfully or fail - with shorter timeout for quick response
|
||||
return new Promise<CamoufoxConfig>((resolve, reject) => {
|
||||
let resolved = false;
|
||||
let stdoutBuffer = "";
|
||||
let stderrBuffer = "";
|
||||
|
||||
// Shorter timeout for quick startup feedback
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
child.kill("SIGKILL");
|
||||
reject(
|
||||
new Error(`Camoufox worker ${id} startup timeout after 5 seconds`),
|
||||
);
|
||||
}
|
||||
}, 5000);
|
||||
|
||||
// Handle stdout - look for success JSON
|
||||
if (child.stdout) {
|
||||
child.stdout.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
stdoutBuffer += output;
|
||||
|
||||
// Look for success JSON message
|
||||
const lines = stdoutBuffer.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line.trim());
|
||||
if (parsed.success && parsed.id === id && parsed.processId) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
config.processId = parsed.processId;
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
// Unref immediately after success to detach properly
|
||||
child.unref();
|
||||
resolve(config);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Handle stderr - look for error JSON
|
||||
if (child.stderr) {
|
||||
child.stderr.on("data", (data) => {
|
||||
const output = data.toString();
|
||||
stderrBuffer += output;
|
||||
|
||||
// Look for error JSON message
|
||||
const lines = stderrBuffer.split("\n");
|
||||
for (const line of lines) {
|
||||
if (line.trim()) {
|
||||
try {
|
||||
const parsed = JSON.parse(line.trim());
|
||||
if (parsed.error && parsed.id === id) {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
reject(
|
||||
new Error(
|
||||
`Camoufox worker failed: ${parsed.message || parsed.error}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, continue
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
clearTimeout(timeout);
|
||||
if (code !== 0) {
|
||||
reject(
|
||||
new Error(
|
||||
`Camoufox worker ${id} exited with code ${code} and signal ${signal}. Stderr: ${stderrBuffer}`,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// Process exited successfully but we didn't get success message
|
||||
reject(
|
||||
new Error(
|
||||
`Camoufox worker ${id} exited without success confirmation`,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a Camoufox process
|
||||
* @param id The Camoufox ID to stop
|
||||
* @returns Promise resolving to true if stopped, false if not found
|
||||
*/
|
||||
export async function stopCamoufoxProcess(id: string): Promise<boolean> {
|
||||
const config = getCamoufoxConfig(id);
|
||||
|
||||
if (!config) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const killByPattern = spawn("pkill", ["-f", `camoufox-worker.*${id}`], {
|
||||
stdio: "ignore",
|
||||
});
|
||||
|
||||
// Method 2: If we have a process ID, kill by PID
|
||||
if (config.processId) {
|
||||
try {
|
||||
process.kill(config.processId, "SIGTERM");
|
||||
|
||||
// Give it a moment to terminate gracefully
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
|
||||
// Force kill if still running
|
||||
try {
|
||||
process.kill(config.processId, "SIGKILL");
|
||||
} catch {
|
||||
// Process already terminated
|
||||
}
|
||||
} catch (error) {
|
||||
// Process not found or already terminated
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for pattern-based kill command to complete
|
||||
await new Promise<void>((resolve) => {
|
||||
killByPattern.on("exit", () => resolve());
|
||||
// Timeout after 3 seconds
|
||||
setTimeout(() => resolve(), 3000);
|
||||
});
|
||||
|
||||
// Delete the configuration
|
||||
deleteCamoufoxConfig(id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// Delete the configuration even if stopping failed
|
||||
deleteCamoufoxConfig(id);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all Camoufox processes
|
||||
* @returns Promise resolving when all instances are stopped
|
||||
*/
|
||||
export async function stopAllCamoufoxProcesses(): Promise<void> {
|
||||
const configs = listCamoufoxConfigs();
|
||||
|
||||
const stopPromises = configs.map((config) => stopCamoufoxProcess(config.id));
|
||||
await Promise.all(stopPromises);
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import type { LaunchOptions } from "camoufox-js/dist/utils.js";
|
||||
import tmp from "tmp";
|
||||
|
||||
export interface CamoufoxConfig {
|
||||
id: string;
|
||||
options: LaunchOptions;
|
||||
profilePath?: string;
|
||||
url?: string;
|
||||
processId?: number;
|
||||
}
|
||||
|
||||
const STORAGE_DIR = path.join(tmp.tmpdir, "donutbrowser", "camoufox");
|
||||
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
fs.mkdirSync(STORAGE_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* Save a Camoufox configuration to disk
|
||||
* @param config The Camoufox configuration to save
|
||||
*/
|
||||
export function saveCamoufoxConfig(config: CamoufoxConfig): void {
|
||||
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
|
||||
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a Camoufox configuration by ID
|
||||
* @param id The Camoufox ID
|
||||
* @returns The Camoufox configuration or null if not found
|
||||
*/
|
||||
export function getCamoufoxConfig(id: string): CamoufoxConfig | null {
|
||||
const filePath = path.join(STORAGE_DIR, `${id}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
return JSON.parse(content) as CamoufoxConfig;
|
||||
} catch (error) {
|
||||
console.error(`Error reading Camoufox config ${id}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Camoufox configuration
|
||||
* @param id The Camoufox ID to delete
|
||||
* @returns True if deleted, false if not found
|
||||
*/
|
||||
export function deleteCamoufoxConfig(id: string): boolean {
|
||||
const filePath = path.join(STORAGE_DIR, `${id}.json`);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
fs.unlinkSync(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Error deleting Camoufox config ${id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all saved Camoufox configurations
|
||||
* @returns Array of Camoufox configurations
|
||||
*/
|
||||
export function listCamoufoxConfigs(): CamoufoxConfig[] {
|
||||
if (!fs.existsSync(STORAGE_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
return fs
|
||||
.readdirSync(STORAGE_DIR)
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => {
|
||||
try {
|
||||
const content = fs.readFileSync(
|
||||
path.join(STORAGE_DIR, file),
|
||||
"utf-8",
|
||||
);
|
||||
return JSON.parse(content) as CamoufoxConfig;
|
||||
} catch (error) {
|
||||
console.error(`Error reading Camoufox config ${file}:`, error);
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((config): config is CamoufoxConfig => config !== null);
|
||||
} catch (error) {
|
||||
console.error("Error listing Camoufox configs:", error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a Camoufox configuration
|
||||
* @param config The Camoufox configuration to update
|
||||
* @returns True if updated, false if not found
|
||||
*/
|
||||
export function updateCamoufoxConfig(config: CamoufoxConfig): boolean {
|
||||
const filePath = path.join(STORAGE_DIR, `${config.id}.json`);
|
||||
|
||||
try {
|
||||
fs.readFileSync(filePath, "utf-8");
|
||||
fs.writeFileSync(filePath, JSON.stringify(config, null, 2));
|
||||
return true;
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
||||
console.error(
|
||||
`Config ${config.id} was deleted while the app was running`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
console.error(`Error updating Camoufox config ${config.id}:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique ID for a Camoufox instance
|
||||
* @returns A unique ID string
|
||||
*/
|
||||
export function generateCamoufoxId(): string {
|
||||
return `camoufox_${Date.now()}_${Math.floor(Math.random() * 10000)}`;
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Camoufox } from "camoufox-js";
|
||||
import type { Page } from "playwright-core";
|
||||
import { getCamoufoxConfig, saveCamoufoxConfig } from "./camoufox-storage.js";
|
||||
|
||||
/**
|
||||
* Run a Camoufox browser server as a worker process
|
||||
* @param id The Camoufox configuration ID
|
||||
*/
|
||||
export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
// Get the Camoufox configuration
|
||||
const config = getCamoufoxConfig(id);
|
||||
|
||||
if (!config) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
error: "Configuration not found",
|
||||
id: id,
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Return success immediately - before any async operations
|
||||
const processId = process.pid;
|
||||
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
success: true,
|
||||
id: id,
|
||||
processId,
|
||||
profilePath: config.profilePath,
|
||||
message: "Camoufox worker started successfully",
|
||||
}),
|
||||
);
|
||||
|
||||
// Update config with process details
|
||||
config.processId = processId;
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
// Handle process termination gracefully
|
||||
const gracefulShutdown = async () => {
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
process.on("SIGTERM", () => void gracefulShutdown());
|
||||
process.on("SIGINT", () => void gracefulShutdown());
|
||||
|
||||
// Launch browser in background - this can take time and may fail
|
||||
setImmediate(async () => {
|
||||
let page: Page | null = null;
|
||||
|
||||
try {
|
||||
// Prepare options for Camoufox
|
||||
const camoufoxOptions = { ...config.options };
|
||||
|
||||
// Add profile path if provided
|
||||
if (config.profilePath) {
|
||||
camoufoxOptions.user_data_dir = config.profilePath;
|
||||
}
|
||||
|
||||
// Remove custom properties before passing to Camoufox
|
||||
camoufoxOptions.disableTheming = true;
|
||||
camoufoxOptions.showcursor = false;
|
||||
|
||||
// Set Firefox preferences for theming
|
||||
if (!camoufoxOptions.firefox_user_prefs) {
|
||||
camoufoxOptions.firefox_user_prefs = {};
|
||||
}
|
||||
|
||||
// Default to non-headless for visibility
|
||||
if (camoufoxOptions.headless === undefined) {
|
||||
camoufoxOptions.headless = false;
|
||||
}
|
||||
|
||||
const browser = await Camoufox(camoufoxOptions);
|
||||
const context = await browser.newContext();
|
||||
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
// Handle URL opening if provided
|
||||
if (config.url && context) {
|
||||
try {
|
||||
if (!page) {
|
||||
page = await context.newPage();
|
||||
}
|
||||
await page.goto(config.url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
} catch {
|
||||
// URL opening failure doesn't affect startup success
|
||||
}
|
||||
}
|
||||
|
||||
// Monitor browser connection
|
||||
const keepAlive = setInterval(async () => {
|
||||
try {
|
||||
if (!browser || !browser.isConnected()) {
|
||||
clearInterval(keepAlive);
|
||||
process.exit(0);
|
||||
}
|
||||
} catch {
|
||||
clearInterval(keepAlive);
|
||||
process.exit(0);
|
||||
}
|
||||
}, 2000);
|
||||
} catch {
|
||||
// Browser launch failed, but worker is still "successful"
|
||||
// Process will stay alive due to the main setInterval above
|
||||
}
|
||||
});
|
||||
|
||||
// Keep process alive
|
||||
process.stdin.resume();
|
||||
}
|
||||
+387
-24
@@ -1,8 +1,16 @@
|
||||
import type { LaunchOptions } from "camoufox-js/dist/utils.js";
|
||||
import { program } from "commander";
|
||||
import {
|
||||
startCamoufoxProcess,
|
||||
stopAllCamoufoxProcesses,
|
||||
stopCamoufoxProcess,
|
||||
} from "./camoufox-launcher.js";
|
||||
import { listCamoufoxConfigs } from "./camoufox-storage.js";
|
||||
import { runCamoufoxWorker } from "./camoufox-worker.js";
|
||||
import {
|
||||
startProxyProcess,
|
||||
stopProxyProcess,
|
||||
stopAllProxyProcesses,
|
||||
stopProxyProcess,
|
||||
} from "./proxy-runner";
|
||||
import { listProxyConfigs } from "./proxy-storage";
|
||||
import { runProxyWorker } from "./proxy-worker";
|
||||
@@ -11,79 +19,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 proxy.example.com --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));
|
||||
} catch (error: any) {
|
||||
console.error(`Failed to start proxy: ${error.message}`);
|
||||
|
||||
// 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: ${
|
||||
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 +157,311 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Command for Camoufox management
|
||||
program
|
||||
.command("camoufox")
|
||||
.argument("<action>", "start, stop, or list Camoufox instances")
|
||||
.option("--id <id>", "Camoufox ID for stop command")
|
||||
.option("--profile-path <path>", "profile directory path")
|
||||
.option("--url <url>", "URL to open")
|
||||
|
||||
// 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)")
|
||||
|
||||
.option("--disable-theming", "disable Firefox theming")
|
||||
.option("--no-showcursor", "disable cursor display")
|
||||
|
||||
.description("manage Camoufox browser instances")
|
||||
.action(
|
||||
async (
|
||||
action: string,
|
||||
options: Record<string, string | number | boolean | undefined>,
|
||||
) => {
|
||||
if (action === "start") {
|
||||
try {
|
||||
// Build Camoufox options in the format expected by camoufox-js
|
||||
const camoufoxOptions: LaunchOptions = {};
|
||||
|
||||
// OS fingerprinting
|
||||
if (options.os && typeof options.os === "string") {
|
||||
camoufoxOptions.os = options.os.includes(",")
|
||||
? (options.os.split(",") as ("windows" | "macos" | "linux")[])
|
||||
: (options.os as "windows" | "macos" | "linux");
|
||||
}
|
||||
|
||||
// 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 as string);
|
||||
}
|
||||
if (options.latitude && options.longitude) {
|
||||
camoufoxOptions.geolocation = {
|
||||
latitude: options.latitude as number,
|
||||
longitude: options.longitude as number,
|
||||
accuracy: 100,
|
||||
};
|
||||
}
|
||||
if (options.country)
|
||||
camoufoxOptions.country = options.country as string;
|
||||
if (options.timezone)
|
||||
camoufoxOptions.timezone = options.timezone as string;
|
||||
|
||||
// UI and behavior
|
||||
if (options.humanize)
|
||||
camoufoxOptions.humanize = options.humanize as boolean | number;
|
||||
if (options.headless) camoufoxOptions.headless = true;
|
||||
|
||||
// Localization
|
||||
if (options.locale && typeof options.locale === "string") {
|
||||
camoufoxOptions.locale = options.locale.includes(",")
|
||||
? options.locale.split(",")
|
||||
: options.locale;
|
||||
}
|
||||
|
||||
// Extensions and fonts
|
||||
if (options.addons && typeof options.addons === "string")
|
||||
camoufoxOptions.addons = options.addons.split(",");
|
||||
if (options.fonts && typeof options.fonts === "string")
|
||||
camoufoxOptions.fonts = options.fonts.split(",");
|
||||
if (options.customFontsOnly) camoufoxOptions.custom_fonts_only = true;
|
||||
if (
|
||||
options.excludeAddons &&
|
||||
typeof options.excludeAddons === "string"
|
||||
)
|
||||
camoufoxOptions.exclude_addons = options.excludeAddons.split(
|
||||
",",
|
||||
) as "UBO"[];
|
||||
|
||||
// Screen and window
|
||||
const screen: {
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
} = {};
|
||||
if (options.screenMinWidth)
|
||||
screen.minWidth = options.screenMinWidth as number;
|
||||
if (options.screenMaxWidth)
|
||||
screen.maxWidth = options.screenMaxWidth as number;
|
||||
if (options.screenMinHeight)
|
||||
screen.minHeight = options.screenMinHeight as number;
|
||||
if (options.screenMaxHeight)
|
||||
screen.maxHeight = options.screenMaxHeight as number;
|
||||
if (Object.keys(screen).length > 0) camoufoxOptions.screen = screen;
|
||||
|
||||
if (options.windowWidth && options.windowHeight) {
|
||||
camoufoxOptions.window = [
|
||||
options.windowWidth as number,
|
||||
options.windowHeight as number,
|
||||
];
|
||||
}
|
||||
|
||||
// Advanced options
|
||||
if (options.ffVersion)
|
||||
camoufoxOptions.ff_version = options.ffVersion as number;
|
||||
if (options.mainWorldEval) camoufoxOptions.main_world_eval = true;
|
||||
if (options.webglVendor && options.webglRenderer) {
|
||||
camoufoxOptions.webgl_config = [
|
||||
options.webglVendor as string,
|
||||
options.webglRenderer as string,
|
||||
];
|
||||
}
|
||||
|
||||
// Proxy
|
||||
if (options.proxy) camoufoxOptions.proxy = options.proxy as string;
|
||||
|
||||
// Cache and performance - default to enabled
|
||||
camoufoxOptions.enable_cache = !options.disableCache;
|
||||
|
||||
// Environment and debugging
|
||||
if (options.virtualDisplay)
|
||||
camoufoxOptions.virtual_display = options.virtualDisplay as string;
|
||||
if (options.debug) camoufoxOptions.debug = true;
|
||||
if (options.args && typeof options.args === "string")
|
||||
camoufoxOptions.args = options.args.split(",");
|
||||
if (options.env && typeof options.env === "string") {
|
||||
try {
|
||||
camoufoxOptions.env = JSON.parse(options.env);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
error: "Invalid JSON for --env option",
|
||||
message: String(e),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Firefox preferences
|
||||
if (
|
||||
options.firefoxPrefs &&
|
||||
typeof options.firefoxPrefs === "string"
|
||||
) {
|
||||
try {
|
||||
camoufoxOptions.firefox_user_prefs = JSON.parse(
|
||||
options.firefoxPrefs,
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
error: "Invalid JSON for --firefox-prefs option",
|
||||
message: String(e),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Theming and cursor - these are custom properties for camoufox-js
|
||||
if (options.disableTheming) camoufoxOptions.disableTheming = true;
|
||||
if (options.showcursor === false) camoufoxOptions.showcursor = false;
|
||||
|
||||
// Use the launcher to start Camoufox properly
|
||||
const config = await startCamoufoxProcess(
|
||||
camoufoxOptions,
|
||||
typeof options.profilePath === "string"
|
||||
? options.profilePath
|
||||
: undefined,
|
||||
typeof options.url === "string" ? options.url : undefined,
|
||||
);
|
||||
|
||||
// Output the configuration as JSON for the Rust side to parse
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
id: config.id,
|
||||
processId: config.processId,
|
||||
profilePath: config.profilePath,
|
||||
url: config.url,
|
||||
}),
|
||||
);
|
||||
|
||||
process.exit(0);
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
error: "Failed to start Camoufox",
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
}),
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
} else if (action === "stop") {
|
||||
if (options.id && typeof options.id === "string") {
|
||||
const stopped = await stopCamoufoxProcess(options.id);
|
||||
console.log(JSON.stringify({ success: stopped }));
|
||||
} else {
|
||||
await stopAllCamoufoxProcesses();
|
||||
console.log(JSON.stringify({ success: true }));
|
||||
}
|
||||
process.exit(0);
|
||||
} else if (action === "list") {
|
||||
const configs = listCamoufoxConfigs();
|
||||
console.log(JSON.stringify(configs));
|
||||
process.exit(0);
|
||||
} else {
|
||||
console.error("Invalid action. Use 'start', 'stop', or 'list'");
|
||||
process.exit(1);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Command for Camoufox worker (internal use)
|
||||
program
|
||||
.command("camoufox-worker")
|
||||
.argument("<action>", "start a Camoufox worker")
|
||||
.requiredOption("--id <id>", "Camoufox configuration ID")
|
||||
.description("run a Camoufox worker process")
|
||||
.action(async (action: string, options: { id: string }) => {
|
||||
if (action === "start") {
|
||||
await runCamoufoxWorker(options.id);
|
||||
} else {
|
||||
console.error("Invalid action for camoufox-worker. Use 'start'");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
+40
-28
@@ -1,14 +1,14 @@
|
||||
import { spawn } from "child_process";
|
||||
import path from "path";
|
||||
import { spawn } from "node:child_process";
|
||||
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 "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
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();
|
||||
}
|
||||
+38
-34
@@ -1,21 +1,27 @@
|
||||
{
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"version": "0.2.1",
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.7.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "biome check src/ && tsc --noEmit && next lint",
|
||||
"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",
|
||||
"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",
|
||||
"format:js": "biome check src/ --fix",
|
||||
"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:biome": "biome check src/ --fix",
|
||||
"format": "pnpm format:js && pnpm format:rust"
|
||||
"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": {
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
@@ -24,51 +30,49 @@
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "^2.5.0",
|
||||
"@tauri-apps/plugin-fs": "~2.3.0",
|
||||
"@tauri-apps/plugin-opener": "^2.2.7",
|
||||
"@tauri-apps/api": "^2.7.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.1",
|
||||
"@tauri-apps/plugin-fs": "~2.4.1",
|
||||
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||
"ahooks": "^3.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"next": "^15.3.2",
|
||||
"next": "^15.4.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-icons": "^5.5.0",
|
||||
"sonner": "^2.0.3",
|
||||
"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.27.0",
|
||||
"@next/eslint-plugin-next": "^15.3.2",
|
||||
"@tailwindcss/postcss": "^4.1.7",
|
||||
"@tauri-apps/cli": "^2.5.0",
|
||||
"@types/node": "^22.15.21",
|
||||
"@types/react": "^19.1.5",
|
||||
"@types/react-dom": "^19.1.5",
|
||||
"@typescript-eslint/eslint-plugin": "^8.32.1",
|
||||
"@typescript-eslint/parser": "^8.32.1",
|
||||
"@vitejs/plugin-react": "^4.5.0",
|
||||
"eslint": "^9.27.0",
|
||||
"eslint-config-next": "^15.3.2",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"@biomejs/biome": "2.1.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tauri-apps/cli": "^2.7.1",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^15.3.0",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tw-animate-css": "^1.3.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.32.1"
|
||||
"lint-staged": "^16.1.2",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.0+sha512.6540583f41cc5f628eb3d9773ecee802f4f9ef9923cc45b69890fb47991d4b092964694ec3a4f738a420c918a333062c8b925d312f42e4f0c263eb603551f977",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"lint-staged": {
|
||||
"src/**/*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
"**/*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
"biome check --fix"
|
||||
],
|
||||
"src-tauri/**/*.rs": [
|
||||
|
||||
Generated
+2721
-3330
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,9 @@
|
||||
packages:
|
||||
- nodecar
|
||||
onlyBuiltDependencies:
|
||||
- '@biomejs/biome'
|
||||
- '@tailwindcss/oxide'
|
||||
- esbuild
|
||||
- sharp
|
||||
- sqlite3
|
||||
- unrs-resolver
|
||||
|
||||
Generated
+1006
-910
File diff suppressed because it is too large
Load Diff
+47
-9
@@ -1,9 +1,10 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.2.1"
|
||||
description = "A Tauri App"
|
||||
version = "0.7.2"
|
||||
description = "Simple Yet Powerful Browser Orchestrator"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
default-run = "donutbrowser"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
@@ -25,28 +26,65 @@ tauri-plugin-opener = "2"
|
||||
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"
|
||||
tokio = { version = "1", features = ["full", "sync"] }
|
||||
sysinfo = "0.36"
|
||||
lazy_static = "1.4"
|
||||
base64 = "0.22"
|
||||
zip = "4"
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
url = "2.5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
zip = "4"
|
||||
|
||||
[target.'cfg(target_os = "macos")'.dependencies]
|
||||
core-foundation="0.10"
|
||||
core-foundation = "0.10"
|
||||
objc2 = "0.6.1"
|
||||
objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
||||
|
||||
[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"] }
|
||||
futures-util = "0.3"
|
||||
|
||||
# Integration test configuration
|
||||
[[test]]
|
||||
name = "nodecar_integration"
|
||||
path = "tests/nodecar_integration.rs"
|
||||
|
||||
[features]
|
||||
# by default Tauri runs in production mode
|
||||
# when `tauri dev` runs it is executed with `cargo run --no-default-features` if `devPath` points to the filesystem
|
||||
default = [ "custom-protocol" ]
|
||||
default = ["custom-protocol"]
|
||||
# this feature is used used for production builds where `devPath` points to the filesystem
|
||||
# DO NOT remove this
|
||||
custom-protocol = [ "tauri/custom-protocol" ]
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
|
||||
+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.2.1</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 {
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:event:default",
|
||||
"core:window:default",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"opener:default",
|
||||
"fs:default",
|
||||
"shell:allow-execute",
|
||||
@@ -13,6 +18,17 @@
|
||||
"shell:allow-open",
|
||||
"shell:allow-spawn",
|
||||
"shell:allow-stdin-write",
|
||||
"deep-link:default"
|
||||
"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",
|
||||
"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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
[Desktop Entry]
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Donut Browser
|
||||
Comment=Simple Yet Powerful Browser Orchestrator
|
||||
Exec=donutbrowser %u
|
||||
Icon=donutbrowser
|
||||
StartupNotify=true
|
||||
NoDisplay=false
|
||||
Categories=Network;WebBrowser;Productivity;
|
||||
MimeType=x-scheme-handler/http;x-scheme-handler/https;text/html;application/xhtml+xml;
|
||||
StartupWMClass=donutbrowser
|
||||
Keywords=browser;web;internet;productivity;
|
||||
@@ -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>
|
||||
+710
-188
File diff suppressed because it is too large
Load Diff
+634
-168
File diff suppressed because it is too large
Load Diff
+271
-206
@@ -1,10 +1,12 @@
|
||||
use crate::browser_runner::{BrowserProfile, BrowserRunner};
|
||||
use crate::api_client::is_browser_version_nightly;
|
||||
use crate::browser_version_service::{BrowserVersionInfo, BrowserVersionService};
|
||||
use crate::profile::BrowserProfile;
|
||||
use crate::settings_manager::SettingsManager;
|
||||
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 {
|
||||
@@ -27,50 +29,52 @@ pub struct AutoUpdateState {
|
||||
}
|
||||
|
||||
pub struct AutoUpdater {
|
||||
version_service: BrowserVersionService,
|
||||
browser_runner: BrowserRunner,
|
||||
settings_manager: SettingsManager,
|
||||
version_service: &'static BrowserVersionService,
|
||||
settings_manager: &'static SettingsManager,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
pub fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
version_service: BrowserVersionService::new(),
|
||||
browser_runner: BrowserRunner::new(),
|
||||
settings_manager: SettingsManager::new(),
|
||||
version_service: BrowserVersionService::instance(),
|
||||
settings_manager: SettingsManager::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static AutoUpdater {
|
||||
&AUTO_UPDATER
|
||||
}
|
||||
|
||||
/// Check for updates for all profiles
|
||||
pub async fn check_for_updates(
|
||||
&self,
|
||||
) -> 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 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
|
||||
// Group profiles by browser
|
||||
let profile_manager = crate::profile::ProfileManager::new();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
|
||||
|
||||
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 +101,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 400+ 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 > 400 {
|
||||
notifications.push(update);
|
||||
} else {
|
||||
println!(
|
||||
"Skipping chromium update notification: only {result} new versions (need 400+)"
|
||||
);
|
||||
}
|
||||
} else {
|
||||
notifications.push(update);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +128,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 +222,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_alpha_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,85 +290,14 @@ 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))
|
||||
}
|
||||
|
||||
/// Start browser update process
|
||||
pub async fn start_browser_update(
|
||||
&self,
|
||||
browser: &str,
|
||||
new_version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Add browser to disabled list to prevent conflicts during update
|
||||
let mut state = self.load_auto_update_state()?;
|
||||
state.disabled_browsers.insert(browser.to_string());
|
||||
|
||||
// Mark this download as auto-update for toast suppression
|
||||
let download_key = format!("{browser}-{new_version}");
|
||||
state.auto_update_downloads.insert(download_key);
|
||||
|
||||
self.save_auto_update_state(&state)?;
|
||||
|
||||
// The actual download will be triggered by the frontend
|
||||
// This function now just marks the browser as updating to prevent conflicts
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Complete browser update process
|
||||
pub async fn complete_browser_update(
|
||||
&self,
|
||||
browser: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Remove browser from disabled list
|
||||
let mut state = self.load_auto_update_state()?;
|
||||
state.disabled_browsers.remove(browser);
|
||||
self.save_auto_update_state(&state)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Automatically update all affected profile versions after browser download
|
||||
pub async fn auto_update_profile_versions(
|
||||
&self,
|
||||
browser: &str,
|
||||
new_version: &str,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let profiles = self
|
||||
.browser_runner
|
||||
let profile_manager = crate::profile::ProfileManager::new();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
@@ -276,10 +314,7 @@ impl AutoUpdater {
|
||||
// Check if this is an update (newer version)
|
||||
if self.is_version_newer(new_version, &profile.version) {
|
||||
// Update the profile version
|
||||
match self
|
||||
.browser_runner
|
||||
.update_profile_version(&profile.name, new_version)
|
||||
{
|
||||
match profile_manager.update_profile_version(&profile.name, new_version) {
|
||||
Ok(_) => {
|
||||
updated_profiles.push(profile.name);
|
||||
}
|
||||
@@ -312,9 +347,47 @@ impl AutoUpdater {
|
||||
state.auto_update_downloads.remove(&download_key);
|
||||
self.save_auto_update_state(&state)?;
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
/// Internal method to cleanup unused binaries (used by auto-cleanup)
|
||||
fn cleanup_unused_binaries_internal(
|
||||
&self,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Load current profiles
|
||||
let profile_manager = crate::profile::ProfileManager::new();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to load profiles: {e}"))?;
|
||||
|
||||
// Load registry
|
||||
let mut registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::load()
|
||||
.map_err(|e| format!("Failed to load browser registry: {e}"))?;
|
||||
|
||||
// Get active browser versions (all profiles)
|
||||
let active_versions = registry.get_active_browser_versions(&profiles);
|
||||
|
||||
// Get running browser versions (only running profiles)
|
||||
let running_versions = registry.get_running_browser_versions(&profiles);
|
||||
|
||||
// Cleanup unused binaries (but keep running ones)
|
||||
let cleaned_up = registry
|
||||
.cleanup_unused_binaries(&active_versions, &running_versions)
|
||||
.map_err(|e| format!("Failed to cleanup unused binaries: {e}"))?;
|
||||
|
||||
// Save updated registry
|
||||
registry
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save registry: {e}"))?;
|
||||
|
||||
Ok(cleaned_up)
|
||||
}
|
||||
|
||||
/// Check if browser is disabled due to ongoing update
|
||||
pub fn is_browser_disabled(
|
||||
&self,
|
||||
@@ -335,34 +408,18 @@ impl AutoUpdater {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper methods
|
||||
|
||||
fn is_alpha_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 {
|
||||
@@ -405,7 +462,7 @@ impl AutoUpdater {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, String> {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
let notifications = updater
|
||||
.check_for_updates()
|
||||
.await
|
||||
@@ -414,27 +471,9 @@ pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, Stri
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_browser_update(browser: String, new_version: String) -> Result<(), String> {
|
||||
let updater = AutoUpdater::new();
|
||||
updater
|
||||
.start_browser_update(&browser, &new_version)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start browser update: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn complete_browser_update(browser: String) -> Result<(), String> {
|
||||
let updater = AutoUpdater::new();
|
||||
updater
|
||||
.complete_browser_update(&browser)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to complete browser update: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
updater
|
||||
.is_browser_disabled(&browser)
|
||||
.map_err(|e| format!("Failed to check browser status: {e}"))
|
||||
@@ -442,7 +481,7 @@ pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, Str
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dismiss_update_notification(notification_id: String) -> Result<(), String> {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
updater
|
||||
.dismiss_update_notification(¬ification_id)
|
||||
.map_err(|e| format!("Failed to dismiss notification: {e}"))
|
||||
@@ -453,7 +492,7 @@ pub async fn complete_browser_update_with_auto_update(
|
||||
browser: String,
|
||||
new_version: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
updater
|
||||
.complete_browser_update_with_auto_update(&browser, &new_version)
|
||||
.await
|
||||
@@ -461,27 +500,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> {
|
||||
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}"))
|
||||
pub async fn check_for_updates_with_progress(app_handle: tauri::AppHandle) {
|
||||
let updater = AutoUpdater::instance();
|
||||
updater.check_for_updates_with_progress(&app_handle).await;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -490,13 +511,16 @@ 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,
|
||||
group_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -508,24 +532,9 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_alpha_version() {
|
||||
let updater = AutoUpdater::new();
|
||||
|
||||
assert!(updater.is_alpha_version("1.0.0-alpha"));
|
||||
assert!(updater.is_alpha_version("1.0.0-beta"));
|
||||
assert!(updater.is_alpha_version("1.0.0-rc"));
|
||||
assert!(updater.is_alpha_version("1.0.0a1"));
|
||||
assert!(updater.is_alpha_version("1.0.0b1"));
|
||||
assert!(updater.is_alpha_version("1.0.0-dev"));
|
||||
|
||||
assert!(!updater.is_alpha_version("1.0.0"));
|
||||
assert!(!updater.is_alpha_version("1.2.3"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_compare_versions() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
|
||||
assert_eq!(
|
||||
updater.compare_versions("1.0.0", "1.0.0"),
|
||||
@@ -551,7 +560,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_is_version_newer() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
|
||||
assert!(updater.is_version_newer("1.0.1", "1.0.0"));
|
||||
assert!(updater.is_version_newer("2.0.0", "1.9.9"));
|
||||
@@ -559,9 +568,71 @@ mod tests {
|
||||
assert!(!updater.is_version_newer("1.0.0", "1.0.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_camoufox_beta_version_comparison() {
|
||||
let updater = AutoUpdater::instance();
|
||||
|
||||
// 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::instance();
|
||||
|
||||
// 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();
|
||||
let updater = AutoUpdater::instance();
|
||||
let profile = create_test_profile("test", "firefox", "1.0.0");
|
||||
let versions = vec![
|
||||
create_test_version_info("1.0.1", false), // stable, newer
|
||||
@@ -579,7 +650,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_check_profile_update_alpha_to_alpha() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
let profile = create_test_profile("test", "firefox", "1.0.0-alpha");
|
||||
let versions = vec![
|
||||
create_test_version_info("1.0.1", false), // stable, should be included
|
||||
@@ -598,7 +669,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_check_profile_update_no_update_available() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
let profile = create_test_profile("test", "firefox", "1.0.0");
|
||||
let versions = vec![
|
||||
create_test_version_info("0.9.0", false), // older
|
||||
@@ -611,7 +682,7 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_group_update_notifications() {
|
||||
let updater = AutoUpdater::new();
|
||||
let updater = AutoUpdater::instance();
|
||||
let notifications = vec![
|
||||
UpdateNotification {
|
||||
id: "firefox_1.0.0_to_1.1.0_profile1".to_string(),
|
||||
@@ -843,15 +914,9 @@ 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]);
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref AUTO_UPDATER: AutoUpdater = AutoUpdater::new();
|
||||
}
|
||||
|
||||
+695
-185
File diff suppressed because it is too large
Load Diff
+1121
-1771
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
use crate::profile::BrowserProfile;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CamoufoxConfig {
|
||||
pub os: Option<Vec<String>>,
|
||||
pub block_images: Option<bool>,
|
||||
pub block_webrtc: Option<bool>,
|
||||
pub block_webgl: Option<bool>,
|
||||
pub disable_coop: Option<bool>,
|
||||
pub geoip: Option<serde_json::Value>, // Can be String or bool
|
||||
pub country: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub latitude: Option<f64>,
|
||||
pub longitude: Option<f64>,
|
||||
pub humanize: Option<bool>,
|
||||
pub humanize_duration: Option<f64>,
|
||||
pub headless: Option<bool>,
|
||||
pub locale: Option<Vec<String>>,
|
||||
pub addons: Option<Vec<String>>,
|
||||
pub fonts: Option<Vec<String>>,
|
||||
pub custom_fonts_only: Option<bool>,
|
||||
pub exclude_addons: Option<Vec<String>>,
|
||||
pub screen_min_width: Option<u32>,
|
||||
pub screen_max_width: Option<u32>,
|
||||
pub screen_min_height: Option<u32>,
|
||||
pub screen_max_height: Option<u32>,
|
||||
pub window_width: Option<u32>,
|
||||
pub window_height: Option<u32>,
|
||||
pub ff_version: Option<u32>,
|
||||
pub main_world_eval: Option<bool>,
|
||||
pub webgl_vendor: Option<String>,
|
||||
pub webgl_renderer: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub enable_cache: Option<bool>,
|
||||
pub virtual_display: Option<String>,
|
||||
pub debug: Option<bool>,
|
||||
pub additional_args: Option<Vec<String>>,
|
||||
pub env_vars: Option<HashMap<String, String>>,
|
||||
pub firefox_prefs: Option<HashMap<String, serde_json::Value>>,
|
||||
pub disable_theming: Option<bool>,
|
||||
pub showcursor: Option<bool>,
|
||||
}
|
||||
|
||||
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,
|
||||
disable_theming: Some(true),
|
||||
showcursor: Some(false),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
pub struct CamoufoxLaunchResult {
|
||||
pub id: String,
|
||||
#[serde(alias = "process_id")]
|
||||
pub processId: Option<u32>,
|
||||
#[serde(alias = "profile_path")]
|
||||
pub profilePath: Option<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct CamoufoxInstance {
|
||||
#[allow(dead_code)]
|
||||
id: String,
|
||||
process_id: Option<u32>,
|
||||
profile_path: Option<String>,
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
struct CamoufoxNodecarLauncherInner {
|
||||
instances: HashMap<String, CamoufoxInstance>,
|
||||
}
|
||||
|
||||
pub struct CamoufoxNodecarLauncher {
|
||||
inner: Arc<AsyncMutex<CamoufoxNodecarLauncherInner>>,
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref GLOBAL_NODECAR_LAUNCHER: CamoufoxNodecarLauncher = CamoufoxNodecarLauncher::new_singleton();
|
||||
}
|
||||
|
||||
impl CamoufoxNodecarLauncher {
|
||||
pub fn new(_app_handle: AppHandle) -> Self {
|
||||
// Return a reference to the global singleton
|
||||
GLOBAL_NODECAR_LAUNCHER.clone()
|
||||
}
|
||||
|
||||
pub fn new_singleton() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(AsyncMutex::new(CamoufoxNodecarLauncherInner {
|
||||
instances: HashMap::new(),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
fn clone(&self) -> Self {
|
||||
Self {
|
||||
inner: Arc::clone(&self.inner),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a test configuration to verify anti-fingerprinting is working
|
||||
pub fn create_test_config() -> CamoufoxConfig {
|
||||
CamoufoxConfig {
|
||||
// Core anti-fingerprinting settings
|
||||
timezone: Some("Europe/London".to_string()),
|
||||
screen_min_width: Some(1440),
|
||||
screen_min_height: Some(900),
|
||||
window_width: Some(1200),
|
||||
window_height: Some(800),
|
||||
|
||||
// Locale settings
|
||||
locale: Some(vec!["en-GB".to_string(), "en-US".to_string()]),
|
||||
|
||||
// WebGL spoofing
|
||||
webgl_vendor: Some("Intel Inc.".to_string()),
|
||||
webgl_renderer: Some("Intel Iris Pro OpenGL Engine".to_string()),
|
||||
|
||||
// Geolocation spoofing (London coordinates)
|
||||
latitude: Some(51.5074),
|
||||
longitude: Some(-0.1278),
|
||||
|
||||
// Font settings
|
||||
fonts: Some(vec![
|
||||
"Arial".to_string(),
|
||||
"Times New Roman".to_string(),
|
||||
"Helvetica".to_string(),
|
||||
"Georgia".to_string(),
|
||||
]),
|
||||
custom_fonts_only: Some(true),
|
||||
|
||||
// Humanization
|
||||
humanize: Some(true),
|
||||
humanize_duration: Some(2.0),
|
||||
|
||||
// Blocking features
|
||||
block_images: Some(false), // Don't block images for testing
|
||||
block_webrtc: Some(true),
|
||||
block_webgl: Some(false), // Don't block WebGL so we can test spoofing
|
||||
|
||||
// Other settings
|
||||
debug: Some(true),
|
||||
enable_cache: Some(true),
|
||||
headless: Some(false), // Not headless for testing
|
||||
disable_theming: Some(true),
|
||||
showcursor: Some(false),
|
||||
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the nodecar sidecar command
|
||||
fn get_nodecar_sidecar(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
) -> Result<tauri_plugin_shell::process::Command, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let shell = app_handle.shell();
|
||||
let sidecar_command = shell
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?;
|
||||
Ok(sidecar_command)
|
||||
}
|
||||
|
||||
/// Launch Camoufox browser using nodecar sidecar
|
||||
pub async fn launch_camoufox(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
profile_path: &str,
|
||||
config: &CamoufoxConfig,
|
||||
url: Option<&str>,
|
||||
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Build nodecar command arguments
|
||||
let mut args = vec!["camoufox".to_string(), "start".to_string()];
|
||||
|
||||
// Add profile path
|
||||
args.extend(["--profile-path".to_string(), profile_path.to_string()]);
|
||||
|
||||
// Add URL if provided
|
||||
if let Some(url) = url {
|
||||
args.extend(["--url".to_string(), url.to_string()]);
|
||||
}
|
||||
|
||||
// Add configuration options
|
||||
if let Some(os_list) = &config.os {
|
||||
let os_str = os_list.join(",");
|
||||
args.extend(["--os".to_string(), os_str]);
|
||||
}
|
||||
|
||||
if let Some(block_images) = config.block_images {
|
||||
if block_images {
|
||||
args.push("--block-images".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(block_webrtc) = config.block_webrtc {
|
||||
if block_webrtc {
|
||||
args.push("--block-webrtc".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(block_webgl) = config.block_webgl {
|
||||
if block_webgl {
|
||||
args.push("--block-webgl".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(disable_coop) = config.disable_coop {
|
||||
if disable_coop {
|
||||
args.push("--disable-coop".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(geoip) = &config.geoip {
|
||||
match geoip {
|
||||
serde_json::Value::Bool(true) => {
|
||||
args.extend(["--geoip".to_string(), "auto".to_string()]);
|
||||
}
|
||||
serde_json::Value::String(ip) => {
|
||||
args.extend(["--geoip".to_string(), ip.clone()]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(country) = &config.country {
|
||||
args.extend(["--country".to_string(), country.clone()]);
|
||||
}
|
||||
|
||||
if let Some(timezone) = &config.timezone {
|
||||
args.extend(["--timezone".to_string(), timezone.clone()]);
|
||||
}
|
||||
|
||||
if let Some(latitude) = config.latitude {
|
||||
args.extend(["--latitude".to_string(), latitude.to_string()]);
|
||||
}
|
||||
|
||||
if let Some(longitude) = config.longitude {
|
||||
args.extend(["--longitude".to_string(), longitude.to_string()]);
|
||||
}
|
||||
|
||||
if let Some(humanize) = config.humanize {
|
||||
if humanize {
|
||||
if let Some(duration) = config.humanize_duration {
|
||||
args.extend(["--humanize".to_string(), duration.to_string()]);
|
||||
} else {
|
||||
args.push("--humanize".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(headless) = config.headless {
|
||||
if headless {
|
||||
args.push("--headless".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(locale_list) = &config.locale {
|
||||
let locale_str = locale_list.join(",");
|
||||
args.extend(["--locale".to_string(), locale_str]);
|
||||
}
|
||||
|
||||
if let Some(addons) = &config.addons {
|
||||
let addons_str = addons.join(",");
|
||||
args.extend(["--addons".to_string(), addons_str]);
|
||||
}
|
||||
|
||||
if let Some(fonts) = &config.fonts {
|
||||
let fonts_str = fonts.join(",");
|
||||
args.extend(["--fonts".to_string(), fonts_str]);
|
||||
}
|
||||
|
||||
if let Some(custom_fonts_only) = config.custom_fonts_only {
|
||||
if custom_fonts_only {
|
||||
args.push("--custom-fonts-only".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(exclude_addons) = &config.exclude_addons {
|
||||
let exclude_str = exclude_addons.join(",");
|
||||
args.extend(["--exclude-addons".to_string(), exclude_str]);
|
||||
}
|
||||
|
||||
if let Some(screen_min_width) = config.screen_min_width {
|
||||
args.extend([
|
||||
"--screen-min-width".to_string(),
|
||||
screen_min_width.to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
if let Some(screen_max_width) = config.screen_max_width {
|
||||
args.extend([
|
||||
"--screen-max-width".to_string(),
|
||||
screen_max_width.to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
if let Some(screen_min_height) = config.screen_min_height {
|
||||
args.extend([
|
||||
"--screen-min-height".to_string(),
|
||||
screen_min_height.to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
if let Some(screen_max_height) = config.screen_max_height {
|
||||
args.extend([
|
||||
"--screen-max-height".to_string(),
|
||||
screen_max_height.to_string(),
|
||||
]);
|
||||
}
|
||||
|
||||
if let Some(window_width) = config.window_width {
|
||||
args.extend(["--window-width".to_string(), window_width.to_string()]);
|
||||
}
|
||||
|
||||
if let Some(window_height) = config.window_height {
|
||||
args.extend(["--window-height".to_string(), window_height.to_string()]);
|
||||
}
|
||||
|
||||
if let Some(ff_version) = config.ff_version {
|
||||
args.extend(["--ff-version".to_string(), ff_version.to_string()]);
|
||||
}
|
||||
|
||||
if let Some(main_world_eval) = config.main_world_eval {
|
||||
if main_world_eval {
|
||||
args.push("--main-world-eval".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(webgl_vendor) = &config.webgl_vendor {
|
||||
args.extend(["--webgl-vendor".to_string(), webgl_vendor.clone()]);
|
||||
}
|
||||
|
||||
if let Some(webgl_renderer) = &config.webgl_renderer {
|
||||
args.extend(["--webgl-renderer".to_string(), webgl_renderer.clone()]);
|
||||
}
|
||||
|
||||
if let Some(proxy) = &config.proxy {
|
||||
args.extend(["--proxy".to_string(), proxy.clone()]);
|
||||
}
|
||||
|
||||
if let Some(enable_cache) = config.enable_cache {
|
||||
if !enable_cache {
|
||||
args.push("--disable-cache".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(virtual_display) = &config.virtual_display {
|
||||
args.extend(["--virtual-display".to_string(), virtual_display.clone()]);
|
||||
}
|
||||
|
||||
if let Some(debug) = config.debug {
|
||||
if debug {
|
||||
args.push("--debug".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(additional_args) = &config.additional_args {
|
||||
let args_str = additional_args.join(",");
|
||||
args.extend(["--args".to_string(), args_str]);
|
||||
}
|
||||
|
||||
if let Some(env_vars) = &config.env_vars {
|
||||
let env_json = serde_json::to_string(env_vars)?;
|
||||
args.extend(["--env".to_string(), env_json]);
|
||||
}
|
||||
|
||||
if let Some(firefox_prefs) = &config.firefox_prefs {
|
||||
let prefs_json = serde_json::to_string(firefox_prefs)?;
|
||||
args.extend(["--firefox-prefs".to_string(), prefs_json]);
|
||||
}
|
||||
|
||||
if let Some(disable_theming) = config.disable_theming {
|
||||
if disable_theming {
|
||||
args.push("--disable-theming".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(showcursor) = config.showcursor {
|
||||
if showcursor {
|
||||
args.push("--showcursor".to_string());
|
||||
} else {
|
||||
args.push("--no-showcursor".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Get the nodecar sidecar command
|
||||
let mut sidecar_command = self.get_nodecar_sidecar(app_handle)?;
|
||||
|
||||
// Add all arguments to the sidecar command
|
||||
for arg in &args {
|
||||
sidecar_command = sidecar_command.arg(arg);
|
||||
}
|
||||
|
||||
// Execute nodecar sidecar command
|
||||
println!("Executing nodecar command with args: {args:?}");
|
||||
let output = sidecar_command.output().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("nodecar camoufox failed - stdout: {stdout}, stderr: {stderr}");
|
||||
return Err(format!("nodecar camoufox failed: {stderr}").into());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("nodecar camoufox output: {stdout}");
|
||||
|
||||
// Parse the JSON output
|
||||
let launch_result: CamoufoxLaunchResult = serde_json::from_str(&stdout)
|
||||
.map_err(|e| format!("Failed to parse nodecar output as JSON: {e}\nOutput was: {stdout}"))?;
|
||||
|
||||
// Store the instance
|
||||
let instance = CamoufoxInstance {
|
||||
id: launch_result.id.clone(),
|
||||
process_id: launch_result.processId,
|
||||
profile_path: launch_result.profilePath.clone(),
|
||||
url: launch_result.url.clone(),
|
||||
};
|
||||
|
||||
{
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.instances.insert(launch_result.id.clone(), instance);
|
||||
}
|
||||
|
||||
Ok(launch_result)
|
||||
}
|
||||
|
||||
/// Stop a Camoufox process by ID
|
||||
pub async fn stop_camoufox(
|
||||
&self,
|
||||
app_handle: &AppHandle,
|
||||
id: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get the nodecar sidecar command
|
||||
let sidecar_command = self
|
||||
.get_nodecar_sidecar(app_handle)?
|
||||
.arg("camoufox")
|
||||
.arg("stop")
|
||||
.arg("--id")
|
||||
.arg(id);
|
||||
|
||||
// Execute nodecar stop command
|
||||
let output = sidecar_command.output().await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let _stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let result: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.map_err(|e| format!("Failed to parse nodecar stop output: {e}"))?;
|
||||
|
||||
let success = result
|
||||
.get("success")
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
if success {
|
||||
// Remove from our tracking
|
||||
let mut inner = self.inner.lock().await;
|
||||
inner.instances.remove(id);
|
||||
}
|
||||
|
||||
Ok(success)
|
||||
}
|
||||
|
||||
/// Find Camoufox server by profile path (for integration with browser_runner)
|
||||
pub async fn find_camoufox_by_profile(
|
||||
&self,
|
||||
profile_path: &str,
|
||||
) -> Result<Option<CamoufoxLaunchResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// First clean up any dead instances
|
||||
self.cleanup_dead_instances().await?;
|
||||
|
||||
let inner = self.inner.lock().await;
|
||||
|
||||
// Convert paths to canonical form for comparison
|
||||
let target_path = std::path::Path::new(profile_path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
||||
|
||||
for (id, instance) in inner.instances.iter() {
|
||||
if let Some(instance_profile_path) = &instance.profile_path {
|
||||
let instance_path = std::path::Path::new(instance_profile_path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(instance_profile_path).to_path_buf());
|
||||
|
||||
if instance_path == target_path {
|
||||
// Verify the server is actually running by checking the process
|
||||
if let Some(process_id) = instance.process_id {
|
||||
if self.is_server_running(process_id).await {
|
||||
println!("Found running Camoufox instance for profile: {profile_path}");
|
||||
return Ok(Some(CamoufoxLaunchResult {
|
||||
id: id.clone(),
|
||||
processId: instance.process_id,
|
||||
profilePath: instance.profile_path.clone(),
|
||||
url: instance.url.clone(),
|
||||
}));
|
||||
} else {
|
||||
println!("Camoufox instance found but process is not running: {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
println!("No running Camoufox instance found for profile: {profile_path}");
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Check if servers are still alive and clean up dead instances
|
||||
pub async fn cleanup_dead_instances(
|
||||
&self,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut dead_instances = Vec::new();
|
||||
let mut instances_to_remove = Vec::new();
|
||||
|
||||
{
|
||||
let inner = self.inner.lock().await;
|
||||
|
||||
for (id, instance) in inner.instances.iter() {
|
||||
if let Some(process_id) = instance.process_id {
|
||||
// Check if the process is still alive
|
||||
if !self.is_server_running(process_id).await {
|
||||
// Process is dead
|
||||
println!("Camoufox instance {id} (PID: {process_id}) is no longer running");
|
||||
dead_instances.push(id.clone());
|
||||
instances_to_remove.push(id.clone());
|
||||
}
|
||||
} else {
|
||||
// No process_id means it's likely a dead instance
|
||||
println!("Camoufox instance {id} has no PID, marking as dead");
|
||||
dead_instances.push(id.clone());
|
||||
instances_to_remove.push(id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove dead instances
|
||||
if !instances_to_remove.is_empty() {
|
||||
let mut inner = self.inner.lock().await;
|
||||
for id in &instances_to_remove {
|
||||
inner.instances.remove(id);
|
||||
println!("Removed dead Camoufox instance: {id}");
|
||||
}
|
||||
}
|
||||
|
||||
Ok(dead_instances)
|
||||
}
|
||||
|
||||
/// Check if a Camoufox server is running with the given process ID
|
||||
async fn is_server_running(&self, process_id: u32) -> bool {
|
||||
// Check if the process is still running
|
||||
use sysinfo::{Pid, System};
|
||||
|
||||
let system = System::new_all();
|
||||
if let Some(process) = system.process(Pid::from(process_id as usize)) {
|
||||
// Check if this is actually a Camoufox process by looking at the command line
|
||||
let cmd = process.cmd();
|
||||
let is_camoufox = cmd.iter().any(|arg| {
|
||||
let arg_str = arg.to_str().unwrap_or("");
|
||||
arg_str.contains("camoufox-worker") || arg_str.contains("camoufox")
|
||||
});
|
||||
|
||||
if is_camoufox {
|
||||
println!("Found running Camoufox process with PID: {process_id}");
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn launch_camoufox_profile_nodecar(
|
||||
app_handle: AppHandle,
|
||||
profile: BrowserProfile,
|
||||
config: CamoufoxConfig,
|
||||
url: Option<String>,
|
||||
) -> Result<CamoufoxLaunchResult, String> {
|
||||
let launcher = CamoufoxNodecarLauncher::new(app_handle.clone());
|
||||
|
||||
// Get profile path
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::new();
|
||||
let profiles_dir = browser_runner.get_profiles_dir();
|
||||
let profile_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_path_str = profile_path.to_string_lossy();
|
||||
|
||||
// Check if there's already a running instance for this profile
|
||||
if let Ok(Some(existing)) = launcher.find_camoufox_by_profile(&profile_path_str).await {
|
||||
// If there's an existing instance, stop it first to avoid conflicts
|
||||
let _ = launcher.stop_camoufox(&app_handle, &existing.id).await;
|
||||
}
|
||||
|
||||
// Clean up any dead instances before launching
|
||||
let _ = launcher.cleanup_dead_instances().await;
|
||||
|
||||
launcher
|
||||
.launch_camoufox(&app_handle, &profile_path_str, &config, url.as_deref())
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch Camoufox via nodecar: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_camoufox_config_creation() {
|
||||
let test_config = CamoufoxNodecarLauncher::create_test_config();
|
||||
|
||||
// Verify test config has expected values
|
||||
assert_eq!(test_config.timezone, Some("Europe/London".to_string()));
|
||||
assert_eq!(test_config.screen_min_width, Some(1440));
|
||||
assert_eq!(test_config.screen_min_height, Some(900));
|
||||
assert_eq!(test_config.window_width, Some(1200));
|
||||
assert_eq!(test_config.window_height, Some(800));
|
||||
assert_eq!(test_config.webgl_vendor, Some("Intel Inc.".to_string()));
|
||||
assert_eq!(
|
||||
test_config.webgl_renderer,
|
||||
Some("Intel Iris Pro OpenGL Engine".to_string())
|
||||
);
|
||||
assert_eq!(test_config.latitude, Some(51.5074));
|
||||
assert_eq!(test_config.longitude, Some(-0.1278));
|
||||
assert_eq!(test_config.humanize, Some(true));
|
||||
assert_eq!(test_config.debug, Some(true));
|
||||
assert_eq!(test_config.enable_cache, Some(true));
|
||||
assert_eq!(test_config.headless, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let default_config = CamoufoxConfig::default();
|
||||
|
||||
// Verify defaults
|
||||
assert_eq!(default_config.enable_cache, Some(true));
|
||||
assert_eq!(default_config.timezone, None);
|
||||
assert_eq!(default_config.debug, None);
|
||||
assert_eq!(default_config.headless, None);
|
||||
}
|
||||
}
|
||||
@@ -65,25 +65,420 @@ 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use std::process::Command;
|
||||
|
||||
const APP_DESKTOP_NAME: &str = "donutbrowser.desktop";
|
||||
|
||||
pub fn is_default_browser() -> Result<bool, String> {
|
||||
// Linux implementation would go here
|
||||
Err("Linux support not implemented yet".to_string())
|
||||
// Check if xdg-mime is available
|
||||
if !is_xdg_mime_available() {
|
||||
return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string());
|
||||
}
|
||||
|
||||
let schemes = ["http", "https"];
|
||||
|
||||
for scheme in schemes {
|
||||
let mime_type = format!("x-scheme-handler/{}", scheme);
|
||||
|
||||
// Query the current default handler for this scheme
|
||||
let output = Command::new("xdg-mime")
|
||||
.args(["query", "default", &mime_type])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to query default handler for {}: {}", scheme, e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("xdg-mime query failed for {}: {}", scheme, stderr));
|
||||
}
|
||||
|
||||
let current_handler = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
|
||||
// Check if our app is the default handler
|
||||
if current_handler != APP_DESKTOP_NAME {
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
pub fn set_as_default_browser() -> Result<(), String> {
|
||||
Err("Linux support not implemented yet".to_string())
|
||||
// Check if xdg-mime is available
|
||||
if !is_xdg_mime_available() {
|
||||
return Err("xdg-mime utility not found. Please install xdg-utils package.".to_string());
|
||||
}
|
||||
|
||||
// Check if the desktop file exists in common locations
|
||||
if !check_desktop_file_exists() {
|
||||
return Err(format!(
|
||||
"Desktop file '{}' not found in standard locations. Please ensure the application is properly installed. You can manually set Donut Browser as the default browser in your system settings.",
|
||||
APP_DESKTOP_NAME
|
||||
));
|
||||
}
|
||||
|
||||
let schemes = ["http", "https"];
|
||||
let mut all_succeeded = true;
|
||||
let mut error_messages = Vec::new();
|
||||
|
||||
for scheme in schemes {
|
||||
let mime_type = format!("x-scheme-handler/{}", scheme);
|
||||
|
||||
// Set our app as the default handler for this scheme
|
||||
let output = Command::new("xdg-mime")
|
||||
.args(["default", APP_DESKTOP_NAME, &mime_type])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to set default handler for {}: {}", scheme, e))?;
|
||||
|
||||
if !output.status.success() {
|
||||
all_succeeded = false;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
error_messages.push(format!("Failed to set default for {}: {}", scheme, stderr));
|
||||
}
|
||||
}
|
||||
|
||||
if !all_succeeded {
|
||||
return Err(format!(
|
||||
"Some xdg-mime commands failed:\n{}\n\nYou may need to:\n1. Run with appropriate permissions\n2. Manually set the default browser in your desktop environment settings\n3. Restart your desktop session",
|
||||
error_messages.join("\n")
|
||||
));
|
||||
}
|
||||
|
||||
// Give the system a moment to process the changes
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
|
||||
// Verify the changes took effect
|
||||
match is_default_browser() {
|
||||
Ok(true) => Ok(()),
|
||||
Ok(false) => {
|
||||
// This is the common case where commands succeed but verification fails
|
||||
Err(format!(
|
||||
"The xdg-mime commands completed successfully, but Donut Browser is not yet set as the default. This is common on some Linux distributions. Please try one of these options:\n\n1. Restart your desktop session and try again\n2. Log out and log back in\n3. Manually set Donut Browser as the default in your system settings:\n - GNOME: Settings > Default Applications > Web\n - KDE: System Settings > Applications > Default Applications > Web Browser\n - XFCE: Settings > Preferred Applications > Web Browser\n - Or run: xdg-settings set default-web-browser {}\n\nThe changes may take effect automatically after a desktop restart.",
|
||||
APP_DESKTOP_NAME
|
||||
))
|
||||
}
|
||||
Err(e) => Err(format!(
|
||||
"Set as default completed, but verification failed: {}. The changes may still be in effect after restarting your desktop session.",
|
||||
e
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_xdg_mime_available() -> bool {
|
||||
Command::new("which")
|
||||
.arg("xdg-mime")
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn check_desktop_file_exists() -> bool {
|
||||
let desktop_locations = [
|
||||
"~/.local/share/applications/",
|
||||
"/usr/share/applications/",
|
||||
"/usr/local/share/applications/",
|
||||
"/var/lib/flatpak/exports/share/applications/",
|
||||
"~/.local/share/flatpak/exports/share/applications/",
|
||||
];
|
||||
|
||||
for location in &desktop_locations {
|
||||
let path = if location.starts_with('~') {
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
location.replace('~', &home)
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
location.to_string()
|
||||
};
|
||||
|
||||
let full_path = format!("{}{}", path, APP_DESKTOP_NAME);
|
||||
if std::path::Path::new(&full_path).exists() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -140,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}");
|
||||
@@ -150,31 +545,3 @@ pub async fn open_url_with_profile(
|
||||
println!("Successfully opened URL '{url}' with profile '{profile_name}'");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn smart_open_url(
|
||||
_app_handle: tauri::AppHandle,
|
||||
_url: String,
|
||||
_is_startup: Option<bool>,
|
||||
) -> Result<String, String> {
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
|
||||
let runner = BrowserRunner::new();
|
||||
|
||||
// Get all profiles
|
||||
let profiles = runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
if profiles.is_empty() {
|
||||
return Err("no_profiles".to_string());
|
||||
}
|
||||
|
||||
println!(
|
||||
"URL opening - Total profiles: {}, showing profile selector",
|
||||
profiles.len()
|
||||
);
|
||||
|
||||
// Always show the profile selector so the user can choose
|
||||
Err("show_selector".to_string())
|
||||
}
|
||||
|
||||
+298
-378
@@ -23,22 +23,22 @@ pub struct DownloadProgress {
|
||||
|
||||
pub struct Downloader {
|
||||
client: Client,
|
||||
api_client: ApiClient,
|
||||
api_client: &'static ApiClient,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_client: ApiClient::new(),
|
||||
api_client: ApiClient::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub fn new_with_api_client(api_client: ApiClient) -> Self {
|
||||
pub fn new_with_api_client(_api_client: ApiClient) -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_client,
|
||||
api_client: ApiClient::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ impl Downloader {
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
match browser_type {
|
||||
BrowserType::Brave => {
|
||||
// For Brave, we need to find the actual macOS asset
|
||||
// For Brave, we need to find the actual platform-specific asset
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_brave_releases_with_caching(true)
|
||||
@@ -65,39 +65,63 @@ impl Downloader {
|
||||
})
|
||||
.ok_or(format!("Brave version {version} not found"))?;
|
||||
|
||||
// Find the universal macOS DMG asset
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("universal"))
|
||||
// Get platform and architecture info
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
|
||||
// Find the appropriate asset based on platform and architecture
|
||||
let asset_url = self
|
||||
.find_brave_asset(&release.assets, &os, &arch)
|
||||
.ok_or(format!(
|
||||
"No universal macOS DMG asset found for Brave version {version}"
|
||||
"No compatible asset found for Brave version {version} on {os}/{arch}"
|
||||
))?;
|
||||
|
||||
Ok(asset.browser_download_url.clone())
|
||||
Ok(asset_url)
|
||||
}
|
||||
BrowserType::Zen => {
|
||||
// For Zen, verify the asset exists
|
||||
let releases = self
|
||||
.api_client
|
||||
.fetch_zen_releases_with_caching(true)
|
||||
.await?;
|
||||
// For Zen, verify the asset exists and handle different naming patterns
|
||||
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(", ")
|
||||
)
|
||||
})?;
|
||||
|
||||
// Find the macOS universal DMG asset
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.macos-universal.dmg")
|
||||
.ok_or(format!(
|
||||
"No macOS universal asset found for Zen version {version}"
|
||||
))?;
|
||||
// Get platform and architecture info
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
|
||||
Ok(asset.browser_download_url.clone())
|
||||
// Find the appropriate asset
|
||||
let asset_url = self
|
||||
.find_zen_asset(&release.assets, &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)
|
||||
}
|
||||
BrowserType::MullvadBrowser => {
|
||||
// For Mullvad, verify the asset exists
|
||||
@@ -111,16 +135,41 @@ impl Downloader {
|
||||
.find(|r| r.tag_name == version)
|
||||
.ok_or(format!("Mullvad version {version} not found"))?;
|
||||
|
||||
// Find the macOS DMG asset
|
||||
let asset = release
|
||||
.assets
|
||||
.iter()
|
||||
.find(|asset| asset.name.contains(".dmg") && asset.name.contains("mac"))
|
||||
// Get platform and architecture info
|
||||
let (os, arch) = Self::get_platform_info();
|
||||
|
||||
// Find the appropriate asset
|
||||
let asset_url = self
|
||||
.find_mullvad_asset(&release.assets, &os, &arch)
|
||||
.ok_or(format!(
|
||||
"No macOS asset found for Mullvad version {version}"
|
||||
"No compatible asset found for Mullvad version {version} on {os}/{arch}"
|
||||
))?;
|
||||
|
||||
Ok(asset.browser_download_url.clone())
|
||||
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
|
||||
@@ -129,6 +178,202 @@ impl Downloader {
|
||||
}
|
||||
}
|
||||
|
||||
/// Get platform and architecture information
|
||||
fn get_platform_info() -> (String, String) {
|
||||
let os = if cfg!(target_os = "windows") {
|
||||
"windows"
|
||||
} else if cfg!(target_os = "linux") {
|
||||
"linux"
|
||||
} else if cfg!(target_os = "macos") {
|
||||
"macos"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
let arch = if cfg!(target_arch = "x86_64") {
|
||||
"x64"
|
||||
} else if cfg!(target_arch = "aarch64") {
|
||||
"arm64"
|
||||
} else {
|
||||
"unknown"
|
||||
};
|
||||
|
||||
(os.to_string(), arch.to_string())
|
||||
}
|
||||
|
||||
/// Find the appropriate Brave asset for the current platform and architecture
|
||||
fn find_brave_asset(
|
||||
&self,
|
||||
assets: &[crate::browser::GithubAsset],
|
||||
os: &str,
|
||||
arch: &str,
|
||||
) -> Option<String> {
|
||||
// Brave asset naming patterns:
|
||||
// Windows: BraveBrowserStandaloneNightlySetup.exe, BraveBrowserStandaloneSilentNightlySetup.exe
|
||||
// macOS: Brave-Browser-Nightly-universal.dmg, Brave-Browser-Nightly-universal.pkg
|
||||
// Linux: brave-browser-1.79.119-linux-arm64.zip, brave-browser-1.79.119-linux-amd64.zip
|
||||
|
||||
let asset = match os {
|
||||
"windows" => {
|
||||
// For Windows, look for standalone setup EXE (not the auto-updater one)
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("standalone") && name.ends_with(".exe") && !name.contains("silent")
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to any EXE if standalone not found
|
||||
assets.iter().find(|asset| asset.name.ends_with(".exe"))
|
||||
})
|
||||
}
|
||||
"macos" => {
|
||||
// For macOS, prefer universal DMG
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| {
|
||||
let name = asset.name.to_lowercase();
|
||||
name.contains("universal") && name.ends_with(".dmg")
|
||||
})
|
||||
.or_else(|| {
|
||||
// Fallback to any DMG
|
||||
assets.iter().find(|asset| asset.name.ends_with(".dmg"))
|
||||
})
|
||||
}
|
||||
"linux" => {
|
||||
// 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")
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
asset.map(|a| a.browser_download_url.clone())
|
||||
}
|
||||
|
||||
/// Find the appropriate Zen asset for the current platform and architecture
|
||||
fn find_zen_asset(
|
||||
&self,
|
||||
assets: &[crate::browser::GithubAsset],
|
||||
os: &str,
|
||||
arch: &str,
|
||||
) -> Option<String> {
|
||||
// Zen asset naming patterns:
|
||||
// Windows: zen.installer.exe, zen.installer-arm64.exe
|
||||
// macOS: zen.macos-universal.dmg
|
||||
// Linux: zen.linux-x86_64.tar.xz, zen.linux-aarch64.tar.xz, zen-x86_64.AppImage, zen-aarch64.AppImage
|
||||
|
||||
let asset = match (os, arch) {
|
||||
("windows", "x64") => assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.installer.exe"),
|
||||
("windows", "arm64") => assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.installer-arm64.exe"),
|
||||
("macos", _) => assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.macos-universal.dmg"),
|
||||
("linux", "x64") => {
|
||||
// Prefer tar.xz, fallback to AppImage
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.linux-x86_64.tar.xz")
|
||||
.or_else(|| {
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen-x86_64.AppImage")
|
||||
})
|
||||
}
|
||||
("linux", "arm64") => {
|
||||
// Prefer tar.xz, fallback to AppImage
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen.linux-aarch64.tar.xz")
|
||||
.or_else(|| {
|
||||
assets
|
||||
.iter()
|
||||
.find(|asset| asset.name == "zen-aarch64.AppImage")
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
asset.map(|a| a.browser_download_url.clone())
|
||||
}
|
||||
|
||||
/// Find the appropriate Mullvad asset for the current platform and architecture
|
||||
fn find_mullvad_asset(
|
||||
&self,
|
||||
assets: &[crate::browser::GithubAsset],
|
||||
os: &str,
|
||||
arch: &str,
|
||||
) -> Option<String> {
|
||||
// Mullvad asset naming patterns:
|
||||
// Windows: mullvad-browser-windows-x86_64-VERSION.exe
|
||||
// macOS: mullvad-browser-macos-VERSION.dmg
|
||||
// Linux: mullvad-browser-x86_64-VERSION.tar.xz
|
||||
|
||||
let asset = match (os, arch) {
|
||||
("windows", "x64") => assets.iter().find(|asset| {
|
||||
asset.name.contains("windows")
|
||||
&& asset.name.contains("x86_64")
|
||||
&& asset.name.ends_with(".exe")
|
||||
}),
|
||||
("windows", "arm64") => {
|
||||
// Mullvad doesn't support ARM64 on Windows
|
||||
None
|
||||
}
|
||||
("macos", _) => assets
|
||||
.iter()
|
||||
.find(|asset| asset.name.contains("macos") && asset.name.ends_with(".dmg")),
|
||||
("linux", "x64") => assets.iter().find(|asset| {
|
||||
asset.name.contains("x86_64")
|
||||
&& asset.name.ends_with(".tar.xz")
|
||||
&& !asset.name.contains("windows")
|
||||
}),
|
||||
("linux", "arm64") => {
|
||||
// Mullvad doesn't support ARM64 on Linux
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
|
||||
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>,
|
||||
@@ -144,6 +389,10 @@ impl Downloader {
|
||||
.resolve_download_url(browser_type.clone(), version, download_info)
|
||||
.await?;
|
||||
|
||||
// Check if this is a twilight release for special handling
|
||||
let is_twilight =
|
||||
browser_type == BrowserType::Zen && version.to_lowercase().contains("twilight");
|
||||
|
||||
// Emit initial progress
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_type.as_str().to_string(),
|
||||
@@ -153,7 +402,11 @@ impl Downloader {
|
||||
percentage: 0.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "downloading".to_string(),
|
||||
stage: if is_twilight {
|
||||
"downloading (twilight rolling release)".to_string()
|
||||
} else {
|
||||
"downloading".to_string()
|
||||
},
|
||||
};
|
||||
|
||||
let _ = app_handle.emit("download-progress", &progress);
|
||||
@@ -162,7 +415,7 @@ impl Downloader {
|
||||
let response = self
|
||||
.client
|
||||
.get(&download_url)
|
||||
.header("User-Agent", "donutbrowser")
|
||||
.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?;
|
||||
|
||||
@@ -205,6 +458,12 @@ impl Downloader {
|
||||
None
|
||||
};
|
||||
|
||||
let stage_description = if is_twilight {
|
||||
"downloading (twilight rolling release)".to_string()
|
||||
} else {
|
||||
"downloading".to_string()
|
||||
};
|
||||
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_type.as_str().to_string(),
|
||||
version: version.to_string(),
|
||||
@@ -213,7 +472,7 @@ impl Downloader {
|
||||
percentage,
|
||||
speed_bytes_per_sec: speed,
|
||||
eta_seconds: eta,
|
||||
stage: "downloading".to_string(),
|
||||
stage: stage_description,
|
||||
};
|
||||
|
||||
let _ = app_handle.emit("download-progress", &progress);
|
||||
@@ -233,7 +492,7 @@ mod tests {
|
||||
use crate::browser_version_service::DownloadInfo;
|
||||
|
||||
use tempfile::TempDir;
|
||||
use wiremock::matchers::{header, method, path};
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
async fn setup_mock_server() -> MockServer {
|
||||
@@ -248,154 +507,13 @@ 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
|
||||
)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_brave_download_url() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.9",
|
||||
"name": "Brave Release 1.81.9",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.9-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg"
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Brave, "v1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/brave-1.81.9-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_zen_download_url() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "1.11b",
|
||||
"name": "Zen Browser 1.11b",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "zen.macos-universal.dmg",
|
||||
"browser_download_url": "https://example.com/zen-1.11b-universal.dmg"
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/zen-browser/desktop/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "zen-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Zen, "1.11b", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/zen-1.11b-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_mullvad_download_url() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "14.5a6",
|
||||
"name": "Mullvad Browser 14.5a6",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-macos-14.5a6.dmg",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg"
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "mullvad-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::MullvadBrowser, "14.5a6", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/mullvad-14.5a6.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_firefox_download_url() {
|
||||
let server = setup_mock_server().await;
|
||||
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
@@ -456,104 +574,6 @@ mod tests {
|
||||
assert_eq!(url, download_info.url);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_brave_version_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.8",
|
||||
"name": "Brave Release 1.81.8",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.8-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.8-universal.dmg"
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Brave, "v1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Brave version v1.81.9 not found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_zen_asset_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "1.11b",
|
||||
"name": "Zen Browser 1.11b",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "zen.linux-universal.tar.bz2",
|
||||
"browser_download_url": "https://example.com/zen-1.11b-linux.tar.bz2"
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/zen-browser/desktop/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "zen-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Zen, "1.11b", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No macOS universal asset found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_with_progress() {
|
||||
let server = setup_mock_server().await;
|
||||
@@ -570,7 +590,6 @@ mod tests {
|
||||
// Mock the download endpoint
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/test-download"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(test_content)
|
||||
@@ -621,7 +640,6 @@ mod tests {
|
||||
// Mock a 404 response
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/missing-file"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(ResponseTemplate::new(404))
|
||||
.mount(&server)
|
||||
.await;
|
||||
@@ -648,103 +666,6 @@ mod tests {
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_resolve_mullvad_asset_not_found() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "14.5a6",
|
||||
"name": "Mullvad Browser 14.5a6",
|
||||
"prerelease": true,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "mullvad-browser-linux-14.5a6.tar.xz",
|
||||
"browser_download_url": "https://example.com/mullvad-14.5a6.tar.xz"
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/mullvad/mullvad-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "mullvad-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::MullvadBrowser, "14.5a6", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
assert!(result
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No macOS asset found"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_brave_version_with_v_prefix() {
|
||||
let server = setup_mock_server().await;
|
||||
let api_client = create_test_api_client(&server);
|
||||
let downloader = Downloader::new_with_api_client(api_client);
|
||||
|
||||
let mock_response = r#"[
|
||||
{
|
||||
"tag_name": "v1.81.9",
|
||||
"name": "Brave Release 1.81.9",
|
||||
"prerelease": false,
|
||||
"published_at": "2024-01-15T10:00:00Z",
|
||||
"assets": [
|
||||
{
|
||||
"name": "brave-v1.81.9-universal.dmg",
|
||||
"browser_download_url": "https://example.com/brave-1.81.9-universal.dmg"
|
||||
}
|
||||
]
|
||||
}
|
||||
]"#;
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/repos/brave/brave-browser/releases"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_string(mock_response)
|
||||
.insert_header("content-type", "application/json"),
|
||||
)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let download_info = DownloadInfo {
|
||||
url: "placeholder".to_string(),
|
||||
filename: "brave-test.dmg".to_string(),
|
||||
is_archive: true,
|
||||
};
|
||||
|
||||
// Test with version without v prefix
|
||||
let result = downloader
|
||||
.resolve_download_url(BrowserType::Brave, "1.81.9", &download_info)
|
||||
.await;
|
||||
|
||||
assert!(result.is_ok());
|
||||
let url = result.unwrap();
|
||||
assert_eq!(url, "https://example.com/brave-1.81.9-universal.dmg");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_download_browser_chunked_response() {
|
||||
let server = setup_mock_server().await;
|
||||
@@ -759,7 +680,6 @@ mod tests {
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path("/chunked-download"))
|
||||
.and(header("user-agent", "donutbrowser"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200)
|
||||
.set_body_bytes(test_content.clone())
|
||||
|
||||
@@ -8,10 +8,7 @@ use std::path::PathBuf;
|
||||
pub struct DownloadedBrowserInfo {
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
pub download_date: u64,
|
||||
pub file_path: PathBuf,
|
||||
pub verified: bool,
|
||||
pub actual_version: Option<String>, // For browsers like Chromium where we track the actual version
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
@@ -24,7 +21,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() {
|
||||
@@ -36,7 +33,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
|
||||
@@ -49,7 +46,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) {
|
||||
@@ -79,21 +76,14 @@ impl DownloadedBrowsersRegistry {
|
||||
.browsers
|
||||
.get(browser)
|
||||
.and_then(|versions| versions.get(version))
|
||||
.map(|info| info.verified)
|
||||
.unwrap_or(false)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn get_downloaded_versions(&self, browser: &str) -> Vec<String> {
|
||||
self
|
||||
.browsers
|
||||
.get(browser)
|
||||
.map(|versions| {
|
||||
versions
|
||||
.iter()
|
||||
.filter(|(_, info)| info.verified)
|
||||
.map(|(version, _)| version.clone())
|
||||
.collect()
|
||||
})
|
||||
.map(|versions| versions.keys().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -101,30 +91,18 @@ impl DownloadedBrowsersRegistry {
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: browser.to_string(),
|
||||
version: version.to_string(),
|
||||
download_date: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
file_path,
|
||||
verified: false,
|
||||
actual_version: None,
|
||||
};
|
||||
self.add_browser(info);
|
||||
}
|
||||
|
||||
pub fn mark_download_completed_with_actual_version(
|
||||
&mut self,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
actual_version: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(info) = self
|
||||
pub fn mark_download_completed(&mut self, browser: &str, version: &str) -> Result<(), String> {
|
||||
if self
|
||||
.browsers
|
||||
.get_mut(browser)
|
||||
.and_then(|versions| versions.get_mut(version))
|
||||
.get(browser)
|
||||
.and_then(|versions| versions.get(version))
|
||||
.is_some()
|
||||
{
|
||||
info.verified = true;
|
||||
info.actual_version = actual_version;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Browser {browser}:{version} not found in registry"))
|
||||
@@ -135,7 +113,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() {
|
||||
@@ -164,6 +142,250 @@ impl DownloadedBrowsersRegistry {
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find and remove unused browser binaries that are not referenced by any active profiles
|
||||
pub fn cleanup_unused_binaries(
|
||||
&mut self,
|
||||
active_profiles: &[(String, String)], // (browser, version) pairs
|
||||
running_profiles: &[(String, String)], // (browser, version) pairs for running profiles
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let active_set: std::collections::HashSet<(String, String)> =
|
||||
active_profiles.iter().cloned().collect();
|
||||
let running_set: std::collections::HashSet<(String, String)> =
|
||||
running_profiles.iter().cloned().collect();
|
||||
let mut cleaned_up = Vec::new();
|
||||
|
||||
// Collect all downloaded browsers that are not in active profiles
|
||||
let mut to_remove = Vec::new();
|
||||
for (browser, versions) in &self.browsers {
|
||||
for version in versions.keys() {
|
||||
let browser_version = (browser.clone(), version.clone());
|
||||
|
||||
// Don't remove if it's used by any active profile
|
||||
if active_set.contains(&browser_version) {
|
||||
println!("Keeping: {browser} {version} (in use by profile)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Don't remove if it's currently running (even if not in active profiles)
|
||||
if running_set.contains(&browser_version) {
|
||||
println!("Keeping: {browser} {version} (currently running)");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark for removal
|
||||
to_remove.push(browser_version);
|
||||
println!("Marking for removal: {browser} {version} (not used by any profile)");
|
||||
}
|
||||
}
|
||||
|
||||
// Remove unused binaries
|
||||
for (browser, version) in to_remove {
|
||||
if let Err(e) = self.cleanup_failed_download(&browser, &version) {
|
||||
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)
|
||||
}
|
||||
|
||||
/// Get all browsers and versions referenced by active profiles
|
||||
pub fn get_active_browser_versions(
|
||||
&self,
|
||||
profiles: &[crate::profile::BrowserProfile],
|
||||
) -> Vec<(String, String)> {
|
||||
profiles
|
||||
.iter()
|
||||
.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)
|
||||
}
|
||||
|
||||
/// Get all browsers and versions that are currently running
|
||||
pub fn get_running_browser_versions(
|
||||
&self,
|
||||
profiles: &[crate::profile::BrowserProfile],
|
||||
) -> Vec<(String, String)> {
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.process_id.is_some())
|
||||
.map(|profile| (profile.browser.clone(), profile.version.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Scan the binaries directory and sync with registry
|
||||
/// This ensures the registry reflects what's actually on disk
|
||||
pub fn sync_with_binaries_directory(
|
||||
&mut self,
|
||||
binaries_dir: &std::path::Path,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut changes = Vec::new();
|
||||
|
||||
if !binaries_dir.exists() {
|
||||
return Ok(changes);
|
||||
}
|
||||
|
||||
// Scan for actual browser directories
|
||||
for browser_entry in fs::read_dir(binaries_dir)? {
|
||||
let browser_entry = browser_entry?;
|
||||
let browser_path = browser_entry.path();
|
||||
|
||||
if !browser_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let browser_name = browser_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if browser_name.is_empty() || browser_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan for version directories within this browser
|
||||
for version_entry in fs::read_dir(&browser_path)? {
|
||||
let version_entry = version_entry?;
|
||||
let version_path = version_entry.path();
|
||||
|
||||
if !version_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let version_name = version_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if version_name.is_empty() || version_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this browser/version is already in registry
|
||||
if !self.is_browser_downloaded(browser_name, version_name) {
|
||||
// Add to registry
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: browser_name.to_string(),
|
||||
version: version_name.to_string(),
|
||||
file_path: version_path.clone(),
|
||||
};
|
||||
self.add_browser(info);
|
||||
changes.push(format!("Added {browser_name} {version_name} to registry"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changes.is_empty() {
|
||||
self.save()?;
|
||||
}
|
||||
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// Comprehensive cleanup that removes unused binaries and syncs registry
|
||||
pub fn comprehensive_cleanup(
|
||||
&mut self,
|
||||
binaries_dir: &std::path::Path,
|
||||
active_profiles: &[(String, String)],
|
||||
running_profiles: &[(String, String)],
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cleanup_results = Vec::new();
|
||||
|
||||
// First, sync registry with actual binaries on disk
|
||||
let sync_results = self.sync_with_binaries_directory(binaries_dir)?;
|
||||
cleanup_results.extend(sync_results);
|
||||
|
||||
// Then perform the regular cleanup
|
||||
let regular_cleanup = self.cleanup_unused_binaries(active_profiles, running_profiles)?;
|
||||
cleanup_results.extend(regular_cleanup);
|
||||
|
||||
// Finally, verify and cleanup stale entries
|
||||
let stale_cleanup = self.verify_and_cleanup_stale_entries_simple(binaries_dir)?;
|
||||
cleanup_results.extend(stale_cleanup);
|
||||
|
||||
if !cleanup_results.is_empty() {
|
||||
self.save()?;
|
||||
}
|
||||
|
||||
Ok(cleanup_results)
|
||||
}
|
||||
|
||||
/// Simplified version of verify_and_cleanup_stale_entries that doesn't need BrowserRunner
|
||||
pub fn verify_and_cleanup_stale_entries_simple(
|
||||
&mut self,
|
||||
binaries_dir: &std::path::Path,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cleaned_up = Vec::new();
|
||||
let mut browsers_to_remove = Vec::new();
|
||||
|
||||
for (browser_str, versions) in &self.browsers {
|
||||
for version in versions.keys() {
|
||||
// Check if the browser directory actually exists
|
||||
let browser_dir = binaries_dir.join(browser_str).join(version);
|
||||
if !browser_dir.exists() {
|
||||
browsers_to_remove.push((browser_str.clone(), version.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale entries
|
||||
for (browser_str, version) in browsers_to_remove {
|
||||
if let Some(_removed) = self.remove_browser(&browser_str, &version) {
|
||||
cleaned_up.push(format!(
|
||||
"Removed stale registry entry for {browser_str} {version}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cleaned_up)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -182,10 +404,7 @@ mod tests {
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
download_date: 1234567890,
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
};
|
||||
|
||||
registry.add_browser(info.clone());
|
||||
@@ -202,28 +421,19 @@ mod tests {
|
||||
let info1 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
download_date: 1234567890,
|
||||
file_path: PathBuf::from("/test/path1"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
};
|
||||
|
||||
let info2 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "140.0".to_string(),
|
||||
download_date: 1234567891,
|
||||
file_path: PathBuf::from("/test/path2"),
|
||||
verified: false, // Not verified, should not be included
|
||||
actual_version: None,
|
||||
};
|
||||
|
||||
let info3 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "141.0".to_string(),
|
||||
download_date: 1234567892,
|
||||
file_path: PathBuf::from("/test/path3"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
};
|
||||
|
||||
registry.add_browser(info1);
|
||||
@@ -231,10 +441,10 @@ mod tests {
|
||||
registry.add_browser(info3);
|
||||
|
||||
let versions = registry.get_downloaded_versions("firefox");
|
||||
assert_eq!(versions.len(), 2);
|
||||
assert_eq!(versions.len(), 3);
|
||||
assert!(versions.contains(&"139.0".to_string()));
|
||||
assert!(versions.contains(&"140.0".to_string()));
|
||||
assert!(versions.contains(&"141.0".to_string()));
|
||||
assert!(!versions.contains(&"140.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -244,15 +454,15 @@ mod tests {
|
||||
// Mark download started
|
||||
registry.mark_download_started("firefox", "139.0", PathBuf::from("/test/path"));
|
||||
|
||||
// Should not be considered downloaded yet
|
||||
assert!(!registry.is_browser_downloaded("firefox", "139.0"));
|
||||
// Should be considered downloaded immediately
|
||||
assert!(registry.is_browser_downloaded("firefox", "139.0"));
|
||||
|
||||
// Mark as completed
|
||||
registry
|
||||
.mark_download_completed_with_actual_version("firefox", "139.0", Some("139.0".to_string()))
|
||||
.mark_download_completed("firefox", "139.0")
|
||||
.unwrap();
|
||||
|
||||
// Now should be considered downloaded
|
||||
// Should still be considered downloaded
|
||||
assert!(registry.is_browser_downloaded("firefox", "139.0"));
|
||||
}
|
||||
|
||||
@@ -262,10 +472,7 @@ mod tests {
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
download_date: 1234567890,
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
};
|
||||
|
||||
registry.add_browser(info);
|
||||
@@ -275,4 +482,15 @@ mod tests {
|
||||
assert!(removed.is_some());
|
||||
assert!(!registry.is_browser_downloaded("firefox", "139.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_twilight_download() {
|
||||
let mut registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
// Mark twilight download started
|
||||
registry.mark_download_started("zen", "twilight", PathBuf::from("/test/zen-twilight"));
|
||||
|
||||
// Check that it's registered
|
||||
assert!(registry.is_browser_downloaded("zen", "twilight"));
|
||||
}
|
||||
}
|
||||
|
||||
+1479
-158
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
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(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new downloader with custom client (for testing)
|
||||
#[cfg(test)]
|
||||
pub fn new_with_client(client: Client) -> Self {
|
||||
Self { client }
|
||||
}
|
||||
|
||||
fn get_cache_dir() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to determine base directories")?;
|
||||
|
||||
#[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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::browser::GithubRelease;
|
||||
use wiremock::matchers::{method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
fn create_mock_release() -> GithubRelease {
|
||||
GithubRelease {
|
||||
tag_name: "v1.0.0".to_string(),
|
||||
name: "Test Release".to_string(),
|
||||
body: Some("Test release body".to_string()),
|
||||
published_at: "2023-01-01T00:00:00Z".to_string(),
|
||||
created_at: Some("2023-01-01T00:00:00Z".to_string()),
|
||||
html_url: Some("https://example.com/release".to_string()),
|
||||
tarball_url: Some("https://example.com/tarball".to_string()),
|
||||
zipball_url: Some("https://example.com/zipball".to_string()),
|
||||
draft: false,
|
||||
prerelease: false,
|
||||
is_nightly: false,
|
||||
id: Some(1),
|
||||
node_id: Some("test_node_id".to_string()),
|
||||
target_commitish: None,
|
||||
assets: vec![crate::browser::GithubAsset {
|
||||
id: Some(1),
|
||||
node_id: Some("test_asset_node_id".to_string()),
|
||||
name: "GeoLite2-City.mmdb".to_string(),
|
||||
label: None,
|
||||
content_type: Some("application/octet-stream".to_string()),
|
||||
state: Some("uploaded".to_string()),
|
||||
size: 1024,
|
||||
download_count: Some(0),
|
||||
created_at: Some("2023-01-01T00:00:00Z".to_string()),
|
||||
updated_at: Some("2023-01-01T00:00:00Z".to_string()),
|
||||
browser_download_url: "https://example.com/GeoLite2-City.mmdb".to_string(),
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_fetch_geoip_releases_success() {
|
||||
let mock_server = MockServer::start().await;
|
||||
let releases = vec![create_mock_release()];
|
||||
|
||||
Mock::given(method("GET"))
|
||||
.and(path(format!("/repos/{MMDB_REPO}/releases")))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(&releases))
|
||||
.mount(&mock_server)
|
||||
.await;
|
||||
|
||||
let client = Client::builder()
|
||||
.build()
|
||||
.expect("Failed to create HTTP client");
|
||||
|
||||
let downloader = GeoIPDownloader::new_with_client(client);
|
||||
|
||||
// Override the URL for testing
|
||||
let url = format!("{}/repos/{}/releases", mock_server.uri(), MMDB_REPO);
|
||||
let response = downloader
|
||||
.client
|
||||
.get(&url)
|
||||
.header("User-Agent", "Mozilla/5.0 (compatible; donutbrowser)")
|
||||
.send()
|
||||
.await
|
||||
.expect("Request should succeed");
|
||||
|
||||
assert!(response.status().is_success());
|
||||
|
||||
let fetched_releases: Vec<GithubRelease> = response.json().await.expect("Should parse JSON");
|
||||
assert_eq!(fetched_releases.len(), 1);
|
||||
assert_eq!(fetched_releases[0].tag_name, "v1.0.0");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_city_mmdb_asset() {
|
||||
let downloader = GeoIPDownloader::new();
|
||||
let release = create_mock_release();
|
||||
|
||||
let asset_url = downloader.find_city_mmdb_asset(&release);
|
||||
assert!(asset_url.is_some());
|
||||
assert_eq!(asset_url.unwrap(), "https://example.com/GeoLite2-City.mmdb");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_find_city_mmdb_asset_not_found() {
|
||||
let downloader = GeoIPDownloader::new();
|
||||
let mut release = create_mock_release();
|
||||
release.assets[0].name = "wrong-file.txt".to_string();
|
||||
|
||||
let asset_url = downloader.find_city_mmdb_asset(&release);
|
||||
assert!(asset_url.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_cache_dir() {
|
||||
let cache_dir = GeoIPDownloader::get_cache_dir();
|
||||
assert!(cache_dir.is_ok());
|
||||
|
||||
let path = cache_dir.unwrap();
|
||||
assert!(path.to_string_lossy().contains("camoufox"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_mmdb_file_path() {
|
||||
let mmdb_path = GeoIPDownloader::get_mmdb_file_path();
|
||||
assert!(mmdb_path.is_ok());
|
||||
|
||||
let path = mmdb_path.unwrap();
|
||||
assert!(path.to_string_lossy().ends_with("GeoLite2-City.mmdb"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_geoip_database_available() {
|
||||
// This test will return false unless the database actually exists
|
||||
// In a real environment, this would check the actual file system
|
||||
let is_available = GeoIPDownloader::is_geoip_database_available();
|
||||
// We can't assert a specific value since it depends on the system state
|
||||
// But we can verify the function doesn't panic
|
||||
println!("GeoIP database available: {is_available}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GroupWithCount {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct GroupsData {
|
||||
groups: Vec<ProfileGroup>,
|
||||
}
|
||||
|
||||
pub struct GroupManager {
|
||||
base_dirs: BaseDirs,
|
||||
}
|
||||
|
||||
impl GroupManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_groups_file_path(&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("data");
|
||||
path.push("groups.json");
|
||||
path
|
||||
}
|
||||
|
||||
fn load_groups_data(&self) -> Result<GroupsData, Box<dyn std::error::Error>> {
|
||||
let groups_file = self.get_groups_file_path();
|
||||
|
||||
if !groups_file.exists() {
|
||||
return Ok(GroupsData { groups: Vec::new() });
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(groups_file)?;
|
||||
let groups_data: GroupsData = serde_json::from_str(&content)?;
|
||||
Ok(groups_data)
|
||||
}
|
||||
|
||||
fn save_groups_data(&self, groups_data: &GroupsData) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let groups_file = self.get_groups_file_path();
|
||||
|
||||
// Ensure the parent directory exists
|
||||
if let Some(parent) = groups_file.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(groups_data)?;
|
||||
fs::write(groups_file, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_groups(&self) -> Result<Vec<ProfileGroup>, Box<dyn std::error::Error>> {
|
||||
let groups_data = self.load_groups_data()?;
|
||||
Ok(groups_data.groups)
|
||||
}
|
||||
|
||||
pub fn create_group(&self, name: String) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
// Check if group with this name already exists
|
||||
if groups_data.groups.iter().any(|g| g.name == name) {
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
}
|
||||
|
||||
let group = ProfileGroup {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
};
|
||||
|
||||
groups_data.groups.push(group.clone());
|
||||
self.save_groups_data(&groups_data)?;
|
||||
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
pub fn update_group(
|
||||
&self,
|
||||
id: String,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
// Check if another group with this name already exists
|
||||
if groups_data
|
||||
.groups
|
||||
.iter()
|
||||
.any(|g| g.name == name && g.id != id)
|
||||
{
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
}
|
||||
|
||||
let group = groups_data
|
||||
.groups
|
||||
.iter_mut()
|
||||
.find(|g| g.id == id)
|
||||
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
|
||||
|
||||
group.name = name;
|
||||
let updated_group = group.clone();
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
Ok(updated_group)
|
||||
}
|
||||
|
||||
pub fn delete_group(&self, id: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
let initial_len = groups_data.groups.len();
|
||||
groups_data.groups.retain(|g| g.id != id);
|
||||
|
||||
if groups_data.groups.len() == initial_len {
|
||||
return Err(format!("Group with id '{id}' not found").into());
|
||||
}
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_groups_with_profile_counts(
|
||||
&self,
|
||||
profiles: &[crate::profile::BrowserProfile],
|
||||
) -> Result<Vec<GroupWithCount>, Box<dyn std::error::Error>> {
|
||||
let groups = self.get_all_groups()?;
|
||||
let mut group_counts = HashMap::new();
|
||||
|
||||
// Count profiles in each group
|
||||
for profile in profiles {
|
||||
if let Some(group_id) = &profile.group_id {
|
||||
*group_counts.entry(group_id.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Create result with counts
|
||||
let mut result = Vec::new();
|
||||
for group in groups {
|
||||
let count = group_counts.get(&group.id).copied().unwrap_or(0);
|
||||
if count > 0 {
|
||||
result.push(GroupWithCount {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add default group count (profiles without group_id)
|
||||
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
|
||||
if default_count > 0 {
|
||||
let default_group = GroupWithCount {
|
||||
id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
count: default_count,
|
||||
};
|
||||
result.insert(0, default_group);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref GROUP_MANAGER: Mutex<GroupManager> = Mutex::new(GroupManager::new());
|
||||
}
|
||||
|
||||
// Helper function to get groups with counts
|
||||
pub fn get_groups_with_counts(profiles: &[crate::profile::BrowserProfile]) -> Vec<GroupWithCount> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.get_groups_with_profile_counts(profiles)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_groups() -> Result<Vec<ProfileGroup>, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.get_all_groups()
|
||||
.map_err(|e| format!("Failed to get profile groups: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_groups_with_profile_counts() -> Result<Vec<GroupWithCount>, String> {
|
||||
let profile_manager = crate::profile::ProfileManager::new();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
Ok(get_groups_with_counts(&profiles))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_profile_group(name: String) -> Result<ProfileGroup, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.create_group(name)
|
||||
.map_err(|e| format!("Failed to create group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_profile_group(group_id: String, name: String) -> Result<ProfileGroup, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.update_group(group_id, name)
|
||||
.map_err(|e| format!("Failed to update group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_profile_group(group_id: String) -> Result<(), String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.delete_group(group_id)
|
||||
.map_err(|e| format!("Failed to delete group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assign_profiles_to_group(
|
||||
profile_names: Vec<String>,
|
||||
group_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let profile_manager = crate::profile::ProfileManager::new();
|
||||
profile_manager
|
||||
.assign_profiles_to_group(profile_names, group_id)
|
||||
.map_err(|e| format!("Failed to assign profiles to group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_selected_profiles(profile_names: Vec<String>) -> Result<(), String> {
|
||||
let profile_manager = crate::profile::ProfileManager::new();
|
||||
profile_manager
|
||||
.delete_multiple_profiles(profile_names)
|
||||
.map_err(|e| format!("Failed to delete profiles: {e}"))
|
||||
}
|
||||
+500
-96
@@ -1,7 +1,7 @@
|
||||
// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/
|
||||
use std::env;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
use tauri::{Emitter, Manager};
|
||||
use tauri::{Emitter, Manager, Runtime, WebviewUrl, WebviewWindow, WebviewWindowBuilder};
|
||||
use tauri_plugin_deep_link::DeepLinkExt;
|
||||
|
||||
// Store pending URLs that need to be handled when the window is ready
|
||||
@@ -13,56 +13,108 @@ 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 group_manager;
|
||||
mod platform_browser;
|
||||
mod profile;
|
||||
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, create_browser_profile_new,
|
||||
delete_profile, download_browser, fetch_browser_versions, fetch_browser_versions_cached_first,
|
||||
fetch_browser_versions_detailed, fetch_browser_versions_with_count,
|
||||
fetch_browser_versions_with_count_cached_first, get_cached_browser_versions_detailed,
|
||||
get_downloaded_browser_versions, get_saved_mullvad_releases, get_supported_browsers,
|
||||
is_browser_downloaded, kill_browser_profile, launch_browser_profile, list_browser_profiles,
|
||||
rename_profile, should_update_browser_cache, 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_camoufox_config, update_profile_proxy,
|
||||
update_profile_version,
|
||||
};
|
||||
|
||||
use settings_manager::{
|
||||
disable_default_browser_prompt, get_app_settings, get_table_sorting_settings, save_app_settings,
|
||||
save_table_sorting_settings, should_show_settings_on_startup,
|
||||
clear_all_version_cache_and_refetch, get_app_settings, get_table_sorting_settings,
|
||||
save_app_settings, save_table_sorting_settings, should_show_settings_on_startup,
|
||||
};
|
||||
|
||||
use default_browser::{
|
||||
is_default_browser, open_url_with_profile, set_as_default_browser, smart_open_url,
|
||||
};
|
||||
use default_browser::{is_default_browser, open_url_with_profile, set_as_default_browser};
|
||||
|
||||
use version_updater::{
|
||||
check_version_update_needed, force_version_update_check, get_version_update_status,
|
||||
get_version_updater, trigger_manual_version_update,
|
||||
get_version_update_status, get_version_updater, trigger_manual_version_update,
|
||||
};
|
||||
|
||||
use auto_updater::{
|
||||
check_for_browser_updates, complete_browser_update, 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, start_browser_update,
|
||||
check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification,
|
||||
is_browser_disabled_for_update,
|
||||
};
|
||||
|
||||
use app_auto_updater::{
|
||||
check_for_app_updates, check_for_app_updates_manual, download_and_install_app_update,
|
||||
get_app_version_info,
|
||||
};
|
||||
|
||||
#[tauri::command]
|
||||
fn greet() -> String {
|
||||
let now = SystemTime::now();
|
||||
let epoch_ms = now.duration_since(UNIX_EPOCH).unwrap().as_millis();
|
||||
format!("Hello world from Rust! Current epoch: {epoch_ms}")
|
||||
use profile_importer::{detect_existing_profiles, import_browser_profile};
|
||||
|
||||
use theme_detector::get_system_theme;
|
||||
|
||||
use system_utils::{get_system_locale, get_system_timezone};
|
||||
|
||||
use group_manager::{
|
||||
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
|
||||
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
|
||||
};
|
||||
|
||||
// Trait to extend WebviewWindow with transparent titlebar functionality
|
||||
pub trait WindowExt {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String>;
|
||||
}
|
||||
|
||||
impl<R: Runtime> WindowExt for WebviewWindow<R> {
|
||||
#[cfg(target_os = "macos")]
|
||||
fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String> {
|
||||
use objc2::rc::Retained;
|
||||
use objc2_app_kit::{NSWindow, NSWindowStyleMask, NSWindowTitleVisibility};
|
||||
|
||||
unsafe {
|
||||
let ns_window: Retained<NSWindow> =
|
||||
Retained::retain(self.ns_window().unwrap().cast()).unwrap();
|
||||
|
||||
if transparent {
|
||||
// Hide the title text
|
||||
ns_window.setTitleVisibility(NSWindowTitleVisibility(2)); // NSWindowTitleHidden
|
||||
|
||||
// Make titlebar transparent
|
||||
ns_window.setTitlebarAppearsTransparent(true);
|
||||
|
||||
// Set full size content view
|
||||
let current_mask = ns_window.styleMask();
|
||||
let new_mask = NSWindowStyleMask(current_mask.0 | (1 << 15)); // NSFullSizeContentViewWindowMask
|
||||
ns_window.setStyleMask(new_mask);
|
||||
} else {
|
||||
// Show the title text
|
||||
ns_window.setTitleVisibility(NSWindowTitleVisibility(0)); // NSWindowTitleVisible
|
||||
|
||||
// Make titlebar opaque
|
||||
ns_window.setTitlebarAppearsTransparent(false);
|
||||
|
||||
// Remove full size content view
|
||||
let current_mask = ns_window.styleMask();
|
||||
let new_mask = NSWindowStyleMask(current_mask.0 & !(1 << 15));
|
||||
ns_window.setStyleMask(new_mask);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -71,20 +123,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");
|
||||
@@ -96,58 +144,129 @@ 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> {
|
||||
let pending_urls = {
|
||||
let mut pending = PENDING_URLS.lock().unwrap();
|
||||
let urls = pending.clone();
|
||||
pending.clear(); // Clear after getting them
|
||||
urls
|
||||
};
|
||||
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}"))
|
||||
}
|
||||
|
||||
if !pending_urls.is_empty() {
|
||||
println!(
|
||||
"Handling {} pending URLs from frontend request",
|
||||
pending_urls.len()
|
||||
);
|
||||
#[tauri::command]
|
||||
async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>, String> {
|
||||
Ok(crate::proxy_manager::PROXY_MANAGER.get_stored_proxies())
|
||||
}
|
||||
|
||||
for url in pending_urls {
|
||||
println!("Emitting show-profile-selector event for URL: {url}");
|
||||
if let Err(e) = app_handle.emit("show-profile-selector", url.clone()) {
|
||||
eprintln!("Failed to emit URL event: {e}");
|
||||
return Err(format!("Failed to emit URL event: {e}"));
|
||||
}
|
||||
}
|
||||
#[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}"))
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
#[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}"))
|
||||
}
|
||||
|
||||
#[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)]
|
||||
let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
|
||||
.title("Donut Browser")
|
||||
.inner_size(900.0, 600.0)
|
||||
.resizable(false)
|
||||
.fullscreen(false)
|
||||
.center()
|
||||
.focused(true)
|
||||
.visible(true);
|
||||
|
||||
#[allow(unused_variables)]
|
||||
let window = win_builder.build().unwrap();
|
||||
|
||||
// Set transparent titlebar for macOS
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Err(e) = window.set_transparent_titlebar(true) {
|
||||
eprintln!("Failed to set transparent titlebar: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
// 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}");
|
||||
@@ -165,27 +284,89 @@ 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;
|
||||
});
|
||||
|
||||
// Handle any pending URLs that were received before the window was ready
|
||||
let handle_pending = handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
// Wait a bit for the window to be fully ready
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
|
||||
let pending_urls = {
|
||||
let mut pending = PENDING_URLS.lock().unwrap();
|
||||
let urls = pending.clone();
|
||||
pending.clear();
|
||||
urls
|
||||
};
|
||||
|
||||
for url in pending_urls {
|
||||
println!("Processing pending URL: {url}");
|
||||
if let Err(e) = handle_url_open(handle_pending.clone(), url).await {
|
||||
eprintln!("Failed to handle pending URL: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Start periodic cleanup task for unused binaries
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(300)); // Every 5 minutes
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::new();
|
||||
if let Err(e) = browser_runner.cleanup_unused_binaries_internal() {
|
||||
eprintln!("Periodic cleanup failed: {e}");
|
||||
} else {
|
||||
println!("Periodic cleanup completed successfully");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 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();
|
||||
let updater = app_auto_updater::AppAutoUpdater::instance();
|
||||
match updater.check_for_updates().await {
|
||||
Ok(Some(update_info)) => {
|
||||
println!(
|
||||
@@ -208,28 +389,82 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
// Start Camoufox cleanup task
|
||||
let app_handle_cleanup = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let launcher = crate::camoufox::CamoufoxNodecarLauncher::new(app_handle_cleanup);
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match launcher.cleanup_dead_instances().await {
|
||||
Ok(dead_instances) => {
|
||||
if !dead_instances.is_empty() {
|
||||
println!(
|
||||
"Cleaned up {} dead Camoufox instances",
|
||||
dead_instances.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error during Camoufox cleanup: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Warm up nodecar binary in the background
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Starting nodecar warm-up...");
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Send a ping request to nodecar to trigger unpacking/warm-up
|
||||
match tokio::process::Command::new("nodecar")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
let duration = start_time.elapsed();
|
||||
if output.status.success() {
|
||||
println!(
|
||||
"Nodecar warm-up completed successfully in {:.2}s",
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Nodecar warm-up completed with non-zero exit code in {:.2}s",
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let duration = start_time.elapsed();
|
||||
println!(
|
||||
"Nodecar warm-up failed after {:.2}s: {e}",
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
greet,
|
||||
get_supported_browsers,
|
||||
is_browser_supported_on_platform,
|
||||
download_browser,
|
||||
delete_profile,
|
||||
is_browser_downloaded,
|
||||
check_browser_exists,
|
||||
create_browser_profile_new,
|
||||
create_browser_profile,
|
||||
list_browser_profiles,
|
||||
launch_browser_profile,
|
||||
fetch_browser_versions,
|
||||
fetch_browser_versions_detailed,
|
||||
fetch_browser_versions_with_count,
|
||||
fetch_browser_versions_cached_first,
|
||||
fetch_browser_versions_with_count_cached_first,
|
||||
get_cached_browser_versions_detailed,
|
||||
should_update_browser_cache,
|
||||
get_downloaded_browser_versions,
|
||||
get_saved_mullvad_releases,
|
||||
get_browser_release_types,
|
||||
update_profile_proxy,
|
||||
update_profile_version,
|
||||
check_browser_status,
|
||||
@@ -238,33 +473,202 @@ pub fn run() {
|
||||
get_app_settings,
|
||||
save_app_settings,
|
||||
should_show_settings_on_startup,
|
||||
disable_default_browser_prompt,
|
||||
get_table_sorting_settings,
|
||||
save_table_sorting_settings,
|
||||
clear_all_version_cache_and_refetch,
|
||||
is_default_browser,
|
||||
open_url_with_profile,
|
||||
set_as_default_browser,
|
||||
smart_open_url,
|
||||
handle_url_open,
|
||||
check_and_handle_startup_url,
|
||||
trigger_manual_version_update,
|
||||
get_version_update_status,
|
||||
check_version_update_needed,
|
||||
force_version_update_check,
|
||||
check_for_browser_updates,
|
||||
start_browser_update,
|
||||
complete_browser_update,
|
||||
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_app_version_info,
|
||||
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,
|
||||
get_profile_groups,
|
||||
get_groups_with_profile_counts,
|
||||
create_profile_group,
|
||||
update_profile_group,
|
||||
delete_profile_group,
|
||||
assign_profiles_to_group,
|
||||
delete_selected_profiles,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn test_no_unused_tauri_commands() {
|
||||
check_unused_commands(false); // Run in strict mode for CI
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_unused_tauri_commands_detailed() {
|
||||
check_unused_commands(true); // Run in verbose mode for development
|
||||
}
|
||||
|
||||
fn check_unused_commands(verbose: bool) {
|
||||
// Extract command names from the generate_handler! macro in this file
|
||||
let lib_rs_content = fs::read_to_string("src/lib.rs").expect("Failed to read lib.rs");
|
||||
let commands = extract_tauri_commands(&lib_rs_content);
|
||||
|
||||
// Get all frontend files
|
||||
let frontend_files = get_frontend_files("../src");
|
||||
|
||||
// Check which commands are actually used
|
||||
let mut unused_commands = Vec::new();
|
||||
let mut used_commands = Vec::new();
|
||||
|
||||
for command in &commands {
|
||||
let mut is_used = false;
|
||||
|
||||
for file_content in &frontend_files {
|
||||
// More comprehensive search for command usage
|
||||
if is_command_used(file_content, command) {
|
||||
is_used = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if is_used {
|
||||
used_commands.push(command.clone());
|
||||
if verbose {
|
||||
println!("✅ {command}");
|
||||
}
|
||||
} else {
|
||||
unused_commands.push(command.clone());
|
||||
if verbose {
|
||||
println!("❌ {command} (UNUSED)");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if verbose {
|
||||
println!("\n📊 Summary:");
|
||||
println!(" ✅ Used commands: {}", used_commands.len());
|
||||
println!(" ❌ Unused commands: {}", unused_commands.len());
|
||||
}
|
||||
|
||||
if !unused_commands.is_empty() {
|
||||
let message = format!(
|
||||
"Found {} unused Tauri commands: {}\n\nThese commands are exported in generate_handler! but not used in the frontend.\nConsider removing them or add them to the allowlist if they're used elsewhere.\n\nRun `pnpm check-unused-commands` for detailed analysis.",
|
||||
unused_commands.len(),
|
||||
unused_commands.join(", ")
|
||||
);
|
||||
|
||||
if verbose {
|
||||
println!("\n🚨 {message}");
|
||||
} else {
|
||||
panic!("{}", message);
|
||||
}
|
||||
} else if verbose {
|
||||
println!("\n🎉 All exported commands are being used!");
|
||||
} else {
|
||||
println!(
|
||||
"✅ All {} exported Tauri commands are being used in the frontend",
|
||||
commands.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_command_used(content: &str, command: &str) -> bool {
|
||||
// Check various patterns for invoke usage
|
||||
let patterns = vec![
|
||||
format!("invoke<{}>(\"{}\"", "", command), // invoke<Type>("command"
|
||||
format!("invoke(\"{}\"", command), // invoke("command"
|
||||
format!("invoke<{}>(\"{}\",", "", command), // invoke<Type>("command",
|
||||
format!("invoke(\"{}\",", command), // invoke("command",
|
||||
format!("\"{}\"", command), // Just the command name in quotes
|
||||
];
|
||||
|
||||
for pattern in patterns {
|
||||
if content.contains(&pattern) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Also check for the command name appearing after "invoke" within a reasonable distance
|
||||
if let Some(invoke_pos) = content.find("invoke") {
|
||||
let after_invoke = &content[invoke_pos..];
|
||||
if let Some(cmd_pos) = after_invoke.find(&format!("\"{command}\"")) {
|
||||
// If the command appears within 100 characters of "invoke", consider it used
|
||||
if cmd_pos < 100 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn extract_tauri_commands(content: &str) -> Vec<String> {
|
||||
let mut commands = Vec::new();
|
||||
|
||||
// Find the generate_handler! macro
|
||||
if let Some(start) = content.find("tauri::generate_handler![") {
|
||||
if let Some(end) = content[start..].find("])") {
|
||||
let handler_content = &content[start + 25..start + end]; // Skip "tauri::generate_handler!["
|
||||
|
||||
// Extract command names
|
||||
for line in handler_content.lines() {
|
||||
let line = line.trim();
|
||||
if !line.is_empty() && !line.starts_with("//") {
|
||||
// Remove trailing comma and whitespace
|
||||
let command = line.trim_end_matches(',').trim();
|
||||
if !command.is_empty() {
|
||||
commands.push(command.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
commands
|
||||
}
|
||||
|
||||
fn get_frontend_files(src_dir: &str) -> Vec<String> {
|
||||
let mut files_content = Vec::new();
|
||||
|
||||
if let Ok(entries) = fs::read_dir(src_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
// Recursively read subdirectories
|
||||
let subdir_files = get_frontend_files(&path.to_string_lossy());
|
||||
files_content.extend(subdir_files);
|
||||
} else if let Some(extension) = path.extension() {
|
||||
if matches!(
|
||||
extension.to_str(),
|
||||
Some("ts") | Some("tsx") | Some("js") | Some("jsx")
|
||||
) {
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
files_content.push(content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
files_content
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,5 @@
|
||||
pub mod manager;
|
||||
pub mod types;
|
||||
|
||||
pub use manager::ProfileManager;
|
||||
pub use types::BrowserProfile;
|
||||
@@ -0,0 +1,34 @@
|
||||
use crate::camoufox::CamoufoxConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct BrowserProfile {
|
||||
pub id: uuid::Uuid,
|
||||
pub name: String,
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
#[serde(default)]
|
||||
pub proxy_id: Option<String>, // Reference to stored proxy
|
||||
#[serde(default)]
|
||||
pub process_id: Option<u32>,
|
||||
#[serde(default)]
|
||||
pub last_launch: Option<u64>,
|
||||
#[serde(default = "default_release_type")]
|
||||
pub release_type: String, // "stable" or "nightly"
|
||||
#[serde(default)]
|
||||
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
|
||||
#[serde(default)]
|
||||
pub group_id: Option<String>, // Reference to profile group
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
"stable".to_string()
|
||||
}
|
||||
|
||||
impl BrowserProfile {
|
||||
/// Get the path to the profile data directory (profiles/{uuid}/profile)
|
||||
pub fn get_profile_data_path(&self, profiles_dir: &Path) -> PathBuf {
|
||||
profiles_dir.join(self.id.to_string()).join("profile")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,774 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::browser::BrowserType;
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DetectedProfile {
|
||||
pub browser: String,
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
pub struct ProfileImporter {
|
||||
base_dirs: BaseDirs,
|
||||
browser_runner: BrowserRunner,
|
||||
}
|
||||
|
||||
impl ProfileImporter {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
browser_runner: BrowserRunner::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Detect existing browser profiles on the system
|
||||
pub fn detect_existing_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut detected_profiles = Vec::new();
|
||||
|
||||
// Detect Firefox profiles
|
||||
detected_profiles.extend(self.detect_firefox_profiles()?);
|
||||
|
||||
// Detect Chrome profiles
|
||||
detected_profiles.extend(self.detect_chrome_profiles()?);
|
||||
|
||||
// Detect Brave profiles
|
||||
detected_profiles.extend(self.detect_brave_profiles()?);
|
||||
|
||||
// Detect Firefox Developer Edition profiles
|
||||
detected_profiles.extend(self.detect_firefox_developer_profiles()?);
|
||||
|
||||
// Detect Chromium profiles
|
||||
detected_profiles.extend(self.detect_chromium_profiles()?);
|
||||
|
||||
// Detect Mullvad Browser profiles
|
||||
detected_profiles.extend(self.detect_mullvad_browser_profiles()?);
|
||||
|
||||
// Detect Zen Browser profiles
|
||||
detected_profiles.extend(self.detect_zen_browser_profiles()?);
|
||||
|
||||
// Detect TOR Browser profiles
|
||||
detected_profiles.extend(self.detect_tor_browser_profiles()?);
|
||||
|
||||
// Remove duplicates based on path
|
||||
let mut seen_paths = HashSet::new();
|
||||
let unique_profiles: Vec<DetectedProfile> = detected_profiles
|
||||
.into_iter()
|
||||
.filter(|profile| seen_paths.insert(profile.path.clone()))
|
||||
.collect();
|
||||
|
||||
Ok(unique_profiles)
|
||||
}
|
||||
|
||||
/// Detect Firefox profiles
|
||||
fn detect_firefox_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let firefox_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Firefox/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// 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")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let firefox_dir = self.base_dirs.home_dir().join(".mozilla/firefox");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dir, "firefox")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Firefox Developer Edition profiles
|
||||
fn detect_firefox_developer_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// Firefox Developer Edition on macOS uses separate profile directories
|
||||
let firefox_dev_alt_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Firefox Developer Edition/Profiles");
|
||||
|
||||
// Only scan the dedicated dev edition directory if it exists, otherwise skip to avoid duplicates
|
||||
if firefox_dev_alt_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_alt_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
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")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Firefox Developer Edition on Linux uses separate directories
|
||||
let firefox_dev_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join(".mozilla/firefox-dev-edition");
|
||||
if firefox_dev_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&firefox_dev_dir, "firefox-developer")?);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Chrome profiles
|
||||
fn detect_chrome_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let chrome_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Google/Chrome");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
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")]
|
||||
{
|
||||
let chrome_dir = self.base_dirs.home_dir().join(".config/google-chrome");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chrome_dir, "chromium")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Chromium profiles
|
||||
fn detect_chromium_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let chromium_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Chromium");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
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")]
|
||||
{
|
||||
let chromium_dir = self.base_dirs.home_dir().join(".config/chromium");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&chromium_dir, "chromium")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Brave profiles
|
||||
fn detect_brave_profiles(&self) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let brave_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/BraveSoftware/Brave-Browser");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
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")]
|
||||
{
|
||||
let brave_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join(".config/BraveSoftware/Brave-Browser");
|
||||
profiles.extend(self.scan_chrome_profiles_dir(&brave_dir, "brave")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Mullvad Browser profiles
|
||||
fn detect_mullvad_browser_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let mullvad_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/MullvadBrowser/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// Primary location in AppData\Roaming
|
||||
let app_data = self.base_dirs.data_dir();
|
||||
let mullvad_dir = app_data.join("MullvadBrowser/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
|
||||
|
||||
// Also check common installation locations
|
||||
let local_app_data = self.base_dirs.data_local_dir();
|
||||
let mullvad_local_dir = local_app_data.join("MullvadBrowser/Profiles");
|
||||
if mullvad_local_dir.exists() {
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_local_dir, "mullvad-browser")?);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mullvad_dir = self.base_dirs.home_dir().join(".mullvad-browser");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&mullvad_dir, "mullvad-browser")?);
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Detect Zen Browser profiles
|
||||
fn detect_zen_browser_profiles(
|
||||
&self,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
let zen_dir = self
|
||||
.base_dirs
|
||||
.home_dir()
|
||||
.join("Library/Application Support/Zen/Profiles");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
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")]
|
||||
{
|
||||
let zen_dir = self.base_dirs.home_dir().join(".zen");
|
||||
profiles.extend(self.scan_firefox_profiles_dir(&zen_dir, "zen")?);
|
||||
}
|
||||
|
||||
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,
|
||||
profiles_dir: &Path,
|
||||
browser_type: &str,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
if !profiles_dir.exists() {
|
||||
return Ok(profiles);
|
||||
}
|
||||
|
||||
// Read profiles.ini file if it exists
|
||||
let profiles_ini = profiles_dir
|
||||
.parent()
|
||||
.unwrap_or(profiles_dir)
|
||||
.join("profiles.ini");
|
||||
if profiles_ini.exists() {
|
||||
if let Ok(content) = fs::read_to_string(&profiles_ini) {
|
||||
profiles.extend(self.parse_firefox_profiles_ini(&content, profiles_dir, browser_type)?);
|
||||
}
|
||||
}
|
||||
|
||||
// Also scan directory for any profile folders not in profiles.ini
|
||||
if let Ok(entries) = fs::read_dir(profiles_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let prefs_file = path.join("prefs.js");
|
||||
if prefs_file.exists() {
|
||||
let profile_name = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("Unknown Profile");
|
||||
|
||||
// Check if this profile was already found in profiles.ini
|
||||
let already_added = profiles.iter().any(|p| p.path == path.to_string_lossy());
|
||||
if !already_added {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
name: format!(
|
||||
"{} Profile - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
description: format!("Profile folder: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Parse Firefox profiles.ini file
|
||||
fn parse_firefox_profiles_ini(
|
||||
&self,
|
||||
content: &str,
|
||||
profiles_dir: &Path,
|
||||
browser_type: &str,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
let mut current_section = String::new();
|
||||
let mut profile_name = String::new();
|
||||
let mut profile_path = String::new();
|
||||
let mut is_relative = true;
|
||||
|
||||
for line in content.lines() {
|
||||
let line = line.trim();
|
||||
|
||||
if line.starts_with('[') && line.ends_with(']') {
|
||||
// Save previous profile if complete
|
||||
if !current_section.is_empty()
|
||||
&& current_section.starts_with("Profile")
|
||||
&& !profile_path.is_empty()
|
||||
{
|
||||
let full_path = if is_relative {
|
||||
profiles_dir.join(&profile_path)
|
||||
} else {
|
||||
PathBuf::from(&profile_path)
|
||||
};
|
||||
|
||||
if full_path.exists() {
|
||||
let display_name = if profile_name.is_empty() {
|
||||
format!("{} Profile", self.get_browser_display_name(browser_type))
|
||||
} else {
|
||||
format!(
|
||||
"{} - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
)
|
||||
};
|
||||
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
name: display_name,
|
||||
path: full_path.to_string_lossy().to_string(),
|
||||
description: format!("Profile: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Start new section
|
||||
current_section = line[1..line.len() - 1].to_string();
|
||||
profile_name.clear();
|
||||
profile_path.clear();
|
||||
is_relative = true;
|
||||
} else if line.contains('=') {
|
||||
let parts: Vec<&str> = line.splitn(2, '=').collect();
|
||||
if parts.len() == 2 {
|
||||
let key = parts[0].trim();
|
||||
let value = parts[1].trim();
|
||||
|
||||
match key {
|
||||
"Name" => profile_name = value.to_string(),
|
||||
"Path" => profile_path = value.to_string(),
|
||||
"IsRelative" => is_relative = value == "1",
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle last profile
|
||||
if !current_section.is_empty()
|
||||
&& current_section.starts_with("Profile")
|
||||
&& !profile_path.is_empty()
|
||||
{
|
||||
let full_path = if is_relative {
|
||||
profiles_dir.join(&profile_path)
|
||||
} else {
|
||||
PathBuf::from(&profile_path)
|
||||
};
|
||||
|
||||
if full_path.exists() {
|
||||
let display_name = if profile_name.is_empty() {
|
||||
format!("{} Profile", self.get_browser_display_name(browser_type))
|
||||
} else {
|
||||
format!(
|
||||
"{} - {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_name
|
||||
)
|
||||
};
|
||||
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
name: display_name,
|
||||
path: full_path.to_string_lossy().to_string(),
|
||||
description: format!("Profile: {profile_name}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Scan Chrome-style profiles directory
|
||||
fn scan_chrome_profiles_dir(
|
||||
&self,
|
||||
browser_dir: &Path,
|
||||
browser_type: &str,
|
||||
) -> Result<Vec<DetectedProfile>, Box<dyn std::error::Error>> {
|
||||
let mut profiles = Vec::new();
|
||||
|
||||
if !browser_dir.exists() {
|
||||
return Ok(profiles);
|
||||
}
|
||||
|
||||
// Check for Default profile
|
||||
let default_profile = browser_dir.join("Default");
|
||||
if default_profile.exists() && default_profile.join("Preferences").exists() {
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
name: format!(
|
||||
"{} - Default Profile",
|
||||
self.get_browser_display_name(browser_type)
|
||||
),
|
||||
path: default_profile.to_string_lossy().to_string(),
|
||||
description: "Default profile".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// Check for Profile X directories
|
||||
if let Ok(entries) = fs::read_dir(browser_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
let dir_name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
|
||||
|
||||
if dir_name.starts_with("Profile ") && path.join("Preferences").exists() {
|
||||
let profile_number = &dir_name[8..]; // Remove "Profile " prefix
|
||||
profiles.push(DetectedProfile {
|
||||
browser: browser_type.to_string(),
|
||||
name: format!(
|
||||
"{} - Profile {}",
|
||||
self.get_browser_display_name(browser_type),
|
||||
profile_number
|
||||
),
|
||||
path: path.to_string_lossy().to_string(),
|
||||
description: format!("Profile {profile_number}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(profiles)
|
||||
}
|
||||
|
||||
/// Get browser display name
|
||||
fn get_browser_display_name(&self, browser_type: &str) -> &str {
|
||||
match browser_type {
|
||||
"firefox" => "Firefox",
|
||||
"firefox-developer" => "Firefox Developer",
|
||||
"chromium" => "Chrome/Chromium",
|
||||
"brave" => "Brave",
|
||||
"mullvad-browser" => "Mullvad Browser",
|
||||
"zen" => "Zen Browser",
|
||||
"tor-browser" => "Tor Browser",
|
||||
_ => "Unknown Browser",
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a profile from an existing browser profile
|
||||
pub fn import_profile(
|
||||
&self,
|
||||
source_path: &str,
|
||||
browser_type: &str,
|
||||
new_profile_name: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Validate that source path exists
|
||||
let source_path = Path::new(source_path);
|
||||
if !source_path.exists() {
|
||||
return Err("Source profile path does not exist".into());
|
||||
}
|
||||
|
||||
// Validate browser type
|
||||
let _browser_type = BrowserType::from_str(browser_type)
|
||||
.map_err(|_| format!("Invalid browser type: {browser_type}"))?;
|
||||
|
||||
// Check if a profile with this name already exists
|
||||
let existing_profiles = self.browser_runner.list_profiles()?;
|
||||
if existing_profiles
|
||||
.iter()
|
||||
.any(|p| p.name.to_lowercase() == new_profile_name.to_lowercase())
|
||||
{
|
||||
return Err(format!("Profile with name '{new_profile_name}' already exists").into());
|
||||
}
|
||||
|
||||
// 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_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_uuid_dir)?;
|
||||
create_dir_all(&new_profile_data_dir)?;
|
||||
|
||||
// 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::profile::BrowserProfile {
|
||||
id: profile_id,
|
||||
name: new_profile_name.to_string(),
|
||||
browser: browser_type.to_string(),
|
||||
version: available_versions,
|
||||
proxy_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None,
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
self.browser_runner.save_profile(&profile)?;
|
||||
|
||||
println!(
|
||||
"Successfully imported profile '{}' from '{}'",
|
||||
new_profile_name,
|
||||
source_path.display()
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get a default version for a browser type
|
||||
fn get_default_version_for_browser(
|
||||
&self,
|
||||
browser_type: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Check if any version of the browser is downloaded
|
||||
let registry =
|
||||
crate::downloaded_browsers::DownloadedBrowsersRegistry::load().unwrap_or_default();
|
||||
let downloaded_versions = registry.get_downloaded_versions(browser_type);
|
||||
|
||||
if let Some(version) = downloaded_versions.first() {
|
||||
return Ok(version.clone());
|
||||
}
|
||||
|
||||
// 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
|
||||
fn copy_directory_recursive(
|
||||
source: &Path,
|
||||
destination: &Path,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
if !destination.exists() {
|
||||
create_dir_all(destination)?;
|
||||
}
|
||||
|
||||
for entry in fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let source_path = entry.path();
|
||||
let dest_path = destination.join(entry.file_name());
|
||||
|
||||
if source_path.is_dir() {
|
||||
Self::copy_directory_recursive(&source_path, &dest_path)?;
|
||||
} else {
|
||||
fs::copy(&source_path, &dest_path)?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
#[tauri::command]
|
||||
pub async fn detect_existing_profiles() -> Result<Vec<DetectedProfile>, String> {
|
||||
let importer = ProfileImporter::new();
|
||||
importer
|
||||
.detect_existing_profiles()
|
||||
.map_err(|e| format!("Failed to detect existing profiles: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_browser_profile(
|
||||
source_path: String,
|
||||
browser_type: String,
|
||||
new_profile_name: String,
|
||||
) -> Result<(), String> {
|
||||
let importer = ProfileImporter::new();
|
||||
importer
|
||||
.import_profile(&source_path, &browser_type, &new_profile_name)
|
||||
.map_err(|e| format!("Failed to import profile: {e}"))
|
||||
}
|
||||
+713
-39
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -163,21 +412,15 @@ impl ProxyManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Get proxy settings for a browser process ID
|
||||
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(),
|
||||
port: proxy.local_port,
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored proxy info for a profile
|
||||
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<(String, u16)> {
|
||||
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}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,3 +428,434 @@ impl ProxyManager {
|
||||
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_binary = nodecar_dir.join("nodecar-bin");
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
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(60), 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(1)).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(60), 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();
|
||||
|
||||
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 output = tokio::time::timeout(Duration::from_secs(60), 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 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(60), 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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,9 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::version_updater;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct TableSortingSettings {
|
||||
pub column: String, // Column to sort by: "name", "browser", "status"
|
||||
@@ -22,33 +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,
|
||||
}
|
||||
|
||||
fn default_show_settings_on_startup() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
"system".to_string()
|
||||
}
|
||||
|
||||
fn default_auto_updates_enabled() -> 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(),
|
||||
show_settings_on_startup: true,
|
||||
theme: "system".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -58,12 +50,16 @@ pub struct SettingsManager {
|
||||
}
|
||||
|
||||
impl SettingsManager {
|
||||
pub fn new() -> Self {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static SettingsManager {
|
||||
&SETTINGS_MANAGER
|
||||
}
|
||||
|
||||
pub fn get_settings_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
@@ -163,18 +159,11 @@ impl SettingsManager {
|
||||
// 3. User hasn't explicitly disabled the default browser setting
|
||||
Ok(settings.show_settings_on_startup && !settings.set_as_default_browser)
|
||||
}
|
||||
|
||||
pub fn disable_default_browser_prompt(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut settings = self.load_settings()?;
|
||||
settings.show_settings_on_startup = false;
|
||||
self.save_settings(&settings)?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_settings() -> Result<AppSettings, String> {
|
||||
let manager = SettingsManager::new();
|
||||
let manager = SettingsManager::instance();
|
||||
manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))
|
||||
@@ -182,7 +171,7 @@ pub async fn get_app_settings() -> Result<AppSettings, String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_app_settings(settings: AppSettings) -> Result<(), String> {
|
||||
let manager = SettingsManager::new();
|
||||
let manager = SettingsManager::instance();
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))
|
||||
@@ -190,23 +179,15 @@ pub async fn save_app_settings(settings: AppSettings) -> Result<(), String> {
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn should_show_settings_on_startup() -> Result<bool, String> {
|
||||
let manager = SettingsManager::new();
|
||||
let manager = SettingsManager::instance();
|
||||
manager
|
||||
.should_show_settings_on_startup()
|
||||
.map_err(|e| format!("Failed to check prompt setting: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn disable_default_browser_prompt() -> Result<(), String> {
|
||||
let manager = SettingsManager::new();
|
||||
manager
|
||||
.disable_default_browser_prompt()
|
||||
.map_err(|e| format!("Failed to disable prompt: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String> {
|
||||
let manager = SettingsManager::new();
|
||||
let manager = SettingsManager::instance();
|
||||
manager
|
||||
.load_table_sorting()
|
||||
.map_err(|e| format!("Failed to load table sorting settings: {e}"))
|
||||
@@ -214,8 +195,35 @@ pub async fn get_table_sorting_settings() -> Result<TableSortingSettings, String
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Result<(), String> {
|
||||
let manager = SettingsManager::new();
|
||||
let manager = SettingsManager::instance();
|
||||
manager
|
||||
.save_table_sorting(&sorting)
|
||||
.map_err(|e| format!("Failed to save table sorting settings: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_all_version_cache_and_refetch(
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let api_client = ApiClient::instance();
|
||||
|
||||
// Clear all cache first
|
||||
api_client
|
||||
.clear_all_cache()
|
||||
.map_err(|e| format!("Failed to clear version cache: {e}"))?;
|
||||
|
||||
let updater = version_updater::get_version_updater();
|
||||
let updater_guard = updater.lock().await;
|
||||
|
||||
updater_guard
|
||||
.trigger_manual_update(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to trigger version update: {e}"))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref SETTINGS_MANAGER: SettingsManager = SettingsManager::new();
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct SystemTheme {
|
||||
pub theme: String, // "light", "dark", or "unknown"
|
||||
}
|
||||
|
||||
pub struct ThemeDetector;
|
||||
|
||||
impl ThemeDetector {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Detect the system theme preference
|
||||
pub fn detect_system_theme(&self) -> SystemTheme {
|
||||
#[cfg(target_os = "linux")]
|
||||
return linux::detect_system_theme();
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
return macos::detect_system_theme();
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
return windows::detect_system_theme();
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))]
|
||||
return SystemTheme {
|
||||
theme: "unknown".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux {
|
||||
use super::*;
|
||||
|
||||
pub fn detect_system_theme() -> SystemTheme {
|
||||
// Try multiple methods in order of preference
|
||||
|
||||
// 1. Try GNOME/GTK settings via gsettings
|
||||
if let Ok(theme) = detect_gnome_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 2. Try KDE Plasma settings via kreadconfig5/kreadconfig6
|
||||
if let Ok(theme) = detect_kde_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 3. Try XFCE settings via xfconf-query
|
||||
if let Ok(theme) = detect_xfce_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 4. Try looking at current GTK theme name
|
||||
if let Ok(theme) = detect_gtk_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 5. Try dconf directly (fallback for GNOME-based systems)
|
||||
if let Ok(theme) = detect_dconf_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 6. Try environment variables
|
||||
if let Ok(theme) = detect_env_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 7. Try freedesktop portal
|
||||
if let Ok(theme) = detect_portal_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// 8. Try looking at system color scheme files
|
||||
if let Ok(theme) = detect_system_files_theme() {
|
||||
return SystemTheme { theme };
|
||||
}
|
||||
|
||||
// Fallback to unknown
|
||||
SystemTheme {
|
||||
theme: "unknown".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_gnome_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Check if gsettings is available
|
||||
if !is_command_available("gsettings") {
|
||||
return Err("gsettings not available".into());
|
||||
}
|
||||
|
||||
// Try GNOME color scheme first (modern way)
|
||||
if let Ok(output) = Command::new("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "color-scheme"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
match scheme.as_str() {
|
||||
"'prefer-dark'" => return Ok("dark".to_string()),
|
||||
"'prefer-light'" => return Ok("light".to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to GTK theme name detection
|
||||
if let Ok(output) = Command::new("gsettings")
|
||||
.args(["get", "org.gnome.desktop.interface", "gtk-theme"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme_name = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.trim_matches('\'')
|
||||
.to_lowercase();
|
||||
|
||||
if theme_name.contains("dark") || theme_name.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect GNOME theme".into())
|
||||
}
|
||||
|
||||
fn detect_kde_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Try KDE Plasma 6 first
|
||||
if is_command_available("kreadconfig6") {
|
||||
if let Ok(output) = Command::new("kreadconfig6")
|
||||
.args([
|
||||
"--file",
|
||||
"kdeglobals",
|
||||
"--group",
|
||||
"KDE",
|
||||
"--key",
|
||||
"LookAndFeelPackage",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if theme.contains("dark") || theme.contains("breezedark") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme.contains("light") || theme.contains("breeze") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try color scheme as well
|
||||
if let Ok(output) = Command::new("kreadconfig6")
|
||||
.args([
|
||||
"--file",
|
||||
"kdeglobals",
|
||||
"--group",
|
||||
"General",
|
||||
"--key",
|
||||
"ColorScheme",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let scheme = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if scheme.contains("dark") || scheme.contains("breezedark") {
|
||||
return Ok("dark".to_string());
|
||||
} else if scheme.contains("light") || scheme.contains("breeze") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try KDE Plasma 5 as fallback
|
||||
if is_command_available("kreadconfig5") {
|
||||
if let Ok(output) = Command::new("kreadconfig5")
|
||||
.args([
|
||||
"--file",
|
||||
"kdeglobals",
|
||||
"--group",
|
||||
"KDE",
|
||||
"--key",
|
||||
"LookAndFeelPackage",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if theme.contains("dark") || theme.contains("breezedark") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme.contains("light") || theme.contains("breeze") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect KDE theme".into())
|
||||
}
|
||||
|
||||
fn detect_xfce_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
if !is_command_available("xfconf-query") {
|
||||
return Err("xfconf-query not available".into());
|
||||
}
|
||||
|
||||
// Check XFCE theme
|
||||
if let Ok(output) = Command::new("xfconf-query")
|
||||
.args(["-c", "xsettings", "-p", "/Net/ThemeName"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if theme.contains("dark") || theme.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme.contains("light") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check XFCE window manager theme as backup
|
||||
if let Ok(output) = Command::new("xfconf-query")
|
||||
.args(["-c", "xfwm4", "-p", "/general/theme"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
if theme.contains("dark") || theme.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme.contains("light") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect XFCE theme".into())
|
||||
}
|
||||
|
||||
fn detect_gtk_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Try to read GTK3 settings file
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let gtk3_settings = std::path::Path::new(&home).join(".config/gtk-3.0/settings.ini");
|
||||
if gtk3_settings.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(gtk3_settings) {
|
||||
for line in content.lines() {
|
||||
if line.starts_with("gtk-theme-name=") {
|
||||
let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase();
|
||||
if theme_name.contains("dark") || theme_name.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try GTK4 settings
|
||||
let gtk4_settings = std::path::Path::new(&home).join(".config/gtk-4.0/settings.ini");
|
||||
if gtk4_settings.exists() {
|
||||
if let Ok(content) = std::fs::read_to_string(gtk4_settings) {
|
||||
for line in content.lines() {
|
||||
if line.starts_with("gtk-theme-name=") {
|
||||
let theme_name = line.split('=').nth(1).unwrap_or("").trim().to_lowercase();
|
||||
if theme_name.contains("dark") || theme_name.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect GTK theme".into())
|
||||
}
|
||||
|
||||
fn detect_dconf_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
if !is_command_available("dconf") {
|
||||
return Err("dconf not available".into());
|
||||
}
|
||||
|
||||
// Try reading color scheme directly from dconf
|
||||
if let Ok(output) = Command::new("dconf")
|
||||
.args(["read", "/org/gnome/desktop/interface/color-scheme"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let scheme = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
match scheme.as_str() {
|
||||
"'prefer-dark'" => return Ok("dark".to_string()),
|
||||
"'prefer-light'" => return Ok("light".to_string()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try reading GTK theme from dconf
|
||||
if let Ok(output) = Command::new("dconf")
|
||||
.args(["read", "/org/gnome/desktop/interface/gtk-theme"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let theme_name = String::from_utf8_lossy(&output.stdout)
|
||||
.trim()
|
||||
.trim_matches('\'')
|
||||
.to_lowercase();
|
||||
|
||||
if theme_name.contains("dark") || theme_name.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_name.contains("light") || theme_name.contains("adwaita") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect dconf theme".into())
|
||||
}
|
||||
|
||||
fn detect_env_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Check common environment variables
|
||||
if let Ok(theme) = std::env::var("GTK_THEME") {
|
||||
let theme_lower = theme.to_lowercase();
|
||||
if theme_lower.contains("dark") || theme_lower.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_lower.contains("light") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(theme) = std::env::var("QT_STYLE_OVERRIDE") {
|
||||
let theme_lower = theme.to_lowercase();
|
||||
if theme_lower.contains("dark") || theme_lower.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
} else if theme_lower.contains("light") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect theme from environment".into())
|
||||
}
|
||||
|
||||
fn detect_portal_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
if !is_command_available("busctl") {
|
||||
return Err("busctl not available".into());
|
||||
}
|
||||
|
||||
// Try to query the color scheme via org.freedesktop.portal.Settings
|
||||
if let Ok(output) = Command::new("busctl")
|
||||
.args([
|
||||
"--user",
|
||||
"call",
|
||||
"org.freedesktop.portal.Desktop",
|
||||
"/org/freedesktop/portal/desktop",
|
||||
"org.freedesktop.portal.Settings",
|
||||
"Read",
|
||||
"ss",
|
||||
"org.freedesktop.appearance",
|
||||
"color-scheme",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let response = String::from_utf8_lossy(&output.stdout);
|
||||
// Parse DBus response - look for preference values
|
||||
if response.contains(" 1 ") {
|
||||
return Ok("dark".to_string());
|
||||
} else if response.contains(" 2 ") {
|
||||
return Ok("light".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect portal theme".into())
|
||||
}
|
||||
|
||||
fn detect_system_files_theme() -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Check if we're in a dark terminal (heuristic)
|
||||
if let Ok(term) = std::env::var("TERM") {
|
||||
let term_lower = term.to_lowercase();
|
||||
if term_lower.contains("dark") || term_lower.contains("night") {
|
||||
return Ok("dark".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
// Check if we can determine from desktop session
|
||||
if let Ok(desktop) = std::env::var("XDG_CURRENT_DESKTOP") {
|
||||
let desktop_lower = desktop.to_lowercase();
|
||||
// Some desktops default to dark
|
||||
if desktop_lower.contains("i3") || desktop_lower.contains("sway") {
|
||||
// Window managers often use dark themes by default
|
||||
return Ok("dark".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Err("Could not detect theme from system files".into())
|
||||
}
|
||||
|
||||
fn is_command_available(command: &str) -> bool {
|
||||
Command::new("which")
|
||||
.arg(command)
|
||||
.output()
|
||||
.map(|output| output.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
mod macos {
|
||||
use super::*;
|
||||
|
||||
pub fn detect_system_theme() -> SystemTheme {
|
||||
// macOS theme detection using osascript
|
||||
if let Ok(output) = Command::new("osascript")
|
||||
.args([
|
||||
"-e",
|
||||
"tell application \"System Events\" to tell appearance preferences to get dark mode",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let result = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let result = result.trim();
|
||||
match result {
|
||||
"true" => {
|
||||
return SystemTheme {
|
||||
theme: "dark".to_string(),
|
||||
}
|
||||
}
|
||||
"false" => {
|
||||
return SystemTheme {
|
||||
theme: "light".to_string(),
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback method using defaults
|
||||
if let Ok(output) = Command::new("defaults")
|
||||
.args(["read", "-g", "AppleInterfaceStyle"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let style = String::from_utf8_lossy(&output.stdout).to_string();
|
||||
let style = style.trim();
|
||||
if style.to_lowercase() == "dark" {
|
||||
return SystemTheme {
|
||||
theme: "dark".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to light if we can't determine
|
||||
SystemTheme {
|
||||
theme: "light".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
mod windows {
|
||||
use super::*;
|
||||
|
||||
pub fn detect_system_theme() -> SystemTheme {
|
||||
// Windows theme detection via registry
|
||||
// This is a simplified implementation - you might want to use winreg crate for better registry access
|
||||
if let Ok(output) = Command::new("reg")
|
||||
.args([
|
||||
"query",
|
||||
"HKEY_CURRENT_USER\\Software\\Microsoft\\Windows\\CurrentVersion\\Themes\\Personalize",
|
||||
"/v",
|
||||
"AppsUseLightTheme",
|
||||
])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let result = String::from_utf8_lossy(&output.stdout);
|
||||
if result.contains("0x0") {
|
||||
return SystemTheme {
|
||||
theme: "dark".to_string(),
|
||||
};
|
||||
} else if result.contains("0x1") {
|
||||
return SystemTheme {
|
||||
theme: "light".to_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Default to light if we can't determine
|
||||
SystemTheme {
|
||||
theme: "light".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Command to expose this functionality to the frontend
|
||||
#[tauri::command]
|
||||
pub fn get_system_theme() -> SystemTheme {
|
||||
let detector = ThemeDetector::new();
|
||||
detector.detect_system_theme()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_theme_detector_creation() {
|
||||
let detector = ThemeDetector::new();
|
||||
let theme = detector.detect_system_theme();
|
||||
|
||||
// Should return a valid theme string
|
||||
assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_system_theme_command() {
|
||||
let theme = get_system_theme();
|
||||
assert!(matches!(theme.theme.as_str(), "light" | "dark" | "unknown"));
|
||||
}
|
||||
}
|
||||
+117
-126
@@ -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 {
|
||||
@@ -45,23 +47,24 @@ impl Default for BackgroundUpdateState {
|
||||
}
|
||||
|
||||
pub struct VersionUpdater {
|
||||
version_service: BrowserVersionService,
|
||||
app_handle: Arc<Mutex<Option<tauri::AppHandle>>>,
|
||||
update_interval: Interval,
|
||||
version_service: &'static BrowserVersionService,
|
||||
auto_updater: &'static 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,
|
||||
version_service: BrowserVersionService::instance(),
|
||||
auto_updater: AutoUpdater::instance(),
|
||||
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)
|
||||
}
|
||||
@@ -448,22 +455,6 @@ pub async fn get_version_update_status() -> Result<(Option<u64>, u64), String> {
|
||||
Ok((last_update, time_until_next))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_version_update_needed() -> Result<bool, String> {
|
||||
Ok(VersionUpdater::should_run_background_update())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn force_version_update_check(_app_handle: tauri::AppHandle) -> Result<bool, String> {
|
||||
let updater = get_version_updater();
|
||||
let updater_guard = updater.lock().await;
|
||||
|
||||
match updater_guard.check_and_run_startup_update().await {
|
||||
Ok(_) => Ok(true),
|
||||
Err(e) => Err(format!("Failed to run version update check: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
+25
-12
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut Browser",
|
||||
"version": "0.2.1",
|
||||
"version": "0.7.2",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -10,15 +10,7 @@
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Donut Browser",
|
||||
"width": 900,
|
||||
"height": 600,
|
||||
"resizable": false,
|
||||
"fullscreen": false
|
||||
}
|
||||
],
|
||||
"windows": [],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
@@ -26,6 +18,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"category": "Productivity",
|
||||
"externalBin": ["binaries/nodecar"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
@@ -45,12 +38,32 @@
|
||||
"files": {
|
||||
"Info.plist": "Info.plist"
|
||||
}
|
||||
},
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": ["xdg-utils"],
|
||||
"files": {
|
||||
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
|
||||
}
|
||||
},
|
||||
"rpm": {
|
||||
"depends": ["xdg-utils"],
|
||||
"files": {
|
||||
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
|
||||
}
|
||||
},
|
||||
"appimage": {
|
||||
"files": {
|
||||
"usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {
|
||||
"deep-link": {
|
||||
"schemes": ["http", "https"],
|
||||
"domains": []
|
||||
"desktop": {
|
||||
"schemes": ["http", "https"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tokio::time::timeout;
|
||||
|
||||
/// Utility functions for integration tests
|
||||
pub struct TestUtils;
|
||||
|
||||
impl TestUtils {
|
||||
/// Build the nodecar binary if it doesn't exist
|
||||
pub async fn ensure_nodecar_binary() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
let cargo_manifest_dir = env::var("CARGO_MANIFEST_DIR")?;
|
||||
let project_root = PathBuf::from(cargo_manifest_dir)
|
||||
.parent()
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
let nodecar_dir = project_root.join("nodecar");
|
||||
let nodecar_binary = nodecar_dir.join("nodecar-bin");
|
||||
|
||||
// Check if binary already exists
|
||||
if nodecar_binary.exists() {
|
||||
return Ok(nodecar_binary);
|
||||
}
|
||||
|
||||
println!("Building nodecar binary for integration tests...");
|
||||
|
||||
// Install dependencies
|
||||
let install_status = Command::new("pnpm")
|
||||
.args(["install", "--frozen-lockfile"])
|
||||
.current_dir(&nodecar_dir)
|
||||
.status()?;
|
||||
|
||||
if !install_status.success() {
|
||||
return Err("Failed to install nodecar dependencies".into());
|
||||
}
|
||||
|
||||
// Build the binary
|
||||
let build_status = Command::new("pnpm")
|
||||
.args(["run", "build"])
|
||||
.current_dir(&nodecar_dir)
|
||||
.status()?;
|
||||
|
||||
if !build_status.success() {
|
||||
return Err("Failed to build nodecar binary".into());
|
||||
}
|
||||
|
||||
if !nodecar_binary.exists() {
|
||||
return Err("Nodecar binary was not created successfully".into());
|
||||
}
|
||||
|
||||
Ok(nodecar_binary)
|
||||
}
|
||||
|
||||
/// Get the appropriate build target for the current platform
|
||||
#[allow(dead_code)]
|
||||
fn get_build_target() -> &'static str {
|
||||
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 {
|
||||
panic!("Unsupported target architecture for nodecar build")
|
||||
}
|
||||
}
|
||||
|
||||
/// Execute a nodecar command with timeout
|
||||
pub async fn execute_nodecar_command(
|
||||
binary_path: &PathBuf,
|
||||
args: &[&str],
|
||||
timeout_secs: u64,
|
||||
) -> Result<std::process::Output, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cmd = Command::new(binary_path);
|
||||
cmd.args(args);
|
||||
|
||||
let output = timeout(Duration::from_secs(timeout_secs), async {
|
||||
tokio::process::Command::from(cmd).output().await
|
||||
})
|
||||
.await??;
|
||||
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Check if a port is available
|
||||
pub async fn is_port_available(port: u16) -> bool {
|
||||
tokio::net::TcpListener::bind(format!("127.0.0.1:{port}"))
|
||||
.await
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// Wait for a port to become available or occupied
|
||||
pub async fn wait_for_port_state(port: u16, should_be_occupied: bool, timeout_secs: u64) -> bool {
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
while start.elapsed().as_secs() < timeout_secs {
|
||||
let is_available = Self::is_port_available(port).await;
|
||||
|
||||
if should_be_occupied && !is_available {
|
||||
return true; // Port is occupied as expected
|
||||
} else if !should_be_occupied && is_available {
|
||||
return true; // Port is available as expected
|
||||
}
|
||||
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Create a temporary directory for test files
|
||||
pub fn create_temp_dir() -> Result<tempfile::TempDir, Box<dyn std::error::Error + Send + Sync>> {
|
||||
Ok(tempfile::tempdir()?)
|
||||
}
|
||||
|
||||
/// Clean up all running nodecar processes (proxies and camoufox instances)
|
||||
pub async fn cleanup_all_nodecar_processes(
|
||||
nodecar_path: &PathBuf,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Cleaning up all nodecar processes...");
|
||||
|
||||
// Get list of all proxies and stop them individually
|
||||
let proxy_list_args = ["proxy", "list"];
|
||||
if let Ok(list_output) = Self::execute_nodecar_command(nodecar_path, &proxy_list_args, 10).await
|
||||
{
|
||||
if list_output.status.success() {
|
||||
let list_stdout = String::from_utf8(list_output.stdout)?;
|
||||
if let Ok(proxies) = serde_json::from_str::<serde_json::Value>(&list_stdout) {
|
||||
if let Some(proxy_array) = proxies.as_array() {
|
||||
for proxy in proxy_array {
|
||||
if let Some(proxy_id) = proxy["id"].as_str() {
|
||||
let stop_args = ["proxy", "stop", "--id", proxy_id];
|
||||
let _ = Self::execute_nodecar_command(nodecar_path, &stop_args, 10).await;
|
||||
println!("Stopped proxy: {proxy_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get list of all camoufox instances and stop them individually
|
||||
let camoufox_list_args = ["camoufox", "list"];
|
||||
if let Ok(list_output) =
|
||||
Self::execute_nodecar_command(nodecar_path, &camoufox_list_args, 10).await
|
||||
{
|
||||
if list_output.status.success() {
|
||||
let list_stdout = String::from_utf8(list_output.stdout)?;
|
||||
if let Ok(instances) = serde_json::from_str::<serde_json::Value>(&list_stdout) {
|
||||
if let Some(instance_array) = instances.as_array() {
|
||||
for instance in instance_array {
|
||||
if let Some(instance_id) = instance["id"].as_str() {
|
||||
let stop_args = ["camoufox", "stop", "--id", instance_id];
|
||||
let _ = Self::execute_nodecar_command(nodecar_path, &stop_args, 30).await;
|
||||
println!("Stopped camoufox instance: {instance_id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Give processes time to clean up
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
|
||||
println!("Nodecar process cleanup completed");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,820 @@
|
||||
mod common;
|
||||
use common::TestUtils;
|
||||
use serde_json::Value;
|
||||
|
||||
/// Setup function to ensure clean state before tests
|
||||
async fn setup_test() -> Result<std::path::PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = TestUtils::ensure_nodecar_binary().await?;
|
||||
|
||||
// Clean up any existing processes from previous test runs
|
||||
let _ = TestUtils::cleanup_all_nodecar_processes(&nodecar_path).await;
|
||||
|
||||
Ok(nodecar_path)
|
||||
}
|
||||
|
||||
/// Helper to track and cleanup specific test resources
|
||||
struct TestResourceTracker {
|
||||
proxy_ids: Vec<String>,
|
||||
camoufox_ids: Vec<String>,
|
||||
nodecar_path: std::path::PathBuf,
|
||||
}
|
||||
|
||||
impl TestResourceTracker {
|
||||
fn new(nodecar_path: std::path::PathBuf) -> Self {
|
||||
Self {
|
||||
proxy_ids: Vec::new(),
|
||||
camoufox_ids: Vec::new(),
|
||||
nodecar_path,
|
||||
}
|
||||
}
|
||||
|
||||
fn track_proxy(&mut self, proxy_id: String) {
|
||||
self.proxy_ids.push(proxy_id);
|
||||
}
|
||||
|
||||
fn track_camoufox(&mut self, camoufox_id: String) {
|
||||
self.camoufox_ids.push(camoufox_id);
|
||||
}
|
||||
|
||||
async fn cleanup_all(&self) {
|
||||
// Clean up tracked proxies
|
||||
for proxy_id in &self.proxy_ids {
|
||||
let stop_args = ["proxy", "stop", "--id", proxy_id];
|
||||
let _ = TestUtils::execute_nodecar_command(&self.nodecar_path, &stop_args, 10).await;
|
||||
}
|
||||
|
||||
// Clean up tracked camoufox instances
|
||||
for camoufox_id in &self.camoufox_ids {
|
||||
let stop_args = ["camoufox", "stop", "--id", camoufox_id];
|
||||
let _ = TestUtils::execute_nodecar_command(&self.nodecar_path, &stop_args, 30).await;
|
||||
}
|
||||
|
||||
// Give processes time to clean up
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestResourceTracker {
|
||||
fn drop(&mut self) {
|
||||
// Ensure cleanup happens even if test panics
|
||||
let proxy_ids = self.proxy_ids.clone();
|
||||
let camoufox_ids = self.camoufox_ids.clone();
|
||||
let nodecar_path = self.nodecar_path.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
for proxy_id in &proxy_ids {
|
||||
let stop_args = ["proxy", "stop", "--id", proxy_id];
|
||||
let _ = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 10).await;
|
||||
}
|
||||
|
||||
for camoufox_id in &camoufox_ids {
|
||||
let stop_args = ["camoufox", "stop", "--id", camoufox_id];
|
||||
let _ = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 30).await;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Integration tests for nodecar proxy functionality
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_proxy_lifecycle() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Test proxy start with a known working upstream
|
||||
let args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"httpbin.org",
|
||||
"--proxy-port",
|
||||
"80",
|
||||
"--type",
|
||||
"http",
|
||||
];
|
||||
|
||||
println!("Starting proxy with nodecar...");
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 30).await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
tracker.cleanup_all().await;
|
||||
return Err(format!("Proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Verify proxy configuration structure
|
||||
assert!(config["id"].is_string(), "Proxy ID should be a string");
|
||||
assert!(
|
||||
config["localPort"].is_number(),
|
||||
"Local port should be a number"
|
||||
);
|
||||
assert!(
|
||||
config["localUrl"].is_string(),
|
||||
"Local URL should be a string"
|
||||
);
|
||||
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
let local_port = config["localPort"].as_u64().unwrap() as u16;
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
println!("Proxy started with ID: {proxy_id} on port: {local_port}");
|
||||
|
||||
// Wait for the proxy to start listening
|
||||
let is_listening = TestUtils::wait_for_port_state(local_port, true, 10).await;
|
||||
assert!(
|
||||
is_listening,
|
||||
"Proxy should be listening on the assigned port"
|
||||
);
|
||||
|
||||
// Test stopping the proxy
|
||||
let stop_args = ["proxy", "stop", "--id", &proxy_id];
|
||||
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 10).await?;
|
||||
|
||||
assert!(stop_output.status.success(), "Proxy stop should succeed");
|
||||
|
||||
let port_available = TestUtils::wait_for_port_state(local_port, false, 5).await;
|
||||
assert!(
|
||||
port_available,
|
||||
"Port should be available after stopping proxy"
|
||||
);
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test proxy with authentication
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_proxy_with_auth() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"httpbin.org",
|
||||
"--proxy-port",
|
||||
"80",
|
||||
"--type",
|
||||
"http",
|
||||
"--username",
|
||||
"testuser",
|
||||
"--password",
|
||||
"testpass",
|
||||
];
|
||||
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 30).await?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
// Verify upstream URL contains encoded credentials
|
||||
if let Some(upstream_url) = config["upstreamUrl"].as_str() {
|
||||
assert!(
|
||||
upstream_url.contains("testuser"),
|
||||
"Upstream URL should contain username"
|
||||
);
|
||||
// Password might be encoded, so we check for the presence of auth info
|
||||
assert!(
|
||||
upstream_url.contains("@"),
|
||||
"Upstream URL should contain auth separator"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test proxy list functionality
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_proxy_list() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Start a proxy first
|
||||
let start_args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"httpbin.org",
|
||||
"--proxy-port",
|
||||
"80",
|
||||
"--type",
|
||||
"http",
|
||||
];
|
||||
|
||||
let start_output = TestUtils::execute_nodecar_command(&nodecar_path, &start_args, 30).await?;
|
||||
|
||||
if start_output.status.success() {
|
||||
let stdout = String::from_utf8(start_output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
// Test list command
|
||||
let list_args = ["proxy", "list"];
|
||||
let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args, 10).await?;
|
||||
|
||||
assert!(list_output.status.success(), "Proxy list should succeed");
|
||||
|
||||
let list_stdout = String::from_utf8(list_output.stdout)?;
|
||||
let proxy_list: Value = serde_json::from_str(&list_stdout)?;
|
||||
|
||||
assert!(proxy_list.is_array(), "Proxy list should be an array");
|
||||
|
||||
let proxies = proxy_list.as_array().unwrap();
|
||||
assert!(
|
||||
!proxies.is_empty(),
|
||||
"Should have at least one proxy in the list"
|
||||
);
|
||||
|
||||
// Find our proxy in the list
|
||||
let found_proxy = proxies.iter().find(|p| p["id"].as_str() == Some(&proxy_id));
|
||||
assert!(found_proxy.is_some(), "Started proxy should be in the list");
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox functionality
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_lifecycle() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let temp_dir = TestUtils::create_temp_dir()?;
|
||||
let profile_path = temp_dir.path().join("test_profile");
|
||||
|
||||
let args = [
|
||||
"camoufox",
|
||||
"start",
|
||||
"--profile-path",
|
||||
profile_path.to_str().unwrap(),
|
||||
"--headless",
|
||||
"--debug",
|
||||
];
|
||||
|
||||
println!("Starting Camoufox with nodecar...");
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 35).await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// If Camoufox is not installed or times out, skip the test
|
||||
if stderr.contains("not installed")
|
||||
|| stderr.contains("not found")
|
||||
|| stderr.contains("timeout")
|
||||
|| stdout.contains("timeout")
|
||||
{
|
||||
println!("Skipping Camoufox test - Camoufox not available or timed out");
|
||||
tracker.cleanup_all().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
return Err(format!("Camoufox start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
// Verify Camoufox configuration structure
|
||||
assert!(config["id"].is_string(), "Camoufox ID should be a string");
|
||||
|
||||
let camoufox_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_camoufox(camoufox_id.clone());
|
||||
println!("Camoufox started with ID: {camoufox_id}");
|
||||
|
||||
// Test stopping Camoufox
|
||||
let stop_args = ["camoufox", "stop", "--id", &camoufox_id];
|
||||
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 30).await?;
|
||||
|
||||
assert!(stop_output.status.success(), "Camoufox stop should succeed");
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox with URL opening
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_with_url() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let temp_dir = TestUtils::create_temp_dir()?;
|
||||
let profile_path = temp_dir.path().join("test_profile_url");
|
||||
|
||||
let args = [
|
||||
"camoufox",
|
||||
"start",
|
||||
"--profile-path",
|
||||
profile_path.to_str().unwrap(),
|
||||
"--url",
|
||||
"https://httpbin.org/get",
|
||||
"--headless",
|
||||
"--debug",
|
||||
];
|
||||
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 15).await?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
let camoufox_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_camoufox(camoufox_id.clone());
|
||||
|
||||
// Verify URL is set
|
||||
if let Some(url) = config["url"].as_str() {
|
||||
assert_eq!(
|
||||
url, "https://httpbin.org/get",
|
||||
"URL should match what was provided"
|
||||
);
|
||||
}
|
||||
|
||||
// Test stopping Camoufox explicitly
|
||||
let stop_args = ["camoufox", "stop", "--id", &camoufox_id];
|
||||
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 30).await?;
|
||||
assert!(stop_output.status.success(), "Camoufox stop should succeed");
|
||||
} else {
|
||||
println!("Skipping Camoufox URL test - likely not installed");
|
||||
tracker.cleanup_all().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox list functionality
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_list() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Test list command (should work even without Camoufox installed)
|
||||
let list_args = ["camoufox", "list"];
|
||||
let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args, 10).await?;
|
||||
|
||||
assert!(list_output.status.success(), "Camoufox list should succeed");
|
||||
|
||||
let list_stdout = String::from_utf8(list_output.stdout)?;
|
||||
let camoufox_list: Value = serde_json::from_str(&list_stdout)?;
|
||||
|
||||
assert!(camoufox_list.is_array(), "Camoufox list should be an array");
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox process tracking and management
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_process_tracking(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let temp_dir = TestUtils::create_temp_dir()?;
|
||||
let profile_path = temp_dir.path().join("test_profile_tracking");
|
||||
|
||||
// Start multiple Camoufox instances
|
||||
let mut instance_ids: Vec<String> = Vec::new();
|
||||
|
||||
for i in 0..2 {
|
||||
let instance_profile_path = format!("{}_instance_{}", profile_path.to_str().unwrap(), i);
|
||||
let args = [
|
||||
"camoufox",
|
||||
"start",
|
||||
"--profile-path",
|
||||
&instance_profile_path,
|
||||
"--headless",
|
||||
"--debug",
|
||||
];
|
||||
|
||||
println!("Starting Camoufox instance {i}...");
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 10).await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// If Camoufox is not installed, skip the test
|
||||
if stderr.contains("not installed") || stderr.contains("not found") {
|
||||
println!("Skipping Camoufox process tracking test - Camoufox not installed");
|
||||
tracker.cleanup_all().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
return Err(
|
||||
format!("Camoufox instance {i} start failed - stdout: {stdout}, stderr: {stderr}").into(),
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
let camoufox_id = config["id"].as_str().unwrap().to_string();
|
||||
instance_ids.push(camoufox_id.clone());
|
||||
tracker.track_camoufox(camoufox_id.clone());
|
||||
println!("Camoufox instance {i} started with ID: {camoufox_id}");
|
||||
}
|
||||
|
||||
// Verify all instances are tracked
|
||||
let list_args = ["camoufox", "list"];
|
||||
let list_output = TestUtils::execute_nodecar_command(&nodecar_path, &list_args, 10).await?;
|
||||
|
||||
assert!(list_output.status.success(), "Camoufox list should succeed");
|
||||
|
||||
let list_stdout = String::from_utf8(list_output.stdout)?;
|
||||
println!("Camoufox list output: {list_stdout}");
|
||||
let instances: Value = serde_json::from_str(&list_stdout)?;
|
||||
|
||||
let instances_array = instances.as_array().unwrap();
|
||||
println!("Found {} instances in list", instances_array.len());
|
||||
|
||||
// Verify our instances are in the list
|
||||
for instance_id in &instance_ids {
|
||||
let instance_found = instances_array
|
||||
.iter()
|
||||
.any(|i| i["id"].as_str() == Some(instance_id));
|
||||
if !instance_found {
|
||||
println!("Instance {instance_id} not found in list. Available instances:");
|
||||
for instance in instances_array {
|
||||
if let Some(id) = instance["id"].as_str() {
|
||||
println!(" - {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
instance_found,
|
||||
"Camoufox instance {instance_id} should be found in list"
|
||||
);
|
||||
}
|
||||
|
||||
// Stop all instances individually
|
||||
for instance_id in &instance_ids {
|
||||
println!("Stopping Camoufox instance: {instance_id}");
|
||||
let stop_args = ["camoufox", "stop", "--id", instance_id];
|
||||
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 30).await?;
|
||||
|
||||
if stop_output.status.success() {
|
||||
let stop_stdout = String::from_utf8(stop_output.stdout)?;
|
||||
if let Ok(stop_result) = serde_json::from_str::<Value>(&stop_stdout) {
|
||||
let success = stop_result["success"].as_bool().unwrap_or(false);
|
||||
if !success {
|
||||
println!("Warning: Stop command returned success=false for instance {instance_id}");
|
||||
}
|
||||
} else {
|
||||
println!("Warning: Could not parse stop result for instance {instance_id}");
|
||||
}
|
||||
} else {
|
||||
println!("Warning: Stop command failed for instance {instance_id}");
|
||||
}
|
||||
}
|
||||
|
||||
// Verify all instances are removed
|
||||
let list_output_after = TestUtils::execute_nodecar_command(&nodecar_path, &list_args, 10).await?;
|
||||
|
||||
let instances_after: Value = serde_json::from_str(&String::from_utf8(list_output_after.stdout)?)?;
|
||||
let instances_after_array = instances_after.as_array().unwrap();
|
||||
|
||||
for instance_id in &instance_ids {
|
||||
let instance_still_exists = instances_after_array
|
||||
.iter()
|
||||
.any(|i| i["id"].as_str() == Some(instance_id));
|
||||
assert!(
|
||||
!instance_still_exists,
|
||||
"Stopped Camoufox instance {instance_id} should not be found in list"
|
||||
);
|
||||
}
|
||||
|
||||
println!("Camoufox process tracking test completed successfully");
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test Camoufox with various configuration options
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_camoufox_configuration_options(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let temp_dir = TestUtils::create_temp_dir()?;
|
||||
let profile_path = temp_dir.path().join("test_profile_config");
|
||||
|
||||
let args = [
|
||||
"camoufox",
|
||||
"start",
|
||||
"--profile-path",
|
||||
profile_path.to_str().unwrap(),
|
||||
"--headless",
|
||||
"--debug",
|
||||
"--os",
|
||||
"linux",
|
||||
"--block-images",
|
||||
"--humanize",
|
||||
"--locale",
|
||||
"en-US,en-GB",
|
||||
"--timezone",
|
||||
"America/New_York",
|
||||
"--disable-cache",
|
||||
];
|
||||
|
||||
println!("Starting Camoufox with configuration options...");
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 45).await?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
// If Camoufox is not installed, skip the test
|
||||
if stderr.contains("not installed") || stderr.contains("not found") {
|
||||
println!("Skipping Camoufox configuration test - Camoufox not installed");
|
||||
tracker.cleanup_all().await;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
return Err(
|
||||
format!("Camoufox with config start failed - stdout: {stdout}, stderr: {stderr}").into(),
|
||||
);
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
|
||||
let camoufox_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_camoufox(camoufox_id.clone());
|
||||
println!("Camoufox with configuration started with ID: {camoufox_id}");
|
||||
|
||||
// Verify configuration was applied by checking the profile path
|
||||
if let Some(returned_profile_path) = config["profilePath"].as_str() {
|
||||
assert!(
|
||||
returned_profile_path.contains("test_profile_config"),
|
||||
"Profile path should match what was provided"
|
||||
);
|
||||
}
|
||||
|
||||
// Test stopping Camoufox explicitly
|
||||
let stop_args = ["camoufox", "stop", "--id", &camoufox_id];
|
||||
let stop_output = TestUtils::execute_nodecar_command(&nodecar_path, &stop_args, 30).await?;
|
||||
|
||||
assert!(stop_output.status.success(), "Camoufox stop should succeed");
|
||||
|
||||
println!("Camoufox configuration test completed successfully");
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test nodecar command validation
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_command_validation() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Test invalid command
|
||||
let invalid_args = ["invalid", "command"];
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &invalid_args, 10).await?;
|
||||
|
||||
assert!(!output.status.success(), "Invalid command should fail");
|
||||
|
||||
// Test proxy without required arguments
|
||||
let incomplete_args = ["proxy", "start"];
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &incomplete_args, 10).await?;
|
||||
|
||||
assert!(
|
||||
!output.status.success(),
|
||||
"Incomplete proxy command should fail"
|
||||
);
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test concurrent proxy operations
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_concurrent_proxies() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Start multiple proxies concurrently
|
||||
let mut handles = vec![];
|
||||
|
||||
for i in 0..3 {
|
||||
let nodecar_path_clone = nodecar_path.clone();
|
||||
let handle = tokio::spawn(async move {
|
||||
let args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"httpbin.org",
|
||||
"--proxy-port",
|
||||
"80",
|
||||
"--type",
|
||||
"http",
|
||||
];
|
||||
|
||||
TestUtils::execute_nodecar_command(&nodecar_path_clone, &args, 30).await
|
||||
});
|
||||
handles.push((i, handle));
|
||||
}
|
||||
|
||||
// Wait for all proxies to start
|
||||
for (i, handle) in handles {
|
||||
match handle.await.map_err(|e| format!("Join error: {e}"))? {
|
||||
Ok(output) if output.status.success() => {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
println!("Proxy {i} started successfully");
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
println!("Proxy {i} failed to start: {stderr}");
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Proxy {i} error: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test proxy with different upstream types
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_proxy_types() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
let test_cases = vec![
|
||||
("http", "httpbin.org", "80"),
|
||||
("https", "httpbin.org", "443"),
|
||||
];
|
||||
|
||||
for (proxy_type, host, port) in test_cases {
|
||||
println!("Testing {proxy_type} proxy to {host}:{port}");
|
||||
|
||||
let args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
host,
|
||||
"--proxy-port",
|
||||
port,
|
||||
"--type",
|
||||
proxy_type,
|
||||
];
|
||||
|
||||
let output = TestUtils::execute_nodecar_command(&nodecar_path, &args, 30).await?;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
let config: Value = serde_json::from_str(&stdout)?;
|
||||
let proxy_id = config["id"].as_str().unwrap().to_string();
|
||||
tracker.track_proxy(proxy_id.clone());
|
||||
|
||||
println!("{proxy_type} proxy test passed");
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
println!("{proxy_type} proxy test failed: {stderr}");
|
||||
}
|
||||
}
|
||||
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Test SOCKS5 proxy chaining - create two proxies where the second uses the first as upstream
|
||||
#[tokio::test]
|
||||
async fn test_nodecar_socks5_proxy_chaining() -> Result<(), Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
let nodecar_path = setup_test().await?;
|
||||
let mut tracker = TestResourceTracker::new(nodecar_path.clone());
|
||||
|
||||
// Step 1: Start a SOCKS5 proxy with a known working upstream (httpbin.org)
|
||||
let socks5_args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--host",
|
||||
"httpbin.org",
|
||||
"--proxy-port",
|
||||
"80",
|
||||
"--type",
|
||||
"http", // Use HTTP upstream for the first proxy
|
||||
];
|
||||
|
||||
println!("Starting first proxy with HTTP upstream...");
|
||||
let socks5_output = TestUtils::execute_nodecar_command(&nodecar_path, &socks5_args, 30).await?;
|
||||
|
||||
if !socks5_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&socks5_output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&socks5_output.stdout);
|
||||
tracker.cleanup_all().await;
|
||||
return Err(format!("First proxy start failed - stdout: {stdout}, stderr: {stderr}").into());
|
||||
}
|
||||
|
||||
let socks5_stdout = String::from_utf8(socks5_output.stdout)?;
|
||||
let socks5_config: Value = serde_json::from_str(&socks5_stdout)?;
|
||||
|
||||
let socks5_proxy_id = socks5_config["id"].as_str().unwrap().to_string();
|
||||
let socks5_local_port = socks5_config["localPort"].as_u64().unwrap() as u16;
|
||||
tracker.track_proxy(socks5_proxy_id.clone());
|
||||
|
||||
println!("First proxy started with ID: {socks5_proxy_id} on port: {socks5_local_port}");
|
||||
|
||||
// Step 2: Start a second proxy that uses the first proxy as upstream
|
||||
let http_proxy_args = [
|
||||
"proxy",
|
||||
"start",
|
||||
"--upstream",
|
||||
&format!("http://127.0.0.1:{socks5_local_port}"),
|
||||
];
|
||||
|
||||
println!("Starting second proxy with first proxy as upstream...");
|
||||
let http_output = TestUtils::execute_nodecar_command(&nodecar_path, &http_proxy_args, 30).await?;
|
||||
|
||||
if !http_output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&http_output.stderr);
|
||||
let stdout = String::from_utf8_lossy(&http_output.stdout);
|
||||
tracker.cleanup_all().await;
|
||||
return Err(
|
||||
format!("Second proxy with chained upstream failed - stdout: {stdout}, stderr: {stderr}")
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let http_stdout = String::from_utf8(http_output.stdout)?;
|
||||
let http_config: Value = serde_json::from_str(&http_stdout)?;
|
||||
|
||||
let http_proxy_id = http_config["id"].as_str().unwrap().to_string();
|
||||
let http_local_port = http_config["localPort"].as_u64().unwrap() as u16;
|
||||
tracker.track_proxy(http_proxy_id.clone());
|
||||
|
||||
println!(
|
||||
"Second proxy started with ID: {http_proxy_id} on port: {http_local_port} (chained through first proxy)"
|
||||
);
|
||||
|
||||
// Verify both proxies are listening by waiting for them to be occupied
|
||||
let socks5_listening = TestUtils::wait_for_port_state(socks5_local_port, true, 5).await;
|
||||
let http_listening = TestUtils::wait_for_port_state(http_local_port, true, 5).await;
|
||||
|
||||
assert!(
|
||||
socks5_listening,
|
||||
"First proxy should be listening on port {socks5_local_port}"
|
||||
);
|
||||
assert!(
|
||||
http_listening,
|
||||
"Second proxy should be listening on port {http_local_port}"
|
||||
);
|
||||
|
||||
// Clean up both proxies
|
||||
let stop_http_args = ["proxy", "stop", "--id", &http_proxy_id];
|
||||
let stop_socks5_args = ["proxy", "stop", "--id", &socks5_proxy_id];
|
||||
|
||||
let http_stop_result =
|
||||
TestUtils::execute_nodecar_command(&nodecar_path, &stop_http_args, 10).await;
|
||||
let socks5_stop_result =
|
||||
TestUtils::execute_nodecar_command(&nodecar_path, &stop_socks5_args, 10).await;
|
||||
|
||||
// Verify cleanup
|
||||
assert!(
|
||||
http_stop_result.is_ok() && http_stop_result.unwrap().status.success(),
|
||||
"Second proxy stop should succeed"
|
||||
);
|
||||
assert!(
|
||||
socks5_stop_result.is_ok() && socks5_stop_result.unwrap().status.success(),
|
||||
"First proxy stop should succeed"
|
||||
);
|
||||
|
||||
let http_port_available = TestUtils::wait_for_port_state(http_local_port, false, 5).await;
|
||||
let socks5_port_available = TestUtils::wait_for_port_state(socks5_local_port, false, 5).await;
|
||||
|
||||
assert!(
|
||||
http_port_available,
|
||||
"Second proxy port should be available after stopping"
|
||||
);
|
||||
assert!(
|
||||
socks5_port_available,
|
||||
"First proxy port should be available after stopping"
|
||||
);
|
||||
|
||||
println!("Proxy chaining test completed successfully");
|
||||
tracker.cleanup_all().await;
|
||||
Ok(())
|
||||
}
|
||||
+3
-1
@@ -4,6 +4,7 @@ import "@/styles/globals.css";
|
||||
import { CustomThemeProvider } from "@/components/theme-provider";
|
||||
import { Toaster } from "@/components/ui/sonner";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { WindowDragArea } from "@/components/window-drag-area";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
@@ -23,11 +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>
|
||||
<TooltipProvider>{children}</TooltipProvider>
|
||||
<Toaster />
|
||||
<WindowDragArea />
|
||||
</CustomThemeProvider>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+573
-249
File diff suppressed because it is too large
Load Diff
@@ -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,129 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import type { BrowserProfile, CamoufoxConfig } from "@/types";
|
||||
|
||||
interface CamoufoxConfigDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
profile: BrowserProfile | null;
|
||||
onSave: (profile: BrowserProfile, config: CamoufoxConfig) => Promise<void>;
|
||||
}
|
||||
|
||||
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">
|
||||
{/* OS Warning */}
|
||||
{showOSWarning && (
|
||||
<div className="mb-6 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>
|
||||
)}
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={config}
|
||||
onConfigChange={updateConfig}
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
|
||||
interface CreateGroupDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onGroupCreated: (group: ProfileGroup) => void;
|
||||
}
|
||||
|
||||
export function CreateGroupDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onGroupCreated,
|
||||
}: CreateGroupDialogProps) {
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!groupName.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newGroup = await invoke<ProfileGroup>("create_profile_group", {
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group created successfully");
|
||||
onGroupCreated(newGroup);
|
||||
setGroupName("");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to create group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to create group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [groupName, onGroupCreated, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setGroupName("");
|
||||
setError(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new group to organize your browser profiles.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && groupName.trim()) {
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
isLoading={isCreating}
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!groupName.trim()}
|
||||
>
|
||||
Create Group
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -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,17 +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 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"
|
||||
@@ -38,7 +35,8 @@ type BrowserTypeString =
|
||||
| "chromium"
|
||||
| "brave"
|
||||
| "zen"
|
||||
| "tor-browser";
|
||||
| "tor-browser"
|
||||
| "camoufox";
|
||||
|
||||
interface CreateProfileDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -47,171 +45,228 @@ 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: "TOR Browser fork by Mullvad VPN",
|
||||
},
|
||||
{
|
||||
value: "tor-browser",
|
||||
label: "Tor Browser",
|
||||
description: "Browse anonymously through the Tor network",
|
||||
},
|
||||
];
|
||||
|
||||
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 [supportedBrowsers, setSupportedBrowsers] = useState<
|
||||
BrowserTypeString[]
|
||||
>([]);
|
||||
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();
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadSupportedBrowsers();
|
||||
void loadExistingProfiles();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && selectedBrowser) {
|
||||
// Reset selected version when browser changes
|
||||
setSelectedVersion(null);
|
||||
void loadVersions(selectedBrowser);
|
||||
void loadDownloadedVersions(selectedBrowser);
|
||||
}
|
||||
}, [isOpen, selectedBrowser, loadVersions, loadDownloadedVersions]);
|
||||
|
||||
// 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_alpha);
|
||||
|
||||
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 loadSupportedBrowsers = async () => {
|
||||
const loadSupportedBrowsers = useCallback(async () => {
|
||||
try {
|
||||
const browsers = await invoke<BrowserTypeString[]>(
|
||||
"get_supported_browsers",
|
||||
);
|
||||
const browsers = await invoke<string[]>("get_supported_browsers");
|
||||
setSupportedBrowsers(browsers);
|
||||
if (browsers.includes("mullvad-browser")) {
|
||||
setSelectedBrowser("mullvad-browser");
|
||||
} else if (browsers.length > 0) {
|
||||
setSelectedBrowser(browsers[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load supported browsers:", error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const loadExistingProfiles = async () => {
|
||||
const loadStoredProxies = useCallback(async () => {
|
||||
try {
|
||||
const profiles = await invoke<BrowserProfile[]>("list_browser_profiles");
|
||||
setExistingProfiles(profiles);
|
||||
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(proxies);
|
||||
} catch (error) {
|
||||
console.error("Failed to load existing profiles:", error);
|
||||
console.error("Failed to load stored proxies:", error);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDownload = async () => {
|
||||
if (!selectedBrowser || !selectedVersion) return;
|
||||
await downloadBrowser(selectedBrowser, selectedVersion);
|
||||
};
|
||||
const loadReleaseTypes = useCallback(
|
||||
async (browser: string) => {
|
||||
try {
|
||||
const releaseTypes = await invoke<BrowserReleaseTypes>(
|
||||
"get_browser_release_types",
|
||||
{ browserStr: browser },
|
||||
);
|
||||
|
||||
const validateProfileName = (name: string): string | null => {
|
||||
const trimmedName = name.trim();
|
||||
if (browser === "camoufox") {
|
||||
setCamoufoxReleaseTypes(releaseTypes);
|
||||
} else {
|
||||
setAvailableReleaseTypes(releaseTypes);
|
||||
}
|
||||
|
||||
if (!trimmedName) {
|
||||
return "Profile name cannot be empty";
|
||||
}
|
||||
// Load downloaded versions for this browser
|
||||
await loadDownloadedVersions(browser);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load release types for ${browser}:`, error);
|
||||
}
|
||||
},
|
||||
[loadDownloadedVersions],
|
||||
);
|
||||
|
||||
// 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
|
||||
// Load data when dialog opens
|
||||
useEffect(() => {
|
||||
if (selectedBrowser === "tor-browser" && proxyEnabled) {
|
||||
setProxyEnabled(false);
|
||||
if (isOpen) {
|
||||
void loadSupportedBrowsers();
|
||||
void loadStoredProxies();
|
||||
// Load camoufox release types when dialog opens
|
||||
void loadReleaseTypes("camoufox");
|
||||
}
|
||||
}, [selectedBrowser, proxyEnabled]);
|
||||
}, [isOpen, loadSupportedBrowsers, loadStoredProxies, loadReleaseTypes]);
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!profileName.trim() || !selectedBrowser || !selectedVersion) return;
|
||||
// Load release types when browser selection changes
|
||||
useEffect(() => {
|
||||
if (selectedBrowser) {
|
||||
void loadReleaseTypes(selectedBrowser);
|
||||
}
|
||||
}, [selectedBrowser, loadReleaseTypes]);
|
||||
|
||||
// Validate profile name
|
||||
const nameError = validateProfileName(profileName);
|
||||
if (nameError) {
|
||||
toast.error(nameError);
|
||||
// Helper function to get the best available version and release type
|
||||
const getBestAvailableVersion = useCallback(
|
||||
(releaseTypes: BrowserReleaseTypes) => {
|
||||
if (releaseTypes.stable) {
|
||||
return { version: releaseTypes.stable, releaseType: "stable" as const };
|
||||
}
|
||||
if (releaseTypes.nightly) {
|
||||
return {
|
||||
version: releaseTypes.nightly,
|
||||
releaseType: "nightly" as const,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDownload = async (browserStr: string) => {
|
||||
const releaseTypes =
|
||||
browserStr === "camoufox" ? camoufoxReleaseTypes : availableReleaseTypes;
|
||||
const bestVersion = getBestAvailableVersion(releaseTypes);
|
||||
|
||||
if (!bestVersion) {
|
||||
console.error("No version available for download");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadBrowser(browserStr, bestVersion.version);
|
||||
} 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 best available version (stable preferred, nightly as fallback)
|
||||
const bestVersion = getBestAvailableVersion(availableReleaseTypes);
|
||||
if (!bestVersion) {
|
||||
console.error("No version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset form
|
||||
setProfileName("");
|
||||
setSelectedVersion(null);
|
||||
setProxyEnabled(false);
|
||||
setProxyHost("");
|
||||
setProxyPort(8080);
|
||||
onClose();
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: selectedBrowser,
|
||||
version: bestVersion.version,
|
||||
releaseType: bestVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
});
|
||||
} else {
|
||||
// Anti-detect tab - always use Camoufox with best available version
|
||||
const bestCamoufoxVersion =
|
||||
getBestAvailableVersion(camoufoxReleaseTypes);
|
||||
if (!bestCamoufoxVersion) {
|
||||
console.error("No Camoufox version available");
|
||||
return;
|
||||
}
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "camoufox" as BrowserTypeString,
|
||||
version: bestCamoufoxVersion.version,
|
||||
releaseType: bestCamoufoxVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
camoufoxConfig,
|
||||
});
|
||||
}
|
||||
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to create profile:", error);
|
||||
} finally {
|
||||
@@ -219,182 +274,233 @@ 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 || !getBestAvailableVersion(availableReleaseTypes)
|
||||
);
|
||||
} else {
|
||||
// For anti-detect, we need camoufox to be available
|
||||
return !getBestAvailableVersion(camoufoxReleaseTypes);
|
||||
}
|
||||
};
|
||||
|
||||
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 bestVersion = getBestAvailableVersion(releaseTypes);
|
||||
return bestVersion && isVersionDownloaded(bestVersion.version);
|
||||
};
|
||||
|
||||
// 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);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select browser" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{supportedBrowsers.map((browser) => (
|
||||
<SelectItem key={browser} value={browser}>
|
||||
{browser
|
||||
.split("-")
|
||||
.map(
|
||||
(word) => word.charAt(0).toUpperCase() + word.slice(1),
|
||||
)
|
||||
.join(" ")}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<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"
|
||||
/>
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{/* 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) &&
|
||||
getBestAvailableVersion(availableReleaseTypes) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion = getBestAvailableVersion(
|
||||
availableReleaseTypes,
|
||||
);
|
||||
return `${bestVersion?.releaseType === "stable" ? "Latest stable" : "Latest nightly"} version (${bestVersion?.version}) 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">
|
||||
{(() => {
|
||||
const bestVersion = getBestAvailableVersion(
|
||||
availableReleaseTypes,
|
||||
);
|
||||
return `✓ ${bestVersion?.releaseType === "stable" ? "Latest stable" : "Latest nightly"} version (${bestVersion?.version}) 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">
|
||||
{/* 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") &&
|
||||
getBestAvailableVersion(camoufoxReleaseTypes) && (
|
||||
<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">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(camoufoxReleaseTypes);
|
||||
return `Camoufox ${bestVersion?.releaseType} version (${bestVersion?.version}) 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">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(camoufoxReleaseTypes);
|
||||
return `✓ Camoufox ${bestVersion?.releaseType} version (${bestVersion?.version}) 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>
|
||||
);
|
||||
|
||||
+198
-30
@@ -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";
|
||||
|
||||
@@ -71,7 +77,12 @@ interface ErrorToastProps extends BaseToastProps {
|
||||
|
||||
interface DownloadToastProps extends BaseToastProps {
|
||||
type: "download";
|
||||
stage?: "downloading" | "extracting" | "verifying" | "completed";
|
||||
stage?:
|
||||
| "downloading"
|
||||
| "extracting"
|
||||
| "verifying"
|
||||
| "completed"
|
||||
| "downloading (twilight rolling release)";
|
||||
progress?: {
|
||||
percentage: number;
|
||||
speed?: string;
|
||||
@@ -85,6 +96,7 @@ interface VersionUpdateToastProps extends BaseToastProps {
|
||||
current: number;
|
||||
total: number;
|
||||
found: number;
|
||||
current_browser?: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -93,38 +105,79 @@ interface FetchingToastProps extends BaseToastProps {
|
||||
browserName?: string;
|
||||
}
|
||||
|
||||
interface TwilightUpdateToastProps extends BaseToastProps {
|
||||
type: "twilight-update";
|
||||
browserName?: string;
|
||||
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
|
||||
| ErrorToastProps
|
||||
| DownloadToastProps
|
||||
| VersionUpdateToastProps
|
||||
| FetchingToastProps;
|
||||
| FetchingToastProps
|
||||
| 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="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" />
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -134,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>
|
||||
|
||||
@@ -149,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`}
|
||||
@@ -164,31 +239,98 @@ 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>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
<p className="text-xs text-gray-600 dark:text-gray-300">
|
||||
{"hasUpdate" in props && props.hasUpdate
|
||||
? "New twilight build available for download"
|
||||
: "Checking for twilight updates..."}
|
||||
</p>
|
||||
{props.browserName && (
|
||||
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
{props.browserName} • Rolling Release
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
)}
|
||||
@@ -203,7 +345,33 @@ 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)" && (
|
||||
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
Downloading rolling release build...
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Stage-specific descriptions for app updates */}
|
||||
{type === "app-update" && !description && (
|
||||
<>
|
||||
{stage === "extracting" && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Preparing update files...
|
||||
</p>
|
||||
)}
|
||||
{stage === "installing" && (
|
||||
<p className="mt-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
Installing new version...
|
||||
</p>
|
||||
)}
|
||||
{stage === "completed" && (
|
||||
<p className="mt-1 text-xs text-green-600 dark:text-green-400">
|
||||
Update completed! Restarting application...
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface DeleteConfirmationDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmButtonText?: string;
|
||||
isLoading?: boolean;
|
||||
profileNames?: string[];
|
||||
}
|
||||
|
||||
export function DeleteConfirmationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmButtonText = "Delete",
|
||||
isLoading = false,
|
||||
profileNames,
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
{profileNames && profileNames.length > 0 && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium mb-2">
|
||||
Profiles to be deleted:
|
||||
</p>
|
||||
<div className="bg-muted rounded-md p-3 max-h-32 overflow-y-auto">
|
||||
<ul className="space-y-1">
|
||||
{profileNames.map((name) => (
|
||||
<li key={name} className="text-sm text-muted-foreground">
|
||||
• {name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Deleting..." : confirmButtonText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"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,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { BrowserProfile, ProfileGroup } from "@/types";
|
||||
|
||||
interface DeleteGroupDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
group: ProfileGroup | null;
|
||||
onGroupDeleted: () => void;
|
||||
}
|
||||
|
||||
export function DeleteGroupDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
group,
|
||||
onGroupDeleted,
|
||||
}: DeleteGroupDialogProps) {
|
||||
const [associatedProfiles, setAssociatedProfiles] = useState<
|
||||
BrowserProfile[]
|
||||
>([]);
|
||||
const [deleteAction, setDeleteAction] = useState<"move" | "delete">("move");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAssociatedProfiles = useCallback(async () => {
|
||||
if (!group) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const allProfiles = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
const groupProfiles = allProfiles.filter(
|
||||
(profile) => profile.group_id === group.id,
|
||||
);
|
||||
setAssociatedProfiles(groupProfiles);
|
||||
} catch (err) {
|
||||
console.error("Failed to load associated profiles:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load profiles");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [group]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && group) {
|
||||
void loadAssociatedProfiles();
|
||||
}
|
||||
}, [isOpen, group, loadAssociatedProfiles]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!group) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (deleteAction === "delete" && associatedProfiles.length > 0) {
|
||||
// Delete all associated profiles first
|
||||
const profileNames = associatedProfiles.map((p) => p.name);
|
||||
await invoke("delete_selected_profiles", { profileNames });
|
||||
} else if (deleteAction === "move" && associatedProfiles.length > 0) {
|
||||
// Move profiles to default group (null group_id)
|
||||
const profileNames = associatedProfiles.map((p) => p.name);
|
||||
await invoke("assign_profiles_to_group", {
|
||||
profileNames,
|
||||
groupId: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the group
|
||||
await invoke("delete_profile_group", { groupId: group.id });
|
||||
|
||||
toast.success("Group deleted successfully");
|
||||
onGroupDeleted();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to delete group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
setDeleteAction("move");
|
||||
setAssociatedProfiles([]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the group
|
||||
"{group?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading associated profiles...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{associatedProfiles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Associated Profiles ({associatedProfiles.length})
|
||||
</Label>
|
||||
<ScrollArea className="h-32 w-full border rounded-md p-3">
|
||||
<div className="space-y-1">
|
||||
{associatedProfiles.map((profile) => (
|
||||
<div key={profile.id} className="text-sm">
|
||||
• {profile.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>What should happen to these profiles?</Label>
|
||||
<RadioGroup
|
||||
value={deleteAction}
|
||||
onValueChange={(value) =>
|
||||
setDeleteAction(value as "move" | "delete")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="move" id="move" />
|
||||
<Label htmlFor="move" className="text-sm">
|
||||
Move profiles to Default group
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="delete" id="delete" />
|
||||
<Label
|
||||
htmlFor="delete"
|
||||
className="text-sm text-red-600"
|
||||
>
|
||||
Delete profiles along with the group
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{associatedProfiles.length === 0 && !isLoading && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This group has no associated profiles.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
isLoading={isDeleting}
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete Group
|
||||
{deleteAction === "delete" &&
|
||||
associatedProfiles.length > 0 &&
|
||||
" & Profiles"}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"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,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
|
||||
interface EditGroupDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
group: ProfileGroup | null;
|
||||
onGroupUpdated: (group: ProfileGroup) => void;
|
||||
}
|
||||
|
||||
export function EditGroupDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
group,
|
||||
onGroupUpdated,
|
||||
}: EditGroupDialogProps) {
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (group) {
|
||||
setGroupName(group.name);
|
||||
} else {
|
||||
setGroupName("");
|
||||
}
|
||||
setError(null);
|
||||
}, [group]);
|
||||
|
||||
const handleUpdate = useCallback(async () => {
|
||||
if (!group || !groupName.trim()) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updatedGroup = await invoke<ProfileGroup>("update_profile_group", {
|
||||
groupId: group.id,
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group updated successfully");
|
||||
onGroupUpdated(updatedGroup);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to update group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to update group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [group, groupName, onGroupUpdated, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the name of the group "{group?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && groupName.trim()) {
|
||||
void handleUpdate();
|
||||
}
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isUpdating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
isLoading={isUpdating}
|
||||
onClick={() => void handleUpdate()}
|
||||
disabled={!groupName.trim() || groupName === group?.name}
|
||||
>
|
||||
Update Group
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user