mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-11 17:27:54 +02:00
Compare commits
83 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 416bec77bc | |||
| d3a6c568dc | |||
| 0659d11ee7 | |||
| 3175ecccf0 | |||
| 7b641e9b41 | |||
| f438621bc8 | |||
| 4fc2cb7730 | |||
| c41a5d84b2 | |||
| fda2887aef | |||
| f58b790293 | |||
| 518a02f782 | |||
| 0999a265dc | |||
| 984f529505 | |||
| 3b030df37f | |||
| 03b8cae825 | |||
| 00e486cc85 | |||
| 640185ff2e | |||
| 22fa2cfef0 | |||
| a1db587314 | |||
| 8862630a09 | |||
| 5956daeb9a | |||
| dfde9df72e | |||
| 3cbbd75618 | |||
| 8a32d73a25 | |||
| 2007080d4b | |||
| feb604ffaa | |||
| 14659180d7 | |||
| 82ebd7dc18 | |||
| 1c995e676c | |||
| e5fd63d03d | |||
| 11200dbe09 | |||
| 2bd01376db | |||
| ba36956158 | |||
| 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 |
@@ -31,19 +31,21 @@ 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
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
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
|
||||
@@ -55,7 +57,7 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
|
||||
uses: swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 #v2.8.1
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
|
||||
@@ -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@9bb69575e74019c2ad085a1860787043adf47ccb" # v2.2.4
|
||||
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@a8b93e4510b1cb03192d058ddef97e6b1de25522 #v2.10.134
|
||||
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@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.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,13 +34,15 @@ 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
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
|
||||
@@ -31,7 +31,7 @@ jobs:
|
||||
build:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [macos-latest, ubuntu-latest]
|
||||
os: [macos-latest, ubuntu-22.04]
|
||||
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
@@ -41,19 +41,21 @@ 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
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
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 +67,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 +79,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 +96,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@9bb69575e74019c2ad085a1860787043adf47ccb" # v2.2.4
|
||||
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@9bb69575e74019c2ad085a1860787043adf47ccb" # v2.2.4
|
||||
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@9bb69575e74019c2ad085a1860787043adf47ccb" # v2.2.4
|
||||
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@9bb69575e74019c2ad085a1860787043adf47ccb" # v2.2.4
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -105,18 +105,21 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -128,7 +131,7 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
|
||||
uses: swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 #v2.8.1
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
@@ -162,7 +165,7 @@ jobs:
|
||||
run: pnpm build
|
||||
|
||||
- name: Build Tauri app
|
||||
uses: tauri-apps/tauri-action@564aea5a8075c7a54c167bb0cf5b3255314a7f9d #v0.5.22
|
||||
uses: tauri-apps/tauri-action@19b93bb55601e3e373a93cfb6eb4242e45f5af20 #v0.6.0
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
@@ -174,8 +177,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@9bb69575e74019c2ad085a1860787043adf47ccb" # v2.2.4
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -104,18 +104,21 @@ jobs:
|
||||
|
||||
runs-on: ${{ matrix.platform }}
|
||||
steps:
|
||||
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 #v4.4.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 #v5.0.0
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
|
||||
with:
|
||||
node-version-file: .node-version
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
uses: dtolnay/rust-toolchain@e97e2d8cc328f1b50210efc529dca0028893a2d9 #master
|
||||
with:
|
||||
toolchain: stable
|
||||
targets: ${{ matrix.target }}
|
||||
@@ -127,7 +130,7 @@ jobs:
|
||||
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
|
||||
|
||||
- name: Rust cache
|
||||
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
|
||||
uses: swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 #v2.8.1
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
@@ -170,7 +173,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@19b93bb55601e3e373a93cfb6eb4242e45f5af20 #v0.6.0
|
||||
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@626c4bedb751ce0b7f03262ca97ddda9a076ae1c #v1.39.2
|
||||
|
||||
@@ -12,7 +12,7 @@ jobs:
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- uses: actions/stale@5bef64f19d7facfb25b37b414482c7164d639639 # v9.1.0
|
||||
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
stale-issue-message: "This issue has been inactive for 60 days. Please respond to keep it open."
|
||||
|
||||
Vendored
+7
@@ -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,6 +84,7 @@
|
||||
"libwebkit",
|
||||
"libxdo",
|
||||
"localtime",
|
||||
"lpdw",
|
||||
"lxml",
|
||||
"lzma",
|
||||
"Matchalk",
|
||||
@@ -112,6 +116,7 @@
|
||||
"peerconnection",
|
||||
"pids",
|
||||
"pixbuf",
|
||||
"pkexec",
|
||||
"pkill",
|
||||
"plasmohq",
|
||||
"platformdirs",
|
||||
@@ -134,6 +139,7 @@
|
||||
"screeninfo",
|
||||
"selectables",
|
||||
"serde",
|
||||
"SETTINGCHANGE",
|
||||
"setuptools",
|
||||
"shadcn",
|
||||
"showcursor",
|
||||
@@ -141,6 +147,7 @@
|
||||
"signon",
|
||||
"signum",
|
||||
"sklearn",
|
||||
"SMTO",
|
||||
"sonner",
|
||||
"splitn",
|
||||
"sspi",
|
||||
|
||||
@@ -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="./dist/types/routes.d.ts" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||
|
||||
@@ -21,14 +21,14 @@
|
||||
"author": "",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.3.0",
|
||||
"commander": "^14.0.0",
|
||||
"donutbrowser-camoufox-js": "^0.6.6",
|
||||
"dotenv": "^17.2.1",
|
||||
"fingerprint-generator": "^2.1.70",
|
||||
"@types/node": "^24.6.0",
|
||||
"commander": "^14.0.1",
|
||||
"donutbrowser-camoufox-js": "^0.7.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"fingerprint-generator": "^2.1.73",
|
||||
"get-port": "^7.1.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"playwright-core": "^1.54.2",
|
||||
"playwright-core": "^1.55.1",
|
||||
"proxy-chain": "^2.5.9",
|
||||
"tmp": "^0.2.5",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -431,6 +431,8 @@ export async function generateCamoufoxConfig(
|
||||
}
|
||||
}
|
||||
|
||||
launchOpts.allowAddonNewTab = true;
|
||||
|
||||
// Generate the configuration using launchOptions
|
||||
const generatedOptions = await launchOptions(launchOpts);
|
||||
|
||||
|
||||
@@ -1,18 +1,40 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { launchOptions } from "donutbrowser-camoufox-js";
|
||||
import type { LaunchOptions } from "donutbrowser-camoufox-js/dist/utils.js";
|
||||
import { type Browser, type BrowserContext, firefox } from "playwright-core";
|
||||
import tmp from "tmp";
|
||||
import { getCamoufoxConfig, saveCamoufoxConfig } from "./camoufox-storage.js";
|
||||
import { getEnvVars, parseProxyString } from "./utils.js";
|
||||
|
||||
// Set up debug logging to a file
|
||||
const LOG_DIR = path.join(tmp.tmpdir, "donutbrowser", "camoufox-logs");
|
||||
if (!fs.existsSync(LOG_DIR)) {
|
||||
fs.mkdirSync(LOG_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
function debugLog(id: string, message: string, data?: any): void {
|
||||
const logFile = path.join(LOG_DIR, `${id}.log`);
|
||||
const timestamp = new Date().toISOString();
|
||||
const logMessage = data
|
||||
? `[${timestamp}] ${message}: ${JSON.stringify(data, null, 2)}\n`
|
||||
: `[${timestamp}] ${message}\n`;
|
||||
fs.appendFileSync(logFile, logMessage);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a Camoufox browser server as a worker process
|
||||
* @param id The Camoufox configuration ID
|
||||
*/
|
||||
export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
debugLog(id, "Worker starting", { pid: process.pid });
|
||||
|
||||
// Get the Camoufox configuration
|
||||
debugLog(id, "Loading Camoufox configuration");
|
||||
const config = getCamoufoxConfig(id);
|
||||
|
||||
if (!config) {
|
||||
debugLog(id, "Configuration not found");
|
||||
console.error(
|
||||
JSON.stringify({
|
||||
error: "Configuration not found",
|
||||
@@ -22,6 +44,13 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
debugLog(id, "Configuration loaded successfully", {
|
||||
profilePath: config.profilePath,
|
||||
hasOptions: !!config.options,
|
||||
hasCustomConfig: !!config.customConfig,
|
||||
hasUrl: !!config.url,
|
||||
});
|
||||
|
||||
config.processId = process.pid;
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
@@ -37,12 +66,14 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
|
||||
// Launch browser in background - this can take time and may fail
|
||||
setImmediate(async () => {
|
||||
debugLog(id, "Starting browser launch in background");
|
||||
let browser: Browser | null = null;
|
||||
let context: BrowserContext | null = null;
|
||||
let windowCheckInterval: NodeJS.Timeout | null = null;
|
||||
|
||||
// Graceful shutdown handler with access to browser and server
|
||||
const gracefulShutdown = async () => {
|
||||
debugLog(id, "Graceful shutdown initiated");
|
||||
try {
|
||||
// Clear any intervals first
|
||||
if (windowCheckInterval) {
|
||||
@@ -76,14 +107,19 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
process.on("unhandledRejection", () => void gracefulShutdown());
|
||||
|
||||
try {
|
||||
debugLog(id, "Preparing launch options");
|
||||
// Deep clone to avoid reference sharing and ensure fresh configuration for each instance
|
||||
const camoufoxOptions: LaunchOptions = JSON.parse(
|
||||
JSON.stringify(config.options || {}),
|
||||
);
|
||||
debugLog(id, "Base options cloned", {
|
||||
hasOptions: Object.keys(camoufoxOptions).length,
|
||||
});
|
||||
|
||||
// Add profile path if provided
|
||||
if (config.profilePath) {
|
||||
camoufoxOptions.user_data_dir = config.profilePath;
|
||||
debugLog(id, "Set user_data_dir", { profilePath: config.profilePath });
|
||||
}
|
||||
|
||||
// Ensure block options are properly set
|
||||
@@ -111,52 +147,94 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
showcursor: false,
|
||||
...(camoufoxOptions.config || {}),
|
||||
};
|
||||
debugLog(id, "Set default options", {
|
||||
i_know_what_im_doing: true,
|
||||
disableTheming: true,
|
||||
showcursor: false,
|
||||
});
|
||||
|
||||
// Generate fresh options for this specific instance
|
||||
debugLog(id, "Generating launch options via launchOptions function");
|
||||
const generatedOptions = await launchOptions(camoufoxOptions);
|
||||
debugLog(id, "Launch options generated successfully", {
|
||||
hasEnv: !!generatedOptions.env,
|
||||
argsLength: generatedOptions.args?.length || 0,
|
||||
});
|
||||
|
||||
// Start with process environment to ensure proper inheritance
|
||||
let finalEnv = { ...process.env };
|
||||
debugLog(id, "Base environment variables set", {
|
||||
envVarCount: Object.keys(finalEnv).length,
|
||||
});
|
||||
|
||||
// Add generated options environment variables
|
||||
if (generatedOptions.env) {
|
||||
finalEnv = { ...finalEnv, ...generatedOptions.env };
|
||||
debugLog(id, "Added generated environment variables", {
|
||||
generatedEnvCount: Object.keys(generatedOptions.env).length,
|
||||
totalEnvCount: Object.keys(finalEnv).length,
|
||||
});
|
||||
}
|
||||
|
||||
// If we have a custom config from Rust, use it directly as environment variables
|
||||
if (config.customConfig) {
|
||||
debugLog(id, "Processing custom config", {
|
||||
customConfigLength: config.customConfig.length,
|
||||
});
|
||||
try {
|
||||
// Parse the custom config JSON string
|
||||
const customConfigObj = JSON.parse(config.customConfig);
|
||||
debugLog(id, "Custom config parsed successfully", {
|
||||
customConfigKeys: Object.keys(customConfigObj),
|
||||
});
|
||||
|
||||
// Ensure default config values are preserved even with custom config
|
||||
const mergedConfig = {
|
||||
...customConfigObj,
|
||||
disableTheming: true,
|
||||
showcursor: false,
|
||||
// allowAddonNewTab will be handled from the fingerprint config if present
|
||||
};
|
||||
|
||||
// Convert merged config to environment variables using getEnvVars
|
||||
const customEnvVars = getEnvVars(mergedConfig);
|
||||
debugLog(id, "Custom config converted to environment variables", {
|
||||
customEnvVarCount: Object.keys(customEnvVars).length,
|
||||
});
|
||||
|
||||
// Merge custom config with generated config (custom takes precedence)
|
||||
finalEnv = { ...finalEnv, ...customEnvVars };
|
||||
debugLog(id, "Custom config merged with final environment", {
|
||||
finalEnvCount: Object.keys(finalEnv).length,
|
||||
});
|
||||
} catch (error) {
|
||||
debugLog(id, "Failed to parse custom config", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
console.error(
|
||||
`Camoufox worker ${id}: Failed to parse custom config, using generated config:`,
|
||||
error,
|
||||
);
|
||||
await gracefulShutdown();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
debugLog(id, "No custom config provided");
|
||||
}
|
||||
// Prepare profile path for persistent context
|
||||
const profilePath = config.profilePath || "";
|
||||
debugLog(id, "Profile path prepared", { profilePath });
|
||||
|
||||
// Launch persistent context with the final configuration
|
||||
const finalOptions: any = {
|
||||
...generatedOptions,
|
||||
env: finalEnv,
|
||||
};
|
||||
debugLog(id, "Final launch options prepared", {
|
||||
hasExecutablePath: !!finalOptions.executablePath,
|
||||
hasProxy: !!camoufoxOptions.proxy,
|
||||
profilePath,
|
||||
});
|
||||
|
||||
// If a custom executable path was provided, ensure Playwright uses it
|
||||
if (
|
||||
@@ -165,46 +243,66 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
) {
|
||||
finalOptions.executablePath = (camoufoxOptions as any)
|
||||
.executable_path as string;
|
||||
debugLog(id, "Custom executable path set", {
|
||||
executablePath: finalOptions.executablePath,
|
||||
});
|
||||
}
|
||||
|
||||
// Only add proxy if it exists and is valid
|
||||
if (camoufoxOptions.proxy) {
|
||||
debugLog(id, "Processing proxy configuration", {
|
||||
proxyString: camoufoxOptions.proxy,
|
||||
});
|
||||
try {
|
||||
finalOptions.proxy = parseProxyString(camoufoxOptions.proxy);
|
||||
debugLog(id, "Proxy parsed successfully");
|
||||
} catch (error) {
|
||||
debugLog(id, "Failed to parse proxy", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
console.error({
|
||||
message: "Failed to parse proxy, launching without proxy",
|
||||
error,
|
||||
});
|
||||
await gracefulShutdown();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Use launchPersistentContext instead of launchServer
|
||||
debugLog(id, "Launching persistent context", { profilePath });
|
||||
context = await firefox.launchPersistentContext(
|
||||
profilePath,
|
||||
finalOptions,
|
||||
);
|
||||
debugLog(id, "Persistent context launched successfully");
|
||||
|
||||
// Get the browser instance from context
|
||||
browser = context.browser();
|
||||
debugLog(id, "Browser instance obtained from context", {
|
||||
browserConnected: browser?.isConnected(),
|
||||
});
|
||||
|
||||
// Handle browser disconnection for proper cleanup
|
||||
if (browser) {
|
||||
browser.on("disconnected", () => void gracefulShutdown());
|
||||
debugLog(id, "Browser disconnect handler registered");
|
||||
}
|
||||
|
||||
// Handle context close for proper cleanup
|
||||
context.on("close", () => void gracefulShutdown());
|
||||
debugLog(id, "Context close handler registered");
|
||||
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
// Monitor for window closure
|
||||
const startWindowMonitoring = () => {
|
||||
debugLog(id, "Starting window monitoring");
|
||||
windowCheckInterval = setInterval(async () => {
|
||||
try {
|
||||
// Check if context is still active
|
||||
if (!context?.pages || context.pages().length === 0) {
|
||||
debugLog(id, "No pages found in context, shutting down");
|
||||
if (windowCheckInterval) {
|
||||
clearInterval(windowCheckInterval);
|
||||
}
|
||||
@@ -214,6 +312,7 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
|
||||
// Check if browser is still connected (if available)
|
||||
if (browser && !browser.isConnected()) {
|
||||
debugLog(id, "Browser disconnected, shutting down");
|
||||
if (windowCheckInterval) {
|
||||
clearInterval(windowCheckInterval);
|
||||
}
|
||||
@@ -224,12 +323,16 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
// Check pages in the persistent context
|
||||
const pages = context.pages();
|
||||
if (pages.length === 0) {
|
||||
debugLog(id, "No pages in context, shutting down");
|
||||
if (windowCheckInterval) {
|
||||
clearInterval(windowCheckInterval);
|
||||
}
|
||||
await gracefulShutdown();
|
||||
}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
debugLog(id, "Error in window monitoring", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
// If we can't check windows, assume browser is closing
|
||||
if (windowCheckInterval) {
|
||||
clearInterval(windowCheckInterval);
|
||||
@@ -241,19 +344,29 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
|
||||
// Handle URL opening if provided
|
||||
if (config.url) {
|
||||
debugLog(id, "Opening URL in browser", { url: config.url });
|
||||
try {
|
||||
const pages = await context.pages();
|
||||
if (pages.length) {
|
||||
const page = pages[0];
|
||||
debugLog(id, "Navigating to URL");
|
||||
await page.goto(config.url, {
|
||||
waitUntil: "domcontentloaded",
|
||||
timeout: 30000,
|
||||
});
|
||||
debugLog(id, "URL opened successfully");
|
||||
|
||||
// Start monitoring after page is created
|
||||
startWindowMonitoring();
|
||||
} else {
|
||||
debugLog(id, "No pages available to open URL");
|
||||
startWindowMonitoring();
|
||||
}
|
||||
} catch (urlError) {
|
||||
debugLog(id, "Failed to open URL", {
|
||||
error:
|
||||
urlError instanceof Error ? urlError.message : String(urlError),
|
||||
});
|
||||
console.error({
|
||||
message: "Failed to open URL",
|
||||
error: urlError,
|
||||
@@ -263,15 +376,18 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
startWindowMonitoring();
|
||||
}
|
||||
} else {
|
||||
debugLog(id, "No URL provided, starting monitoring");
|
||||
// Start monitoring after page is created
|
||||
startWindowMonitoring();
|
||||
}
|
||||
|
||||
// Monitor browser/context connection
|
||||
debugLog(id, "Starting keep-alive monitoring");
|
||||
const keepAlive = setInterval(async () => {
|
||||
try {
|
||||
// Check if context is still active
|
||||
if (!context?.pages) {
|
||||
debugLog(id, "Context not active in keep-alive, shutting down");
|
||||
clearInterval(keepAlive);
|
||||
await gracefulShutdown();
|
||||
return;
|
||||
@@ -279,11 +395,15 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
|
||||
// Check browser connection if available
|
||||
if (browser && !browser.isConnected()) {
|
||||
debugLog(id, "Browser not connected in keep-alive, shutting down");
|
||||
clearInterval(keepAlive);
|
||||
await gracefulShutdown();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
debugLog(id, "Error in keep-alive check", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
console.error({
|
||||
message: "Error in keepAlive check",
|
||||
error,
|
||||
@@ -293,6 +413,9 @@ export async function runCamoufoxWorker(id: string): Promise<void> {
|
||||
}
|
||||
}, 2000);
|
||||
} catch (error) {
|
||||
debugLog(id, "Failed to launch Camoufox", {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
console.error({
|
||||
message: "Failed to launch Camoufox",
|
||||
error,
|
||||
|
||||
+20
-21
@@ -2,7 +2,7 @@
|
||||
"name": "donutbrowser",
|
||||
"private": true,
|
||||
"license": "AGPL-3.0",
|
||||
"version": "0.10.1",
|
||||
"version": "0.12.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
@@ -37,19 +37,18 @@
|
||||
"@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.3",
|
||||
"@tauri-apps/plugin-dialog": "^2.4.0",
|
||||
"@tauri-apps/plugin-fs": "~2.4.2",
|
||||
"@tauri-apps/plugin-opener": "^2.5.0",
|
||||
"ahooks": "^3.9.5",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"color": "^5.0.0",
|
||||
"lucide-react": "^0.539.0",
|
||||
"motion": "^12.23.12",
|
||||
"next": "^15.4.6",
|
||||
"color": "^5.0.2",
|
||||
"motion": "^12.23.22",
|
||||
"next": "^15.5.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"radix-ui": "^1.4.3",
|
||||
"react": "^19.1.1",
|
||||
@@ -60,19 +59,19 @@
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.1.4",
|
||||
"@tailwindcss/postcss": "^4.1.12",
|
||||
"@tauri-apps/cli": "^2.7.1",
|
||||
"@biomejs/biome": "2.2.3",
|
||||
"@tailwindcss/postcss": "^4.1.13",
|
||||
"@tauri-apps/cli": "^2.8.4",
|
||||
"@types/color": "^4.2.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@types/react": "^19.1.10",
|
||||
"@types/react-dom": "^19.1.7",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"@types/node": "^24.6.0",
|
||||
"@types/react": "^19.1.15",
|
||||
"@types/react-dom": "^19.1.9",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.5",
|
||||
"tailwindcss": "^4.1.12",
|
||||
"lint-staged": "^16.2.3",
|
||||
"tailwindcss": "^4.1.13",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.3.7",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "~5.9.2"
|
||||
},
|
||||
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748",
|
||||
|
||||
Generated
+1443
-2365
File diff suppressed because it is too large
Load Diff
@@ -1,8 +1,10 @@
|
||||
packages:
|
||||
- nodecar
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- '@biomejs/biome'
|
||||
- '@tailwindcss/oxide'
|
||||
- better-sqlite3
|
||||
- esbuild
|
||||
- sharp
|
||||
- sqlite3
|
||||
|
||||
Generated
+730
-660
File diff suppressed because it is too large
Load Diff
+10
-6
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "donutbrowser"
|
||||
version = "0.10.1"
|
||||
version = "0.12.3"
|
||||
description = "Simple Yet Powerful Anti-Detect Browser"
|
||||
authors = ["zhom@github"]
|
||||
edition = "2021"
|
||||
@@ -29,6 +29,8 @@ 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"] }
|
||||
@@ -37,20 +39,22 @@ lazy_static = "1.4"
|
||||
base64 = "0.22"
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
zip = "4"
|
||||
zip = "5"
|
||||
tar = "0"
|
||||
bzip2 = "0"
|
||||
flate2 = "1"
|
||||
lzma-rs = "0"
|
||||
msi-extract = "0"
|
||||
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
uuid = { version = "1.18", 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"] }
|
||||
@@ -62,7 +66,7 @@ objc2-app-kit = { version = "0.3.1", features = ["NSWindow"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winreg = "0.55"
|
||||
windows = { version = "0.61", features = [
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_System_ProcessStatus",
|
||||
"Win32_System_Threading",
|
||||
@@ -75,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()
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
Version=1.0
|
||||
Type=Application
|
||||
Name=Donut Browser
|
||||
Name[en]=Donut Browser
|
||||
GenericName=Web Browser
|
||||
X-GNOME-FullName=Donut Browser
|
||||
Comment=Simple Yet Powerful Anti-Detect Browser
|
||||
Exec=donutbrowser %u
|
||||
Icon=donutbrowser
|
||||
|
||||
@@ -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)
|
||||
|
||||
+177
-17
@@ -1,16 +1,19 @@
|
||||
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, State},
|
||||
http::StatusCode,
|
||||
response::Json,
|
||||
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;
|
||||
@@ -110,6 +113,19 @@ struct UpdateProxyRequest {
|
||||
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,
|
||||
@@ -118,6 +134,13 @@ pub struct ToastPayload {
|
||||
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<()>>,
|
||||
@@ -174,13 +197,14 @@ impl ApiServer {
|
||||
.map_err(|e| format!("Failed to get local address: {e}"))?
|
||||
.port();
|
||||
|
||||
// Create router with CORS
|
||||
let app = Router::new()
|
||||
// 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}",
|
||||
@@ -192,6 +216,19 @@ impl ApiServer {
|
||||
"/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);
|
||||
|
||||
@@ -225,6 +262,41 @@ impl ApiServer {
|
||||
}
|
||||
}
|
||||
|
||||
// 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()));
|
||||
@@ -283,7 +355,7 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: false, // For now, set to false - can add running status later
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
})
|
||||
.collect();
|
||||
|
||||
@@ -320,7 +392,7 @@ async fn get_profile(
|
||||
.and_then(|c| serde_json::to_value(c).ok()),
|
||||
group_id: profile.group_id.clone(),
|
||||
tags: profile.tags.clone(),
|
||||
is_running: false, // Simplified for now to avoid async complexity
|
||||
is_running: profile.process_id.is_some(), // Simple check based on process_id
|
||||
},
|
||||
}))
|
||||
} else {
|
||||
@@ -402,7 +474,7 @@ async fn create_profile(
|
||||
}
|
||||
|
||||
async fn update_profile(
|
||||
Path(name): Path<String>,
|
||||
Path(id): Path<String>,
|
||||
State(state): State<ApiServerState>,
|
||||
Json(request): Json<UpdateProfileRequest>,
|
||||
) -> Result<Json<ApiProfileResponse>, StatusCode> {
|
||||
@@ -411,7 +483,7 @@ async fn update_profile(
|
||||
// Update profile fields
|
||||
if let Some(new_name) = request.name {
|
||||
if profile_manager
|
||||
.rename_profile(&state.app_handle, &name, &new_name)
|
||||
.rename_profile(&state.app_handle, &id, &new_name)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
@@ -420,7 +492,7 @@ async fn update_profile(
|
||||
|
||||
if let Some(version) = request.version {
|
||||
if profile_manager
|
||||
.update_profile_version(&state.app_handle, &name, &version)
|
||||
.update_profile_version(&state.app_handle, &id, &version)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
@@ -429,7 +501,7 @@ async fn update_profile(
|
||||
|
||||
if let Some(proxy_id) = request.proxy_id {
|
||||
if profile_manager
|
||||
.update_profile_proxy(state.app_handle.clone(), &name, Some(proxy_id))
|
||||
.update_profile_proxy(state.app_handle.clone(), &id, Some(proxy_id))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
@@ -438,12 +510,11 @@ async fn update_profile(
|
||||
}
|
||||
|
||||
if let Some(camoufox_config) = request.camoufox_config {
|
||||
let config: Result<crate::camoufox::CamoufoxConfig, _> =
|
||||
serde_json::from_value(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(), &name, config)
|
||||
.update_camoufox_config(state.app_handle.clone(), &id, config)
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
@@ -456,7 +527,7 @@ async fn update_profile(
|
||||
|
||||
if let Some(group_id) = request.group_id {
|
||||
if profile_manager
|
||||
.assign_profiles_to_group(&state.app_handle, vec![name.clone()], Some(group_id))
|
||||
.assign_profiles_to_group(&state.app_handle, vec![id.clone()], Some(group_id))
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
@@ -465,7 +536,7 @@ async fn update_profile(
|
||||
|
||||
if let Some(tags) = request.tags {
|
||||
if profile_manager
|
||||
.update_profile_tags(&state.app_handle, &name, tags)
|
||||
.update_profile_tags(&state.app_handle, &id, tags)
|
||||
.is_err()
|
||||
{
|
||||
return Err(StatusCode::BAD_REQUEST);
|
||||
@@ -480,7 +551,7 @@ async fn update_profile(
|
||||
}
|
||||
|
||||
// Return updated profile
|
||||
get_profile(Path(name), State(state)).await
|
||||
get_profile(Path(id), State(state)).await
|
||||
}
|
||||
|
||||
async fn delete_profile(
|
||||
@@ -710,3 +781,92 @@ async fn delete_proxy(
|
||||
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?;
|
||||
|
||||
@@ -1614,14 +1614,6 @@ pub async fn check_for_app_updates_manual() -> Result<Option<AppUpdateInfo>, Str
|
||||
.map_err(|e| format!("Failed to check for app updates: {e}"))
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Clone)]
|
||||
pub struct PlatformInfo {
|
||||
pub os: String,
|
||||
pub arch: String,
|
||||
pub installation_method: String,
|
||||
pub supported_formats: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -156,8 +157,9 @@ impl AutoUpdater {
|
||||
|
||||
// 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,12 +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(
|
||||
app_handle_clone,
|
||||
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!(
|
||||
@@ -223,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
|
||||
@@ -231,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));
|
||||
|
||||
@@ -298,8 +303,8 @@ impl AutoUpdater {
|
||||
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}"))?;
|
||||
|
||||
@@ -316,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(app_handle, &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);
|
||||
}
|
||||
@@ -350,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,
|
||||
@@ -411,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 {
|
||||
@@ -458,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
|
||||
|
||||
+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]
|
||||
|
||||
+303
-877
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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,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();
|
||||
}
|
||||
|
||||
@@ -293,22 +293,22 @@ pub async fn delete_profile_group(
|
||||
#[tauri::command]
|
||||
pub async fn assign_profiles_to_group(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_names: Vec<String>,
|
||||
profile_ids: Vec<String>,
|
||||
group_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
profile_manager
|
||||
.assign_profiles_to_group(&app_handle, 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(
|
||||
app_handle: tauri::AppHandle,
|
||||
profile_names: Vec<String>,
|
||||
profile_ids: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
let profile_manager = crate::profile::ProfileManager::instance();
|
||||
profile_manager
|
||||
.delete_multiple_profiles(&app_handle, profile_names)
|
||||
.delete_multiple_profiles(&app_handle, profile_ids)
|
||||
.map_err(|e| format!("Failed to delete profiles: {e}"))
|
||||
}
|
||||
|
||||
+39
-25
@@ -14,10 +14,10 @@ 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;
|
||||
@@ -31,24 +31,38 @@ mod tag_manager;
|
||||
mod version_updater;
|
||||
|
||||
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_all_tags, get_downloaded_browser_versions,
|
||||
get_supported_browsers, is_browser_supported_on_platform, kill_browser_profile,
|
||||
launch_browser_profile, list_browser_profiles, rename_profile, update_camoufox_config,
|
||||
update_profile_proxy, update_profile_tags,
|
||||
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::{
|
||||
@@ -66,7 +80,7 @@ use group_manager::{
|
||||
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;
|
||||
|
||||
@@ -379,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");
|
||||
@@ -417,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) => {
|
||||
@@ -440,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();
|
||||
@@ -502,7 +517,7 @@ pub fn run() {
|
||||
|
||||
let runner = crate::browser_runner::BrowserRunner::instance();
|
||||
// If listing profiles fails, skip this tick
|
||||
let profiles = match runner.list_profiles() {
|
||||
let profiles = match runner.profile_manager.list_profiles() {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
println!("Warning: Failed to list profiles in status checker: {e}");
|
||||
@@ -573,7 +588,7 @@ pub fn run() {
|
||||
// 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().await {
|
||||
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...");
|
||||
@@ -658,7 +673,6 @@ pub fn run() {
|
||||
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,
|
||||
|
||||
@@ -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 {
|
||||
@@ -154,7 +167,8 @@ impl ProfileManager {
|
||||
tags: Vec::new(),
|
||||
};
|
||||
|
||||
match camoufox_launcher
|
||||
match self
|
||||
.camoufox_manager
|
||||
.generate_fingerprint_config(app_handle, &temp_profile, &config)
|
||||
.await
|
||||
{
|
||||
@@ -237,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(())
|
||||
}
|
||||
|
||||
@@ -268,7 +287,7 @@ impl ProfileManager {
|
||||
pub fn rename_profile(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
old_name: &str,
|
||||
profile_id: &str,
|
||||
new_name: &str,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
// Check if new name already exists (case insensitive)
|
||||
@@ -280,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();
|
||||
@@ -308,16 +329,18 @@ impl ProfileManager {
|
||||
pub fn delete_profile(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_name: &str,
|
||||
profile_id: &str,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Attempting to delete profile: {profile_name}");
|
||||
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() {
|
||||
@@ -338,16 +361,24 @@ 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}");
|
||||
@@ -359,15 +390,17 @@ impl ProfileManager {
|
||||
pub fn update_profile_version(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_name: &str,
|
||||
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() {
|
||||
@@ -390,12 +423,11 @@ 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)?;
|
||||
@@ -411,22 +443,24 @@ impl ProfileManager {
|
||||
pub fn assign_profiles_to_group(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_names: Vec<String>,
|
||||
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());
|
||||
}
|
||||
|
||||
@@ -450,15 +484,17 @@ impl ProfileManager {
|
||||
pub fn update_profile_tags(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_name: &str,
|
||||
profile_id: &str,
|
||||
tags: Vec<String>,
|
||||
) -> 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"))?;
|
||||
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut deduped: Vec<String> = Vec::with_capacity(tags.len());
|
||||
@@ -488,21 +524,24 @@ impl ProfileManager {
|
||||
pub fn delete_multiple_profiles(
|
||||
&self,
|
||||
app_handle: &tauri::AppHandle,
|
||||
profile_names: Vec<String>,
|
||||
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(),
|
||||
);
|
||||
@@ -528,10 +567,15 @@ impl ProfileManager {
|
||||
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()
|
||||
@@ -540,9 +584,9 @@ 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
|
||||
@@ -566,7 +610,10 @@ 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", ()) {
|
||||
@@ -579,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()
|
||||
@@ -592,9 +644,9 @@ 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()
|
||||
})?;
|
||||
|
||||
// Update proxy settings
|
||||
@@ -837,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();
|
||||
@@ -983,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
|
||||
@@ -1173,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();
|
||||
@@ -1295,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};
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -559,7 +564,7 @@ impl ProfileImporter {
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
BrowserRunner::instance().save_profile(&profile)?;
|
||||
self.profile_manager.save_profile(&profile)?;
|
||||
|
||||
println!(
|
||||
"Successfully imported profile '{}' from '{}'",
|
||||
@@ -576,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());
|
||||
|
||||
@@ -181,7 +181,10 @@ impl ProxyManager {
|
||||
// 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
|
||||
|
||||
@@ -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 {
|
||||
@@ -33,6 +36,8 @@ pub struct AppSettings {
|
||||
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 {
|
||||
@@ -51,6 +56,7 @@ impl Default for AppSettings {
|
||||
custom_theme: None,
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
api_token: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -164,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]
|
||||
@@ -206,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();
|
||||
@@ -337,6 +522,7 @@ mod tests {
|
||||
custom_theme: None,
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
api_token: None,
|
||||
};
|
||||
|
||||
// Save settings
|
||||
|
||||
@@ -25,6 +25,7 @@ impl TagManager {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper for tests to override data directory without global env var
|
||||
#[allow(dead_code)]
|
||||
pub fn with_data_dir_override(dir: &Path) -> Self {
|
||||
Self {
|
||||
@@ -100,6 +101,14 @@ impl TagManager {
|
||||
}
|
||||
}
|
||||
|
||||
#[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.10.1",
|
||||
"version": "0.12.3",
|
||||
"identifier": "com.donutbrowser",
|
||||
"build": {
|
||||
"beforeDevCommand": "pnpm dev",
|
||||
@@ -41,16 +41,12 @@
|
||||
},
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": ["xdg-utils"],
|
||||
"files": {
|
||||
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
|
||||
}
|
||||
"desktopTemplate": "donutbrowser.desktop",
|
||||
"depends": ["xdg-utils"]
|
||||
},
|
||||
"rpm": {
|
||||
"depends": ["xdg-utils"],
|
||||
"files": {
|
||||
"/usr/share/applications/donutbrowser.desktop": "donutbrowser.desktop"
|
||||
}
|
||||
"desktopTemplate": "donutbrowser.desktop",
|
||||
"depends": ["xdg-utils"]
|
||||
},
|
||||
"appimage": {
|
||||
"files": {
|
||||
|
||||
+44
-14
@@ -80,6 +80,7 @@ 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);
|
||||
@@ -378,7 +379,7 @@ export default function Home() {
|
||||
async (profile: BrowserProfile, config: CamoufoxConfig) => {
|
||||
try {
|
||||
await invoke("update_camoufox_config", {
|
||||
profileName: profile.name,
|
||||
profileId: profile.id,
|
||||
config,
|
||||
});
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
@@ -464,7 +465,7 @@ export default function Home() {
|
||||
}
|
||||
|
||||
// Attempt to delete the profile
|
||||
await invoke("delete_profile", { profileName: profile.name });
|
||||
await invoke("delete_profile", { profileId: profile.id });
|
||||
console.log("Profile deletion command completed successfully");
|
||||
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
@@ -477,9 +478,9 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const handleRenameProfile = useCallback(
|
||||
async (oldName: string, newName: string) => {
|
||||
async (profileId: string, newName: string) => {
|
||||
try {
|
||||
await invoke("rename_profile", { oldName, newName });
|
||||
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);
|
||||
@@ -507,9 +508,9 @@ export default function Home() {
|
||||
}, []);
|
||||
|
||||
const handleDeleteSelectedProfiles = useCallback(
|
||||
async (profileNames: string[]) => {
|
||||
async (profileIds: string[]) => {
|
||||
try {
|
||||
await invoke("delete_selected_profiles", { profileNames });
|
||||
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);
|
||||
@@ -521,8 +522,8 @@ export default function Home() {
|
||||
[],
|
||||
);
|
||||
|
||||
const handleAssignProfilesToGroup = useCallback((profileNames: string[]) => {
|
||||
setSelectedProfilesForGroup(profileNames);
|
||||
const handleAssignProfilesToGroup = useCallback((profileIds: string[]) => {
|
||||
setSelectedProfilesForGroup(profileIds);
|
||||
setGroupAssignmentDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -537,7 +538,7 @@ export default function Home() {
|
||||
setIsBulkDeleting(true);
|
||||
try {
|
||||
await invoke("delete_selected_profiles", {
|
||||
profileNames: selectedProfiles,
|
||||
profileIds: selectedProfiles,
|
||||
});
|
||||
// No need to manually reload - useProfileEvents will handle the update
|
||||
setSelectedProfiles([]);
|
||||
@@ -655,14 +656,39 @@ export default function Home() {
|
||||
}
|
||||
}, [isInitialized, checkAllPermissions]);
|
||||
|
||||
// Filter data by selected group
|
||||
// Filter data by selected group and search query
|
||||
const filteredProfiles = useMemo(() => {
|
||||
let filtered = profiles;
|
||||
|
||||
// Filter by group
|
||||
if (!selectedGroupId || selectedGroupId === "default") {
|
||||
return profiles.filter((profile) => !profile.group_id);
|
||||
filtered = profiles.filter((profile) => !profile.group_id);
|
||||
} else {
|
||||
filtered = profiles.filter(
|
||||
(profile) => profile.group_id === selectedGroupId,
|
||||
);
|
||||
}
|
||||
|
||||
return profiles.filter((profile) => profile.group_id === selectedGroupId);
|
||||
}, [profiles, 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;
|
||||
@@ -680,6 +706,8 @@ export default function Home() {
|
||||
onImportProfileDialogOpen={setImportProfileDialogOpen}
|
||||
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
|
||||
onSettingsDialogOpen={setSettingsDialogOpen}
|
||||
searchQuery={searchQuery}
|
||||
onSearchQueryChange={setSearchQuery}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-4 w-full">
|
||||
@@ -797,6 +825,7 @@ export default function Home() {
|
||||
}}
|
||||
selectedProfiles={selectedProfilesForGroup}
|
||||
onAssignmentComplete={handleGroupAssignmentComplete}
|
||||
profiles={profiles}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
@@ -807,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,6 +25,7 @@ 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";
|
||||
@@ -99,28 +100,43 @@ 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("firefox");
|
||||
} 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 } = useProxyEvents();
|
||||
const [showProxyForm, setShowProxyForm] = useState(false);
|
||||
@@ -227,15 +243,15 @@ export function CreateProfileDialog({
|
||||
// Load data when dialog opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// Ensure we have a selected browser
|
||||
if (!selectedBrowser) {
|
||||
setSelectedBrowser("camoufox");
|
||||
}
|
||||
void loadSupportedBrowsers();
|
||||
// Load camoufox release types when dialog opens
|
||||
void loadReleaseTypes(selectedBrowser || "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,
|
||||
@@ -297,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;
|
||||
@@ -318,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();
|
||||
@@ -355,13 +372,14 @@ export function CreateProfileDialog({
|
||||
|
||||
// Reset all states
|
||||
setProfileName("");
|
||||
setSelectedBrowser("camoufox"); // Set default browser instead of null
|
||||
setCurrentStep("browser-selection");
|
||||
setActiveTab("anti-detect");
|
||||
setSelectedBrowser(null);
|
||||
setSelectedProxyId(undefined);
|
||||
setReleaseTypes({});
|
||||
setCamoufoxConfig({
|
||||
geoip: true, // Reset to automatic geoip
|
||||
});
|
||||
setActiveTab("anti-detect");
|
||||
onClose();
|
||||
};
|
||||
|
||||
@@ -400,11 +418,23 @@ export function CreateProfileDialog({
|
||||
isBrowserVersionAvailable,
|
||||
]);
|
||||
|
||||
// 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,213 +450,428 @@ 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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -96,15 +96,15 @@ type TableMeta = {
|
||||
proxyOverrides: Record<string, string | null>;
|
||||
storedProxies: StoredProxy[];
|
||||
handleProxySelection: (
|
||||
profileName: string,
|
||||
profileId: string,
|
||||
proxyId: string | null,
|
||||
) => void | Promise<void>;
|
||||
|
||||
// Selection helpers
|
||||
isProfileSelected: (name: string) => boolean;
|
||||
isProfileSelected: (id: string) => boolean;
|
||||
handleToggleAll: (checked: boolean) => void;
|
||||
handleCheckboxChange: (name: string, checked: boolean) => void;
|
||||
handleIconClick: (name: string) => void;
|
||||
handleCheckboxChange: (id: string, checked: boolean) => void;
|
||||
handleIconClick: (id: string) => void;
|
||||
|
||||
// Rename helpers
|
||||
handleRename: () => void | Promise<void>;
|
||||
@@ -125,7 +125,7 @@ type TableMeta = {
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
|
||||
// Overflow actions
|
||||
onAssignProfilesToGroup?: (profileNames: string[]) => void;
|
||||
onAssignProfilesToGroup?: (profileIds: string[]) => void;
|
||||
onConfigureCamoufox?: (profile: BrowserProfile) => void;
|
||||
};
|
||||
|
||||
@@ -151,8 +151,8 @@ const TagsCell = React.memo<{
|
||||
setOpenTagsEditorFor,
|
||||
setTagsOverrides,
|
||||
}) => {
|
||||
const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.name)
|
||||
? tagsOverrides[profile.name]
|
||||
const effectiveTags: string[] = Object.hasOwn(tagsOverrides, profile.id)
|
||||
? tagsOverrides[profile.id]
|
||||
: (profile.tags ?? []);
|
||||
|
||||
const valueOptions: Option[] = React.useMemo(
|
||||
@@ -164,10 +164,9 @@ const TagsCell = React.memo<{
|
||||
[allTags],
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
async (opts: Option[]) => {
|
||||
const newTagsRaw = opts.map((o) => o.value);
|
||||
// Dedupe while preserving order
|
||||
const onTagsChange = React.useCallback(
|
||||
async (newTagsRaw: string[]) => {
|
||||
// Dedupe tags
|
||||
const seen = new Set<string>();
|
||||
const newTags: string[] = [];
|
||||
for (const t of newTagsRaw) {
|
||||
@@ -176,10 +175,10 @@ const TagsCell = React.memo<{
|
||||
newTags.push(t);
|
||||
}
|
||||
}
|
||||
setTagsOverrides((prev) => ({ ...prev, [profile.name]: newTags }));
|
||||
setTagsOverrides((prev) => ({ ...prev, [profile.id]: newTags }));
|
||||
try {
|
||||
await invoke<BrowserProfile>("update_profile_tags", {
|
||||
profileName: profile.name,
|
||||
profileId: profile.id,
|
||||
tags: newTags,
|
||||
});
|
||||
setAllTags((prev) => {
|
||||
@@ -191,7 +190,15 @@ const TagsCell = React.memo<{
|
||||
console.error("Failed to update tags:", error);
|
||||
}
|
||||
},
|
||||
[profile.name, setAllTags, setTagsOverrides],
|
||||
[profile.id, setTagsOverrides, setAllTags],
|
||||
);
|
||||
|
||||
const handleChange = React.useCallback(
|
||||
async (opts: Option[]) => {
|
||||
const newTagsRaw = opts.map((o) => o.value);
|
||||
await onTagsChange(newTagsRaw);
|
||||
},
|
||||
[onTagsChange],
|
||||
);
|
||||
|
||||
const containerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
@@ -202,7 +209,7 @@ const TagsCell = React.memo<{
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
// Only measure when not editing this profile's tags
|
||||
if (openTagsEditorFor === profile.name) return;
|
||||
if (openTagsEditorFor === profile.id) return;
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
@@ -253,10 +260,10 @@ const TagsCell = React.memo<{
|
||||
ro.disconnect();
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
};
|
||||
}, [effectiveTags, openTagsEditorFor, profile.name]);
|
||||
}, [effectiveTags, openTagsEditorFor, profile.id]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (openTagsEditorFor !== profile.name) return;
|
||||
if (openTagsEditorFor !== profile.id) return;
|
||||
const handleClick = (e: MouseEvent) => {
|
||||
const target = e.target as Node | null;
|
||||
if (
|
||||
@@ -269,19 +276,19 @@ const TagsCell = React.memo<{
|
||||
};
|
||||
document.addEventListener("mousedown", handleClick);
|
||||
return () => document.removeEventListener("mousedown", handleClick);
|
||||
}, [openTagsEditorFor, profile.name, setOpenTagsEditorFor]);
|
||||
}, [openTagsEditorFor, profile.id, setOpenTagsEditorFor]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (openTagsEditorFor === profile.name && editorRef.current) {
|
||||
if (openTagsEditorFor === profile.id && editorRef.current) {
|
||||
// Focus the inner input of MultipleSelector on open
|
||||
const inputEl = editorRef.current.querySelector("input");
|
||||
if (inputEl) {
|
||||
(inputEl as HTMLInputElement).focus();
|
||||
}
|
||||
}
|
||||
}, [openTagsEditorFor, profile.name]);
|
||||
}, [openTagsEditorFor, profile.id]);
|
||||
|
||||
if (openTagsEditorFor !== profile.name) {
|
||||
if (openTagsEditorFor !== profile.id) {
|
||||
const hiddenCount = Math.max(0, effectiveTags.length - visibleCount);
|
||||
const ButtonContent = (
|
||||
<button
|
||||
@@ -292,7 +299,7 @@ const TagsCell = React.memo<{
|
||||
isDisabled ? "opacity-60" : "cursor-pointer hover:bg-accent/50",
|
||||
)}
|
||||
onClick={() => {
|
||||
if (!isDisabled) setOpenTagsEditorFor(profile.name);
|
||||
if (!isDisabled) setOpenTagsEditorFor(profile.id);
|
||||
}}
|
||||
>
|
||||
{effectiveTags.slice(0, visibleCount).map((t) => (
|
||||
@@ -372,12 +379,12 @@ interface ProfilesDataTableProps {
|
||||
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onKillProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onDeleteProfile: (profile: BrowserProfile) => void | Promise<void>;
|
||||
onRenameProfile: (oldName: string, newName: string) => Promise<void>;
|
||||
onRenameProfile: (profileId: string, newName: string) => Promise<void>;
|
||||
onConfigureCamoufox: (profile: BrowserProfile) => void;
|
||||
runningProfiles: Set<string>;
|
||||
isUpdating: (browser: string) => boolean;
|
||||
onDeleteSelectedProfiles: (profileNames: string[]) => Promise<void>;
|
||||
onAssignProfilesToGroup: (profileNames: string[]) => void;
|
||||
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
|
||||
onAssignProfilesToGroup: (profileIds: string[]) => void;
|
||||
selectedGroupId: string | null;
|
||||
selectedProfiles: string[];
|
||||
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
|
||||
@@ -441,13 +448,13 @@ export function ProfilesDataTable({
|
||||
}, []);
|
||||
|
||||
const handleProxySelection = React.useCallback(
|
||||
async (profileName: string, proxyId: string | null) => {
|
||||
async (profileId: string, proxyId: string | null) => {
|
||||
try {
|
||||
await invoke("update_profile_proxy", {
|
||||
profileName,
|
||||
profileId,
|
||||
proxyId,
|
||||
});
|
||||
setProxyOverrides((prev) => ({ ...prev, [profileName]: proxyId }));
|
||||
setProxyOverrides((prev) => ({ ...prev, [profileId]: proxyId }));
|
||||
// Notify other parts of the app so usage counts and lists refresh
|
||||
await emit("profile-updated");
|
||||
} catch (error) {
|
||||
@@ -527,8 +534,8 @@ export function ProfilesDataTable({
|
||||
const newSet = new Set(selectedProfiles);
|
||||
let hasChanges = false;
|
||||
|
||||
for (const profileName of selectedProfiles) {
|
||||
const profile = profiles.find((p) => p.name === profileName);
|
||||
for (const profileId of selectedProfiles) {
|
||||
const profile = profiles.find((p) => p.id === profileId);
|
||||
if (profile) {
|
||||
const isRunning =
|
||||
browserState.isClient && runningProfiles.has(profile.id);
|
||||
@@ -537,7 +544,7 @@ export function ProfilesDataTable({
|
||||
const isBrowserUpdating = isUpdating(profile.browser);
|
||||
|
||||
if (isRunning || isLaunching || isStopping || isBrowserUpdating) {
|
||||
newSet.delete(profileName);
|
||||
newSet.delete(profileId);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
@@ -581,7 +588,7 @@ export function ProfilesDataTable({
|
||||
|
||||
try {
|
||||
setIsRenamingSaving(true);
|
||||
await onRenameProfile(profileToRename.name, newProfileName.trim());
|
||||
await onRenameProfile(profileToRename.id, newProfileName.trim());
|
||||
setProfileToRename(null);
|
||||
setNewProfileName("");
|
||||
setRenameError(null);
|
||||
@@ -631,8 +638,8 @@ export function ProfilesDataTable({
|
||||
|
||||
// Handle icon/checkbox click
|
||||
const handleIconClick = React.useCallback(
|
||||
(profileName: string) => {
|
||||
const profile = profiles.find((p) => p.name === profileName);
|
||||
(profileId: string) => {
|
||||
const profile = profiles.find((p) => p.id === profileId);
|
||||
if (!profile) return;
|
||||
|
||||
// Prevent selection of profiles whose browsers are updating
|
||||
@@ -642,10 +649,10 @@ export function ProfilesDataTable({
|
||||
|
||||
setShowCheckboxes(true);
|
||||
const newSet = new Set(selectedProfiles);
|
||||
if (newSet.has(profileName)) {
|
||||
newSet.delete(profileName);
|
||||
if (newSet.has(profileId)) {
|
||||
newSet.delete(profileId);
|
||||
} else {
|
||||
newSet.add(profileName);
|
||||
newSet.add(profileId);
|
||||
}
|
||||
|
||||
// Hide checkboxes if no profiles are selected
|
||||
@@ -671,12 +678,12 @@ export function ProfilesDataTable({
|
||||
|
||||
// Handle checkbox change
|
||||
const handleCheckboxChange = React.useCallback(
|
||||
(profileName: string, checked: boolean) => {
|
||||
(profileId: string, checked: boolean) => {
|
||||
const newSet = new Set(selectedProfiles);
|
||||
if (checked) {
|
||||
newSet.add(profileName);
|
||||
newSet.add(profileId);
|
||||
} else {
|
||||
newSet.delete(profileName);
|
||||
newSet.delete(profileId);
|
||||
}
|
||||
|
||||
// Hide checkboxes if no profiles are selected
|
||||
@@ -708,7 +715,7 @@ export function ProfilesDataTable({
|
||||
!isBrowserUpdating
|
||||
);
|
||||
})
|
||||
.map((profile) => profile.name),
|
||||
.map((profile) => profile.id),
|
||||
)
|
||||
: new Set<string>();
|
||||
|
||||
@@ -774,7 +781,7 @@ export function ProfilesDataTable({
|
||||
handleProxySelection,
|
||||
|
||||
// Selection helpers
|
||||
isProfileSelected: (name: string) => selectedProfiles.includes(name),
|
||||
isProfileSelected: (id: string) => selectedProfiles.includes(id),
|
||||
handleToggleAll,
|
||||
handleCheckboxChange,
|
||||
handleIconClick,
|
||||
@@ -857,7 +864,7 @@ export function ProfilesDataTable({
|
||||
const browser = profile.browser;
|
||||
const IconComponent = getBrowserIcon(browser);
|
||||
|
||||
const isSelected = meta.isProfileSelected(profile.name);
|
||||
const isSelected = meta.isProfileSelected(profile.id);
|
||||
const isRunning =
|
||||
meta.isClient && meta.runningProfiles.has(profile.id);
|
||||
const isLaunching = meta.launchingProfiles.has(profile.id);
|
||||
@@ -897,7 +904,7 @@ export function ProfilesDataTable({
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(value) =>
|
||||
meta.handleCheckboxChange(profile.name, !!value)
|
||||
meta.handleCheckboxChange(profile.id, !!value)
|
||||
}
|
||||
aria-label="Select row"
|
||||
className="w-4 h-4"
|
||||
@@ -911,7 +918,7 @@ export function ProfilesDataTable({
|
||||
<button
|
||||
type="button"
|
||||
className="flex justify-center items-center p-0 border-none cursor-pointer"
|
||||
onClick={() => meta.handleIconClick(profile.name)}
|
||||
onClick={() => meta.handleIconClick(profile.id)}
|
||||
aria-label="Select profile"
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
@@ -1039,7 +1046,7 @@ export function ProfilesDataTable({
|
||||
const profile = row.original as BrowserProfile;
|
||||
const rawName: string = row.getValue("name");
|
||||
const name = getBrowserDisplayName(rawName);
|
||||
const isEditing = meta.profileToRename?.name === profile.name;
|
||||
const isEditing = meta.profileToRename?.id === profile.id;
|
||||
|
||||
if (isEditing) {
|
||||
const isSaveDisabled =
|
||||
@@ -1227,9 +1234,9 @@ export function ProfilesDataTable({
|
||||
const isDisabled =
|
||||
isRunning || isLaunching || isStopping || isBrowserUpdating;
|
||||
|
||||
const hasOverride = Object.hasOwn(meta.proxyOverrides, profile.name);
|
||||
const hasOverride = Object.hasOwn(meta.proxyOverrides, profile.id);
|
||||
const effectiveProxyId = hasOverride
|
||||
? meta.proxyOverrides[profile.name]
|
||||
? meta.proxyOverrides[profile.id]
|
||||
: (profile.proxy_id ?? null);
|
||||
const effectiveProxy = effectiveProxyId
|
||||
? (meta.storedProxies.find((p) => p.id === effectiveProxyId) ??
|
||||
@@ -1248,7 +1255,7 @@ export function ProfilesDataTable({
|
||||
: profileHasProxy && effectiveProxy
|
||||
? effectiveProxy.name
|
||||
: null;
|
||||
const isSelectorOpen = meta.openProxySelectorFor === profile.name;
|
||||
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
|
||||
|
||||
if (profile.browser === "tor-browser") {
|
||||
return (
|
||||
@@ -1271,7 +1278,7 @@ export function ProfilesDataTable({
|
||||
<Popover
|
||||
open={isSelectorOpen}
|
||||
onOpenChange={(open) =>
|
||||
meta.setOpenProxySelectorFor(open ? profile.name : null)
|
||||
meta.setOpenProxySelectorFor(open ? profile.id : null)
|
||||
}
|
||||
>
|
||||
<Tooltip>
|
||||
@@ -1311,7 +1318,7 @@ export function ProfilesDataTable({
|
||||
<CommandItem
|
||||
value="__none__"
|
||||
onSelect={() =>
|
||||
void meta.handleProxySelection(profile.name, null)
|
||||
void meta.handleProxySelection(profile.id, null)
|
||||
}
|
||||
>
|
||||
<LuCheck
|
||||
@@ -1330,7 +1337,7 @@ export function ProfilesDataTable({
|
||||
value={proxy.name}
|
||||
onSelect={() =>
|
||||
void meta.handleProxySelection(
|
||||
profile.name,
|
||||
profile.id,
|
||||
proxy.id,
|
||||
)
|
||||
}
|
||||
@@ -1385,7 +1392,7 @@ export function ProfilesDataTable({
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
meta.onAssignProfilesToGroup?.([profile.name]);
|
||||
meta.onAssignProfilesToGroup?.([profile.id]);
|
||||
}}
|
||||
disabled={isDisabled}
|
||||
>
|
||||
|
||||
@@ -94,12 +94,12 @@ export function ProfileSelectorDialog({
|
||||
|
||||
setIsLaunching(true);
|
||||
const selected = profiles.find((p) => p.name === selectedProfile);
|
||||
if (selected) {
|
||||
setLaunchingProfiles((prev) => new Set(prev).add(selected.id));
|
||||
}
|
||||
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();
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -130,82 +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)}
|
||||
</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>
|
||||
<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}
|
||||
onClick={() => handleEditProxy(proxy)}
|
||||
>
|
||||
<FiTrash2 className="w-4 h-4" />
|
||||
<FiEdit2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{(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>
|
||||
</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>
|
||||
{(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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@ interface AppSettings {
|
||||
custom_theme?: Record<string, string>;
|
||||
api_enabled: boolean;
|
||||
api_port: number;
|
||||
api_token?: string;
|
||||
}
|
||||
|
||||
interface CustomThemeState {
|
||||
@@ -81,6 +82,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
custom_theme: undefined,
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
api_token: undefined,
|
||||
});
|
||||
const [originalSettings, setOriginalSettings] = useState<AppSettings>({
|
||||
set_as_default_browser: false,
|
||||
@@ -88,6 +90,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
custom_theme: undefined,
|
||||
api_enabled: false,
|
||||
api_port: 10108,
|
||||
api_token: undefined,
|
||||
});
|
||||
const [customThemeState, setCustomThemeState] = useState<CustomThemeState>({
|
||||
selectedThemeId: null,
|
||||
@@ -298,7 +301,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// Update settings with current custom theme state
|
||||
const settingsToSave = {
|
||||
let settingsToSave: AppSettings = {
|
||||
...settings,
|
||||
custom_theme:
|
||||
settings.theme === "custom"
|
||||
@@ -306,7 +309,12 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
: settings.custom_theme,
|
||||
};
|
||||
|
||||
await invoke("save_app_settings", { settings: settingsToSave });
|
||||
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
|
||||
@@ -355,7 +363,12 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
});
|
||||
// Revert the API enabled setting if start failed
|
||||
settingsToSave.api_enabled = false;
|
||||
await invoke("save_app_settings", { settings: settingsToSave });
|
||||
const revertedSettings = await invoke<AppSettings>(
|
||||
"save_app_settings",
|
||||
{ settings: settingsToSave },
|
||||
);
|
||||
setSettings(revertedSettings);
|
||||
settingsToSave = revertedSettings;
|
||||
}
|
||||
} else if (!isApiEnabled && wasApiEnabled) {
|
||||
// Stop API server
|
||||
@@ -764,8 +777,34 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
<Checkbox
|
||||
id="api-enabled"
|
||||
checked={settings.api_enabled}
|
||||
onCheckedChange={(checked: boolean) => {
|
||||
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">
|
||||
@@ -787,6 +826,210 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
</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 */}
|
||||
|
||||
@@ -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>
|
||||
@@ -808,6 +808,23 @@ export function SharedCamoufoxConfigForm({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Browser Behavior */}
|
||||
{/* <div className="space-y-3">
|
||||
<Label>Browser Behavior</Label>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="allow-addon-new-tab"
|
||||
checked={fingerprintConfig.allowAddonNewTab}
|
||||
onCheckedChange={(checked) =>
|
||||
updateFingerprintConfig("allowAddonNewTab", checked)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="allow-addon-new-tab">
|
||||
Allow browser addons to open new tabs automatically
|
||||
</Label>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -852,7 +869,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"
|
||||
@@ -867,7 +886,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"
|
||||
@@ -882,7 +903,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"
|
||||
@@ -897,7 +920,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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -87,6 +87,9 @@ export interface CamoufoxConfig {
|
||||
|
||||
// Extended interface for the advanced fingerprint configuration
|
||||
export interface CamoufoxFingerprintConfig {
|
||||
// Browser behavior
|
||||
allowAddonNewTab?: boolean;
|
||||
|
||||
// Navigator properties
|
||||
"navigator.userAgent"?: string;
|
||||
"navigator.appVersion"?: string;
|
||||
|
||||
+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