mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-05-04 01:25:12 +02:00
Compare commits
105 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ce3e27ca64 | |||
| fd0fb8c7ca | |||
| 701c8aefd3 | |||
| d4a7c347b6 | |||
| 3c3e6df3b2 | |||
| cd4b23bd27 | |||
| 042a348971 | |||
| b8f4e4adda | |||
| e8852a3caf | |||
| 6ed1adafc8 | |||
| 22e6b2762e | |||
| bc7c8d1a1e | |||
| b133f928d4 | |||
| 02185e0480 | |||
| 6402ff302a | |||
| ed830ed789 | |||
| d03f598567 | |||
| 6aedf58264 | |||
| 636f1ea4ba | |||
| adb253e103 | |||
| e12ac66c7a | |||
| e06a824438 | |||
| 4293b7eab5 | |||
| 68b138d5ff | |||
| b79bd94506 | |||
| 181c76980a | |||
| 274b275c03 | |||
| 821cce0986 | |||
| 716a028923 | |||
| 7c25bd3ba2 | |||
| 6d89098263 | |||
| a1a1a2202e | |||
| 485daae40e | |||
| 9f22c57b7a | |||
| 45d959e407 | |||
| d75a367f39 | |||
| a48eb5d631 | |||
| 0d79f385bd | |||
| 25bb1dccdc | |||
| 97044d58fe | |||
| 4748a31714 | |||
| d91c97dd85 | |||
| 8e299fddd4 | |||
| 6c3c9fb58a | |||
| f5066e866b | |||
| e12a5661b1 | |||
| f8a4ec3277 | |||
| 1e5664e3b2 | |||
| d0fea2fec1 | |||
| ce0627030d | |||
| d70ec16706 | |||
| 5863d5549e | |||
| 4df35515ae | |||
| 59f430ec43 | |||
| 9f68a21824 | |||
| 9bf7f39c0c | |||
| d1b45778c4 | |||
| 6d6527d812 | |||
| c30df278fb | |||
| 95592b4aa1 | |||
| 58b0067b37 | |||
| 6260d78901 | |||
| efab286dad | |||
| e51e31911b | |||
| 348a727da7 | |||
| 10f8061acf | |||
| 69348a101e | |||
| f7e116f345 | |||
| 2e6bb2498b | |||
| 178f07bec7 | |||
| c6caf0633e | |||
| 29fe20af09 | |||
| 1cb8e7236d | |||
| ab256cd695 | |||
| 96c42ae55e | |||
| c98e12900f | |||
| 7a0d14642a | |||
| a1f153f4fa | |||
| ff9ad0a5ad | |||
| 3b78fea62a | |||
| 74e1642aa2 | |||
| c9d37519f7 | |||
| da9e1d1b58 | |||
| 77f93cc122 | |||
| f7ccca0075 | |||
| d7c2f08133 | |||
| 8dffd86ab9 | |||
| f3b3207489 | |||
| d7a787586d | |||
| 4a98eedba0 | |||
| 95ee807f3b | |||
| fac99f4a51 | |||
| 88cb154fca | |||
| a6af568d9e | |||
| 7c2ed1e0fc | |||
| 334f894e68 | |||
| a77b733a31 | |||
| c10c3b0f95 | |||
| 4b16341401 | |||
| 016d423d2c | |||
| 0596cc4009 | |||
| 269db678b7 | |||
| f809b975f3 | |||
| e369214715 | |||
| 5f93841bb7 |
@@ -0,0 +1,6 @@
|
||||
---
|
||||
description:
|
||||
globs:
|
||||
alwaysApply: true
|
||||
---
|
||||
If you are modifying the UI, do not add random colors that are not controlled by src/lib/themes.ts file.
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
# build-mode: none
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: x86_64-unknown-linux-gnu
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -73,7 +73,7 @@ jobs:
|
||||
compat-lookup: true
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Auto-merge minor and patch updates
|
||||
uses: ridedott/merge-me-action@f96a67511b4be051e77760230e6a3fb9cb7b1903 #v2.10.124
|
||||
uses: ridedott/merge-me-action@ad649157c69da4d34e601ee360de7a74ce4e2090 #v2.10.126
|
||||
with:
|
||||
GITHUB_TOKEN: ${{ secrets.SECRET_DEPENDABOT_GITHUB_TOKEN }}
|
||||
MERGE_METHOD: SQUASH
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/first-interaction@2d4393e6bc0e2efb2e48fba7e06819c3bf61ffc9 # v2.0.0
|
||||
- uses: actions/first-interaction@753c925c8d1ac6fede23781875376600628d9b5d # v3.0.0
|
||||
with:
|
||||
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."
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
|
||||
- name: Get issue templates
|
||||
id: get-templates
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Validate issue with AI
|
||||
id: validate
|
||||
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
|
||||
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
|
||||
with:
|
||||
prompt-file: issue_analysis.txt
|
||||
system-prompt: |
|
||||
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest]
|
||||
os: [macos-latest, ubuntu-22.04]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -41,7 +41,7 @@ jobs:
|
||||
run: git config --global core.autocrlf false
|
||||
|
||||
- name: Checkout repository code
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
|
||||
- name: Set up pnpm package manager
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
@@ -53,7 +53,7 @@ jobs:
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Rust toolchain
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
|
||||
with:
|
||||
toolchain: stable
|
||||
components: rustfmt, clippy
|
||||
@@ -65,7 +65,7 @@ jobs:
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install dependencies (Ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
if: matrix.os == 'ubuntu-22.04'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt install libwebkit2gtk-4.1-dev build-essential curl wget file libxdo-dev libssl-dev libayatana-appindicator3-dev librsvg2-dev
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
shell: bash
|
||||
working-directory: ./nodecar
|
||||
run: |
|
||||
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
|
||||
if [[ "${{ matrix.os }}" == "ubuntu-22.04" ]]; then
|
||||
pnpm run build:linux-x64
|
||||
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
|
||||
pnpm run build:mac-aarch64
|
||||
@@ -94,7 +94,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
|
||||
if [[ "${{ matrix.os }}" == "ubuntu-22.04" ]]; then
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-aarch64-apple-darwin
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
scan-scheduled:
|
||||
name: Scheduled Security Scan
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
scan-pr:
|
||||
name: PR Security Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
with:
|
||||
fetch-depth: 0 # Fetch full history to compare with previous release
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
- name: Generate release notes with AI
|
||||
id: generate-notes
|
||||
uses: actions/ai-inference@b81b2afb8390ee6839b494a404766bef6493c7d9 # v1.2.8
|
||||
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
|
||||
with:
|
||||
prompt-file: commits.txt
|
||||
system-prompt: |
|
||||
|
||||
@@ -13,7 +13,7 @@ env:
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
@@ -116,7 +116,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
|
||||
uses: tauri-apps/tauri-action@e834788a94591d81e3ae0bd9ec06366f5afb8994 #v0.5.23
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
@@ -174,8 +174,8 @@ jobs:
|
||||
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]"
|
||||
# - 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]"
|
||||
|
||||
@@ -12,7 +12,7 @@ env:
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@90b209d0ea55cea1da9fc0c4e65782cc6acb6e2e" # v2.2.2
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -104,7 +104,7 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
@@ -115,7 +115,7 @@ jobs:
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -170,7 +170,7 @@ jobs:
|
||||
echo "Generated timestamp: ${TIMESTAMP}-${COMMIT_HASH}"
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
|
||||
uses: tauri-apps/tauri-action@e834788a94591d81e3ae0bd9ec06366f5afb8994 #v0.5.23
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
BUILD_TAG: "nightly-${{ steps.timestamp.outputs.timestamp }}"
|
||||
|
||||
@@ -21,6 +21,6 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout Actions Repository
|
||||
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
- name: Spell Check Repo
|
||||
uses: crate-ci/typos@52bd719c2c91f9d676e2aa359fc8e0db8925e6d8 #v1.35.3
|
||||
uses: crate-ci/typos@65f69f021b736bdbe548ce72200500752d42b40e #v1.35.7
|
||||
|
||||
Vendored
+10
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"ABORTIFHUNG",
|
||||
"adwaita",
|
||||
"ahooks",
|
||||
"akhilmhdh",
|
||||
@@ -20,6 +21,7 @@
|
||||
"CFURL",
|
||||
"checkin",
|
||||
"chrono",
|
||||
"ciphertext",
|
||||
"CLICOLOR",
|
||||
"clippy",
|
||||
"cmdk",
|
||||
@@ -31,6 +33,7 @@
|
||||
"dataclasses",
|
||||
"datareporting",
|
||||
"datas",
|
||||
"DBAPI",
|
||||
"dconf",
|
||||
"devedition",
|
||||
"distro",
|
||||
@@ -81,8 +84,10 @@
|
||||
"libwebkit",
|
||||
"libxdo",
|
||||
"localtime",
|
||||
"lpdw",
|
||||
"lxml",
|
||||
"lzma",
|
||||
"Matchalk",
|
||||
"mmdb",
|
||||
"mountpoint",
|
||||
"msiexec",
|
||||
@@ -111,6 +116,8 @@
|
||||
"peerconnection",
|
||||
"pids",
|
||||
"pixbuf",
|
||||
"pkexec",
|
||||
"pkill",
|
||||
"plasmohq",
|
||||
"platformdirs",
|
||||
"prefs",
|
||||
@@ -130,7 +137,9 @@
|
||||
"SARIF",
|
||||
"scipy",
|
||||
"screeninfo",
|
||||
"selectables",
|
||||
"serde",
|
||||
"SETTINGCHANGE",
|
||||
"setuptools",
|
||||
"shadcn",
|
||||
"showcursor",
|
||||
@@ -138,6 +147,7 @@
|
||||
"signon",
|
||||
"signum",
|
||||
"sklearn",
|
||||
"SMTO",
|
||||
"sonner",
|
||||
"splitn",
|
||||
"sspi",
|
||||
|
||||
@@ -6,3 +6,4 @@
|
||||
- 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.
|
||||
- If you are modifying the UI, do not add random colors that are not controlled by src/lib/themes.ts file.
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
- Create unlimited number of local browser profiles completely isolated from each other
|
||||
- Safely use multiple accounts on one device by using anti-detect browser profiles, powered by [Camoufox](https://camoufox.com)
|
||||
- Proxy support with basic auth for all browsers except for TOR Browser
|
||||
- Proxy support with basic auth for all browsers
|
||||
- Import profiles from your existing browsers
|
||||
- Automatic updates for browsers
|
||||
- Set Donut Browser as your default browser to control in which profile to open links
|
||||
|
||||
+4
-4
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.0.6/schema.json",
|
||||
"$schema": "https://biomejs.dev/schemas/2.2.0/schema.json",
|
||||
"vcs": {
|
||||
"enabled": false,
|
||||
"clientKind": "git",
|
||||
@@ -18,11 +18,11 @@
|
||||
"rules": {
|
||||
"recommended": true,
|
||||
"correctness": {
|
||||
"useUniqueElementIds": "off",
|
||||
"useHookAtTopLevel": "error"
|
||||
},
|
||||
"nursery": {
|
||||
"useUniqueElementIds": "off"
|
||||
},
|
||||
"nursery": "off",
|
||||
"suspicious": "off",
|
||||
"a11y": {
|
||||
"useSemanticElements": "off"
|
||||
}
|
||||
|
||||
Vendored
+1
@@ -1,5 +1,6 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
/// <reference path="./.next/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -7,6 +7,9 @@ const nextConfig: NextConfig = {
|
||||
unoptimized: true,
|
||||
},
|
||||
distDir: "dist",
|
||||
compiler: {
|
||||
removeConsole: process.env.NODE_ENV === "production",
|
||||
},
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
"author": "",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/node": "^24.3.0",
|
||||
"commander": "^14.0.0",
|
||||
"donutbrowser-camoufox-js": "^0.6.6",
|
||||
"dotenv": "^17.2.1",
|
||||
"fingerprint-generator": "^2.1.69",
|
||||
"fingerprint-generator": "^2.1.70",
|
||||
"get-port": "^7.1.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"playwright-core": "^1.54.2",
|
||||
"playwright-core": "^1.55.0",
|
||||
"proxy-chain": "^2.5.9",
|
||||
"tmp": "^0.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
+31
-27
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.9.2",
|
||||
"version": "0.12.2",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -21,34 +21,37 @@
|
||||
"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"
|
||||
"check-unused-commands": "cd src-tauri && cargo test test_no_unused_tauri_commands"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-checkbox": "^1.3.2",
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.15",
|
||||
"@radix-ui/react-checkbox": "^1.3.3",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@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-radio-group": "^1.3.8",
|
||||
"@radix-ui/react-scroll-area": "^1.2.10",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-tooltip": "^1.2.8",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "^2.7.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.2",
|
||||
"@tauri-apps/plugin-fs": "~2.4.1",
|
||||
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||
"ahooks": "^3.9.0",
|
||||
"@tauri-apps/api": "^2.8.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.2",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.3",
|
||||
"@tauri-apps/plugin-fs": "~2.4.2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"ahooks": "^3.9.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.0",
|
||||
"lucide-react": "^0.542.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "^15.4.6",
|
||||
"next": "^15.5.2",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-icons": "^5.5.0",
|
||||
@@ -57,18 +60,19 @@
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.4",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tauri-apps/cli": "^2.7.1",
|
||||
"@types/node": "^24.2.1",
|
||||
"@types/react": "^19.1.9",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@biomejs/biome": "2.2.2",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tauri-apps/cli": "^2.8.3",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.2",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.5",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
|
||||
|
||||
Generated
+1756
-1002
File diff suppressed because it is too large
Load Diff
Generated
+527
-274
File diff suppressed because it is too large
Load Diff
+12
-4
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.9.2"
|
||||
version = "0.12.2"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -29,10 +29,12 @@ 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", "sync"] }
|
||||
sysinfo = "0.36"
|
||||
sysinfo = "0.37"
|
||||
lazy_static = "1.4"
|
||||
base64 = "0.22"
|
||||
async-trait = "0.1"
|
||||
@@ -47,6 +49,12 @@ msi-extract = "0"
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
url = "2.5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
axum = "0.8.4"
|
||||
tower = "0.5"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
rand = "0.9.2"
|
||||
argon2 = "0.5"
|
||||
aes-gcm = "0.10"
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
@@ -71,9 +79,9 @@ windows = { version = "0.61", features = [
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3.13.0"
|
||||
tempfile = "3.21.0"
|
||||
wiremock = "0.6"
|
||||
hyper = { version = "1.0", features = ["full"] }
|
||||
hyper = { version = "1.7", features = ["full"] }
|
||||
hyper-util = { version = "0.1", features = ["full"] }
|
||||
http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
|
||||
@@ -26,5 +26,13 @@ fn main() {
|
||||
println!("cargo:rustc-env=BUILD_VERSION=dev-{version}");
|
||||
}
|
||||
|
||||
// Inject vault password at build time
|
||||
if let Ok(vault_password) = std::env::var("DONUT_BROWSER_VAULT_PASSWORD") {
|
||||
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD={vault_password}");
|
||||
} else {
|
||||
// Use default password if environment variable is not set
|
||||
println!("cargo:rustc-env=DONUT_BROWSER_VAULT_PASSWORD=donutbrowser-api-vault-password");
|
||||
}
|
||||
|
||||
tauri_build::build()
|
||||
}
|
||||
|
||||
+34
-10
@@ -221,6 +221,20 @@ pub fn sort_versions(versions: &mut [String]) {
|
||||
});
|
||||
}
|
||||
|
||||
// Helper function to compare two versions
|
||||
pub fn compare_versions(version1: &str, version2: &str) -> std::cmp::Ordering {
|
||||
let version_a = VersionComponent::parse(version1);
|
||||
let version_b = VersionComponent::parse(version2);
|
||||
version_a.cmp(&version_b)
|
||||
}
|
||||
|
||||
pub fn is_version_newer(version1: &str, version2: &str) -> bool {
|
||||
// Use the proper VersionComponent comparison from api_client.rs
|
||||
let version_a = VersionComponent::parse(version1);
|
||||
let version_b = VersionComponent::parse(version2);
|
||||
version_a > version_b
|
||||
}
|
||||
|
||||
// Helper function to sort GitHub releases
|
||||
pub fn sort_github_releases(releases: &mut [GithubRelease]) {
|
||||
releases.sort_by(|a, b| {
|
||||
@@ -268,7 +282,12 @@ pub fn is_browser_version_nightly(
|
||||
// Last resort: when no name available, treat as nightly (non-Release)
|
||||
true
|
||||
}
|
||||
"firefox" | "firefox-developer" => {
|
||||
"firefox-developer" => {
|
||||
// For Firefox Developer Edition, always treat as nightly/prerelease
|
||||
// This ensures consistent behavior regardless of cache state or API response parsing
|
||||
true
|
||||
}
|
||||
"firefox" => {
|
||||
// For Firefox, use the category from the API response to determine stability
|
||||
// This will be handled in the API parsing, so this fallback is for cached versions
|
||||
is_nightly_version(version)
|
||||
@@ -334,9 +353,14 @@ pub struct ApiClient {
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
fn new() -> Self {
|
||||
pub fn new() -> Self {
|
||||
let client = Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(30))
|
||||
.build()
|
||||
.unwrap_or_else(|_| Client::new());
|
||||
|
||||
Self {
|
||||
client: Client::new(),
|
||||
client,
|
||||
firefox_api_base: "https://product-details.mozilla.org/1.0".to_string(),
|
||||
firefox_dev_api_base: "https://product-details.mozilla.org/1.0".to_string(),
|
||||
github_api_base: "https://api.github.com".to_string(),
|
||||
@@ -647,19 +671,19 @@ impl ApiClient {
|
||||
|
||||
let response = self
|
||||
.client
|
||||
.get(url)
|
||||
.get(&url)
|
||||
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to fetch Firefox Developer Edition versions: {}",
|
||||
response.status()
|
||||
)
|
||||
.into(),
|
||||
let error_msg = format!(
|
||||
"Failed to fetch Firefox Developer Edition versions: {} - URL: {}",
|
||||
response.status(),
|
||||
url
|
||||
);
|
||||
eprintln!("{error_msg}");
|
||||
return Err(error_msg.into());
|
||||
}
|
||||
|
||||
let firefox_response: FirefoxApiResponse = response.json().await?;
|
||||
|
||||
@@ -0,0 +1,872 @@
|
||||
use crate::camoufox_manager::CamoufoxConfig;
|
||||
use crate::group_manager::GROUP_MANAGER;
|
||||
use crate::profile::manager::ProfileManager;
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use crate::tag_manager::TAG_MANAGER;
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
middleware::{self, Next},
|
||||
response::{Json, Response},
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use lazy_static::lazy_static;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tauri::Emitter;
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
// API Types
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct ApiProfile {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
pub proxy_id: Option<String>,
|
||||
pub process_id: Option<u32>,
|
||||
pub last_launch: Option<u64>,
|
||||
pub release_type: String,
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Vec<String>,
|
||||
pub is_running: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ApiProfilesResponse {
|
||||
pub profiles: Vec<ApiProfile>,
|
||||
pub total: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct ApiProfileResponse {
|
||||
pub profile: ApiProfile,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CreateProfileRequest {
|
||||
pub name: String,
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
pub proxy_id: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct UpdateProfileRequest {
|
||||
pub name: Option<String>,
|
||||
pub browser: Option<String>,
|
||||
pub version: Option<String>,
|
||||
pub proxy_id: Option<String>,
|
||||
pub release_type: Option<String>,
|
||||
pub camoufox_config: Option<serde_json::Value>,
|
||||
pub group_id: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct ApiServerState {
|
||||
app_handle: tauri::AppHandle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ApiGroupResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
profile_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateGroupRequest {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateGroupRequest {
|
||||
name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct ApiProxyResponse {
|
||||
id: String,
|
||||
name: String,
|
||||
proxy_settings: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct CreateProxyRequest {
|
||||
name: String,
|
||||
proxy_settings: serde_json::Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UpdateProxyRequest {
|
||||
name: Option<String>,
|
||||
proxy_settings: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct DownloadBrowserRequest {
|
||||
browser: String,
|
||||
version: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct DownloadBrowserResponse {
|
||||
browser: String,
|
||||
version: String,
|
||||
status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToastPayload {
|
||||
pub message: String,
|
||||
pub variant: String,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RunProfileResponse {
|
||||
profile_id: String,
|
||||
remote_debugging_port: u16,
|
||||
headless: bool,
|
||||
}
|
||||
|
||||
pub struct ApiServer {
|
||||
port: Option<u16>,
|
||||
shutdown_tx: Option<mpsc::Sender<()>>,
|
||||
task_handle: Option<tokio::task::JoinHandle<()>>,
|
||||
}
|
||||
|
||||
impl ApiServer {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
port: None,
|
||||
shutdown_tx: None,
|
||||
task_handle: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_port(&self) -> Option<u16> {
|
||||
self.port
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&mut self,
|
||||
app_handle: tauri::AppHandle,
|
||||
preferred_port: u16,
|
||||
) -> Result<u16, String> {
|
||||
// Stop existing server if running
|
||||
self.stop().await.ok();
|
||||
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel(1);
|
||||
let state = ApiServerState {
|
||||
app_handle: app_handle.clone(),
|
||||
};
|
||||
|
||||
// Try preferred port first, then random port
|
||||
let listener = match TcpListener::bind(format!("127.0.0.1:{preferred_port}")).await {
|
||||
Ok(listener) => listener,
|
||||
Err(_) => {
|
||||
// Port conflict, try random port
|
||||
let random_port = rand::random::<u16>().saturating_add(10000);
|
||||
match TcpListener::bind(format!("127.0.0.1:{random_port}")).await {
|
||||
Ok(listener) => {
|
||||
let _ = app_handle.emit(
|
||||
"api-port-conflict",
|
||||
format!("API server using fallback port {random_port}"),
|
||||
);
|
||||
listener
|
||||
}
|
||||
Err(e) => return Err(format!("Failed to bind to any port: {e}")),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let actual_port = listener
|
||||
.local_addr()
|
||||
.map_err(|e| format!("Failed to get local address: {e}"))?
|
||||
.port();
|
||||
|
||||
// Create router with CORS, authentication, and versioning
|
||||
let v1_routes = Router::new()
|
||||
.route("/profiles", get(get_profiles))
|
||||
.route("/profiles", post(create_profile))
|
||||
.route("/profiles/{id}", get(get_profile))
|
||||
.route("/profiles/{id}", put(update_profile))
|
||||
.route("/profiles/{id}", delete(delete_profile))
|
||||
.route("/profiles/{id}/run", post(run_profile))
|
||||
.route("/groups", get(get_groups).post(create_group))
|
||||
.route(
|
||||
"/groups/{id}",
|
||||
get(get_group).put(update_group).delete(delete_group),
|
||||
)
|
||||
.route("/tags", get(get_tags))
|
||||
.route("/proxies", get(get_proxies).post(create_proxy))
|
||||
.route(
|
||||
"/proxies/{id}",
|
||||
get(get_proxy).put(update_proxy).delete(delete_proxy),
|
||||
)
|
||||
.route("/browsers/download", post(download_browser_api))
|
||||
.route("/browsers/{browser}/versions", get(get_browser_versions))
|
||||
.route(
|
||||
"/browsers/{browser}/versions/{version}/downloaded",
|
||||
get(check_browser_downloaded),
|
||||
)
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
auth_middleware,
|
||||
));
|
||||
|
||||
let app = Router::new()
|
||||
.nest("/v1", v1_routes)
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(state);
|
||||
|
||||
// Start server task
|
||||
let task_handle = tokio::spawn(async move {
|
||||
let server = axum::serve(listener, app);
|
||||
tokio::select! {
|
||||
_ = server => {},
|
||||
_ = shutdown_rx.recv() => {},
|
||||
}
|
||||
});
|
||||
|
||||
self.port = Some(actual_port);
|
||||
self.shutdown_tx = Some(shutdown_tx);
|
||||
self.task_handle = Some(task_handle);
|
||||
|
||||
Ok(actual_port)
|
||||
}
|
||||
|
||||
async fn stop(&mut self) -> Result<(), String> {
|
||||
if let Some(shutdown_tx) = self.shutdown_tx.take() {
|
||||
let _ = shutdown_tx.send(()).await;
|
||||
}
|
||||
|
||||
if let Some(handle) = self.task_handle.take() {
|
||||
handle.abort();
|
||||
}
|
||||
|
||||
self.port = None;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// Authentication middleware
|
||||
async fn auth_middleware(
|
||||
State(state): State<ApiServerState>,
|
||||
headers: HeaderMap,
|
||||
request: axum::extract::Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// Get the Authorization header
|
||||
let auth_header = headers
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|h| h.strip_prefix("Bearer "));
|
||||
|
||||
let token = match auth_header {
|
||||
Some(token) => token,
|
||||
None => return Err(StatusCode::UNAUTHORIZED),
|
||||
};
|
||||
|
||||
// Get the stored token
|
||||
let settings_manager = crate::settings_manager::SettingsManager::instance();
|
||||
let stored_token = match settings_manager.get_api_token(&state.app_handle).await {
|
||||
Ok(Some(stored_token)) => stored_token,
|
||||
Ok(None) => return Err(StatusCode::UNAUTHORIZED),
|
||||
Err(_) => return Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
};
|
||||
|
||||
// Compare tokens
|
||||
if token != stored_token {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
// Token is valid, continue with the request
|
||||
Ok(next.run(request).await)
|
||||
}
|
||||
|
||||
// Global API server instance
|
||||
lazy_static! {
|
||||
pub static ref API_SERVER: Arc<Mutex<ApiServer>> = Arc::new(Mutex::new(ApiServer::new()));
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
#[tauri::command]
|
||||
pub async fn start_api_server_internal(
|
||||
port: u16,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<u16, String> {
|
||||
let mut server_guard = API_SERVER.lock().await;
|
||||
server_guard.start(app_handle.clone(), port).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_api_server() -> Result<(), String> {
|
||||
let mut server_guard = API_SERVER.lock().await;
|
||||
server_guard.stop().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_api_server(
|
||||
port: Option<u16>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<u16, String> {
|
||||
let actual_port = port.unwrap_or(10108);
|
||||
start_api_server_internal(actual_port, &app_handle).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_api_server_status() -> Result<Option<u16>, String> {
|
||||
let server_guard = API_SERVER.lock().await;
|
||||
Ok(server_guard.get_port())
|
||||
}
|
||||
|
||||
// API Handlers - Profiles
|
||||
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
match profile_manager.list_profiles() {
|
||||
Ok(profiles) => {
|
||||
let api_profiles: Vec<ApiProfile> = profiles
|
||||
.iter()
|
||||
.map(|profile| ApiProfile {
|
||||
id: profile.id.to_string(),
|
||||
name: profile.name.clone(),
|
||||
browser: profile.browser.clone(),
|
||||
version: profile.version.clone(),
|
||||
proxy_id: profile.proxy_id.clone(),
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
camoufox_config: profile
|
||||
.camoufox_config
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(Json(ApiProfilesResponse {
|
||||
profiles: api_profiles,
|
||||
total: profiles.len(),
|
||||
}))
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_profile(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
match profile_manager.list_profiles() {
|
||||
Ok(profiles) => {
|
||||
if let Some(profile) = profiles.iter().find(|p| p.id.to_string() == id) {
|
||||
Ok(Json(ApiProfileResponse {
|
||||
profile: ApiProfile {
|
||||
id: profile.id.to_string(),
|
||||
name: profile.name.clone(),
|
||||
browser: profile.browser.clone(),
|
||||
version: profile.version.clone(),
|
||||
proxy_id: profile.proxy_id.clone(),
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type.clone(),
|
||||
camoufox_config: profile
|
||||
.camoufox_config
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_profile(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateProfileRequest>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
|
||||
// Parse camoufox config if provided
|
||||
let camoufox_config = if let Some(config) = &request.camoufox_config {
|
||||
serde_json::from_value(config.clone()).ok()
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Create profile using the async create_profile_with_group method
|
||||
match profile_manager
|
||||
.create_profile_with_group(
|
||||
&state.app_handle,
|
||||
&request.name,
|
||||
&request.browser,
|
||||
&request.version,
|
||||
request.release_type.as_deref().unwrap_or("stable"),
|
||||
request.proxy_id.clone(),
|
||||
camoufox_config,
|
||||
request.group_id.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut profile) => {
|
||||
// Apply tags if provided
|
||||
if let Some(tags) = &request.tags {
|
||||
if profile_manager
|
||||
.update_profile_tags(&state.app_handle, &profile.name, tags.clone())
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::INTERNAL_SERVER_ERROR);
|
||||
}
|
||||
profile.tags = tags.clone();
|
||||
}
|
||||
|
||||
// Update tag manager with new tags
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
let _ = crate::tag_manager::TAG_MANAGER
|
||||
.lock()
|
||||
.map(|manager| manager.rebuild_from_profiles(&profiles));
|
||||
}
|
||||
|
||||
Ok(Json(ApiProfileResponse {
|
||||
profile: ApiProfile {
|
||||
id: profile.id.to_string(),
|
||||
name: profile.name,
|
||||
browser: profile.browser,
|
||||
version: profile.version,
|
||||
proxy_id: profile.proxy_id,
|
||||
process_id: profile.process_id,
|
||||
last_launch: profile.last_launch,
|
||||
release_type: profile.release_type,
|
||||
camoufox_config: profile
|
||||
.camoufox_config
|
||||
.as_ref()
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
group_id: profile.group_id,
|
||||
tags: profile.tags,
|
||||
is_running: false,
|
||||
},
|
||||
}))
|
||||
}
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateProfileRequest>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
|
||||
// Update profile fields
|
||||
if let Some(new_name) = request.name {
|
||||
if profile_manager
|
||||
.rename_profile(&state.app_handle, &id, &new_name)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(version) = request.version {
|
||||
if profile_manager
|
||||
.update_profile_version(&state.app_handle, &id, &version)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(proxy_id) = request.proxy_id {
|
||||
if profile_manager
|
||||
.update_profile_proxy(state.app_handle.clone(), &id, Some(proxy_id))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(camoufox_config) = request.camoufox_config {
|
||||
let config: Result<CamoufoxConfig, _> = serde_json::from_value(camoufox_config);
|
||||
match config {
|
||||
Ok(config) => {
|
||||
if profile_manager
|
||||
.update_camoufox_config(state.app_handle.clone(), &id, config)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(group_id) = request.group_id {
|
||||
if profile_manager
|
||||
.assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id))
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(tags) = request.tags {
|
||||
if profile_manager
|
||||
.update_profile_tags(&state.app_handle, &id, tags)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
|
||||
// Update tag manager with new tags from all profiles
|
||||
if let Ok(profiles) = profile_manager.list_profiles() {
|
||||
let _ = crate::tag_manager::TAG_MANAGER
|
||||
.lock()
|
||||
.map(|manager| manager.rebuild_from_profiles(&profiles));
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated profile
|
||||
get_profile(Path(id), State(state)).await
|
||||
}
|
||||
|
||||
async fn delete_profile(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
match profile_manager.delete_profile(&state.app_handle, &id) {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
|
||||
// API Handlers - Groups
|
||||
async fn get_groups(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<ApiGroupResponse>>, StatusCode> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => {
|
||||
match manager.get_all_groups() {
|
||||
Ok(groups) => {
|
||||
let api_groups = groups
|
||||
.into_iter()
|
||||
.map(|group| ApiGroupResponse {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
profile_count: 0, // Would need profile list to calculate this
|
||||
})
|
||||
.collect();
|
||||
Ok(Json(api_groups))
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
async fn get_group(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiGroupResponse>, StatusCode> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => match manager.get_all_groups() {
|
||||
Ok(groups) => {
|
||||
if let Some(group) = groups.into_iter().find(|g| g.id == id) {
|
||||
Ok(Json(ApiGroupResponse {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
profile_count: 0,
|
||||
}))
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
},
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_group(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateGroupRequest>,
|
||||
) -> Result<Json<ApiGroupResponse>, StatusCode> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => match manager.create_group(&state.app_handle, request.name) {
|
||||
Ok(group) => Ok(Json(ApiGroupResponse {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
profile_count: 0,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
},
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_group(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateGroupRequest>,
|
||||
) -> Result<Json<ApiGroupResponse>, StatusCode> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => match manager.update_group(&state.app_handle, id.clone(), request.name) {
|
||||
Ok(group) => Ok(Json(ApiGroupResponse {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
profile_count: 0,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
},
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_group(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
match GROUP_MANAGER.lock() {
|
||||
Ok(manager) => match manager.delete_group(&state.app_handle, id.clone()) {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
},
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
// API Handlers - Tags
|
||||
async fn get_tags(State(_state): State<ApiServerState>) -> Result<Json<Vec<String>>, StatusCode> {
|
||||
match TAG_MANAGER.lock() {
|
||||
Ok(manager) => match manager.get_all_tags() {
|
||||
Ok(tags) => Ok(Json(tags)),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
},
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
// API Handlers - Proxies
|
||||
async fn get_proxies(
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<ApiProxyResponse>>, StatusCode> {
|
||||
let proxies = PROXY_MANAGER.get_stored_proxies();
|
||||
Ok(Json(
|
||||
proxies
|
||||
.into_iter()
|
||||
.map(|p| ApiProxyResponse {
|
||||
id: p.id,
|
||||
name: p.name,
|
||||
proxy_settings: serde_json::to_value(p.proxy_settings).unwrap_or_default(),
|
||||
})
|
||||
.collect(),
|
||||
))
|
||||
}
|
||||
|
||||
async fn get_proxy(
|
||||
Path(id): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
let proxies = PROXY_MANAGER.get_stored_proxies();
|
||||
if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
|
||||
Ok(Json(ApiProxyResponse {
|
||||
id: proxy.id,
|
||||
name: proxy.name,
|
||||
proxy_settings: serde_json::to_value(proxy.proxy_settings).unwrap_or_default(),
|
||||
}))
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
async fn create_proxy(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<CreateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
// Convert JSON value to ProxySettings
|
||||
match serde_json::from_value(request.proxy_settings.clone()) {
|
||||
Ok(proxy_settings) => {
|
||||
match PROXY_MANAGER.create_stored_proxy(
|
||||
&state.app_handle,
|
||||
request.name.clone(),
|
||||
proxy_settings,
|
||||
) {
|
||||
Ok(_) => {
|
||||
// Find the created proxy to return it
|
||||
let proxies = PROXY_MANAGER.get_stored_proxies();
|
||||
if let Some(proxy) = proxies.into_iter().find(|p| p.name == request.name) {
|
||||
Ok(Json(ApiProxyResponse {
|
||||
id: proxy.id,
|
||||
name: proxy.name,
|
||||
proxy_settings: request.proxy_settings,
|
||||
}))
|
||||
} else {
|
||||
Err(StatusCode::INTERNAL_SERVER_ERROR)
|
||||
}
|
||||
}
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
|
||||
async fn update_proxy(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateProxyRequest>,
|
||||
) -> Result<Json<ApiProxyResponse>, StatusCode> {
|
||||
let proxies = PROXY_MANAGER.get_stored_proxies();
|
||||
if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
|
||||
let new_name = request.name.unwrap_or(proxy.name.clone());
|
||||
let new_proxy_settings = if let Some(settings_json) = request.proxy_settings {
|
||||
match serde_json::from_value(settings_json) {
|
||||
Ok(settings) => settings,
|
||||
Err(_) => return Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
} else {
|
||||
proxy.proxy_settings.clone()
|
||||
};
|
||||
|
||||
match PROXY_MANAGER.update_stored_proxy(
|
||||
&state.app_handle,
|
||||
&id,
|
||||
Some(new_name.clone()),
|
||||
Some(new_proxy_settings.clone()),
|
||||
) {
|
||||
Ok(_) => Ok(Json(ApiProxyResponse {
|
||||
id,
|
||||
name: new_name,
|
||||
proxy_settings: serde_json::to_value(new_proxy_settings).unwrap_or_default(),
|
||||
})),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
} else {
|
||||
Err(StatusCode::NOT_FOUND)
|
||||
}
|
||||
}
|
||||
|
||||
async fn delete_proxy(
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<StatusCode, StatusCode> {
|
||||
match PROXY_MANAGER.delete_stored_proxy(&state.app_handle, &id) {
|
||||
Ok(_) => Ok(StatusCode::NO_CONTENT),
|
||||
Err(_) => Err(StatusCode::BAD_REQUEST),
|
||||
}
|
||||
}
|
||||
|
||||
// API Handler - Run Profile with Remote Debugging
|
||||
async fn run_profile(
|
||||
Path(id): Path<String>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
State(state): State<ApiServerState>,
|
||||
) -> Result<Json<RunProfileResponse>, StatusCode> {
|
||||
let headless = params
|
||||
.get("headless")
|
||||
.and_then(|v| v.parse::<bool>().ok())
|
||||
.unwrap_or(false);
|
||||
|
||||
let profile_manager = ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.id.to_string() == id)
|
||||
.ok_or(StatusCode::NOT_FOUND)?;
|
||||
|
||||
// Generate a random port for remote debugging
|
||||
let remote_debugging_port = rand::random::<u16>().saturating_add(9000).max(9000);
|
||||
|
||||
// Use the same launch method as the main app, but with remote debugging enabled
|
||||
match crate::browser_runner::launch_browser_profile_with_debugging(
|
||||
state.app_handle.clone(),
|
||||
profile.clone(),
|
||||
None,
|
||||
Some(remote_debugging_port),
|
||||
headless,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated_profile) => Ok(Json(RunProfileResponse {
|
||||
profile_id: updated_profile.id.to_string(),
|
||||
remote_debugging_port,
|
||||
headless,
|
||||
})),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
// API Handler - Download Browser
|
||||
async fn download_browser_api(
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<DownloadBrowserRequest>,
|
||||
) -> Result<Json<DownloadBrowserResponse>, StatusCode> {
|
||||
match crate::downloader::download_browser(
|
||||
state.app_handle.clone(),
|
||||
request.browser.clone(),
|
||||
request.version.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => Ok(Json(DownloadBrowserResponse {
|
||||
browser: request.browser,
|
||||
version: request.version,
|
||||
status: "downloaded".to_string(),
|
||||
})),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
// API Handler - Get Browser Versions
|
||||
async fn get_browser_versions(
|
||||
Path(browser): Path<String>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<Vec<String>>, StatusCode> {
|
||||
let version_manager = crate::browser_version_manager::BrowserVersionManager::instance();
|
||||
|
||||
match version_manager
|
||||
.fetch_browser_versions_with_count(&browser, false)
|
||||
.await
|
||||
{
|
||||
Ok(result) => Ok(Json(result.versions)),
|
||||
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
|
||||
}
|
||||
}
|
||||
|
||||
// API Handler - Check if Browser is Downloaded
|
||||
async fn check_browser_downloaded(
|
||||
Path((browser, version)): Path<(String, String)>,
|
||||
State(_state): State<ApiServerState>,
|
||||
) -> Result<Json<bool>, StatusCode> {
|
||||
let is_downloaded = crate::downloaded_browsers_registry::is_browser_downloaded(browser, version);
|
||||
Ok(Json(is_downloaded))
|
||||
}
|
||||
@@ -120,12 +120,14 @@ pub struct AppUpdateProgress {
|
||||
|
||||
pub struct AppAutoUpdater {
|
||||
client: Client,
|
||||
extractor: &'static crate::extraction::Extractor,
|
||||
}
|
||||
|
||||
impl AppAutoUpdater {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
extractor: crate::extraction::Extractor::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -829,8 +831,6 @@ impl AppAutoUpdater {
|
||||
archive_path: &Path,
|
||||
dest_dir: &Path,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let extractor = crate::extraction::Extractor::instance();
|
||||
|
||||
let file_name = archive_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
@@ -838,7 +838,7 @@ impl AppAutoUpdater {
|
||||
|
||||
// Handle compound extensions like .tar.gz
|
||||
if file_name.ends_with(".tar.gz") {
|
||||
return extractor.extract_tar_gz(archive_path, dest_dir).await;
|
||||
return self.extractor.extract_tar_gz(archive_path, dest_dir).await;
|
||||
}
|
||||
|
||||
let extension = archive_path
|
||||
@@ -850,7 +850,7 @@ impl AppAutoUpdater {
|
||||
"dmg" => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
extractor.extract_dmg(archive_path, dest_dir).await
|
||||
self.extractor.extract_dmg(archive_path, dest_dir).await
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
@@ -914,7 +914,7 @@ impl AppAutoUpdater {
|
||||
Err("AppImage installation is only supported on Linux".into())
|
||||
}
|
||||
}
|
||||
"zip" => extractor.extract_zip(archive_path, dest_dir).await,
|
||||
"zip" => self.extractor.extract_zip(archive_path, dest_dir).await,
|
||||
_ => Err(format!("Unsupported archive format: {extension}").into()),
|
||||
}
|
||||
}
|
||||
@@ -1083,8 +1083,8 @@ impl AppAutoUpdater {
|
||||
fs::create_dir_all(&temp_extract_dir)?;
|
||||
|
||||
// Extract ZIP file
|
||||
let extractor = crate::extraction::Extractor::instance();
|
||||
let extracted_path = extractor
|
||||
let extracted_path = self
|
||||
.extractor
|
||||
.extract_zip(installer_path, &temp_extract_dir)
|
||||
.await?;
|
||||
|
||||
@@ -1314,8 +1314,8 @@ impl AppAutoUpdater {
|
||||
fs::create_dir_all(&temp_extract_dir)?;
|
||||
|
||||
// Extract tarball
|
||||
let extractor = crate::extraction::Extractor::instance();
|
||||
let extracted_path = extractor
|
||||
let extracted_path = self
|
||||
.extractor
|
||||
.extract_tar_gz(tarball_path, &temp_extract_dir)
|
||||
.await?;
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use crate::api_client::is_browser_version_nightly;
|
||||
use crate::browser_version_manager::{BrowserVersionInfo, BrowserVersionManager};
|
||||
use crate::profile::BrowserProfile;
|
||||
use crate::profile::{BrowserProfile, ProfileManager};
|
||||
use crate::settings_manager::SettingsManager;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
@@ -29,15 +28,17 @@ pub struct AutoUpdateState {
|
||||
}
|
||||
|
||||
pub struct AutoUpdater {
|
||||
version_service: &'static BrowserVersionManager,
|
||||
browser_version_manager: &'static BrowserVersionManager,
|
||||
settings_manager: &'static SettingsManager,
|
||||
profile_manager: &'static ProfileManager,
|
||||
}
|
||||
|
||||
impl AutoUpdater {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
version_service: BrowserVersionManager::instance(),
|
||||
browser_version_manager: BrowserVersionManager::instance(),
|
||||
settings_manager: SettingsManager::instance(),
|
||||
profile_manager: ProfileManager::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +54,8 @@ impl AutoUpdater {
|
||||
let mut browser_versions: HashMap<String, Vec<BrowserVersionInfo>> = HashMap::new();
|
||||
|
||||
// Group profiles by browser
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
let profiles = self
|
||||
.profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let mut browser_profiles: HashMap<String, Vec<BrowserProfile>> = HashMap::new();
|
||||
@@ -62,7 +63,7 @@ impl AutoUpdater {
|
||||
for profile in profiles {
|
||||
// Only check supported browsers
|
||||
if !self
|
||||
.version_service
|
||||
.browser_version_manager
|
||||
.is_browser_supported(&profile.browser)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
@@ -78,14 +79,14 @@ impl AutoUpdater {
|
||||
for (browser, profiles) in browser_profiles {
|
||||
// Get cached versions first, then try to fetch if needed
|
||||
let versions = if let Some(cached) = self
|
||||
.version_service
|
||||
.browser_version_manager
|
||||
.get_cached_browser_versions_detailed(&browser)
|
||||
{
|
||||
cached
|
||||
} else if self.version_service.should_update_cache(&browser) {
|
||||
} else if self.browser_version_manager.should_update_cache(&browser) {
|
||||
// Try to fetch fresh versions
|
||||
match self
|
||||
.version_service
|
||||
.browser_version_manager
|
||||
.fetch_browser_versions_detailed(&browser, false)
|
||||
.await
|
||||
{
|
||||
@@ -148,16 +149,17 @@ impl AutoUpdater {
|
||||
);
|
||||
|
||||
// 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();
|
||||
let app_handle_clone = app_handle.clone();
|
||||
|
||||
// Spawn async task to handle the download and auto-update
|
||||
tokio::spawn(async move {
|
||||
// TODO: update the logic to use the downloaded browsers registry instance instead of the static method
|
||||
// First, check if browser already exists
|
||||
match crate::browser_runner::is_browser_downloaded(
|
||||
match crate::downloaded_browsers_registry::is_browser_downloaded(
|
||||
browser.clone(),
|
||||
new_version.clone(),
|
||||
) {
|
||||
@@ -165,11 +167,13 @@ impl AutoUpdater {
|
||||
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
|
||||
match AutoUpdater::instance()
|
||||
.complete_browser_update_with_auto_update(
|
||||
&app_handle_clone,
|
||||
&browser.clone(),
|
||||
&new_version.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(updated_profiles) => {
|
||||
println!(
|
||||
@@ -222,7 +226,8 @@ impl AutoUpdater {
|
||||
available_versions: &[BrowserVersionInfo],
|
||||
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let current_version = &profile.version;
|
||||
let is_current_nightly = is_browser_version_nightly(&profile.browser, current_version, None);
|
||||
let is_current_nightly =
|
||||
crate::api_client::is_browser_version_nightly(&profile.browser, current_version, None);
|
||||
|
||||
// Find the best available update
|
||||
let best_update = available_versions
|
||||
@@ -230,7 +235,8 @@ impl AutoUpdater {
|
||||
.filter(|v| {
|
||||
// Only consider versions newer than current
|
||||
self.is_version_newer(&v.version, current_version)
|
||||
&& is_browser_version_nightly(&profile.browser, &v.version, None) == is_current_nightly
|
||||
&& crate::api_client::is_browser_version_nightly(&profile.browser, &v.version, None)
|
||||
== is_current_nightly
|
||||
})
|
||||
.max_by(|a, b| self.compare_versions(&a.version, &b.version));
|
||||
|
||||
@@ -293,11 +299,12 @@ impl AutoUpdater {
|
||||
/// Automatically update all affected profile versions after browser download
|
||||
pub async fn auto_update_profile_versions(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
browser: &str,
|
||||
new_version: &str,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
let profiles = profile_manager
|
||||
let profiles = self
|
||||
.profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
@@ -314,7 +321,11 @@ impl AutoUpdater {
|
||||
// Check if this is an update (newer version)
|
||||
if self.is_version_newer(new_version, &profile.version) {
|
||||
// Update the profile version
|
||||
match profile_manager.update_profile_version(&profile.name, new_version) {
|
||||
match self.profile_manager.update_profile_version(
|
||||
app_handle,
|
||||
&profile.id.to_string(),
|
||||
new_version,
|
||||
) {
|
||||
Ok(_) => {
|
||||
updated_profiles.push(profile.name);
|
||||
}
|
||||
@@ -332,12 +343,13 @@ impl AutoUpdater {
|
||||
/// Complete browser update process with auto-update of profile versions
|
||||
pub async fn complete_browser_update_with_auto_update(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
browser: &str,
|
||||
new_version: &str,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Auto-update profile versions first
|
||||
let updated_profiles = self
|
||||
.auto_update_profile_versions(browser, new_version)
|
||||
.auto_update_profile_versions(app_handle, browser, new_version)
|
||||
.await?;
|
||||
|
||||
// Remove browser from disabled list and clean up auto-update tracking
|
||||
@@ -347,46 +359,9 @@ 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::instance();
|
||||
let profiles = profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to load profiles: {e}"))?;
|
||||
|
||||
// Get registry instance
|
||||
let registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::instance();
|
||||
|
||||
// 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,
|
||||
@@ -408,17 +383,11 @@ impl AutoUpdater {
|
||||
}
|
||||
|
||||
fn is_version_newer(&self, version1: &str, version2: &str) -> bool {
|
||||
// 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
|
||||
crate::api_client::is_version_newer(version1, version2)
|
||||
}
|
||||
|
||||
fn compare_versions(&self, version1: &str, version2: &str) -> std::cmp::Ordering {
|
||||
// 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)
|
||||
crate::api_client::compare_versions(version1, version2)
|
||||
}
|
||||
|
||||
fn get_auto_update_state_file(&self) -> PathBuf {
|
||||
@@ -455,6 +424,39 @@ impl AutoUpdater {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get pending update versions for a specific browser
|
||||
/// Returns a set of (browser, version) pairs that have pending updates
|
||||
pub fn get_pending_update_versions(
|
||||
&self,
|
||||
) -> Result<std::collections::HashSet<(String, String)>, Box<dyn std::error::Error + Send + Sync>>
|
||||
{
|
||||
let state = self.load_auto_update_state()?;
|
||||
let mut pending_versions = std::collections::HashSet::new();
|
||||
|
||||
for update in &state.pending_updates {
|
||||
pending_versions.insert((update.browser.clone(), update.new_version.clone()));
|
||||
}
|
||||
|
||||
Ok(pending_versions)
|
||||
}
|
||||
|
||||
/// Get pending update for a specific browser version if it exists
|
||||
pub fn get_pending_update(
|
||||
&self,
|
||||
browser: &str,
|
||||
current_version: &str,
|
||||
) -> Result<Option<UpdateNotification>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let state = self.load_auto_update_state()?;
|
||||
|
||||
for update in &state.pending_updates {
|
||||
if update.browser == browser && update.current_version == current_version {
|
||||
return Ok(Some(update.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
@@ -470,14 +472,6 @@ pub async fn check_for_browser_updates() -> Result<Vec<UpdateNotification>, Stri
|
||||
Ok(grouped)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn is_browser_disabled_for_update(browser: String) -> Result<bool, String> {
|
||||
let updater = AutoUpdater::instance();
|
||||
updater
|
||||
.is_browser_disabled(&browser)
|
||||
.map_err(|e| format!("Failed to check browser status: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn dismiss_update_notification(notification_id: String) -> Result<(), String> {
|
||||
let updater = AutoUpdater::instance();
|
||||
@@ -488,12 +482,13 @@ pub async fn dismiss_update_notification(notification_id: String) -> Result<(),
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn complete_browser_update_with_auto_update(
|
||||
app_handle: tauri::AppHandle,
|
||||
browser: String,
|
||||
new_version: String,
|
||||
) -> Result<Vec<String>, String> {
|
||||
let updater = AutoUpdater::instance();
|
||||
updater
|
||||
.complete_browser_update_with_auto_update(&browser, &new_version)
|
||||
.complete_browser_update_with_auto_update(&app_handle, &browser, &new_version)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to complete browser update: {e}"))
|
||||
}
|
||||
@@ -520,6 +515,7 @@ mod tests {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -883,7 +879,7 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
// Create a temporary directory for testing
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
|
||||
// Create a mock settings manager that uses the temp directory
|
||||
struct TestSettingsManager {
|
||||
|
||||
+160
-17
@@ -58,6 +58,8 @@ pub trait Browser: Send + Sync {
|
||||
profile_path: &str,
|
||||
proxy_settings: Option<&ProxySettings>,
|
||||
url: Option<String>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>>;
|
||||
fn is_version_downloaded(&self, version: &str, binaries_dir: &Path) -> bool;
|
||||
fn prepare_executable(&self, executable_path: &Path) -> Result<(), Box<dyn std::error::Error>>;
|
||||
@@ -239,12 +241,29 @@ mod linux {
|
||||
browser_type: &BrowserType,
|
||||
) -> Result<PathBuf, Box<dyn std::error::Error>> {
|
||||
let possible_executables = match browser_type {
|
||||
BrowserType::Chromium => vec![install_dir.join("chromium"), install_dir.join("chrome")],
|
||||
BrowserType::Chromium => vec![
|
||||
// Direct paths (for manual installations)
|
||||
install_dir.join("chromium"),
|
||||
install_dir.join("chrome"),
|
||||
install_dir.join("chromium-browser"),
|
||||
// Subdirectory paths (for downloaded archives)
|
||||
install_dir.join("chrome-linux").join("chrome"),
|
||||
install_dir.join("chrome-linux").join("chromium"),
|
||||
install_dir.join("chromium").join("chromium"),
|
||||
install_dir.join("chromium").join("chrome"),
|
||||
// Binary subdirectory
|
||||
install_dir.join("bin").join("chromium"),
|
||||
install_dir.join("bin").join("chrome"),
|
||||
],
|
||||
BrowserType::Brave => vec![
|
||||
install_dir.join("brave"),
|
||||
install_dir.join("brave-browser"),
|
||||
install_dir.join("brave-browser-nightly"),
|
||||
install_dir.join("brave-browser-beta"),
|
||||
// Subdirectory paths
|
||||
install_dir.join("brave").join("brave"),
|
||||
install_dir.join("brave-browser").join("brave"),
|
||||
install_dir.join("bin").join("brave"),
|
||||
],
|
||||
_ => vec![],
|
||||
};
|
||||
@@ -320,12 +339,29 @@ mod linux {
|
||||
|
||||
pub fn is_chromium_version_downloaded(install_dir: &Path, browser_type: &BrowserType) -> bool {
|
||||
let possible_executables = match browser_type {
|
||||
BrowserType::Chromium => vec![install_dir.join("chromium"), install_dir.join("chrome")],
|
||||
BrowserType::Chromium => vec![
|
||||
// Direct paths (for manual installations)
|
||||
install_dir.join("chromium"),
|
||||
install_dir.join("chrome"),
|
||||
install_dir.join("chromium-browser"),
|
||||
// Subdirectory paths (for downloaded archives)
|
||||
install_dir.join("chrome-linux").join("chrome"),
|
||||
install_dir.join("chrome-linux").join("chromium"),
|
||||
install_dir.join("chromium").join("chromium"),
|
||||
install_dir.join("chromium").join("chrome"),
|
||||
// Binary subdirectory
|
||||
install_dir.join("bin").join("chromium"),
|
||||
install_dir.join("bin").join("chrome"),
|
||||
],
|
||||
BrowserType::Brave => vec![
|
||||
install_dir.join("brave"),
|
||||
install_dir.join("brave-browser"),
|
||||
install_dir.join("brave-browser-nightly"),
|
||||
install_dir.join("brave-browser-beta"),
|
||||
// Subdirectory paths
|
||||
install_dir.join("brave").join("brave"),
|
||||
install_dir.join("brave-browser").join("brave"),
|
||||
install_dir.join("bin").join("brave"),
|
||||
],
|
||||
_ => vec![],
|
||||
};
|
||||
@@ -413,11 +449,18 @@ mod windows {
|
||||
install_dir.join("chrome.exe"),
|
||||
install_dir.join("chromium-browser.exe"),
|
||||
install_dir.join("bin").join("chromium.exe"),
|
||||
// Common archive extraction patterns
|
||||
install_dir.join("chrome-win").join("chrome.exe"),
|
||||
install_dir.join("chromium").join("chromium.exe"),
|
||||
install_dir.join("chromium").join("chrome.exe"),
|
||||
],
|
||||
BrowserType::Brave => vec![
|
||||
install_dir.join("brave.exe"),
|
||||
install_dir.join("brave-browser.exe"),
|
||||
install_dir.join("bin").join("brave.exe"),
|
||||
// Subdirectory patterns
|
||||
install_dir.join("brave").join("brave.exe"),
|
||||
install_dir.join("brave-browser").join("brave.exe"),
|
||||
],
|
||||
_ => vec![],
|
||||
};
|
||||
@@ -489,11 +532,18 @@ mod windows {
|
||||
install_dir.join("chrome.exe"),
|
||||
install_dir.join("chromium-browser.exe"),
|
||||
install_dir.join("bin").join("chromium.exe"),
|
||||
// Common archive extraction patterns
|
||||
install_dir.join("chrome-win").join("chrome.exe"),
|
||||
install_dir.join("chromium").join("chromium.exe"),
|
||||
install_dir.join("chromium").join("chrome.exe"),
|
||||
],
|
||||
BrowserType::Brave => vec![
|
||||
install_dir.join("brave.exe"),
|
||||
install_dir.join("brave-browser.exe"),
|
||||
install_dir.join("bin").join("brave.exe"),
|
||||
// Subdirectory patterns
|
||||
install_dir.join("brave").join("brave.exe"),
|
||||
install_dir.join("brave-browser").join("brave.exe"),
|
||||
],
|
||||
_ => vec![],
|
||||
};
|
||||
@@ -557,11 +607,23 @@ impl Browser for FirefoxBrowser {
|
||||
profile_path: &str,
|
||||
_proxy_settings: Option<&ProxySettings>,
|
||||
url: Option<String>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
let mut args = vec!["-profile".to_string(), profile_path.to_string()];
|
||||
|
||||
// Only use -no-remote for browsers that require it for security (Mullvad, Tor)
|
||||
// Regular Firefox browsers can use remote commands for better URL handling
|
||||
// Add remote debugging if requested
|
||||
if let Some(port) = remote_debugging_port {
|
||||
args.push("--start-debugger-server".to_string());
|
||||
args.push(port.to_string());
|
||||
}
|
||||
|
||||
// Add headless mode if requested
|
||||
if headless {
|
||||
args.push("--headless".to_string());
|
||||
}
|
||||
|
||||
// Use -no-remote for browsers that require it for security (Mullvad, Tor) or when remote debugging
|
||||
match self.browser_type {
|
||||
BrowserType::MullvadBrowser | BrowserType::TorBrowser => {
|
||||
args.push("-no-remote".to_string());
|
||||
@@ -570,7 +632,11 @@ impl Browser for FirefoxBrowser {
|
||||
| BrowserType::FirefoxDeveloper
|
||||
| BrowserType::Zen
|
||||
| BrowserType::Camoufox => {
|
||||
// Don't use -no-remote so we can communicate with existing instances
|
||||
// Use -no-remote when remote debugging to avoid conflicts
|
||||
if remote_debugging_port.is_some() {
|
||||
args.push("-no-remote".to_string());
|
||||
}
|
||||
// Don't use -no-remote for normal launches so we can communicate with existing instances
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
@@ -659,6 +725,8 @@ impl Browser for ChromiumBrowser {
|
||||
profile_path: &str,
|
||||
proxy_settings: Option<&ProxySettings>,
|
||||
url: Option<String>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
let mut args = vec![
|
||||
format!("--user-data-dir={}", profile_path),
|
||||
@@ -670,9 +738,19 @@ impl Browser for ChromiumBrowser {
|
||||
"--disable-updater".to_string(),
|
||||
];
|
||||
|
||||
// Add remote debugging if requested
|
||||
if let Some(port) = remote_debugging_port {
|
||||
args.push("--remote-debugging-address=0.0.0.0".to_string());
|
||||
args.push(format!("--remote-debugging-port={port}"));
|
||||
}
|
||||
|
||||
// Add headless mode if requested
|
||||
if headless {
|
||||
args.push("--headless".to_string());
|
||||
}
|
||||
|
||||
// Add proxy configuration if provided
|
||||
if let Some(proxy) = proxy_settings {
|
||||
// Apply proxy settings
|
||||
args.push(format!(
|
||||
"--proxy-server=http://{}:{}",
|
||||
proxy.host, proxy.port
|
||||
@@ -758,6 +836,8 @@ impl Browser for CamoufoxBrowser {
|
||||
profile_path: &str,
|
||||
_proxy_settings: Option<&ProxySettings>,
|
||||
url: Option<String>,
|
||||
remote_debugging_port: Option<u16>,
|
||||
headless: bool,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
// For Camoufox, we handle launching through the camoufox launcher
|
||||
// This method won't be used directly, but we provide basic Firefox args as fallback
|
||||
@@ -767,6 +847,17 @@ impl Browser for CamoufoxBrowser {
|
||||
"-no-remote".to_string(),
|
||||
];
|
||||
|
||||
// Add remote debugging if requested
|
||||
if let Some(port) = remote_debugging_port {
|
||||
args.push("--start-debugger-server".to_string());
|
||||
args.push(port.to_string());
|
||||
}
|
||||
|
||||
// Add headless mode if requested
|
||||
if headless {
|
||||
args.push("--headless".to_string());
|
||||
}
|
||||
|
||||
if let Some(url) = url {
|
||||
args.push(url);
|
||||
}
|
||||
@@ -962,15 +1053,15 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn test_firefox_launch_args() {
|
||||
// Test regular Firefox (should not use -no-remote)
|
||||
// Test regular Firefox (should not use -no-remote for normal launch)
|
||||
let browser = FirefoxBrowser::new(BrowserType::Firefox);
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None)
|
||||
.create_launch_args("/path/to/profile", None, None, None, false)
|
||||
.expect("Failed to create launch args for Firefox");
|
||||
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
|
||||
assert!(
|
||||
!args.contains(&"-no-remote".to_string()),
|
||||
"Firefox should not use -no-remote"
|
||||
"Firefox should not use -no-remote for normal launch"
|
||||
);
|
||||
|
||||
let args = browser
|
||||
@@ -978,6 +1069,8 @@ mod tests {
|
||||
"/path/to/profile",
|
||||
None,
|
||||
Some("https://example.com".to_string()),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.expect("Failed to create launch args for Firefox with URL");
|
||||
assert_eq!(
|
||||
@@ -985,29 +1078,55 @@ mod tests {
|
||||
vec!["-profile", "/path/to/profile", "https://example.com"]
|
||||
);
|
||||
|
||||
// Test Mullvad Browser (should use -no-remote)
|
||||
// Test Firefox with remote debugging (should use -no-remote)
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None, Some(9222), false)
|
||||
.expect("Failed to create launch args for Firefox with remote debugging");
|
||||
assert!(
|
||||
args.contains(&"-no-remote".to_string()),
|
||||
"Firefox should use -no-remote for remote debugging"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"--start-debugger-server".to_string()),
|
||||
"Firefox should include debugger server arg"
|
||||
);
|
||||
assert!(
|
||||
args.contains(&"9222".to_string()),
|
||||
"Firefox should include debugging port"
|
||||
);
|
||||
|
||||
// Test Mullvad Browser (should always use -no-remote)
|
||||
let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser);
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None)
|
||||
.create_launch_args("/path/to/profile", None, None, None, false)
|
||||
.expect("Failed to create launch args for Mullvad Browser");
|
||||
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
|
||||
|
||||
// Test Tor Browser (should use -no-remote)
|
||||
// Test Tor Browser (should always use -no-remote)
|
||||
let browser = FirefoxBrowser::new(BrowserType::TorBrowser);
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None)
|
||||
.create_launch_args("/path/to/profile", None, None, None, false)
|
||||
.expect("Failed to create launch args for Tor Browser");
|
||||
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
|
||||
|
||||
// Test Zen Browser (should not use -no-remote)
|
||||
// Test Zen Browser (should not use -no-remote for normal launch)
|
||||
let browser = FirefoxBrowser::new(BrowserType::Zen);
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None)
|
||||
.create_launch_args("/path/to/profile", None, None, None, false)
|
||||
.expect("Failed to create launch args for Zen Browser");
|
||||
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
|
||||
assert!(
|
||||
!args.contains(&"-no-remote".to_string()),
|
||||
"Zen Browser should not use -no-remote"
|
||||
"Zen Browser should not use -no-remote for normal launch"
|
||||
);
|
||||
|
||||
// Test headless mode
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None, None, true)
|
||||
.expect("Failed to create launch args for Zen Browser headless");
|
||||
assert!(
|
||||
args.contains(&"--headless".to_string()),
|
||||
"Browser should include headless flag when requested"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1015,7 +1134,7 @@ mod tests {
|
||||
fn test_chromium_launch_args() {
|
||||
let browser = ChromiumBrowser::new(BrowserType::Chromium);
|
||||
let args = browser
|
||||
.create_launch_args("/path/to/profile", None, None)
|
||||
.create_launch_args("/path/to/profile", None, None, None, false)
|
||||
.expect("Failed to create launch args for Chromium");
|
||||
|
||||
// Test that basic required arguments are present
|
||||
@@ -1043,6 +1162,8 @@ mod tests {
|
||||
"/path/to/profile",
|
||||
None,
|
||||
Some("https://example.com".to_string()),
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.expect("Failed to create launch args for Chromium with URL");
|
||||
assert!(
|
||||
@@ -1055,6 +1176,28 @@ mod tests {
|
||||
args_with_url.last().expect("Args should not be empty"),
|
||||
"https://example.com"
|
||||
);
|
||||
|
||||
// Test remote debugging
|
||||
let args_with_debug = browser
|
||||
.create_launch_args("/path/to/profile", None, None, Some(9222), false)
|
||||
.expect("Failed to create launch args for Chromium with remote debugging");
|
||||
assert!(
|
||||
args_with_debug.contains(&"--remote-debugging-port=9222".to_string()),
|
||||
"Chromium args should contain remote debugging port"
|
||||
);
|
||||
assert!(
|
||||
args_with_debug.contains(&"--remote-debugging-address=0.0.0.0".to_string()),
|
||||
"Chromium args should contain remote debugging address"
|
||||
);
|
||||
|
||||
// Test headless mode
|
||||
let args_headless = browser
|
||||
.create_launch_args("/path/to/profile", None, None, None, true)
|
||||
.expect("Failed to create launch args for Chromium headless");
|
||||
assert!(
|
||||
args_headless.contains(&"--headless".to_string()),
|
||||
"Chromium args should contain headless flag when requested"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
+801
-944
File diff suppressed because it is too large
Load Diff
@@ -117,7 +117,8 @@ impl BrowserVersionManager {
|
||||
/// Get cached browser versions immediately (returns None if no cache exists)
|
||||
pub fn get_cached_browser_versions(&self, browser: &str) -> Option<Vec<String>> {
|
||||
if browser == "brave" {
|
||||
return ApiClient::instance()
|
||||
return self
|
||||
.api_client
|
||||
.get_cached_github_releases("brave")
|
||||
.map(|releases| releases.into_iter().map(|r| r.tag_name).collect());
|
||||
}
|
||||
@@ -134,7 +135,7 @@ impl BrowserVersionManager {
|
||||
browser: &str,
|
||||
) -> Option<Vec<BrowserVersionInfo>> {
|
||||
if browser == "brave" {
|
||||
if let Some(releases) = ApiClient::instance().get_cached_github_releases("brave") {
|
||||
if let Some(releases) = self.api_client.get_cached_github_releases("brave") {
|
||||
let detailed_info: Vec<BrowserVersionInfo> = releases
|
||||
.into_iter()
|
||||
.map(|r| BrowserVersionInfo {
|
||||
@@ -1274,6 +1275,101 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_supported_browsers() -> Result<Vec<String>, String> {
|
||||
let service = BrowserVersionManager::instance();
|
||||
Ok(service.get_supported_browsers())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn is_browser_supported_on_platform(browser_str: String) -> Result<bool, String> {
|
||||
let service = BrowserVersionManager::instance();
|
||||
service
|
||||
.is_browser_supported(&browser_str)
|
||||
.map_err(|e| format!("Failed to check browser support: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_browser_versions_cached_first(
|
||||
browser_str: String,
|
||||
) -> Result<Vec<BrowserVersionInfo>, String> {
|
||||
let service = BrowserVersionManager::instance();
|
||||
|
||||
// Get cached versions immediately if available
|
||||
if let Some(cached_versions) = service.get_cached_browser_versions_detailed(&browser_str) {
|
||||
// Check if we should update cache in background
|
||||
if service.should_update_cache(&browser_str) {
|
||||
// Start background update but return cached data immediately
|
||||
let service_clone = BrowserVersionManager::instance();
|
||||
let browser_str_clone = browser_str.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = service_clone
|
||||
.fetch_browser_versions_detailed(&browser_str_clone, false)
|
||||
.await
|
||||
{
|
||||
eprintln!("Background version update failed for {browser_str_clone}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
Ok(cached_versions)
|
||||
} else {
|
||||
// No cache available, fetch fresh
|
||||
service
|
||||
.fetch_browser_versions_detailed(&browser_str, false)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch detailed browser versions: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_browser_versions_with_count_cached_first(
|
||||
browser_str: String,
|
||||
) -> Result<BrowserVersionsResult, String> {
|
||||
let service = BrowserVersionManager::instance();
|
||||
|
||||
// Get cached versions immediately if available
|
||||
if let Some(cached_versions) = service.get_cached_browser_versions(&browser_str) {
|
||||
// Check if we should update cache in background
|
||||
if service.should_update_cache(&browser_str) {
|
||||
// Start background update but return cached data immediately
|
||||
let service_clone = BrowserVersionManager::instance();
|
||||
let browser_str_clone = browser_str.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = service_clone
|
||||
.fetch_browser_versions_with_count(&browser_str_clone, false)
|
||||
.await
|
||||
{
|
||||
eprintln!("Background version update failed for {browser_str_clone}: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Return cached data in the expected format
|
||||
Ok(BrowserVersionsResult {
|
||||
versions: cached_versions.clone(),
|
||||
new_versions_count: None, // No new versions when returning cached data
|
||||
total_versions_count: cached_versions.len(),
|
||||
})
|
||||
} else {
|
||||
// No cache available, fetch fresh
|
||||
service
|
||||
.fetch_browser_versions_with_count(&browser_str, false)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch browser versions: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_browser_versions_with_count(
|
||||
browser_str: String,
|
||||
) -> Result<BrowserVersionsResult, String> {
|
||||
let service = BrowserVersionManager::instance();
|
||||
service
|
||||
.fetch_browser_versions_with_count(&browser_str, false)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to fetch browser versions: {e}"))
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref BROWSER_VERSION_SERVICE: BrowserVersionManager = BrowserVersionManager::new();
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
use crate::profile::BrowserProfile;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
@@ -60,27 +62,40 @@ struct CamoufoxInstance {
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
struct CamoufoxNodecarLauncherInner {
|
||||
struct CamoufoxManagerInner {
|
||||
instances: HashMap<String, CamoufoxInstance>,
|
||||
}
|
||||
|
||||
pub struct CamoufoxNodecarLauncher {
|
||||
inner: Arc<AsyncMutex<CamoufoxNodecarLauncherInner>>,
|
||||
pub struct CamoufoxManager {
|
||||
inner: Arc<AsyncMutex<CamoufoxManagerInner>>,
|
||||
base_dirs: BaseDirs,
|
||||
}
|
||||
|
||||
impl CamoufoxNodecarLauncher {
|
||||
impl CamoufoxManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(AsyncMutex::new(CamoufoxNodecarLauncherInner {
|
||||
inner: Arc::new(AsyncMutex::new(CamoufoxManagerInner {
|
||||
instances: HashMap::new(),
|
||||
})),
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static CamoufoxNodecarLauncher {
|
||||
pub fn instance() -> &'static CamoufoxManager {
|
||||
&CAMOUFOX_NODECAR_LAUNCHER
|
||||
}
|
||||
|
||||
pub fn get_profiles_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("profiles");
|
||||
path
|
||||
}
|
||||
|
||||
/// Generate Camoufox fingerprint configuration during profile creation
|
||||
pub async fn generate_fingerprint_config(
|
||||
&self,
|
||||
@@ -95,8 +110,8 @@ impl CamoufoxNodecarLauncher {
|
||||
path.clone()
|
||||
} else {
|
||||
// Use the browser runner helper with the real profile
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
browser_runner
|
||||
// Use self.browser_runner instead of instance()
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
|
||||
.to_string_lossy()
|
||||
@@ -202,8 +217,8 @@ impl CamoufoxNodecarLauncher {
|
||||
path.clone()
|
||||
} else {
|
||||
// Use the browser runner helper with the real profile
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
browser_runner
|
||||
// Use self.browser_runner instead of instance()
|
||||
BrowserRunner::instance()
|
||||
.get_browser_executable_path(profile)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?
|
||||
.to_string_lossy()
|
||||
@@ -431,7 +446,7 @@ impl CamoufoxNodecarLauncher {
|
||||
}
|
||||
}
|
||||
|
||||
impl CamoufoxNodecarLauncher {
|
||||
impl CamoufoxManager {
|
||||
pub async fn launch_camoufox_profile_nodecar(
|
||||
&self,
|
||||
app_handle: AppHandle,
|
||||
@@ -440,8 +455,7 @@ impl CamoufoxNodecarLauncher {
|
||||
url: Option<String>,
|
||||
) -> Result<CamoufoxLaunchResult, String> {
|
||||
// Get profile path
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
let profiles_dir = browser_runner.get_profiles_dir();
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_path_str = profile_path.to_string_lossy();
|
||||
|
||||
@@ -484,5 +498,5 @@ mod tests {
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref CAMOUFOX_NODECAR_LAUNCHER: CamoufoxNodecarLauncher = CamoufoxNodecarLauncher::new();
|
||||
static ref CAMOUFOX_NODECAR_LAUNCHER: CamoufoxManager = CamoufoxManager::new();
|
||||
}
|
||||
@@ -1,10 +1,10 @@
|
||||
use tauri::command;
|
||||
|
||||
pub struct DefaultBrowser;
|
||||
pub struct DefaultBrowser {}
|
||||
|
||||
impl DefaultBrowser {
|
||||
fn new() -> Self {
|
||||
Self
|
||||
Self {}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static DefaultBrowser {
|
||||
@@ -38,38 +38,6 @@ impl DefaultBrowser {
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
|
||||
Err("Unsupported platform".to_string())
|
||||
}
|
||||
|
||||
pub async fn open_url_with_profile(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_name: String,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let runner = crate::browser_runner::BrowserRunner::instance();
|
||||
|
||||
// Get the profile by name
|
||||
let profiles = runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
let profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
|
||||
|
||||
println!("Opening URL '{url}' with profile '{profile_name}'");
|
||||
|
||||
// Use launch_or_open_url which handles both launching new instances and opening in existing ones
|
||||
runner
|
||||
.launch_or_open_url(app_handle, &profile, Some(url.clone()), None)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
println!("Failed to open URL with profile '{profile_name}': {e}");
|
||||
format!("Failed to open URL with profile: {e}")
|
||||
})?;
|
||||
|
||||
println!("Successfully opened URL '{url}' with profile '{profile_name}'");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -570,15 +538,3 @@ pub async fn set_as_default_browser() -> Result<(), String> {
|
||||
let default_browser = DefaultBrowser::instance();
|
||||
default_browser.set_as_default_browser().await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_url_with_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_name: String,
|
||||
url: String,
|
||||
) -> Result<(), String> {
|
||||
let default_browser = DefaultBrowser::instance();
|
||||
default_browser
|
||||
.open_url_with_profile(app_handle, profile_name, url)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -1,586 +0,0 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DownloadedBrowserInfo {
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
pub file_path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
struct RegistryData {
|
||||
pub browsers: HashMap<String, HashMap<String, DownloadedBrowserInfo>>, // browser -> version -> info
|
||||
}
|
||||
|
||||
pub struct DownloadedBrowsersRegistry {
|
||||
data: Mutex<RegistryData>,
|
||||
}
|
||||
|
||||
impl DownloadedBrowsersRegistry {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
data: Mutex::new(RegistryData::default()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instance() -> &'static DownloadedBrowsersRegistry {
|
||||
&DOWNLOADED_BROWSERS_REGISTRY
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let registry_path = Self::get_registry_path()?;
|
||||
|
||||
if !registry_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(®istry_path)?;
|
||||
let registry_data: RegistryData = serde_json::from_str(&content)?;
|
||||
|
||||
let mut data = self.data.lock().unwrap();
|
||||
*data = registry_data;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn save(&self) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
let registry_path = Self::get_registry_path()?;
|
||||
|
||||
// Ensure parent directory exists
|
||||
if let Some(parent) = registry_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let data = self.data.lock().unwrap();
|
||||
let content = serde_json::to_string_pretty(&*data)?;
|
||||
fs::write(®istry_path, content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_registry_path() -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let base_dirs = BaseDirs::new().ok_or("Failed to get base directories")?;
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("data");
|
||||
path.push("downloaded_browsers.json");
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
pub fn add_browser(&self, info: DownloadedBrowserInfo) {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
data
|
||||
.browsers
|
||||
.entry(info.browser.clone())
|
||||
.or_default()
|
||||
.insert(info.version.clone(), info);
|
||||
}
|
||||
|
||||
pub fn remove_browser(&self, browser: &str, version: &str) -> Option<DownloadedBrowserInfo> {
|
||||
let mut data = self.data.lock().unwrap();
|
||||
data.browsers.get_mut(browser)?.remove(version)
|
||||
}
|
||||
|
||||
pub fn is_browser_downloaded(&self, browser: &str, version: &str) -> bool {
|
||||
let data = self.data.lock().unwrap();
|
||||
data
|
||||
.browsers
|
||||
.get(browser)
|
||||
.and_then(|versions| versions.get(version))
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn get_downloaded_versions(&self, browser: &str) -> Vec<String> {
|
||||
let data = self.data.lock().unwrap();
|
||||
data
|
||||
.browsers
|
||||
.get(browser)
|
||||
.map(|versions| versions.keys().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn mark_download_started(&self, browser: &str, version: &str, file_path: PathBuf) {
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: browser.to_string(),
|
||||
version: version.to_string(),
|
||||
file_path,
|
||||
};
|
||||
self.add_browser(info);
|
||||
}
|
||||
|
||||
pub fn mark_download_completed(&self, browser: &str, version: &str) -> Result<(), String> {
|
||||
let data = self.data.lock().unwrap();
|
||||
if data
|
||||
.browsers
|
||||
.get(browser)
|
||||
.and_then(|versions| versions.get(version))
|
||||
.is_some()
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Browser {browser}:{version} not found in registry"))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cleanup_failed_download(
|
||||
&self,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
if let Some(info) = self.remove_browser(browser, version) {
|
||||
// Clean up extracted binaries but preserve downloaded archives
|
||||
if info.file_path.exists() {
|
||||
if info.file_path.is_dir() {
|
||||
// Allowed archive extensions to preserve
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
|
||||
for entry in fs::read_dir(&info.file_path)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(&path)?;
|
||||
continue;
|
||||
}
|
||||
|
||||
// For files, preserve if they look like downloaded archives/installers
|
||||
let keep = path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.map(|name| {
|
||||
// Match suffixes (handles multi-part extensions like .tar.xz)
|
||||
archive_exts
|
||||
.iter()
|
||||
.any(|ext| name.to_lowercase().ends_with(&ext.to_lowercase()))
|
||||
})
|
||||
.unwrap_or(false);
|
||||
|
||||
if !keep {
|
||||
fs::remove_file(&path)?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// It's a file. If it's not an archive, remove it; otherwise preserve it.
|
||||
let file_name = info
|
||||
.file_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
let archive_exts = [
|
||||
"zip", "dmg", "tar.xz", "tar.gz", "tar.bz2", "AppImage", "exe", "pkg", "msi",
|
||||
];
|
||||
let is_archive = archive_exts
|
||||
.iter()
|
||||
.any(|ext| file_name.to_lowercase().ends_with(&ext.to_lowercase()));
|
||||
if !is_archive {
|
||||
fs::remove_file(&info.file_path)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find and remove unused browser binaries that are not referenced by any active profiles
|
||||
pub fn cleanup_unused_binaries(
|
||||
&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();
|
||||
{
|
||||
let data = self.data.lock().unwrap();
|
||||
for (browser, versions) in &data.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(
|
||||
&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)> = {
|
||||
let data = self.data.lock().unwrap();
|
||||
data
|
||||
.browsers
|
||||
.iter()
|
||||
.flat_map(|(browser, versions)| {
|
||||
versions
|
||||
.keys()
|
||||
.map(|version| (browser.clone(), version.clone()))
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
|
||||
for (browser_str, version) in browsers_to_check {
|
||||
if let Ok(browser_type) = BrowserType::from_str(&browser_str) {
|
||||
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(
|
||||
&self,
|
||||
binaries_dir: &std::path::Path,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut changes = Vec::new();
|
||||
|
||||
if !binaries_dir.exists() {
|
||||
return Ok(changes);
|
||||
}
|
||||
|
||||
// Scan for actual browser directories
|
||||
for browser_entry in fs::read_dir(binaries_dir)? {
|
||||
let browser_entry = browser_entry?;
|
||||
let browser_path = browser_entry.path();
|
||||
|
||||
if !browser_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let browser_name = browser_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if browser_name.is_empty() || browser_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan for version directories within this browser
|
||||
for version_entry in fs::read_dir(&browser_path)? {
|
||||
let version_entry = version_entry?;
|
||||
let version_path = version_entry.path();
|
||||
|
||||
if !version_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let version_name = version_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if version_name.is_empty() || version_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only add to registry if this looks like a valid installed browser, not just an archive
|
||||
if !self.is_browser_downloaded(browser_name, version_name) {
|
||||
if let Ok(browser_type) = crate::browser::BrowserType::from_str(browser_name) {
|
||||
let browser = crate::browser::create_browser(browser_type);
|
||||
if browser.is_version_downloaded(version_name, binaries_dir) {
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: browser_name.to_string(),
|
||||
version: version_name.to_string(),
|
||||
file_path: version_path.clone(),
|
||||
};
|
||||
self.add_browser(info);
|
||||
changes.push(format!("Added {browser_name} {version_name} to registry"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changes.is_empty() {
|
||||
self.save()?;
|
||||
}
|
||||
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// Comprehensive cleanup that removes unused binaries and syncs registry
|
||||
pub fn comprehensive_cleanup(
|
||||
&self,
|
||||
binaries_dir: &std::path::Path,
|
||||
active_profiles: &[(String, String)],
|
||||
running_profiles: &[(String, String)],
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cleanup_results = Vec::new();
|
||||
|
||||
// First, sync registry with actual binaries on disk
|
||||
let sync_results = self.sync_with_binaries_directory(binaries_dir)?;
|
||||
cleanup_results.extend(sync_results);
|
||||
|
||||
// Then perform the regular cleanup
|
||||
let regular_cleanup = self.cleanup_unused_binaries(active_profiles, running_profiles)?;
|
||||
cleanup_results.extend(regular_cleanup);
|
||||
|
||||
// Finally, verify and cleanup stale entries
|
||||
let stale_cleanup = self.verify_and_cleanup_stale_entries_simple(binaries_dir)?;
|
||||
cleanup_results.extend(stale_cleanup);
|
||||
|
||||
if !cleanup_results.is_empty() {
|
||||
self.save()?;
|
||||
}
|
||||
|
||||
Ok(cleanup_results)
|
||||
}
|
||||
|
||||
/// Simplified version of verify_and_cleanup_stale_entries that doesn't need BrowserRunner
|
||||
pub fn verify_and_cleanup_stale_entries_simple(
|
||||
&self,
|
||||
binaries_dir: &std::path::Path,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cleaned_up = Vec::new();
|
||||
let mut browsers_to_remove = Vec::new();
|
||||
|
||||
{
|
||||
let data = self.data.lock().unwrap();
|
||||
for (browser_str, versions) in &data.browsers {
|
||||
for version in versions.keys() {
|
||||
// Check if the browser directory actually exists
|
||||
let browser_dir = binaries_dir.join(browser_str).join(version);
|
||||
if !browser_dir.exists() {
|
||||
browsers_to_remove.push((browser_str.clone(), version.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale entries
|
||||
for (browser_str, version) in browsers_to_remove {
|
||||
if let Some(_removed) = self.remove_browser(&browser_str, &version) {
|
||||
cleaned_up.push(format!(
|
||||
"Removed stale registry entry for {browser_str} {version}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cleaned_up)
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref DOWNLOADED_BROWSERS_REGISTRY: DownloadedBrowsersRegistry = {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
if let Err(e) = registry.load() {
|
||||
eprintln!("Warning: Failed to load downloaded browsers registry: {e}");
|
||||
}
|
||||
registry
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_registry_creation() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
let data = registry.data.lock().unwrap();
|
||||
assert!(data.browsers.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_add_and_get_browser() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
};
|
||||
|
||||
registry.add_browser(info.clone());
|
||||
|
||||
assert!(registry.is_browser_downloaded("firefox", "139.0"));
|
||||
assert!(!registry.is_browser_downloaded("firefox", "140.0"));
|
||||
assert!(!registry.is_browser_downloaded("chrome", "139.0"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_downloaded_versions() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
let info1 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path1"),
|
||||
};
|
||||
|
||||
let info2 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "140.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path2"),
|
||||
};
|
||||
|
||||
let info3 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "141.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path3"),
|
||||
};
|
||||
|
||||
registry.add_browser(info1);
|
||||
registry.add_browser(info2);
|
||||
registry.add_browser(info3);
|
||||
|
||||
let versions = registry.get_downloaded_versions("firefox");
|
||||
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()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mark_download_lifecycle() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
// Mark download started
|
||||
registry.mark_download_started("firefox", "139.0", PathBuf::from("/test/path"));
|
||||
|
||||
// Should be considered downloaded immediately
|
||||
assert!(
|
||||
registry.is_browser_downloaded("firefox", "139.0"),
|
||||
"Browser should be considered downloaded after marking as started"
|
||||
);
|
||||
|
||||
// Mark as completed
|
||||
registry
|
||||
.mark_download_completed("firefox", "139.0")
|
||||
.expect("Failed to mark download as completed");
|
||||
|
||||
// Should still be considered downloaded
|
||||
assert!(
|
||||
registry.is_browser_downloaded("firefox", "139.0"),
|
||||
"Browser should still be considered downloaded after completion"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_remove_browser() {
|
||||
let registry = DownloadedBrowsersRegistry::new();
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
};
|
||||
|
||||
registry.add_browser(info);
|
||||
assert!(
|
||||
registry.is_browser_downloaded("firefox", "139.0"),
|
||||
"Browser should be downloaded after adding"
|
||||
);
|
||||
|
||||
let removed = registry.remove_browser("firefox", "139.0");
|
||||
assert!(
|
||||
removed.is_some(),
|
||||
"Remove operation should return the removed browser info"
|
||||
);
|
||||
assert!(
|
||||
!registry.is_browser_downloaded("firefox", "139.0"),
|
||||
"Browser should not be downloaded after removal"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_twilight_download() {
|
||||
let 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"),
|
||||
"Zen twilight version should be registered as downloaded"
|
||||
);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,12 +2,19 @@ use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::browser::BrowserType;
|
||||
use crate::browser::{create_browser, BrowserType};
|
||||
use crate::browser_version_manager::DownloadInfo;
|
||||
|
||||
// Global state to track currently downloading browser-version pairs
|
||||
lazy_static::lazy_static! {
|
||||
static ref DOWNLOADING_BROWSERS: std::sync::Arc<Mutex<std::collections::HashSet<String>>> =
|
||||
std::sync::Arc::new(Mutex::new(std::collections::HashSet::new()));
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DownloadProgress {
|
||||
pub browser: String,
|
||||
@@ -23,6 +30,10 @@ pub struct DownloadProgress {
|
||||
pub struct Downloader {
|
||||
client: Client,
|
||||
api_client: &'static ApiClient,
|
||||
registry: &'static crate::downloaded_browsers_registry::DownloadedBrowsersRegistry,
|
||||
version_service: &'static crate::browser_version_manager::BrowserVersionManager,
|
||||
extractor: &'static crate::extraction::Extractor,
|
||||
geoip_downloader: &'static crate::geoip_downloader::GeoIPDownloader,
|
||||
}
|
||||
|
||||
impl Downloader {
|
||||
@@ -30,6 +41,10 @@ impl Downloader {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_client: ApiClient::instance(),
|
||||
registry: crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(),
|
||||
version_service: crate::browser_version_manager::BrowserVersionManager::instance(),
|
||||
extractor: crate::extraction::Extractor::instance(),
|
||||
geoip_downloader: crate::geoip_downloader::GeoIPDownloader::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +57,10 @@ impl Downloader {
|
||||
Self {
|
||||
client: Client::new(),
|
||||
api_client: ApiClient::instance(),
|
||||
registry: crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance(),
|
||||
version_service: crate::browser_version_manager::BrowserVersionManager::instance(),
|
||||
extractor: crate::extraction::Extractor::instance(),
|
||||
geoip_downloader: crate::geoip_downloader::GeoIPDownloader::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,23 +421,44 @@ impl Downloader {
|
||||
existing_size = meta.len();
|
||||
}
|
||||
|
||||
// Build request, add Range only if we have bytes
|
||||
let mut request = self
|
||||
.client
|
||||
.get(&download_url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
);
|
||||
// Build request, add Range only if we have bytes. If the server responds with 416 (Range Not
|
||||
// Satisfiable), delete the partial file and retry once without the Range header.
|
||||
let response = {
|
||||
let mut request = self
|
||||
.client
|
||||
.get(&download_url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
);
|
||||
|
||||
if existing_size > 0 {
|
||||
request = request.header("Range", format!("bytes={existing_size}-"));
|
||||
}
|
||||
if existing_size > 0 {
|
||||
request = request.header("Range", format!("bytes={existing_size}-"));
|
||||
}
|
||||
|
||||
// Start download (or resume)
|
||||
let response = request.send().await?;
|
||||
let first = request.send().await?;
|
||||
|
||||
// Check if the response is successful
|
||||
if first.status().as_u16() == 416 && existing_size > 0 {
|
||||
// Partial file on disk is not acceptable to the server — remove it and retry from scratch
|
||||
let _ = std::fs::remove_file(&file_path);
|
||||
existing_size = 0;
|
||||
|
||||
let retry = self
|
||||
.client
|
||||
.get(&download_url)
|
||||
.header(
|
||||
"User-Agent",
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
)
|
||||
.send()
|
||||
.await?;
|
||||
retry
|
||||
} else {
|
||||
first
|
||||
}
|
||||
};
|
||||
|
||||
// Check if the response is successful (200 OK or 206 Partial Content)
|
||||
if !(response.status().is_success() || response.status().as_u16() == 206) {
|
||||
return Err(format!("Download failed with status: {}", response.status()).into());
|
||||
}
|
||||
@@ -552,6 +592,327 @@ impl Downloader {
|
||||
|
||||
Ok(file_path)
|
||||
}
|
||||
|
||||
/// Download a browser binary, verify it, and register it in the downloaded browsers registry
|
||||
pub async fn download_browser_full(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
browser_str: String,
|
||||
version: String,
|
||||
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Check if this browser-version pair is already being downloaded
|
||||
let download_key = format!("{browser_str}-{version}");
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
if downloading.contains(&download_key) {
|
||||
return Err(format!("Browser '{browser_str}' version '{version}' is already being downloaded. Please wait for the current download to complete.").into());
|
||||
}
|
||||
// Mark this browser-version pair as being downloaded
|
||||
downloading.insert(download_key.clone());
|
||||
}
|
||||
|
||||
let browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
let browser = create_browser(browser_type.clone());
|
||||
|
||||
// Use injected registry instance
|
||||
|
||||
// Get binaries directory - we need to get it from somewhere
|
||||
// This is a bit tricky since we don't have access to BrowserRunner's get_binaries_dir
|
||||
// We'll need to replicate this logic
|
||||
let binaries_dir = if let Some(base_dirs) = directories::BaseDirs::new() {
|
||||
let mut path = base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
} else {
|
||||
return Err("Failed to get base directories".into());
|
||||
};
|
||||
|
||||
// Check if registry thinks it's downloaded, but also verify files actually exist
|
||||
if self.registry.is_browser_downloaded(&browser_str, &version) {
|
||||
let actually_exists = browser.is_version_downloaded(&version, &binaries_dir);
|
||||
|
||||
if actually_exists {
|
||||
// Remove from downloading set since it's already downloaded
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
return Ok(version);
|
||||
} else {
|
||||
// Registry says it's downloaded but files don't exist - clean up registry
|
||||
println!("Registry indicates {browser_str} {version} is downloaded, but files are missing. Cleaning up registry entry.");
|
||||
self.registry.remove_browser(&browser_str, &version);
|
||||
self
|
||||
.registry
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save cleaned registry: {e}"))?;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if browser is supported on current platform before attempting download
|
||||
if !self
|
||||
.version_service
|
||||
.is_browser_supported(&browser_str)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
// Remove from downloading set on error
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
return Err(
|
||||
format!(
|
||||
"Browser '{}' is not supported on your platform ({} {}). Supported browsers: {}",
|
||||
browser_str,
|
||||
std::env::consts::OS,
|
||||
std::env::consts::ARCH,
|
||||
self.version_service.get_supported_browsers().join(", ")
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
let download_info = self
|
||||
.version_service
|
||||
.get_download_info(&browser_str, &version)
|
||||
.map_err(|e| format!("Failed to get download info: {e}"))?;
|
||||
|
||||
// Create browser directory
|
||||
let mut browser_dir = binaries_dir.clone();
|
||||
browser_dir.push(&browser_str);
|
||||
browser_dir.push(&version);
|
||||
|
||||
std::fs::create_dir_all(&browser_dir)
|
||||
.map_err(|e| format!("Failed to create browser directory: {e}"))?;
|
||||
|
||||
// Mark download as started (but don't add to registry yet)
|
||||
self
|
||||
.registry
|
||||
.mark_download_started(&browser_str, &version, browser_dir.clone());
|
||||
|
||||
// Attempt to download the archive. If the download fails but an archive with the
|
||||
// expected filename already exists (manual download), continue using that file.
|
||||
let download_path: PathBuf = match self
|
||||
.download_browser(
|
||||
app_handle,
|
||||
browser_type.clone(),
|
||||
&version,
|
||||
&download_info,
|
||||
&browser_dir,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(path) => path,
|
||||
Err(e) => {
|
||||
// Do NOT continue with extraction on failed downloads. Partial files may exist but are invalid.
|
||||
// Clean registry entry and stop here so the UI can show a single, clear error.
|
||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||
let _ = self.registry.save();
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
return Err(format!("Failed to download browser: {e}").into());
|
||||
}
|
||||
};
|
||||
|
||||
// Use the extraction module
|
||||
if download_info.is_archive {
|
||||
match self
|
||||
.extractor
|
||||
.extract_browser(
|
||||
app_handle,
|
||||
browser_type.clone(),
|
||||
&version,
|
||||
&download_path,
|
||||
&browser_dir,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
// Do not remove the archive here. We keep it until verification succeeds.
|
||||
}
|
||||
Err(e) => {
|
||||
// Do not remove the archive or extracted files. Just drop the registry entry
|
||||
// so it won't be reported as downloaded.
|
||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||
let _ = self.registry.save();
|
||||
// Remove browser-version pair from downloading set on error
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
return Err(format!("Failed to extract browser: {e}").into());
|
||||
}
|
||||
}
|
||||
|
||||
// Give filesystem a moment to settle after extraction
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
}
|
||||
|
||||
// Emit verification progress
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_str.clone(),
|
||||
version: version.clone(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 100.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: None,
|
||||
stage: "verifying".to_string(),
|
||||
};
|
||||
let _ = app_handle.emit("download-progress", &progress);
|
||||
|
||||
// Verify the browser was downloaded correctly
|
||||
println!("Verifying download for browser: {browser_str}, version: {version}");
|
||||
|
||||
// Use the browser's own verification method
|
||||
if !browser.is_version_downloaded(&version, &binaries_dir) {
|
||||
// Provide detailed error information for debugging
|
||||
let browser_dir = binaries_dir.join(&browser_str).join(&version);
|
||||
let mut error_details = format!(
|
||||
"Browser download completed but verification failed for {} {}. Expected directory: {}",
|
||||
browser_str,
|
||||
version,
|
||||
browser_dir.display()
|
||||
);
|
||||
|
||||
// List what files actually exist
|
||||
if browser_dir.exists() {
|
||||
error_details.push_str("\nFiles found in directory:");
|
||||
if let Ok(entries) = std::fs::read_dir(&browser_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_type = if path.is_dir() { "DIR" } else { "FILE" };
|
||||
error_details.push_str(&format!("\n {} {}", file_type, path.display()));
|
||||
}
|
||||
} else {
|
||||
error_details.push_str("\n (Could not read directory contents)");
|
||||
}
|
||||
} else {
|
||||
error_details.push_str("\nDirectory does not exist!");
|
||||
}
|
||||
|
||||
// For Camoufox on Linux, provide specific expected files
|
||||
if browser_str == "camoufox" && cfg!(target_os = "linux") {
|
||||
let camoufox_subdir = browser_dir.join("camoufox");
|
||||
error_details.push_str("\nExpected Camoufox executable locations:");
|
||||
error_details.push_str(&format!("\n {}/camoufox-bin", camoufox_subdir.display()));
|
||||
error_details.push_str(&format!("\n {}/camoufox", camoufox_subdir.display()));
|
||||
|
||||
if camoufox_subdir.exists() {
|
||||
error_details.push_str(&format!(
|
||||
"\nCamoufox subdirectory exists: {}",
|
||||
camoufox_subdir.display()
|
||||
));
|
||||
if let Ok(entries) = std::fs::read_dir(&camoufox_subdir) {
|
||||
error_details.push_str("\nFiles in camoufox subdirectory:");
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_type = if path.is_dir() { "DIR" } else { "FILE" };
|
||||
error_details.push_str(&format!("\n {} {}", file_type, path.display()));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
error_details.push_str(&format!(
|
||||
"\nCamoufox subdirectory does not exist: {}",
|
||||
camoufox_subdir.display()
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Do not delete files on verification failure; keep archive for manual retry.
|
||||
let _ = self.registry.remove_browser(&browser_str, &version);
|
||||
let _ = self.registry.save();
|
||||
// Remove browser-version pair from downloading set on verification failure
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
return Err(error_details.into());
|
||||
}
|
||||
|
||||
// Mark completion in registry - only now add to registry after verification
|
||||
if let Err(e) =
|
||||
self
|
||||
.registry
|
||||
.mark_download_completed(&browser_str, &version, browser_dir.clone())
|
||||
{
|
||||
eprintln!("Warning: Could not mark {browser_str} {version} as completed in registry: {e}");
|
||||
}
|
||||
self
|
||||
.registry
|
||||
.save()
|
||||
.map_err(|e| format!("Failed to save registry: {e}"))?;
|
||||
|
||||
// Now that verification succeeded, remove the archive file if it exists
|
||||
if download_info.is_archive {
|
||||
let archive_path = browser_dir.join(&download_info.filename);
|
||||
if archive_path.exists() {
|
||||
if let Err(e) = std::fs::remove_file(&archive_path) {
|
||||
println!("Warning: Could not delete archive file after verification: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If this is Camoufox, automatically download GeoIP database
|
||||
if browser_str == "camoufox" {
|
||||
// Check if GeoIP database is already available
|
||||
if !crate::geoip_downloader::GeoIPDownloader::is_geoip_database_available() {
|
||||
println!("Downloading GeoIP database for Camoufox...");
|
||||
|
||||
match self
|
||||
.geoip_downloader
|
||||
.download_geoip_database(app_handle)
|
||||
.await
|
||||
{
|
||||
Ok(_) => {
|
||||
println!("GeoIP database downloaded successfully");
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to download GeoIP database: {e}");
|
||||
// Don't fail the browser download if GeoIP download fails
|
||||
}
|
||||
}
|
||||
} else {
|
||||
println!("GeoIP database already available");
|
||||
}
|
||||
}
|
||||
|
||||
// Emit completion
|
||||
let progress = DownloadProgress {
|
||||
browser: browser_str.clone(),
|
||||
version: version.clone(),
|
||||
downloaded_bytes: 0,
|
||||
total_bytes: None,
|
||||
percentage: 100.0,
|
||||
speed_bytes_per_sec: 0.0,
|
||||
eta_seconds: Some(0.0),
|
||||
stage: "completed".to_string(),
|
||||
};
|
||||
let _ = app_handle.emit("download-progress", &progress);
|
||||
|
||||
// Remove browser-version pair from downloading set
|
||||
{
|
||||
let mut downloading = DOWNLOADING_BROWSERS.lock().unwrap();
|
||||
downloading.remove(&download_key);
|
||||
}
|
||||
|
||||
Ok(version)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn download_browser(
|
||||
app_handle: tauri::AppHandle,
|
||||
browser_str: String,
|
||||
version: String,
|
||||
) -> Result<String, String> {
|
||||
let downloader = Downloader::instance();
|
||||
downloader
|
||||
.download_browser_full(&app_handle, browser_str, version)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to download browser: {e}"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -4,7 +4,7 @@ use std::path::{Path, PathBuf};
|
||||
use tauri::Emitter;
|
||||
|
||||
use crate::browser::BrowserType;
|
||||
use crate::download::DownloadProgress;
|
||||
use crate::downloader::DownloadProgress;
|
||||
|
||||
#[cfg(any(target_os = "macos", target_os = "windows"))]
|
||||
use std::process::Command;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::browser::GithubRelease;
|
||||
use crate::profile::manager::ProfileManager;
|
||||
use directories::BaseDirs;
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
@@ -75,6 +76,25 @@ impl GeoIPDownloader {
|
||||
false
|
||||
}
|
||||
}
|
||||
/// Check if GeoIP database is missing for Camoufox profiles
|
||||
pub fn check_missing_geoip_database(
|
||||
&self,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Get all profiles
|
||||
let profiles = ProfileManager::instance()
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
|
||||
// Check if there are any Camoufox profiles
|
||||
let has_camoufox_profiles = profiles.iter().any(|profile| profile.browser == "camoufox");
|
||||
|
||||
if has_camoufox_profiles {
|
||||
// Check if GeoIP database is available
|
||||
return Ok(!Self::is_geoip_database_available());
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn find_city_mmdb_asset(&self, release: &GithubRelease) -> Option<String> {
|
||||
for asset in &release.assets {
|
||||
@@ -218,6 +238,19 @@ impl GeoIPDownloader {
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn check_missing_geoip_database() -> Result<bool, String> {
|
||||
let geoip_downloader = GeoIPDownloader::instance();
|
||||
geoip_downloader
|
||||
.check_missing_geoip_database()
|
||||
.map_err(|e| format!("Failed to check missing GeoIP database: {e}"))
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref GEOIP_DOWNLOADER: GeoIPDownloader = GeoIPDownloader::new();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -353,8 +386,3 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref GEOIP_DOWNLOADER: GeoIPDownloader = GeoIPDownloader::new();
|
||||
}
|
||||
|
||||
+53
-273
@@ -4,6 +4,7 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
use tauri::Emitter;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileGroup {
|
||||
@@ -97,7 +98,11 @@ impl GroupManager {
|
||||
Ok(groups_data.groups)
|
||||
}
|
||||
|
||||
pub fn create_group(&self, name: String) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
pub fn create_group(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
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
|
||||
@@ -113,11 +118,17 @@ impl GroupManager {
|
||||
groups_data.groups.push(group.clone());
|
||||
self.save_groups_data(&groups_data)?;
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("groups-changed", ()) {
|
||||
eprintln!("Failed to emit groups-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
pub fn update_group(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
id: String,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
@@ -142,10 +153,20 @@ impl GroupManager {
|
||||
let updated_group = group.clone();
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("groups-changed", ()) {
|
||||
eprintln!("Failed to emit groups-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(updated_group)
|
||||
}
|
||||
|
||||
pub fn delete_group(&self, id: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
pub fn delete_group(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
id: String,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
let initial_len = groups_data.groups.len();
|
||||
@@ -156,6 +177,12 @@ impl GroupManager {
|
||||
}
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("groups-changed", ()) {
|
||||
eprintln!("Failed to emit groups-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -203,267 +230,6 @@ lazy_static::lazy_static! {
|
||||
pub static ref GROUP_MANAGER: Mutex<GroupManager> = Mutex::new(GroupManager::new());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn create_test_group_manager() -> (GroupManager, TempDir) {
|
||||
let temp_dir = TempDir::new().expect("Failed to create temp directory");
|
||||
|
||||
// Set up a temporary home directory for testing
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
|
||||
// Use per-test isolated data directory without relying on global env vars
|
||||
let data_override = temp_dir.path().join("donutbrowser_test_data");
|
||||
let manager = GroupManager::with_data_dir_override(&data_override);
|
||||
(manager, temp_dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_group_manager_creation() {
|
||||
let (_manager, _temp_dir) = create_test_group_manager();
|
||||
// Test passes if no panic occurs
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_and_get_groups() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Initially should have no groups
|
||||
let groups = manager
|
||||
.get_all_groups()
|
||||
.expect("Should be able to get groups");
|
||||
assert!(groups.is_empty(), "Should start with no groups");
|
||||
|
||||
// Create a group
|
||||
let group_name = "Test Group".to_string();
|
||||
let created_group = manager
|
||||
.create_group(group_name.clone())
|
||||
.expect("Should create group successfully");
|
||||
|
||||
assert_eq!(
|
||||
created_group.name, group_name,
|
||||
"Created group should have correct name"
|
||||
);
|
||||
assert!(
|
||||
!created_group.id.is_empty(),
|
||||
"Created group should have an ID"
|
||||
);
|
||||
|
||||
// Verify group was saved
|
||||
let groups = manager
|
||||
.get_all_groups()
|
||||
.expect("Should be able to get groups");
|
||||
assert_eq!(groups.len(), 1, "Should have one group");
|
||||
assert_eq!(
|
||||
groups[0].name, group_name,
|
||||
"Retrieved group should have correct name"
|
||||
);
|
||||
assert_eq!(
|
||||
groups[0].id, created_group.id,
|
||||
"Retrieved group should have correct ID"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_create_duplicate_group_fails() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
let group_name = "Duplicate Group".to_string();
|
||||
|
||||
// Create first group
|
||||
let _first_group = manager
|
||||
.create_group(group_name.clone())
|
||||
.expect("Should create first group");
|
||||
|
||||
// Try to create duplicate group
|
||||
let result = manager.create_group(group_name.clone());
|
||||
assert!(result.is_err(), "Should fail to create duplicate group");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("already exists"),
|
||||
"Error should mention group already exists"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_group() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Create a group
|
||||
let original_name = "Original Name".to_string();
|
||||
let created_group = manager
|
||||
.create_group(original_name)
|
||||
.expect("Should create group");
|
||||
|
||||
// Update the group
|
||||
let new_name = "Updated Name".to_string();
|
||||
let updated_group = manager
|
||||
.update_group(created_group.id.clone(), new_name.clone())
|
||||
.expect("Should update group successfully");
|
||||
|
||||
assert_eq!(
|
||||
updated_group.name, new_name,
|
||||
"Updated group should have new name"
|
||||
);
|
||||
assert_eq!(
|
||||
updated_group.id, created_group.id,
|
||||
"Updated group should keep same ID"
|
||||
);
|
||||
|
||||
// Verify update was persisted
|
||||
let groups = manager.get_all_groups().expect("Should get groups");
|
||||
assert_eq!(groups.len(), 1, "Should still have one group");
|
||||
assert_eq!(
|
||||
groups[0].name, new_name,
|
||||
"Persisted group should have updated name"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_update_nonexistent_group_fails() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
let result = manager.update_group("nonexistent-id".to_string(), "New Name".to_string());
|
||||
assert!(result.is_err(), "Should fail to update nonexistent group");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("not found"),
|
||||
"Error should mention group not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_group() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Create a group
|
||||
let group_name = "To Delete".to_string();
|
||||
let created_group = manager
|
||||
.create_group(group_name)
|
||||
.expect("Should create group");
|
||||
|
||||
// Verify group exists
|
||||
let groups = manager.get_all_groups().expect("Should get groups");
|
||||
assert_eq!(groups.len(), 1, "Should have one group");
|
||||
|
||||
// Delete the group
|
||||
manager
|
||||
.delete_group(created_group.id)
|
||||
.expect("Should delete group successfully");
|
||||
|
||||
// Verify group was deleted
|
||||
let groups = manager.get_all_groups().expect("Should get groups");
|
||||
assert!(groups.is_empty(), "Should have no groups after deletion");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_delete_nonexistent_group_fails() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
let result = manager.delete_group("nonexistent-id".to_string());
|
||||
assert!(result.is_err(), "Should fail to delete nonexistent group");
|
||||
|
||||
let error_msg = result.unwrap_err().to_string();
|
||||
assert!(
|
||||
error_msg.contains("not found"),
|
||||
"Error should mention group not found"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_groups_with_profile_counts() {
|
||||
let (manager, _temp_dir) = create_test_group_manager();
|
||||
|
||||
// Create test groups
|
||||
let group1 = manager
|
||||
.create_group("Group 1".to_string())
|
||||
.expect("Should create group 1");
|
||||
let _group2 = manager
|
||||
.create_group("Group 2".to_string())
|
||||
.expect("Should create group 2");
|
||||
|
||||
// Create mock profiles
|
||||
let profiles = vec![
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "Profile 1".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: Some(group1.id.clone()),
|
||||
},
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "Profile 2".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: Some(group1.id.clone()),
|
||||
},
|
||||
crate::profile::BrowserProfile {
|
||||
id: uuid::Uuid::new_v4(),
|
||||
name: "Profile 3".to_string(),
|
||||
browser: "firefox".to_string(),
|
||||
version: "1.0".to_string(),
|
||||
proxy_id: None,
|
||||
process_id: None,
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None, // Default group
|
||||
},
|
||||
];
|
||||
|
||||
let groups_with_counts = manager
|
||||
.get_groups_with_profile_counts(&profiles)
|
||||
.expect("Should get groups with counts");
|
||||
|
||||
// Should have default group + group1 + group2 (group2 has 0 profiles but should still appear)
|
||||
assert_eq!(
|
||||
groups_with_counts.len(),
|
||||
3,
|
||||
"Should include all groups, even those with 0 profiles"
|
||||
);
|
||||
|
||||
// Check default group
|
||||
let default_group = groups_with_counts
|
||||
.iter()
|
||||
.find(|g| g.id == "default")
|
||||
.expect("Should have default group");
|
||||
assert_eq!(
|
||||
default_group.count, 1,
|
||||
"Default group should have 1 profile"
|
||||
);
|
||||
|
||||
// Check group1
|
||||
let group1_with_count = groups_with_counts
|
||||
.iter()
|
||||
.find(|g| g.id == group1.id)
|
||||
.expect("Should have group1");
|
||||
assert_eq!(group1_with_count.count, 2, "Group1 should have 2 profiles");
|
||||
|
||||
// Check that group2 exists with 0 profiles
|
||||
let group2_with_count = groups_with_counts
|
||||
.iter()
|
||||
.find(|g| g.name == "Group 2")
|
||||
.expect("Should have group2 present even with 0 profiles");
|
||||
assert_eq!(group2_with_count.count, 0, "Group2 should have 0 profiles");
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get groups with counts
|
||||
pub fn get_groups_with_counts(profiles: &[crate::profile::BrowserProfile]) -> Vec<GroupWithCount> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
@@ -491,44 +257,58 @@ pub async fn get_groups_with_profile_counts() -> Result<Vec<GroupWithCount>, Str
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_profile_group(name: String) -> Result<ProfileGroup, String> {
|
||||
pub async fn create_profile_group(
|
||||
app_handle: tauri::AppHandle,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.create_group(name)
|
||||
.create_group(&app_handle, 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> {
|
||||
pub async fn update_profile_group(
|
||||
app_handle: tauri::AppHandle,
|
||||
group_id: String,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.update_group(group_id, name)
|
||||
.update_group(&app_handle, 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> {
|
||||
pub async fn delete_profile_group(
|
||||
app_handle: tauri::AppHandle,
|
||||
group_id: String,
|
||||
) -> Result<(), String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.delete_group(group_id)
|
||||
.delete_group(&app_handle, group_id)
|
||||
.map_err(|e| format!("Failed to delete group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assign_profiles_to_group(
|
||||
profile_names: Vec<String>,
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_ids: Vec<String>,
|
||||
group_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
profile_manager
|
||||
.assign_profiles_to_group(profile_names, group_id)
|
||||
.assign_profiles_to_group(&app_handle, profile_ids, 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> {
|
||||
pub async fn delete_selected_profiles(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
profile_manager
|
||||
.delete_multiple_profiles(profile_names)
|
||||
.delete_multiple_profiles(&app_handle, profile_ids)
|
||||
.map_err(|e| format!("Failed to delete profiles: {e}"))
|
||||
}
|
||||
|
||||
+179
-33
@@ -8,15 +8,16 @@ use tauri_plugin_deep_link::DeepLinkExt;
|
||||
static PENDING_URLS: Mutex<Vec<String>> = Mutex::new(Vec::new());
|
||||
|
||||
mod api_client;
|
||||
mod api_server;
|
||||
mod app_auto_updater;
|
||||
mod auto_updater;
|
||||
mod browser;
|
||||
mod browser_runner;
|
||||
mod browser_version_manager;
|
||||
mod camoufox;
|
||||
mod camoufox_manager;
|
||||
mod default_browser;
|
||||
mod download;
|
||||
mod downloaded_browsers;
|
||||
mod downloaded_browsers_registry;
|
||||
mod downloader;
|
||||
mod extraction;
|
||||
mod geoip_downloader;
|
||||
mod group_manager;
|
||||
@@ -26,34 +27,46 @@ mod profile_importer;
|
||||
mod proxy_manager;
|
||||
mod settings_manager;
|
||||
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
|
||||
mod tag_manager;
|
||||
mod version_updater;
|
||||
|
||||
extern crate lazy_static;
|
||||
|
||||
use browser_runner::{
|
||||
check_browser_exists, check_browser_status, check_missing_binaries, check_missing_geoip_database,
|
||||
create_browser_profile_new, delete_profile, download_browser, ensure_all_binaries_exist,
|
||||
fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
|
||||
fetch_browser_versions_with_count_cached_first, get_downloaded_browser_versions,
|
||||
get_supported_browsers, is_browser_supported_on_platform, kill_browser_profile,
|
||||
launch_browser_profile, list_browser_profiles, rename_profile, update_camoufox_config,
|
||||
update_profile_proxy,
|
||||
check_browser_exists, kill_browser_profile, launch_browser_profile, open_url_with_profile,
|
||||
};
|
||||
|
||||
use profile::manager::{
|
||||
check_browser_status, create_browser_profile_new, delete_profile, list_browser_profiles,
|
||||
rename_profile, update_camoufox_config, update_profile_proxy, update_profile_tags,
|
||||
};
|
||||
|
||||
use browser_version_manager::{
|
||||
fetch_browser_versions_cached_first, fetch_browser_versions_with_count,
|
||||
fetch_browser_versions_with_count_cached_first, get_supported_browsers,
|
||||
is_browser_supported_on_platform,
|
||||
};
|
||||
|
||||
use downloaded_browsers_registry::{
|
||||
check_missing_binaries, ensure_all_binaries_exist, get_downloaded_browser_versions,
|
||||
};
|
||||
|
||||
use downloader::download_browser;
|
||||
|
||||
use settings_manager::{
|
||||
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,
|
||||
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};
|
||||
use tag_manager::get_all_tags;
|
||||
|
||||
use default_browser::{is_default_browser, set_as_default_browser};
|
||||
|
||||
use version_updater::{
|
||||
get_version_update_status, get_version_updater, trigger_manual_version_update,
|
||||
clear_all_version_cache_and_refetch, get_version_update_status, get_version_updater,
|
||||
trigger_manual_version_update,
|
||||
};
|
||||
|
||||
use auto_updater::{
|
||||
check_for_browser_updates, complete_browser_update_with_auto_update, dismiss_update_notification,
|
||||
is_browser_disabled_for_update,
|
||||
};
|
||||
|
||||
use app_auto_updater::{
|
||||
@@ -62,17 +75,17 @@ use app_auto_updater::{
|
||||
|
||||
use profile_importer::{detect_existing_profiles, import_browser_profile};
|
||||
|
||||
// use theme_detector::get_system_theme;
|
||||
|
||||
use group_manager::{
|
||||
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
|
||||
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
|
||||
};
|
||||
|
||||
use geoip_downloader::GeoIPDownloader;
|
||||
use geoip_downloader::{check_missing_geoip_database, GeoIPDownloader};
|
||||
|
||||
use browser_version_manager::get_browser_release_types;
|
||||
|
||||
use api_server::{get_api_server_status, start_api_server, stop_api_server};
|
||||
|
||||
// Trait to extend WebviewWindow with transparent titlebar functionality
|
||||
pub trait WindowExt {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -175,11 +188,12 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_stored_proxy(
|
||||
app_handle: tauri::AppHandle,
|
||||
name: String,
|
||||
proxy_settings: crate::browser::ProxySettings,
|
||||
) -> Result<crate::proxy_manager::StoredProxy, String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.create_stored_proxy(name, proxy_settings)
|
||||
.create_stored_proxy(&app_handle, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to create stored proxy: {e}"))
|
||||
}
|
||||
|
||||
@@ -190,19 +204,20 @@ async fn get_stored_proxies() -> Result<Vec<crate::proxy_manager::StoredProxy>,
|
||||
|
||||
#[tauri::command]
|
||||
async fn update_stored_proxy(
|
||||
app_handle: tauri::AppHandle,
|
||||
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)
|
||||
.update_stored_proxy(&app_handle, &proxy_id, name, proxy_settings)
|
||||
.map_err(|e| format!("Failed to update stored proxy: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn delete_stored_proxy(proxy_id: String) -> Result<(), String> {
|
||||
async fn delete_stored_proxy(app_handle: tauri::AppHandle, proxy_id: String) -> Result<(), String> {
|
||||
crate::proxy_manager::PROXY_MANAGER
|
||||
.delete_stored_proxy(&proxy_id)
|
||||
.delete_stored_proxy(&app_handle, &proxy_id)
|
||||
.map_err(|e| format!("Failed to delete stored proxy: {e}"))
|
||||
}
|
||||
|
||||
@@ -378,8 +393,9 @@ pub fn run() {
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
if let Err(e) = browser_runner.cleanup_unused_binaries_internal() {
|
||||
let registry =
|
||||
crate::downloaded_browsers_registry::DownloadedBrowsersRegistry::instance();
|
||||
if let Err(e) = registry.cleanup_unused_binaries() {
|
||||
eprintln!("Periodic cleanup failed: {e}");
|
||||
} else {
|
||||
println!("Periodic cleanup completed successfully");
|
||||
@@ -416,14 +432,14 @@ pub fn run() {
|
||||
// Start Camoufox cleanup task
|
||||
let _app_handle_cleanup = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let launcher = crate::camoufox::CamoufoxNodecarLauncher::instance();
|
||||
let camoufox_manager = crate::camoufox_manager::CamoufoxManager::instance();
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match launcher.cleanup_dead_instances().await {
|
||||
Ok(_dead_instances) => {
|
||||
match camoufox_manager.cleanup_dead_instances().await {
|
||||
Ok(_) => {
|
||||
// Cleanup completed silently
|
||||
}
|
||||
Err(e) => {
|
||||
@@ -439,8 +455,8 @@ pub fn run() {
|
||||
// Wait a bit for the app to fully initialize
|
||||
tokio::time::sleep(tokio::time::Duration::from_secs(2)).await;
|
||||
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
match browser_runner.check_missing_geoip_database() {
|
||||
let geoip_downloader = crate::geoip_downloader::GeoIPDownloader::instance();
|
||||
match geoip_downloader.check_missing_geoip_database() {
|
||||
Ok(true) => {
|
||||
println!("GeoIP database is missing for Camoufox profiles, downloading at startup...");
|
||||
let geoip_downloader = GeoIPDownloader::instance();
|
||||
@@ -489,8 +505,135 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
// Periodically broadcast browser running status to the frontend
|
||||
let app_handle_status = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(500));
|
||||
let mut last_running_states: std::collections::HashMap<String, bool> =
|
||||
std::collections::HashMap::new();
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let runner = crate::browser_runner::BrowserRunner::instance();
|
||||
// If listing profiles fails, skip this tick
|
||||
let profiles = match runner.profile_manager.list_profiles() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
println!("Warning: Failed to list profiles in status checker: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for profile in profiles {
|
||||
// Check browser status and track changes
|
||||
match runner
|
||||
.check_browser_status(app_handle_status.clone(), &profile)
|
||||
.await
|
||||
{
|
||||
Ok(is_running) => {
|
||||
let profile_id = profile.id.to_string();
|
||||
let last_state = last_running_states
|
||||
.get(&profile_id)
|
||||
.copied()
|
||||
.unwrap_or(false);
|
||||
|
||||
// Only emit event if state actually changed
|
||||
if last_state != is_running {
|
||||
println!(
|
||||
"Status checker detected change for profile {}: {} -> {}",
|
||||
profile.name, last_state, is_running
|
||||
);
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct RunningChangedPayload {
|
||||
id: String,
|
||||
is_running: bool,
|
||||
}
|
||||
|
||||
let payload = RunningChangedPayload {
|
||||
id: profile_id.clone(),
|
||||
is_running,
|
||||
};
|
||||
|
||||
if let Err(e) = app_handle_status.emit("profile-running-changed", &payload) {
|
||||
println!("Warning: Failed to emit profile running changed event: {e}");
|
||||
} else {
|
||||
println!(
|
||||
"Status checker emitted profile-running-changed event for {}: running={}",
|
||||
profile.name, is_running
|
||||
);
|
||||
}
|
||||
|
||||
last_running_states.insert(profile_id, is_running);
|
||||
} else {
|
||||
// Update the state even if unchanged to ensure we have it tracked
|
||||
last_running_states.insert(profile_id, is_running);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!(
|
||||
"Warning: Status check failed for profile {}: {}",
|
||||
profile.name, e
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Nodecar warm-up is now triggered from the frontend to allow UI blocking overlay
|
||||
|
||||
// Start API server if enabled in settings
|
||||
let app_handle_api = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
match crate::settings_manager::get_app_settings(app_handle_api.clone()).await {
|
||||
Ok(settings) => {
|
||||
if settings.api_enabled {
|
||||
println!("API is enabled in settings, starting API server...");
|
||||
match crate::api_server::start_api_server_internal(settings.api_port, &app_handle_api)
|
||||
.await
|
||||
{
|
||||
Ok(port) => {
|
||||
println!("API server started successfully on port {port}");
|
||||
// Emit success toast to frontend
|
||||
if let Err(e) = app_handle_api.emit(
|
||||
"show-toast",
|
||||
crate::api_server::ToastPayload {
|
||||
message: "API server started successfully".to_string(),
|
||||
variant: "success".to_string(),
|
||||
title: "Local API Started".to_string(),
|
||||
description: Some(format!("API server running on port {port}")),
|
||||
},
|
||||
) {
|
||||
eprintln!("Failed to emit API start toast: {e}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start API server at startup: {e}");
|
||||
// Emit error toast to frontend
|
||||
if let Err(toast_err) = app_handle_api.emit(
|
||||
"show-toast",
|
||||
crate::api_server::ToastPayload {
|
||||
message: "Failed to start API server".to_string(),
|
||||
variant: "error".to_string(),
|
||||
title: "Failed to Start Local API".to_string(),
|
||||
description: Some(format!("Error: {e}")),
|
||||
},
|
||||
) {
|
||||
eprintln!("Failed to emit API error toast: {toast_err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to load app settings for API startup: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -506,8 +649,10 @@ pub fn run() {
|
||||
fetch_browser_versions_cached_first,
|
||||
fetch_browser_versions_with_count_cached_first,
|
||||
get_downloaded_browser_versions,
|
||||
get_all_tags,
|
||||
get_browser_release_types,
|
||||
update_profile_proxy,
|
||||
update_profile_tags,
|
||||
check_browser_status,
|
||||
kill_browser_profile,
|
||||
rename_profile,
|
||||
@@ -523,13 +668,11 @@ pub fn run() {
|
||||
trigger_manual_version_update,
|
||||
get_version_update_status,
|
||||
check_for_browser_updates,
|
||||
is_browser_disabled_for_update,
|
||||
dismiss_update_notification,
|
||||
complete_browser_update_with_auto_update,
|
||||
check_for_app_updates,
|
||||
check_for_app_updates_manual,
|
||||
download_and_install_app_update,
|
||||
// get_system_theme, // removed
|
||||
detect_existing_profiles,
|
||||
import_browser_profile,
|
||||
check_missing_binaries,
|
||||
@@ -550,6 +693,9 @@ pub fn run() {
|
||||
is_geoip_database_available,
|
||||
download_geoip_database,
|
||||
warm_up_nodecar,
|
||||
start_api_server,
|
||||
stop_api_server,
|
||||
get_api_server_status
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -214,6 +214,227 @@ end try
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn kill_browser_process_impl(
|
||||
pid: u32,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Attempting to kill browser process with PID: {pid}");
|
||||
|
||||
// For Chromium-based browsers, use immediate aggressive termination
|
||||
// Chromium browsers are notoriously difficult to kill on macOS due to process spawning
|
||||
|
||||
// Step 1: Immediate SIGKILL on main process (no graceful shutdown for Chromium)
|
||||
println!("Starting immediate SIGKILL for PID: {pid}");
|
||||
let _ = Command::new("kill")
|
||||
.args(["-KILL", &pid.to_string()])
|
||||
.output();
|
||||
|
||||
// Step 2: Comprehensive process tree termination using multiple methods simultaneously
|
||||
let _ = kill_chromium_process_tree_aggressive(pid).await;
|
||||
|
||||
// Step 2.5: Nuclear option - kill all Chromium processes by name pattern
|
||||
let _ = kill_all_chromium_processes_by_name().await;
|
||||
|
||||
// Step 3: Use multiple kill strategies in parallel
|
||||
let pid_str = pid.to_string();
|
||||
|
||||
// Kill by parent PID with SIGKILL
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-P", &pid_str])
|
||||
.output();
|
||||
|
||||
// Kill by process group with SIGKILL
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-g", &pid_str])
|
||||
.output();
|
||||
|
||||
// Kill by session ID
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-s", &pid_str])
|
||||
.output();
|
||||
|
||||
// Wait briefly for initial termination
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
|
||||
// Step 4: Verify and retry with pattern-based killing for common Chromium process names
|
||||
use sysinfo::{Pid, System};
|
||||
let system = System::new_all();
|
||||
|
||||
// Check if main process still exists
|
||||
if system.process(Pid::from(pid as usize)).is_some() {
|
||||
println!("Main process {pid} still running, using pattern-based termination");
|
||||
|
||||
// Kill by common Chromium process patterns
|
||||
let chromium_patterns = [
|
||||
"Chrome",
|
||||
"Chromium",
|
||||
"Brave",
|
||||
"chrome",
|
||||
"chromium",
|
||||
"brave",
|
||||
"Google Chrome",
|
||||
"Brave Browser",
|
||||
"Chrome Helper",
|
||||
"Chromium Helper",
|
||||
];
|
||||
|
||||
for pattern in &chromium_patterns {
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-f", pattern])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
// Step 5: Final aggressive cleanup - kill any remaining processes
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(300)).await;
|
||||
|
||||
// One more round of comprehensive killing
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-P", &pid_str])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-g", &pid_str])
|
||||
.output();
|
||||
|
||||
// Final verification with extended wait
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(500)).await;
|
||||
let system = System::new_all();
|
||||
|
||||
if system.process(Pid::from(pid as usize)).is_some() {
|
||||
// Last resort: try system kill command with different signals
|
||||
println!("Process {pid} extremely persistent, trying system-level termination");
|
||||
|
||||
let _ = Command::new("/bin/kill").args(["-KILL", &pid_str]).output();
|
||||
|
||||
let _ = Command::new("/usr/bin/killall")
|
||||
.args(["-KILL", "-m", "Chrome"])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("/usr/bin/killall")
|
||||
.args(["-KILL", "-m", "Chromium"])
|
||||
.output();
|
||||
|
||||
let _ = Command::new("/usr/bin/killall")
|
||||
.args(["-KILL", "-m", "Brave"])
|
||||
.output();
|
||||
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(1000)).await;
|
||||
let system = System::new_all();
|
||||
|
||||
if system.process(Pid::from(pid as usize)).is_some() {
|
||||
println!("WARNING: Process {pid} could not be terminated despite aggressive attempts");
|
||||
// Don't return error - let the UI update anyway since we tried everything
|
||||
}
|
||||
}
|
||||
|
||||
println!("Aggressive browser termination completed for PID: {pid}");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper function to kill process tree (Chromium browsers often spawn child processes)
|
||||
async fn kill_chromium_process_tree_aggressive(
|
||||
pid: u32,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Killing comprehensive process tree for PID: {pid}");
|
||||
|
||||
// Get all descendant processes using recursive process tree discovery
|
||||
let descendant_pids = get_all_descendant_pids(pid).await;
|
||||
println!(
|
||||
"Found {} descendant processes to terminate",
|
||||
descendant_pids.len()
|
||||
);
|
||||
|
||||
// Kill all descendants first (reverse order - children before parents)
|
||||
for &desc_pid in descendant_pids.iter().rev() {
|
||||
if desc_pid != pid {
|
||||
println!("Terminating descendant process: {desc_pid}");
|
||||
let _ = Command::new("kill")
|
||||
.args(["-KILL", &desc_pid.to_string()])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
// No delay for initial termination
|
||||
|
||||
// Force kill any remaining descendants
|
||||
for &desc_pid in descendant_pids.iter().rev() {
|
||||
if desc_pid != pid {
|
||||
let _ = Command::new("kill")
|
||||
.args(["-KILL", &desc_pid.to_string()])
|
||||
.output();
|
||||
}
|
||||
}
|
||||
|
||||
// Also use pkill as a backup to catch any processes we might have missed
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-P", &pid.to_string()])
|
||||
.output();
|
||||
|
||||
// On macOS, also try killing by process group for Chromium browsers
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-g", &pid.to_string()])
|
||||
.output();
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Helper function to kill all Chromium-related processes by name patterns
|
||||
async fn kill_all_chromium_processes_by_name(
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Killing all Chromium-related processes by name patterns");
|
||||
|
||||
let chromium_patterns = [
|
||||
"Chrome",
|
||||
"Chromium",
|
||||
"Brave",
|
||||
"chrome",
|
||||
"chromium",
|
||||
"brave",
|
||||
"Google Chrome",
|
||||
"Brave Browser",
|
||||
"Chrome Helper",
|
||||
"Chromium Helper",
|
||||
];
|
||||
|
||||
for pattern in &chromium_patterns {
|
||||
let _ = Command::new("pkill")
|
||||
.args(["-KILL", "-f", pattern])
|
||||
.output();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Recursively find all descendant processes
|
||||
async fn get_all_descendant_pids(parent_pid: u32) -> Vec<u32> {
|
||||
use sysinfo::System;
|
||||
|
||||
let system = System::new_all();
|
||||
let mut descendants = Vec::new();
|
||||
let mut to_check = vec![parent_pid];
|
||||
let mut checked = std::collections::HashSet::new();
|
||||
|
||||
while let Some(current_pid) = to_check.pop() {
|
||||
if checked.contains(¤t_pid) {
|
||||
continue;
|
||||
}
|
||||
checked.insert(current_pid);
|
||||
|
||||
// Find direct children of current_pid
|
||||
for (pid, process) in system.processes() {
|
||||
let pid_u32 = pid.as_u32();
|
||||
if let Some(parent) = process.parent() {
|
||||
if parent.as_u32() == current_pid && !checked.contains(&pid_u32) {
|
||||
descendants.push(pid_u32);
|
||||
to_check.push(pid_u32);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
descendants
|
||||
}
|
||||
|
||||
pub async fn open_url_in_existing_browser_tor_mullvad(
|
||||
profile: &BrowserProfile,
|
||||
url: &str,
|
||||
@@ -455,39 +676,6 @@ end try
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn kill_browser_process_impl(
|
||||
pid: u32,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Attempting to kill browser process with PID: {pid}");
|
||||
|
||||
// First try SIGTERM (graceful shutdown)
|
||||
let output = Command::new("kill")
|
||||
.args(["-TERM", &pid.to_string()])
|
||||
.output()
|
||||
.map_err(|e| format!("Failed to execute kill command: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
// If SIGTERM fails, try SIGKILL (force kill)
|
||||
let output = Command::new("kill")
|
||||
.args(["-KILL", &pid.to_string()])
|
||||
.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to kill process {}: {}",
|
||||
pid,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
println!("Successfully killed browser process with PID: {pid}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
@@ -727,41 +915,10 @@ pub mod windows {
|
||||
cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let output = cmd.output()?;
|
||||
|
||||
if !output.status.success() {
|
||||
// Try fallback without --new-window
|
||||
let mut fallback_cmd = Command::new(&executable_path);
|
||||
fallback_cmd.args([
|
||||
&format!(
|
||||
"--user-data-dir={}",
|
||||
profile
|
||||
.get_profile_data_path(profiles_dir)
|
||||
.to_string_lossy()
|
||||
),
|
||||
url,
|
||||
]);
|
||||
|
||||
if let Some(parent_dir) = browser_dir
|
||||
.parent()
|
||||
.or_else(|| browser_dir.ancestors().nth(1))
|
||||
{
|
||||
fallback_cmd.current_dir(parent_dir);
|
||||
}
|
||||
|
||||
let fallback_output = fallback_cmd.output()?;
|
||||
|
||||
if !fallback_output.status.success() {
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to open URL in existing Chromium-based browser: {}",
|
||||
String::from_utf8_lossy(&fallback_output.stderr)
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Do not call output() to avoid blocking the UI thread while the browser processes the request.
|
||||
// Spawn the helper process and return immediately. This applies to Chromium-based browsers
|
||||
// including Brave to prevent UI freezes observed in production.
|
||||
let _child = cmd.spawn()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
+443
-195
@@ -1,5 +1,7 @@
|
||||
use crate::api_client::is_browser_version_nightly;
|
||||
use crate::browser::{create_browser, BrowserType, ProxySettings};
|
||||
use crate::camoufox::CamoufoxConfig;
|
||||
use crate::camoufox_manager::CamoufoxConfig;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::profile::types::BrowserProfile;
|
||||
use crate::proxy_manager::PROXY_MANAGER;
|
||||
use directories::BaseDirs;
|
||||
@@ -10,12 +12,14 @@ use tauri::Emitter;
|
||||
|
||||
pub struct ProfileManager {
|
||||
base_dirs: BaseDirs,
|
||||
camoufox_manager: &'static crate::camoufox_manager::CamoufoxManager,
|
||||
}
|
||||
|
||||
impl ProfileManager {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
camoufox_manager: crate::camoufox_manager::CamoufoxManager::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +38,17 @@ impl ProfileManager {
|
||||
path
|
||||
}
|
||||
|
||||
pub fn get_binaries_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn create_profile_with_group(
|
||||
&self,
|
||||
@@ -72,13 +87,12 @@ impl ProfileManager {
|
||||
let final_camoufox_config = if browser == "camoufox" {
|
||||
let mut config = camoufox_config.unwrap_or_else(|| {
|
||||
println!("Creating default Camoufox config for profile: {name}");
|
||||
crate::camoufox::CamoufoxConfig::default()
|
||||
crate::camoufox_manager::CamoufoxConfig::default()
|
||||
});
|
||||
|
||||
// Always ensure executable_path is set to the user's binary location
|
||||
if config.executable_path.is_none() {
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::instance();
|
||||
let mut browser_dir = browser_runner.get_binaries_dir();
|
||||
let mut browser_dir = self.get_binaries_dir();
|
||||
browser_dir.push(browser);
|
||||
browser_dir.push(version);
|
||||
|
||||
@@ -137,7 +151,6 @@ impl ProfileManager {
|
||||
println!("Generating fingerprint for Camoufox profile: {name}");
|
||||
|
||||
// Use the camoufox launcher to generate the config
|
||||
let camoufox_launcher = crate::camoufox::CamoufoxNodecarLauncher::instance();
|
||||
|
||||
// Create a temporary profile for fingerprint generation
|
||||
let temp_profile = BrowserProfile {
|
||||
@@ -151,9 +164,11 @@ impl ProfileManager {
|
||||
release_type: release_type.to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: group_id.clone(),
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
match camoufox_launcher
|
||||
match self
|
||||
.camoufox_manager
|
||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
||||
.await
|
||||
{
|
||||
@@ -191,6 +206,7 @@ impl ProfileManager {
|
||||
release_type: release_type.to_string(),
|
||||
camoufox_config: final_camoufox_config,
|
||||
group_id: group_id.clone(),
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
// Save profile info
|
||||
@@ -216,6 +232,11 @@ impl ProfileManager {
|
||||
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
|
||||
}
|
||||
|
||||
// Emit profile creation event
|
||||
if let Err(e) = app_handle.emit("profiles-changed", ()) {
|
||||
println!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
@@ -230,6 +251,11 @@ impl ProfileManager {
|
||||
let json = serde_json::to_string_pretty(profile)?;
|
||||
fs::write(profile_file, json)?;
|
||||
|
||||
// Update tag suggestions after any save
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -260,7 +286,8 @@ impl ProfileManager {
|
||||
|
||||
pub fn rename_profile(
|
||||
&self,
|
||||
old_name: &str,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
new_name: &str,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
// Check if new name already exists (case insensitive)
|
||||
@@ -272,11 +299,13 @@ impl ProfileManager {
|
||||
return Err(format!("Profile with name '{new_name}' already exists").into());
|
||||
}
|
||||
|
||||
// Find the profile by old name
|
||||
// Find the profile by ID
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let mut profile = existing_profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == old_name)
|
||||
.ok_or_else(|| format!("Profile '{old_name}' not found"))?;
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
// Update profile name (no need to move directories since we use UUID)
|
||||
profile.name = new_name.to_string();
|
||||
@@ -284,18 +313,34 @@ impl ProfileManager {
|
||||
// Save profile with new name
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
// Keep tag suggestions up to date after name change (rebuild from all profiles)
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
// Emit profile rename event
|
||||
if let Err(e) = app_handle.emit("profiles-changed", ()) {
|
||||
println!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn delete_profile(&self, profile_name: &str) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Attempting to delete profile: {profile_name}");
|
||||
pub fn delete_profile(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Attempting to delete profile with ID: {profile_id}");
|
||||
|
||||
// Find the profile by name
|
||||
// Find the profile by ID
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profiles = self.list_profiles()?;
|
||||
let profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
// Check if browser is running
|
||||
if profile.process_id.is_some() {
|
||||
@@ -316,25 +361,46 @@ impl ProfileManager {
|
||||
|
||||
// Verify deletion was successful
|
||||
if profile_uuid_dir.exists() {
|
||||
return Err(format!("Failed to completely delete profile '{profile_name}'").into());
|
||||
return Err(format!("Failed to completely delete profile '{}'", profile.name).into());
|
||||
}
|
||||
|
||||
println!("Profile '{profile_name}' deleted successfully");
|
||||
println!(
|
||||
"Profile '{}' (ID: {}) deleted successfully",
|
||||
profile.name, profile_id
|
||||
);
|
||||
|
||||
// Rebuild tag suggestions after deletion
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
// Always perform cleanup after profile deletion to remove unused binaries
|
||||
if let Err(e) = DownloadedBrowsersRegistry::instance().cleanup_unused_binaries() {
|
||||
println!("Warning: Failed to cleanup unused binaries after profile deletion: {e}");
|
||||
}
|
||||
|
||||
// Emit profile deletion event
|
||||
if let Err(e) = app_handle.emit("profiles-changed", ()) {
|
||||
println!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_profile_version(
|
||||
&self,
|
||||
profile_name: &str,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
version: &str,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
// Find the profile by name
|
||||
// Find the profile by ID
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profiles = self.list_profiles()?;
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile {profile_name} not found"))?;
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
// Check if the browser is currently running
|
||||
if profile.process_id.is_some() {
|
||||
@@ -357,37 +423,44 @@ impl ProfileManager {
|
||||
profile.version = version.to_string();
|
||||
|
||||
// Update the release_type based on the version and browser
|
||||
profile.release_type =
|
||||
if crate::api_client::is_browser_version_nightly(&profile.browser, version, None) {
|
||||
"nightly".to_string()
|
||||
} else {
|
||||
"stable".to_string()
|
||||
};
|
||||
profile.release_type = if is_browser_version_nightly(&profile.browser, version, None) {
|
||||
"nightly".to_string()
|
||||
} else {
|
||||
"stable".to_string()
|
||||
};
|
||||
|
||||
// Save the updated profile
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
// Emit profile update event
|
||||
if let Err(e) = app_handle.emit("profiles-changed", ()) {
|
||||
println!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn assign_profiles_to_group(
|
||||
&self,
|
||||
profile_names: Vec<String>,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_ids: Vec<String>,
|
||||
group_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let profiles = self.list_profiles()?;
|
||||
|
||||
for profile_name in profile_names {
|
||||
for profile_id in profile_ids {
|
||||
let profile_uuid = uuid::Uuid::parse_str(&profile_id)
|
||||
.map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let mut profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?
|
||||
.clone();
|
||||
|
||||
// Check if browser is running
|
||||
if profile.process_id.is_some() {
|
||||
return Err(format!(
|
||||
"Cannot modify group for profile '{profile_name}' while browser is running. Please stop the browser first."
|
||||
"Cannot modify group for profile '{}' while browser is running. Please stop the browser first.", profile.name
|
||||
).into());
|
||||
}
|
||||
|
||||
@@ -395,26 +468,80 @@ impl ProfileManager {
|
||||
self.save_profile(&profile)?;
|
||||
}
|
||||
|
||||
// Rebuild tag suggestions after group changes just in case
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
// Emit profile group assignment event
|
||||
if let Err(e) = app_handle.emit("profiles-changed", ()) {
|
||||
println!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn update_profile_tags(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_id: &str,
|
||||
tags: Vec<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
// Find the profile by ID
|
||||
let profile_uuid =
|
||||
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profiles = self.list_profiles()?;
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut deduped: Vec<String> = Vec::with_capacity(tags.len());
|
||||
for t in tags.into_iter() {
|
||||
if seen.insert(t.clone()) {
|
||||
deduped.push(t);
|
||||
}
|
||||
}
|
||||
profile.tags = deduped;
|
||||
|
||||
// Save profile
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
// Update global tag suggestions from all profiles
|
||||
let _ = crate::tag_manager::TAG_MANAGER.lock().map(|tm| {
|
||||
let _ = tm.rebuild_from_profiles(&self.list_profiles().unwrap_or_default());
|
||||
});
|
||||
|
||||
// Emit profile tags update event
|
||||
if let Err(e) = app_handle.emit("profiles-changed", ()) {
|
||||
println!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
pub fn delete_multiple_profiles(
|
||||
&self,
|
||||
profile_names: Vec<String>,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_ids: Vec<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let profiles = self.list_profiles()?;
|
||||
|
||||
for profile_name in profile_names {
|
||||
for profile_id in profile_ids {
|
||||
let profile_uuid = uuid::Uuid::parse_str(&profile_id)
|
||||
.map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
|
||||
|
||||
// Check if browser is running
|
||||
if profile.process_id.is_some() {
|
||||
return Err(
|
||||
format!(
|
||||
"Cannot delete profile '{profile_name}' while browser is running. Please stop the browser first."
|
||||
"Cannot delete profile '{}' while browser is running. Please stop the browser first.",
|
||||
profile.name
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
@@ -429,16 +556,26 @@ impl ProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit profile deletion event
|
||||
if let Err(e) = app_handle.emit("profiles-changed", ()) {
|
||||
println!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_camoufox_config(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_name: &str,
|
||||
profile_id: &str,
|
||||
config: CamoufoxConfig,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Find the profile by name
|
||||
// Find the profile by ID
|
||||
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|
||||
|_| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Invalid profile ID: {profile_id}").into()
|
||||
},
|
||||
)?;
|
||||
let profiles =
|
||||
self
|
||||
.list_profiles()
|
||||
@@ -447,13 +584,15 @@ impl ProfileManager {
|
||||
})?;
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Profile {profile_name} not found").into()
|
||||
format!("Profile with ID '{profile_id}' not found").into()
|
||||
})?;
|
||||
|
||||
// Check if the browser is currently running using the comprehensive status check
|
||||
let is_running = self.check_browser_status(app_handle, &profile).await?;
|
||||
let is_running = self
|
||||
.check_browser_status(app_handle.clone(), &profile)
|
||||
.await?;
|
||||
|
||||
if is_running {
|
||||
return Err(
|
||||
@@ -471,7 +610,15 @@ impl ProfileManager {
|
||||
format!("Failed to save profile: {e}").into()
|
||||
})?;
|
||||
|
||||
println!("Camoufox configuration updated for profile '{profile_name}'.");
|
||||
println!(
|
||||
"Camoufox configuration updated for profile '{}' (ID: {}).",
|
||||
profile.name, profile_id
|
||||
);
|
||||
|
||||
// Emit profile config update event
|
||||
if let Err(e) = app_handle.emit("profiles-changed", ()) {
|
||||
println!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -479,10 +626,15 @@ impl ProfileManager {
|
||||
pub async fn update_profile_proxy(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_name: &str,
|
||||
profile_id: &str,
|
||||
proxy_id: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Find the profile by name
|
||||
// Find the profile by ID
|
||||
let profile_uuid = uuid::Uuid::parse_str(profile_id).map_err(
|
||||
|_| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Invalid profile ID: {profile_id}").into()
|
||||
},
|
||||
)?;
|
||||
let profiles =
|
||||
self
|
||||
.list_profiles()
|
||||
@@ -492,24 +644,11 @@ impl ProfileManager {
|
||||
|
||||
let mut profile = profiles
|
||||
.into_iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.find(|p| p.id == profile_uuid)
|
||||
.ok_or_else(|| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Profile {profile_name} not found").into()
|
||||
format!("Profile with ID '{profile_id}' not found").into()
|
||||
})?;
|
||||
|
||||
// Check if browser is running to manage proxy accordingly
|
||||
let browser_is_running = profile.process_id.is_some()
|
||||
&& self
|
||||
.check_browser_status(app_handle.clone(), &profile)
|
||||
.await?;
|
||||
|
||||
// If browser is running, stop existing proxy
|
||||
if browser_is_running && profile.proxy_id.is_some() {
|
||||
if let Some(pid) = profile.process_id {
|
||||
let _ = PROXY_MANAGER.stop_proxy(app_handle.clone(), pid).await;
|
||||
}
|
||||
}
|
||||
|
||||
// Update proxy settings
|
||||
profile.proxy_id = proxy_id.clone();
|
||||
|
||||
@@ -520,68 +659,16 @@ impl ProfileManager {
|
||||
format!("Failed to save profile: {e}").into()
|
||||
})?;
|
||||
|
||||
// Handle proxy startup/configuration
|
||||
// Update on-disk browser profile config immediately
|
||||
if let Some(proxy_id_ref) = &proxy_id {
|
||||
if let Some(proxy_settings) = PROXY_MANAGER.get_proxy_settings_by_id(proxy_id_ref) {
|
||||
if browser_is_running {
|
||||
// Browser is running and proxy is enabled, start new proxy
|
||||
if let Some(pid) = profile.process_id {
|
||||
match PROXY_MANAGER
|
||||
.start_proxy(
|
||||
app_handle.clone(),
|
||||
Some(&proxy_settings),
|
||||
pid,
|
||||
Some(profile_name),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(internal_proxy_settings) => {
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
|
||||
// Apply the proxy settings with the internal proxy to the profile directory
|
||||
self
|
||||
.apply_proxy_settings_to_profile(
|
||||
&profile_path,
|
||||
&proxy_settings,
|
||||
Some(&internal_proxy_settings),
|
||||
)
|
||||
.map_err(|e| format!("Failed to update profile proxy: {e}"))?;
|
||||
|
||||
println!("Successfully started proxy for profile: {}", profile.name);
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to start proxy: {e}");
|
||||
// Apply proxy settings without internal proxy
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
self
|
||||
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to apply proxy settings: {e}").into()
|
||||
})?;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No PID available, apply proxy settings without internal proxy
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
self
|
||||
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to apply proxy settings: {e}").into()
|
||||
})?;
|
||||
}
|
||||
} else {
|
||||
// Proxy disabled or browser not running, just apply settings
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
self
|
||||
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to apply proxy settings: {e}").into()
|
||||
})?;
|
||||
}
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
|
||||
self
|
||||
.apply_proxy_settings_to_profile(&profile_path, &proxy_settings, None)
|
||||
.map_err(|e| -> Box<dyn std::error::Error + Send + Sync> {
|
||||
format!("Failed to apply proxy settings: {e}").into()
|
||||
})?;
|
||||
} else {
|
||||
// Proxy ID provided but proxy not found, disable proxy
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
@@ -608,6 +695,11 @@ impl ProfileManager {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
|
||||
// Emit general profiles changed event for profile list updates
|
||||
if let Err(e) = app_handle.emit("profiles-changed", ()) {
|
||||
println!("Warning: Failed to emit profiles-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
@@ -624,7 +716,7 @@ impl ProfileManager {
|
||||
}
|
||||
|
||||
// For non-camoufox browsers, use the existing PID-based logic
|
||||
let mut inner_profile = profile.clone();
|
||||
let inner_profile = profile.clone();
|
||||
let system = System::new_all();
|
||||
let mut is_running = false;
|
||||
let mut found_pid: Option<u32> = None;
|
||||
@@ -748,24 +840,42 @@ impl ProfileManager {
|
||||
let metadata_exists = metadata_file.exists();
|
||||
|
||||
if metadata_exists {
|
||||
// Update the process ID if we found a different one
|
||||
// Load the latest profile from disk to avoid overwriting fields like proxy_id
|
||||
let latest_profile: BrowserProfile = match std::fs::read_to_string(&metadata_file)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
{
|
||||
Some(p) => p,
|
||||
None => inner_profile.clone(),
|
||||
};
|
||||
|
||||
let previous_pid = latest_profile.process_id;
|
||||
let mut merged = latest_profile.clone();
|
||||
|
||||
if let Some(pid) = found_pid {
|
||||
if inner_profile.process_id != Some(pid) {
|
||||
inner_profile.process_id = Some(pid);
|
||||
if let Err(e) = self.save_profile(&inner_profile) {
|
||||
if merged.process_id != Some(pid) {
|
||||
merged.process_id = Some(pid);
|
||||
if let Err(e) = self.save_profile(&merged) {
|
||||
println!("Warning: Failed to update profile with new PID: {e}");
|
||||
}
|
||||
}
|
||||
} else if inner_profile.process_id.is_some() {
|
||||
} else if merged.process_id.is_some() {
|
||||
// Clear the PID if no process found
|
||||
inner_profile.process_id = None;
|
||||
if let Err(e) = self.save_profile(&inner_profile) {
|
||||
merged.process_id = None;
|
||||
if let Err(e) = self.save_profile(&merged) {
|
||||
println!("Warning: Failed to clear profile PID: {e}");
|
||||
}
|
||||
|
||||
// Stop any associated proxy immediately when the browser stops
|
||||
if let Some(old_pid) = previous_pid {
|
||||
let _ = crate::proxy_manager::PROXY_MANAGER
|
||||
.stop_proxy(app_handle.clone(), old_pid)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &inner_profile) {
|
||||
if let Err(e) = app_handle.emit("profile-updated", &merged) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
}
|
||||
@@ -779,9 +889,7 @@ impl ProfileManager {
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
use crate::camoufox::CamoufoxNodecarLauncher;
|
||||
|
||||
let launcher = CamoufoxNodecarLauncher::instance();
|
||||
let launcher = self.camoufox_manager;
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
|
||||
let profile_path_str = profile_data_path.to_string_lossy();
|
||||
@@ -790,49 +898,71 @@ impl ProfileManager {
|
||||
match launcher.find_camoufox_by_profile(&profile_path_str).await {
|
||||
Ok(Some(camoufox_process)) => {
|
||||
// Found a running instance, update profile with process info if changed
|
||||
let process_id_changed = profile.process_id != camoufox_process.processId;
|
||||
// Only write status changes if metadata still exists
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
let metadata_exists = metadata_file.exists();
|
||||
|
||||
if process_id_changed && metadata_exists {
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.process_id = camoufox_process.processId;
|
||||
if let Err(e) = self.save_profile(&updated_profile) {
|
||||
println!("Warning: Failed to update Camoufox profile with process info: {e}");
|
||||
}
|
||||
if metadata_exists {
|
||||
// Load latest to avoid overwriting other fields
|
||||
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
{
|
||||
Some(p) => p,
|
||||
None => profile.clone(),
|
||||
};
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
if latest.process_id != camoufox_process.processId {
|
||||
latest.process_id = camoufox_process.processId;
|
||||
if let Err(e) = self.save_profile(&latest) {
|
||||
println!("Warning: Failed to update Camoufox profile with process info: {e}");
|
||||
}
|
||||
|
||||
println!(
|
||||
"Camoufox process has started for profile '{}' with PID: {:?}",
|
||||
profile.name, camoufox_process.processId
|
||||
);
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &latest) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
|
||||
println!(
|
||||
"Camoufox process has started for profile '{}' with PID: {:?}",
|
||||
profile.name, camoufox_process.processId
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
Ok(None) => {
|
||||
// No running instance found, clear process ID if set
|
||||
// No running instance found, clear process ID if set and stop proxy
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
let metadata_exists = metadata_file.exists();
|
||||
|
||||
if profile.process_id.is_some() && metadata_exists {
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.process_id = None;
|
||||
if let Err(e) = self.save_profile(&updated_profile) {
|
||||
println!("Warning: Failed to clear Camoufox profile process info: {e}");
|
||||
}
|
||||
if metadata_exists {
|
||||
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
{
|
||||
Some(p) => p,
|
||||
None => profile.clone(),
|
||||
};
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
if let Some(old_pid) = latest.process_id {
|
||||
latest.process_id = None;
|
||||
if let Err(e) = self.save_profile(&latest) {
|
||||
println!("Warning: Failed to clear Camoufox profile process info: {e}");
|
||||
}
|
||||
|
||||
// Stop any proxy tied to this old PID immediately
|
||||
let _ = crate::proxy_manager::PROXY_MANAGER
|
||||
.stop_proxy(app_handle.clone(), old_pid)
|
||||
.await;
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &latest) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
@@ -845,16 +975,30 @@ impl ProfileManager {
|
||||
let metadata_file = profile_uuid_dir.join("metadata.json");
|
||||
let metadata_exists = metadata_file.exists();
|
||||
|
||||
if profile.process_id.is_some() && metadata_exists {
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.process_id = None;
|
||||
if let Err(e) = self.save_profile(&updated_profile) {
|
||||
println!("Warning: Failed to clear Camoufox profile process info after error: {e}");
|
||||
}
|
||||
if metadata_exists {
|
||||
let mut latest: BrowserProfile = match std::fs::read_to_string(&metadata_file)
|
||||
.ok()
|
||||
.and_then(|s| serde_json::from_str(&s).ok())
|
||||
{
|
||||
Some(p) => p,
|
||||
None => profile.clone(),
|
||||
};
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
if let Some(old_pid) = latest.process_id {
|
||||
latest.process_id = None;
|
||||
if let Err(e2) = self.save_profile(&latest) {
|
||||
println!("Warning: Failed to clear Camoufox profile process info after error: {e2}");
|
||||
}
|
||||
|
||||
// Best-effort stop of proxy tied to old PID
|
||||
let _ = crate::proxy_manager::PROXY_MANAGER
|
||||
.stop_proxy(app_handle.clone(), old_pid)
|
||||
.await;
|
||||
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e3) = app_handle.emit("profile-updated", &latest) {
|
||||
println!("Warning: Failed to emit profile update event: {e3}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
@@ -889,17 +1033,6 @@ impl ProfileManager {
|
||||
}
|
||||
}
|
||||
|
||||
fn get_binaries_dir(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("binaries");
|
||||
path
|
||||
}
|
||||
|
||||
fn get_common_firefox_preferences(&self) -> Vec<String> {
|
||||
vec![
|
||||
// Disable default browser updates
|
||||
@@ -1079,23 +1212,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_list_profiles_empty() {
|
||||
let (manager, _temp_dir) = create_test_profile_manager();
|
||||
|
||||
let result = manager.list_profiles();
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"Should successfully list profiles even when empty"
|
||||
);
|
||||
|
||||
let profiles = result.unwrap();
|
||||
assert!(
|
||||
profiles.is_empty(),
|
||||
"Should return empty vector when no profiles exist"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_common_firefox_preferences() {
|
||||
let (manager, _temp_dir) = create_test_profile_manager();
|
||||
@@ -1201,7 +1317,139 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[tauri::command]
|
||||
pub async fn create_browser_profile_with_group(
|
||||
app_handle: tauri::AppHandle,
|
||||
name: String,
|
||||
browser: String,
|
||||
version: String,
|
||||
release_type: String,
|
||||
proxy_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
group_id: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.create_profile_with_group(
|
||||
&app_handle,
|
||||
&name,
|
||||
&browser,
|
||||
&version,
|
||||
&release_type,
|
||||
proxy_id,
|
||||
camoufox_config,
|
||||
group_id,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create profile: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn list_browser_profiles() -> Result<Vec<BrowserProfile>, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_profile_proxy(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
proxy_id: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_profile_proxy(app_handle, &profile_id, proxy_id)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update profile: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn update_profile_tags(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
tags: Vec<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_profile_tags(&app_handle, &profile_id, tags)
|
||||
.map_err(|e| format!("Failed to update profile tags: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_browser_status(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: BrowserProfile,
|
||||
) -> Result<bool, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.check_browser_status(app_handle, &profile)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to check browser status: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn rename_profile(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
new_name: String,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.rename_profile(&app_handle, &profile_id, &new_name)
|
||||
.map_err(|e| format!("Failed to rename profile: {e}"))
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
#[tauri::command]
|
||||
pub async fn create_browser_profile_new(
|
||||
app_handle: tauri::AppHandle,
|
||||
name: String,
|
||||
browser_str: String,
|
||||
version: String,
|
||||
release_type: String,
|
||||
proxy_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
group_id: Option<String>,
|
||||
) -> Result<BrowserProfile, String> {
|
||||
let browser_type =
|
||||
BrowserType::from_str(&browser_str).map_err(|e| format!("Invalid browser type: {e}"))?;
|
||||
create_browser_profile_with_group(
|
||||
app_handle,
|
||||
name,
|
||||
browser_type.as_str().to_string(),
|
||||
version,
|
||||
release_type,
|
||||
proxy_id,
|
||||
camoufox_config,
|
||||
group_id,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_camoufox_config(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_id: String,
|
||||
config: CamoufoxConfig,
|
||||
) -> Result<(), String> {
|
||||
let profile_manager = ProfileManager::instance();
|
||||
profile_manager
|
||||
.update_camoufox_config(app_handle, &profile_id, config)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to update Camoufox config: {e}"))
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
#[tauri::command]
|
||||
pub fn delete_profile(app_handle: tauri::AppHandle, profile_id: String) -> Result<(), String> {
|
||||
ProfileManager::instance()
|
||||
.delete_profile(&app_handle, &profile_id)
|
||||
.map_err(|e| format!("Failed to delete profile: {e}"))
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
static ref PROFILE_MANAGER: ProfileManager = ProfileManager::new();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use crate::camoufox::CamoufoxConfig;
|
||||
use crate::camoufox_manager::CamoufoxConfig;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
@@ -20,6 +20,8 @@ pub struct BrowserProfile {
|
||||
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
|
||||
#[serde(default)]
|
||||
pub group_id: Option<String>, // Reference to profile group
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>, // Free-form tags
|
||||
}
|
||||
|
||||
pub fn default_release_type() -> String {
|
||||
|
||||
@@ -5,7 +5,8 @@ use std::fs::{self, create_dir_all};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::browser::BrowserType;
|
||||
use crate::browser_runner::BrowserRunner;
|
||||
use crate::downloaded_browsers_registry::DownloadedBrowsersRegistry;
|
||||
use crate::profile::ProfileManager;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct DetectedProfile {
|
||||
@@ -17,12 +18,16 @@ pub struct DetectedProfile {
|
||||
|
||||
pub struct ProfileImporter {
|
||||
base_dirs: BaseDirs,
|
||||
downloaded_browsers_registry: &'static DownloadedBrowsersRegistry,
|
||||
profile_manager: &'static ProfileManager,
|
||||
}
|
||||
|
||||
impl ProfileImporter {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
downloaded_browsers_registry: DownloadedBrowsersRegistry::instance(),
|
||||
profile_manager: ProfileManager::instance(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,7 +525,7 @@ impl ProfileImporter {
|
||||
.map_err(|_| format!("Invalid browser type: {browser_type}"))?;
|
||||
|
||||
// Check if a profile with this name already exists
|
||||
let existing_profiles = BrowserRunner::instance().list_profiles()?;
|
||||
let existing_profiles = self.profile_manager.list_profiles()?;
|
||||
if existing_profiles
|
||||
.iter()
|
||||
.any(|p| p.name.to_lowercase() == new_profile_name.to_lowercase())
|
||||
@@ -530,7 +535,7 @@ impl ProfileImporter {
|
||||
|
||||
// Generate UUID for new profile and create the directory structure
|
||||
let profile_id = uuid::Uuid::new_v4();
|
||||
let profiles_dir = BrowserRunner::instance().get_profiles_dir();
|
||||
let profiles_dir = self.profile_manager.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");
|
||||
|
||||
@@ -555,10 +560,11 @@ impl ProfileImporter {
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None,
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
BrowserRunner::instance().save_profile(&profile)?;
|
||||
self.profile_manager.save_profile(&profile)?;
|
||||
|
||||
println!(
|
||||
"Successfully imported profile '{}' from '{}'",
|
||||
@@ -575,8 +581,9 @@ impl ProfileImporter {
|
||||
browser_type: &str,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Check if any version of the browser is downloaded
|
||||
let registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::instance();
|
||||
let downloaded_versions = registry.get_downloaded_versions(browser_type);
|
||||
let downloaded_versions = self
|
||||
.downloaded_browsers_registry
|
||||
.get_downloaded_versions(browser_type);
|
||||
|
||||
if let Some(version) = downloaded_versions.first() {
|
||||
return Ok(version.clone());
|
||||
|
||||
@@ -5,6 +5,7 @@ use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use tauri::Emitter;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
use crate::browser::ProxySettings;
|
||||
@@ -146,6 +147,7 @@ impl ProxyManager {
|
||||
// Create a new stored proxy
|
||||
pub fn create_stored_proxy(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
name: String,
|
||||
proxy_settings: ProxySettings,
|
||||
) -> Result<StoredProxy, String> {
|
||||
@@ -168,13 +170,21 @@ impl ProxyManager {
|
||||
eprintln!("Warning: Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("proxies-changed", ()) {
|
||||
eprintln!("Failed to emit proxies-changed event: {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()
|
||||
let mut list: Vec<StoredProxy> = stored_proxies.values().cloned().collect();
|
||||
// Sort case-insensitively by name for consistent ordering across UI/API consumers
|
||||
list.sort_by_key(|p| p.name.to_lowercase());
|
||||
list
|
||||
}
|
||||
|
||||
// Get a stored proxy by ID
|
||||
@@ -182,6 +192,7 @@ impl ProxyManager {
|
||||
// Update a stored proxy
|
||||
pub fn update_stored_proxy(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
proxy_id: &str,
|
||||
name: Option<String>,
|
||||
proxy_settings: Option<ProxySettings>,
|
||||
@@ -226,11 +237,20 @@ impl ProxyManager {
|
||||
eprintln!("Warning: Failed to save proxy: {e}");
|
||||
}
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("proxies-changed", ()) {
|
||||
eprintln!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(updated_proxy)
|
||||
}
|
||||
|
||||
// Delete a stored proxy
|
||||
pub fn delete_stored_proxy(&self, proxy_id: &str) -> Result<(), String> {
|
||||
pub fn delete_stored_proxy(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
proxy_id: &str,
|
||||
) -> Result<(), String> {
|
||||
{
|
||||
let mut stored_proxies = self.stored_proxies.lock().unwrap();
|
||||
if stored_proxies.remove(proxy_id).is_none() {
|
||||
@@ -242,6 +262,11 @@ impl ProxyManager {
|
||||
eprintln!("Warning: Failed to delete proxy file: {e}");
|
||||
}
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("proxies-changed", ()) {
|
||||
eprintln!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -350,29 +375,6 @@ impl ProxyManager {
|
||||
let _ = self.stop_proxy(app_handle.clone(), browser_pid).await;
|
||||
}
|
||||
|
||||
// Check if we have a preferred port for this profile
|
||||
let preferred_port = if let Some(name) = profile_name {
|
||||
let profile_proxies = self.profile_proxies.lock().unwrap();
|
||||
profile_proxies.get(name).and_then(|_settings| {
|
||||
// Find existing proxy with same settings to reuse port
|
||||
let active_proxies = self.active_proxies.lock().unwrap();
|
||||
active_proxies
|
||||
.values()
|
||||
.find(|p| {
|
||||
if let Some(proxy_settings) = proxy_settings {
|
||||
p.upstream_host == proxy_settings.host
|
||||
&& p.upstream_port == proxy_settings.port
|
||||
&& p.upstream_type == proxy_settings.proxy_type
|
||||
} else {
|
||||
p.upstream_type == "DIRECT"
|
||||
}
|
||||
})
|
||||
.map(|p| p.local_port)
|
||||
})
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
// Start a new proxy using the nodecar binary with the correct CLI interface
|
||||
let mut nodecar = app_handle
|
||||
.shell()
|
||||
@@ -400,11 +402,6 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
// If we have a preferred port, use it
|
||||
if let Some(port) = preferred_port {
|
||||
nodecar = nodecar.arg("--port").arg(port.to_string());
|
||||
}
|
||||
|
||||
// Execute the command and wait for it to complete
|
||||
// The nodecar binary should start the worker and then exit
|
||||
let output = nodecar
|
||||
@@ -449,6 +446,30 @@ impl ProxyManager {
|
||||
profile_name: profile_name.map(|s| s.to_string()),
|
||||
};
|
||||
|
||||
// Wait for the local proxy port to be ready to accept connections
|
||||
{
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::time::{sleep, Duration};
|
||||
let mut ready = false;
|
||||
for _ in 0..50 {
|
||||
match TcpStream::connect((std::net::Ipv4Addr::LOCALHOST, proxy_info.local_port)).await {
|
||||
Ok(_stream) => {
|
||||
ready = true;
|
||||
break;
|
||||
}
|
||||
Err(_) => {
|
||||
sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
if !ready {
|
||||
return Err(format!(
|
||||
"Local proxy on 127.0.0.1:{} did not become ready in time",
|
||||
proxy_info.local_port
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Store the proxy info
|
||||
{
|
||||
let mut proxies = self.active_proxies.lock().unwrap();
|
||||
@@ -518,6 +539,11 @@ impl ProxyManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("proxies-changed", ()) {
|
||||
eprintln!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -558,6 +584,11 @@ impl ProxyManager {
|
||||
let _ = self.stop_proxy(app_handle.clone(), *dead_pid).await;
|
||||
}
|
||||
|
||||
// Emit event for reactive UI updates
|
||||
if let Err(e) = app_handle.emit("proxies-changed", ()) {
|
||||
eprintln!("Failed to emit proxies-changed event: {e}");
|
||||
}
|
||||
|
||||
Ok(dead_pids)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,8 +3,11 @@ use serde::{Deserialize, Serialize};
|
||||
use std::fs::{self, create_dir_all};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::api_client::ApiClient;
|
||||
use crate::version_updater;
|
||||
use aes_gcm::{
|
||||
aead::{Aead, AeadCore, KeyInit, OsRng},
|
||||
Aes256Gcm, Key, Nonce,
|
||||
};
|
||||
use argon2::{password_hash::SaltString, Argon2, PasswordHasher};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct TableSortingSettings {
|
||||
@@ -27,17 +30,33 @@ pub struct AppSettings {
|
||||
pub set_as_default_browser: bool,
|
||||
#[serde(default = "default_theme")]
|
||||
pub theme: String, // "light", "dark", or "system"
|
||||
#[serde(default)]
|
||||
pub custom_theme: Option<std::collections::HashMap<String, String>>, // CSS var name -> value (e.g., "--background": "#1a1b26")
|
||||
#[serde(default)]
|
||||
pub api_enabled: bool,
|
||||
#[serde(default = "default_api_port")]
|
||||
pub api_port: u16,
|
||||
#[serde(default)]
|
||||
pub api_token: Option<String>, // Displayed token for user to copy
|
||||
}
|
||||
|
||||
fn default_theme() -> String {
|
||||
"system".to_string()
|
||||
}
|
||||
|
||||
fn default_api_port() -> u16 {
|
||||
10108
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
set_as_default_browser: false,
|
||||
theme: "system".to_string(),
|
||||
custom_theme: None,
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
api_token: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -151,22 +170,249 @@ impl SettingsManager {
|
||||
// Always return false - we don't show settings on startup anymore
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
fn get_vault_password() -> String {
|
||||
env!("DONUT_BROWSER_VAULT_PASSWORD").to_string()
|
||||
}
|
||||
|
||||
pub async fn generate_api_token(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<String, Box<dyn std::error::Error>> {
|
||||
// Generate a secure random token (base64 encoded for URL safety)
|
||||
let token_bytes: [u8; 32] = {
|
||||
use rand::RngCore;
|
||||
let mut rng = rand::rng();
|
||||
let mut bytes = [0u8; 32];
|
||||
rng.fill_bytes(&mut bytes);
|
||||
bytes
|
||||
};
|
||||
use base64::{engine::general_purpose, Engine as _};
|
||||
let token = general_purpose::URL_SAFE_NO_PAD.encode(token_bytes);
|
||||
|
||||
// Store token securely
|
||||
self.store_api_token(app_handle, &token).await?;
|
||||
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
pub async fn store_api_token(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
token: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Store token in an encrypted file using Argon2 + AES-GCM
|
||||
let token_file = self.get_settings_dir().join("api_token.dat");
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if let Some(parent) = token_file.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let vault_password = Self::get_vault_password();
|
||||
|
||||
// Generate a random salt for Argon2
|
||||
let salt = SaltString::generate(&mut OsRng);
|
||||
|
||||
// Use Argon2 to derive a 32-byte key from the vault password
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(vault_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
|
||||
// Take first 32 bytes for AES-256 key
|
||||
let key = Key::<Aes256Gcm>::from_slice(&hash_bytes[..32]);
|
||||
let cipher = Aes256Gcm::new(key);
|
||||
|
||||
// Generate a random nonce
|
||||
let nonce = Aes256Gcm::generate_nonce(&mut OsRng);
|
||||
|
||||
// Encrypt the token
|
||||
let ciphertext = cipher
|
||||
.encrypt(&nonce, token.as_bytes())
|
||||
.map_err(|e| format!("Encryption failed: {e}"))?;
|
||||
|
||||
// Create file data with header, salt, nonce, and encrypted data
|
||||
let mut file_data = Vec::new();
|
||||
file_data.extend_from_slice(b"DBAPI"); // 5-byte header
|
||||
file_data.push(2u8); // Version 2 (Argon2 + AES-GCM)
|
||||
|
||||
// Store salt length and salt
|
||||
let salt_str = salt.as_str();
|
||||
file_data.push(salt_str.len() as u8);
|
||||
file_data.extend_from_slice(salt_str.as_bytes());
|
||||
|
||||
// Store nonce (12 bytes for AES-GCM)
|
||||
file_data.extend_from_slice(&nonce);
|
||||
|
||||
// Store ciphertext length and ciphertext
|
||||
file_data.extend_from_slice(&(ciphertext.len() as u32).to_le_bytes());
|
||||
file_data.extend_from_slice(&ciphertext);
|
||||
|
||||
std::fs::write(token_file, file_data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn get_api_token(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
) -> Result<Option<String>, Box<dyn std::error::Error>> {
|
||||
let token_file = self.get_settings_dir().join("api_token.dat");
|
||||
|
||||
if !token_file.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let file_data = std::fs::read(token_file)?;
|
||||
|
||||
// Validate header
|
||||
if file_data.len() < 6 || &file_data[0..5] != b"DBAPI" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let version = file_data[5];
|
||||
|
||||
// Only support Argon2 + AES-GCM (version 2)
|
||||
if version != 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Argon2 + AES-GCM decryption
|
||||
let mut offset = 6;
|
||||
|
||||
// Read salt
|
||||
if offset >= file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_len = file_data[offset] as usize;
|
||||
offset += 1;
|
||||
|
||||
if offset + salt_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let salt_bytes = &file_data[offset..offset + salt_len];
|
||||
let salt_str = std::str::from_utf8(salt_bytes).map_err(|_| "Invalid salt encoding")?;
|
||||
let salt = SaltString::from_b64(salt_str).map_err(|_| "Invalid salt format")?;
|
||||
offset += salt_len;
|
||||
|
||||
// Read nonce (12 bytes)
|
||||
if offset + 12 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let nonce_bytes = &file_data[offset..offset + 12];
|
||||
let nonce = Nonce::from_slice(nonce_bytes);
|
||||
offset += 12;
|
||||
|
||||
// Read ciphertext
|
||||
if offset + 4 > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext_len = u32::from_le_bytes([
|
||||
file_data[offset],
|
||||
file_data[offset + 1],
|
||||
file_data[offset + 2],
|
||||
file_data[offset + 3],
|
||||
]) as usize;
|
||||
offset += 4;
|
||||
|
||||
if offset + ciphertext_len > file_data.len() {
|
||||
return Ok(None);
|
||||
}
|
||||
let ciphertext = &file_data[offset..offset + ciphertext_len];
|
||||
|
||||
// Derive key using Argon2
|
||||
let vault_password = Self::get_vault_password();
|
||||
let argon2 = Argon2::default();
|
||||
let password_hash = argon2
|
||||
.hash_password(vault_password.as_bytes(), &salt)
|
||||
.map_err(|e| format!("Argon2 key derivation failed: {e}"))?;
|
||||
let hash_value = password_hash.hash.unwrap();
|
||||
let hash_bytes = hash_value.as_bytes();
|
||||
|
||||
let key = Key::<Aes256Gcm>::from_slice(&hash_bytes[..32]);
|
||||
let cipher = Aes256Gcm::new(key);
|
||||
|
||||
// Decrypt the token
|
||||
let plaintext = cipher
|
||||
.decrypt(nonce, ciphertext)
|
||||
.map_err(|_| "Decryption failed")?;
|
||||
|
||||
match String::from_utf8(plaintext) {
|
||||
Ok(token) => Ok(Some(token)),
|
||||
Err(_) => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn remove_api_token(
|
||||
&self,
|
||||
_app_handle: &tauri::AppHandle,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let token_file = self.get_settings_dir().join("api_token.dat");
|
||||
|
||||
if token_file.exists() {
|
||||
std::fs::remove_file(token_file)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_app_settings() -> Result<AppSettings, String> {
|
||||
pub async fn get_app_settings(app_handle: tauri::AppHandle) -> Result<AppSettings, String> {
|
||||
let manager = SettingsManager::instance();
|
||||
manager
|
||||
let mut settings = manager
|
||||
.load_settings()
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))
|
||||
.map_err(|e| format!("Failed to load settings: {e}"))?;
|
||||
|
||||
// Always load token for display purposes if it exists
|
||||
settings.api_token = manager
|
||||
.get_api_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to load API token: {e}"))?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn save_app_settings(settings: AppSettings) -> Result<(), String> {
|
||||
pub async fn save_app_settings(
|
||||
app_handle: tauri::AppHandle,
|
||||
mut settings: AppSettings,
|
||||
) -> Result<AppSettings, String> {
|
||||
let manager = SettingsManager::instance();
|
||||
|
||||
if settings.api_enabled {
|
||||
if let Some(ref token) = settings.api_token {
|
||||
manager
|
||||
.store_api_token(&app_handle, token)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to store API token: {e}"))?;
|
||||
} else {
|
||||
let token = manager
|
||||
.generate_api_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to generate API token: {e}"))?;
|
||||
settings.api_token = Some(token);
|
||||
}
|
||||
}
|
||||
|
||||
// If API is being disabled, remove the token
|
||||
if !settings.api_enabled {
|
||||
manager
|
||||
.remove_api_token(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove API token: {e}"))?;
|
||||
settings.api_token = None;
|
||||
}
|
||||
|
||||
let mut persist_settings = settings.clone();
|
||||
persist_settings.api_token = None;
|
||||
manager
|
||||
.save_settings(&settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))
|
||||
.save_settings(&persist_settings)
|
||||
.map_err(|e| format!("Failed to save settings: {e}"))?;
|
||||
|
||||
Ok(settings)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -193,54 +439,6 @@ pub async fn save_table_sorting_settings(sorting: TableSortingSettings) -> Resul
|
||||
.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}"))?;
|
||||
|
||||
// Disable all browsers during the update process
|
||||
let auto_updater = crate::auto_updater::AutoUpdater::instance();
|
||||
let supported_browsers =
|
||||
crate::browser_version_manager::BrowserVersionManager::instance().get_supported_browsers();
|
||||
|
||||
// Load current state and disable all browsers
|
||||
let mut state = auto_updater
|
||||
.load_auto_update_state()
|
||||
.map_err(|e| format!("Failed to load auto update state: {e}"))?;
|
||||
for browser in &supported_browsers {
|
||||
state.disabled_browsers.insert(browser.clone());
|
||||
}
|
||||
auto_updater
|
||||
.save_auto_update_state(&state)
|
||||
.map_err(|e| format!("Failed to save auto update state: {e}"))?;
|
||||
|
||||
let updater = version_updater::get_version_updater();
|
||||
let updater_guard = updater.lock().await;
|
||||
|
||||
let result = updater_guard
|
||||
.trigger_manual_update(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to trigger version update: {e}"));
|
||||
|
||||
// Re-enable all browsers after the update completes (regardless of success/failure)
|
||||
let mut final_state = auto_updater.load_auto_update_state().unwrap_or_default();
|
||||
for browser in &supported_browsers {
|
||||
final_state.disabled_browsers.remove(browser);
|
||||
}
|
||||
if let Err(e) = auto_updater.save_auto_update_state(&final_state) {
|
||||
eprintln!("Warning: Failed to re-enable browsers after cache clear: {e}");
|
||||
}
|
||||
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Global singleton instance
|
||||
lazy_static::lazy_static! {
|
||||
static ref SETTINGS_MANAGER: SettingsManager = SettingsManager::new();
|
||||
@@ -321,6 +519,10 @@ mod tests {
|
||||
let test_settings = AppSettings {
|
||||
set_as_default_browser: true,
|
||||
theme: "dark".to_string(),
|
||||
custom_theme: None,
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
api_token: None,
|
||||
};
|
||||
|
||||
// Save settings
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
use crate::profile::BrowserProfile;
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::BTreeSet;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default, Clone)]
|
||||
struct TagsData {
|
||||
tags: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct TagManager {
|
||||
base_dirs: BaseDirs,
|
||||
data_dir_override: Option<PathBuf>,
|
||||
}
|
||||
|
||||
impl TagManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: std::env::var("DONUTBROWSER_DATA_DIR")
|
||||
.ok()
|
||||
.map(PathBuf::from),
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for tests to override data directory without global env var
|
||||
#[allow(dead_code)]
|
||||
pub fn with_data_dir_override(dir: &Path) -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
data_dir_override: Some(dir.to_path_buf()),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_tags_file_path(&self) -> PathBuf {
|
||||
if let Some(dir) = &self.data_dir_override {
|
||||
let mut override_path = dir.clone();
|
||||
let _ = fs::create_dir_all(&override_path);
|
||||
override_path.push("tags.json");
|
||||
return override_path;
|
||||
}
|
||||
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("data");
|
||||
path.push("tags.json");
|
||||
path
|
||||
}
|
||||
|
||||
fn load_tags_data(&self) -> Result<TagsData, Box<dyn std::error::Error>> {
|
||||
let file_path = self.get_tags_file_path();
|
||||
if !file_path.exists() {
|
||||
return Ok(TagsData::default());
|
||||
}
|
||||
let content = fs::read_to_string(file_path)?;
|
||||
let data: TagsData = serde_json::from_str(&content)?;
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
fn save_tags_data(&self, data: &TagsData) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let file_path = self.get_tags_file_path();
|
||||
if let Some(parent) = file_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(data)?;
|
||||
fs::write(file_path, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_tags(&self) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
let mut all = self.load_tags_data()?.tags;
|
||||
// Ensure deterministic order
|
||||
all.sort();
|
||||
all.dedup();
|
||||
Ok(all)
|
||||
}
|
||||
|
||||
pub fn rebuild_from_profiles(
|
||||
&self,
|
||||
profiles: &[BrowserProfile],
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error>> {
|
||||
// Build a set of all tags currently used by any profile
|
||||
let mut set: BTreeSet<String> = BTreeSet::new();
|
||||
for profile in profiles {
|
||||
for tag in &profile.tags {
|
||||
// Store exactly as provided (no normalization) to preserve characters
|
||||
set.insert(tag.clone());
|
||||
}
|
||||
}
|
||||
let combined: Vec<String> = set.into_iter().collect();
|
||||
self.save_tags_data(&TagsData {
|
||||
tags: combined.clone(),
|
||||
})?;
|
||||
Ok(combined)
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_all_tags() -> Result<Vec<String>, String> {
|
||||
let tag_manager = crate::tag_manager::TAG_MANAGER.lock().unwrap();
|
||||
tag_manager
|
||||
.get_all_tags()
|
||||
.map_err(|e| format!("Failed to get tags: {e}"))
|
||||
}
|
||||
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref TAG_MANAGER: std::sync::Mutex<TagManager> = std::sync::Mutex::new(TagManager::new());
|
||||
}
|
||||
@@ -46,8 +46,9 @@ impl Default for BackgroundUpdateState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Extension of auto_updater.rs for background updates
|
||||
pub struct VersionUpdater {
|
||||
version_service: &'static BrowserVersionManager,
|
||||
browser_version_manager: &'static BrowserVersionManager,
|
||||
auto_updater: &'static AutoUpdater,
|
||||
app_handle: Option<tauri::AppHandle>,
|
||||
}
|
||||
@@ -55,7 +56,7 @@ pub struct VersionUpdater {
|
||||
impl VersionUpdater {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
version_service: BrowserVersionManager::instance(),
|
||||
browser_version_manager: BrowserVersionManager::instance(),
|
||||
auto_updater: AutoUpdater::instance(),
|
||||
app_handle: None,
|
||||
}
|
||||
@@ -263,7 +264,7 @@ impl VersionUpdater {
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
) -> Result<Vec<BackgroundUpdateResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let supported_browsers = self.version_service.get_supported_browsers();
|
||||
let supported_browsers = self.browser_version_manager.get_supported_browsers();
|
||||
let total_browsers = supported_browsers.len();
|
||||
let mut results = Vec::new();
|
||||
let mut total_new_versions = 0;
|
||||
@@ -374,7 +375,7 @@ impl VersionUpdater {
|
||||
browser: &str,
|
||||
) -> Result<usize, Box<dyn std::error::Error + Send + Sync>> {
|
||||
self
|
||||
.version_service
|
||||
.browser_version_manager
|
||||
.update_browser_versions_incrementally(browser)
|
||||
.await
|
||||
}
|
||||
@@ -455,6 +456,63 @@ pub async fn get_version_update_status() -> Result<(Option<u64>, u64), String> {
|
||||
Ok((last_update, time_until_next))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn clear_all_version_cache_and_refetch(
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
let api_client = crate::api_client::ApiClient::instance();
|
||||
let version_updater = VersionUpdater::new();
|
||||
|
||||
// Clear all cache first
|
||||
api_client
|
||||
.clear_all_cache()
|
||||
.map_err(|e| format!("Failed to clear version cache: {e}"))?;
|
||||
|
||||
// Disable all browsers during the update process
|
||||
let supported_browsers = version_updater
|
||||
.browser_version_manager
|
||||
.get_supported_browsers();
|
||||
|
||||
// Load current state and disable all browsers
|
||||
let mut state = version_updater
|
||||
.auto_updater
|
||||
.load_auto_update_state()
|
||||
.map_err(|e| format!("Failed to load auto update state: {e}"))?;
|
||||
for browser in &supported_browsers {
|
||||
state.disabled_browsers.insert(browser.clone());
|
||||
}
|
||||
version_updater
|
||||
.auto_updater
|
||||
.save_auto_update_state(&state)
|
||||
.map_err(|e| format!("Failed to save auto update state: {e}"))?;
|
||||
|
||||
let updater = get_version_updater();
|
||||
let updater_guard = updater.lock().await;
|
||||
|
||||
let result = updater_guard
|
||||
.trigger_manual_update(&app_handle)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to trigger version update: {e}"));
|
||||
|
||||
// Re-enable all browsers after the update completes (regardless of success/failure)
|
||||
let mut final_state = version_updater
|
||||
.auto_updater
|
||||
.load_auto_update_state()
|
||||
.unwrap_or_default();
|
||||
for browser in &supported_browsers {
|
||||
final_state.disabled_browsers.remove(browser);
|
||||
}
|
||||
if let Err(e) = version_updater
|
||||
.auto_updater
|
||||
.save_auto_update_state(&final_state)
|
||||
{
|
||||
eprintln!("Warning: Failed to re-enable browsers after cache clear: {e}");
|
||||
}
|
||||
|
||||
result?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -607,7 +665,10 @@ mod tests {
|
||||
|
||||
// Should have valid references to services
|
||||
assert!(
|
||||
!std::ptr::eq(updater.version_service as *const _, std::ptr::null()),
|
||||
!std::ptr::eq(
|
||||
updater.browser_version_manager as *const _,
|
||||
std::ptr::null()
|
||||
),
|
||||
"Version service should not be null"
|
||||
);
|
||||
assert!(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Donut Browser",
|
||||
"version": "0.9.2",
|
||||
"version": "0.12.2",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
|
||||
+1
-1
@@ -24,7 +24,7 @@ export default function RootLayout({
|
||||
return (
|
||||
<html lang="en" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden`}
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased overflow-hidden bg-background`}
|
||||
>
|
||||
<CustomThemeProvider>
|
||||
<WindowDragArea />
|
||||
|
||||
+202
-267
@@ -3,7 +3,7 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
@@ -18,11 +18,15 @@ import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
|
||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import { useGroupEvents } from "@/hooks/use-group-events";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { showErrorToast, showToast } from "@/lib/toast-utils";
|
||||
import type { BrowserProfile, CamoufoxConfig, GroupWithCount } from "@/types";
|
||||
import type { BrowserProfile, CamoufoxConfig } from "@/types";
|
||||
|
||||
type BrowserTypeString =
|
||||
| "mullvad-browser"
|
||||
@@ -40,9 +44,26 @@ interface PendingUrl {
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
// Mount global version update listener/toasts
|
||||
useVersionUpdater();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Use the new profile events hook for centralized profile management
|
||||
const {
|
||||
profiles,
|
||||
runningProfiles,
|
||||
isLoading: profilesLoading,
|
||||
error: profilesError,
|
||||
} = useProfileEvents();
|
||||
|
||||
const {
|
||||
groups: groupsData,
|
||||
isLoading: groupsLoading,
|
||||
error: groupsError,
|
||||
} = useGroupEvents();
|
||||
|
||||
const { isLoading: proxiesLoading, error: proxiesError } = useProxyEvents();
|
||||
|
||||
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
|
||||
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
|
||||
const [importProfileDialogOpen, setImportProfileDialogOpen] = useState(false);
|
||||
@@ -59,13 +80,12 @@ export default function Home() {
|
||||
string[]
|
||||
>([]);
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
|
||||
const [currentProfileForCamoufoxConfig, setCurrentProfileForCamoufoxConfig] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
||||
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
|
||||
const [groups, setGroups] = useState<GroupWithCount[]>([]);
|
||||
const [areGroupsLoading, setGroupsLoading] = useState(true);
|
||||
const [currentPermissionType, setCurrentPermissionType] =
|
||||
useState<PermissionType>("microphone");
|
||||
const [showBulkDeleteConfirmation, setShowBulkDeleteConfirmation] =
|
||||
@@ -142,11 +162,6 @@ export default function Home() {
|
||||
"Failed to download missing components:",
|
||||
downloadError,
|
||||
);
|
||||
setError(
|
||||
`Failed to download missing components: ${JSON.stringify(
|
||||
downloadError,
|
||||
)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
@@ -154,22 +169,6 @@ export default function Home() {
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Simple profiles loader without updates check (for use as callback)
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try {
|
||||
const profileList = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
setProfiles(profileList);
|
||||
|
||||
// Check for missing binaries after loading profiles
|
||||
await checkMissingBinaries();
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load profiles:", err);
|
||||
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, [checkMissingBinaries]);
|
||||
|
||||
const [processingUrls, setProcessingUrls] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleUrlOpen = useCallback(
|
||||
@@ -203,26 +202,9 @@ export default function Home() {
|
||||
);
|
||||
|
||||
// Auto-update functionality - use the existing hook for compatibility
|
||||
const updateNotifications = useUpdateNotifications(loadProfiles);
|
||||
const updateNotifications = useUpdateNotifications();
|
||||
const { checkForUpdates, isUpdating } = updateNotifications;
|
||||
|
||||
// Profiles loader with update check (for initial load and manual refresh)
|
||||
const loadProfilesWithUpdateCheck = useCallback(async () => {
|
||||
try {
|
||||
const profileList = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
setProfiles(profileList);
|
||||
|
||||
// Check for updates after loading profiles
|
||||
await checkForUpdates();
|
||||
await checkMissingBinaries();
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load profiles:", err);
|
||||
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, [checkForUpdates, checkMissingBinaries]);
|
||||
|
||||
useAppUpdateNotifications();
|
||||
|
||||
// Check for startup URLs but only process them once
|
||||
@@ -269,9 +251,8 @@ export default function Home() {
|
||||
await invoke("warm_up_nodecar");
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setError(
|
||||
`Initialization failed: ${err instanceof Error ? err.message : String(err)}`,
|
||||
);
|
||||
// Don't set error here since useProfileEvents handles profile errors
|
||||
console.error("Initialization failed:", err);
|
||||
}
|
||||
} finally {
|
||||
if (!cancelled) setIsInitializing(false);
|
||||
@@ -283,6 +264,27 @@ export default function Home() {
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle profile errors from useProfileEvents hook
|
||||
useEffect(() => {
|
||||
if (profilesError) {
|
||||
showErrorToast(profilesError);
|
||||
}
|
||||
}, [profilesError]);
|
||||
|
||||
// Handle group errors from useGroupEvents hook
|
||||
useEffect(() => {
|
||||
if (groupsError) {
|
||||
showErrorToast(groupsError);
|
||||
}
|
||||
}, [groupsError]);
|
||||
|
||||
// Handle proxy errors from useProxyEvents hook
|
||||
useEffect(() => {
|
||||
if (proxiesError) {
|
||||
showErrorToast(proxiesError);
|
||||
}
|
||||
}, [proxiesError]);
|
||||
|
||||
const checkAllPermissions = useCallback(async () => {
|
||||
try {
|
||||
// Wait for permissions to be initialized before checking
|
||||
@@ -339,7 +341,7 @@ export default function Home() {
|
||||
"Received show create profile dialog request:",
|
||||
event.payload,
|
||||
);
|
||||
setError(
|
||||
showErrorToast(
|
||||
"No profiles available. Please create a profile first before opening URLs.",
|
||||
);
|
||||
setCreateProfileDialogOpen(true);
|
||||
@@ -375,38 +377,24 @@ export default function Home() {
|
||||
|
||||
const handleSaveCamoufoxConfig = useCallback(
|
||||
async (profile: BrowserProfile, config: CamoufoxConfig) => {
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("update_camoufox_config", {
|
||||
profileName: profile.name,
|
||||
profileId: profile.id,
|
||||
config,
|
||||
});
|
||||
await loadProfiles();
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setCamoufoxConfigDialogOpen(false);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to update camoufox config:", err);
|
||||
setError(`Failed to update camoufox config: ${JSON.stringify(err)}`);
|
||||
showErrorToast(
|
||||
`Failed to update camoufox config: ${JSON.stringify(err)}`,
|
||||
);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[loadProfiles],
|
||||
[],
|
||||
);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
setGroupsLoading(true);
|
||||
try {
|
||||
const groupsWithCounts = await invoke<GroupWithCount[]>(
|
||||
"get_groups_with_profile_counts",
|
||||
);
|
||||
setGroups(groupsWithCounts);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups with counts:", err);
|
||||
setGroups([]);
|
||||
} finally {
|
||||
setGroupsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleCreateProfile = useCallback(
|
||||
async (profileData: {
|
||||
name: string;
|
||||
@@ -417,29 +405,22 @@ export default function Home() {
|
||||
camoufoxConfig?: CamoufoxConfig;
|
||||
groupId?: string;
|
||||
}) => {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const _profile = await invoke<BrowserProfile>(
|
||||
"create_browser_profile_new",
|
||||
{
|
||||
name: profileData.name,
|
||||
browserStr: profileData.browserStr,
|
||||
version: profileData.version,
|
||||
releaseType: profileData.releaseType,
|
||||
proxyId: profileData.proxyId,
|
||||
camoufoxConfig: profileData.camoufoxConfig,
|
||||
groupId:
|
||||
profileData.groupId ||
|
||||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
|
||||
},
|
||||
);
|
||||
await invoke<BrowserProfile>("create_browser_profile_new", {
|
||||
name: profileData.name,
|
||||
browserStr: profileData.browserStr,
|
||||
version: profileData.version,
|
||||
releaseType: profileData.releaseType,
|
||||
proxyId: profileData.proxyId,
|
||||
camoufoxConfig: profileData.camoufoxConfig,
|
||||
groupId:
|
||||
profileData.groupId ||
|
||||
(selectedGroupId !== "default" ? selectedGroupId : undefined),
|
||||
});
|
||||
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
// Trigger proxy data reload in the table
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (error) {
|
||||
setError(
|
||||
showErrorToast(
|
||||
`Failed to create profile: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
@@ -447,166 +428,102 @@ export default function Home() {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[loadProfiles, loadGroups, selectedGroupId],
|
||||
[selectedGroupId],
|
||||
);
|
||||
|
||||
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const launchProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
console.log("Starting launch for profile:", profile.name);
|
||||
|
||||
const runningProfilesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const checkBrowserStatus = useCallback(async (profile: BrowserProfile) => {
|
||||
try {
|
||||
const result = await invoke<BrowserProfile>("launch_browser_profile", {
|
||||
profile,
|
||||
});
|
||||
console.log("Successfully launched profile:", result.name);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to launch browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to launch browser: ${errorMessage}`);
|
||||
// Re-throw the error so the table component can handle loading state cleanup
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeleteProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
console.log("Attempting to delete profile:", profile.name);
|
||||
|
||||
try {
|
||||
// First check if the browser is running for this profile
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
|
||||
const currentRunning = runningProfilesRef.current.has(profile.name);
|
||||
|
||||
if (isRunning !== currentRunning) {
|
||||
console.log(
|
||||
`Profile ${profile.name} (${profile.browser}) status changed: ${currentRunning} -> ${isRunning}`,
|
||||
if (isRunning) {
|
||||
showErrorToast(
|
||||
"Cannot delete profile while browser is running. Please stop the browser first.",
|
||||
);
|
||||
setRunningProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isRunning) {
|
||||
next.add(profile.name);
|
||||
} else {
|
||||
next.delete(profile.name);
|
||||
}
|
||||
runningProfilesRef.current = next;
|
||||
return next;
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check browser status:", err);
|
||||
|
||||
// Attempt to delete the profile
|
||||
await invoke("delete_profile", { profileId: profile.id });
|
||||
console.log("Profile deletion command completed successfully");
|
||||
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
console.log("Profile deleted successfully");
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete profile:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to delete profile: ${errorMessage}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const launchProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
setError(null);
|
||||
|
||||
// Check if browser is disabled due to ongoing update
|
||||
try {
|
||||
const isDisabled = await invoke<boolean>(
|
||||
"is_browser_disabled_for_update",
|
||||
{
|
||||
browser: profile.browser,
|
||||
},
|
||||
);
|
||||
|
||||
if (isDisabled || isUpdating(profile.browser)) {
|
||||
setError(
|
||||
`${profile.browser} is currently being updated. Please wait for the update to complete.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to check browser update status:", err);
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedProfile = await invoke<BrowserProfile>(
|
||||
"launch_browser_profile",
|
||||
{ profile },
|
||||
);
|
||||
await loadProfiles();
|
||||
await checkBrowserStatus(updatedProfile);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to launch browser:", err);
|
||||
setError(`Failed to launch browser: ${JSON.stringify(err)}`);
|
||||
}
|
||||
},
|
||||
[loadProfiles, checkBrowserStatus, isUpdating],
|
||||
);
|
||||
|
||||
const handleDeleteProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
setError(null);
|
||||
console.log("Attempting to delete profile:", profile.name);
|
||||
|
||||
try {
|
||||
// First check if the browser is running for this profile
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
|
||||
if (isRunning) {
|
||||
setError(
|
||||
"Cannot delete profile while browser is running. Please stop the browser first.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Attempt to delete the profile
|
||||
await invoke("delete_profile", { profileName: profile.name });
|
||||
console.log("Profile deletion command completed successfully");
|
||||
|
||||
// Give a small delay to ensure file system operations complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Reload profiles and groups to ensure UI is updated
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
|
||||
console.log("Profile deleted and profiles reloaded successfully");
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete profile:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
setError(`Failed to delete profile: ${errorMessage}`);
|
||||
}
|
||||
},
|
||||
[loadProfiles, loadGroups],
|
||||
);
|
||||
|
||||
const handleRenameProfile = useCallback(
|
||||
async (oldName: string, newName: string) => {
|
||||
setError(null);
|
||||
async (profileId: string, newName: string) => {
|
||||
try {
|
||||
await invoke("rename_profile", { oldName, newName });
|
||||
await loadProfiles();
|
||||
await invoke("rename_profile", { profileId, newName });
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to rename profile:", err);
|
||||
setError(`Failed to rename profile: ${JSON.stringify(err)}`);
|
||||
showErrorToast(`Failed to rename profile: ${JSON.stringify(err)}`);
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[loadProfiles],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleKillProfile = useCallback(
|
||||
async (profile: BrowserProfile) => {
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("kill_browser_profile", { profile });
|
||||
await loadProfiles();
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to kill browser:", err);
|
||||
setError(`Failed to kill browser: ${JSON.stringify(err)}`);
|
||||
}
|
||||
},
|
||||
[loadProfiles],
|
||||
);
|
||||
const handleKillProfile = useCallback(async (profile: BrowserProfile) => {
|
||||
console.log("Starting kill for profile:", profile.name);
|
||||
|
||||
try {
|
||||
await invoke("kill_browser_profile", { profile });
|
||||
console.log("Successfully killed profile:", profile.name);
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to kill browser:", err);
|
||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
||||
showErrorToast(`Failed to kill browser: ${errorMessage}`);
|
||||
// Re-throw the error so the table component can handle loading state cleanup
|
||||
throw err;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeleteSelectedProfiles = useCallback(
|
||||
async (profileNames: string[]) => {
|
||||
setError(null);
|
||||
async (profileIds: string[]) => {
|
||||
try {
|
||||
await invoke("delete_selected_profiles", { profileNames });
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
await invoke("delete_selected_profiles", { profileIds });
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete selected profiles:", err);
|
||||
setError(`Failed to delete selected profiles: ${JSON.stringify(err)}`);
|
||||
showErrorToast(
|
||||
`Failed to delete selected profiles: ${JSON.stringify(err)}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
[loadProfiles, loadGroups],
|
||||
[],
|
||||
);
|
||||
|
||||
const handleAssignProfilesToGroup = useCallback((profileNames: string[]) => {
|
||||
setSelectedProfilesForGroup(profileNames);
|
||||
const handleAssignProfilesToGroup = useCallback((profileIds: string[]) => {
|
||||
setSelectedProfilesForGroup(profileIds);
|
||||
setGroupAssignmentDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -621,19 +538,20 @@ export default function Home() {
|
||||
setIsBulkDeleting(true);
|
||||
try {
|
||||
await invoke("delete_selected_profiles", {
|
||||
profileNames: selectedProfiles,
|
||||
profileIds: selectedProfiles,
|
||||
});
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setSelectedProfiles([]);
|
||||
setShowBulkDeleteConfirmation(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete selected profiles:", error);
|
||||
setError(`Failed to delete selected profiles: ${JSON.stringify(error)}`);
|
||||
showErrorToast(
|
||||
`Failed to delete selected profiles: ${JSON.stringify(error)}`,
|
||||
);
|
||||
} finally {
|
||||
setIsBulkDeleting(false);
|
||||
}
|
||||
}, [selectedProfiles, loadProfiles, loadGroups]);
|
||||
}, [selectedProfiles]);
|
||||
|
||||
const handleBulkGroupAssignment = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
@@ -642,20 +560,16 @@ export default function Home() {
|
||||
}, [selectedProfiles, handleAssignProfilesToGroup]);
|
||||
|
||||
const handleGroupAssignmentComplete = useCallback(async () => {
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setGroupAssignmentDialogOpen(false);
|
||||
setSelectedProfilesForGroup([]);
|
||||
}, [loadProfiles, loadGroups]);
|
||||
}, []);
|
||||
|
||||
const handleGroupManagementComplete = useCallback(async () => {
|
||||
await loadGroups();
|
||||
}, [loadGroups]);
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfilesWithUpdateCheck();
|
||||
void loadGroups();
|
||||
|
||||
// Check for startup default browser prompt
|
||||
void checkStartupPrompt();
|
||||
|
||||
@@ -681,6 +595,11 @@ export default function Home() {
|
||||
30 * 60 * 1000,
|
||||
);
|
||||
|
||||
// Check for missing binaries after initial profile load
|
||||
if (!profilesLoading && profiles.length > 0) {
|
||||
void checkMissingBinaries();
|
||||
}
|
||||
|
||||
return () => {
|
||||
clearInterval(updateInterval);
|
||||
if (cleanup) {
|
||||
@@ -688,12 +607,13 @@ export default function Home() {
|
||||
}
|
||||
};
|
||||
}, [
|
||||
loadProfilesWithUpdateCheck,
|
||||
checkForUpdates,
|
||||
checkStartupPrompt,
|
||||
listenForUrlEvents,
|
||||
checkCurrentUrl,
|
||||
loadGroups,
|
||||
checkMissingBinaries,
|
||||
profilesLoading,
|
||||
profiles.length,
|
||||
]);
|
||||
|
||||
// Show deprecation warning for unsupported profiles (with names)
|
||||
@@ -729,31 +649,6 @@ export default function Home() {
|
||||
}
|
||||
}, [profiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (profiles.length === 0) return;
|
||||
|
||||
const interval = setInterval(() => {
|
||||
for (const profile of profiles) {
|
||||
void checkBrowserStatus(profile);
|
||||
}
|
||||
}, 500);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
};
|
||||
}, [profiles, checkBrowserStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
runningProfilesRef.current = runningProfiles;
|
||||
}, [runningProfiles]);
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
showErrorToast(error);
|
||||
setError(null);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Check permissions when they are initialized
|
||||
useEffect(() => {
|
||||
if (isInitialized) {
|
||||
@@ -761,8 +656,45 @@ export default function Home() {
|
||||
}
|
||||
}, [isInitialized, checkAllPermissions]);
|
||||
|
||||
// Filter data by selected group and search query
|
||||
const filteredProfiles = useMemo(() => {
|
||||
let filtered = profiles;
|
||||
|
||||
// Filter by group
|
||||
if (!selectedGroupId || selectedGroupId === "default") {
|
||||
filtered = profiles.filter((profile) => !profile.group_id);
|
||||
} else {
|
||||
filtered = profiles.filter(
|
||||
(profile) => profile.group_id === selectedGroupId,
|
||||
);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase().trim();
|
||||
filtered = filtered.filter((profile) => {
|
||||
// Search in profile name
|
||||
if (profile.name.toLowerCase().includes(query)) return true;
|
||||
|
||||
// Search in browser name
|
||||
if (profile.browser.toLowerCase().includes(query)) return true;
|
||||
|
||||
// Search in tags
|
||||
if (profile.tags?.some((tag) => tag.toLowerCase().includes(query)))
|
||||
return true;
|
||||
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [profiles, selectedGroupId, searchQuery]);
|
||||
|
||||
// Update loading states
|
||||
const isLoading = profilesLoading || groupsLoading || proxiesLoading;
|
||||
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-background">
|
||||
<main className="flex flex-col row-start-2 gap-6 items-center w-full max-w-3xl">
|
||||
<div className="w-full">
|
||||
<HomeHeader
|
||||
@@ -774,17 +706,19 @@ export default function Home() {
|
||||
onImportProfileDialogOpen={setImportProfileDialogOpen}
|
||||
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
|
||||
onSettingsDialogOpen={setSettingsDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4 w-full">
|
||||
<GroupBadges
|
||||
selectedGroupId={selectedGroupId}
|
||||
onGroupSelect={handleSelectGroup}
|
||||
groups={groups}
|
||||
isLoading={areGroupsLoading}
|
||||
groups={groupsData}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<ProfilesDataTable
|
||||
data={profiles}
|
||||
profiles={filteredProfiles}
|
||||
onLaunchProfile={launchProfile}
|
||||
onKillProfile={handleKillProfile}
|
||||
onDeleteProfile={handleDeleteProfile}
|
||||
@@ -802,13 +736,13 @@ export default function Home() {
|
||||
</main>
|
||||
|
||||
{isInitializing && (
|
||||
<div className="fixed inset-0 z-[100000] backdrop-blur-sm bg-black/30 flex items-center justify-center">
|
||||
<div className="bg-white dark:bg-neutral-900 rounded-xl p-6 shadow-xl border border-black/10 dark:border-white/10 w-[320px] text-center">
|
||||
<div className="fixed inset-0 z-[1000] backdrop-blur-sm bg-background/30 flex items-center justify-center">
|
||||
<div className="bg-background rounded-xl p-6 shadow-xl border border-border/10 w-[320px] text-center">
|
||||
<div className="text-lg font-medium">Initializing</div>
|
||||
<div className="mt-1 mb-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
Please don't close the app
|
||||
</div>
|
||||
<div className="mx-auto mb-4 w-8 h-8 rounded-full border-2 border-gray-300 animate-spin border-t-gray-900 dark:border-gray-700 dark:border-t-white" />
|
||||
<div className="mx-auto mb-4 w-8 h-8 rounded-full border-2 animate-spin border-muted/40 border-t-primary" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -834,7 +768,6 @@ export default function Home() {
|
||||
onClose={() => {
|
||||
setImportProfileDialogOpen(false);
|
||||
}}
|
||||
onImportComplete={() => void loadProfiles()}
|
||||
/>
|
||||
|
||||
<ProxyManagementDialog
|
||||
@@ -892,6 +825,7 @@ export default function Home() {
|
||||
}}
|
||||
selectedProfiles={selectedProfilesForGroup}
|
||||
onAssignmentComplete={handleGroupAssignmentComplete}
|
||||
profiles={profiles}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
@@ -902,7 +836,8 @@ export default function Home() {
|
||||
description={`This action cannot be undone. This will permanently delete ${selectedProfiles.length} profile${selectedProfiles.length !== 1 ? "s" : ""} and all associated data.`}
|
||||
confirmButtonText={`Delete ${selectedProfiles.length} Profile${selectedProfiles.length !== 1 ? "s" : ""}`}
|
||||
isLoading={isBulkDeleting}
|
||||
profileNames={selectedProfiles}
|
||||
profileIds={selectedProfiles}
|
||||
profiles={profiles.map((p) => ({ id: p.id, name: p.name }))}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -107,7 +107,7 @@ export function CamoufoxConfigDialog({
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<ScrollArea className="flex-1 h-[400px]">
|
||||
<ScrollArea className="flex-1 h-[320px]">
|
||||
<div className="py-4">
|
||||
<SharedCamoufoxConfigForm
|
||||
config={config}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { GoPlus } from "react-icons/go";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { SharedCamoufoxConfigForm } from "@/components/shared-camoufox-config-form";
|
||||
import { Combobox } from "@/components/ui/combobox";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -25,9 +25,11 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
|
||||
import { useBrowserDownload } from "@/hooks/use-browser-download";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
|
||||
import type { BrowserReleaseTypes, CamoufoxConfig } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
type BrowserTypeString =
|
||||
@@ -98,30 +100,45 @@ export function CreateProfileDialog({
|
||||
selectedGroupId,
|
||||
}: CreateProfileDialogProps) {
|
||||
const [profileName, setProfileName] = useState("");
|
||||
const [currentStep, setCurrentStep] = useState<
|
||||
"browser-selection" | "browser-config"
|
||||
>("browser-selection");
|
||||
const [activeTab, setActiveTab] = useState("anti-detect");
|
||||
|
||||
// Regular browser states
|
||||
// Browser selection states
|
||||
const [selectedBrowser, setSelectedBrowser] =
|
||||
useState<BrowserTypeString | null>("camoufox");
|
||||
useState<BrowserTypeString | null>(null);
|
||||
const [selectedProxyId, setSelectedProxyId] = useState<string>();
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
if (value === "regular") {
|
||||
setSelectedBrowser(null);
|
||||
} else if (value === "anti-detect") {
|
||||
setSelectedBrowser("camoufox");
|
||||
}
|
||||
|
||||
setActiveTab(value);
|
||||
};
|
||||
|
||||
// Camoufox anti-detect states
|
||||
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({
|
||||
geoip: true, // Default to automatic geoip
|
||||
});
|
||||
|
||||
// Handle browser selection from the initial screen
|
||||
const handleBrowserSelect = (browser: BrowserTypeString) => {
|
||||
setSelectedBrowser(browser);
|
||||
setCurrentStep("browser-config");
|
||||
};
|
||||
|
||||
// Handle back button
|
||||
const handleBack = () => {
|
||||
setCurrentStep("browser-selection");
|
||||
setSelectedBrowser(null);
|
||||
setProfileName("");
|
||||
setSelectedProxyId(undefined);
|
||||
};
|
||||
|
||||
const handleTabChange = (value: string) => {
|
||||
setActiveTab(value);
|
||||
setCurrentStep("browser-selection");
|
||||
setSelectedBrowser(null);
|
||||
setProfileName("");
|
||||
setSelectedProxyId(undefined);
|
||||
};
|
||||
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const { storedProxies } = useProxyEvents();
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [releaseTypes, setReleaseTypes] = useState<BrowserReleaseTypes>();
|
||||
@@ -144,15 +161,6 @@ export function CreateProfileDialog({
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadStoredProxies = useCallback(async () => {
|
||||
try {
|
||||
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(proxies);
|
||||
} catch (error) {
|
||||
console.error("Failed to load stored proxies:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const checkAndDownloadGeoIPDatabase = useCallback(async () => {
|
||||
try {
|
||||
const isAvailable = await invoke<boolean>("is_geoip_database_available");
|
||||
@@ -178,6 +186,8 @@ export function CreateProfileDialog({
|
||||
{ browserStr: browser },
|
||||
);
|
||||
|
||||
await loadDownloadedVersions(browser);
|
||||
|
||||
// Only update state if this browser is still the one we're loading
|
||||
if (loadingBrowserRef.current === browser) {
|
||||
// Filter to enforce stable-only creation, except Firefox Developer (nightly-only)
|
||||
@@ -197,12 +207,29 @@ export function CreateProfileDialog({
|
||||
filtered.stable = rawReleaseTypes.stable;
|
||||
setReleaseTypes(filtered);
|
||||
}
|
||||
|
||||
// Load downloaded versions for this browser
|
||||
await loadDownloadedVersions(browser);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load release types for ${browser}:`, error);
|
||||
|
||||
// Fallback: still load downloaded versions and derive release type from them if possible
|
||||
try {
|
||||
const downloaded = await loadDownloadedVersions(browser);
|
||||
if (loadingBrowserRef.current === browser && downloaded.length > 0) {
|
||||
const latest = downloaded[0];
|
||||
const fallback: BrowserReleaseTypes = {};
|
||||
if (browser === "firefox-developer") {
|
||||
fallback.nightly = latest;
|
||||
} else {
|
||||
fallback.stable = latest;
|
||||
}
|
||||
setReleaseTypes(fallback);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(
|
||||
`Failed to load downloaded versions for ${browser}:`,
|
||||
e,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Clear loading state only if we're still loading this browser
|
||||
if (loadingBrowserRef.current === browser) {
|
||||
@@ -217,18 +244,21 @@ export function CreateProfileDialog({
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadSupportedBrowsers();
|
||||
void loadStoredProxies();
|
||||
// Load camoufox release types when dialog opens
|
||||
void loadReleaseTypes("camoufox");
|
||||
// Load release types when a browser is selected
|
||||
if (selectedBrowser) {
|
||||
void loadReleaseTypes(selectedBrowser);
|
||||
}
|
||||
// Check and download GeoIP database if needed for Camoufox
|
||||
void checkAndDownloadGeoIPDatabase();
|
||||
if (selectedBrowser === "camoufox") {
|
||||
void checkAndDownloadGeoIPDatabase();
|
||||
}
|
||||
}
|
||||
}, [
|
||||
isOpen,
|
||||
loadSupportedBrowsers,
|
||||
loadStoredProxies,
|
||||
loadReleaseTypes,
|
||||
checkAndDownloadGeoIPDatabase,
|
||||
selectedBrowser,
|
||||
]);
|
||||
|
||||
// Load release types when browser selection changes
|
||||
@@ -283,7 +313,29 @@ export function CreateProfileDialog({
|
||||
|
||||
setIsCreating(true);
|
||||
try {
|
||||
if (activeTab === "regular") {
|
||||
if (activeTab === "anti-detect") {
|
||||
// Anti-detect browser - always use Camoufox with best available version
|
||||
const bestCamoufoxVersion = getBestAvailableVersion("camoufox");
|
||||
if (!bestCamoufoxVersion) {
|
||||
console.error("No Camoufox version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
// We don't need to generate it here during profile creation
|
||||
const finalCamoufoxConfig = { ...camoufoxConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "camoufox" as BrowserTypeString,
|
||||
version: bestCamoufoxVersion.version,
|
||||
releaseType: bestCamoufoxVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
camoufoxConfig: finalCamoufoxConfig,
|
||||
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
});
|
||||
} else {
|
||||
// Regular browser
|
||||
if (!selectedBrowser) {
|
||||
console.error("Missing required browser selection");
|
||||
return;
|
||||
@@ -304,27 +356,6 @@ export function CreateProfileDialog({
|
||||
proxyId: selectedProxyId,
|
||||
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
});
|
||||
} else {
|
||||
// Anti-detect tab - always use Camoufox with best available version
|
||||
const bestCamoufoxVersion = getBestAvailableVersion("camoufox");
|
||||
if (!bestCamoufoxVersion) {
|
||||
console.error("No Camoufox version available");
|
||||
return;
|
||||
}
|
||||
|
||||
// The fingerprint will be generated at launch time by the Rust backend
|
||||
// We don't need to generate it here during profile creation
|
||||
const finalCamoufoxConfig = { ...camoufoxConfig };
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "camoufox" as BrowserTypeString,
|
||||
version: bestCamoufoxVersion.version,
|
||||
releaseType: bestCamoufoxVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
camoufoxConfig: finalCamoufoxConfig,
|
||||
groupId: selectedGroupId !== "default" ? selectedGroupId : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
handleClose();
|
||||
@@ -341,13 +372,14 @@ export function CreateProfileDialog({
|
||||
|
||||
// Reset all states
|
||||
setProfileName("");
|
||||
setCurrentStep("browser-selection");
|
||||
setActiveTab("anti-detect");
|
||||
setSelectedBrowser(null);
|
||||
setSelectedProxyId(undefined);
|
||||
setReleaseTypes({});
|
||||
setCamoufoxConfig({
|
||||
geoip: true, // Reset to automatic geoip
|
||||
});
|
||||
setActiveTab("anti-detect");
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -386,25 +418,23 @@ export function CreateProfileDialog({
|
||||
isBrowserVersionAvailable,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(
|
||||
selectedBrowser,
|
||||
selectedBrowser && isBrowserCurrentlyDownloading(selectedBrowser),
|
||||
selectedBrowser && isBrowserVersionAvailable(selectedBrowser),
|
||||
selectedBrowser && getBestAvailableVersion(selectedBrowser),
|
||||
);
|
||||
}, [
|
||||
selectedBrowser,
|
||||
isBrowserCurrentlyDownloading,
|
||||
isBrowserVersionAvailable,
|
||||
getBestAvailableVersion,
|
||||
]);
|
||||
// Filter supported browsers for regular browsers (excluding mullvad and tor)
|
||||
const regularBrowsers = browserOptions.filter(
|
||||
(browser) =>
|
||||
supportedBrowsers.includes(browser.value) &&
|
||||
browser.value !== "mullvad-browser" &&
|
||||
browser.value !== "tor-browser",
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="w-full max-h-[90vh] flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Create New Profile</DialogTitle>
|
||||
<DialogTitle>
|
||||
{currentStep === "browser-selection"
|
||||
? "Create New Profile"
|
||||
: "Configure Profile"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
@@ -420,221 +450,432 @@ export function CreateProfileDialog({
|
||||
<TabsTrigger value="regular">Regular</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<ScrollArea className="flex-1 h-[330px] overflow-y-hidden">
|
||||
<ScrollArea className="overflow-y-auto flex-1">
|
||||
<div className="flex flex-col justify-center items-center w-full">
|
||||
<div className="py-4 space-y-6 w-full max-w-md">
|
||||
{/* Profile Name - Common to both tabs */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-name">Profile Name</Label>
|
||||
<Input
|
||||
id="profile-name"
|
||||
value={profileName}
|
||||
onChange={(e) => setProfileName(e.target.value)}
|
||||
placeholder="Enter profile name"
|
||||
/>
|
||||
</div>
|
||||
{currentStep === "browser-selection" ? (
|
||||
<>
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
{/* Anti-Detect Browser Selection */}
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">
|
||||
Anti-Detect Browser
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Choose Firefox for anti-detection capabilities
|
||||
</p>
|
||||
</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) &&
|
||||
browser.value !== "mullvad-browser" &&
|
||||
browser.value !== "tor-browser",
|
||||
)
|
||||
.map((browser) => {
|
||||
const IconComponent = getBrowserIcon(browser.value);
|
||||
return {
|
||||
value: browser.value,
|
||||
label: browser.label,
|
||||
icon: IconComponent,
|
||||
};
|
||||
})}
|
||||
value={selectedBrowser || ""}
|
||||
onValueChange={(value) =>
|
||||
setSelectedBrowser(value as BrowserTypeString)
|
||||
}
|
||||
placeholder="Select a browser..."
|
||||
searchPlaceholder="Search browsers..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
{!isBrowserCurrentlyDownloading(selectedBrowser) &&
|
||||
!isBrowserVersionAvailable(selectedBrowser) &&
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(selectedBrowser);
|
||||
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload(selectedBrowser)}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
)}
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
)}
|
||||
>
|
||||
Download
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{!isBrowserCurrentlyDownloading(selectedBrowser) &&
|
||||
isBrowserVersionAvailable(selectedBrowser) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(selectedBrowser);
|
||||
return `✓ Latest version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading(selectedBrowser) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<Button
|
||||
onClick={() => handleBrowserSelect("camoufox")}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center w-8 h-8">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(selectedBrowser);
|
||||
return `Downloading version (${bestVersion?.version})...`;
|
||||
const IconComponent = getBrowserIcon("firefox");
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-6 h-6" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
<div className="text-left">
|
||||
<div className="font-medium">Firefox</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Anti-Detect Browser
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
<div className="space-y-6">
|
||||
{/* Camoufox Download Status */}
|
||||
{!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
!isBrowserVersionAvailable("camoufox") &&
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Camoufox version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
<TabsContent value="regular" className="mt-0 space-y-6">
|
||||
{/* Regular Browser Selection */}
|
||||
<div className="space-y-6">
|
||||
<div className="text-center">
|
||||
<h3 className="text-lg font-medium">
|
||||
Regular Browsers
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Choose from supported regular browsers
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload("camoufox")}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
size="sm"
|
||||
disabled={isBrowserCurrentlyDownloading("camoufox")}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? "Downloading..."
|
||||
: "Download"}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
isBrowserVersionAvailable("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `✓ Camoufox version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
|
||||
<div className="space-y-3">
|
||||
{regularBrowsers.map((browser) => {
|
||||
if (browser.value === "camoufox") return null; // Skip camoufox as it's handled in anti-detect tab
|
||||
const IconComponent = getBrowserIcon(browser.value);
|
||||
return (
|
||||
<Button
|
||||
key={browser.value}
|
||||
onClick={() =>
|
||||
handleBrowserSelect(browser.value)
|
||||
}
|
||||
className="flex gap-3 justify-start items-center p-4 w-full h-16 border-2 transition-colors hover:border-primary/50"
|
||||
variant="outline"
|
||||
>
|
||||
<div className="flex justify-center items-center w-8 h-8">
|
||||
{IconComponent && (
|
||||
<IconComponent className="w-6 h-6" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<div className="font-medium">
|
||||
{browser.label}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Regular Browser
|
||||
</div>
|
||||
</div>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Downloading Camoufox version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TabsContent value="anti-detect" className="mt-0">
|
||||
{/* Anti-Detect Configuration */}
|
||||
<div className="space-y-6">
|
||||
{/* Profile Name */}
|
||||
<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>
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
isCreating
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{selectedBrowser === "camoufox" ? (
|
||||
// Camoufox Configuration
|
||||
<div className="space-y-6">
|
||||
{/* Camoufox Download Status */}
|
||||
{!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
!isBrowserVersionAvailable("camoufox") &&
|
||||
getBestAvailableVersion("camoufox") && (
|
||||
<div className="flex gap-3 items-center p-3 rounded-md border">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Camoufox version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload("camoufox")}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
size="sm"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
"camoufox",
|
||||
)}
|
||||
>
|
||||
{isBrowserCurrentlyDownloading("camoufox")
|
||||
? "Downloading..."
|
||||
: "Download"}
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{!isBrowserCurrentlyDownloading("camoufox") &&
|
||||
isBrowserVersionAvailable("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `✓ Camoufox version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading("camoufox") && (
|
||||
<div className="p-3 text-sm rounded-md border text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion("camoufox");
|
||||
return `Downloading Camoufox version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proxy Selection - Common to both tabs - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowProxyForm(true)}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedProxyId(value === "none" ? undefined : value)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies available. Add one to route this profile's
|
||||
traffic.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
isCreating
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
// Regular Browser Configuration
|
||||
<div className="space-y-4">
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
{!isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) &&
|
||||
!isBrowserVersionAvailable(selectedBrowser) &&
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() =>
|
||||
handleDownload(selectedBrowser)
|
||||
}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
)}
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
)}
|
||||
>
|
||||
Download
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{!isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) &&
|
||||
isBrowserVersionAvailable(
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `✓ Latest version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Downloading version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Proxy Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowProxyForm(true)}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedProxyId(
|
||||
value === "none" ? undefined : value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies available. Add one to route this
|
||||
profile's traffic.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="regular" className="mt-0">
|
||||
{/* Regular Browser Configuration */}
|
||||
<div className="space-y-6">
|
||||
{/* Profile Name */}
|
||||
<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>
|
||||
|
||||
{/* Regular Browser Configuration */}
|
||||
<div className="space-y-4">
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
{!isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) &&
|
||||
!isBrowserVersionAvailable(selectedBrowser) &&
|
||||
getBestAvailableVersion(selectedBrowser) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `Latest version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() =>
|
||||
handleDownload(selectedBrowser)
|
||||
}
|
||||
isLoading={isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
)}
|
||||
className="ml-auto"
|
||||
size="sm"
|
||||
disabled={isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
)}
|
||||
>
|
||||
Download
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{!isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) &&
|
||||
isBrowserVersionAvailable(selectedBrowser) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(
|
||||
selectedBrowser,
|
||||
);
|
||||
return `✓ Latest version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
{isBrowserCurrentlyDownloading(
|
||||
selectedBrowser,
|
||||
) && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(selectedBrowser);
|
||||
return `Downloading version (${bestVersion?.version})...`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Proxy Selection - Always visible */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Proxy</Label>
|
||||
<RippleButton
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setShowProxyForm(true)}
|
||||
className="px-2 h-7 text-xs"
|
||||
>
|
||||
<GoPlus className="mr-1 w-3 h-3" /> Add Proxy
|
||||
</RippleButton>
|
||||
</div>
|
||||
{storedProxies.length > 0 ? (
|
||||
<Select
|
||||
value={selectedProxyId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSelectedProxyId(
|
||||
value === "none" ? undefined : value,
|
||||
)
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="No proxy" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">No proxy</SelectItem>
|
||||
{storedProxies.map((proxy) => (
|
||||
<SelectItem key={proxy.id} value={proxy.id}>
|
||||
{proxy.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<div className="flex gap-3 items-center p-3 text-sm rounded-md border text-muted-foreground">
|
||||
No proxies available. Add one to route this
|
||||
profile's traffic.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs>
|
||||
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
<DialogFooter className="flex-shrink-0 pt-4 border-t">
|
||||
{currentStep === "browser-config" ? (
|
||||
<>
|
||||
<RippleButton variant="outline" onClick={handleBack}>
|
||||
Back
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
onClick={handleCreate}
|
||||
isLoading={isCreating}
|
||||
disabled={isCreateDisabled}
|
||||
>
|
||||
Create
|
||||
</LoadingButton>
|
||||
</>
|
||||
) : (
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
onClick={handleCreate}
|
||||
isLoading={isCreating}
|
||||
disabled={isCreateDisabled}
|
||||
>
|
||||
Create
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</Tabs>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
<ProxyFormDialog
|
||||
isOpen={showProxyForm}
|
||||
onClose={() => setShowProxyForm(false)}
|
||||
onSave={(proxy) => {
|
||||
setStoredProxies((prev) => [...prev, proxy]);
|
||||
setSelectedProxyId(proxy.id);
|
||||
}}
|
||||
/>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -168,7 +168,7 @@ export function UnifiedToast(props: ToastProps) {
|
||||
const progress = "progress" in props ? props.progress : undefined;
|
||||
|
||||
return (
|
||||
<div className="flex items-start p-4 w-full max-w-md rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="flex items-start p-3 w-96 rounded-lg border shadow-lg bg-card border-border text-card-foreground">
|
||||
<div className="mr-3 mt-0.5">{getToastIcon(type, stage)}</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-semibold leading-tight text-foreground">
|
||||
|
||||
@@ -19,7 +19,8 @@ interface DeleteConfirmationDialogProps {
|
||||
description: string;
|
||||
confirmButtonText?: string;
|
||||
isLoading?: boolean;
|
||||
profileNames?: string[];
|
||||
profileIds?: string[];
|
||||
profiles?: { id: string; name: string }[];
|
||||
}
|
||||
|
||||
export function DeleteConfirmationDialog({
|
||||
@@ -30,7 +31,8 @@ export function DeleteConfirmationDialog({
|
||||
description,
|
||||
confirmButtonText = "Delete",
|
||||
isLoading = false,
|
||||
profileNames,
|
||||
profileIds,
|
||||
profiles = [],
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
@@ -42,18 +44,22 @@ export function DeleteConfirmationDialog({
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
{profileNames && profileNames.length > 0 && (
|
||||
{profileIds && profileIds.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>
|
||||
))}
|
||||
{profileIds.map((id) => {
|
||||
const profile = profiles.find((p) => p.id === id);
|
||||
const displayName = profile ? profile.name : id;
|
||||
return (
|
||||
<li key={id} className="text-sm text-muted-foreground">
|
||||
• {displayName}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -74,13 +74,13 @@ export function DeleteGroupDialog({
|
||||
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 });
|
||||
const profileIds = associatedProfiles.map((p) => p.id);
|
||||
await invoke("delete_selected_profiles", { profileIds });
|
||||
} else if (deleteAction === "move" && associatedProfiles.length > 0) {
|
||||
// Move profiles to default group (null group_id)
|
||||
const profileNames = associatedProfiles.map((p) => p.name);
|
||||
const profileIds = associatedProfiles.map((p) => p.id);
|
||||
await invoke("assign_profiles_to_group", {
|
||||
profileNames,
|
||||
profileIds,
|
||||
groupId: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
import type { BrowserProfile, ProfileGroup } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface GroupAssignmentDialogProps {
|
||||
@@ -30,6 +30,7 @@ interface GroupAssignmentDialogProps {
|
||||
onClose: () => void;
|
||||
selectedProfiles: string[];
|
||||
onAssignmentComplete: () => void;
|
||||
profiles?: BrowserProfile[];
|
||||
}
|
||||
|
||||
export function GroupAssignmentDialog({
|
||||
@@ -37,6 +38,7 @@ export function GroupAssignmentDialog({
|
||||
onClose,
|
||||
selectedProfiles,
|
||||
onAssignmentComplete,
|
||||
profiles = [],
|
||||
}: GroupAssignmentDialogProps) {
|
||||
const [groups, setGroups] = useState<ProfileGroup[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
@@ -64,7 +66,7 @@ export function GroupAssignmentDialog({
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("assign_profiles_to_group", {
|
||||
profileNames: selectedProfiles,
|
||||
profileIds: selectedProfiles,
|
||||
groupId: selectedGroupId,
|
||||
});
|
||||
|
||||
@@ -119,11 +121,18 @@ export function GroupAssignmentDialog({
|
||||
<Label>Selected Profiles:</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileName) => (
|
||||
<li key={profileName} className="truncate">
|
||||
• {profileName}
|
||||
</li>
|
||||
))}
|
||||
{selectedProfiles.map((profileId) => {
|
||||
// Find the profile name for display
|
||||
const profile = profiles.find(
|
||||
(p: BrowserProfile) => p.id === profileId,
|
||||
);
|
||||
const displayName = profile ? profile.name : profileId;
|
||||
return (
|
||||
<li key={profileId} className="truncate">
|
||||
• {displayName}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import { LuTrash2, LuUsers } from "react-icons/lu";
|
||||
import { LuSearch, LuTrash2, LuUsers, LuX } from "react-icons/lu";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Button } from "./ui/button";
|
||||
import { CardTitle } from "./ui/card";
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Input } from "./ui/input";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
@@ -23,6 +24,8 @@ type Props = {
|
||||
onGroupManagementDialogOpen: (open: boolean) => void;
|
||||
onImportProfileDialogOpen: (open: boolean) => void;
|
||||
onCreateProfileDialogOpen: (open: boolean) => void;
|
||||
searchQuery: string;
|
||||
onSearchQueryChange: (query: string) => void;
|
||||
};
|
||||
|
||||
const HomeHeader = ({
|
||||
@@ -34,6 +37,8 @@ const HomeHeader = ({
|
||||
onGroupManagementDialogOpen,
|
||||
onImportProfileDialogOpen,
|
||||
onCreateProfileDialogOpen,
|
||||
searchQuery,
|
||||
onSearchQueryChange,
|
||||
}: Props) => {
|
||||
const handleLogoClick = () => {
|
||||
// Trigger the same URL handling logic as if the URL came from the system
|
||||
@@ -76,7 +81,7 @@ const HomeHeader = ({
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
Delete Selected
|
||||
Delete
|
||||
</RippleButton>
|
||||
</div>
|
||||
</div>
|
||||
@@ -85,12 +90,32 @@ const HomeHeader = ({
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search profiles..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => onSearchQueryChange(e.target.value)}
|
||||
className="pr-8 pl-10 w-48"
|
||||
/>
|
||||
<LuSearch className="absolute left-3 top-1/2 w-4 h-4 transform -translate-y-1/2 text-muted-foreground" />
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSearchQueryChange("")}
|
||||
className="absolute right-2 top-1/2 p-1 rounded-sm transition-colors transform -translate-y-1/2 hover:bg-accent"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<LuX className="w-4 h-4 text-muted-foreground hover:text-foreground" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex gap-2 items-center"
|
||||
className="flex gap-2 items-center h-[36px]"
|
||||
>
|
||||
<GoKebabHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
@@ -138,7 +163,7 @@ const HomeHeader = ({
|
||||
onClick={() => {
|
||||
onCreateProfileDialogOpen(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
className="flex gap-2 items-center h-[36px]"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
|
||||
@@ -31,13 +31,11 @@ import { RippleButton } from "./ui/ripple";
|
||||
interface ImportProfileDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onImportComplete?: () => void;
|
||||
}
|
||||
|
||||
export function ImportProfileDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onImportComplete,
|
||||
}: ImportProfileDialogProps) {
|
||||
const [detectedProfiles, setDetectedProfiles] = useState<DetectedProfile[]>(
|
||||
[],
|
||||
@@ -140,9 +138,6 @@ export function ImportProfileDialog({
|
||||
toast.success(
|
||||
`Successfully imported profile "${autoDetectProfileName.trim()}"`,
|
||||
);
|
||||
if (onImportComplete) {
|
||||
onImportComplete();
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to import profile:", error);
|
||||
@@ -168,7 +163,6 @@ export function ImportProfileDialog({
|
||||
selectedDetectedProfile,
|
||||
autoDetectProfileName,
|
||||
detectedProfiles,
|
||||
onImportComplete,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
@@ -193,9 +187,6 @@ export function ImportProfileDialog({
|
||||
toast.success(
|
||||
`Successfully imported profile "${manualProfileName.trim()}"`,
|
||||
);
|
||||
if (onImportComplete) {
|
||||
onImportComplete();
|
||||
}
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to import profile:", error);
|
||||
@@ -217,13 +208,7 @@ export function ImportProfileDialog({
|
||||
} finally {
|
||||
setIsImporting(false);
|
||||
}
|
||||
}, [
|
||||
manualBrowserType,
|
||||
manualProfilePath,
|
||||
manualProfileName,
|
||||
onImportComplete,
|
||||
onClose,
|
||||
]);
|
||||
}, [manualBrowserType, manualProfilePath, manualProfileName, onClose]);
|
||||
|
||||
const handleClose = () => {
|
||||
setSelectedDetectedProfile(null);
|
||||
|
||||
@@ -287,6 +287,7 @@ const MultipleSelector = React.forwardRef<
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]);
|
||||
|
||||
// biome-ignore lint/correctness/noNestedComponentDefinitions: public code, TODO: fix
|
||||
const CreatableItem = () => {
|
||||
if (!creatable) return undefined;
|
||||
if (
|
||||
@@ -352,6 +353,10 @@ const MultipleSelector = React.forwardRef<
|
||||
[options, selected],
|
||||
);
|
||||
|
||||
const hasAvailableOptions = React.useMemo(() => {
|
||||
return Object.values(selectables).some((group) => group.length > 0);
|
||||
}, [selectables]);
|
||||
|
||||
/** Avoid Creatable Selector freezing or lagging when paste a long string. */
|
||||
const commandFilter = React.useCallback(() => {
|
||||
if (commandProps?.filter) {
|
||||
@@ -416,7 +421,7 @@ const MultipleSelector = React.forwardRef<
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
"ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"cursor-pointer ml-1 rounded-full outline-none ring-offset-background focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
(disabled || option.fixed) && "hidden",
|
||||
)}
|
||||
onKeyDown={(e) => {
|
||||
@@ -445,6 +450,40 @@ const MultipleSelector = React.forwardRef<
|
||||
setInputValue(value);
|
||||
inputProps?.onValueChange?.(value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
// Allow consumer to handle first
|
||||
inputProps?.onKeyDown?.(
|
||||
e as unknown as React.KeyboardEvent<HTMLInputElement>,
|
||||
);
|
||||
if (e.defaultPrevented) return;
|
||||
if (e.key === "Enter") {
|
||||
const value = inputValue.trim();
|
||||
if (value.length === 0) return;
|
||||
// If option already exists among available options, pick that; otherwise create
|
||||
const entries = Object.values(options).flat();
|
||||
const existing = entries.find(
|
||||
(o) => o.value === value && !o.disable,
|
||||
);
|
||||
// Prevent duplicates in the current selection
|
||||
if (
|
||||
selected.some((s) => s.value === (existing?.value ?? value))
|
||||
) {
|
||||
e.preventDefault();
|
||||
setInputValue("");
|
||||
return;
|
||||
}
|
||||
if (selected.length >= maxSelected) {
|
||||
onMaxSelected?.(selected.length);
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setInputValue("");
|
||||
const picked = existing ?? { value, label: value };
|
||||
const newOptions = [...selected, picked];
|
||||
setSelected(newOptions);
|
||||
onChange?.(newOptions);
|
||||
}
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
setOpen(false);
|
||||
inputProps?.onBlur?.(event);
|
||||
@@ -465,7 +504,7 @@ const MultipleSelector = React.forwardRef<
|
||||
"flex-1 bg-transparent outline-none placeholder:text-muted-foreground",
|
||||
{
|
||||
"w-full": hidePlaceholderWhenSelected,
|
||||
"px-3 py-2": selected.length === 0,
|
||||
"px-3 mt-1": selected.length === 0,
|
||||
"ml-1": selected.length !== 0,
|
||||
},
|
||||
inputProps?.className,
|
||||
@@ -474,7 +513,7 @@ const MultipleSelector = React.forwardRef<
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative">
|
||||
{open && (
|
||||
{open && hasAvailableOptions && (
|
||||
<CommandList className="absolute top-1 z-10 w-full rounded-md border shadow-md outline-none bg-popover text-popover-foreground animate-in">
|
||||
{isLoading ? (
|
||||
loadingIndicator
|
||||
@@ -489,7 +528,7 @@ const MultipleSelector = React.forwardRef<
|
||||
<CommandGroup
|
||||
key={key}
|
||||
heading={key}
|
||||
className="overflow-auto h-full"
|
||||
className="overflow-auto h-24"
|
||||
>
|
||||
{dropdowns.map((option) => {
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -27,8 +27,10 @@ import {
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useBrowserState } from "@/hooks/use-browser-state";
|
||||
import { useProfileEvents } from "@/hooks/use-profile-events";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type { BrowserProfile, StoredProxy } from "@/types";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface ProfileSelectorDialogProps {
|
||||
@@ -43,14 +45,19 @@ export function ProfileSelectorDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
url,
|
||||
runningProfiles = new Set(),
|
||||
runningProfiles: externalRunningProfiles,
|
||||
isUpdating,
|
||||
}: ProfileSelectorDialogProps) {
|
||||
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
|
||||
// Use the centralized profile events hook
|
||||
const { profiles, runningProfiles: hookRunningProfiles } = useProfileEvents();
|
||||
|
||||
// Use external runningProfiles if provided, otherwise use hook's runningProfiles
|
||||
const runningProfiles = externalRunningProfiles || hookRunningProfiles;
|
||||
|
||||
const { storedProxies } = useProxyEvents();
|
||||
|
||||
const [selectedProfile, setSelectedProfile] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const [launchingProfiles, setLaunchingProfiles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
@@ -77,48 +84,6 @@ export function ProfileSelectorDialog({
|
||||
[storedProxies],
|
||||
);
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
// Load both profiles and stored proxies
|
||||
const [profileList, proxiesList] = await Promise.all([
|
||||
invoke<BrowserProfile[]>("list_browser_profiles"),
|
||||
invoke<StoredProxy[]>("get_stored_proxies"),
|
||||
]);
|
||||
|
||||
// Sort profiles by name
|
||||
profileList.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
// Set both profiles and proxies
|
||||
setProfiles(profileList);
|
||||
setStoredProxies(proxiesList);
|
||||
|
||||
// Auto-select first available profile for link opening
|
||||
if (profileList.length > 0) {
|
||||
// First, try to find a running profile that can be used for opening links
|
||||
const runningAvailableProfile = profileList.find((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
// Simple check without browserState dependency
|
||||
return (
|
||||
isRunning &&
|
||||
profile.browser !== "tor-browser" &&
|
||||
profile.browser !== "mullvad-browser"
|
||||
);
|
||||
});
|
||||
|
||||
if (runningAvailableProfile) {
|
||||
setSelectedProfile(runningAvailableProfile.name);
|
||||
} else {
|
||||
setSelectedProfile(profileList[0].name);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Failed to load profiles:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [runningProfiles]);
|
||||
|
||||
// Helper function to get tooltip content for profiles - now uses shared hook
|
||||
const getProfileTooltipContent = (profile: BrowserProfile): string | null => {
|
||||
return browserState.getProfileTooltipContent(profile);
|
||||
@@ -128,10 +93,13 @@ export function ProfileSelectorDialog({
|
||||
if (!selectedProfile || !url) return;
|
||||
|
||||
setIsLaunching(true);
|
||||
setLaunchingProfiles((prev) => new Set(prev).add(selectedProfile));
|
||||
const selected = profiles.find((p) => p.name === selectedProfile);
|
||||
if (!selected) return;
|
||||
|
||||
setLaunchingProfiles((prev) => new Set(prev).add(selected.id));
|
||||
try {
|
||||
await invoke("open_url_with_profile", {
|
||||
profileName: selectedProfile,
|
||||
profileId: selected.id,
|
||||
url,
|
||||
});
|
||||
onClose();
|
||||
@@ -139,13 +107,15 @@ export function ProfileSelectorDialog({
|
||||
console.error("Failed to open URL with profile:", error);
|
||||
} finally {
|
||||
setIsLaunching(false);
|
||||
setLaunchingProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(selectedProfile);
|
||||
return next;
|
||||
});
|
||||
if (selected) {
|
||||
setLaunchingProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(selected.id);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, [selectedProfile, url, onClose]);
|
||||
}, [selectedProfile, url, onClose, profiles]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
setSelectedProfile(null);
|
||||
@@ -178,11 +148,31 @@ export function ProfileSelectorDialog({
|
||||
return getProfileTooltipContent(selectedProfileData);
|
||||
};
|
||||
|
||||
// Auto-select first available profile when dialog opens and profiles are loaded
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadProfiles();
|
||||
if (isOpen && profiles.length > 0 && !selectedProfile) {
|
||||
// First, try to find a running profile that can be used for opening links
|
||||
const runningAvailableProfile = profiles.find((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
// Simple check without browserState dependency
|
||||
return (
|
||||
isRunning &&
|
||||
profile.browser !== "tor-browser" &&
|
||||
profile.browser !== "mullvad-browser"
|
||||
);
|
||||
});
|
||||
|
||||
if (runningAvailableProfile) {
|
||||
setSelectedProfile(runningAvailableProfile.name);
|
||||
} else {
|
||||
// Sort profiles by name and select first
|
||||
const sortedProfiles = [...profiles].sort((a, b) =>
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
setSelectedProfile(sortedProfiles[0].name);
|
||||
}
|
||||
}
|
||||
}, [isOpen, loadProfiles]);
|
||||
}, [isOpen, profiles, selectedProfile, runningProfiles]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
@@ -214,11 +204,7 @@ export function ProfileSelectorDialog({
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="profile-select">Select Profile:</Label>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading profiles...
|
||||
</div>
|
||||
) : profiles.length === 0 ? (
|
||||
{profiles.length === 0 ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No profiles available. Please create a profile first.
|
||||
@@ -238,7 +224,7 @@ export function ProfileSelectorDialog({
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const canUseForLinks =
|
||||
browserState.canUseProfileForLinks(profile);
|
||||
const tooltipContent = getProfileTooltipContent(profile);
|
||||
|
||||
@@ -35,14 +35,12 @@ interface ProxyFormData {
|
||||
interface ProxyFormDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (proxy: StoredProxy) => void;
|
||||
editingProxy?: StoredProxy | null;
|
||||
}
|
||||
|
||||
export function ProxyFormDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSave,
|
||||
editingProxy,
|
||||
}: ProxyFormDialogProps) {
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -105,11 +103,9 @@ export function ProxyFormDialog({
|
||||
password: formData.password.trim() || undefined,
|
||||
};
|
||||
|
||||
let savedProxy: StoredProxy;
|
||||
|
||||
if (editingProxy) {
|
||||
// Update existing proxy
|
||||
savedProxy = await invoke<StoredProxy>("update_stored_proxy", {
|
||||
await invoke("update_stored_proxy", {
|
||||
proxyId: editingProxy.id,
|
||||
name: formData.name.trim(),
|
||||
proxySettings,
|
||||
@@ -117,14 +113,13 @@ export function ProxyFormDialog({
|
||||
toast.success("Proxy updated successfully");
|
||||
} else {
|
||||
// Create new proxy
|
||||
savedProxy = await invoke<StoredProxy>("create_stored_proxy", {
|
||||
await invoke("create_stored_proxy", {
|
||||
name: formData.name.trim(),
|
||||
proxySettings,
|
||||
});
|
||||
toast.success("Proxy created successfully");
|
||||
}
|
||||
|
||||
onSave(savedProxy);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save proxy:", error);
|
||||
@@ -134,7 +129,7 @@ export function ProxyFormDialog({
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}, [formData, editingProxy, onSave, onClose]);
|
||||
}, [formData, editingProxy, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
if (!isSubmitting) {
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { emit } from "@tauri-apps/api/event";
|
||||
import { useCallback, useState } from "react";
|
||||
import { FiEdit2, FiPlus, FiTrash2, FiWifi } from "react-icons/fi";
|
||||
import { toast } from "sonner";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { ProxyFormDialog } from "@/components/proxy-form-dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -15,11 +16,13 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useProxyEvents } from "@/hooks/use-proxy-events";
|
||||
import { trimName } from "@/lib/name-utils";
|
||||
import type { StoredProxy } from "@/types";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
@@ -33,80 +36,33 @@ export function ProxyManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: ProxyManagementDialogProps) {
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
const [editingProxy, setEditingProxy] = useState<StoredProxy | null>(null);
|
||||
const [proxyUsage, setProxyUsage] = useState<Record<string, number>>({});
|
||||
const [proxyToDelete, setProxyToDelete] = useState<StoredProxy | null>(null);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
|
||||
const loadStoredProxies = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const proxies = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(proxies);
|
||||
} catch (error) {
|
||||
console.error("Failed to load stored proxies:", error);
|
||||
toast.error("Failed to load proxies");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const { storedProxies, proxyUsage, isLoading } = useProxyEvents();
|
||||
|
||||
const handleDeleteProxy = useCallback((proxy: StoredProxy) => {
|
||||
// Open in-app confirmation dialog
|
||||
setProxyToDelete(proxy);
|
||||
}, []);
|
||||
|
||||
const loadProxyUsage = useCallback(async () => {
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
if (!proxyToDelete) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const profiles = await invoke<Array<{ proxy_id?: string }>>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const p of profiles) {
|
||||
if (p.proxy_id) counts[p.proxy_id] = (counts[p.proxy_id] ?? 0) + 1;
|
||||
}
|
||||
setProxyUsage(counts);
|
||||
} catch (_err) {
|
||||
// ignore non-critical errors
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadStoredProxies();
|
||||
void loadProxyUsage();
|
||||
}
|
||||
}, [isOpen, loadStoredProxies, loadProxyUsage]);
|
||||
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
const setup = async () => {
|
||||
try {
|
||||
unlisten = await listen("profile-updated", () => {
|
||||
void loadProxyUsage();
|
||||
});
|
||||
} catch (_err) {
|
||||
// ignore non-critical errors
|
||||
}
|
||||
};
|
||||
if (isOpen) void setup();
|
||||
return () => {
|
||||
if (unlisten) unlisten();
|
||||
};
|
||||
}, [isOpen, loadProxyUsage]);
|
||||
|
||||
const handleDeleteProxy = useCallback(async (proxy: StoredProxy) => {
|
||||
if (
|
||||
!confirm(`Are you sure you want to delete the proxy "${proxy.name}"?`)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await invoke("delete_stored_proxy", { proxyId: proxy.id });
|
||||
setStoredProxies((prev) => prev.filter((p) => p.id !== proxy.id));
|
||||
await invoke("delete_stored_proxy", { proxyId: proxyToDelete.id });
|
||||
toast.success("Proxy deleted successfully");
|
||||
await emit("stored-proxies-changed");
|
||||
} catch (error) {
|
||||
console.error("Failed to delete proxy:", error);
|
||||
toast.error("Failed to delete proxy");
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
setProxyToDelete(null);
|
||||
}
|
||||
}, []);
|
||||
}, [proxyToDelete]);
|
||||
|
||||
const handleCreateProxy = useCallback(() => {
|
||||
setEditingProxy(null);
|
||||
@@ -118,23 +74,6 @@ export function ProxyManagementDialog({
|
||||
setShowProxyForm(true);
|
||||
}, []);
|
||||
|
||||
const handleProxySaved = useCallback((savedProxy: StoredProxy) => {
|
||||
setStoredProxies((prev) => {
|
||||
const existingIndex = prev.findIndex((p) => p.id === savedProxy.id);
|
||||
if (existingIndex >= 0) {
|
||||
// Update existing proxy
|
||||
const updated = [...prev];
|
||||
updated[existingIndex] = savedProxy;
|
||||
return updated;
|
||||
} else {
|
||||
// Add new proxy
|
||||
return [...prev, savedProxy];
|
||||
}
|
||||
});
|
||||
setShowProxyForm(false);
|
||||
setEditingProxy(null);
|
||||
}, []);
|
||||
|
||||
const handleProxyFormClose = useCallback(() => {
|
||||
setShowProxyForm(false);
|
||||
setEditingProxy(null);
|
||||
@@ -172,13 +111,12 @@ export function ProxyManagementDialog({
|
||||
|
||||
{/* Proxy List - Scrollable */}
|
||||
<div className="flex-1 min-h-0">
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-32">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading proxies...
|
||||
</p>
|
||||
{isLoading && (
|
||||
<div className="flex justify-center items-center py-6">
|
||||
<div className="w-8 h-8 rounded-full border-b-2 animate-spin border-primary"></div>
|
||||
</div>
|
||||
) : storedProxies.length === 0 ? (
|
||||
)}
|
||||
{storedProxies.length === 0 && !isLoading ? (
|
||||
<div className="flex flex-col justify-center items-center h-32 text-center">
|
||||
<FiWifi className="mx-auto mb-4 w-12 h-12 text-muted-foreground" />
|
||||
<p className="mb-2 text-muted-foreground">
|
||||
@@ -193,71 +131,84 @@ export function ProxyManagementDialog({
|
||||
</RippleButton>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-y-auto pr-2 space-y-2 h-full">
|
||||
{storedProxies.map((proxy) => (
|
||||
<div
|
||||
key={proxy.id}
|
||||
className="flex justify-between items-center p-1 rounded border bg-card"
|
||||
>
|
||||
<div className="flex-1 ml-2 min-w-0">
|
||||
{proxy.name.length > 30 ? (
|
||||
<ScrollArea className="h-[240px] pr-2">
|
||||
<div className="space-y-2">
|
||||
{storedProxies.map((proxy) => (
|
||||
<div
|
||||
key={proxy.id}
|
||||
className="flex justify-between items-center p-1 rounded border bg-card"
|
||||
>
|
||||
<div className="flex-1 ml-2 min-w-0">
|
||||
{proxy.name.length > 30 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block font-medium truncate text-card-foreground">
|
||||
{trimName(proxy.name)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="text-sm font-medium text-card-foreground">
|
||||
{proxy.name}
|
||||
</span>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-card-foreground">
|
||||
{proxy.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mr-2">
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 gap-1 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="block font-medium truncate text-card-foreground">
|
||||
{trimName(proxy.name)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditProxy(proxy)}
|
||||
>
|
||||
<FiEdit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteProxy(proxy)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={(proxyUsage[proxy.id] ?? 0) > 0}
|
||||
>
|
||||
<FiTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<span className="text-sm font-medium text-card-foreground">
|
||||
{proxy.name}
|
||||
</span>
|
||||
{(proxyUsage[proxy.id] ?? 0) > 0 ? (
|
||||
<p>
|
||||
Cannot delete: in use by{" "}
|
||||
{proxyUsage[proxy.id]} profile
|
||||
{proxyUsage[proxy.id] > 1 ? "s" : ""}
|
||||
</p>
|
||||
) : (
|
||||
<p>Delete proxy</p>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-card-foreground">
|
||||
{proxy.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mr-2">
|
||||
<Badge variant="secondary">
|
||||
{proxyUsage[proxy.id] ?? 0}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex flex-shrink-0 gap-1 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditProxy(proxy)}
|
||||
>
|
||||
<FiEdit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Edit proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteProxy(proxy)}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<FiTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Delete proxy</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -271,9 +222,17 @@ export function ProxyManagementDialog({
|
||||
<ProxyFormDialog
|
||||
isOpen={showProxyForm}
|
||||
onClose={handleProxyFormClose}
|
||||
onSave={handleProxySaved}
|
||||
editingProxy={editingProxy}
|
||||
/>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={proxyToDelete !== null}
|
||||
onClose={() => setProxyToDelete(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="Delete Proxy"
|
||||
description={`This action cannot be undone. This will permanently delete the proxy "${proxyToDelete?.name ?? ""}".`}
|
||||
confirmButtonText="Delete"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
+631
-104
@@ -1,13 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import Color from "color";
|
||||
import { useTheme } from "next-themes";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { BsCamera, BsMic } from "react-icons/bs";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
ColorPicker,
|
||||
ColorPickerAlpha,
|
||||
ColorPickerEyeDropper,
|
||||
ColorPickerFormat,
|
||||
ColorPickerHue,
|
||||
ColorPickerOutput,
|
||||
ColorPickerSelection,
|
||||
} from "@/components/ui/color-picker";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -16,6 +25,11 @@ import {
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -25,18 +39,27 @@ import {
|
||||
} from "@/components/ui/select";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
import { getBrowserDisplayName } from "@/lib/browser-utils";
|
||||
import {
|
||||
dismissToast,
|
||||
showErrorToast,
|
||||
showSuccessToast,
|
||||
showUnifiedVersionUpdateToast,
|
||||
} from "@/lib/toast-utils";
|
||||
getThemeByColors,
|
||||
getThemeById,
|
||||
THEME_VARIABLES,
|
||||
THEMES,
|
||||
} from "@/lib/themes";
|
||||
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
|
||||
import { RippleButton } from "./ui/ripple";
|
||||
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
theme: string;
|
||||
custom_theme?: Record<string, string>;
|
||||
api_enabled: boolean;
|
||||
api_port: number;
|
||||
api_token?: string;
|
||||
}
|
||||
|
||||
interface CustomThemeState {
|
||||
selectedThemeId: string | null;
|
||||
colors: Record<string, string>;
|
||||
}
|
||||
|
||||
interface PermissionInfo {
|
||||
@@ -45,14 +68,7 @@ interface PermissionInfo {
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface VersionUpdateProgress {
|
||||
current_browser: string;
|
||||
total_browsers: number;
|
||||
completed_browsers: number;
|
||||
new_versions_found: number;
|
||||
browser_new_versions: number;
|
||||
status: string; // "updating", "completed", "error"
|
||||
}
|
||||
// Version update progress toasts are handled globally via useVersionUpdater
|
||||
|
||||
interface SettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -63,10 +79,22 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
const [settings, setSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
theme: "system",
|
||||
custom_theme: undefined,
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
api_token: undefined,
|
||||
});
|
||||
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
theme: "system",
|
||||
custom_theme: undefined,
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
api_token: undefined,
|
||||
});
|
||||
const [customThemeState, setCustomThemeState] = useState<CustomThemeState>({
|
||||
selectedThemeId: null,
|
||||
colors: {},
|
||||
});
|
||||
const [isDefaultBrowser, setIsDefaultBrowser] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -78,6 +106,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
const [requestingPermission, setRequestingPermission] =
|
||||
useState<PermissionType | null>(null);
|
||||
const [isMacOS, setIsMacOS] = useState(false);
|
||||
const [apiServerPort, setApiServerPort] = useState<number | null>(null);
|
||||
|
||||
const { setTheme } = useTheme();
|
||||
const {
|
||||
@@ -123,12 +152,40 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
return "Access to camera for browser applications";
|
||||
}
|
||||
}, []);
|
||||
|
||||
const loadSettings = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const appSettings = await invoke<AppSettings>("get_app_settings");
|
||||
setSettings(appSettings);
|
||||
setOriginalSettings(appSettings);
|
||||
const tokyoNightTheme = getThemeById("tokyo-night");
|
||||
if (!tokyoNightTheme) {
|
||||
throw new Error("Tokyo Night theme not found");
|
||||
}
|
||||
const merged: AppSettings = {
|
||||
...appSettings,
|
||||
custom_theme:
|
||||
appSettings.custom_theme &&
|
||||
Object.keys(appSettings.custom_theme).length > 0
|
||||
? appSettings.custom_theme
|
||||
: tokyoNightTheme.colors,
|
||||
};
|
||||
setSettings(merged);
|
||||
setOriginalSettings(merged);
|
||||
|
||||
// Initialize custom theme state
|
||||
if (merged.theme === "custom" && merged.custom_theme) {
|
||||
const matchingTheme = getThemeByColors(merged.custom_theme);
|
||||
setCustomThemeState({
|
||||
selectedThemeId: matchingTheme?.id || null,
|
||||
colors: merged.custom_theme,
|
||||
});
|
||||
} else if (merged.theme === "custom") {
|
||||
// Initialize with Tokyo Night if no custom theme exists
|
||||
setCustomThemeState({
|
||||
selectedThemeId: "tokyo-night",
|
||||
colors: tokyoNightTheme.colors,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load settings:", error);
|
||||
} finally {
|
||||
@@ -136,6 +193,20 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyCustomTheme = useCallback((vars: Record<string, string>) => {
|
||||
const root = document.documentElement;
|
||||
Object.entries(vars).forEach(([k, v]) =>
|
||||
root.style.setProperty(k, v, "important"),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const clearCustomTheme = useCallback(() => {
|
||||
const root = document.documentElement;
|
||||
THEME_VARIABLES.forEach(({ key }) =>
|
||||
root.style.removeProperty(key as string),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const loadPermissions = useCallback(async () => {
|
||||
setIsLoadingPermissions(true);
|
||||
try {
|
||||
@@ -225,31 +296,162 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
},
|
||||
[getPermissionDisplayName, requestPermission],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
await invoke("save_app_settings", { settings });
|
||||
setTheme(settings.theme);
|
||||
setOriginalSettings(settings);
|
||||
// Update settings with current custom theme state
|
||||
let settingsToSave: AppSettings = {
|
||||
...settings,
|
||||
custom_theme:
|
||||
settings.theme === "custom"
|
||||
? customThemeState.colors
|
||||
: settings.custom_theme,
|
||||
};
|
||||
|
||||
const savedSettings = await invoke<AppSettings>("save_app_settings", {
|
||||
settings: settingsToSave,
|
||||
});
|
||||
// Update settings with any generated tokens
|
||||
setSettings(savedSettings);
|
||||
settingsToSave = savedSettings;
|
||||
setTheme(settings.theme === "custom" ? "dark" : settings.theme);
|
||||
|
||||
// Apply or clear custom variables only on Save
|
||||
if (settings.theme === "custom") {
|
||||
if (
|
||||
customThemeState.colors &&
|
||||
Object.keys(customThemeState.colors).length > 0
|
||||
) {
|
||||
try {
|
||||
const root = document.documentElement;
|
||||
// Clear any previous custom vars first
|
||||
THEME_VARIABLES.forEach(({ key }) =>
|
||||
root.style.removeProperty(key as string),
|
||||
);
|
||||
Object.entries(customThemeState.colors).forEach(([k, v]) =>
|
||||
root.style.setProperty(k, v, "important"),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
const root = document.documentElement;
|
||||
THEME_VARIABLES.forEach(({ key }) =>
|
||||
root.style.removeProperty(key as string),
|
||||
);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Handle API server start/stop based on settings
|
||||
const wasApiEnabled = originalSettings.api_enabled;
|
||||
const isApiEnabled = settingsToSave.api_enabled;
|
||||
|
||||
if (isApiEnabled && !wasApiEnabled) {
|
||||
// Start API server
|
||||
try {
|
||||
const port = await invoke<number>("start_api_server", {
|
||||
port: settingsToSave.api_port,
|
||||
});
|
||||
setApiServerPort(port);
|
||||
showSuccessToast(`Local API started on port ${port}`);
|
||||
} catch (error) {
|
||||
console.error("Failed to start API server:", error);
|
||||
showErrorToast("Failed to start API server", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
// Revert the API enabled setting if start failed
|
||||
settingsToSave.api_enabled = false;
|
||||
const revertedSettings = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{ settings: settingsToSave },
|
||||
);
|
||||
setSettings(revertedSettings);
|
||||
settingsToSave = revertedSettings;
|
||||
}
|
||||
} else if (!isApiEnabled && wasApiEnabled) {
|
||||
// Stop API server
|
||||
try {
|
||||
await invoke("stop_api_server");
|
||||
setApiServerPort(null);
|
||||
showSuccessToast("Local API stopped");
|
||||
} catch (error) {
|
||||
console.error("Failed to stop API server:", error);
|
||||
showErrorToast("Failed to stop API server", {
|
||||
description:
|
||||
error instanceof Error ? error.message : "Unknown error occurred",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
setOriginalSettings(settingsToSave);
|
||||
onClose();
|
||||
} catch (error) {
|
||||
console.error("Failed to save settings:", error);
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [onClose, setTheme, settings]);
|
||||
}, [onClose, setTheme, settings, customThemeState, originalSettings]);
|
||||
|
||||
const updateSetting = useCallback(
|
||||
(key: keyof AppSettings, value: boolean | string) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value }));
|
||||
(
|
||||
key: keyof AppSettings,
|
||||
value: boolean | string | Record<string, string> | undefined,
|
||||
) => {
|
||||
setSettings((prev) => ({ ...prev, [key]: value as unknown as never }));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadApiServerStatus = useCallback(async () => {
|
||||
try {
|
||||
const port = await invoke<number | null>("get_api_server_status");
|
||||
setApiServerPort(port);
|
||||
} catch (error) {
|
||||
console.error("Failed to load API server status:", error);
|
||||
setApiServerPort(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
// Restore original theme when closing without saving
|
||||
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
|
||||
applyCustomTheme(originalSettings.custom_theme);
|
||||
} else {
|
||||
clearCustomTheme();
|
||||
}
|
||||
|
||||
// Reset custom theme state to original
|
||||
if (originalSettings.theme === "custom" && originalSettings.custom_theme) {
|
||||
const matchingTheme = getThemeByColors(originalSettings.custom_theme);
|
||||
setCustomThemeState({
|
||||
selectedThemeId: matchingTheme?.id || null,
|
||||
colors: originalSettings.custom_theme,
|
||||
});
|
||||
}
|
||||
|
||||
onClose();
|
||||
}, [
|
||||
originalSettings.theme,
|
||||
originalSettings.custom_theme,
|
||||
applyCustomTheme,
|
||||
clearCustomTheme,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
// Only clear custom theme when switching away from custom, don't apply live changes
|
||||
useEffect(() => {
|
||||
if (settings.theme !== "custom") {
|
||||
clearCustomTheme();
|
||||
}
|
||||
}, [settings.theme, clearCustomTheme]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSettings().catch(console.error);
|
||||
checkDefaultBrowserStatus().catch(console.error);
|
||||
loadApiServerStatus().catch(console.error);
|
||||
|
||||
// Check if we're on macOS
|
||||
const userAgent = navigator.userAgent;
|
||||
@@ -265,86 +467,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
checkDefaultBrowserStatus().catch(console.error);
|
||||
}, 500); // Check every 500ms
|
||||
|
||||
// Listen for version update progress events
|
||||
let unlistenFn: (() => void) | null = null;
|
||||
const setupVersionUpdateListener = async () => {
|
||||
try {
|
||||
unlistenFn = await listen<VersionUpdateProgress>(
|
||||
"version-update-progress",
|
||||
(event) => {
|
||||
const progress = event.payload;
|
||||
|
||||
if (progress.status === "updating") {
|
||||
// Show unified progress toast
|
||||
const currentBrowserName = progress.current_browser
|
||||
? getBrowserDisplayName(progress.current_browser)
|
||||
: undefined;
|
||||
|
||||
showUnifiedVersionUpdateToast(
|
||||
"Checking for browser updates...",
|
||||
{
|
||||
description: currentBrowserName
|
||||
? `Fetching ${currentBrowserName} release information...`
|
||||
: "Initializing version check...",
|
||||
progress: {
|
||||
current: progress.completed_browsers,
|
||||
total: progress.total_browsers,
|
||||
found: progress.new_versions_found,
|
||||
current_browser: currentBrowserName,
|
||||
},
|
||||
},
|
||||
);
|
||||
} else if (progress.status === "completed") {
|
||||
dismissToast("unified-version-update");
|
||||
|
||||
if (progress.new_versions_found > 0) {
|
||||
showSuccessToast("Browser versions updated successfully", {
|
||||
duration: 5000,
|
||||
description:
|
||||
"Auto-downloads will start shortly for available updates.",
|
||||
});
|
||||
} else {
|
||||
showSuccessToast("No new browser versions found", {
|
||||
duration: 3000,
|
||||
description: "All browser versions are up to date",
|
||||
});
|
||||
}
|
||||
} else if (progress.status === "error") {
|
||||
dismissToast("unified-version-update");
|
||||
|
||||
showErrorToast("Failed to update browser versions", {
|
||||
duration: 6000,
|
||||
description: "Check your internet connection and try again",
|
||||
});
|
||||
}
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to setup version update progress listener:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
setupVersionUpdateListener();
|
||||
|
||||
// Cleanup interval and listener on component unmount or dialog close
|
||||
// Cleanup interval on component unmount or dialog close
|
||||
return () => {
|
||||
clearInterval(intervalId);
|
||||
if (unlistenFn) {
|
||||
try {
|
||||
unlistenFn();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Failed to cleanup version update progress listener:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}, [isOpen, loadPermissions, checkDefaultBrowserStatus, loadSettings]);
|
||||
}, [
|
||||
isOpen,
|
||||
loadPermissions,
|
||||
checkDefaultBrowserStatus,
|
||||
loadSettings,
|
||||
loadApiServerStatus,
|
||||
]);
|
||||
|
||||
// Update permissions when the permission states change
|
||||
useEffect(() => {
|
||||
@@ -373,10 +507,18 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
]);
|
||||
|
||||
// Check if settings have changed (excluding default browser setting)
|
||||
const hasChanges = settings.theme !== originalSettings.theme;
|
||||
const hasChanges =
|
||||
settings.theme !== originalSettings.theme ||
|
||||
settings.api_enabled !== originalSettings.api_enabled ||
|
||||
(settings.theme === "custom" &&
|
||||
JSON.stringify(customThemeState.colors) !==
|
||||
JSON.stringify(originalSettings.custom_theme ?? {})) ||
|
||||
(settings.theme !== "custom" &&
|
||||
JSON.stringify(settings.custom_theme ?? {}) !==
|
||||
JSON.stringify(originalSettings.custom_theme ?? {}));
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle>Settings</DialogTitle>
|
||||
@@ -395,6 +537,15 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
value={settings.theme}
|
||||
onValueChange={(value) => {
|
||||
updateSetting("theme", value);
|
||||
if (value === "custom") {
|
||||
const tokyoNightTheme = getThemeById("tokyo-night");
|
||||
if (tokyoNightTheme) {
|
||||
setCustomThemeState({
|
||||
selectedThemeId: "tokyo-night",
|
||||
colors: tokyoNightTheme.colors,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="theme-select">
|
||||
@@ -404,13 +555,126 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
<SelectItem value="light">Light</SelectItem>
|
||||
<SelectItem value="dark">Dark</SelectItem>
|
||||
<SelectItem value="system">System</SelectItem>
|
||||
<SelectItem value="custom">Custom</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Choose your preferred theme or follow your system settings.
|
||||
Choose your preferred theme or follow your system settings. Custom
|
||||
theme changes are applied only when you save.
|
||||
</p>
|
||||
|
||||
{settings.theme === "custom" && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
htmlFor="theme-preset-select"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
Theme Preset
|
||||
</Label>
|
||||
<Select
|
||||
value={customThemeState.selectedThemeId || "custom"}
|
||||
onValueChange={(value) => {
|
||||
if (value === "custom") {
|
||||
setCustomThemeState((prev) => ({
|
||||
...prev,
|
||||
selectedThemeId: null,
|
||||
}));
|
||||
} else {
|
||||
const theme = getThemeById(value);
|
||||
if (theme) {
|
||||
setCustomThemeState({
|
||||
selectedThemeId: value,
|
||||
colors: theme.colors,
|
||||
});
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger id="theme-preset-select">
|
||||
<SelectValue placeholder="Select a theme preset" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{THEMES.map((theme) => (
|
||||
<SelectItem key={theme.id} value={theme.id}>
|
||||
{theme.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
<SelectItem value="custom">Your Own</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-medium">Custom Colors</div>
|
||||
<div className="grid grid-cols-4 gap-3">
|
||||
{THEME_VARIABLES.map(({ key, label }) => {
|
||||
const colorValue =
|
||||
customThemeState.colors[key] || "#000000";
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className="flex flex-col gap-1 items-center"
|
||||
>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
className="w-8 h-8 rounded-md border shadow-sm cursor-pointer"
|
||||
style={{ backgroundColor: colorValue }}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[320px] p-3"
|
||||
sideOffset={6}
|
||||
>
|
||||
<ColorPicker
|
||||
className="p-3 rounded-md border shadow-sm bg-background"
|
||||
value={colorValue}
|
||||
onColorChange={([r, g, b, a]) => {
|
||||
const next = Color({ r, g, b }).alpha(a);
|
||||
const nextStr = next.hexa();
|
||||
const newColors = {
|
||||
...customThemeState.colors,
|
||||
[key]: nextStr,
|
||||
};
|
||||
|
||||
// Check if colors match any preset theme
|
||||
const matchingTheme =
|
||||
getThemeByColors(newColors);
|
||||
|
||||
setCustomThemeState({
|
||||
selectedThemeId: matchingTheme?.id || null,
|
||||
colors: newColors,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<ColorPickerSelection className="h-36 rounded" />
|
||||
<div className="flex gap-3 items-center mt-3">
|
||||
<ColorPickerEyeDropper />
|
||||
<div className="grid gap-1 w-full">
|
||||
<ColorPickerHue />
|
||||
<ColorPickerAlpha />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center mt-3">
|
||||
<ColorPickerOutput />
|
||||
<ColorPickerFormat />
|
||||
</div>
|
||||
</ColorPicker>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="text-[10px] text-muted-foreground text-center leading-tight">
|
||||
{label}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Default Browser Section */}
|
||||
@@ -505,6 +769,269 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Local API Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Local API</Label>
|
||||
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="api-enabled"
|
||||
checked={settings.api_enabled}
|
||||
onCheckedChange={async (checked: boolean) => {
|
||||
updateSetting("api_enabled", checked);
|
||||
try {
|
||||
if (checked) {
|
||||
// Ask backend to enable API and return settings with token
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{
|
||||
settings: { ...settings, api_enabled: true },
|
||||
},
|
||||
);
|
||||
setSettings(next);
|
||||
} else {
|
||||
const next = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{
|
||||
settings: {
|
||||
...settings,
|
||||
api_enabled: false,
|
||||
api_token: null,
|
||||
},
|
||||
},
|
||||
);
|
||||
setSettings(next);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Failed to toggle API:", e);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="grid gap-1.5 leading-none">
|
||||
<Label
|
||||
htmlFor="api-enabled"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
(ALPHA) Enable Local API Server
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow managing the application data externally via REST API.
|
||||
Server will start on port 10108 or a random port if
|
||||
unavailable.
|
||||
{apiServerPort && (
|
||||
<span className="ml-1 font-medium text-green-600">
|
||||
(Currently running on port {apiServerPort})
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{settings.api_enabled && settings.api_token && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium">
|
||||
API Authentication Token
|
||||
</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="text"
|
||||
value={settings.api_token}
|
||||
readOnly
|
||||
className="flex-1 px-3 py-2 font-mono text-sm rounded-md border bg-muted"
|
||||
/>
|
||||
<RippleButton
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(settings.api_token || "");
|
||||
showSuccessToast("API token copied to clipboard");
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</RippleButton>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Include this token in the Authorization header as "Bearer{" "}
|
||||
{settings.api_token}" for all API requests.
|
||||
</p>
|
||||
{/* Temporary in-app API docs */}
|
||||
<div className="p-3 mt-3 space-y-2 text-xs leading-relaxed rounded-md border bg-muted/40">
|
||||
<div className="font-medium">
|
||||
Temporary in-app API docs (alpha)
|
||||
</div>
|
||||
<div>
|
||||
<div>
|
||||
Base URL:{" "}
|
||||
<code className="font-mono">{`http://127.0.0.1:${apiServerPort ?? settings.api_port ?? 10108}/v1`}</code>
|
||||
</div>
|
||||
<div>
|
||||
Auth:{" "}
|
||||
<code className="font-mono">
|
||||
Authorization: Bearer {settings.api_token}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Profiles</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5">
|
||||
<li>
|
||||
<code className="font-mono">GET /profiles</code> — list
|
||||
profiles
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
GET /profiles/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— get one
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">POST /profiles</code> —
|
||||
create
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(required: name, browser, version; optional:
|
||||
release_type, proxy_id, camoufox_config, group_id,
|
||||
tags)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
PUT /profiles/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— update
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(any of: name, version, proxy_id, camoufox_config,
|
||||
group_id, tags)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
DELETE /profiles/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— delete
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
POST /profiles/{"{"}id{"}"}/run?headless=true|false
|
||||
</code>{" "}
|
||||
— launch with remote debugging
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Groups</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5">
|
||||
<li>
|
||||
<code className="font-mono">GET /groups</code> — list
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
GET /groups/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— get one
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">POST /groups</code> — create
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(required: name)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
PUT /groups/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— rename
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(required: name)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
DELETE /groups/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— delete
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Tags</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5">
|
||||
<li>
|
||||
<code className="font-mono">GET /tags</code> — list
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Proxies</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5">
|
||||
<li>
|
||||
<code className="font-mono">GET /proxies</code> — list
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
GET /proxies/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— get one
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">POST /proxies</code> —
|
||||
create
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(required: name, proxy_settings object)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
PUT /proxies/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— update
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(optional: name, proxy_settings)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
DELETE /proxies/{"{"}id{"}"}
|
||||
</code>{" "}
|
||||
— delete
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="font-medium">Browsers</div>
|
||||
<ul className="list-disc ml-5 space-y-0.5">
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
POST /browsers/download
|
||||
</code>{" "}
|
||||
— download
|
||||
<span className="ml-1 text-muted-foreground">
|
||||
(required: browser, version)
|
||||
</span>
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
GET /browsers/{"{"}browser{"}"}/versions
|
||||
</code>{" "}
|
||||
— list versions
|
||||
</li>
|
||||
<li>
|
||||
<code className="font-mono">
|
||||
GET /browsers/{"{"}browser{"}"}/versions/{"{"}version
|
||||
{"}"}/downloaded
|
||||
</code>{" "}
|
||||
— is downloaded
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div className="text-muted-foreground">
|
||||
These docs are temporary and will be replaced with full
|
||||
documentation later.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-base font-medium">Advanced</Label>
|
||||
@@ -529,7 +1056,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex-shrink-0">
|
||||
<RippleButton variant="outline" onClick={onClose}>
|
||||
<RippleButton variant="outline" onClick={handleClose}>
|
||||
Cancel
|
||||
</RippleButton>
|
||||
<LoadingButton
|
||||
|
||||
@@ -83,7 +83,7 @@ export function SharedCamoufoxConfigForm({
|
||||
forceAdvanced = false,
|
||||
}: SharedCamoufoxConfigFormProps) {
|
||||
const [activeTab, setActiveTab] = useState(
|
||||
forceAdvanced ? "advanced" : "normal",
|
||||
forceAdvanced ? "manual" : "automatic",
|
||||
);
|
||||
const [fingerprintConfig, setFingerprintConfig] =
|
||||
useState<CamoufoxFingerprintConfig>({});
|
||||
@@ -285,7 +285,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"navigator.hardwareConcurrency",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 8"
|
||||
@@ -300,7 +300,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"navigator.maxTouchPoints",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 0"
|
||||
@@ -357,7 +357,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"screen.width",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1920"
|
||||
@@ -372,7 +372,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"screen.height",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1080"
|
||||
@@ -387,7 +387,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"screen.availWidth",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1920"
|
||||
@@ -402,7 +402,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"screen.availHeight",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1055"
|
||||
@@ -417,7 +417,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"screen.colorDepth",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 30"
|
||||
@@ -432,7 +432,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"screen.pixelDepth",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 30"
|
||||
@@ -454,7 +454,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"window.outerWidth",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1512"
|
||||
@@ -469,7 +469,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"window.outerHeight",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 886"
|
||||
@@ -484,7 +484,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"window.innerWidth",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1512"
|
||||
@@ -499,7 +499,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"window.innerHeight",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 886"
|
||||
@@ -514,7 +514,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"window.screenX",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 0"
|
||||
@@ -529,7 +529,7 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"window.screenY",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 0"
|
||||
@@ -538,6 +538,106 @@ export function SharedCamoufoxConfigForm({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Geolocation */}
|
||||
<div className="space-y-3">
|
||||
<Label>Geolocation</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="latitude">Latitude</Label>
|
||||
<Input
|
||||
id="latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={fingerprintConfig["geolocation:latitude"] || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"geolocation:latitude",
|
||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 41.0019"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="longitude">Longitude</Label>
|
||||
<Input
|
||||
id="longitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={fingerprintConfig["geolocation:longitude"] || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"geolocation:longitude",
|
||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 28.9645"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Input
|
||||
id="timezone"
|
||||
type="text"
|
||||
value={fingerprintConfig.timezone || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig("timezone", e.target.value || undefined)
|
||||
}
|
||||
placeholder="e.g., America/New_York"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Locale */}
|
||||
<div className="space-y-3">
|
||||
<Label>Locale</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="locale-language">Language</Label>
|
||||
<Input
|
||||
id="locale-language"
|
||||
value={fingerprintConfig["locale:language"] || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"locale:language",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., tr"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="locale-region">Region</Label>
|
||||
<Input
|
||||
id="locale-region"
|
||||
value={fingerprintConfig["locale:region"] || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"locale:region",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., TR"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="locale-script">Script</Label>
|
||||
<Input
|
||||
id="locale-script"
|
||||
value={fingerprintConfig["locale:script"] || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"locale:script",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., Latn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* WebGL Properties */}
|
||||
<div className="space-y-3">
|
||||
<Label>WebGL Properties</Label>
|
||||
@@ -637,106 +737,6 @@ export function SharedCamoufoxConfigForm({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Geolocation */}
|
||||
<div className="space-y-3">
|
||||
<Label>Geolocation</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="latitude">Latitude</Label>
|
||||
<Input
|
||||
id="latitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={fingerprintConfig["geolocation:latitude"] || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"geolocation:latitude",
|
||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 41.0019"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="longitude">Longitude</Label>
|
||||
<Input
|
||||
id="longitude"
|
||||
type="number"
|
||||
step="any"
|
||||
value={fingerprintConfig["geolocation:longitude"] || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"geolocation:longitude",
|
||||
e.target.value ? parseFloat(e.target.value) : undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 28.9645"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="timezone">Timezone</Label>
|
||||
<Input
|
||||
id="timezone"
|
||||
type="text"
|
||||
value={fingerprintConfig.timezone || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig("timezone", e.target.value || undefined)
|
||||
}
|
||||
placeholder="e.g., America/New_York"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Locale */}
|
||||
<div className="space-y-3">
|
||||
<Label>Locale</Label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="locale-language">Language</Label>
|
||||
<Input
|
||||
id="locale-language"
|
||||
value={fingerprintConfig["locale:language"] || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"locale:language",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., tr"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="locale-region">Region</Label>
|
||||
<Input
|
||||
id="locale-region"
|
||||
value={fingerprintConfig["locale:region"] || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"locale:region",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., TR"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="locale-script">Script</Label>
|
||||
<Input
|
||||
id="locale-script"
|
||||
value={fingerprintConfig["locale:script"] || ""}
|
||||
onChange={(e) =>
|
||||
updateFingerprintConfig(
|
||||
"locale:script",
|
||||
e.target.value || undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., Latn"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Fonts */}
|
||||
<div className="space-y-3">
|
||||
<Label>Fonts</Label>
|
||||
@@ -817,14 +817,13 @@ export function SharedCamoufoxConfigForm({
|
||||
// Advanced mode only (for editing)
|
||||
renderAdvancedForm()
|
||||
) : (
|
||||
// Normal/Advanced tabs for creation
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList className="grid grid-cols-2 w-full">
|
||||
<TabsTrigger value="normal">Normal</TabsTrigger>
|
||||
<TabsTrigger value="advanced">Advanced</TabsTrigger>
|
||||
<TabsTrigger value="automatic">Automatic</TabsTrigger>
|
||||
<TabsTrigger value="manual">Manual</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="normal" className="space-y-6">
|
||||
<TabsContent value="automatic" className="space-y-6">
|
||||
{/* Automatic Location Configuration */}
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
@@ -853,7 +852,9 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
onConfigChange(
|
||||
"screen_max_width",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1920"
|
||||
@@ -868,7 +869,9 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
onConfigChange(
|
||||
"screen_max_height",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 1080"
|
||||
@@ -883,7 +886,9 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
onConfigChange(
|
||||
"screen_min_width",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 800"
|
||||
@@ -898,7 +903,9 @@ export function SharedCamoufoxConfigForm({
|
||||
onChange={(e) =>
|
||||
onConfigChange(
|
||||
"screen_min_height",
|
||||
e.target.value ? parseInt(e.target.value) : undefined,
|
||||
e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
)
|
||||
}
|
||||
placeholder="e.g., 600"
|
||||
@@ -908,7 +915,7 @@ export function SharedCamoufoxConfigForm({
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="advanced" className="space-y-6">
|
||||
<TabsContent value="manual" className="space-y-6">
|
||||
{renderAdvancedForm()}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import { ThemeProvider } from "next-themes";
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyThemeColors, clearThemeColors } from "@/lib/themes";
|
||||
|
||||
interface AppSettings {
|
||||
set_as_default_browser: boolean;
|
||||
theme: string;
|
||||
custom_theme?: Record<string, string>;
|
||||
}
|
||||
|
||||
interface CustomThemeProviderProps {
|
||||
@@ -27,12 +29,26 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
// Lazy import to avoid pulling Tauri API on SSR
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const settings = await invoke<AppSettings>("get_app_settings");
|
||||
const themeValue = settings?.theme ?? "system";
|
||||
|
||||
if (
|
||||
settings?.theme === "light" ||
|
||||
settings?.theme === "dark" ||
|
||||
settings?.theme === "system"
|
||||
themeValue === "light" ||
|
||||
themeValue === "dark" ||
|
||||
themeValue === "system"
|
||||
) {
|
||||
setDefaultTheme(settings.theme);
|
||||
setDefaultTheme(themeValue);
|
||||
} else if (themeValue === "custom") {
|
||||
setDefaultTheme("light");
|
||||
if (
|
||||
settings.custom_theme &&
|
||||
Object.keys(settings.custom_theme).length > 0
|
||||
) {
|
||||
try {
|
||||
applyThemeColors(settings.custom_theme);
|
||||
} catch (error) {
|
||||
console.warn("Failed to apply custom theme variables:", error);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setDefaultTheme("system");
|
||||
}
|
||||
@@ -51,6 +67,29 @@ export function CustomThemeProvider({ children }: CustomThemeProviderProps) {
|
||||
void loadTheme();
|
||||
}, []);
|
||||
|
||||
// Additional effect to ensure custom theme is applied after mount
|
||||
useEffect(() => {
|
||||
if (!isLoading && _mounted) {
|
||||
const reapplyCustomTheme = async () => {
|
||||
try {
|
||||
const { invoke } = await import("@tauri-apps/api/core");
|
||||
const settings = await invoke<AppSettings>("get_app_settings");
|
||||
|
||||
if (settings?.theme === "custom" && settings.custom_theme) {
|
||||
applyThemeColors(settings.custom_theme);
|
||||
} else {
|
||||
clearThemeColors();
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn("Failed to reapply custom theme:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Apply after a short delay to ensure CSS has loaded
|
||||
setTimeout(reapplyCustomTheme, 100);
|
||||
}
|
||||
}, [isLoading, _mounted]);
|
||||
|
||||
if (isLoading) {
|
||||
// Keep UI simple during initial settings load to avoid flicker
|
||||
return null;
|
||||
|
||||
@@ -0,0 +1,517 @@
|
||||
"use client";
|
||||
|
||||
import Color from "color";
|
||||
import { Slider } from "radix-ui";
|
||||
import {
|
||||
type ComponentProps,
|
||||
createContext,
|
||||
type HTMLAttributes,
|
||||
memo,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { LuPipette } from "react-icons/lu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface ColorPickerContextValue {
|
||||
hue: number;
|
||||
saturation: number;
|
||||
lightness: number;
|
||||
alpha: number;
|
||||
mode: string;
|
||||
setHue: (hue: number) => void;
|
||||
setSaturation: (saturation: number) => void;
|
||||
setLightness: (lightness: number) => void;
|
||||
setAlpha: (alpha: number) => void;
|
||||
setMode: (mode: string) => void;
|
||||
}
|
||||
|
||||
const ColorPickerContext = createContext<ColorPickerContextValue | undefined>(
|
||||
undefined,
|
||||
);
|
||||
|
||||
export const useColorPicker = () => {
|
||||
const context = useContext(ColorPickerContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error("useColorPicker must be used within a ColorPickerProvider");
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ColorPickerProps = Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"onChange"
|
||||
> & {
|
||||
value?: Parameters<typeof Color>[0];
|
||||
defaultValue?: Parameters<typeof Color>[0];
|
||||
onColorChange?: (value: [number, number, number, number]) => void;
|
||||
};
|
||||
|
||||
export const ColorPicker = ({
|
||||
value,
|
||||
defaultValue = "#000000",
|
||||
onColorChange,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: ColorPickerProps) => {
|
||||
const selectedColor = Color(value ?? defaultValue);
|
||||
const defaultColor = Color(defaultValue);
|
||||
|
||||
const initialHue = Number.isFinite(selectedColor.hue())
|
||||
? selectedColor.hue()
|
||||
: Number.isFinite(defaultColor.hue())
|
||||
? defaultColor.hue()
|
||||
: 0;
|
||||
const initialSaturation = Number.isFinite(selectedColor.saturationl())
|
||||
? selectedColor.saturationl()
|
||||
: Number.isFinite(defaultColor.saturationl())
|
||||
? defaultColor.saturationl()
|
||||
: 100;
|
||||
const initialLightness = Number.isFinite(selectedColor.lightness())
|
||||
? selectedColor.lightness()
|
||||
: Number.isFinite(defaultColor.lightness())
|
||||
? defaultColor.lightness()
|
||||
: 50;
|
||||
const initialAlpha = Number.isFinite(selectedColor.alpha())
|
||||
? Math.round(selectedColor.alpha() * 100)
|
||||
: Math.round(defaultColor.alpha() * 100);
|
||||
|
||||
const [hue, setHue] = useState(initialHue);
|
||||
const [saturation, setSaturation] = useState(initialSaturation);
|
||||
const [lightness, setLightness] = useState(initialLightness);
|
||||
const [alpha, setAlpha] = useState(initialAlpha);
|
||||
const [mode, setMode] = useState("hex");
|
||||
const lastEmittedRef = useRef<string>(
|
||||
`${Math.round(initialHue)}|${Math.round(initialSaturation)}|${Math.round(initialLightness)}|${Math.round(initialAlpha)}`,
|
||||
);
|
||||
|
||||
// Update color when controlled value changes
|
||||
useEffect(() => {
|
||||
if (value !== undefined) {
|
||||
const c = Color(value).hsl();
|
||||
const nextHue = Number.isFinite(c.hue()) ? c.hue() : 0;
|
||||
const nextSat = Number.isFinite(c.saturationl()) ? c.saturationl() : 0;
|
||||
const nextLight = Number.isFinite(c.lightness()) ? c.lightness() : 0;
|
||||
const nextAlpha = Math.round(
|
||||
(Number.isFinite(c.alpha()) ? c.alpha() : 1) * 100,
|
||||
);
|
||||
|
||||
// Update internal state unconditionally when value prop changes
|
||||
setHue(nextHue);
|
||||
setSaturation(nextSat);
|
||||
setLightness(nextLight);
|
||||
setAlpha(nextAlpha);
|
||||
}
|
||||
}, [value]); // Remove state values from dependency array to prevent infinite loop
|
||||
|
||||
// Notify parent of changes
|
||||
useEffect(() => {
|
||||
if (onColorChange) {
|
||||
const key = `${Math.round(hue)}|${Math.round(saturation)}|${Math.round(lightness)}|${Math.round(alpha)}`;
|
||||
if (key === lastEmittedRef.current) {
|
||||
return;
|
||||
}
|
||||
lastEmittedRef.current = key;
|
||||
|
||||
const color = Color.hsl(hue, saturation, lightness).alpha(alpha / 100);
|
||||
const rgba = color.rgb().array();
|
||||
onColorChange([rgba[0], rgba[1], rgba[2], alpha / 100]);
|
||||
}
|
||||
}, [hue, saturation, lightness, alpha, onColorChange]);
|
||||
|
||||
return (
|
||||
<ColorPickerContext.Provider
|
||||
value={{
|
||||
hue,
|
||||
saturation,
|
||||
lightness,
|
||||
alpha,
|
||||
mode,
|
||||
setHue,
|
||||
setSaturation,
|
||||
setLightness,
|
||||
setAlpha,
|
||||
setMode,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("flex flex-col gap-4 size-full", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</ColorPickerContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerSelectionProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ColorPickerSelection = memo(
|
||||
({ className, ...props }: ColorPickerSelectionProps) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [positionX, setPositionX] = useState(0);
|
||||
const [positionY, setPositionY] = useState(0);
|
||||
const { hue, saturation, lightness, setSaturation, setLightness } =
|
||||
useColorPicker();
|
||||
|
||||
const backgroundGradient = useMemo(() => {
|
||||
return `linear-gradient(0deg, rgba(0,0,0,1), rgba(0,0,0,0)),
|
||||
linear-gradient(90deg, rgba(255,255,255,1), rgba(255,255,255,0)),
|
||||
hsl(${hue}, 100%, 50%)`;
|
||||
}, [hue]);
|
||||
|
||||
// Update position indicators when saturation/lightness change externally
|
||||
useEffect(() => {
|
||||
if (!isDragging) {
|
||||
const x = saturation / 100;
|
||||
const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x);
|
||||
const y = topLightness > 0 ? 1 - lightness / topLightness : 0;
|
||||
setPositionX(x);
|
||||
setPositionY(Math.max(0, Math.min(1, y)));
|
||||
}
|
||||
}, [saturation, lightness, isDragging]);
|
||||
|
||||
const handlePointerMove = useCallback(
|
||||
(event: PointerEvent) => {
|
||||
if (!(isDragging && containerRef.current)) {
|
||||
return;
|
||||
}
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const x = Math.max(
|
||||
0,
|
||||
Math.min(1, (event.clientX - rect.left) / rect.width),
|
||||
);
|
||||
const y = Math.max(
|
||||
0,
|
||||
Math.min(1, (event.clientY - rect.top) / rect.height),
|
||||
);
|
||||
setPositionX(x);
|
||||
setPositionY(y);
|
||||
setSaturation(x * 100);
|
||||
const topLightness = x < 0.01 ? 100 : 50 + 50 * (1 - x);
|
||||
const lightness = topLightness * (1 - y);
|
||||
|
||||
setLightness(lightness);
|
||||
},
|
||||
[isDragging, setSaturation, setLightness],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const handlePointerUp = () => setIsDragging(false);
|
||||
|
||||
if (isDragging) {
|
||||
window.addEventListener("pointermove", handlePointerMove);
|
||||
window.addEventListener("pointerup", handlePointerUp);
|
||||
}
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("pointermove", handlePointerMove);
|
||||
window.removeEventListener("pointerup", handlePointerUp);
|
||||
};
|
||||
}, [isDragging, handlePointerMove]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn("relative rounded cursor-pointer size-full", className)}
|
||||
onPointerDown={(e) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
handlePointerMove(e.nativeEvent);
|
||||
}}
|
||||
ref={containerRef}
|
||||
style={{
|
||||
background: backgroundGradient,
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<div
|
||||
className="absolute w-4 h-4 rounded-full border-2 border-white -translate-x-1/2 -translate-y-1/2 pointer-events-none"
|
||||
style={{
|
||||
left: `${positionX * 100}%`,
|
||||
top: `${positionY * 100}%`,
|
||||
boxShadow: "0 0 0 1px rgba(0,0,0,0.5)",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ColorPickerSelection.displayName = "ColorPickerSelection";
|
||||
|
||||
export type ColorPickerHueProps = ComponentProps<typeof Slider.Root>;
|
||||
|
||||
export const ColorPickerHue = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerHueProps) => {
|
||||
const { hue, setHue } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Slider.Root
|
||||
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||
max={360}
|
||||
onValueChange={([hue]) => setHue(hue)}
|
||||
step={1}
|
||||
value={[hue]}
|
||||
{...props}
|
||||
>
|
||||
<Slider.Track className="relative my-0.5 h-3 w-full grow rounded-full bg-[linear-gradient(90deg,#FF0000,#FFFF00,#00FF00,#00FFFF,#0000FF,#FF00FF,#FF0000)]">
|
||||
<Slider.Range className="absolute h-full" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</Slider.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerAlphaProps = ComponentProps<typeof Slider.Root>;
|
||||
|
||||
export const ColorPickerAlpha = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerAlphaProps) => {
|
||||
const { alpha, setAlpha } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Slider.Root
|
||||
className={cn("flex relative w-full h-4 touch-none", className)}
|
||||
max={100}
|
||||
onValueChange={([alpha]) => setAlpha(alpha)}
|
||||
step={1}
|
||||
value={[alpha]}
|
||||
{...props}
|
||||
>
|
||||
<Slider.Track
|
||||
className="relative my-0.5 h-3 w-full grow rounded-full"
|
||||
style={{
|
||||
background:
|
||||
'url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAMUlEQVQ4T2NkYGAQYcAP3uCTZhw1gGGYhAGBZIA/nYDCgBDAm9BGDWAAJyRCgLaBCAAgXwixzAS0pgAAAABJRU5ErkJggg==") left center',
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent rounded-full to-black/50" />
|
||||
<Slider.Range className="absolute h-full bg-transparent rounded-full" />
|
||||
</Slider.Track>
|
||||
<Slider.Thumb className="block w-4 h-4 rounded-full border shadow transition-colors border-primary/50 bg-background focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
|
||||
</Slider.Root>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerEyeDropperProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ColorPickerEyeDropper = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerEyeDropperProps) => {
|
||||
const { setHue, setSaturation, setLightness, setAlpha } = useColorPicker();
|
||||
|
||||
const handleEyeDropper = async () => {
|
||||
try {
|
||||
// @ts-expect-error - EyeDropper API is experimental
|
||||
const eyeDropper = new EyeDropper();
|
||||
const result = await eyeDropper.open();
|
||||
const color = Color(result.sRGBHex);
|
||||
const [h, s, l] = color.hsl().array();
|
||||
|
||||
setHue(h);
|
||||
setSaturation(s);
|
||||
setLightness(l);
|
||||
setAlpha(100);
|
||||
} catch (error) {
|
||||
console.error("EyeDropper failed:", error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn("shrink-0 text-muted-foreground", className)}
|
||||
onClick={handleEyeDropper}
|
||||
size="icon"
|
||||
variant="outline"
|
||||
type="button"
|
||||
{...props}
|
||||
>
|
||||
<LuPipette size={16} />
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerOutputProps = ComponentProps<typeof SelectTrigger>;
|
||||
|
||||
const formats = ["hex", "rgb", "css", "hsl"];
|
||||
|
||||
export const ColorPickerOutput = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerOutputProps) => {
|
||||
const { mode, setMode } = useColorPicker();
|
||||
|
||||
return (
|
||||
<Select onValueChange={setMode} value={mode}>
|
||||
<SelectTrigger className="w-20 h-8 text-xs shrink-0" {...props}>
|
||||
<SelectValue placeholder="Mode" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{formats.map((format) => (
|
||||
<SelectItem className="text-xs" key={format} value={format}>
|
||||
{format.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
);
|
||||
};
|
||||
|
||||
type PercentageInputProps = ComponentProps<typeof Input>;
|
||||
|
||||
const PercentageInput = ({ className, ...props }: PercentageInputProps) => {
|
||||
return (
|
||||
<div className="relative">
|
||||
<Input
|
||||
readOnly
|
||||
type="text"
|
||||
{...props}
|
||||
className={cn(
|
||||
"h-8 w-[3.25rem] rounded-l-none bg-secondary px-2 text-xs shadow-none",
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
<span className="absolute right-2 top-1/2 text-xs -translate-y-1/2 text-muted-foreground">
|
||||
%
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export type ColorPickerFormatProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const ColorPickerFormat = ({
|
||||
className,
|
||||
...props
|
||||
}: ColorPickerFormatProps) => {
|
||||
const { hue, saturation, lightness, alpha, mode } = useColorPicker();
|
||||
const color = Color.hsl(hue, saturation, lightness, alpha / 100);
|
||||
|
||||
if (mode === "hex") {
|
||||
const hex = color.hex();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex relative items-center -space-x-px w-full rounded-md shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Input
|
||||
className="px-2 h-8 text-xs rounded-r-none shadow-none bg-secondary"
|
||||
readOnly
|
||||
type="text"
|
||||
value={hex}
|
||||
/>
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "rgb") {
|
||||
const rgb = color
|
||||
.rgb()
|
||||
.array()
|
||||
.map((value) => Math.round(value));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center -space-x-px rounded-md shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{rgb.map((value, index) => (
|
||||
<Input
|
||||
className={cn(
|
||||
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
|
||||
index && "rounded-l-none",
|
||||
className,
|
||||
)}
|
||||
key={`rgb-${value.toString()}`}
|
||||
readOnly
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "css") {
|
||||
const rgb = color
|
||||
.rgb()
|
||||
.array()
|
||||
.map((value) => Math.round(value));
|
||||
|
||||
return (
|
||||
<div className={cn("w-full rounded-md shadow-sm", className)} {...props}>
|
||||
<Input
|
||||
className="px-2 w-full h-8 text-xs shadow-none bg-secondary"
|
||||
readOnly
|
||||
type="text"
|
||||
value={`rgba(${rgb.join(", ")}, ${alpha}%)`}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (mode === "hsl") {
|
||||
const hsl = color
|
||||
.hsl()
|
||||
.array()
|
||||
.map((value) => Math.round(value));
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-center -space-x-px rounded-md shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{hsl.map((value, index) => (
|
||||
<Input
|
||||
className={cn(
|
||||
"h-8 rounded-r-none bg-secondary px-2 text-xs shadow-none",
|
||||
index && "rounded-l-none",
|
||||
className,
|
||||
)}
|
||||
key={`hsl-${value.toString()}`}
|
||||
readOnly
|
||||
type="text"
|
||||
value={value}
|
||||
/>
|
||||
))}
|
||||
<PercentageInput value={alpha} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -39,7 +39,7 @@ function DialogOverlay({
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[9999] bg-black/50",
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[9999] bg-background/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -6,10 +6,7 @@ import { cn } from "@/lib/utils";
|
||||
|
||||
function Table({ className, ...props }: React.ComponentProps<"table">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="table-container"
|
||||
className="overflow-x-auto relative w-full"
|
||||
>
|
||||
<div data-slot="table-container" className="overflow-visible w-full">
|
||||
<table
|
||||
data-slot="table"
|
||||
className={cn("w-full text-sm caption-bottom", className)}
|
||||
@@ -70,7 +67,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap",
|
||||
"px-2 h-10 font-medium text-left align-middle whitespace-nowrap text-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -41,7 +41,7 @@ export function WindowDragArea() {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[9999] select-none"
|
||||
className="fixed top-0 right-0 left-0 h-10 bg-transparent border-0 z-[999999] select-none"
|
||||
data-window-drag-area="true"
|
||||
onPointerDown={handlePointerDown}
|
||||
onContextMenu={(e) => {
|
||||
|
||||
@@ -35,7 +35,7 @@ export function useBrowserState(
|
||||
(browserType: string): boolean => {
|
||||
if (!isClient) return false;
|
||||
return profiles.some(
|
||||
(p) => p.browser === browserType && runningProfiles.has(p.name),
|
||||
(p) => p.browser === browserType && runningProfiles.has(p.id),
|
||||
);
|
||||
},
|
||||
[profiles, runningProfiles, isClient],
|
||||
@@ -48,10 +48,10 @@ export function useBrowserState(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!isClient) return false;
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
|
||||
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
// If the profile is launching or stopping, disable the button
|
||||
if (isLaunching || isStopping) {
|
||||
@@ -92,10 +92,10 @@ export function useBrowserState(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!isClient) return false;
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
|
||||
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
// If this specific browser is updating, downloading, launching, or stopping, block it
|
||||
if (isBrowserUpdating || isLaunching || isStopping) {
|
||||
@@ -105,7 +105,7 @@ export function useBrowserState(
|
||||
// For single-instance browsers (Tor and Mullvad)
|
||||
if (isSingleInstanceBrowser(profile.browser)) {
|
||||
const runningInstancesOfType = profiles.filter(
|
||||
(p) => p.browser === profile.browser && runningProfiles.has(p.name),
|
||||
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
|
||||
);
|
||||
|
||||
// If no instances are running, any profile of this type can be used
|
||||
@@ -142,10 +142,10 @@ export function useBrowserState(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!isClient) return false;
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
|
||||
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
// If profile is running, launching, stopping, or browser is updating, block selection
|
||||
if (isRunning || isLaunching || isStopping || isBrowserUpdating) {
|
||||
@@ -170,10 +170,10 @@ export function useBrowserState(
|
||||
(profile: BrowserProfile): string => {
|
||||
if (!isClient) return "Loading...";
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
|
||||
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
if (isLaunching) {
|
||||
return "Launching browser...";
|
||||
@@ -224,10 +224,10 @@ export function useBrowserState(
|
||||
|
||||
if (canUseForLinks) return null;
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isLaunching = launchingProfiles?.has(profile.name) ?? false;
|
||||
const isStopping = stoppingProfiles?.has(profile.name) ?? false;
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
const isRunning = runningProfiles.has(profile.id);
|
||||
const isLaunching = launchingProfiles.has(profile.id);
|
||||
const isStopping = stoppingProfiles.has(profile.id);
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
if (isLaunching) {
|
||||
return "Profile is currently launching. Please wait.";
|
||||
@@ -245,7 +245,7 @@ export function useBrowserState(
|
||||
const browserDisplayName =
|
||||
profile.browser === "tor-browser" ? "TOR" : "Mullvad";
|
||||
const runningInstancesOfType = profiles.filter(
|
||||
(p) => p.browser === profile.browser && runningProfiles.has(p.name),
|
||||
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
|
||||
);
|
||||
|
||||
if (runningInstancesOfType.length > 0) {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
|
||||
/**
|
||||
* Custom hook to manage group-related state and listen for backend events.
|
||||
* This hook eliminates the need for manual UI refreshes by automatically
|
||||
* updating state when the backend emits group change events.
|
||||
*/
|
||||
export function useGroupEvents() {
|
||||
const [groups, setGroups] = useState<GroupWithCount[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load groups from backend
|
||||
const loadGroups = useCallback(async () => {
|
||||
try {
|
||||
const groupsWithCounts = await invoke<GroupWithCount[]>(
|
||||
"get_groups_with_profile_counts",
|
||||
);
|
||||
setGroups(groupsWithCounts);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(`Failed to load groups: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Clear error state
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Initial load and event listeners setup
|
||||
useEffect(() => {
|
||||
let groupsUnlisten: (() => void) | undefined;
|
||||
|
||||
const setupListeners = async () => {
|
||||
try {
|
||||
// Initial load
|
||||
await loadGroups();
|
||||
|
||||
// Listen for group changes (create, delete, rename, update, etc.)
|
||||
groupsUnlisten = await listen("groups-changed", () => {
|
||||
console.log("Received groups-changed event, reloading groups");
|
||||
void loadGroups();
|
||||
});
|
||||
|
||||
// Also listen for profile changes since groups show profile counts
|
||||
const profilesUnlisten = await listen("profiles-changed", () => {
|
||||
console.log(
|
||||
"Received profiles-changed event, reloading groups for updated counts",
|
||||
);
|
||||
void loadGroups();
|
||||
});
|
||||
|
||||
// Store both listeners for cleanup
|
||||
groupsUnlisten = () => {
|
||||
groupsUnlisten?.();
|
||||
profilesUnlisten();
|
||||
};
|
||||
|
||||
console.log("Group event listeners set up successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to setup group event listeners:", err);
|
||||
setError(
|
||||
`Failed to setup group event listeners: ${JSON.stringify(err)}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void setupListeners();
|
||||
|
||||
// Cleanup listeners on unmount
|
||||
return () => {
|
||||
if (groupsUnlisten) groupsUnlisten();
|
||||
};
|
||||
}, [loadGroups]);
|
||||
|
||||
return {
|
||||
groups,
|
||||
isLoading,
|
||||
error,
|
||||
loadGroups,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { BrowserProfile, GroupWithCount } from "@/types";
|
||||
|
||||
interface UseProfileEventsReturn {
|
||||
profiles: BrowserProfile[];
|
||||
groups: GroupWithCount[];
|
||||
runningProfiles: Set<string>;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
loadProfiles: () => Promise<void>;
|
||||
loadGroups: () => Promise<void>;
|
||||
clearError: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom hook to manage profile-related state and listen for backend events.
|
||||
* This hook eliminates the need for manual UI refreshes by automatically
|
||||
* updating state when the backend emits profile change events.
|
||||
*/
|
||||
export function useProfileEvents(): UseProfileEventsReturn {
|
||||
const [profiles, setProfiles] = useState<BrowserProfile[]>([]);
|
||||
const [groups, setGroups] = useState<GroupWithCount[]>([]);
|
||||
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load profiles from backend
|
||||
const loadProfiles = useCallback(async () => {
|
||||
try {
|
||||
const profileList = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
setProfiles(profileList);
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load profiles:", err);
|
||||
setError(`Failed to load profiles: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load groups from backend
|
||||
const loadGroups = useCallback(async () => {
|
||||
try {
|
||||
const groupsWithCounts = await invoke<GroupWithCount[]>(
|
||||
"get_groups_with_profile_counts",
|
||||
);
|
||||
setGroups(groupsWithCounts);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups with counts:", err);
|
||||
setGroups([]);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Clear error state
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Initial load and event listeners setup
|
||||
useEffect(() => {
|
||||
let profilesUnlisten: (() => void) | undefined;
|
||||
let runningUnlisten: (() => void) | undefined;
|
||||
|
||||
const setupListeners = async () => {
|
||||
try {
|
||||
// Initial load
|
||||
await Promise.all([loadProfiles(), loadGroups()]);
|
||||
|
||||
// Listen for profile changes (create, delete, rename, update, etc.)
|
||||
profilesUnlisten = await listen("profiles-changed", () => {
|
||||
console.log(
|
||||
"Received profiles-changed event, reloading profiles and groups",
|
||||
);
|
||||
void loadProfiles();
|
||||
void loadGroups();
|
||||
});
|
||||
|
||||
// Listen for profile running state changes
|
||||
runningUnlisten = await listen<{ id: string; is_running: boolean }>(
|
||||
"profile-running-changed",
|
||||
(event) => {
|
||||
const { id, is_running } = event.payload;
|
||||
setRunningProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (is_running) {
|
||||
next.add(id);
|
||||
} else {
|
||||
next.delete(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
console.log("Profile event listeners set up successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to setup profile event listeners:", err);
|
||||
setError(
|
||||
`Failed to setup profile event listeners: ${JSON.stringify(err)}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void setupListeners();
|
||||
|
||||
// Cleanup listeners on unmount
|
||||
return () => {
|
||||
if (profilesUnlisten) profilesUnlisten();
|
||||
if (runningUnlisten) runningUnlisten();
|
||||
};
|
||||
}, [loadProfiles, loadGroups]);
|
||||
|
||||
// Sync profile running states periodically to ensure consistency
|
||||
useEffect(() => {
|
||||
const syncRunningStates = async () => {
|
||||
if (profiles.length === 0) return;
|
||||
|
||||
try {
|
||||
const statusChecks = profiles.map(async (profile) => {
|
||||
try {
|
||||
const isRunning = await invoke<boolean>("check_browser_status", {
|
||||
profile,
|
||||
});
|
||||
return { id: profile.id, isRunning };
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`Failed to check status for profile ${profile.name}:`,
|
||||
error,
|
||||
);
|
||||
return { id: profile.id, isRunning: false };
|
||||
}
|
||||
});
|
||||
|
||||
const statuses = await Promise.all(statusChecks);
|
||||
|
||||
setRunningProfiles((prev) => {
|
||||
const next = new Set(prev);
|
||||
let hasChanges = false;
|
||||
|
||||
statuses.forEach(({ id, isRunning }) => {
|
||||
if (isRunning && !prev.has(id)) {
|
||||
next.add(id);
|
||||
hasChanges = true;
|
||||
} else if (!isRunning && prev.has(id)) {
|
||||
next.delete(id);
|
||||
hasChanges = true;
|
||||
}
|
||||
});
|
||||
|
||||
return hasChanges ? next : prev;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to sync profile running states:", error);
|
||||
}
|
||||
};
|
||||
|
||||
// Initial sync
|
||||
void syncRunningStates();
|
||||
|
||||
// Sync every 30 seconds to catch any missed events
|
||||
const interval = setInterval(() => {
|
||||
void syncRunningStates();
|
||||
}, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [profiles]);
|
||||
|
||||
return {
|
||||
profiles,
|
||||
groups,
|
||||
runningProfiles,
|
||||
isLoading,
|
||||
error,
|
||||
loadProfiles,
|
||||
loadGroups,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { StoredProxy } from "@/types";
|
||||
|
||||
/**
|
||||
* Custom hook to manage proxy-related state and listen for backend events.
|
||||
* This hook eliminates the need for manual UI refreshes by automatically
|
||||
* updating state when the backend emits proxy change events.
|
||||
*/
|
||||
export function useProxyEvents() {
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
const [proxyUsage, setProxyUsage] = useState<Record<string, number>>({});
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Load proxy usage (how many profiles are using each proxy)
|
||||
const loadProxyUsage = useCallback(async () => {
|
||||
try {
|
||||
const profiles = await invoke<Array<{ proxy_id?: string }>>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
const counts: Record<string, number> = {};
|
||||
for (const p of profiles) {
|
||||
if (p.proxy_id) counts[p.proxy_id] = (counts[p.proxy_id] ?? 0) + 1;
|
||||
}
|
||||
setProxyUsage(counts);
|
||||
} catch (err) {
|
||||
console.error("Failed to load proxy usage:", err);
|
||||
// Don't set error for non-critical proxy usage
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load proxies from backend
|
||||
const loadProxies = useCallback(async () => {
|
||||
try {
|
||||
const stored = await invoke<StoredProxy[]>("get_stored_proxies");
|
||||
setStoredProxies(stored);
|
||||
await loadProxyUsage();
|
||||
setError(null);
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to load proxies:", err);
|
||||
setError(`Failed to load proxies: ${JSON.stringify(err)}`);
|
||||
}
|
||||
}, [loadProxyUsage]);
|
||||
|
||||
// Clear error state
|
||||
const clearError = useCallback(() => {
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
// Initial load and event listeners setup
|
||||
useEffect(() => {
|
||||
let proxiesUnlisten: (() => void) | undefined;
|
||||
let profilesUnlisten: (() => void) | undefined;
|
||||
let storedProxiesUnlisten: (() => void) | undefined;
|
||||
|
||||
const setupListeners = async () => {
|
||||
try {
|
||||
// Initial load
|
||||
await loadProxies();
|
||||
|
||||
// Listen for proxy changes (create, delete, update, start, stop, etc.)
|
||||
proxiesUnlisten = await listen("proxies-changed", () => {
|
||||
console.log("Received proxies-changed event, reloading proxies");
|
||||
void loadProxies();
|
||||
});
|
||||
|
||||
// Listen for profile changes to update proxy usage counts
|
||||
profilesUnlisten = await listen("profiles-changed", () => {
|
||||
console.log("Received profiles-changed event, reloading proxy usage");
|
||||
void loadProxyUsage();
|
||||
});
|
||||
|
||||
// Listen for profile updates to update proxy usage counts
|
||||
storedProxiesUnlisten = await listen("stored-proxies-changed", () => {
|
||||
console.log(
|
||||
"Received stored-proxies-changed event, reloading proxies",
|
||||
);
|
||||
void loadProxies();
|
||||
});
|
||||
|
||||
console.log("Proxy event listeners set up successfully");
|
||||
} catch (err) {
|
||||
console.error("Failed to setup proxy event listeners:", err);
|
||||
setError(
|
||||
`Failed to setup proxy event listeners: ${JSON.stringify(err)}`,
|
||||
);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
void setupListeners();
|
||||
|
||||
// Cleanup listeners on unmount
|
||||
return () => {
|
||||
if (proxiesUnlisten) proxiesUnlisten();
|
||||
if (profilesUnlisten) profilesUnlisten();
|
||||
if (storedProxiesUnlisten) storedProxiesUnlisten();
|
||||
};
|
||||
}, [loadProxies, loadProxyUsage]);
|
||||
|
||||
return {
|
||||
storedProxies,
|
||||
proxyUsage,
|
||||
isLoading,
|
||||
error,
|
||||
loadProxies,
|
||||
clearError,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
export interface ThemeColors extends Record<string, string> {
|
||||
"--background": string;
|
||||
"--foreground": string;
|
||||
"--card": string;
|
||||
"--card-foreground": string;
|
||||
"--popover": string;
|
||||
"--popover-foreground": string;
|
||||
"--primary": string;
|
||||
"--primary-foreground": string;
|
||||
"--secondary": string;
|
||||
"--secondary-foreground": string;
|
||||
"--muted": string;
|
||||
"--muted-foreground": string;
|
||||
"--accent": string;
|
||||
"--accent-foreground": string;
|
||||
"--destructive": string;
|
||||
"--destructive-foreground": string;
|
||||
"--border": string;
|
||||
}
|
||||
|
||||
export interface Theme {
|
||||
id: string;
|
||||
name: string;
|
||||
colors: ThemeColors;
|
||||
}
|
||||
|
||||
export const THEMES: Theme[] = [
|
||||
{
|
||||
id: "tokyo-night",
|
||||
name: "Tokyo Night",
|
||||
colors: {
|
||||
"--background": "#1a1b26",
|
||||
"--foreground": "#c0caf5",
|
||||
"--card": "#24283b",
|
||||
"--card-foreground": "#c0caf5",
|
||||
"--popover": "#24283b",
|
||||
"--popover-foreground": "#c0caf5",
|
||||
"--primary": "#7aa2f7",
|
||||
"--primary-foreground": "#1a1b26",
|
||||
"--secondary": "#2ac3de",
|
||||
"--secondary-foreground": "#1a1b26",
|
||||
"--muted": "#3b4261",
|
||||
"--muted-foreground": "#a9b1d6",
|
||||
"--accent": "#bb9af7",
|
||||
"--accent-foreground": "#1a1b26",
|
||||
"--destructive": "#f7768e",
|
||||
"--destructive-foreground": "#1a1b26",
|
||||
"--border": "#3b4261",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "dracula",
|
||||
name: "Dracula",
|
||||
colors: {
|
||||
"--background": "#282a36",
|
||||
"--foreground": "#f8f8f2",
|
||||
"--card": "#44475a",
|
||||
"--card-foreground": "#f8f8f2",
|
||||
"--popover": "#44475a",
|
||||
"--popover-foreground": "#f8f8f2",
|
||||
"--primary": "#bd93f9",
|
||||
"--primary-foreground": "#282a36",
|
||||
"--secondary": "#8be9fd",
|
||||
"--secondary-foreground": "#282a36",
|
||||
"--muted": "#6272a4",
|
||||
"--muted-foreground": "#f8f8f2",
|
||||
"--accent": "#ff79c6",
|
||||
"--accent-foreground": "#282a36",
|
||||
"--destructive": "#ff5555",
|
||||
"--destructive-foreground": "#f8f8f2",
|
||||
"--border": "#6272a4",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "matchalk",
|
||||
name: "Matchalk",
|
||||
colors: {
|
||||
"--background": "#273136",
|
||||
"--foreground": "#D1DED3",
|
||||
"--card": "#1c2427",
|
||||
"--card-foreground": "#D1DED3",
|
||||
"--popover": "#323e45",
|
||||
"--popover-foreground": "#D1DED3",
|
||||
"--primary": "#7eb08a",
|
||||
"--primary-foreground": "#273136",
|
||||
"--secondary": "#d2b48c",
|
||||
"--secondary-foreground": "#273136",
|
||||
"--muted": "#323e45",
|
||||
"--muted-foreground": "#7ea4b0",
|
||||
"--accent": "#d2b48c",
|
||||
"--accent-foreground": "#273136",
|
||||
"--destructive": "#ff819f",
|
||||
"--destructive-foreground": "#273136",
|
||||
"--border": "#304e37",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "houston",
|
||||
name: "Houston",
|
||||
colors: {
|
||||
"--background": "#17191e",
|
||||
"--foreground": "#f7f7f8",
|
||||
"--card": "#21252e",
|
||||
"--card-foreground": "#f7f7f8",
|
||||
"--popover": "#21252e",
|
||||
"--popover-foreground": "#f7f7f8",
|
||||
"--primary": "#5755d9",
|
||||
"--primary-foreground": "#f7f7f8",
|
||||
"--secondary": "#f25f4c",
|
||||
"--secondary-foreground": "#f7f7f8",
|
||||
"--muted": "#2a2e39",
|
||||
"--muted-foreground": "#9ca3af",
|
||||
"--accent": "#0ea5e9",
|
||||
"--accent-foreground": "#f7f7f8",
|
||||
"--destructive": "#ef4444",
|
||||
"--destructive-foreground": "#f7f7f8",
|
||||
"--border": "#2a2e39",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ayu-dark",
|
||||
name: "Ayu Dark",
|
||||
colors: {
|
||||
"--background": "#0a0e14",
|
||||
"--foreground": "#b3b1ad",
|
||||
"--card": "#11151c",
|
||||
"--card-foreground": "#b3b1ad",
|
||||
"--popover": "#11151c",
|
||||
"--popover-foreground": "#b3b1ad",
|
||||
"--primary": "#39bae6",
|
||||
"--primary-foreground": "#0a0e14",
|
||||
"--secondary": "#ffb454",
|
||||
"--secondary-foreground": "#0a0e14",
|
||||
"--muted": "#1f2430",
|
||||
"--muted-foreground": "#5c6773",
|
||||
"--accent": "#d2a6ff",
|
||||
"--accent-foreground": "#0a0e14",
|
||||
"--destructive": "#f07178",
|
||||
"--destructive-foreground": "#b3b1ad",
|
||||
"--border": "#1f2430",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "ayu-light",
|
||||
name: "Ayu Light",
|
||||
colors: {
|
||||
"--background": "#fafafa",
|
||||
"--foreground": "#5c6773",
|
||||
"--card": "#ffffff",
|
||||
"--card-foreground": "#5c6773",
|
||||
"--popover": "#ffffff",
|
||||
"--popover-foreground": "#5c6773",
|
||||
"--primary": "#399ee6",
|
||||
"--primary-foreground": "#fafafa",
|
||||
"--secondary": "#fa8d3e",
|
||||
"--secondary-foreground": "#fafafa",
|
||||
"--muted": "#f0f0f0",
|
||||
"--muted-foreground": "#828c99",
|
||||
"--accent": "#a37acc",
|
||||
"--accent-foreground": "#fafafa",
|
||||
"--destructive": "#f07178",
|
||||
"--destructive-foreground": "#fafafa",
|
||||
"--border": "#e7eaed",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export const THEME_VARIABLES: Array<{ key: keyof ThemeColors; label: string }> =
|
||||
[
|
||||
{ key: "--background", label: "Background" },
|
||||
{ key: "--foreground", label: "Foreground" },
|
||||
{ key: "--card", label: "Card" },
|
||||
{ key: "--card-foreground", label: "Card FG" },
|
||||
{ key: "--popover", label: "Popover" },
|
||||
{ key: "--popover-foreground", label: "Popover FG" },
|
||||
{ key: "--primary", label: "Primary" },
|
||||
{ key: "--primary-foreground", label: "Primary FG" },
|
||||
{ key: "--secondary", label: "Secondary" },
|
||||
{ key: "--secondary-foreground", label: "Secondary FG" },
|
||||
{ key: "--muted", label: "Muted" },
|
||||
{ key: "--muted-foreground", label: "Muted FG" },
|
||||
{ key: "--accent", label: "Accent" },
|
||||
{ key: "--accent-foreground", label: "Accent FG" },
|
||||
{ key: "--destructive", label: "Destructive" },
|
||||
{ key: "--destructive-foreground", label: "Destructive FG" },
|
||||
{ key: "--border", label: "Border" },
|
||||
];
|
||||
|
||||
export function getThemeById(id: string): Theme | undefined {
|
||||
return THEMES.find((theme) => theme.id === id);
|
||||
}
|
||||
|
||||
export function getThemeByColors(
|
||||
colors: Record<string, string>,
|
||||
): Theme | undefined {
|
||||
return THEMES.find((theme) => {
|
||||
return THEME_VARIABLES.every(({ key }) => {
|
||||
return theme.colors[key] === colors[key];
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function applyThemeColors(colors: Record<string, string>): void {
|
||||
const root = document.documentElement;
|
||||
Object.entries(colors).forEach(([key, value]) => {
|
||||
root.style.setProperty(key, value, "important");
|
||||
});
|
||||
}
|
||||
|
||||
export function clearThemeColors(): void {
|
||||
const root = document.documentElement;
|
||||
THEME_VARIABLES.forEach(({ key }) => {
|
||||
root.style.removeProperty(key as string);
|
||||
});
|
||||
}
|
||||
@@ -127,13 +127,13 @@
|
||||
/* Ensure Sonner toasts appear above all dialogs and remain interactive */
|
||||
.toaster,
|
||||
[data-sonner-toaster] {
|
||||
z-index: 99999 !important;
|
||||
pointer-events: auto !important;
|
||||
z-index: 99999;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
[data-sonner-toast] {
|
||||
z-index: 99999 !important;
|
||||
pointer-events: auto !important;
|
||||
z-index: 99999;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Ensure toast buttons and interactive elements work */
|
||||
@@ -141,5 +141,5 @@
|
||||
[data-sonner-toast] [role="button"],
|
||||
[data-sonner-toast] input,
|
||||
[data-sonner-toast] select {
|
||||
pointer-events: auto !important;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export interface BrowserProfile {
|
||||
release_type: string; // "stable" or "nightly"
|
||||
camoufox_config?: CamoufoxConfig; // Camoufox configuration
|
||||
group_id?: string; // Reference to profile group
|
||||
tags?: string[];
|
||||
}
|
||||
|
||||
export interface StoredProxy {
|
||||
|
||||
+1
-1
@@ -31,5 +31,5 @@
|
||||
"next-env.d.ts",
|
||||
"dist/types/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules", "nodecar"]
|
||||
"exclude": ["node_modules", "nodecar", "src-tauri/target"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user