mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-26 16:39:58 +02:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| adcb20fab9 | |||
| ff9c633b07 | |||
| 7ca76b1f78 | |||
| 4887a3db4d | |||
| e38cd2e560 | |||
| 7e7b47cae3 | |||
| 13ae170166 | |||
| df78e22650 | |||
| 328e6f16ee | |||
| 40ad32af6d | |||
| f299eeaea5 | |||
| 84142caac9 | |||
| d06dbb6c70 | |||
| cf5b498bd6 | |||
| 3c28a169bd | |||
| 25653e166b | |||
| 0b4263140d | |||
| b500c28b96 | |||
| 7c2be81531 | |||
| b55ef469ed | |||
| 76a206093d | |||
| 3e88dbc30e | |||
| 031823587e | |||
| c7a1ac228c | |||
| 8ede335bed | |||
| b170b8846d | |||
| 632d90a022 |
@@ -59,6 +59,9 @@ jobs:
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
- name: Install banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install dependencies from lockfile
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -80,7 +83,7 @@ jobs:
|
||||
shell: bash
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@b1e4dc3db58c9601794e22a9f6d28d45461b9dbf #v3.29.0
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -9,7 +9,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/first-interaction@34f15e814fe48ac9312ccf29db4e74fa767cbab7 #v1.3.0
|
||||
- uses: actions/first-interaction@2d4393e6bc0e2efb2e48fba7e06819c3bf61ffc9 #v2.0.0
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
issue-message: "Thank you for your first issue ❤️ If it's a feature request, please make sure it's clear what you want, why you want it, and how important it is to you. If you posted a bug report, please make sure it includes as much detail as possible."
|
||||
|
||||
@@ -48,7 +48,7 @@ jobs:
|
||||
|
||||
- name: Validate issue with AI
|
||||
id: validate
|
||||
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
|
||||
uses: actions/ai-inference@9693b137b6566bb66055a713613bf4f0493701eb # v1.2.3
|
||||
with:
|
||||
prompt-file: issue_analysis.txt
|
||||
system-prompt: |
|
||||
|
||||
@@ -62,6 +62,9 @@ jobs:
|
||||
- name: Install cargo-audit
|
||||
run: cargo install cargo-audit
|
||||
|
||||
- name: Install banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install dependencies (Ubuntu only)
|
||||
if: matrix.os == 'ubuntu-latest'
|
||||
run: |
|
||||
@@ -88,11 +91,11 @@ jobs:
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
if [[ "${{ matrix.os }}" == "ubuntu-latest" ]]; then
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-x86_64-unknown-linux-gnu
|
||||
elif [[ "${{ matrix.os }}" == "macos-latest" ]]; then
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-aarch64-apple-darwin
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-aarch64-apple-darwin
|
||||
elif [[ "${{ matrix.os }}" == "windows-latest" ]]; then
|
||||
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-x86_64-pc-windows-msvc.exe
|
||||
cp nodecar/nodecar-bin.exe src-tauri/binaries/nodecar-x86_64-pc-windows-msvc.exe
|
||||
fi
|
||||
|
||||
- name: Create empty 'dist' directory
|
||||
|
||||
@@ -50,7 +50,7 @@ jobs:
|
||||
scan-scheduled:
|
||||
name: Scheduled Security Scan
|
||||
if: ${{ github.event_name == 'push' || github.event_name == 'schedule' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
scan-pr:
|
||||
name: PR Security Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -29,7 +29,7 @@ jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
if: ${{ github.event_name == 'pull_request' || github.event_name == 'merge_group' }}
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
|
||||
- name: Generate release notes with AI
|
||||
id: generate-notes
|
||||
uses: actions/ai-inference@d645f067d89ee1d5d736a5990e327e504d1c5a4a # v1.1.0
|
||||
uses: actions/ai-inference@9693b137b6566bb66055a713613bf4f0493701eb # v1.2.3
|
||||
with:
|
||||
prompt-file: commits.txt
|
||||
system-prompt: |
|
||||
|
||||
@@ -13,7 +13,7 @@ env:
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -115,6 +115,14 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 #v5.9.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyOxidizer
|
||||
run: pip install pyoxidizer
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
with:
|
||||
@@ -132,9 +140,12 @@ jobs:
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
- name: Install banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
|
||||
- name: Build nodecar sidecar
|
||||
shell: bash
|
||||
working-directory: ./nodecar
|
||||
@@ -146,9 +157,9 @@ jobs:
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
|
||||
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
|
||||
cp nodecar/nodecar-bin.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
|
||||
else
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Build frontend
|
||||
|
||||
@@ -12,7 +12,7 @@ env:
|
||||
jobs:
|
||||
security-scan:
|
||||
name: Security Vulnerability Scan
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@40a8940a65eab1544a6af759e43d936201a131a2" # v2.0.3
|
||||
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@b00f71e051ddddc6e46a193c31c8c0bf283bf9e6" # v2.1.0
|
||||
with:
|
||||
scan-args: |-
|
||||
-r
|
||||
@@ -114,6 +114,14 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@a7487c7e89a18df4991f7f222e4898a00d66ddda #v4.1.0
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 #v5.9.0
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyOxidizer
|
||||
run: pip install pyoxidizer
|
||||
|
||||
- name: Setup Rust
|
||||
uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b #master
|
||||
with:
|
||||
@@ -130,6 +138,9 @@ jobs:
|
||||
uses: swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 #v2.8.0
|
||||
with:
|
||||
workdir: ./src-tauri
|
||||
|
||||
- name: Install banderole
|
||||
run: cargo install banderole
|
||||
|
||||
- name: Install frontend dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
@@ -145,9 +156,9 @@ jobs:
|
||||
run: |
|
||||
mkdir -p src-tauri/binaries
|
||||
if [[ "${{ matrix.platform }}" == "windows-latest" ]]; then
|
||||
cp nodecar/dist/nodecar.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
|
||||
cp nodecar/nodecar-bin.exe src-tauri/binaries/nodecar-${{ matrix.target }}.exe
|
||||
else
|
||||
cp nodecar/dist/nodecar src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
cp nodecar/nodecar-bin src-tauri/binaries/nodecar-${{ matrix.target }}
|
||||
fi
|
||||
|
||||
- name: Build frontend
|
||||
|
||||
+4
-1
@@ -46,4 +46,7 @@ yarn-error.log*
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
!**/.gitkeep
|
||||
!**/.gitkeep
|
||||
|
||||
# nodecar
|
||||
nodecar/nodecar-bin
|
||||
Vendored
+53
@@ -1,24 +1,37 @@
|
||||
{
|
||||
"cSpell.words": [
|
||||
"adwaita",
|
||||
"ahooks",
|
||||
"akhilmhdh",
|
||||
"appimage",
|
||||
"appindicator",
|
||||
"applescript",
|
||||
"asyncio",
|
||||
"autoconfig",
|
||||
"autologin",
|
||||
"biomejs",
|
||||
"breezedark",
|
||||
"browserforge",
|
||||
"busctl",
|
||||
"CAMOU",
|
||||
"camoufox",
|
||||
"cdylib",
|
||||
"certifi",
|
||||
"CFURL",
|
||||
"checkin",
|
||||
"chrono",
|
||||
"CLICOLOR",
|
||||
"clippy",
|
||||
"cmdk",
|
||||
"codegen",
|
||||
"codesign",
|
||||
"CTYPE",
|
||||
"dataclasses",
|
||||
"datareporting",
|
||||
"datas",
|
||||
"dconf",
|
||||
"devedition",
|
||||
"doctest",
|
||||
"doesn",
|
||||
"donutbrowser",
|
||||
"dpkg",
|
||||
@@ -28,17 +41,27 @@
|
||||
"errorlevel",
|
||||
"esac",
|
||||
"esbuild",
|
||||
"etree",
|
||||
"frontmost",
|
||||
"geoip",
|
||||
"getcwd",
|
||||
"gettimezone",
|
||||
"gifs",
|
||||
"gsettings",
|
||||
"healthreport",
|
||||
"hiddenimports",
|
||||
"hkcu",
|
||||
"hooksconfig",
|
||||
"hookspath",
|
||||
"icns",
|
||||
"idlelib",
|
||||
"idletime",
|
||||
"idna",
|
||||
"Inno",
|
||||
"kdeglobals",
|
||||
"keras",
|
||||
"KHTML",
|
||||
"kreadconfig",
|
||||
"launchservices",
|
||||
"letterboxing",
|
||||
"libatk",
|
||||
@@ -51,6 +74,7 @@
|
||||
"libwebkit",
|
||||
"libxdo",
|
||||
"localtime",
|
||||
"lxml",
|
||||
"mmdb",
|
||||
"mountpoint",
|
||||
"msiexec",
|
||||
@@ -58,27 +82,49 @@
|
||||
"msys",
|
||||
"Mullvad",
|
||||
"mullvadbrowser",
|
||||
"mypy",
|
||||
"noarchive",
|
||||
"noconfirm",
|
||||
"nodecar",
|
||||
"nodemon",
|
||||
"norestart",
|
||||
"NSIS",
|
||||
"ntlm",
|
||||
"numpy",
|
||||
"objc",
|
||||
"orhun",
|
||||
"orjson",
|
||||
"osascript",
|
||||
"pathex",
|
||||
"pathlib",
|
||||
"peerconnection",
|
||||
"pixbuf",
|
||||
"plasmohq",
|
||||
"platformdirs",
|
||||
"prefs",
|
||||
"propertylist",
|
||||
"psutil",
|
||||
"pycache",
|
||||
"pydantic",
|
||||
"pyee",
|
||||
"pyinstaller",
|
||||
"pyoxidizer",
|
||||
"pytest",
|
||||
"pyyaml",
|
||||
"reqwest",
|
||||
"ridedott",
|
||||
"rlib",
|
||||
"rustc",
|
||||
"SARIF",
|
||||
"scipy",
|
||||
"screeninfo",
|
||||
"serde",
|
||||
"setuptools",
|
||||
"shadcn",
|
||||
"shutil",
|
||||
"signon",
|
||||
"signum",
|
||||
"sklearn",
|
||||
"sonner",
|
||||
"splitn",
|
||||
"sspi",
|
||||
@@ -97,14 +143,19 @@
|
||||
"TERX",
|
||||
"timedatectl",
|
||||
"titlebar",
|
||||
"tkinter",
|
||||
"Torbrowser",
|
||||
"tqdm",
|
||||
"trackingprotection",
|
||||
"turbopack",
|
||||
"turtledemo",
|
||||
"udeps",
|
||||
"unlisten",
|
||||
"unminimize",
|
||||
"unrs",
|
||||
"urlencoding",
|
||||
"urllib",
|
||||
"venv",
|
||||
"vercel",
|
||||
"VERYSILENT",
|
||||
"webgl",
|
||||
@@ -112,6 +163,8 @@
|
||||
"winreg",
|
||||
"wiremock",
|
||||
"xattr",
|
||||
"xfconf",
|
||||
"xsettings",
|
||||
"zhom",
|
||||
"zoneinfo"
|
||||
]
|
||||
|
||||
+6
-5
@@ -26,6 +26,7 @@ Ensure you have the following dependencies installed:
|
||||
- Node.js (see `.node-version` for exact version)
|
||||
- pnpm package manager
|
||||
- Latest Rust and Cargo toolchain
|
||||
- [Banderole](https://github.com/zhom/banderole)
|
||||
- [Tauri prerequisites guide](https://v2.tauri.app/start/prerequisites/).
|
||||
|
||||
## Run Locally
|
||||
@@ -46,12 +47,13 @@ After having the above dependencies installed, proceed through the following ste
|
||||
pnpm install
|
||||
```
|
||||
|
||||
4. **Install nodecar dependencies**
|
||||
4. **Build nodecar**
|
||||
|
||||
Building nodecar requires you to have `banderole` installed.
|
||||
|
||||
```bash
|
||||
cd nodecar
|
||||
pnpm install --frozen-lockfile
|
||||
cd ..
|
||||
pnpm build
|
||||
```
|
||||
|
||||
5. **Start the development server**
|
||||
@@ -105,7 +107,6 @@ Make sure the build completes successfully without errors.
|
||||
## Testing
|
||||
|
||||
- Always test your changes on the target platform
|
||||
- Test both development and production builds
|
||||
- Verify that existing functionality still works
|
||||
- Add tests for new features when possible
|
||||
|
||||
@@ -155,7 +156,7 @@ Donut Browser is built with:
|
||||
|
||||
- **Frontend**: Next.js React application
|
||||
- **Backend**: Tauri (Rust) for native functionality
|
||||
- **Node.js Sidecar**: `nodecar` binary for proxy support
|
||||
- **Node.js Sidecar**: `nodecar` binary for access to JavaScript ecosystem
|
||||
- **Build System**: GitHub Actions for CI/CD
|
||||
|
||||
Understanding this architecture will help you contribute more effectively.
|
||||
|
||||
@@ -22,7 +22,7 @@ if [ -z "$TARGET_TRIPLE" ]; then
|
||||
fi
|
||||
|
||||
# Copy the file with target triple suffix
|
||||
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
|
||||
cp "nodecar-bin${EXT}" "../src-tauri/binaries/nodecar-${TARGET_TRIPLE}${EXT}"
|
||||
|
||||
# Also copy a generic version for Tauri to find
|
||||
cp "dist/nodecar${EXT}" "../src-tauri/binaries/nodecar${EXT}"
|
||||
cp "nodecar-bin${EXT}" "../src-tauri/binaries/nodecar${EXT}"
|
||||
+12
-11
@@ -3,31 +3,32 @@
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"main": "dist/index.js",
|
||||
"bin": "dist/index.js",
|
||||
"scripts": {
|
||||
"watch": "nodemon --exec ts-node --esm ./src/index.ts --watch src",
|
||||
"dev": "node --loader ts-node/esm ./src/index.ts",
|
||||
"start": "tsc && node ./dist/index.js",
|
||||
"test": "tsc && node ./dist/test-proxy.js",
|
||||
"rename-binary": "sh ./copy-binary.sh",
|
||||
"build": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:mac-aarch64": "tsc && pkg ./dist/index.js --targets latest-macos-arm64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:mac-x86_64": "tsc && pkg ./dist/index.js --targets latest-macos-x64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:linux-x64": "tsc && pkg ./dist/index.js --targets latest-linux-x64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:linux-arm64": "tsc && pkg ./dist/index.js --targets latest-linux-arm64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:win-x64": "tsc && pkg ./dist/index.js --targets latest-win-x64 --output dist/nodecar && pnpm rename-binary",
|
||||
"build:win-arm64": "tsc && pkg ./dist/index.js --targets latest-win-arm64 --output dist/nodecar && pnpm rename-binary"
|
||||
"build": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:mac-aarch64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:mac-x86_64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:linux-x64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:linux-arm64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:win-x64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary",
|
||||
"build:win-arm64": "tsc && banderole bundle . --output nodecar-bin && pnpm rename-binary"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^24.0.10",
|
||||
"@yao-pkg/pkg": "^6.5.1",
|
||||
"camoufox-js": "^0.6.0",
|
||||
"@types/node": "^24.1.0",
|
||||
"camoufox-js": "^0.6.1",
|
||||
"commander": "^14.0.0",
|
||||
"dotenv": "^17.0.1",
|
||||
"dotenv": "^17.2.0",
|
||||
"get-port": "^7.1.0",
|
||||
"nodemon": "^3.1.10",
|
||||
"playwright-core": "^1.54.1",
|
||||
"proxy-chain": "^2.5.9",
|
||||
"tmp": "^0.2.3",
|
||||
"ts-node": "^10.9.2",
|
||||
|
||||
@@ -1,16 +1,4 @@
|
||||
import { spawn } from "child_process";
|
||||
import * as fs from "fs";
|
||||
import * as os from "os";
|
||||
import * as path from "path";
|
||||
|
||||
export interface CamoufoxConfig {
|
||||
id: string;
|
||||
pid?: number;
|
||||
executablePath: string;
|
||||
profilePath: string;
|
||||
url?: string;
|
||||
options: CamoufoxLaunchOptions;
|
||||
}
|
||||
import { launchOptions } from "camoufox-js";
|
||||
|
||||
export interface CamoufoxLaunchOptions {
|
||||
// Operating system to use for fingerprint generation
|
||||
@@ -92,412 +80,84 @@ export interface CamoufoxLaunchOptions {
|
||||
};
|
||||
}
|
||||
|
||||
// Store for active Camoufox processes
|
||||
const activeCamoufoxProcesses = new Map<string, CamoufoxConfig>();
|
||||
|
||||
/**
|
||||
* Generate a unique ID for the Camoufox instance
|
||||
* Generate Camoufox configuration using camoufox-js-lsd
|
||||
*/
|
||||
function generateCamoufoxId(): string {
|
||||
return `camoufox_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Save Camoufox configuration to storage
|
||||
*/
|
||||
function saveCamoufoxConfig(config: CamoufoxConfig): void {
|
||||
try {
|
||||
const configDir = path.join(os.tmpdir(), "nodecar_camoufox");
|
||||
if (!fs.existsSync(configDir)) {
|
||||
fs.mkdirSync(configDir, { recursive: true });
|
||||
}
|
||||
|
||||
const configFile = path.join(configDir, `${config.id}.json`);
|
||||
fs.writeFileSync(configFile, JSON.stringify(config, null, 2));
|
||||
activeCamoufoxProcesses.set(config.id, config);
|
||||
} catch (error) {
|
||||
console.error(`Failed to save Camoufox config: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load Camoufox configuration from storage
|
||||
*/
|
||||
function loadCamoufoxConfig(id: string): CamoufoxConfig | null {
|
||||
try {
|
||||
const configFile = path.join(os.tmpdir(), "nodecar_camoufox", `${id}.json`);
|
||||
if (fs.existsSync(configFile)) {
|
||||
const config = JSON.parse(fs.readFileSync(configFile, "utf8"));
|
||||
activeCamoufoxProcesses.set(id, config);
|
||||
return config;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load Camoufox config: ${error}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete Camoufox configuration from storage
|
||||
*/
|
||||
function deleteCamoufoxConfig(id: string): boolean {
|
||||
try {
|
||||
const configFile = path.join(os.tmpdir(), "nodecar_camoufox", `${id}.json`);
|
||||
if (fs.existsSync(configFile)) {
|
||||
fs.unlinkSync(configFile);
|
||||
}
|
||||
activeCamoufoxProcesses.delete(id);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete Camoufox config: ${error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load all Camoufox configurations on startup
|
||||
*/
|
||||
function loadAllCamoufoxConfigs(): void {
|
||||
try {
|
||||
const configDir = path.join(os.tmpdir(), "nodecar_camoufox");
|
||||
if (fs.existsSync(configDir)) {
|
||||
const files = fs.readdirSync(configDir);
|
||||
for (const file of files) {
|
||||
if (file.endsWith(".json")) {
|
||||
const id = path.basename(file, ".json");
|
||||
loadCamoufoxConfig(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load Camoufox configs: ${error}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a process is still running
|
||||
*/
|
||||
function isProcessRunning(pid: number): boolean {
|
||||
try {
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Camoufox options to command line arguments
|
||||
*/
|
||||
function buildCamoufoxArgs(
|
||||
options: CamoufoxLaunchOptions,
|
||||
profilePath: string,
|
||||
url?: string,
|
||||
): string[] {
|
||||
const args: string[] = [];
|
||||
|
||||
// Always use profile
|
||||
args.push("-profile", profilePath);
|
||||
|
||||
// Cache enabled by default as requested
|
||||
if (options.enable_cache !== false) {
|
||||
// Cache is enabled by default in Camoufox, no special args needed
|
||||
}
|
||||
|
||||
// Headless mode
|
||||
if (options.headless) {
|
||||
args.push("-headless");
|
||||
}
|
||||
|
||||
// No remote for security (anti-detect)
|
||||
args.push("-no-remote");
|
||||
|
||||
// Custom Firefox user preferences will be written to user.js in profile
|
||||
|
||||
// Additional custom args
|
||||
if (options.args) {
|
||||
args.push(...options.args);
|
||||
}
|
||||
|
||||
// URL to open
|
||||
if (url) {
|
||||
args.push(url);
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create user.js file with Camoufox preferences
|
||||
*/
|
||||
function createUserJs(
|
||||
profilePath: string,
|
||||
options: CamoufoxLaunchOptions,
|
||||
): void {
|
||||
const preferences: string[] = [];
|
||||
|
||||
// Anti-detect preferences
|
||||
preferences.push('user_pref("privacy.resistFingerprinting", true);');
|
||||
preferences.push(
|
||||
'user_pref("privacy.resistFingerprinting.letterboxing", true);',
|
||||
);
|
||||
preferences.push('user_pref("privacy.trackingprotection.enabled", true);');
|
||||
|
||||
// Disable telemetry and data collection
|
||||
preferences.push(
|
||||
'user_pref("datareporting.healthreport.uploadEnabled", false);',
|
||||
);
|
||||
preferences.push(
|
||||
'user_pref("datareporting.policy.dataSubmissionEnabled", false);',
|
||||
);
|
||||
preferences.push('user_pref("toolkit.telemetry.enabled", false);');
|
||||
preferences.push('user_pref("toolkit.telemetry.unified", false);');
|
||||
|
||||
// Block options
|
||||
if (options.block_images) {
|
||||
preferences.push('user_pref("permissions.default.image", 2);');
|
||||
}
|
||||
|
||||
if (options.block_webrtc) {
|
||||
preferences.push('user_pref("media.peerconnection.enabled", false);');
|
||||
preferences.push('user_pref("media.navigator.enabled", false);');
|
||||
}
|
||||
|
||||
if (options.block_webgl) {
|
||||
preferences.push('user_pref("webgl.disabled", true);');
|
||||
preferences.push('user_pref("webgl.disable-extensions", true);');
|
||||
}
|
||||
|
||||
// COOP settings
|
||||
if (options.disable_coop) {
|
||||
preferences.push(
|
||||
'user_pref("browser.tabs.remote.useCrossOriginOpenerPolicy", false);',
|
||||
);
|
||||
}
|
||||
|
||||
// Locale settings
|
||||
if (options.locale) {
|
||||
const localeStr = Array.isArray(options.locale)
|
||||
? options.locale[0]
|
||||
: options.locale;
|
||||
preferences.push(`user_pref("intl.locale.requested", "${localeStr}");`);
|
||||
preferences.push(`user_pref("general.useragent.locale", "${localeStr}");`);
|
||||
}
|
||||
|
||||
// Timezone
|
||||
if (options.timezone) {
|
||||
preferences.push(
|
||||
`user_pref("privacy.resistFingerprinting.timezone", "${options.timezone}");`,
|
||||
);
|
||||
}
|
||||
|
||||
// Custom Firefox preferences
|
||||
if (options.firefox_user_prefs) {
|
||||
for (const [key, value] of Object.entries(options.firefox_user_prefs)) {
|
||||
if (typeof value === "string") {
|
||||
preferences.push(`user_pref("${key}", "${value}");`);
|
||||
} else if (typeof value === "boolean") {
|
||||
preferences.push(`user_pref("${key}", ${value});`);
|
||||
} else if (typeof value === "number") {
|
||||
preferences.push(`user_pref("${key}", ${value});`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Proxy settings
|
||||
if (options.proxy) {
|
||||
if (typeof options.proxy === "string") {
|
||||
// Parse proxy URL
|
||||
try {
|
||||
const proxyUrl = new URL(options.proxy);
|
||||
const port =
|
||||
parseInt(proxyUrl.port) ||
|
||||
(proxyUrl.protocol === "https:" ? 443 : 80);
|
||||
|
||||
if (proxyUrl.protocol.startsWith("socks")) {
|
||||
preferences.push('user_pref("network.proxy.type", 1);');
|
||||
preferences.push(
|
||||
`user_pref("network.proxy.socks", "${proxyUrl.hostname}");`,
|
||||
);
|
||||
preferences.push(`user_pref("network.proxy.socks_port", ${port});`);
|
||||
if (proxyUrl.protocol === "socks5:") {
|
||||
preferences.push('user_pref("network.proxy.socks_version", 5);');
|
||||
} else {
|
||||
preferences.push('user_pref("network.proxy.socks_version", 4);');
|
||||
}
|
||||
} else {
|
||||
preferences.push('user_pref("network.proxy.type", 1);');
|
||||
preferences.push(
|
||||
`user_pref("network.proxy.http", "${proxyUrl.hostname}");`,
|
||||
);
|
||||
preferences.push(`user_pref("network.proxy.http_port", ${port});`);
|
||||
preferences.push(
|
||||
`user_pref("network.proxy.ssl", "${proxyUrl.hostname}");`,
|
||||
);
|
||||
preferences.push(`user_pref("network.proxy.ssl_port", ${port});`);
|
||||
}
|
||||
|
||||
if (proxyUrl.username && proxyUrl.password) {
|
||||
// Note: Basic auth for proxies is handled differently in modern Firefox
|
||||
preferences.push(
|
||||
'user_pref("network.proxy.allow_hijacking_localhost", true);',
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Invalid proxy URL: ${options.proxy}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Geolocation
|
||||
if (options.geolocation) {
|
||||
preferences.push('user_pref("geo.enabled", true);');
|
||||
preferences.push(
|
||||
`user_pref("geo.wifi.uri", "data:application/json,{\\"location\\": {\\"lat\\": ${options.geolocation.latitude}, \\"lng\\": ${options.geolocation.longitude}}, \\"accuracy\\": ${options.geolocation.accuracy || 100}}");`,
|
||||
);
|
||||
} else {
|
||||
preferences.push('user_pref("geo.enabled", false);');
|
||||
}
|
||||
|
||||
// Write user.js file
|
||||
const userJsPath = path.join(profilePath, "user.js");
|
||||
fs.writeFileSync(userJsPath, preferences.join("\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch Camoufox browser with specified options
|
||||
*/
|
||||
export async function launchCamoufox(
|
||||
executablePath: string,
|
||||
profilePath: string,
|
||||
export async function generateCamoufoxConfig(
|
||||
options: CamoufoxLaunchOptions = {},
|
||||
url?: string,
|
||||
): Promise<CamoufoxConfig> {
|
||||
const id = generateCamoufoxId();
|
||||
|
||||
// Ensure profile directory exists
|
||||
if (!fs.existsSync(profilePath)) {
|
||||
fs.mkdirSync(profilePath, { recursive: true });
|
||||
}
|
||||
|
||||
// Create user.js with preferences
|
||||
createUserJs(profilePath, options);
|
||||
|
||||
// Build command line arguments
|
||||
const args = buildCamoufoxArgs(options, profilePath, url);
|
||||
|
||||
// Prepare environment variables
|
||||
const env = {
|
||||
...process.env,
|
||||
...options.env,
|
||||
};
|
||||
|
||||
// Handle virtual display
|
||||
if (options.virtual_display) {
|
||||
env.DISPLAY = options.virtual_display;
|
||||
}
|
||||
|
||||
// Launch the process
|
||||
const child = spawn(executablePath, args, {
|
||||
env: env as NodeJS.ProcessEnv,
|
||||
detached: true,
|
||||
stdio: options.debug ? "inherit" : "ignore",
|
||||
});
|
||||
|
||||
if (!child.pid) {
|
||||
throw new Error("Failed to launch Camoufox process");
|
||||
}
|
||||
|
||||
const config: CamoufoxConfig = {
|
||||
id,
|
||||
pid: child.pid,
|
||||
executablePath,
|
||||
profilePath,
|
||||
url,
|
||||
options,
|
||||
};
|
||||
|
||||
// Save configuration
|
||||
saveCamoufoxConfig(config);
|
||||
|
||||
// Handle process exit
|
||||
child.on("exit", (code, signal) => {
|
||||
console.log(
|
||||
`Camoufox process ${child.pid} exited with code ${code}, signal ${signal}`,
|
||||
);
|
||||
deleteCamoufoxConfig(id);
|
||||
});
|
||||
|
||||
child.on("error", (error) => {
|
||||
console.error(`Camoufox process error: ${error}`);
|
||||
deleteCamoufoxConfig(id);
|
||||
});
|
||||
|
||||
// Detach the child process so it can continue running independently
|
||||
child.unref();
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a Camoufox process by ID
|
||||
*/
|
||||
export async function stopCamoufox(id: string): Promise<boolean> {
|
||||
const config = activeCamoufoxProcesses.get(id) || loadCamoufoxConfig(id);
|
||||
|
||||
if (!config || !config.pid) {
|
||||
return false;
|
||||
}
|
||||
|
||||
): Promise<any> {
|
||||
try {
|
||||
if (isProcessRunning(config.pid)) {
|
||||
process.kill(config.pid, "SIGTERM");
|
||||
// Convert our options to camoufox-js-lsd format
|
||||
const camoufoxOptions: any = {};
|
||||
|
||||
// Wait a moment for graceful shutdown
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
// Map our options to camoufox-js-lsd format
|
||||
if (options.os) camoufoxOptions.os = options.os;
|
||||
if (options.block_images !== undefined)
|
||||
camoufoxOptions.block_images = options.block_images;
|
||||
if (options.block_webrtc !== undefined)
|
||||
camoufoxOptions.block_webrtc = options.block_webrtc;
|
||||
if (options.block_webgl !== undefined)
|
||||
camoufoxOptions.block_webgl = options.block_webgl;
|
||||
if (options.disable_coop !== undefined)
|
||||
camoufoxOptions.disable_coop = options.disable_coop;
|
||||
if (options.geoip !== undefined) camoufoxOptions.geoip = options.geoip;
|
||||
if (options.humanize !== undefined)
|
||||
camoufoxOptions.humanize = options.humanize;
|
||||
if (options.locale) camoufoxOptions.locale = options.locale;
|
||||
if (options.addons) camoufoxOptions.addons = options.addons;
|
||||
if (options.fonts) camoufoxOptions.fonts = options.fonts;
|
||||
if (options.custom_fonts_only !== undefined)
|
||||
camoufoxOptions.custom_fonts_only = options.custom_fonts_only;
|
||||
if (options.exclude_addons)
|
||||
camoufoxOptions.exclude_addons = options.exclude_addons;
|
||||
if (options.screen) camoufoxOptions.screen = options.screen;
|
||||
if (options.window) camoufoxOptions.window = options.window;
|
||||
if (options.fingerprint) camoufoxOptions.fingerprint = options.fingerprint;
|
||||
if (options.ff_version !== undefined)
|
||||
camoufoxOptions.ff_version = options.ff_version;
|
||||
if (options.headless !== undefined)
|
||||
camoufoxOptions.headless = options.headless;
|
||||
if (options.main_world_eval !== undefined)
|
||||
camoufoxOptions.main_world_eval = options.main_world_eval;
|
||||
if (options.executable_path)
|
||||
camoufoxOptions.executable_path = options.executable_path;
|
||||
if (options.firefox_user_prefs)
|
||||
camoufoxOptions.firefox_user_prefs = options.firefox_user_prefs;
|
||||
if (options.proxy) camoufoxOptions.proxy = options.proxy;
|
||||
if (options.enable_cache !== undefined)
|
||||
camoufoxOptions.enable_cache = options.enable_cache;
|
||||
if (options.args) camoufoxOptions.args = options.args;
|
||||
if (options.env) camoufoxOptions.env = options.env;
|
||||
if (options.debug !== undefined) camoufoxOptions.debug = options.debug;
|
||||
if (options.virtual_display)
|
||||
camoufoxOptions.virtual_display = options.virtual_display;
|
||||
if (options.webgl_config)
|
||||
camoufoxOptions.webgl_config = options.webgl_config;
|
||||
|
||||
// Force kill if still running
|
||||
if (isProcessRunning(config.pid)) {
|
||||
process.kill(config.pid, "SIGKILL");
|
||||
}
|
||||
// Handle custom options that might need mapping
|
||||
if (options.timezone) {
|
||||
// If timezone is provided directly, we can set it in the generated config
|
||||
// This will be handled after generation
|
||||
}
|
||||
if (options.country) {
|
||||
// Similar for country
|
||||
}
|
||||
if (options.geolocation) {
|
||||
// Handle geolocation coordinates
|
||||
}
|
||||
|
||||
deleteCamoufoxConfig(id);
|
||||
return true;
|
||||
// Generate the configuration using camoufox-js-lsd
|
||||
const generatedConfig = await launchOptions(camoufoxOptions);
|
||||
|
||||
// Apply any custom overrides
|
||||
if (options.timezone) {
|
||||
generatedConfig.env = generatedConfig.env || {};
|
||||
// The timezone will be handled in the CAMOU_CONFIG environment variable
|
||||
}
|
||||
|
||||
return generatedConfig;
|
||||
} catch (error) {
|
||||
console.error(`Failed to stop Camoufox process: ${error}`);
|
||||
return false;
|
||||
console.error(`Failed to generate Camoufox config: ${error}`);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all Camoufox processes
|
||||
*/
|
||||
export function listCamoufoxProcesses(): any[] {
|
||||
loadAllCamoufoxConfigs();
|
||||
|
||||
// Filter out dead processes
|
||||
const activeConfigs: any[] = [];
|
||||
|
||||
for (const [id, config] of activeCamoufoxProcesses) {
|
||||
if (config.pid && isProcessRunning(config.pid)) {
|
||||
// Return in snake_case format for Rust compatibility
|
||||
activeConfigs.push({
|
||||
id: config.id,
|
||||
pid: config.pid,
|
||||
executable_path: config.executablePath,
|
||||
profile_path: config.profilePath,
|
||||
url: config.url,
|
||||
options: config.options,
|
||||
});
|
||||
} else {
|
||||
// Clean up dead processes
|
||||
deleteCamoufoxConfig(id);
|
||||
}
|
||||
}
|
||||
|
||||
return activeConfigs;
|
||||
}
|
||||
|
||||
// Load existing configurations on module initialization
|
||||
loadAllCamoufoxConfigs();
|
||||
|
||||
+14
-82
@@ -1,9 +1,5 @@
|
||||
import { program } from "commander";
|
||||
import {
|
||||
launchCamoufox,
|
||||
listCamoufoxProcesses,
|
||||
stopCamoufox,
|
||||
} from "./camoufox-launcher";
|
||||
import { generateCamoufoxConfig } from "./camoufox-launcher.js";
|
||||
import {
|
||||
startProxyProcess,
|
||||
stopAllProxyProcesses,
|
||||
@@ -71,7 +67,7 @@ program
|
||||
"Error: Either --upstream URL or --host, --proxy-port, and --type are required",
|
||||
);
|
||||
console.log(
|
||||
"Example: proxy start --host datacenter.proxyempire.io --proxy-port 9000 --type http --username user --password pass",
|
||||
"Example: proxy start --host proxy.example.com --proxy-port 9000 --type http --username user --password pass",
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
@@ -154,14 +150,10 @@ program
|
||||
}
|
||||
});
|
||||
|
||||
// Command for Camoufox browser orchestrator
|
||||
// Command for generating Camoufox configuration
|
||||
program
|
||||
.command("camoufox")
|
||||
.argument("<action>", "launch, stop, list, or open-url for Camoufox browser")
|
||||
.requiredOption("--executable-path <path>", "path to Camoufox executable")
|
||||
.requiredOption("--profile-path <path>", "path to browser profile directory")
|
||||
.option("--url <url>", "URL to open")
|
||||
.option("--id <id>", "Camoufox instance ID (for stop/open-url actions)")
|
||||
.command("camoufox-config")
|
||||
.argument("<action>", "generate Camoufox configuration")
|
||||
|
||||
// Operating system fingerprinting
|
||||
.option(
|
||||
@@ -239,22 +231,13 @@ program
|
||||
// Firefox preferences
|
||||
.option("--firefox-prefs <prefs>", "Firefox user preferences (JSON string)")
|
||||
|
||||
.description("launch and manage Camoufox browser orchestrator instances")
|
||||
.description("generate Camoufox configuration using camoufox-js")
|
||||
.action(async (action: string, options: any) => {
|
||||
try {
|
||||
if (action === "launch") {
|
||||
// Validate required options
|
||||
if (!options.executablePath || !options.profilePath) {
|
||||
console.error(
|
||||
"Error: --executable-path and --profile-path are required for launch",
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === "generate") {
|
||||
// Build Camoufox options
|
||||
const camoufoxOptions: any = {
|
||||
enable_cache: !options.disableCache, // Cache enabled by default as requested
|
||||
enable_cache: !options.disableCache, // Cache enabled by default
|
||||
};
|
||||
|
||||
// OS fingerprinting
|
||||
@@ -358,70 +341,19 @@ program
|
||||
}
|
||||
}
|
||||
|
||||
// Launch Camoufox
|
||||
const config = await launchCamoufox(
|
||||
options.executablePath,
|
||||
options.profilePath,
|
||||
camoufoxOptions,
|
||||
options.url,
|
||||
);
|
||||
|
||||
// Output the configuration as JSON for the Rust side to parse
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
id: config.id,
|
||||
pid: config.pid,
|
||||
executable_path: config.executablePath,
|
||||
profile_path: config.profilePath,
|
||||
url: config.url,
|
||||
}),
|
||||
);
|
||||
// Generate configuration
|
||||
const config = await generateCamoufoxConfig(camoufoxOptions);
|
||||
|
||||
// Output the configuration as JSON
|
||||
console.log(JSON.stringify(config, null, 2));
|
||||
process.exit(0);
|
||||
} else if (action === "stop") {
|
||||
if (!options.id) {
|
||||
console.error("Error: --id is required for stop action");
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await stopCamoufox(options.id);
|
||||
console.log(JSON.stringify({ success }));
|
||||
process.exit(0);
|
||||
} else if (action === "list") {
|
||||
const processes = listCamoufoxProcesses();
|
||||
// Convert camelCase to snake_case for Rust compatibility
|
||||
const rustCompatibleProcesses = processes.map((process) => ({
|
||||
id: process.id,
|
||||
pid: process.pid,
|
||||
executable_path: process.executablePath,
|
||||
profile_path: process.profilePath,
|
||||
url: process.url,
|
||||
}));
|
||||
console.log(JSON.stringify(rustCompatibleProcesses));
|
||||
process.exit(0);
|
||||
} else if (action === "open-url") {
|
||||
if (!options.id || !options.url) {
|
||||
console.error(
|
||||
"Error: --id and --url are required for open-url action",
|
||||
);
|
||||
process.exit(1);
|
||||
return;
|
||||
}
|
||||
|
||||
// This would require implementing URL opening in existing instance
|
||||
// For now, we'll return an error as this feature would need additional implementation
|
||||
console.error("open-url action is not yet implemented");
|
||||
process.exit(1);
|
||||
} else {
|
||||
console.error(
|
||||
"Invalid action. Use 'launch', 'stop', 'list', or 'open-url'",
|
||||
);
|
||||
console.error("Invalid action. Use 'generate'");
|
||||
process.exit(1);
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
console.error(
|
||||
`Camoufox command failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
|
||||
`Camoufox config generation failed: ${error instanceof Error ? error.message : JSON.stringify(error)}`,
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
+12
-11
@@ -30,22 +30,23 @@
|
||||
"@radix-ui/react-label": "^2.1.7",
|
||||
"@radix-ui/react-popover": "^1.1.14",
|
||||
"@radix-ui/react-progress": "^1.1.7",
|
||||
"@radix-ui/react-radio-group": "^1.3.7",
|
||||
"@radix-ui/react-scroll-area": "^1.2.9",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tauri-apps/api": "^2.6.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.0",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.0",
|
||||
"@tauri-apps/plugin-fs": "~2.4.0",
|
||||
"@tauri-apps/api": "^2.7.0",
|
||||
"@tauri-apps/plugin-deep-link": "^2.4.1",
|
||||
"@tauri-apps/plugin-dialog": "^2.3.1",
|
||||
"@tauri-apps/plugin-fs": "~2.4.1",
|
||||
"@tauri-apps/plugin-opener": "^2.4.0",
|
||||
"ahooks": "^3.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"next": "^15.3.5",
|
||||
"next": "^15.4.4",
|
||||
"next-themes": "^0.4.6",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
@@ -55,21 +56,21 @@
|
||||
"tauri-plugin-macos-permissions-api": "^2.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.0.6",
|
||||
"@biomejs/biome": "2.1.1",
|
||||
"@tailwindcss/postcss": "^4.1.11",
|
||||
"@tauri-apps/cli": "^2.6.2",
|
||||
"@types/node": "^24.0.13",
|
||||
"@tauri-apps/cli": "^2.7.1",
|
||||
"@types/node": "^24.1.0",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"husky": "^9.1.7",
|
||||
"lint-staged": "^16.1.2",
|
||||
"tailwindcss": "^4.1.11",
|
||||
"ts-unused-exports": "^11.0.1",
|
||||
"tw-animate-css": "^1.3.5",
|
||||
"tw-animate-css": "^1.3.6",
|
||||
"typescript": "~5.8.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.11.1",
|
||||
"packageManager": "pnpm@10.13.1",
|
||||
"lint-staged": {
|
||||
"**/*.{js,jsx,ts,tsx,json,css,md}": [
|
||||
"biome check --fix"
|
||||
|
||||
Generated
+531
-772
File diff suppressed because it is too large
Load Diff
Generated
+116
-63
@@ -124,9 +124,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "async-channel"
|
||||
version = "2.4.0"
|
||||
version = "2.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "16c74e56284d2188cabb6ad99603d1ace887a5d7e7b695d01b728155ed9ed427"
|
||||
checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"event-listener-strategy",
|
||||
@@ -349,9 +349,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "blocking"
|
||||
version = "1.6.1"
|
||||
version = "1.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea"
|
||||
checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21"
|
||||
dependencies = [
|
||||
"async-channel",
|
||||
"async-task",
|
||||
@@ -410,21 +410,11 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "bzip2"
|
||||
version = "0.5.2"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "49ecfb22d906f800d4fe833b6282cf4dc1c298f5057ca0b5445e5c209735ca47"
|
||||
checksum = "bea8dcd42434048e4f7a304411d9273a411f647446c1234a65ce0554923f4cff"
|
||||
dependencies = [
|
||||
"bzip2-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bzip2-sys"
|
||||
version = "0.1.13+1.0.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "225bff33b2141874fe80d71e07d6eec4f85c5c216453dd96388240f96e1acc14"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"pkg-config",
|
||||
"libbz2-rs-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -491,14 +481,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "02260d489095346e5cafd04dea8e8cb54d1d74fcd759022a9b72986ebe9a1257"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"toml",
|
||||
"toml 0.8.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.28"
|
||||
version = "1.2.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4ad45f4f74e4e20eaa392913b7b33a7091c87e59628f4dd27888205ad888843c"
|
||||
checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362"
|
||||
dependencies = [
|
||||
"jobserver",
|
||||
"libc",
|
||||
@@ -552,8 +542,10 @@ checksum = "c469d952047f47f91b68d1cba3f10d63c11d73e4636f24f08daf0278abf01c4d"
|
||||
dependencies = [
|
||||
"android-tzdata",
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
@@ -975,6 +967,7 @@ version = "0.7.2"
|
||||
dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"core-foundation 0.10.1",
|
||||
"directories",
|
||||
"futures-util",
|
||||
@@ -1001,6 +994,7 @@ dependencies = [
|
||||
"tokio",
|
||||
"tower",
|
||||
"tower-http",
|
||||
"url",
|
||||
"uuid",
|
||||
"windows",
|
||||
"winreg",
|
||||
@@ -1046,14 +1040,14 @@ checksum = "1c7a8fb8a9fbf66c1f703fe16184d10ca0ee9d23be5b4436400408ba54a95005"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.4"
|
||||
version = "3.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0963f530273dc3022ab2bdc3fcd6d488e850256f2284a82b7413cb9481ee85dd"
|
||||
checksum = "4c6d81016d6c977deefb2ef8d8290da019e27cc26167e102185da528e6c0ab38"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml",
|
||||
"toml 0.9.2",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
@@ -1123,7 +1117,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1499,11 +1493,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasi 0.14.2+wasi-0.2.4",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1849,9 +1841,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "hyper-util"
|
||||
version = "0.1.14"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb"
|
||||
checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"bytes",
|
||||
@@ -2252,6 +2244,12 @@ dependencies = [
|
||||
"once_cell",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libbz2-rs-sys"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "775bf80d5878ab7c2b1080b5351a48b2f737d9f6f8b383574eebcc22be0dfccb"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.174"
|
||||
@@ -3155,9 +3153,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.7.2"
|
||||
version = "1.7.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3d77244ce2d584cd84f6a15f86195b8c9b2a0dfbfd817c09e0464244091a58ed"
|
||||
checksum = "3af6b589e163c5a788fab00ce0c0366f6efbb9959c2f9874b224936af7fce7e1"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.9.0",
|
||||
@@ -3209,6 +3207,12 @@ version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
|
||||
|
||||
[[package]]
|
||||
name = "ppmd-rust"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c834641d8ad1b348c9ee86dec3b9840d805acd5f24daa5f90c788951a52ff59b"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
@@ -3293,9 +3297,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.37.5"
|
||||
version = "0.38.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "331e97a1af0bf59823e6eadffe373d7b27f485be8748f71471c662c1f269b7fb"
|
||||
checksum = "8927b0664f5c5a98265138b7e3f90aa19a6b21353182469ace36d4ac527b7b1b"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -3617,14 +3621,14 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.28"
|
||||
version = "0.23.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7160e3e10bf4535308537f3c4e1641468cd0e485175d6163087c0393c7d46643"
|
||||
checksum = "2491382039b29b9b11ff08b76ff6c97cf287671dbb74f0be44bda389fffe9bd1"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"rustls-pki-types",
|
||||
@@ -3644,9 +3648,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.3"
|
||||
version = "0.103.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e4a72fe2bcf7a6ac6fd7d0b9e5cb68aeb7d4c0a0271730218b3e92d43b4eb435"
|
||||
checksum = "0a17884ae0c1b773f1ccd2bd4a8c72f16da897310a98b0e84bf349ad5ead92fc"
|
||||
dependencies = [
|
||||
"ring",
|
||||
"rustls-pki-types",
|
||||
@@ -3864,6 +3868,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_urlencoded"
|
||||
version = "0.7.1"
|
||||
@@ -4201,9 +4214,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "sysinfo"
|
||||
version = "0.35.2"
|
||||
version = "0.36.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c3ffa3e4ff2b324a57f7aeb3c349656c7b127c3c189520251a648102a92496e"
|
||||
checksum = "aab138f5c1bb35231de19049060a87977ad23e04f2303e953bc5c2947ac7dec4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"memchr",
|
||||
@@ -4243,7 +4256,7 @@ dependencies = [
|
||||
"cfg-expr",
|
||||
"heck 0.5.0",
|
||||
"pkg-config",
|
||||
"toml",
|
||||
"toml 0.8.22",
|
||||
"version-compare",
|
||||
]
|
||||
|
||||
@@ -4371,7 +4384,7 @@ dependencies = [
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"tauri-winres",
|
||||
"toml",
|
||||
"toml 0.8.22",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4429,7 +4442,7 @@ dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri-utils",
|
||||
"toml",
|
||||
"toml 0.8.22",
|
||||
"walkdir",
|
||||
]
|
||||
|
||||
@@ -4489,7 +4502,7 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
"tauri-utils",
|
||||
"thiserror 2.0.12",
|
||||
"toml",
|
||||
"toml 0.8.22",
|
||||
"url",
|
||||
]
|
||||
|
||||
@@ -4647,7 +4660,7 @@ dependencies = [
|
||||
"serde_with",
|
||||
"swift-rs",
|
||||
"thiserror 2.0.12",
|
||||
"toml",
|
||||
"toml 0.8.22",
|
||||
"url",
|
||||
"urlpattern",
|
||||
"uuid",
|
||||
@@ -4662,7 +4675,7 @@ checksum = "e8d321dbc6f998d825ab3f0d62673e810c861aac2d0de2cc2c395328f1d113b4"
|
||||
dependencies = [
|
||||
"embed-resource",
|
||||
"indexmap 2.9.0",
|
||||
"toml",
|
||||
"toml 0.8.22",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4675,7 +4688,7 @@ dependencies = [
|
||||
"getrandom 0.3.3",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4851,11 +4864,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "05ae329d1f08c4d17a59bed7ff5b5a769d062e64a62d34a3261b219e62cd5aae"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.26",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "0.9.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"serde",
|
||||
"serde_spanned 1.0.0",
|
||||
"toml_datetime 0.7.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 0.7.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
@@ -4865,6 +4893,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.19.15"
|
||||
@@ -4872,7 +4909,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"toml_datetime",
|
||||
"toml_datetime 0.6.11",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
@@ -4883,7 +4920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70f427fce4d84c72b5b732388bf4a9f4531b53f74e2887e3ecb2481f68f66d81"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"toml_datetime",
|
||||
"toml_datetime 0.6.11",
|
||||
"winnow 0.5.40",
|
||||
]
|
||||
|
||||
@@ -4895,18 +4932,33 @@ checksum = "310068873db2c5b3e7659d2cc35d21855dbafa50d1ce336397c666e3cb08137e"
|
||||
dependencies = [
|
||||
"indexmap 2.9.0",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30"
|
||||
dependencies = [
|
||||
"winnow 0.7.10",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_write"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64"
|
||||
|
||||
[[package]]
|
||||
name = "tower"
|
||||
version = "0.5.2"
|
||||
@@ -5431,7 +5483,7 @@ version = "0.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||
dependencies = [
|
||||
"windows-sys 0.52.0",
|
||||
"windows-sys 0.59.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5965,9 +6017,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zbus"
|
||||
version = "5.7.1"
|
||||
version = "5.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3a7c7cee313d044fca3f48fa782cb750c79e4ca76ba7bc7718cd4024cdf6f68"
|
||||
checksum = "597f45e98bc7e6f0988276012797855613cd8269e23b5be62cc4e5d28b7e515d"
|
||||
dependencies = [
|
||||
"async-broadcast",
|
||||
"async-executor",
|
||||
@@ -5999,9 +6051,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zbus_macros"
|
||||
version = "5.7.1"
|
||||
version = "5.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a17e7e5eec1550f747e71a058df81a9a83813ba0f6a95f39c4e218bdc7ba366a"
|
||||
checksum = "e5c8e4e14dcdd9d97a98b189cd1220f30e8394ad271e8c987da84f73693862c2"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.3.0",
|
||||
"proc-macro2",
|
||||
@@ -6120,9 +6172,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zip"
|
||||
version = "4.2.0"
|
||||
version = "4.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "95ab361742de920c5535880f89bbd611ee62002bf11341d16a5f057bb8ba6899"
|
||||
checksum = "9aed4ac33e8eb078c89e6cbb1d5c4c7703ec6d299fc3e7c3695af8f8b423468b"
|
||||
dependencies = [
|
||||
"aes",
|
||||
"arbitrary",
|
||||
@@ -6137,6 +6189,7 @@ dependencies = [
|
||||
"liblzma",
|
||||
"memchr",
|
||||
"pbkdf2",
|
||||
"ppmd-rust",
|
||||
"sha1",
|
||||
"time",
|
||||
"zeroize",
|
||||
@@ -6192,9 +6245,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zvariant"
|
||||
version = "5.5.3"
|
||||
version = "5.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d30786f75e393ee63a21de4f9074d4c038d52c5b1bb4471f955db249f9dffb1"
|
||||
checksum = "d91b3680bb339216abd84714172b5138a4edac677e641ef17e1d8cb1b3ca6e6f"
|
||||
dependencies = [
|
||||
"endi",
|
||||
"enumflags2",
|
||||
@@ -6207,9 +6260,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zvariant_derive"
|
||||
version = "5.5.3"
|
||||
version = "5.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "75fda702cd42d735ccd48117b1630432219c0e9616bf6cb0f8350844ee4d9580"
|
||||
checksum = "3a8c68501be459a8dbfffbe5d792acdd23b4959940fc87785fb013b32edbc208"
|
||||
dependencies = [
|
||||
"proc-macro-crate 3.3.0",
|
||||
"proc-macro2",
|
||||
|
||||
@@ -30,13 +30,16 @@ tauri-plugin-dialog = "2"
|
||||
tauri-plugin-macos-permissions = "2"
|
||||
directories = "6"
|
||||
reqwest = { version = "0.12", features = ["json", "stream"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
sysinfo = "0.35"
|
||||
tokio = { version = "1", features = ["full", "sync"] }
|
||||
sysinfo = "0.36"
|
||||
lazy_static = "1.4"
|
||||
base64 = "0.22"
|
||||
async-trait = "0.1"
|
||||
futures-util = "0.3"
|
||||
|
||||
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||
url = "2.5"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
|
||||
[target."cfg(any(target_os = \"macos\", windows, target_os = \"linux\"))".dependencies]
|
||||
tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
|
||||
|
||||
@@ -370,12 +370,15 @@ impl AutoUpdater {
|
||||
let mut registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::load()
|
||||
.map_err(|e| format!("Failed to load browser registry: {e}"))?;
|
||||
|
||||
// Get active browser versions
|
||||
// Get active browser versions (all profiles)
|
||||
let active_versions = registry.get_active_browser_versions(&profiles);
|
||||
|
||||
// Cleanup unused binaries
|
||||
// Get running browser versions (only running profiles)
|
||||
let running_versions = registry.get_running_browser_versions(&profiles);
|
||||
|
||||
// Cleanup unused binaries (but keep running ones)
|
||||
let cleaned_up = registry
|
||||
.cleanup_unused_binaries(&active_versions)
|
||||
.cleanup_unused_binaries(&active_versions, &running_versions)
|
||||
.map_err(|e| format!("Failed to cleanup unused binaries: {e}"))?;
|
||||
|
||||
// Save updated registry
|
||||
@@ -518,6 +521,7 @@ mod tests {
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -198,8 +198,8 @@ mod linux {
|
||||
}
|
||||
BrowserType::Camoufox => {
|
||||
vec![
|
||||
browser_subdir.join("camoufox"),
|
||||
browser_subdir.join("camoufox-bin"),
|
||||
browser_subdir.join("camoufox"),
|
||||
]
|
||||
}
|
||||
_ => vec![],
|
||||
|
||||
+243
-169
@@ -13,7 +13,7 @@ use crate::browser::{create_browser, BrowserType, ProxySettings};
|
||||
use crate::browser_version_service::{
|
||||
BrowserVersionInfo, BrowserVersionService, BrowserVersionsResult,
|
||||
};
|
||||
use crate::camoufox::CamoufoxConfig;
|
||||
use crate::camoufox_direct::CamoufoxConfig;
|
||||
use crate::download::{DownloadProgress, Downloader};
|
||||
use crate::downloaded_browsers::DownloadedBrowsersRegistry;
|
||||
use crate::extraction::Extractor;
|
||||
@@ -34,6 +34,8 @@ pub struct BrowserProfile {
|
||||
pub release_type: String, // "stable" or "nightly"
|
||||
#[serde(default)]
|
||||
pub camoufox_config: Option<CamoufoxConfig>, // Camoufox configuration
|
||||
#[serde(default)]
|
||||
pub group_id: Option<String>, // Reference to profile group
|
||||
}
|
||||
|
||||
fn default_release_type() -> String {
|
||||
@@ -1351,6 +1353,28 @@ impl BrowserRunner {
|
||||
release_type: &str,
|
||||
proxy_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
self.create_profile_with_group(
|
||||
name,
|
||||
browser,
|
||||
version,
|
||||
release_type,
|
||||
proxy_id,
|
||||
camoufox_config,
|
||||
None,
|
||||
)
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn create_profile_with_group(
|
||||
&self,
|
||||
name: &str,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
release_type: &str,
|
||||
proxy_id: Option<String>,
|
||||
camoufox_config: Option<CamoufoxConfig>,
|
||||
group_id: Option<String>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
|
||||
println!("Attempting to create profile: {name}");
|
||||
|
||||
@@ -1384,6 +1408,7 @@ impl BrowserRunner {
|
||||
last_launch: None,
|
||||
release_type: release_type.to_string(),
|
||||
camoufox_config: camoufox_config.clone(),
|
||||
group_id: group_id.clone(),
|
||||
};
|
||||
|
||||
// Save profile info
|
||||
@@ -1409,6 +1434,11 @@ impl BrowserRunner {
|
||||
self.disable_proxy_settings_in_profile(&profile_data_dir)?;
|
||||
}
|
||||
|
||||
// Perform cleanup after profile creation to remove any unused binaries
|
||||
if let Err(e) = self.cleanup_unused_binaries_internal() {
|
||||
println!("Warning: Failed to cleanup unused binaries after profile creation: {e}");
|
||||
}
|
||||
|
||||
Ok(profile)
|
||||
}
|
||||
|
||||
@@ -1591,7 +1621,7 @@ impl BrowserRunner {
|
||||
}
|
||||
|
||||
/// Internal method to cleanup unused binaries (used by auto-cleanup)
|
||||
fn cleanup_unused_binaries_internal(
|
||||
pub fn cleanup_unused_binaries_internal(
|
||||
&self,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Load current profiles
|
||||
@@ -1602,18 +1632,90 @@ impl BrowserRunner {
|
||||
// Load registry
|
||||
let mut registry = crate::downloaded_browsers::DownloadedBrowsersRegistry::load()?;
|
||||
|
||||
// Get active browser versions
|
||||
// Get active browser versions (all profiles)
|
||||
let active_versions = registry.get_active_browser_versions(&profiles);
|
||||
|
||||
// Cleanup unused binaries
|
||||
let cleaned_up = registry.cleanup_unused_binaries(&active_versions)?;
|
||||
// Get running browser versions (only running profiles)
|
||||
let running_versions = registry.get_running_browser_versions(&profiles);
|
||||
|
||||
// Save updated registry
|
||||
registry.save()?;
|
||||
// Get binaries directory
|
||||
let binaries_dir = self.get_binaries_dir();
|
||||
|
||||
// Use comprehensive cleanup that syncs registry with disk and removes unused binaries
|
||||
let cleaned_up =
|
||||
registry.comprehensive_cleanup(&binaries_dir, &active_versions, &running_versions)?;
|
||||
|
||||
// Registry is already saved by comprehensive_cleanup
|
||||
Ok(cleaned_up)
|
||||
}
|
||||
|
||||
pub fn assign_profiles_to_group(
|
||||
&self,
|
||||
profile_names: Vec<String>,
|
||||
group_id: Option<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let profiles = self.list_profiles()?;
|
||||
|
||||
for profile_name in profile_names {
|
||||
let mut profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile '{profile_name}' 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."
|
||||
).into());
|
||||
}
|
||||
|
||||
profile.group_id = group_id.clone();
|
||||
self.save_profile(&profile)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_multiple_profiles(
|
||||
&self,
|
||||
profile_names: Vec<String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let profiles = self.list_profiles()?;
|
||||
|
||||
for profile_name in profile_names {
|
||||
let profile = profiles
|
||||
.iter()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile '{profile_name}' 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."
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Delete the profile
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
let profile_uuid_dir = profiles_dir.join(profile.id.to_string());
|
||||
|
||||
if profile_uuid_dir.exists() {
|
||||
std::fs::remove_dir_all(&profile_uuid_dir)?;
|
||||
}
|
||||
}
|
||||
|
||||
// Always perform cleanup after profile deletion to remove unused binaries
|
||||
if let Err(e) = self.cleanup_unused_binaries_internal() {
|
||||
println!("Warning: Failed to cleanup unused binaries: {e}");
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn get_common_firefox_preferences(&self) -> Vec<String> {
|
||||
vec![
|
||||
// Disable default browser check
|
||||
@@ -1782,7 +1884,7 @@ impl BrowserRunner {
|
||||
url: Option<String>,
|
||||
local_proxy_settings: Option<&ProxySettings>,
|
||||
) -> Result<BrowserProfile, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Handle camoufox profiles specially
|
||||
// Handle camoufox profiles specially using only the direct launcher
|
||||
if profile.browser == "camoufox" {
|
||||
if let Some(mut camoufox_config) = profile.camoufox_config.clone() {
|
||||
// Handle proxy settings for camoufox
|
||||
@@ -1840,11 +1942,29 @@ impl BrowserRunner {
|
||||
}
|
||||
}
|
||||
|
||||
// Use the camoufox launcher
|
||||
let camoufox_result = crate::camoufox::launch_camoufox_profile(
|
||||
// Use the existing config or create a test config if none exists
|
||||
let final_config = if camoufox_config.timezone.is_some()
|
||||
|| camoufox_config.screen_min_width.is_some()
|
||||
|| camoufox_config.window_width.is_some()
|
||||
{
|
||||
camoufox_config.clone()
|
||||
} else {
|
||||
// No meaningful config provided, use test config to ensure anti-fingerprinting works
|
||||
println!("No Camoufox configuration provided, using test configuration");
|
||||
let mut test_config =
|
||||
crate::camoufox_direct::CamoufoxDirectLauncher::create_test_config();
|
||||
// Preserve any proxy settings from the original config
|
||||
test_config.proxy = camoufox_config.proxy.clone();
|
||||
test_config.headless = camoufox_config.headless;
|
||||
test_config.debug = Some(true); // Enable debug for troubleshooting
|
||||
test_config
|
||||
};
|
||||
|
||||
// Use the direct camoufox launcher
|
||||
let camoufox_result = crate::camoufox_direct::launch_camoufox_profile_direct(
|
||||
app_handle.clone(),
|
||||
profile.clone(),
|
||||
camoufox_config,
|
||||
final_config,
|
||||
url,
|
||||
)
|
||||
.await
|
||||
@@ -2046,9 +2166,10 @@ impl BrowserRunner {
|
||||
url: &str,
|
||||
_internal_proxy_settings: Option<&ProxySettings>,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Handle camoufox profiles specially
|
||||
// Handle camoufox profiles specially using only the direct launcher
|
||||
if profile.browser == "camoufox" {
|
||||
let camoufox_launcher = crate::camoufox::CamoufoxLauncher::new(app_handle.clone());
|
||||
let camoufox_launcher =
|
||||
crate::camoufox_direct::CamoufoxDirectLauncher::new(app_handle.clone());
|
||||
|
||||
// Get the profile path based on the UUID
|
||||
let profiles_dir = self.get_profiles_dir();
|
||||
@@ -2066,9 +2187,9 @@ impl BrowserRunner {
|
||||
profile.name
|
||||
);
|
||||
|
||||
// For Camoufox, we need to launch a new instance with the URL since nodecar doesn't support
|
||||
// opening URLs in existing instances. This is a limitation of the anti-detect architecture.
|
||||
return Err("Camoufox does not support opening URLs in existing instances. Please close the browser and relaunch it with the new URL.".into());
|
||||
// For Camoufox, we need to launch a new instance with the URL since it doesn't support remote commands
|
||||
// This is a limitation of Camoufox's architecture
|
||||
return Err("Camoufox doesn't support opening URLs in existing instances. Please close the browser and launch again with the URL.".into());
|
||||
}
|
||||
Ok(None) => {
|
||||
return Err("Camoufox browser is not running".into());
|
||||
@@ -2240,7 +2361,7 @@ impl BrowserRunner {
|
||||
}
|
||||
BrowserType::Camoufox => {
|
||||
// This should never be reached due to the early return above, but handle it just in case
|
||||
Err("Camoufox does not support opening URLs in existing instances".into())
|
||||
Err("Camoufox URL opening should be handled in the early return above".into())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2447,77 +2568,8 @@ impl BrowserRunner {
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Handle camoufox profiles specially using the camoufox launcher
|
||||
if profile.browser == "camoufox" {
|
||||
let camoufox_launcher = crate::camoufox::CamoufoxLauncher::new(app_handle.clone());
|
||||
|
||||
// Get the profile path based on the UUID
|
||||
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();
|
||||
|
||||
println!("Checking Camoufox status for profile: {}", profile.name);
|
||||
println!("Profile UUID: {}", profile.id);
|
||||
println!("Profile path: {profile_path_str}");
|
||||
|
||||
match camoufox_launcher
|
||||
.find_camoufox_by_profile(&profile_path_str)
|
||||
.await
|
||||
{
|
||||
Ok(Some(camoufox_process)) => {
|
||||
// Found a running camoufox process for this profile
|
||||
println!(
|
||||
"Found running Camoufox process for profile {}: {:?}",
|
||||
profile.name, camoufox_process
|
||||
);
|
||||
|
||||
// Update the profile with the current PID if it's different
|
||||
if let Some(pid) = camoufox_process.pid {
|
||||
if profile.process_id != Some(pid) {
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.process_id = Some(pid);
|
||||
if let Err(e) = self.save_profile(&updated_profile) {
|
||||
println!("Warning: Failed to update profile PID: {e}");
|
||||
} else {
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
Ok(None) => {
|
||||
// No running camoufox process found for this profile
|
||||
println!(
|
||||
"No running Camoufox process found for profile: {}",
|
||||
profile.name
|
||||
);
|
||||
|
||||
// Clear the PID if one was stored
|
||||
if profile.process_id.is_some() {
|
||||
let mut updated_profile = profile.clone();
|
||||
updated_profile.process_id = None;
|
||||
if let Err(e) = self.save_profile(&updated_profile) {
|
||||
println!("Warning: Failed to clear profile PID: {e}");
|
||||
} else {
|
||||
// Emit profile update event to frontend
|
||||
if let Err(e) = app_handle.emit("profile-updated", &updated_profile) {
|
||||
println!("Warning: Failed to emit profile update event: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(false);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error checking Camoufox status: {e}");
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Handle camoufox profiles using the same fast approach as other browsers
|
||||
// No special handling needed - camoufox uses the same process checking logic
|
||||
|
||||
// For non-camoufox browsers, use the existing logic
|
||||
let mut inner_profile = profile.clone();
|
||||
@@ -2535,12 +2587,13 @@ impl BrowserRunner {
|
||||
let profile_data_path_str = profile_data_path.to_string_lossy();
|
||||
let profile_path_match = cmd.iter().any(|s| {
|
||||
let arg = s.to_str().unwrap_or("");
|
||||
// For Firefox-based browsers, check for exact profile path match
|
||||
// For Firefox-based browsers (including camoufox), check for exact profile path match
|
||||
if profile.browser == "tor-browser"
|
||||
|| profile.browser == "firefox"
|
||||
|| profile.browser == "firefox-developer"
|
||||
|| profile.browser == "mullvad-browser"
|
||||
|| profile.browser == "zen"
|
||||
|| profile.browser == "camoufox"
|
||||
{
|
||||
arg == profile_data_path_str
|
||||
|| arg == format!("-profile={profile_data_path_str}")
|
||||
@@ -2583,6 +2636,7 @@ impl BrowserRunner {
|
||||
&& !exe_name.contains("developer")
|
||||
&& !exe_name.contains("tor")
|
||||
&& !exe_name.contains("mullvad")
|
||||
&& !exe_name.contains("camoufox")
|
||||
}
|
||||
"firefox-developer" => exe_name.contains("firefox") && exe_name.contains("developer"),
|
||||
"mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser"),
|
||||
@@ -2590,6 +2644,13 @@ impl BrowserRunner {
|
||||
"zen" => exe_name.contains("zen"),
|
||||
"chromium" => exe_name.contains("chromium"),
|
||||
"brave" => exe_name.contains("brave"),
|
||||
"camoufox" => {
|
||||
exe_name.contains("camoufox")
|
||||
|| (exe_name.contains("firefox")
|
||||
&& cmd
|
||||
.iter()
|
||||
.any(|arg| arg.to_str().unwrap_or("").contains("camoufox")))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -2662,66 +2723,99 @@ impl BrowserRunner {
|
||||
Ok(is_running)
|
||||
}
|
||||
|
||||
pub fn update_camoufox_config(
|
||||
&self,
|
||||
profile_name: &str,
|
||||
config: crate::camoufox_direct::CamoufoxConfig,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Find the profile by name
|
||||
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"))?;
|
||||
|
||||
// Check if the browser is currently running
|
||||
if profile.process_id.is_some() {
|
||||
return Err(
|
||||
"Cannot update Camoufox configuration while browser is running. Please stop the browser first.".into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Update the Camoufox configuration
|
||||
profile.camoufox_config = Some(config);
|
||||
|
||||
// Save the updated profile
|
||||
self.save_profile(&profile)?;
|
||||
|
||||
println!("Camoufox configuration updated for profile '{profile_name}'.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn kill_browser_process(
|
||||
&self,
|
||||
app_handle: tauri::AppHandle,
|
||||
profile: &BrowserProfile,
|
||||
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
|
||||
// Handle camoufox profiles specially
|
||||
// Handle camoufox profiles specially using only the direct launcher
|
||||
if profile.browser == "camoufox" {
|
||||
let camoufox_launcher = crate::camoufox::CamoufoxLauncher::new(app_handle.clone());
|
||||
let camoufox_launcher =
|
||||
crate::camoufox_direct::CamoufoxDirectLauncher::new(app_handle.clone());
|
||||
|
||||
// Get the profile path based on the UUID
|
||||
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();
|
||||
|
||||
println!(
|
||||
"Attempting to kill Camoufox process for profile: {}",
|
||||
profile.name
|
||||
);
|
||||
println!("Profile UUID: {}", profile.id);
|
||||
println!("Profile path: {profile_path_str}");
|
||||
|
||||
match camoufox_launcher
|
||||
.find_camoufox_by_profile(&profile_path_str)
|
||||
.await
|
||||
{
|
||||
Ok(Some(camoufox_process)) => {
|
||||
println!(
|
||||
"Found running Camoufox process for profile {}: {:?}",
|
||||
profile.name, camoufox_process
|
||||
);
|
||||
|
||||
// Stop the camoufox process using the launcher
|
||||
match camoufox_launcher.stop_camoufox(&camoufox_process.id).await {
|
||||
Ok(stopped) => {
|
||||
if stopped {
|
||||
println!(
|
||||
"Successfully stopped Camoufox process: {}",
|
||||
camoufox_process.id
|
||||
);
|
||||
} else {
|
||||
println!("Failed to stop Camoufox process: {}", camoufox_process.id);
|
||||
return Err("Failed to stop Camoufox process".into());
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error stopping Camoufox process: {e}");
|
||||
return Err(format!("Error stopping Camoufox process: {e}").into());
|
||||
// Try to stop by PID first (faster)
|
||||
if let Some(stored_pid) = profile.process_id {
|
||||
match camoufox_launcher
|
||||
.stop_camoufox(&stored_pid.to_string())
|
||||
.await
|
||||
{
|
||||
Ok(stopped) => {
|
||||
if stopped {
|
||||
println!("Successfully stopped Camoufox process by PID: {stored_pid}");
|
||||
} else {
|
||||
println!("Failed to stop Camoufox process by PID: {stored_pid}");
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error stopping Camoufox process by PID: {e}");
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
println!(
|
||||
"No running Camoufox process found for profile: {}",
|
||||
profile.name
|
||||
);
|
||||
// Process might already be stopped, just clear the PID
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error finding Camoufox process: {e}");
|
||||
return Err(format!("Error finding Camoufox process: {e}").into());
|
||||
} else {
|
||||
// Fallback: search by profile path
|
||||
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();
|
||||
|
||||
match camoufox_launcher
|
||||
.find_camoufox_by_profile(&profile_path_str)
|
||||
.await
|
||||
{
|
||||
Ok(Some(camoufox_process)) => {
|
||||
match camoufox_launcher.stop_camoufox(&camoufox_process.id).await {
|
||||
Ok(stopped) => {
|
||||
if stopped {
|
||||
println!(
|
||||
"Successfully stopped Camoufox process: {}",
|
||||
camoufox_process.id
|
||||
);
|
||||
} else {
|
||||
println!("Failed to stop Camoufox process: {}", camoufox_process.id);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error stopping Camoufox process: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
println!(
|
||||
"No running Camoufox process found for profile: {}",
|
||||
profile.name
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
println!("Error finding Camoufox process: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2766,6 +2860,7 @@ impl BrowserRunner {
|
||||
&& !exe_name.contains("developer")
|
||||
&& !exe_name.contains("tor")
|
||||
&& !exe_name.contains("mullvad")
|
||||
&& !exe_name.contains("camoufox")
|
||||
}
|
||||
"firefox-developer" => exe_name.contains("firefox") && exe_name.contains("developer"),
|
||||
"mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser"),
|
||||
@@ -2773,6 +2868,13 @@ impl BrowserRunner {
|
||||
"zen" => exe_name.contains("zen"),
|
||||
"chromium" => exe_name.contains("chromium"),
|
||||
"brave" => exe_name.contains("brave"),
|
||||
"camoufox" => {
|
||||
exe_name.contains("camoufox")
|
||||
|| (exe_name.contains("firefox")
|
||||
&& cmd
|
||||
.iter()
|
||||
.any(|arg| arg.to_str().unwrap_or("").contains("camoufox")))
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
|
||||
@@ -3098,14 +3200,14 @@ impl BrowserRunner {
|
||||
}
|
||||
|
||||
// Mark download as completed in registry
|
||||
let actual_version = if browser_str == "chromium" {
|
||||
let _actual_version = if browser_str == "chromium" {
|
||||
Some(version.clone())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
registry
|
||||
.mark_download_completed_with_actual_version(&browser_str, &version, actual_version)
|
||||
.mark_download_completed(&browser_str, &version)
|
||||
.map_err(|e| format!("Failed to mark download as completed: {e}"))?;
|
||||
registry
|
||||
.save()
|
||||
@@ -3180,34 +3282,6 @@ impl BrowserRunner {
|
||||
|
||||
files_exist
|
||||
}
|
||||
|
||||
/// Update camoufox configuration for a profile
|
||||
pub fn update_camoufox_config(
|
||||
&self,
|
||||
profile_name: &str,
|
||||
config: CamoufoxConfig,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut profiles = self.list_profiles()?;
|
||||
|
||||
// Find the profile to update
|
||||
let profile = profiles
|
||||
.iter_mut()
|
||||
.find(|p| p.name == profile_name)
|
||||
.ok_or_else(|| format!("Profile '{profile_name}' not found"))?;
|
||||
|
||||
// Ensure the profile is a camoufox profile
|
||||
if profile.browser != "camoufox" {
|
||||
return Err(format!("Profile '{profile_name}' is not a camoufox profile").into());
|
||||
}
|
||||
|
||||
// Update the camoufox configuration
|
||||
profile.camoufox_config = Some(config);
|
||||
|
||||
// Save the updated profile
|
||||
self.save_profile(profile)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl BrowserProfile {
|
||||
|
||||
@@ -1,607 +0,0 @@
|
||||
use crate::browser_runner::BrowserProfile;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use tauri::AppHandle;
|
||||
use tauri_plugin_shell::ShellExt;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CamoufoxConfig {
|
||||
pub os: Option<Vec<String>>,
|
||||
pub block_images: Option<bool>,
|
||||
pub block_webrtc: Option<bool>,
|
||||
pub block_webgl: Option<bool>,
|
||||
pub disable_coop: Option<bool>,
|
||||
pub geoip: Option<serde_json::Value>, // Can be String or bool
|
||||
pub country: Option<String>,
|
||||
pub timezone: Option<String>,
|
||||
pub latitude: Option<f64>,
|
||||
pub longitude: Option<f64>,
|
||||
pub humanize: Option<bool>,
|
||||
pub humanize_duration: Option<f64>,
|
||||
pub headless: Option<bool>,
|
||||
pub locale: Option<Vec<String>>,
|
||||
pub addons: Option<Vec<String>>,
|
||||
pub fonts: Option<Vec<String>>,
|
||||
pub custom_fonts_only: Option<bool>,
|
||||
pub exclude_addons: Option<Vec<String>>,
|
||||
pub screen_min_width: Option<u32>,
|
||||
pub screen_max_width: Option<u32>,
|
||||
pub screen_min_height: Option<u32>,
|
||||
pub screen_max_height: Option<u32>,
|
||||
pub window_width: Option<u32>,
|
||||
pub window_height: Option<u32>,
|
||||
pub ff_version: Option<u32>,
|
||||
pub main_world_eval: Option<bool>,
|
||||
pub webgl_vendor: Option<String>,
|
||||
pub webgl_renderer: Option<String>,
|
||||
pub proxy: Option<String>,
|
||||
pub enable_cache: Option<bool>,
|
||||
pub virtual_display: Option<String>,
|
||||
pub debug: Option<bool>,
|
||||
pub additional_args: Option<Vec<String>>,
|
||||
pub env_vars: Option<HashMap<String, String>>,
|
||||
pub firefox_prefs: Option<HashMap<String, serde_json::Value>>,
|
||||
}
|
||||
|
||||
impl Default for CamoufoxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
os: None,
|
||||
block_images: None,
|
||||
block_webrtc: None,
|
||||
block_webgl: None,
|
||||
disable_coop: None,
|
||||
geoip: None,
|
||||
country: None,
|
||||
timezone: None,
|
||||
latitude: None,
|
||||
longitude: None,
|
||||
humanize: None,
|
||||
humanize_duration: None,
|
||||
headless: None,
|
||||
locale: None,
|
||||
addons: None,
|
||||
fonts: None,
|
||||
custom_fonts_only: None,
|
||||
exclude_addons: None,
|
||||
screen_min_width: None,
|
||||
screen_max_width: None,
|
||||
screen_min_height: None,
|
||||
screen_max_height: None,
|
||||
window_width: None,
|
||||
window_height: None,
|
||||
ff_version: None,
|
||||
main_world_eval: None,
|
||||
webgl_vendor: None,
|
||||
webgl_renderer: None,
|
||||
proxy: None,
|
||||
enable_cache: Some(true), // Cache enabled by default
|
||||
virtual_display: None,
|
||||
debug: None,
|
||||
additional_args: None,
|
||||
env_vars: None,
|
||||
firefox_prefs: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[allow(non_snake_case)]
|
||||
pub struct CamoufoxLaunchResult {
|
||||
pub id: String,
|
||||
pub pid: Option<u32>,
|
||||
#[serde(alias = "executable_path")]
|
||||
pub executablePath: String,
|
||||
#[serde(alias = "profile_path")]
|
||||
pub profilePath: String,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
pub struct CamoufoxLauncher {
|
||||
app_handle: AppHandle,
|
||||
}
|
||||
|
||||
impl CamoufoxLauncher {
|
||||
pub fn new(app_handle: AppHandle) -> Self {
|
||||
Self { app_handle }
|
||||
}
|
||||
|
||||
/// Launch Camoufox browser with the specified configuration
|
||||
pub async fn launch_camoufox(
|
||||
&self,
|
||||
executable_path: &str,
|
||||
profile_path: &str,
|
||||
config: &CamoufoxConfig,
|
||||
url: Option<&str>,
|
||||
) -> Result<CamoufoxLaunchResult, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Launching Camoufox with executable: {executable_path}");
|
||||
println!("Profile path: {profile_path}");
|
||||
println!("URL: {url:?}");
|
||||
|
||||
// Use Tauri's sidecar to call nodecar
|
||||
let mut sidecar = self
|
||||
.app_handle
|
||||
.shell()
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
|
||||
.arg("camoufox")
|
||||
.arg("launch")
|
||||
.arg("--executable-path")
|
||||
.arg(executable_path)
|
||||
.arg("--profile-path")
|
||||
.arg(profile_path);
|
||||
|
||||
// Add URL if provided
|
||||
if let Some(url) = url {
|
||||
sidecar = sidecar.arg("--url").arg(url);
|
||||
}
|
||||
|
||||
// Add configuration options
|
||||
if let Some(os_list) = &config.os {
|
||||
sidecar = sidecar.arg("--os").arg(os_list.join(","));
|
||||
}
|
||||
|
||||
if config.block_images.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--block-images");
|
||||
}
|
||||
|
||||
if config.block_webrtc.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--block-webrtc");
|
||||
}
|
||||
|
||||
if config.block_webgl.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--block-webgl");
|
||||
}
|
||||
|
||||
if config.disable_coop.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--disable-coop");
|
||||
}
|
||||
|
||||
if let Some(geoip) = &config.geoip {
|
||||
match geoip {
|
||||
serde_json::Value::String(s) => {
|
||||
sidecar = sidecar.arg("--geoip").arg(s);
|
||||
}
|
||||
serde_json::Value::Bool(b) => {
|
||||
sidecar = sidecar
|
||||
.arg("--geoip")
|
||||
.arg(if *b { "auto" } else { "false" });
|
||||
}
|
||||
_ => {
|
||||
sidecar = sidecar.arg("--geoip").arg(geoip.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(country) = &config.country {
|
||||
sidecar = sidecar.arg("--country").arg(country);
|
||||
}
|
||||
|
||||
if let Some(timezone) = &config.timezone {
|
||||
sidecar = sidecar.arg("--timezone").arg(timezone);
|
||||
}
|
||||
|
||||
if let Some(latitude) = config.latitude {
|
||||
if let Some(longitude) = config.longitude {
|
||||
sidecar = sidecar.arg("--latitude").arg(latitude.to_string());
|
||||
sidecar = sidecar.arg("--longitude").arg(longitude.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(humanize) = config.humanize {
|
||||
if humanize {
|
||||
if let Some(duration) = config.humanize_duration {
|
||||
sidecar = sidecar.arg("--humanize").arg(duration.to_string());
|
||||
} else {
|
||||
sidecar = sidecar.arg("--humanize");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if config.headless.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--headless");
|
||||
}
|
||||
|
||||
if let Some(locale_list) = &config.locale {
|
||||
sidecar = sidecar.arg("--locale").arg(locale_list.join(","));
|
||||
}
|
||||
|
||||
if let Some(addons_list) = &config.addons {
|
||||
sidecar = sidecar.arg("--addons").arg(addons_list.join(","));
|
||||
}
|
||||
|
||||
if let Some(fonts_list) = &config.fonts {
|
||||
sidecar = sidecar.arg("--fonts").arg(fonts_list.join(","));
|
||||
}
|
||||
|
||||
if config.custom_fonts_only.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--custom-fonts-only");
|
||||
}
|
||||
|
||||
if let Some(exclude_addons_list) = &config.exclude_addons {
|
||||
sidecar = sidecar
|
||||
.arg("--exclude-addons")
|
||||
.arg(exclude_addons_list.join(","));
|
||||
}
|
||||
|
||||
// Screen size configuration
|
||||
if let Some(width) = config.screen_min_width {
|
||||
sidecar = sidecar.arg("--screen-min-width").arg(width.to_string());
|
||||
}
|
||||
|
||||
if let Some(width) = config.screen_max_width {
|
||||
sidecar = sidecar.arg("--screen-max-width").arg(width.to_string());
|
||||
}
|
||||
|
||||
if let Some(height) = config.screen_min_height {
|
||||
sidecar = sidecar.arg("--screen-min-height").arg(height.to_string());
|
||||
}
|
||||
|
||||
if let Some(height) = config.screen_max_height {
|
||||
sidecar = sidecar.arg("--screen-max-height").arg(height.to_string());
|
||||
}
|
||||
|
||||
if let Some(width) = config.window_width {
|
||||
sidecar = sidecar.arg("--window-width").arg(width.to_string());
|
||||
}
|
||||
|
||||
if let Some(height) = config.window_height {
|
||||
sidecar = sidecar.arg("--window-height").arg(height.to_string());
|
||||
}
|
||||
|
||||
// Advanced options
|
||||
if let Some(ff_version) = config.ff_version {
|
||||
sidecar = sidecar.arg("--ff-version").arg(ff_version.to_string());
|
||||
}
|
||||
|
||||
if config.main_world_eval.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--main-world-eval");
|
||||
}
|
||||
|
||||
if let Some(vendor) = &config.webgl_vendor {
|
||||
if let Some(renderer) = &config.webgl_renderer {
|
||||
sidecar = sidecar.arg("--webgl-vendor").arg(vendor);
|
||||
sidecar = sidecar.arg("--webgl-renderer").arg(renderer);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(proxy) = &config.proxy {
|
||||
sidecar = sidecar.arg("--proxy").arg(proxy);
|
||||
}
|
||||
|
||||
// Cache is enabled by default, only add flag if disabled
|
||||
if !config.enable_cache.unwrap_or(true) {
|
||||
sidecar = sidecar.arg("--disable-cache");
|
||||
}
|
||||
|
||||
if let Some(virtual_display) = &config.virtual_display {
|
||||
sidecar = sidecar.arg("--virtual-display").arg(virtual_display);
|
||||
}
|
||||
|
||||
if config.debug.unwrap_or(false) {
|
||||
sidecar = sidecar.arg("--debug");
|
||||
}
|
||||
|
||||
if let Some(args) = &config.additional_args {
|
||||
sidecar = sidecar.arg("--args").arg(args.join(","));
|
||||
}
|
||||
|
||||
if let Some(env_vars) = &config.env_vars {
|
||||
let env_json = serde_json::to_string(env_vars)
|
||||
.map_err(|e| format!("Failed to serialize environment variables: {e}"))?;
|
||||
sidecar = sidecar.arg("--env").arg(env_json);
|
||||
}
|
||||
|
||||
if let Some(firefox_prefs) = &config.firefox_prefs {
|
||||
let prefs_json = serde_json::to_string(firefox_prefs)
|
||||
.map_err(|e| format!("Failed to serialize Firefox preferences: {e}"))?;
|
||||
sidecar = sidecar.arg("--firefox-prefs").arg(prefs_json);
|
||||
}
|
||||
|
||||
// Execute the command
|
||||
println!("Executing nodecar command...");
|
||||
let output = sidecar
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute nodecar command: {e}"))?;
|
||||
|
||||
// Check the command status first
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout_msg = String::from_utf8_lossy(&output.stdout);
|
||||
return Err(
|
||||
format!(
|
||||
"Failed to launch Camoufox: Command failed with status {:?}\nstderr: {}\nstdout: {}",
|
||||
output.status, error_msg, stdout_msg
|
||||
)
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
|
||||
// Parse the JSON response
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("Nodecar stdout: {stdout}");
|
||||
|
||||
// Try to parse the JSON response
|
||||
let result: CamoufoxLaunchResult = serde_json::from_str(&stdout)
|
||||
.map_err(|e| format!("Failed to parse nodecar response as JSON: {e}\nResponse: {stdout}"))?;
|
||||
|
||||
println!("Successfully launched Camoufox with ID: {}", result.id);
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Stop a Camoufox process by ID
|
||||
pub async fn stop_camoufox(
|
||||
&self,
|
||||
id: &str,
|
||||
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Stopping Camoufox process with ID: {id}");
|
||||
|
||||
// First, we need to find the process to get its executable and profile paths
|
||||
let processes = self.list_camoufox_processes().await?;
|
||||
let target_process = processes.iter().find(|p| p.id == id);
|
||||
|
||||
if let Some(process) = target_process {
|
||||
println!(
|
||||
"Found process to stop: executable={}, profile={}",
|
||||
process.executablePath, process.profilePath
|
||||
);
|
||||
|
||||
let sidecar = self
|
||||
.app_handle
|
||||
.shell()
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
|
||||
.arg("camoufox")
|
||||
.arg("stop")
|
||||
.arg("--executable-path")
|
||||
.arg(&process.executablePath)
|
||||
.arg("--profile-path")
|
||||
.arg(&process.profilePath)
|
||||
.arg("--id")
|
||||
.arg(id);
|
||||
|
||||
let output = sidecar
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute nodecar stop command: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
let stdout_msg = String::from_utf8_lossy(&output.stdout);
|
||||
println!("Failed to stop Camoufox process - stderr: {error_msg}, stdout: {stdout_msg}");
|
||||
return Err(format!("Failed to stop Camoufox process: {error_msg}").into());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
println!("Stop command result: {stdout}");
|
||||
|
||||
// Parse the JSON response which contains a "success" field
|
||||
let response: serde_json::Value = serde_json::from_str(&stdout)
|
||||
.map_err(|e| format!("Failed to parse stop response as JSON: {e}\nResponse: {stdout}"))?;
|
||||
|
||||
let success = response
|
||||
.get("success")
|
||||
.and_then(|v| v.as_bool())
|
||||
.ok_or_else(|| {
|
||||
format!("Invalid response format - missing or invalid 'success' field: {stdout}")
|
||||
})?;
|
||||
|
||||
if success {
|
||||
println!("Successfully stopped Camoufox process: {id}");
|
||||
} else {
|
||||
println!("Failed to stop Camoufox process: {id} (process may not exist)");
|
||||
}
|
||||
|
||||
Ok(success)
|
||||
} else {
|
||||
println!("Camoufox process with ID {id} not found in running processes");
|
||||
// If we can't find the process, it might already be stopped
|
||||
Ok(false)
|
||||
}
|
||||
}
|
||||
|
||||
/// List all Camoufox processes
|
||||
pub async fn list_camoufox_processes(
|
||||
&self,
|
||||
) -> Result<Vec<CamoufoxLaunchResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Listing Camoufox processes...");
|
||||
|
||||
// For the list command, we need to provide dummy executable-path and profile-path
|
||||
// even though they're not used by the list action
|
||||
let sidecar = self
|
||||
.app_handle
|
||||
.shell()
|
||||
.sidecar("nodecar")
|
||||
.map_err(|e| format!("Failed to create nodecar sidecar: {e}"))?
|
||||
.arg("camoufox")
|
||||
.arg("list")
|
||||
.arg("--executable-path")
|
||||
.arg("/dummy/path") // Dummy path since list doesn't use it
|
||||
.arg("--profile-path")
|
||||
.arg("/dummy/profile"); // Dummy path since list doesn't use it
|
||||
|
||||
let output = sidecar
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| format!("Failed to execute nodecar list command: {e}"))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let error_msg = String::from_utf8_lossy(&output.stderr);
|
||||
return Err(format!("Failed to list Camoufox processes: {error_msg}").into());
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
println!("List command result: {stdout}");
|
||||
|
||||
// Parse the response as an array of process info
|
||||
let processes: Vec<serde_json::Value> =
|
||||
serde_json::from_str(&stdout).map_err(|e| format!("Failed to parse list response: {e}"))?;
|
||||
|
||||
// Convert to CamoufoxLaunchResult format
|
||||
let mut results = Vec::new();
|
||||
for process in processes {
|
||||
// Handle both camelCase and snake_case formats from nodecar
|
||||
let id = process.get("id").and_then(|v| v.as_str());
|
||||
|
||||
// Try both formats for executable path
|
||||
let executable_path = process
|
||||
.get("executable_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| process.get("executablePath").and_then(|v| v.as_str()));
|
||||
|
||||
// Try both formats for profile path
|
||||
let profile_path = process
|
||||
.get("profile_path")
|
||||
.and_then(|v| v.as_str())
|
||||
.or_else(|| process.get("profilePath").and_then(|v| v.as_str()));
|
||||
|
||||
if let (Some(id), Some(executable_path), Some(profile_path)) =
|
||||
(id, executable_path, profile_path)
|
||||
{
|
||||
let pid = process
|
||||
.get("pid")
|
||||
.and_then(|v| v.as_u64())
|
||||
.map(|v| v as u32);
|
||||
|
||||
let url = process
|
||||
.get("url")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
results.push(CamoufoxLaunchResult {
|
||||
id: id.to_string(),
|
||||
pid,
|
||||
executablePath: executable_path.to_string(),
|
||||
profilePath: profile_path.to_string(),
|
||||
url,
|
||||
});
|
||||
} else {
|
||||
println!("Skipping malformed process entry: {process:?}");
|
||||
}
|
||||
}
|
||||
|
||||
println!("Parsed {} valid Camoufox processes", results.len());
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
/// Find Camoufox process by profile path (for integration with browser_runner)
|
||||
pub async fn find_camoufox_by_profile(
|
||||
&self,
|
||||
profile_path: &str,
|
||||
) -> Result<Option<CamoufoxLaunchResult>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
println!("Looking for Camoufox process with profile path: {profile_path}");
|
||||
|
||||
let processes = self.list_camoufox_processes().await?;
|
||||
println!("Found {} running Camoufox processes", processes.len());
|
||||
|
||||
for process in &processes {
|
||||
println!(
|
||||
"Checking process with profile path: {}",
|
||||
process.profilePath
|
||||
);
|
||||
}
|
||||
|
||||
// Convert both paths to canonical form for comparison
|
||||
let target_path = std::path::Path::new(profile_path)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
|
||||
|
||||
for process in &processes {
|
||||
println!(
|
||||
"Comparing target path: {} with process path: {}",
|
||||
target_path.display(),
|
||||
process.profilePath
|
||||
);
|
||||
|
||||
// Try multiple comparison methods
|
||||
let process_path = std::path::Path::new(&process.profilePath)
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| std::path::Path::new(&process.profilePath).to_path_buf());
|
||||
|
||||
// Method 1: Canonical path comparison
|
||||
if process_path == target_path {
|
||||
println!("Found match using canonical path comparison");
|
||||
return Ok(Some(process.clone()));
|
||||
}
|
||||
|
||||
// Method 2: Direct string comparison
|
||||
if process.profilePath == profile_path {
|
||||
println!("Found match using direct string comparison");
|
||||
return Ok(Some(process.clone()));
|
||||
}
|
||||
|
||||
// Method 3: Compare as strings after canonicalization
|
||||
if process_path.to_string_lossy() == target_path.to_string_lossy() {
|
||||
println!("Found match using canonical string comparison");
|
||||
return Ok(Some(process.clone()));
|
||||
}
|
||||
|
||||
// Method 4: Compare file names if full paths don't match
|
||||
if let (Some(process_file), Some(target_file)) =
|
||||
(process_path.file_name(), target_path.file_name())
|
||||
{
|
||||
if process_file == target_file {
|
||||
// If the parent directories also match, it's likely the same profile
|
||||
if let (Some(process_parent), Some(target_parent)) =
|
||||
(process_path.parent(), target_path.parent())
|
||||
{
|
||||
if process_parent == target_parent {
|
||||
println!("Found match using parent directory and file name comparison");
|
||||
return Ok(Some(process.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Method 5: Check if either path contains the other (for symlinks or different representations)
|
||||
let process_path_str = process_path.to_string_lossy();
|
||||
let target_path_str = target_path.to_string_lossy();
|
||||
|
||||
if process_path_str.contains(target_path_str.as_ref())
|
||||
|| target_path_str.contains(process_path_str.as_ref())
|
||||
{
|
||||
println!("Found match using path containment check");
|
||||
return Ok(Some(process.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
println!("No matching Camoufox process found for profile path: {profile_path}");
|
||||
Ok(None)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn launch_camoufox_profile(
|
||||
app_handle: AppHandle,
|
||||
profile: BrowserProfile,
|
||||
config: CamoufoxConfig,
|
||||
url: Option<String>,
|
||||
) -> Result<CamoufoxLaunchResult, String> {
|
||||
let launcher = CamoufoxLauncher::new(app_handle);
|
||||
|
||||
// Get the executable path for Camoufox
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::new();
|
||||
let binaries_dir = browser_runner.get_binaries_dir();
|
||||
let browser_dir = binaries_dir.join("camoufox").join(&profile.version);
|
||||
|
||||
// Get executable path
|
||||
let browser = crate::browser::create_browser(crate::browser::BrowserType::Camoufox);
|
||||
let executable_path = browser
|
||||
.get_executable_path(&browser_dir)
|
||||
.map_err(|e| format!("Failed to get Camoufox executable path: {e}"))?;
|
||||
|
||||
// Get profile path
|
||||
let profiles_dir = browser_runner.get_profiles_dir();
|
||||
let profile_path = profile.get_profile_data_path(&profiles_dir);
|
||||
|
||||
launcher
|
||||
.launch_camoufox(
|
||||
&executable_path.to_string_lossy(),
|
||||
&profile_path.to_string_lossy(),
|
||||
&config,
|
||||
url.as_deref(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to launch Camoufox: {e}"))
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,13 +8,7 @@ use std::path::PathBuf;
|
||||
pub struct DownloadedBrowserInfo {
|
||||
pub browser: String,
|
||||
pub version: String,
|
||||
pub download_date: u64,
|
||||
pub file_path: PathBuf,
|
||||
pub verified: bool,
|
||||
pub actual_version: Option<String>, // For browsers like Chromium where we track the actual version
|
||||
pub file_size: Option<u64>, // For tracking file size changes (useful for rolling releases)
|
||||
#[serde(default)] // Add default value (false) for backwards compatibility
|
||||
pub is_rolling_release: bool, // True for Zen's twilight releases and other rolling releases
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, Default)]
|
||||
@@ -82,66 +76,39 @@ impl DownloadedBrowsersRegistry {
|
||||
.browsers
|
||||
.get(browser)
|
||||
.and_then(|versions| versions.get(version))
|
||||
.map(|info| info.verified)
|
||||
.unwrap_or(false)
|
||||
.is_some()
|
||||
}
|
||||
|
||||
pub fn get_downloaded_versions(&self, browser: &str) -> Vec<String> {
|
||||
self
|
||||
.browsers
|
||||
.get(browser)
|
||||
.map(|versions| {
|
||||
versions
|
||||
.iter()
|
||||
.filter(|(_, info)| info.verified)
|
||||
.map(|(version, _)| version.clone())
|
||||
.collect()
|
||||
})
|
||||
.map(|versions| versions.keys().cloned().collect())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn mark_download_started(&mut self, browser: &str, version: &str, file_path: PathBuf) {
|
||||
let is_rolling = Self::is_rolling_release(browser, version);
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: browser.to_string(),
|
||||
version: version.to_string(),
|
||||
download_date: std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
file_path,
|
||||
verified: false,
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: is_rolling,
|
||||
};
|
||||
self.add_browser(info);
|
||||
}
|
||||
|
||||
pub fn mark_download_completed_with_actual_version(
|
||||
&mut self,
|
||||
browser: &str,
|
||||
version: &str,
|
||||
actual_version: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
if let Some(info) = self
|
||||
pub fn mark_download_completed(&mut self, browser: &str, version: &str) -> Result<(), String> {
|
||||
if self
|
||||
.browsers
|
||||
.get_mut(browser)
|
||||
.and_then(|versions| versions.get_mut(version))
|
||||
.get(browser)
|
||||
.and_then(|versions| versions.get(version))
|
||||
.is_some()
|
||||
{
|
||||
info.verified = true;
|
||||
info.actual_version = actual_version;
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("Browser {browser}:{version} not found in registry"))
|
||||
}
|
||||
}
|
||||
|
||||
fn is_rolling_release(browser: &str, version: &str) -> bool {
|
||||
// Check if this is a rolling release like twilight
|
||||
browser == "zen" && version.to_lowercase() == "twilight"
|
||||
}
|
||||
|
||||
pub fn cleanup_failed_download(
|
||||
&mut self,
|
||||
browser: &str,
|
||||
@@ -180,32 +147,35 @@ impl DownloadedBrowsersRegistry {
|
||||
pub fn cleanup_unused_binaries(
|
||||
&mut self,
|
||||
active_profiles: &[(String, String)], // (browser, version) pairs
|
||||
running_profiles: &[(String, String)], // (browser, version) pairs for running profiles
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let active_set: std::collections::HashSet<(String, String)> =
|
||||
active_profiles.iter().cloned().collect();
|
||||
let running_set: std::collections::HashSet<(String, String)> =
|
||||
running_profiles.iter().cloned().collect();
|
||||
let mut cleaned_up = Vec::new();
|
||||
|
||||
// Collect all downloaded browsers that are not in active profiles
|
||||
let mut to_remove = Vec::new();
|
||||
for (browser, versions) in &self.browsers {
|
||||
for (version, info) in versions {
|
||||
// Only remove verified downloads that are not used by any active profile
|
||||
if info.verified && !active_set.contains(&(browser.clone(), version.clone())) {
|
||||
// Double-check that this browser+version is truly not in use
|
||||
// by looking for exact matches in the active profiles
|
||||
let is_in_use = active_profiles
|
||||
.iter()
|
||||
.any(|(active_browser, active_version)| {
|
||||
active_browser == browser && active_version == version
|
||||
});
|
||||
for version in versions.keys() {
|
||||
let browser_version = (browser.clone(), version.clone());
|
||||
|
||||
if !is_in_use {
|
||||
to_remove.push((browser.clone(), version.clone()));
|
||||
println!("Marking for removal: {browser} {version} (not used by any profile)");
|
||||
} else {
|
||||
println!("Keeping: {browser} {version} (in use by profile)");
|
||||
}
|
||||
// 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)");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +247,145 @@ impl DownloadedBrowsersRegistry {
|
||||
|
||||
Ok(cleaned_up)
|
||||
}
|
||||
|
||||
/// Get all browsers and versions that are currently running
|
||||
pub fn get_running_browser_versions(
|
||||
&self,
|
||||
profiles: &[crate::browser_runner::BrowserProfile],
|
||||
) -> Vec<(String, String)> {
|
||||
profiles
|
||||
.iter()
|
||||
.filter(|profile| profile.process_id.is_some())
|
||||
.map(|profile| (profile.browser.clone(), profile.version.clone()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Scan the binaries directory and sync with registry
|
||||
/// This ensures the registry reflects what's actually on disk
|
||||
pub fn sync_with_binaries_directory(
|
||||
&mut self,
|
||||
binaries_dir: &std::path::Path,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut changes = Vec::new();
|
||||
|
||||
if !binaries_dir.exists() {
|
||||
return Ok(changes);
|
||||
}
|
||||
|
||||
// Scan for actual browser directories
|
||||
for browser_entry in fs::read_dir(binaries_dir)? {
|
||||
let browser_entry = browser_entry?;
|
||||
let browser_path = browser_entry.path();
|
||||
|
||||
if !browser_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let browser_name = browser_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if browser_name.is_empty() || browser_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Scan for version directories within this browser
|
||||
for version_entry in fs::read_dir(&browser_path)? {
|
||||
let version_entry = version_entry?;
|
||||
let version_path = version_entry.path();
|
||||
|
||||
if !version_path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let version_name = version_path
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or("");
|
||||
|
||||
if version_name.is_empty() || version_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if this browser/version is already in registry
|
||||
if !self.is_browser_downloaded(browser_name, version_name) {
|
||||
// Add to registry
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: browser_name.to_string(),
|
||||
version: version_name.to_string(),
|
||||
file_path: version_path.clone(),
|
||||
};
|
||||
self.add_browser(info);
|
||||
changes.push(format!("Added {browser_name} {version_name} to registry"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !changes.is_empty() {
|
||||
self.save()?;
|
||||
}
|
||||
|
||||
Ok(changes)
|
||||
}
|
||||
|
||||
/// Comprehensive cleanup that removes unused binaries and syncs registry
|
||||
pub fn comprehensive_cleanup(
|
||||
&mut self,
|
||||
binaries_dir: &std::path::Path,
|
||||
active_profiles: &[(String, String)],
|
||||
running_profiles: &[(String, String)],
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cleanup_results = Vec::new();
|
||||
|
||||
// First, sync registry with actual binaries on disk
|
||||
let sync_results = self.sync_with_binaries_directory(binaries_dir)?;
|
||||
cleanup_results.extend(sync_results);
|
||||
|
||||
// Then perform the regular cleanup
|
||||
let regular_cleanup = self.cleanup_unused_binaries(active_profiles, running_profiles)?;
|
||||
cleanup_results.extend(regular_cleanup);
|
||||
|
||||
// Finally, verify and cleanup stale entries
|
||||
let stale_cleanup = self.verify_and_cleanup_stale_entries_simple(binaries_dir)?;
|
||||
cleanup_results.extend(stale_cleanup);
|
||||
|
||||
if !cleanup_results.is_empty() {
|
||||
self.save()?;
|
||||
}
|
||||
|
||||
Ok(cleanup_results)
|
||||
}
|
||||
|
||||
/// Simplified version of verify_and_cleanup_stale_entries that doesn't need BrowserRunner
|
||||
pub fn verify_and_cleanup_stale_entries_simple(
|
||||
&mut self,
|
||||
binaries_dir: &std::path::Path,
|
||||
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
|
||||
let mut cleaned_up = Vec::new();
|
||||
let mut browsers_to_remove = Vec::new();
|
||||
|
||||
for (browser_str, versions) in &self.browsers {
|
||||
for version in versions.keys() {
|
||||
// Check if the browser directory actually exists
|
||||
let browser_dir = binaries_dir.join(browser_str).join(version);
|
||||
if !browser_dir.exists() {
|
||||
browsers_to_remove.push((browser_str.clone(), version.clone()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove stale entries
|
||||
for (browser_str, version) in browsers_to_remove {
|
||||
if let Some(_removed) = self.remove_browser(&browser_str, &version) {
|
||||
cleaned_up.push(format!(
|
||||
"Removed stale registry entry for {browser_str} {version}"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(cleaned_up)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -295,12 +404,7 @@ mod tests {
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
download_date: 1234567890,
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: false,
|
||||
};
|
||||
|
||||
registry.add_browser(info.clone());
|
||||
@@ -317,34 +421,19 @@ mod tests {
|
||||
let info1 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
download_date: 1234567890,
|
||||
file_path: PathBuf::from("/test/path1"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: false,
|
||||
};
|
||||
|
||||
let info2 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "140.0".to_string(),
|
||||
download_date: 1234567891,
|
||||
file_path: PathBuf::from("/test/path2"),
|
||||
verified: false, // Not verified, should not be included
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: false,
|
||||
};
|
||||
|
||||
let info3 = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "141.0".to_string(),
|
||||
download_date: 1234567892,
|
||||
file_path: PathBuf::from("/test/path3"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: false,
|
||||
};
|
||||
|
||||
registry.add_browser(info1);
|
||||
@@ -352,10 +441,10 @@ mod tests {
|
||||
registry.add_browser(info3);
|
||||
|
||||
let versions = registry.get_downloaded_versions("firefox");
|
||||
assert_eq!(versions.len(), 2);
|
||||
assert_eq!(versions.len(), 3);
|
||||
assert!(versions.contains(&"139.0".to_string()));
|
||||
assert!(versions.contains(&"140.0".to_string()));
|
||||
assert!(versions.contains(&"141.0".to_string()));
|
||||
assert!(!versions.contains(&"140.0".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -365,15 +454,15 @@ mod tests {
|
||||
// Mark download started
|
||||
registry.mark_download_started("firefox", "139.0", PathBuf::from("/test/path"));
|
||||
|
||||
// Should not be considered downloaded yet
|
||||
assert!(!registry.is_browser_downloaded("firefox", "139.0"));
|
||||
// Should be considered downloaded immediately
|
||||
assert!(registry.is_browser_downloaded("firefox", "139.0"));
|
||||
|
||||
// Mark as completed
|
||||
registry
|
||||
.mark_download_completed_with_actual_version("firefox", "139.0", Some("139.0".to_string()))
|
||||
.mark_download_completed("firefox", "139.0")
|
||||
.unwrap();
|
||||
|
||||
// Now should be considered downloaded
|
||||
// Should still be considered downloaded
|
||||
assert!(registry.is_browser_downloaded("firefox", "139.0"));
|
||||
}
|
||||
|
||||
@@ -383,12 +472,7 @@ mod tests {
|
||||
let info = DownloadedBrowserInfo {
|
||||
browser: "firefox".to_string(),
|
||||
version: "139.0".to_string(),
|
||||
download_date: 1234567890,
|
||||
file_path: PathBuf::from("/test/path"),
|
||||
verified: true,
|
||||
actual_version: None,
|
||||
file_size: None,
|
||||
is_rolling_release: false,
|
||||
};
|
||||
|
||||
registry.add_browser(info);
|
||||
@@ -400,15 +484,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_twilight_rolling_release() {
|
||||
fn test_twilight_download() {
|
||||
let mut registry = DownloadedBrowsersRegistry::new();
|
||||
|
||||
// Mark twilight download started
|
||||
registry.mark_download_started("zen", "twilight", PathBuf::from("/test/zen-twilight"));
|
||||
|
||||
// Check that it's marked as rolling release
|
||||
let zen_versions = ®istry.browsers["zen"];
|
||||
let twilight_info = &zen_versions["twilight"];
|
||||
assert!(twilight_info.is_rolling_release);
|
||||
// Check that it's registered
|
||||
assert!(registry.is_browser_downloaded("zen", "twilight"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
use directories::BaseDirs;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProfileGroup {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct GroupWithCount {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub count: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
struct GroupsData {
|
||||
groups: Vec<ProfileGroup>,
|
||||
}
|
||||
|
||||
pub struct GroupManager {
|
||||
base_dirs: BaseDirs,
|
||||
}
|
||||
|
||||
impl GroupManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
base_dirs: BaseDirs::new().expect("Failed to get base directories"),
|
||||
}
|
||||
}
|
||||
|
||||
fn get_groups_file_path(&self) -> PathBuf {
|
||||
let mut path = self.base_dirs.data_local_dir().to_path_buf();
|
||||
path.push(if cfg!(debug_assertions) {
|
||||
"DonutBrowserDev"
|
||||
} else {
|
||||
"DonutBrowser"
|
||||
});
|
||||
path.push("data");
|
||||
path.push("groups.json");
|
||||
path
|
||||
}
|
||||
|
||||
fn load_groups_data(&self) -> Result<GroupsData, Box<dyn std::error::Error>> {
|
||||
let groups_file = self.get_groups_file_path();
|
||||
|
||||
if !groups_file.exists() {
|
||||
return Ok(GroupsData { groups: Vec::new() });
|
||||
}
|
||||
|
||||
let content = fs::read_to_string(groups_file)?;
|
||||
let groups_data: GroupsData = serde_json::from_str(&content)?;
|
||||
Ok(groups_data)
|
||||
}
|
||||
|
||||
fn save_groups_data(&self, groups_data: &GroupsData) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let groups_file = self.get_groups_file_path();
|
||||
|
||||
// Ensure the parent directory exists
|
||||
if let Some(parent) = groups_file.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let json = serde_json::to_string_pretty(groups_data)?;
|
||||
fs::write(groups_file, json)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_all_groups(&self) -> Result<Vec<ProfileGroup>, Box<dyn std::error::Error>> {
|
||||
let groups_data = self.load_groups_data()?;
|
||||
Ok(groups_data.groups)
|
||||
}
|
||||
|
||||
pub fn create_group(&self, name: String) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
// Check if group with this name already exists
|
||||
if groups_data.groups.iter().any(|g| g.name == name) {
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
}
|
||||
|
||||
let group = ProfileGroup {
|
||||
id: uuid::Uuid::new_v4().to_string(),
|
||||
name,
|
||||
};
|
||||
|
||||
groups_data.groups.push(group.clone());
|
||||
self.save_groups_data(&groups_data)?;
|
||||
|
||||
Ok(group)
|
||||
}
|
||||
|
||||
pub fn update_group(
|
||||
&self,
|
||||
id: String,
|
||||
name: String,
|
||||
) -> Result<ProfileGroup, Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
// Check if another group with this name already exists
|
||||
if groups_data
|
||||
.groups
|
||||
.iter()
|
||||
.any(|g| g.name == name && g.id != id)
|
||||
{
|
||||
return Err(format!("Group with name '{name}' already exists").into());
|
||||
}
|
||||
|
||||
let group = groups_data
|
||||
.groups
|
||||
.iter_mut()
|
||||
.find(|g| g.id == id)
|
||||
.ok_or_else(|| format!("Group with id '{id}' not found"))?;
|
||||
|
||||
group.name = name;
|
||||
let updated_group = group.clone();
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
Ok(updated_group)
|
||||
}
|
||||
|
||||
pub fn delete_group(&self, id: String) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut groups_data = self.load_groups_data()?;
|
||||
|
||||
let initial_len = groups_data.groups.len();
|
||||
groups_data.groups.retain(|g| g.id != id);
|
||||
|
||||
if groups_data.groups.len() == initial_len {
|
||||
return Err(format!("Group with id '{id}' not found").into());
|
||||
}
|
||||
|
||||
self.save_groups_data(&groups_data)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn get_groups_with_profile_counts(
|
||||
&self,
|
||||
profiles: &[crate::browser_runner::BrowserProfile],
|
||||
) -> Result<Vec<GroupWithCount>, Box<dyn std::error::Error>> {
|
||||
let groups = self.get_all_groups()?;
|
||||
let mut group_counts = HashMap::new();
|
||||
|
||||
// Count profiles in each group
|
||||
for profile in profiles {
|
||||
if let Some(group_id) = &profile.group_id {
|
||||
*group_counts.entry(group_id.clone()).or_insert(0) += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Create result with counts
|
||||
let mut result = Vec::new();
|
||||
for group in groups {
|
||||
let count = group_counts.get(&group.id).copied().unwrap_or(0);
|
||||
if count > 0 {
|
||||
result.push(GroupWithCount {
|
||||
id: group.id,
|
||||
name: group.name,
|
||||
count,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Add default group count (profiles without group_id)
|
||||
let default_count = profiles.iter().filter(|p| p.group_id.is_none()).count();
|
||||
if default_count > 0 {
|
||||
let default_group = GroupWithCount {
|
||||
id: "default".to_string(),
|
||||
name: "Default".to_string(),
|
||||
count: default_count,
|
||||
};
|
||||
result.insert(0, default_group);
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
}
|
||||
|
||||
// Global instance
|
||||
lazy_static::lazy_static! {
|
||||
pub static ref GROUP_MANAGER: Mutex<GroupManager> = Mutex::new(GroupManager::new());
|
||||
}
|
||||
|
||||
// Helper function to get groups with counts
|
||||
pub fn get_groups_with_counts(
|
||||
profiles: &[crate::browser_runner::BrowserProfile],
|
||||
) -> Vec<GroupWithCount> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.get_groups_with_profile_counts(profiles)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// Tauri commands
|
||||
#[tauri::command]
|
||||
pub async fn get_profile_groups() -> Result<Vec<ProfileGroup>, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.get_all_groups()
|
||||
.map_err(|e| format!("Failed to get profile groups: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn get_groups_with_profile_counts() -> Result<Vec<GroupWithCount>, String> {
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::new();
|
||||
let profiles = browser_runner
|
||||
.list_profiles()
|
||||
.map_err(|e| format!("Failed to list profiles: {e}"))?;
|
||||
Ok(get_groups_with_counts(&profiles))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn create_profile_group(name: String) -> Result<ProfileGroup, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.create_group(name)
|
||||
.map_err(|e| format!("Failed to create group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_profile_group(group_id: String, name: String) -> Result<ProfileGroup, String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.update_group(group_id, name)
|
||||
.map_err(|e| format!("Failed to update group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_profile_group(group_id: String) -> Result<(), String> {
|
||||
let group_manager = GROUP_MANAGER.lock().unwrap();
|
||||
group_manager
|
||||
.delete_group(group_id)
|
||||
.map_err(|e| format!("Failed to delete group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn assign_profiles_to_group(
|
||||
profile_names: Vec<String>,
|
||||
group_id: Option<String>,
|
||||
) -> Result<(), String> {
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::new();
|
||||
browser_runner
|
||||
.assign_profiles_to_group(profile_names, group_id)
|
||||
.map_err(|e| format!("Failed to assign profiles to group: {e}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_selected_profiles(profile_names: Vec<String>) -> Result<(), String> {
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::new();
|
||||
browser_runner
|
||||
.delete_multiple_profiles(profile_names)
|
||||
.map_err(|e| format!("Failed to delete profiles: {e}"))
|
||||
}
|
||||
+91
-46
@@ -13,12 +13,13 @@ mod auto_updater;
|
||||
mod browser;
|
||||
mod browser_runner;
|
||||
mod browser_version_service;
|
||||
mod camoufox;
|
||||
mod camoufox_direct;
|
||||
mod default_browser;
|
||||
mod download;
|
||||
mod downloaded_browsers;
|
||||
mod extraction;
|
||||
mod geoip_downloader;
|
||||
mod group_manager;
|
||||
|
||||
mod profile_importer;
|
||||
mod proxy_manager;
|
||||
@@ -66,6 +67,11 @@ use theme_detector::get_system_theme;
|
||||
|
||||
use system_utils::{get_system_locale, get_system_timezone};
|
||||
|
||||
use group_manager::{
|
||||
assign_profiles_to_group, create_profile_group, delete_profile_group, delete_selected_profiles,
|
||||
get_groups_with_profile_counts, get_profile_groups, update_profile_group,
|
||||
};
|
||||
|
||||
// Trait to extend WebviewWindow with transparent titlebar functionality
|
||||
pub trait WindowExt {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -137,49 +143,6 @@ async fn handle_url_open(app: tauri::AppHandle, url: String) -> Result<(), Strin
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn check_and_handle_startup_url(app_handle: tauri::AppHandle) -> Result<bool, String> {
|
||||
println!("check_and_handle_startup_url called");
|
||||
|
||||
let pending_urls = {
|
||||
let mut pending = PENDING_URLS.lock().unwrap();
|
||||
let urls = pending.clone();
|
||||
pending.clear(); // Clear after getting them
|
||||
urls
|
||||
};
|
||||
|
||||
println!("Found {} pending URLs", pending_urls.len());
|
||||
|
||||
if !pending_urls.is_empty() {
|
||||
println!(
|
||||
"Handling {} pending URLs from frontend request",
|
||||
pending_urls.len()
|
||||
);
|
||||
|
||||
// Ensure the main window is visible and focused
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.show();
|
||||
let _ = window.set_focus();
|
||||
let _ = window.unminimize();
|
||||
|
||||
// Give the window a moment to become visible
|
||||
tokio::time::sleep(tokio::time::Duration::from_millis(200)).await;
|
||||
}
|
||||
|
||||
for url in pending_urls {
|
||||
println!("Emitting show-profile-selector event for URL: {url}");
|
||||
if let Err(e) = app_handle.emit("show-profile-selector", url.clone()) {
|
||||
eprintln!("Failed to emit URL event: {e}");
|
||||
return Err(format!("Failed to emit URL event: {e}"));
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn create_stored_proxy(
|
||||
name: String,
|
||||
@@ -216,7 +179,7 @@ async fn delete_stored_proxy(proxy_id: String) -> Result<(), String> {
|
||||
#[tauri::command]
|
||||
async fn update_camoufox_config(
|
||||
profile_name: String,
|
||||
config: crate::camoufox::CamoufoxConfig,
|
||||
config: crate::camoufox_direct::CamoufoxConfig,
|
||||
) -> Result<(), String> {
|
||||
let browser_runner = browser_runner::BrowserRunner::new();
|
||||
browser_runner
|
||||
@@ -374,6 +337,22 @@ pub fn run() {
|
||||
auto_updater::check_for_updates_with_progress(app_handle_auto_updater).await;
|
||||
});
|
||||
|
||||
// Start periodic cleanup task for unused binaries
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(300)); // Every 5 minutes
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
let browser_runner = crate::browser_runner::BrowserRunner::new();
|
||||
if let Err(e) = browser_runner.cleanup_unused_binaries_internal() {
|
||||
eprintln!("Periodic cleanup failed: {e}");
|
||||
} else {
|
||||
println!("Periodic cleanup completed successfully");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let app_handle_update = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Starting app update check at startup...");
|
||||
@@ -400,6 +379,66 @@ pub fn run() {
|
||||
}
|
||||
});
|
||||
|
||||
// Start Camoufox cleanup task
|
||||
let app_handle_cleanup = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let launcher = crate::camoufox_direct::CamoufoxDirectLauncher::new(app_handle_cleanup);
|
||||
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
|
||||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
|
||||
match launcher.cleanup_dead_instances().await {
|
||||
Ok(dead_instances) => {
|
||||
if !dead_instances.is_empty() {
|
||||
println!(
|
||||
"Cleaned up {} dead Camoufox instances",
|
||||
dead_instances.len()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Error during Camoufox cleanup: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Warm up nodecar binary in the background
|
||||
tauri::async_runtime::spawn(async move {
|
||||
println!("Starting nodecar warm-up...");
|
||||
let start_time = std::time::Instant::now();
|
||||
|
||||
// Send a ping request to nodecar to trigger unpacking/warm-up
|
||||
match tokio::process::Command::new("nodecar")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.await
|
||||
{
|
||||
Ok(output) => {
|
||||
let duration = start_time.elapsed();
|
||||
if output.status.success() {
|
||||
println!(
|
||||
"Nodecar warm-up completed successfully in {:.2}s",
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
} else {
|
||||
println!(
|
||||
"Nodecar warm-up completed with non-zero exit code in {:.2}s",
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
let duration = start_time.elapsed();
|
||||
println!(
|
||||
"Nodecar warm-up failed after {:.2}s: {e}",
|
||||
duration.as_secs_f64()
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@@ -431,7 +470,6 @@ pub fn run() {
|
||||
open_url_with_profile,
|
||||
set_as_default_browser,
|
||||
smart_open_url,
|
||||
check_and_handle_startup_url,
|
||||
trigger_manual_version_update,
|
||||
get_version_update_status,
|
||||
check_for_browser_updates,
|
||||
@@ -453,6 +491,13 @@ pub fn run() {
|
||||
update_camoufox_config,
|
||||
get_system_locale,
|
||||
get_system_timezone,
|
||||
get_profile_groups,
|
||||
get_groups_with_profile_counts,
|
||||
create_profile_group,
|
||||
update_profile_group,
|
||||
delete_profile_group,
|
||||
assign_profiles_to_group,
|
||||
delete_selected_profiles,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
@@ -690,6 +690,7 @@ impl ProfileImporter {
|
||||
last_launch: None,
|
||||
release_type: "stable".to_string(),
|
||||
camoufox_config: None,
|
||||
group_id: None,
|
||||
};
|
||||
|
||||
// Save the profile metadata
|
||||
|
||||
@@ -412,26 +412,6 @@ impl ProxyManager {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Get proxy settings for a browser process ID
|
||||
#[allow(dead_code)]
|
||||
pub fn get_proxy_settings(&self, browser_pid: u32) -> Option<ProxySettings> {
|
||||
let proxies = self.active_proxies.lock().unwrap();
|
||||
proxies.get(&browser_pid).map(|proxy| ProxySettings {
|
||||
proxy_type: "http".to_string(),
|
||||
host: "127.0.0.1".to_string(), // Use 127.0.0.1 instead of localhost for better compatibility
|
||||
port: proxy.local_port,
|
||||
username: None,
|
||||
password: None,
|
||||
})
|
||||
}
|
||||
|
||||
// Get stored proxy info for a profile
|
||||
#[allow(dead_code)]
|
||||
pub fn get_profile_proxy_info(&self, profile_name: &str) -> Option<ProxySettings> {
|
||||
let profile_proxies = self.profile_proxies.lock().unwrap();
|
||||
profile_proxies.get(profile_name).cloned()
|
||||
}
|
||||
|
||||
// Update the PID mapping for an existing proxy
|
||||
pub fn update_proxy_pid(&self, old_pid: u32, new_pid: u32) -> Result<(), String> {
|
||||
let mut proxies = self.active_proxies.lock().unwrap();
|
||||
@@ -476,8 +456,7 @@ mod tests {
|
||||
.unwrap()
|
||||
.to_path_buf();
|
||||
let nodecar_dir = project_root.join("nodecar");
|
||||
let nodecar_dist = nodecar_dir.join("dist");
|
||||
let nodecar_binary = nodecar_dist.join("nodecar");
|
||||
let nodecar_binary = nodecar_dir.join("nodecar-bin");
|
||||
|
||||
// Check if binary already exists
|
||||
if nodecar_binary.exists() {
|
||||
@@ -531,70 +510,6 @@ mod tests {
|
||||
Ok(nodecar_binary)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_manager_profile_persistence() {
|
||||
let proxy_manager = ProxyManager::new();
|
||||
|
||||
let proxy_settings = ProxySettings {
|
||||
proxy_type: "socks5".to_string(),
|
||||
host: "127.0.0.1".to_string(),
|
||||
port: 1080,
|
||||
username: None,
|
||||
password: None,
|
||||
};
|
||||
|
||||
// Test profile proxy info storage
|
||||
{
|
||||
let mut profile_proxies = proxy_manager.profile_proxies.lock().unwrap();
|
||||
profile_proxies.insert("test_profile".to_string(), proxy_settings.clone());
|
||||
}
|
||||
|
||||
// Test retrieval
|
||||
let retrieved = proxy_manager.get_profile_proxy_info("test_profile");
|
||||
assert!(retrieved.is_some());
|
||||
let retrieved = retrieved.unwrap();
|
||||
assert_eq!(retrieved.proxy_type, "socks5");
|
||||
assert_eq!(retrieved.host, "127.0.0.1");
|
||||
assert_eq!(retrieved.port, 1080);
|
||||
|
||||
// Test non-existent profile
|
||||
let non_existent = proxy_manager.get_profile_proxy_info("non_existent");
|
||||
assert!(non_existent.is_none());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_proxy_manager_active_proxy_tracking() {
|
||||
let proxy_manager = ProxyManager::new();
|
||||
|
||||
let proxy_info = ProxyInfo {
|
||||
id: "test_proxy_123".to_string(),
|
||||
local_url: "http://localhost:8080".to_string(),
|
||||
upstream_host: "proxy.example.com".to_string(),
|
||||
upstream_port: 3128,
|
||||
upstream_type: "http".to_string(),
|
||||
local_port: 8080,
|
||||
};
|
||||
|
||||
let browser_pid = 54321u32;
|
||||
|
||||
// Add active proxy
|
||||
{
|
||||
let mut active_proxies = proxy_manager.active_proxies.lock().unwrap();
|
||||
active_proxies.insert(browser_pid, proxy_info.clone());
|
||||
}
|
||||
|
||||
// Test retrieval of proxy settings
|
||||
let proxy_settings = proxy_manager.get_proxy_settings(browser_pid);
|
||||
assert!(proxy_settings.is_some());
|
||||
let settings = proxy_settings.unwrap();
|
||||
assert!(settings.host == "127.0.0.1");
|
||||
assert!(settings.port == 8080);
|
||||
|
||||
// Test non-existent browser PID
|
||||
let non_existent = proxy_manager.get_proxy_settings(99999);
|
||||
assert!(non_existent.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_proxy_settings_validation() {
|
||||
// Test valid proxy settings
|
||||
@@ -648,10 +563,6 @@ mod tests {
|
||||
active_proxies.insert(browser_pid, proxy_info);
|
||||
}
|
||||
|
||||
// Read proxy
|
||||
let settings = pm.get_proxy_settings(browser_pid);
|
||||
assert!(settings.is_some());
|
||||
|
||||
browser_pid
|
||||
});
|
||||
handles.push(handle);
|
||||
@@ -714,7 +625,7 @@ mod tests {
|
||||
.arg("http");
|
||||
|
||||
// Set a timeout for the command
|
||||
let output = tokio::time::timeout(Duration::from_secs(10), async { cmd.output() }).await??;
|
||||
let output = tokio::time::timeout(Duration::from_secs(60), async { cmd.output() }).await??;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
@@ -730,7 +641,7 @@ mod tests {
|
||||
|
||||
// Wait for proxy worker to start
|
||||
println!("Waiting for proxy worker to start...");
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
// Test that the local port is listening
|
||||
let mut port_test = Command::new("nc");
|
||||
@@ -751,7 +662,7 @@ mod tests {
|
||||
stop_cmd.arg("proxy").arg("stop").arg("--id").arg(proxy_id);
|
||||
|
||||
let stop_output =
|
||||
tokio::time::timeout(Duration::from_secs(5), async { stop_cmd.output() }).await??;
|
||||
tokio::time::timeout(Duration::from_secs(60), async { stop_cmd.output() }).await??;
|
||||
|
||||
assert!(stop_output.status.success());
|
||||
|
||||
@@ -826,13 +737,6 @@ mod tests {
|
||||
match output {
|
||||
Ok(Ok(cmd_output)) => {
|
||||
let execution_time = start_time.elapsed();
|
||||
println!("CLI completed in {execution_time:?}");
|
||||
|
||||
// Should complete very quickly if properly detached
|
||||
assert!(
|
||||
execution_time < Duration::from_secs(3),
|
||||
"CLI took too long ({execution_time:?}), should exit immediately after starting worker"
|
||||
);
|
||||
|
||||
if cmd_output.status.success() {
|
||||
let stdout = String::from_utf8(cmd_output.stdout)?;
|
||||
@@ -876,17 +780,7 @@ mod tests {
|
||||
.arg("--type")
|
||||
.arg("http");
|
||||
|
||||
let start_time = std::time::Instant::now();
|
||||
let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??;
|
||||
let execution_time = start_time.elapsed();
|
||||
|
||||
// Command should complete very quickly if properly detached
|
||||
assert!(
|
||||
execution_time < Duration::from_secs(5),
|
||||
"CLI command took {execution_time:?}, should complete in under 5 seconds for proper detachment"
|
||||
);
|
||||
|
||||
println!("CLI detachment test: command completed in {execution_time:?}");
|
||||
let output = tokio::time::timeout(Duration::from_secs(60), async { cmd.output() }).await??;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
@@ -928,7 +822,7 @@ mod tests {
|
||||
.arg("--password")
|
||||
.arg("pass word!"); // Contains space and special character
|
||||
|
||||
let output = tokio::time::timeout(Duration::from_secs(5), async { cmd.output() }).await??;
|
||||
let output = tokio::time::timeout(Duration::from_secs(60), async { cmd.output() }).await??;
|
||||
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8(output.stdout)?;
|
||||
|
||||
+194
-130
@@ -4,12 +4,14 @@ import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrent } from "@tauri-apps/plugin-deep-link";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import { CamoufoxConfigDialog } from "@/components/camoufox-config-dialog";
|
||||
import { ChangeVersionDialog } from "@/components/change-version-dialog";
|
||||
import { CreateProfileDialog } from "@/components/create-profile-dialog";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { GroupAssignmentDialog } from "@/components/group-assignment-dialog";
|
||||
import { GroupBadges } from "@/components/group-badges";
|
||||
import { GroupManagementDialog } from "@/components/group-management-dialog";
|
||||
import HomeHeader from "@/components/home-header";
|
||||
import { ImportProfileDialog } from "@/components/import-profile-dialog";
|
||||
import { PermissionDialog } from "@/components/permission-dialog";
|
||||
import { ProfilesDataTable } from "@/components/profile-data-table";
|
||||
@@ -17,19 +19,7 @@ import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
|
||||
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
|
||||
import { ProxySettingsDialog } from "@/components/proxy-settings-dialog";
|
||||
import { SettingsDialog } from "@/components/settings-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Card, CardContent, CardHeader } from "@/components/ui/card";
|
||||
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
|
||||
import type { PermissionType } from "@/hooks/use-permissions";
|
||||
import { usePermissions } from "@/hooks/use-permissions";
|
||||
@@ -37,7 +27,7 @@ import { useUpdateNotifications } from "@/hooks/use-update-notifications";
|
||||
import { useVersionUpdater } from "@/hooks/use-version-updater";
|
||||
import { showErrorToast } from "@/lib/toast-utils";
|
||||
import { sleep } from "@/lib/utils";
|
||||
import type { BrowserProfile, CamoufoxConfig } from "@/types";
|
||||
import type { BrowserProfile, CamoufoxConfig, GroupWithCount } from "@/types";
|
||||
|
||||
type BrowserTypeString =
|
||||
| "mullvad-browser"
|
||||
@@ -66,6 +56,15 @@ export default function Home() {
|
||||
useState(false);
|
||||
const [camoufoxConfigDialogOpen, setCamoufoxConfigDialogOpen] =
|
||||
useState(false);
|
||||
const [groupManagementDialogOpen, setGroupManagementDialogOpen] =
|
||||
useState(false);
|
||||
const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] =
|
||||
useState(false);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
|
||||
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
|
||||
const [currentProfileForProxy, setCurrentProfileForProxy] =
|
||||
useState<BrowserProfile | null>(null);
|
||||
@@ -75,12 +74,21 @@ export default function Home() {
|
||||
useState<BrowserProfile | null>(null);
|
||||
const [hasCheckedStartupPrompt, setHasCheckedStartupPrompt] = useState(false);
|
||||
const [permissionDialogOpen, setPermissionDialogOpen] = useState(false);
|
||||
const [groups, setGroups] = useState<GroupWithCount[]>([]);
|
||||
const [areGroupsLoading, setGroupsLoading] = useState(true);
|
||||
const [currentPermissionType, setCurrentPermissionType] =
|
||||
useState<PermissionType>("microphone");
|
||||
const [proxyDataReloadTrigger, setProxyDataReloadTrigger] = useState(0);
|
||||
const [showBulkDeleteConfirmation, setShowBulkDeleteConfirmation] =
|
||||
useState(false);
|
||||
const [isBulkDeleting, setIsBulkDeleting] = useState(false);
|
||||
const { isMicrophoneAccessGranted, isCameraAccessGranted, isInitialized } =
|
||||
usePermissions();
|
||||
|
||||
const handleSelectGroup = useCallback((groupId: string | null) => {
|
||||
setSelectedGroupId(groupId);
|
||||
setSelectedProfiles([]);
|
||||
}, []);
|
||||
|
||||
// Check for missing binaries and offer to download them
|
||||
const checkMissingBinaries = useCallback(async () => {
|
||||
try {
|
||||
@@ -141,9 +149,7 @@ export default function Home() {
|
||||
const profileList = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
setProfiles(
|
||||
profileList.filter((profile) => profile.browser !== "camoufox"),
|
||||
);
|
||||
setProfiles(profileList);
|
||||
|
||||
// Check for missing binaries after loading profiles
|
||||
await checkMissingBinaries();
|
||||
@@ -153,30 +159,47 @@ export default function Home() {
|
||||
}
|
||||
}, [checkMissingBinaries]);
|
||||
|
||||
// Trigger proxy data reload in ProfilesDataTable
|
||||
const triggerProxyDataReload = useCallback(() => {
|
||||
setProxyDataReloadTrigger((prev) => prev + 1);
|
||||
}, []);
|
||||
const [processingUrls, setProcessingUrls] = useState<Set<string>>(new Set());
|
||||
|
||||
const handleUrlOpen = useCallback(async (url: string) => {
|
||||
try {
|
||||
// Use smart profile selection
|
||||
const result = await invoke<string>("smart_open_url", {
|
||||
url,
|
||||
});
|
||||
console.log("Smart URL opening succeeded:", result);
|
||||
// URL was handled successfully, no need to show selector
|
||||
} catch (error: unknown) {
|
||||
console.log(
|
||||
"Smart URL opening failed or requires profile selection:",
|
||||
error,
|
||||
);
|
||||
const handleUrlOpen = useCallback(
|
||||
async (url: string) => {
|
||||
// Prevent duplicate processing of the same URL
|
||||
if (processingUrls.has(url)) {
|
||||
console.log("URL already being processed:", url);
|
||||
return;
|
||||
}
|
||||
|
||||
// Show profile selector for manual selection
|
||||
// Replace any existing pending URL with the new one
|
||||
setPendingUrls([{ id: Date.now().toString(), url }]);
|
||||
}
|
||||
}, []);
|
||||
setProcessingUrls((prev) => new Set(prev).add(url));
|
||||
|
||||
try {
|
||||
// Use smart profile selection
|
||||
const result = await invoke<string>("smart_open_url", {
|
||||
url,
|
||||
});
|
||||
console.log("Smart URL opening succeeded:", result);
|
||||
// URL was handled successfully, no need to show selector
|
||||
} catch (error: unknown) {
|
||||
console.log(
|
||||
"Smart URL opening failed or requires profile selection:",
|
||||
error,
|
||||
);
|
||||
|
||||
// Show profile selector for manual selection
|
||||
// Replace any existing pending URL with the new one
|
||||
setPendingUrls([{ id: Date.now().toString(), url }]);
|
||||
} finally {
|
||||
// Remove URL from processing set after a short delay to prevent rapid duplicates
|
||||
setTimeout(() => {
|
||||
setProcessingUrls((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(url);
|
||||
return next;
|
||||
});
|
||||
}, 1000);
|
||||
}
|
||||
},
|
||||
[processingUrls],
|
||||
);
|
||||
|
||||
// Version updater for handling version fetching progress events and auto-updates
|
||||
useVersionUpdater();
|
||||
@@ -191,9 +214,7 @@ export default function Home() {
|
||||
const profileList = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
setProfiles(
|
||||
profileList.filter((profile) => profile.browser !== "camoufox"),
|
||||
);
|
||||
setProfiles(profileList);
|
||||
|
||||
// TODO: remove after a few version bumps, needed to properly display migrated profiles
|
||||
setTimeout(async () => {
|
||||
@@ -201,9 +222,7 @@ export default function Home() {
|
||||
const profiles = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
setProfiles(
|
||||
profiles.filter((profile) => profile.browser !== "camoufox"),
|
||||
);
|
||||
setProfiles(profiles);
|
||||
}
|
||||
await sleep(500);
|
||||
}, 0);
|
||||
@@ -285,19 +304,6 @@ export default function Home() {
|
||||
}
|
||||
}, [isMicrophoneAccessGranted, isCameraAccessGranted]);
|
||||
|
||||
const checkStartupUrls = useCallback(async () => {
|
||||
try {
|
||||
const hasStartupUrl = await invoke<boolean>(
|
||||
"check_and_handle_startup_url",
|
||||
);
|
||||
if (hasStartupUrl) {
|
||||
console.log("Handled startup URL successfully");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to check startup URLs:", error);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const listenForUrlEvents = useCallback(async () => {
|
||||
try {
|
||||
// Listen for URL open events from the deep link handler (when app is already running)
|
||||
@@ -376,13 +382,12 @@ export default function Home() {
|
||||
}
|
||||
await loadProfiles();
|
||||
// Trigger proxy data reload in the table
|
||||
triggerProxyDataReload();
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to update proxy settings:", err);
|
||||
setError(`Failed to update proxy settings: ${JSON.stringify(err)}`);
|
||||
}
|
||||
},
|
||||
[currentProfileForProxy, loadProfiles, triggerProxyDataReload],
|
||||
[currentProfileForProxy, loadProfiles],
|
||||
);
|
||||
|
||||
const handleCreateProfile = useCallback(
|
||||
@@ -411,7 +416,6 @@ export default function Home() {
|
||||
|
||||
await loadProfiles();
|
||||
// Trigger proxy data reload in the table
|
||||
triggerProxyDataReload();
|
||||
} catch (error) {
|
||||
setError(
|
||||
`Failed to create profile: ${
|
||||
@@ -421,7 +425,7 @@ export default function Home() {
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
[loadProfiles, triggerProxyDataReload],
|
||||
[loadProfiles],
|
||||
);
|
||||
|
||||
const [runningProfiles, setRunningProfiles] = useState<Set<string>>(
|
||||
@@ -516,7 +520,7 @@ export default function Home() {
|
||||
console.log("Profile deletion command completed successfully");
|
||||
|
||||
// Give a small delay to ensure file system operations complete
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
// Reload profiles to ensure UI is updated
|
||||
await loadProfiles();
|
||||
@@ -560,8 +564,81 @@ export default function Home() {
|
||||
[loadProfiles],
|
||||
);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
setGroupsLoading(true);
|
||||
try {
|
||||
const groupsWithCounts = await invoke<GroupWithCount[]>(
|
||||
"get_groups_with_profile_counts",
|
||||
);
|
||||
setGroups(groupsWithCounts);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups with counts:", err);
|
||||
setGroups([]);
|
||||
} finally {
|
||||
setGroupsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDeleteSelectedProfiles = useCallback(
|
||||
async (profileNames: string[]) => {
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("delete_selected_profiles", { profileNames });
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
} catch (err: unknown) {
|
||||
console.error("Failed to delete selected profiles:", err);
|
||||
setError(`Failed to delete selected profiles: ${JSON.stringify(err)}`);
|
||||
}
|
||||
},
|
||||
[loadProfiles, loadGroups],
|
||||
);
|
||||
|
||||
const handleAssignProfilesToGroup = useCallback((profileNames: string[]) => {
|
||||
setSelectedProfilesForGroup(profileNames);
|
||||
setGroupAssignmentDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleBulkDelete = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
setShowBulkDeleteConfirmation(true);
|
||||
}, [selectedProfiles]);
|
||||
|
||||
const confirmBulkDelete = useCallback(async () => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
|
||||
setIsBulkDeleting(true);
|
||||
try {
|
||||
await invoke("delete_selected_profiles", {
|
||||
profileNames: selectedProfiles,
|
||||
});
|
||||
await loadProfiles();
|
||||
setSelectedProfiles([]);
|
||||
setShowBulkDeleteConfirmation(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete selected profiles:", error);
|
||||
setError(`Failed to delete selected profiles: ${JSON.stringify(error)}`);
|
||||
} finally {
|
||||
setIsBulkDeleting(false);
|
||||
}
|
||||
}, [selectedProfiles, loadProfiles]);
|
||||
|
||||
const handleBulkGroupAssignment = useCallback(() => {
|
||||
if (selectedProfiles.length === 0) return;
|
||||
handleAssignProfilesToGroup(selectedProfiles);
|
||||
setSelectedProfiles([]);
|
||||
}, [selectedProfiles, handleAssignProfilesToGroup]);
|
||||
|
||||
const handleGroupAssignmentComplete = useCallback(async () => {
|
||||
await loadProfiles();
|
||||
await loadGroups();
|
||||
setGroupAssignmentDialogOpen(false);
|
||||
setSelectedProfilesForGroup([]);
|
||||
}, [loadProfiles, loadGroups]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadProfilesWithUpdateCheck();
|
||||
void loadGroups();
|
||||
|
||||
// Check for startup default browser prompt
|
||||
void checkStartupPrompt();
|
||||
@@ -570,7 +647,6 @@ export default function Home() {
|
||||
void listenForUrlEvents();
|
||||
|
||||
// Check for startup URLs (when app was launched as default browser)
|
||||
void checkStartupUrls();
|
||||
void checkCurrentUrl();
|
||||
|
||||
// Set up periodic update checks (every 30 minutes)
|
||||
@@ -587,10 +663,10 @@ export default function Home() {
|
||||
}, [
|
||||
loadProfilesWithUpdateCheck,
|
||||
checkForUpdates,
|
||||
checkCurrentUrl,
|
||||
checkStartupPrompt,
|
||||
listenForUrlEvents,
|
||||
checkStartupUrls,
|
||||
checkCurrentUrl,
|
||||
loadGroups,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -628,66 +704,26 @@ export default function Home() {
|
||||
return (
|
||||
<div className="grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen gap-8 font-[family-name:var(--font-geist-sans)] bg-white dark:bg-black">
|
||||
<main className="flex flex-col row-start-2 gap-8 items-center w-full max-w-3xl">
|
||||
<Card className="w-full">
|
||||
<Card className="w-full gap-2">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-center">
|
||||
<CardTitle>Profiles</CardTitle>
|
||||
<div className="flex gap-2 items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoKebabHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setSettingsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<GoGear className="mr-2 w-4 h-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setProxyManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FiWifi className="mr-2 w-4 h-4" />
|
||||
Proxies
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setImportProfileDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FaDownload className="mr-2 w-4 h-4" />
|
||||
Import Profile
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCreateProfileDialogOpen(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Create a new profile</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<HomeHeader
|
||||
selectedProfiles={selectedProfiles}
|
||||
onBulkDelete={handleBulkDelete}
|
||||
onBulkGroupAssignment={handleBulkGroupAssignment}
|
||||
onCreateProfileDialogOpen={setCreateProfileDialogOpen}
|
||||
onGroupManagementDialogOpen={setGroupManagementDialogOpen}
|
||||
onImportProfileDialogOpen={setImportProfileDialogOpen}
|
||||
onProxyManagementDialogOpen={setProxyManagementDialogOpen}
|
||||
onSettingsDialogOpen={setSettingsDialogOpen}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<GroupBadges
|
||||
selectedGroupId={selectedGroupId}
|
||||
onGroupSelect={handleSelectGroup}
|
||||
groups={groups}
|
||||
isLoading={areGroupsLoading}
|
||||
/>
|
||||
<ProfilesDataTable
|
||||
data={profiles}
|
||||
onLaunchProfile={launchProfile}
|
||||
@@ -699,9 +735,11 @@ export default function Home() {
|
||||
onConfigureCamoufox={handleConfigureCamoufox}
|
||||
runningProfiles={runningProfiles}
|
||||
isUpdating={isUpdating}
|
||||
onReloadProxyData={
|
||||
proxyDataReloadTrigger > 0 ? triggerProxyDataReload : undefined
|
||||
}
|
||||
onDeleteSelectedProfiles={handleDeleteSelectedProfiles}
|
||||
onAssignProfilesToGroup={handleAssignProfilesToGroup}
|
||||
selectedGroupId={selectedGroupId}
|
||||
selectedProfiles={selectedProfiles}
|
||||
onSelectedProfilesChange={setSelectedProfiles}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -787,6 +825,32 @@ export default function Home() {
|
||||
profile={currentProfileForCamoufoxConfig}
|
||||
onSave={handleSaveCamoufoxConfig}
|
||||
/>
|
||||
|
||||
<GroupManagementDialog
|
||||
isOpen={groupManagementDialogOpen}
|
||||
onClose={() => {
|
||||
setGroupManagementDialogOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<GroupAssignmentDialog
|
||||
isOpen={groupAssignmentDialogOpen}
|
||||
onClose={() => {
|
||||
setGroupAssignmentDialogOpen(false);
|
||||
}}
|
||||
selectedProfiles={selectedProfilesForGroup}
|
||||
onAssignmentComplete={handleGroupAssignmentComplete}
|
||||
/>
|
||||
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={showBulkDeleteConfirmation}
|
||||
onClose={() => setShowBulkDeleteConfirmation(false)}
|
||||
onConfirm={confirmBulkDelete}
|
||||
title="Delete Selected Profiles"
|
||||
description={`This action cannot be undone. This will permanently delete ${selectedProfiles.length} profile${selectedProfiles.length !== 1 ? "s" : ""} and all associated data.`}
|
||||
confirmButtonText={`Delete ${selectedProfiles.length} Profile${selectedProfiles.length !== 1 ? "s" : ""}`}
|
||||
isLoading={isBulkDeleting}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
|
||||
interface CreateGroupDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onGroupCreated: (group: ProfileGroup) => void;
|
||||
}
|
||||
|
||||
export function CreateGroupDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onGroupCreated,
|
||||
}: CreateGroupDialogProps) {
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleCreate = useCallback(async () => {
|
||||
if (!groupName.trim()) return;
|
||||
|
||||
setIsCreating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const newGroup = await invoke<ProfileGroup>("create_profile_group", {
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group created successfully");
|
||||
onGroupCreated(newGroup);
|
||||
setGroupName("");
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to create group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to create group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
}, [groupName, onGroupCreated, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setGroupName("");
|
||||
setError(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create New Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new group to organize your browser profiles.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && groupName.trim()) {
|
||||
void handleCreate();
|
||||
}
|
||||
}}
|
||||
disabled={isCreating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isCreating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
isLoading={isCreating}
|
||||
onClick={() => void handleCreate()}
|
||||
disabled={!groupName.trim()}
|
||||
>
|
||||
Create Group
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -86,7 +86,7 @@ const browserOptions: BrowserOption[] = [
|
||||
{
|
||||
value: "mullvad-browser",
|
||||
label: "Mullvad Browser",
|
||||
description: "Privacy browser by Mullvad VPN",
|
||||
description: "TOR Browser fork by Mullvad VPN",
|
||||
},
|
||||
{
|
||||
value: "tor-browser",
|
||||
@@ -95,8 +95,6 @@ const browserOptions: BrowserOption[] = [
|
||||
},
|
||||
];
|
||||
|
||||
const IS_ANTI_DETECT_SUPPORTED = false;
|
||||
|
||||
export function CreateProfileDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -190,18 +188,35 @@ export function CreateProfileDialog({
|
||||
}
|
||||
}, [selectedBrowser, loadReleaseTypes]);
|
||||
|
||||
// Helper function to get the best available version and release type
|
||||
const getBestAvailableVersion = useCallback(
|
||||
(releaseTypes: BrowserReleaseTypes) => {
|
||||
if (releaseTypes.stable) {
|
||||
return { version: releaseTypes.stable, releaseType: "stable" as const };
|
||||
}
|
||||
if (releaseTypes.nightly) {
|
||||
return {
|
||||
version: releaseTypes.nightly,
|
||||
releaseType: "nightly" as const,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleDownload = async (browserStr: string) => {
|
||||
const releaseTypes =
|
||||
browserStr === "camoufox" ? camoufoxReleaseTypes : availableReleaseTypes;
|
||||
const latestStableVersion = releaseTypes.stable;
|
||||
const bestVersion = getBestAvailableVersion(releaseTypes);
|
||||
|
||||
if (!latestStableVersion) {
|
||||
console.error("No stable version available for download");
|
||||
if (!bestVersion) {
|
||||
console.error("No version available for download");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await downloadBrowser(browserStr, latestStableVersion);
|
||||
await downloadBrowser(browserStr, bestVersion.version);
|
||||
} catch (error) {
|
||||
console.error("Failed to download browser:", error);
|
||||
}
|
||||
@@ -218,24 +233,25 @@ export function CreateProfileDialog({
|
||||
return;
|
||||
}
|
||||
|
||||
// Use the latest stable version by default
|
||||
const latestStableVersion = availableReleaseTypes.stable;
|
||||
if (!latestStableVersion) {
|
||||
console.error("No stable version available");
|
||||
// Use the best available version (stable preferred, nightly as fallback)
|
||||
const bestVersion = getBestAvailableVersion(availableReleaseTypes);
|
||||
if (!bestVersion) {
|
||||
console.error("No version available");
|
||||
return;
|
||||
}
|
||||
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: selectedBrowser,
|
||||
version: latestStableVersion,
|
||||
releaseType: "stable",
|
||||
version: bestVersion.version,
|
||||
releaseType: bestVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
});
|
||||
} else {
|
||||
// Anti-detect tab - always use Camoufox with latest version
|
||||
const latestCamoufoxVersion = camoufoxReleaseTypes.stable;
|
||||
if (!latestCamoufoxVersion) {
|
||||
// Anti-detect tab - always use Camoufox with best available version
|
||||
const bestCamoufoxVersion =
|
||||
getBestAvailableVersion(camoufoxReleaseTypes);
|
||||
if (!bestCamoufoxVersion) {
|
||||
console.error("No Camoufox version available");
|
||||
return;
|
||||
}
|
||||
@@ -243,8 +259,8 @@ export function CreateProfileDialog({
|
||||
await onCreateProfile({
|
||||
name: profileName.trim(),
|
||||
browserStr: "camoufox" as BrowserTypeString,
|
||||
version: latestCamoufoxVersion,
|
||||
releaseType: "stable",
|
||||
version: bestCamoufoxVersion.version,
|
||||
releaseType: bestCamoufoxVersion.releaseType,
|
||||
proxyId: selectedProxyId,
|
||||
camoufoxConfig,
|
||||
});
|
||||
@@ -275,10 +291,12 @@ export function CreateProfileDialog({
|
||||
if (!profileName.trim()) return true;
|
||||
|
||||
if (activeTab === "regular") {
|
||||
return !selectedBrowser || !availableReleaseTypes.stable;
|
||||
return (
|
||||
!selectedBrowser || !getBestAvailableVersion(availableReleaseTypes)
|
||||
);
|
||||
} else {
|
||||
// For anti-detect, we need camoufox to be available
|
||||
return !camoufoxReleaseTypes.stable;
|
||||
return !getBestAvailableVersion(camoufoxReleaseTypes);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -290,8 +308,8 @@ export function CreateProfileDialog({
|
||||
const isBrowserVersionAvailable = (browserStr: string) => {
|
||||
const releaseTypes =
|
||||
browserStr === "camoufox" ? camoufoxReleaseTypes : availableReleaseTypes;
|
||||
const latestStableVersion = releaseTypes.stable;
|
||||
return latestStableVersion && isVersionDownloaded(latestStableVersion);
|
||||
const bestVersion = getBestAvailableVersion(releaseTypes);
|
||||
return bestVersion && isVersionDownloaded(bestVersion.version);
|
||||
};
|
||||
|
||||
// Get the selected OS for warning
|
||||
@@ -359,12 +377,15 @@ export function CreateProfileDialog({
|
||||
{selectedBrowser && (
|
||||
<div className="space-y-3">
|
||||
{!isBrowserVersionAvailable(selectedBrowser) &&
|
||||
availableReleaseTypes.stable && (
|
||||
getBestAvailableVersion(availableReleaseTypes) && (
|
||||
<div className="flex gap-3 items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Latest stable version (
|
||||
{availableReleaseTypes.stable}) needs to be
|
||||
downloaded
|
||||
{(() => {
|
||||
const bestVersion = getBestAvailableVersion(
|
||||
availableReleaseTypes,
|
||||
);
|
||||
return `${bestVersion?.releaseType === "stable" ? "Latest stable" : "Latest nightly"} version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload(selectedBrowser)}
|
||||
@@ -378,8 +399,12 @@ export function CreateProfileDialog({
|
||||
)}
|
||||
{isBrowserVersionAvailable(selectedBrowser) && (
|
||||
<div className="text-sm text-green-600">
|
||||
✓ Latest stable version (
|
||||
{availableReleaseTypes.stable}) is available
|
||||
{(() => {
|
||||
const bestVersion = getBestAvailableVersion(
|
||||
availableReleaseTypes,
|
||||
);
|
||||
return `✓ ${bestVersion?.releaseType === "stable" ? "Latest stable" : "Latest nightly"} version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -388,54 +413,51 @@ export function CreateProfileDialog({
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
{/* Anti-Detect Description */}
|
||||
<div className="p-3 text-center bg-blue-50 rounded-md border border-blue-200 dark:bg-blue-950 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
Anti-Detect support is coming soon!
|
||||
Powered by Camoufox
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
{IS_ANTI_DETECT_SUPPORTED && (
|
||||
<TabsContent value="anti-detect" className="mt-0 space-y-6">
|
||||
{/* Anti-Detect Description */}
|
||||
<div className="p-3 text-center bg-blue-50 rounded-md border border-blue-200 dark:bg-blue-950 dark:border-blue-800">
|
||||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||||
Powered by Camoufox
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Camoufox Download Status */}
|
||||
{!isBrowserVersionAvailable("camoufox") &&
|
||||
camoufoxReleaseTypes.stable && (
|
||||
<div className="flex gap-3 items-center p-3 bg-amber-50 rounded-md border border-amber-200">
|
||||
<p className="text-sm text-amber-800">
|
||||
Camoufox version ({camoufoxReleaseTypes.stable})
|
||||
needs to be downloaded
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload("camoufox")}
|
||||
isLoading={isBrowserDownloading("camoufox")}
|
||||
size="sm"
|
||||
disabled={isBrowserDownloading("camoufox")}
|
||||
>
|
||||
Download
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{isBrowserVersionAvailable("camoufox") && (
|
||||
<div className="p-3 text-sm text-green-600 bg-green-50 rounded-md border border-green-200">
|
||||
✓ Camoufox version ({camoufoxReleaseTypes.stable}) is
|
||||
available
|
||||
<div className="space-y-6">
|
||||
{/* Camoufox Download Status */}
|
||||
{!isBrowserVersionAvailable("camoufox") &&
|
||||
getBestAvailableVersion(camoufoxReleaseTypes) && (
|
||||
<div className="flex gap-3 items-center p-3 bg-amber-50 rounded-md border border-amber-200">
|
||||
<p className="text-sm text-amber-800">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(camoufoxReleaseTypes);
|
||||
return `Camoufox ${bestVersion?.releaseType} version (${bestVersion?.version}) needs to be downloaded`;
|
||||
})()}
|
||||
</p>
|
||||
<LoadingButton
|
||||
onClick={() => handleDownload("camoufox")}
|
||||
isLoading={isBrowserDownloading("camoufox")}
|
||||
size="sm"
|
||||
disabled={isBrowserDownloading("camoufox")}
|
||||
>
|
||||
Download
|
||||
</LoadingButton>
|
||||
</div>
|
||||
)}
|
||||
{isBrowserVersionAvailable("camoufox") && (
|
||||
<div className="p-3 text-sm text-green-600 bg-green-50 rounded-md border border-green-200">
|
||||
{(() => {
|
||||
const bestVersion =
|
||||
getBestAvailableVersion(camoufoxReleaseTypes);
|
||||
return `✓ Camoufox ${bestVersion?.releaseType} version (${bestVersion?.version}) is available`;
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
)}
|
||||
<SharedCamoufoxConfigForm
|
||||
config={camoufoxConfig}
|
||||
onConfigChange={updateCamoufoxConfig}
|
||||
/>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
{/* Proxy Selection - Common to both tabs - Compact without card */}
|
||||
{storedProxies.length > 0 && (
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface DeleteConfirmationDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void | Promise<void>;
|
||||
title: string;
|
||||
description: string;
|
||||
confirmButtonText?: string;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function DeleteConfirmationDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
onConfirm,
|
||||
title,
|
||||
description,
|
||||
confirmButtonText = "Delete",
|
||||
isLoading = false,
|
||||
}: DeleteConfirmationDialogProps) {
|
||||
const handleConfirm = async () => {
|
||||
await onConfirm();
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleConfirm()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? "Deleting..." : confirmButtonText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import type { BrowserProfile, ProfileGroup } from "@/types";
|
||||
|
||||
interface DeleteGroupDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
group: ProfileGroup | null;
|
||||
onGroupDeleted: () => void;
|
||||
}
|
||||
|
||||
export function DeleteGroupDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
group,
|
||||
onGroupDeleted,
|
||||
}: DeleteGroupDialogProps) {
|
||||
const [associatedProfiles, setAssociatedProfiles] = useState<
|
||||
BrowserProfile[]
|
||||
>([]);
|
||||
const [deleteAction, setDeleteAction] = useState<"move" | "delete">("move");
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAssociatedProfiles = useCallback(async () => {
|
||||
if (!group) return;
|
||||
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const allProfiles = await invoke<BrowserProfile[]>(
|
||||
"list_browser_profiles",
|
||||
);
|
||||
const groupProfiles = allProfiles.filter(
|
||||
(profile) => profile.group_id === group.id,
|
||||
);
|
||||
setAssociatedProfiles(groupProfiles);
|
||||
} catch (err) {
|
||||
console.error("Failed to load associated profiles:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load profiles");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [group]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen && group) {
|
||||
void loadAssociatedProfiles();
|
||||
}
|
||||
}, [isOpen, group, loadAssociatedProfiles]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!group) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
setError(null);
|
||||
try {
|
||||
if (deleteAction === "delete" && associatedProfiles.length > 0) {
|
||||
// Delete all associated profiles first
|
||||
const profileNames = associatedProfiles.map((p) => p.name);
|
||||
await invoke("delete_selected_profiles", { profileNames });
|
||||
} else if (deleteAction === "move" && associatedProfiles.length > 0) {
|
||||
// Move profiles to default group (null group_id)
|
||||
const profileNames = associatedProfiles.map((p) => p.name);
|
||||
await invoke("assign_profiles_to_group", {
|
||||
profileNames,
|
||||
groupId: null,
|
||||
});
|
||||
}
|
||||
|
||||
// Delete the group
|
||||
await invoke("delete_profile_group", { groupId: group.id });
|
||||
|
||||
toast.success("Group deleted successfully");
|
||||
onGroupDeleted();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to delete group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to delete group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [group, deleteAction, associatedProfiles, onGroupDeleted, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
setDeleteAction("move");
|
||||
setAssociatedProfiles([]);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the group
|
||||
"{group?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading associated profiles...
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{associatedProfiles.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
Associated Profiles ({associatedProfiles.length})
|
||||
</Label>
|
||||
<ScrollArea className="h-32 w-full border rounded-md p-3">
|
||||
<div className="space-y-1">
|
||||
{associatedProfiles.map((profile) => (
|
||||
<div key={profile.id} className="text-sm">
|
||||
• {profile.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label>What should happen to these profiles?</Label>
|
||||
<RadioGroup
|
||||
value={deleteAction}
|
||||
onValueChange={(value) =>
|
||||
setDeleteAction(value as "move" | "delete")
|
||||
}
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="move" id="move" />
|
||||
<Label htmlFor="move" className="text-sm">
|
||||
Move profiles to Default group
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="delete" id="delete" />
|
||||
<Label
|
||||
htmlFor="delete"
|
||||
className="text-sm text-red-600"
|
||||
>
|
||||
Delete profiles along with the group
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{associatedProfiles.length === 0 && !isLoading && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
This group has no associated profiles.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isDeleting}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
isLoading={isDeleting}
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Delete Group
|
||||
{deleteAction === "delete" &&
|
||||
associatedProfiles.length > 0 &&
|
||||
" & Profiles"}
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
|
||||
interface EditGroupDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
group: ProfileGroup | null;
|
||||
onGroupUpdated: (group: ProfileGroup) => void;
|
||||
}
|
||||
|
||||
export function EditGroupDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
group,
|
||||
onGroupUpdated,
|
||||
}: EditGroupDialogProps) {
|
||||
const [groupName, setGroupName] = useState("");
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (group) {
|
||||
setGroupName(group.name);
|
||||
} else {
|
||||
setGroupName("");
|
||||
}
|
||||
setError(null);
|
||||
}, [group]);
|
||||
|
||||
const handleUpdate = useCallback(async () => {
|
||||
if (!group || !groupName.trim()) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
setError(null);
|
||||
try {
|
||||
const updatedGroup = await invoke<ProfileGroup>("update_profile_group", {
|
||||
groupId: group.id,
|
||||
name: groupName.trim(),
|
||||
});
|
||||
|
||||
toast.success("Group updated successfully");
|
||||
onGroupUpdated(updatedGroup);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to update group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error ? err.message : "Failed to update group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
}, [group, groupName, onGroupUpdated, onClose]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setError(null);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the name of the group "{group?.name}".
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-name">Group Name</Label>
|
||||
<Input
|
||||
id="group-name"
|
||||
placeholder="Enter group name..."
|
||||
value={groupName}
|
||||
onChange={(e) => setGroupName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && groupName.trim()) {
|
||||
void handleUpdate();
|
||||
}
|
||||
}}
|
||||
disabled={isUpdating}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={handleClose} disabled={isUpdating}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
isLoading={isUpdating}
|
||||
onClick={() => void handleUpdate()}
|
||||
disabled={!groupName.trim() || groupName === group?.name}
|
||||
>
|
||||
Update Group
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { LoadingButton } from "@/components/loading-button";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
|
||||
interface GroupAssignmentDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
selectedProfiles: string[];
|
||||
onAssignmentComplete: () => void;
|
||||
}
|
||||
|
||||
export function GroupAssignmentDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
selectedProfiles,
|
||||
onAssignmentComplete,
|
||||
}: GroupAssignmentDialogProps) {
|
||||
const [groups, setGroups] = useState<ProfileGroup[]>([]);
|
||||
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isAssigning, setIsAssigning] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const groupList = await invoke<ProfileGroup[]>("get_profile_groups");
|
||||
setGroups(groupList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleAssign = useCallback(async () => {
|
||||
setIsAssigning(true);
|
||||
setError(null);
|
||||
try {
|
||||
await invoke("assign_profiles_to_group", {
|
||||
profileNames: selectedProfiles,
|
||||
groupId: selectedGroupId,
|
||||
});
|
||||
|
||||
const groupName = selectedGroupId
|
||||
? groups.find((g) => g.id === selectedGroupId)?.name || "Unknown Group"
|
||||
: "Default";
|
||||
|
||||
toast.success(
|
||||
`Successfully assigned ${selectedProfiles.length} profile(s) to ${groupName}`,
|
||||
);
|
||||
onAssignmentComplete();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
console.error("Failed to assign profiles to group:", err);
|
||||
const errorMessage =
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "Failed to assign profiles to group";
|
||||
setError(errorMessage);
|
||||
toast.error(errorMessage);
|
||||
} finally {
|
||||
setIsAssigning(false);
|
||||
}
|
||||
}, [
|
||||
selectedProfiles,
|
||||
selectedGroupId,
|
||||
groups,
|
||||
onAssignmentComplete,
|
||||
onClose,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadGroups();
|
||||
setSelectedGroupId(null);
|
||||
setError(null);
|
||||
}
|
||||
}, [isOpen, loadGroups]);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Assign to Group</DialogTitle>
|
||||
<DialogDescription>
|
||||
Assign {selectedProfiles.length} selected profile(s) to a group.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Selected Profiles:</Label>
|
||||
<div className="p-3 bg-muted rounded-md max-h-32 overflow-y-auto">
|
||||
<ul className="text-sm space-y-1">
|
||||
{selectedProfiles.map((profileName) => (
|
||||
<li key={profileName} className="truncate">
|
||||
• {profileName}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="group-select">Assign to Group:</Label>
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={selectedGroupId || "default"}
|
||||
onValueChange={(value) => {
|
||||
setSelectedGroupId(value === "default" ? null : value);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="Select a group" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="default">Default (No Group)</SelectItem>
|
||||
{groups.map((group) => (
|
||||
<SelectItem key={group.id} value={group.id}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose} disabled={isAssigning}>
|
||||
Cancel
|
||||
</Button>
|
||||
<LoadingButton
|
||||
isLoading={isAssigning}
|
||||
onClick={() => void handleAssign()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Assign
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import type { GroupWithCount } from "@/types";
|
||||
|
||||
interface GroupBadgesProps {
|
||||
selectedGroupId: string | null;
|
||||
onGroupSelect: (groupId: string | null) => void;
|
||||
refreshTrigger?: number;
|
||||
groups: GroupWithCount[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
export function GroupBadges({
|
||||
selectedGroupId,
|
||||
onGroupSelect,
|
||||
groups,
|
||||
isLoading,
|
||||
}: GroupBadgesProps) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
<div className="text-sm text-muted-foreground">Loading groups...</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (groups.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{groups.map((group) => (
|
||||
<Badge
|
||||
key={group.id}
|
||||
variant={selectedGroupId === group.id ? "default" : "secondary"}
|
||||
className="cursor-pointer hover:bg-primary/80 transition-colors flex items-center gap-2 px-3 py-1"
|
||||
onClick={() => {
|
||||
onGroupSelect(selectedGroupId === group.id ? null : group.id);
|
||||
}}
|
||||
>
|
||||
<span>{group.name}</span>
|
||||
<span className="bg-background/20 text-xs px-1.5 py-0.5 rounded-sm">
|
||||
{group.count}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
"use client";
|
||||
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import { LuPencil, LuTrash2 } from "react-icons/lu";
|
||||
import { CreateGroupDialog } from "@/components/create-group-dialog";
|
||||
import { DeleteGroupDialog } from "@/components/delete-group-dialog";
|
||||
import { EditGroupDialog } from "@/components/edit-group-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import type { ProfileGroup } from "@/types";
|
||||
|
||||
interface GroupManagementDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function GroupManagementDialog({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: GroupManagementDialogProps) {
|
||||
const [groups, setGroups] = useState<ProfileGroup[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Dialog states
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [selectedGroup, setSelectedGroup] = useState<ProfileGroup | null>(null);
|
||||
|
||||
const loadGroups = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const groupList = await invoke<ProfileGroup[]>("get_profile_groups");
|
||||
setGroups(groupList);
|
||||
} catch (err) {
|
||||
console.error("Failed to load groups:", err);
|
||||
setError(err instanceof Error ? err.message : "Failed to load groups");
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleGroupCreated = useCallback((newGroup: ProfileGroup) => {
|
||||
setGroups((prev) => [...prev, newGroup]);
|
||||
}, []);
|
||||
|
||||
const handleGroupUpdated = useCallback((updatedGroup: ProfileGroup) => {
|
||||
setGroups((prev) =>
|
||||
prev.map((group) =>
|
||||
group.id === updatedGroup.id ? updatedGroup : group,
|
||||
),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const handleGroupDeleted = useCallback(() => {
|
||||
void loadGroups();
|
||||
}, [loadGroups]);
|
||||
|
||||
const handleEditGroup = useCallback((group: ProfileGroup) => {
|
||||
setSelectedGroup(group);
|
||||
setEditDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteGroup = useCallback((group: ProfileGroup) => {
|
||||
setSelectedGroup(group);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
void loadGroups();
|
||||
}
|
||||
}, [isOpen, loadGroups]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Manage Profile Groups</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create, edit, and delete profile groups. Profiles without a group
|
||||
will appear in the "Default" group.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* Create new group button */}
|
||||
<div className="flex justify-between items-center">
|
||||
<Label>Groups</Label>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
Create Group
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 text-sm text-red-600 bg-red-50 rounded-md dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Groups list */}
|
||||
{isLoading ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Loading groups...
|
||||
</div>
|
||||
) : groups.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No groups created yet. Create your first group using the button
|
||||
above.
|
||||
</div>
|
||||
) : (
|
||||
<div className="border rounded-md">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead className="w-24">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{groups.map((group) => (
|
||||
<TableRow key={group.id}>
|
||||
<TableCell className="font-medium">
|
||||
{group.name}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditGroup(group)}
|
||||
>
|
||||
<LuPencil className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteGroup(group)}
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<CreateGroupDialog
|
||||
isOpen={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
onGroupCreated={handleGroupCreated}
|
||||
/>
|
||||
|
||||
<EditGroupDialog
|
||||
isOpen={editDialogOpen}
|
||||
onClose={() => setEditDialogOpen(false)}
|
||||
group={selectedGroup}
|
||||
onGroupUpdated={handleGroupUpdated}
|
||||
/>
|
||||
|
||||
<DeleteGroupDialog
|
||||
isOpen={deleteDialogOpen}
|
||||
onClose={() => setDeleteDialogOpen(false)}
|
||||
group={selectedGroup}
|
||||
onGroupDeleted={handleGroupDeleted}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import { FaDownload } from "react-icons/fa";
|
||||
import { FiWifi } from "react-icons/fi";
|
||||
import { GoGear, GoKebabHorizontal, GoPlus } from "react-icons/go";
|
||||
import { LuTrash2, LuUsers } from "react-icons/lu";
|
||||
import { Logo } from "./icons/logo";
|
||||
import { Button } from "./ui/button";
|
||||
import { CardTitle } from "./ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "./ui/dropdown-menu";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
type Props = {
|
||||
selectedProfiles: string[];
|
||||
onBulkGroupAssignment: () => void;
|
||||
onBulkDelete: () => void;
|
||||
onSettingsDialogOpen: (open: boolean) => void;
|
||||
onProxyManagementDialogOpen: (open: boolean) => void;
|
||||
onGroupManagementDialogOpen: (open: boolean) => void;
|
||||
onImportProfileDialogOpen: (open: boolean) => void;
|
||||
onCreateProfileDialogOpen: (open: boolean) => void;
|
||||
};
|
||||
|
||||
const HomeHeader = ({
|
||||
selectedProfiles,
|
||||
onBulkGroupAssignment,
|
||||
onBulkDelete,
|
||||
onSettingsDialogOpen,
|
||||
onProxyManagementDialogOpen,
|
||||
onGroupManagementDialogOpen,
|
||||
onImportProfileDialogOpen,
|
||||
onCreateProfileDialogOpen,
|
||||
}: Props) => {
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="flex items-center gap-3">
|
||||
<Logo className="w-10 h-10" />
|
||||
{selectedProfiles.length > 0 ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedProfiles.length} profile
|
||||
{selectedProfiles.length !== 1 ? "s" : ""} selected
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onBulkGroupAssignment}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuUsers className="w-4 h-4" />
|
||||
Assign to Group
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={onBulkDelete}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<LuTrash2 className="w-4 h-4" />
|
||||
Delete Selected
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<CardTitle>Donut</CardTitle>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex gap-2 items-center">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoKebabHorizontal className="w-4 h-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onSettingsDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<GoGear className="mr-2 w-4 h-4" />
|
||||
Settings
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onProxyManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FiWifi className="mr-2 w-4 h-4" />
|
||||
Proxies
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onGroupManagementDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<LuUsers className="mr-2 w-4 h-4" />
|
||||
Groups
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onImportProfileDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<FaDownload className="mr-2 w-4 h-4" />
|
||||
Import Profile
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
onCreateProfileDialogOpen(true);
|
||||
}}
|
||||
className="flex gap-2 items-center"
|
||||
>
|
||||
<GoPlus className="w-4 h-4" />
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Create a new profile</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HomeHeader;
|
||||
File diff suppressed because one or more lines are too long
@@ -13,11 +13,12 @@ import * as React from "react";
|
||||
import { CiCircleCheck } from "react-icons/ci";
|
||||
import { IoEllipsisHorizontal } from "react-icons/io5";
|
||||
import { LuChevronDown, LuChevronUp } from "react-icons/lu";
|
||||
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
@@ -44,6 +45,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useBrowserState } from "@/hooks/use-browser-support";
|
||||
import { useTableSorting } from "@/hooks/use-table-sorting";
|
||||
import {
|
||||
getBrowserDisplayName,
|
||||
@@ -67,6 +69,11 @@ interface ProfilesDataTableProps {
|
||||
runningProfiles: Set<string>;
|
||||
isUpdating?: (browser: string) => boolean;
|
||||
onReloadProxyData?: () => void | Promise<void>;
|
||||
onDeleteSelectedProfiles?: (profileNames: string[]) => Promise<void>;
|
||||
onAssignProfilesToGroup?: (profileNames: string[]) => void;
|
||||
selectedGroupId?: string | null;
|
||||
selectedProfiles?: string[];
|
||||
onSelectedProfilesChange?: (profiles: string[]) => void;
|
||||
}
|
||||
|
||||
export function ProfilesDataTable({
|
||||
@@ -80,7 +87,11 @@ export function ProfilesDataTable({
|
||||
onConfigureCamoufox,
|
||||
runningProfiles,
|
||||
isUpdating = () => false,
|
||||
onReloadProxyData,
|
||||
onDeleteSelectedProfiles: _onDeleteSelectedProfiles,
|
||||
onAssignProfilesToGroup,
|
||||
selectedGroupId,
|
||||
selectedProfiles: externalSelectedProfiles = [],
|
||||
onSelectedProfilesChange,
|
||||
}: ProfilesDataTableProps) {
|
||||
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
|
||||
const [sorting, setSorting] = React.useState<SortingState>([]);
|
||||
@@ -90,11 +101,13 @@ export function ProfilesDataTable({
|
||||
const [renameError, setRenameError] = React.useState<string | null>(null);
|
||||
const [profileToDelete, setProfileToDelete] =
|
||||
React.useState<BrowserProfile | null>(null);
|
||||
const [deleteConfirmationName, setDeleteConfirmationName] =
|
||||
React.useState("");
|
||||
const [deleteError, setDeleteError] = React.useState<string | null>(null);
|
||||
const [isClient, setIsClient] = React.useState(false);
|
||||
const [isDeleting, setIsDeleting] = React.useState(false);
|
||||
|
||||
const [storedProxies, setStoredProxies] = React.useState<StoredProxy[]>([]);
|
||||
const [selectedProfiles, setSelectedProfiles] = React.useState<Set<string>>(
|
||||
new Set(externalSelectedProfiles),
|
||||
);
|
||||
const [showCheckboxes, setShowCheckboxes] = React.useState(false);
|
||||
|
||||
// Helper function to check if a profile has a proxy
|
||||
const hasProxy = React.useCallback(
|
||||
@@ -125,10 +138,21 @@ export function ProfilesDataTable({
|
||||
[storedProxies],
|
||||
);
|
||||
|
||||
// Ensure we're on the client side to prevent hydration mismatches
|
||||
React.useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
// Filter data by selected group
|
||||
const filteredData = React.useMemo(() => {
|
||||
if (!selectedGroupId) return data;
|
||||
if (selectedGroupId === "default") {
|
||||
return data.filter((profile) => !profile.group_id);
|
||||
}
|
||||
return data.filter((profile) => profile.group_id === selectedGroupId);
|
||||
}, [data, selectedGroupId]);
|
||||
|
||||
// Use shared browser state hook
|
||||
const browserState = useBrowserState(
|
||||
filteredData,
|
||||
runningProfiles,
|
||||
isUpdating,
|
||||
);
|
||||
|
||||
// Load stored proxies
|
||||
const loadStoredProxies = React.useCallback(async () => {
|
||||
@@ -141,35 +165,35 @@ export function ProfilesDataTable({
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isClient) {
|
||||
if (browserState.isClient) {
|
||||
void loadStoredProxies();
|
||||
}
|
||||
}, [isClient, loadStoredProxies]);
|
||||
}, [browserState.isClient, loadStoredProxies]);
|
||||
|
||||
// Reload proxy data when requested from parent
|
||||
// Sync external selected profiles with internal state
|
||||
React.useEffect(() => {
|
||||
if (onReloadProxyData) {
|
||||
void loadStoredProxies();
|
||||
}
|
||||
}, [onReloadProxyData, loadStoredProxies]);
|
||||
const newSet = new Set(externalSelectedProfiles);
|
||||
setSelectedProfiles(newSet);
|
||||
setShowCheckboxes(newSet.size > 0);
|
||||
}, [externalSelectedProfiles]);
|
||||
|
||||
// Update local sorting state when settings are loaded
|
||||
React.useEffect(() => {
|
||||
if (isLoaded && isClient) {
|
||||
if (isLoaded && browserState.isClient) {
|
||||
setSorting(getTableSorting());
|
||||
}
|
||||
}, [isLoaded, getTableSorting, isClient]);
|
||||
}, [isLoaded, getTableSorting, browserState.isClient]);
|
||||
|
||||
// Handle sorting changes
|
||||
const handleSortingChange = React.useCallback(
|
||||
(updater: React.SetStateAction<SortingState>) => {
|
||||
if (!isClient) return;
|
||||
if (!browserState.isClient) return;
|
||||
const newSorting =
|
||||
typeof updater === "function" ? updater(sorting) : updater;
|
||||
setSorting(newSorting);
|
||||
updateSorting(newSorting);
|
||||
},
|
||||
[sorting, updateSorting, isClient],
|
||||
[browserState.isClient, sorting, updateSorting],
|
||||
);
|
||||
|
||||
const handleRename = async () => {
|
||||
@@ -180,80 +204,190 @@ export function ProfilesDataTable({
|
||||
setProfileToRename(null);
|
||||
setNewProfileName("");
|
||||
setRenameError(null);
|
||||
} catch (err) {
|
||||
setRenameError(err as string);
|
||||
} catch (error) {
|
||||
setRenameError(
|
||||
error instanceof Error ? error.message : "Failed to rename profile",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!profileToDelete || !deleteConfirmationName.trim()) return;
|
||||
|
||||
if (deleteConfirmationName.trim() !== profileToDelete.name) {
|
||||
setDeleteError(
|
||||
"Profile name doesn't match. Please type the exact name to confirm deletion.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!profileToDelete) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
await onDeleteProfile(profileToDelete);
|
||||
setProfileToDelete(null);
|
||||
setDeleteConfirmationName("");
|
||||
setDeleteError(null);
|
||||
} catch (err) {
|
||||
setDeleteError(err as string);
|
||||
} catch (error) {
|
||||
console.error("Failed to delete profile:", error);
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle icon/checkbox click
|
||||
const handleIconClick = React.useCallback(
|
||||
(profileName: string) => {
|
||||
setShowCheckboxes(true);
|
||||
setSelectedProfiles((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(profileName)) {
|
||||
newSet.delete(profileName);
|
||||
} else {
|
||||
newSet.add(profileName);
|
||||
}
|
||||
|
||||
// Hide checkboxes if no profiles are selected
|
||||
if (newSet.size === 0) {
|
||||
setShowCheckboxes(false);
|
||||
}
|
||||
|
||||
// Notify parent component
|
||||
if (onSelectedProfilesChange) {
|
||||
onSelectedProfilesChange(Array.from(newSet));
|
||||
}
|
||||
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
[onSelectedProfilesChange],
|
||||
);
|
||||
|
||||
// Handle checkbox change
|
||||
const handleCheckboxChange = React.useCallback(
|
||||
(profileName: string, checked: boolean) => {
|
||||
setSelectedProfiles((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (checked) {
|
||||
newSet.add(profileName);
|
||||
} else {
|
||||
newSet.delete(profileName);
|
||||
}
|
||||
|
||||
// Hide checkboxes if no profiles are selected
|
||||
if (newSet.size === 0) {
|
||||
setShowCheckboxes(false);
|
||||
}
|
||||
|
||||
// Notify parent component
|
||||
if (onSelectedProfilesChange) {
|
||||
onSelectedProfilesChange(Array.from(newSet));
|
||||
}
|
||||
|
||||
return newSet;
|
||||
});
|
||||
},
|
||||
[onSelectedProfilesChange],
|
||||
);
|
||||
|
||||
// Handle select all checkbox
|
||||
const handleToggleAll = React.useCallback(
|
||||
(checked: boolean) => {
|
||||
const newSet = checked
|
||||
? new Set(filteredData.map((profile) => profile.name))
|
||||
: new Set<string>();
|
||||
|
||||
setSelectedProfiles(newSet);
|
||||
setShowCheckboxes(checked);
|
||||
|
||||
// Notify parent component
|
||||
if (onSelectedProfilesChange) {
|
||||
onSelectedProfilesChange(Array.from(newSet));
|
||||
}
|
||||
},
|
||||
[filteredData, onSelectedProfilesChange],
|
||||
);
|
||||
|
||||
const columns: ColumnDef<BrowserProfile>[] = React.useMemo(
|
||||
() => [
|
||||
{
|
||||
id: "select",
|
||||
header: () => (
|
||||
<span>
|
||||
<Checkbox
|
||||
checked={
|
||||
selectedProfiles.size === filteredData.length &&
|
||||
filteredData.length !== 0
|
||||
}
|
||||
onCheckedChange={(value) => handleToggleAll(!!value)}
|
||||
aria-label="Select all"
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
</span>
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
const browser = profile.browser;
|
||||
const IconComponent = getBrowserIcon(browser);
|
||||
const isSelected = selectedProfiles.has(profile.name);
|
||||
|
||||
if (showCheckboxes || isSelected) {
|
||||
return (
|
||||
<span className="w-4 h-4 flex items-center justify-center">
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={(value) =>
|
||||
handleCheckboxChange(profile.name, !!value)
|
||||
}
|
||||
aria-label="Select row"
|
||||
className="w-4 h-4"
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="relative flex items-center justify-center w-4 h-4">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center justify-center cursor-pointer border-none p-0"
|
||||
onClick={() => handleIconClick(profile.name)}
|
||||
aria-label="Select profile"
|
||||
>
|
||||
<span className="w-4 h-4 group">
|
||||
{IconComponent && (
|
||||
<IconComponent className="w-4 h-4 group-hover:hidden" />
|
||||
)}
|
||||
<span className="peer border-input dark:bg-input/30 dark:data-[state=checked]:bg-primary size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none w-4 h-4 hidden group-hover:block pointer-events-none items-center justify-center duration-200" />
|
||||
</span>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
size: 40,
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
const isRunning = isClient && runningProfiles.has(profile.name);
|
||||
const isBrowserUpdating = isClient && isUpdating(profile.browser);
|
||||
|
||||
// Check if any TOR browser profile is running
|
||||
const isTorBrowser = profile.browser === "tor-browser";
|
||||
const anyTorRunning =
|
||||
isClient &&
|
||||
data.some(
|
||||
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name),
|
||||
);
|
||||
const shouldDisableTorStart =
|
||||
isTorBrowser && !isRunning && anyTorRunning;
|
||||
|
||||
const isDisabled = shouldDisableTorStart || isBrowserUpdating;
|
||||
const isRunning =
|
||||
browserState.isClient && runningProfiles.has(profile.name);
|
||||
const canLaunch = browserState.canLaunchProfile(profile);
|
||||
const tooltipContent = browserState.getLaunchTooltipContent(profile);
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={isRunning ? "destructive" : "default"}
|
||||
size="sm"
|
||||
disabled={!isClient || isDisabled}
|
||||
onClick={() =>
|
||||
void (isRunning
|
||||
? onKillProfile(profile)
|
||||
: onLaunchProfile(profile))
|
||||
}
|
||||
>
|
||||
{isRunning ? "Stop" : "Launch"}
|
||||
</Button>
|
||||
<span className="inline-flex">
|
||||
<Button
|
||||
variant={isRunning ? "destructive" : "default"}
|
||||
size="sm"
|
||||
disabled={!canLaunch}
|
||||
className={!canLaunch ? "opacity-50" : ""}
|
||||
onClick={() =>
|
||||
void (isRunning
|
||||
? onKillProfile(profile)
|
||||
: onLaunchProfile(profile))
|
||||
}
|
||||
>
|
||||
{isRunning ? "Stop" : "Launch"}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{!isClient
|
||||
? "Loading..."
|
||||
: isRunning
|
||||
? "Click to forcefully stop the browser"
|
||||
: isBrowserUpdating
|
||||
? `${profile.browser} is being updated. Please wait for the update to complete.`
|
||||
: shouldDisableTorStart
|
||||
? "Only one TOR browser instance can run at a time. Stop the running TOR browser first."
|
||||
: "Click to launch the browser"}
|
||||
</TooltipContent>
|
||||
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
@@ -262,91 +396,65 @@ export function ProfilesDataTable({
|
||||
{
|
||||
accessorKey: "name",
|
||||
header: ({ column }) => {
|
||||
const isSorted = column.getIsSorted();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="p-0 h-auto font-semibold hover:bg-transparent"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
className="h-auto p-0 font-semibold text-left justify-start"
|
||||
>
|
||||
Profile
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
|
||||
{isSorted === "desc" && (
|
||||
<LuChevronDown className="ml-2 w-4 h-4" />
|
||||
)}
|
||||
{!isSorted && (
|
||||
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
|
||||
)}
|
||||
Name
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
<LuChevronUp className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === "desc" ? (
|
||||
<LuChevronDown className="ml-2 h-4 w-4" />
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
sortingFn: "alphanumeric",
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
return profile.name.length > 15 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="truncate">{profile.name.slice(0, 15)}...</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{profile.name}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
profile.name
|
||||
);
|
||||
const name: string = row.getValue("name");
|
||||
return <div className="font-medium text-left">{name}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "browser",
|
||||
header: ({ column }) => {
|
||||
const isSorted = column.getIsSorted();
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="p-0 h-auto font-semibold hover:bg-transparent"
|
||||
onClick={() =>
|
||||
column.toggleSorting(column.getIsSorted() === "asc")
|
||||
}
|
||||
className="h-auto p-0 font-semibold text-left justify-start"
|
||||
>
|
||||
Browser
|
||||
{isSorted === "asc" && <LuChevronUp className="ml-2 w-4 h-4" />}
|
||||
{isSorted === "desc" && (
|
||||
<LuChevronDown className="ml-2 w-4 h-4" />
|
||||
)}
|
||||
{!isSorted && (
|
||||
<LuChevronDown className="ml-2 w-4 h-4 opacity-50" />
|
||||
)}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
<LuChevronUp className="ml-2 h-4 w-4" />
|
||||
) : column.getIsSorted() === "desc" ? (
|
||||
<LuChevronDown className="ml-2 h-4 w-4" />
|
||||
) : null}
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
cell: ({ row }) => {
|
||||
const browser: string = row.getValue("browser");
|
||||
const IconComponent = getBrowserIcon(browser);
|
||||
const browserDisplayName = getBrowserDisplayName(browser);
|
||||
return browserDisplayName.length > 15 ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex gap-2 items-center">
|
||||
{IconComponent && <IconComponent className="w-4 h-4" />}
|
||||
<span>{browserDisplayName.slice(0, 15)}...</span>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{browserDisplayName}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<div className="flex gap-2 items-center">
|
||||
{IconComponent && <IconComponent className="w-4 h-4" />}
|
||||
<span>{browserDisplayName}</span>
|
||||
return (
|
||||
<div className="flex items-center">
|
||||
<span>{getBrowserDisplayName(browser)}</span>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
sortingFn: (rowA, rowB, columnId) => {
|
||||
const browserA = getBrowserDisplayName(rowA.getValue(columnId));
|
||||
const browserB = getBrowserDisplayName(rowB.getValue(columnId));
|
||||
return browserA.localeCompare(browserB);
|
||||
const browserA: string = rowA.getValue(columnId);
|
||||
const browserB: string = rowB.getValue(columnId);
|
||||
return getBrowserDisplayName(browserA).localeCompare(
|
||||
getBrowserDisplayName(browserB),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -398,38 +506,41 @@ export function ProfilesDataTable({
|
||||
: "No proxy configured";
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<div className="flex gap-2 items-center">
|
||||
{profileHasProxy && (
|
||||
<CiCircleCheck className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
|
||||
{proxyDisplayName.length > 10 ? (
|
||||
<span className="text-sm truncate text-muted-foreground">
|
||||
{proxyDisplayName.slice(0, 10)}...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{profile.browser === "tor-browser"
|
||||
? "Not supported"
|
||||
: proxyDisplayName}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex gap-2 items-center">
|
||||
{profileHasProxy && (
|
||||
<CiCircleCheck className="w-4 h-4 text-green-500" />
|
||||
)}
|
||||
{proxyDisplayName.length > 10 ? (
|
||||
<span className="text-sm truncate text-muted-foreground">
|
||||
{proxyDisplayName.slice(0, 10)}...
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{profile.browser === "tor-browser"
|
||||
? "Not supported"
|
||||
: proxyDisplayName}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipText}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
// Update the settings column to use the confirmation dialog
|
||||
{
|
||||
id: "settings",
|
||||
cell: ({ row }) => {
|
||||
const profile = row.original;
|
||||
const isRunning = isClient && runningProfiles.has(profile.name);
|
||||
const isBrowserUpdating = isClient && isUpdating(profile.browser);
|
||||
const isRunning =
|
||||
browserState.isClient && runningProfiles.has(profile.name);
|
||||
const isBrowserUpdating =
|
||||
browserState.isClient && isUpdating(profile.browser);
|
||||
|
||||
return (
|
||||
<div className="flex justify-end items-center">
|
||||
<DropdownMenu>
|
||||
@@ -437,7 +548,7 @@ export function ProfilesDataTable({
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="p-0 w-8 h-8"
|
||||
disabled={!isClient}
|
||||
disabled={!browserState.isClient}
|
||||
>
|
||||
<span className="sr-only">Open menu</span>
|
||||
<IoEllipsisHorizontal className="w-4 h-4" />
|
||||
@@ -450,16 +561,28 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
onProxySettings(profile);
|
||||
}}
|
||||
disabled={!isClient || isBrowserUpdating}
|
||||
disabled={!browserState.isClient || isBrowserUpdating}
|
||||
>
|
||||
Configure Proxy
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
if (onAssignProfilesToGroup) {
|
||||
onAssignProfilesToGroup([profile.name]);
|
||||
}
|
||||
}}
|
||||
disabled={!browserState.isClient || isBrowserUpdating}
|
||||
>
|
||||
Assign to Group
|
||||
</DropdownMenuItem>
|
||||
{profile.browser === "camoufox" && onConfigureCamoufox && (
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
onConfigureCamoufox(profile);
|
||||
}}
|
||||
disabled={!isClient || isBrowserUpdating}
|
||||
disabled={
|
||||
!browserState.isClient || isRunning || isBrowserUpdating
|
||||
}
|
||||
>
|
||||
Configure Camoufox
|
||||
</DropdownMenuItem>
|
||||
@@ -471,7 +594,9 @@ export function ProfilesDataTable({
|
||||
onClick={() => {
|
||||
onChangeVersion(profile);
|
||||
}}
|
||||
disabled={!isClient || isRunning || isBrowserUpdating}
|
||||
disabled={
|
||||
!browserState.isClient || isRunning || isBrowserUpdating
|
||||
}
|
||||
>
|
||||
Switch Release
|
||||
</DropdownMenuItem>
|
||||
@@ -481,17 +606,19 @@ export function ProfilesDataTable({
|
||||
setProfileToRename(profile);
|
||||
setNewProfileName(profile.name);
|
||||
}}
|
||||
disabled={!isClient || isRunning || isBrowserUpdating}
|
||||
disabled={
|
||||
!browserState.isClient || isRunning || isBrowserUpdating
|
||||
}
|
||||
>
|
||||
Rename
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setProfileToDelete(profile);
|
||||
setDeleteConfirmationName("");
|
||||
}}
|
||||
className="text-red-600"
|
||||
disabled={!isClient || isRunning || isBrowserUpdating}
|
||||
disabled={
|
||||
!browserState.isClient || isRunning || isBrowserUpdating
|
||||
}
|
||||
>
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
@@ -503,23 +630,29 @@ export function ProfilesDataTable({
|
||||
},
|
||||
],
|
||||
[
|
||||
isClient,
|
||||
showCheckboxes,
|
||||
selectedProfiles,
|
||||
handleToggleAll,
|
||||
handleCheckboxChange,
|
||||
handleIconClick,
|
||||
runningProfiles,
|
||||
isUpdating,
|
||||
data,
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onProxySettings,
|
||||
onChangeVersion,
|
||||
onConfigureCamoufox,
|
||||
getProxyInfo,
|
||||
browserState,
|
||||
hasProxy,
|
||||
getProxyDisplayName,
|
||||
getProxyInfo,
|
||||
onProxySettings,
|
||||
onLaunchProfile,
|
||||
onKillProfile,
|
||||
onConfigureCamoufox,
|
||||
onChangeVersion,
|
||||
onAssignProfilesToGroup,
|
||||
isUpdating,
|
||||
filteredData.length,
|
||||
],
|
||||
);
|
||||
|
||||
const table = useReactTable({
|
||||
data,
|
||||
data: filteredData,
|
||||
columns,
|
||||
state: {
|
||||
sorting,
|
||||
@@ -536,7 +669,7 @@ export function ProfilesDataTable({
|
||||
<ScrollArea
|
||||
className={cn(
|
||||
"rounded-md border",
|
||||
platform === "macos" ? "h-[380px]" : "h-[320px]",
|
||||
platform === "macos" ? "h-[340px]" : "h-[280px]",
|
||||
)}
|
||||
>
|
||||
<Table>
|
||||
@@ -559,11 +692,12 @@ export function ProfilesDataTable({
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows.length ? (
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
className="hover:bg-accent/50"
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell key={cell.id}>
|
||||
@@ -635,73 +769,15 @@ export function ProfilesDataTable({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={profileToDelete !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setProfileToDelete(null);
|
||||
setDeleteConfirmationName("");
|
||||
setDeleteError(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Profile</DialogTitle>
|
||||
<DialogDescription>
|
||||
This action cannot be undone. This will permanently delete the
|
||||
profile "{profileToDelete?.name}" and all its associated
|
||||
data.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="delete-confirmation">
|
||||
Please type <strong>{profileToDelete?.name}</strong> to confirm:
|
||||
</Label>
|
||||
<Input
|
||||
id="delete-confirmation"
|
||||
value={deleteConfirmationName}
|
||||
onChange={(e) => {
|
||||
setDeleteConfirmationName(e.target.value);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
void handleDelete();
|
||||
}
|
||||
}}
|
||||
placeholder="Type the profile name here"
|
||||
/>
|
||||
</div>
|
||||
{deleteError && (
|
||||
<p className="text-sm text-red-600">{deleteError}</p>
|
||||
)}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setProfileToDelete(null);
|
||||
setDeleteConfirmationName("");
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => void handleDelete()}
|
||||
disabled={
|
||||
!deleteConfirmationName.trim() ||
|
||||
deleteConfirmationName !== profileToDelete?.name
|
||||
}
|
||||
>
|
||||
Delete Profile
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DeleteConfirmationDialog
|
||||
isOpen={profileToDelete !== null}
|
||||
onClose={() => setProfileToDelete(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="Delete Profile"
|
||||
description={`This action cannot be undone. This will permanently delete the profile "${profileToDelete?.name}" and all its associated data.`}
|
||||
confirmButtonText="Delete Profile"
|
||||
isLoading={isDeleting}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { useBrowserState } from "@/hooks/use-browser-support";
|
||||
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
|
||||
import type { BrowserProfile, StoredProxy } from "@/types";
|
||||
|
||||
@@ -49,6 +50,9 @@ export function ProfileSelectorDialog({
|
||||
const [isLaunching, setIsLaunching] = useState(false);
|
||||
const [storedProxies, setStoredProxies] = useState<StoredProxy[]>([]);
|
||||
|
||||
// Use shared browser state hook
|
||||
const browserState = useBrowserState(profiles, runningProfiles);
|
||||
|
||||
// Helper function to check if a profile has a proxy
|
||||
const hasProxy = useCallback(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
@@ -59,50 +63,6 @@ export function ProfileSelectorDialog({
|
||||
[storedProxies],
|
||||
);
|
||||
|
||||
// Helper function to determine if a profile can be used for opening links
|
||||
const canUseProfileForLinks = useCallback(
|
||||
(
|
||||
profile: BrowserProfile,
|
||||
allProfiles: BrowserProfile[],
|
||||
runningProfiles: Set<string>,
|
||||
): boolean => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
|
||||
// For TOR browser: Check if any TOR browser is running
|
||||
if (profile.browser === "tor-browser") {
|
||||
const runningTorProfiles = allProfiles.filter(
|
||||
(p) => p.browser === "tor-browser" && runningProfiles.has(p.name),
|
||||
);
|
||||
|
||||
// If no TOR browser is running, allow any TOR profile
|
||||
if (runningTorProfiles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If TOR browser(s) are running, only allow the running one(s)
|
||||
return isRunning;
|
||||
}
|
||||
|
||||
// For Mullvad browser: Check if any Mullvad browser is running
|
||||
if (profile.browser === "mullvad-browser") {
|
||||
const runningMullvadProfiles = allProfiles.filter(
|
||||
(p) => p.browser === "mullvad-browser" && runningProfiles.has(p.name),
|
||||
);
|
||||
|
||||
// If no Mullvad browser is running, allow any Mullvad profile
|
||||
if (runningMullvadProfiles.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If Mullvad browser(s) are running, only allow the running one(s)
|
||||
return isRunning;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const loadProfiles = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
@@ -124,52 +84,31 @@ export function ProfileSelectorDialog({
|
||||
// First, try to find a running profile that can be used for opening links
|
||||
const runningAvailableProfile = profileList.find((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
return (
|
||||
isRunning &&
|
||||
canUseProfileForLinks(profile, profileList, runningProfiles)
|
||||
);
|
||||
return isRunning && browserState.canUseProfileForLinks(profile);
|
||||
});
|
||||
|
||||
if (runningAvailableProfile) {
|
||||
setSelectedProfile(runningAvailableProfile.name);
|
||||
} else {
|
||||
// If no running profile is suitable, find the first profile that can be used for opening links
|
||||
const availableProfile = profileList.find((profile) => {
|
||||
return canUseProfileForLinks(profile, profileList, runningProfiles);
|
||||
});
|
||||
|
||||
// If no running profile is available, find the first available profile
|
||||
const availableProfile = profileList.find((profile) =>
|
||||
browserState.canUseProfileForLinks(profile),
|
||||
);
|
||||
if (availableProfile) {
|
||||
setSelectedProfile(availableProfile.name);
|
||||
} else {
|
||||
// If no suitable profile found, still select the first one to show UI
|
||||
setSelectedProfile(profileList[0].name);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to load profiles:", error);
|
||||
} catch (err) {
|
||||
console.error("Failed to load profiles:", err);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [runningProfiles, canUseProfileForLinks]);
|
||||
}, [runningProfiles, browserState]);
|
||||
|
||||
// Helper function to get tooltip content for profiles
|
||||
const getProfileTooltipContent = (profile: BrowserProfile): string => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
|
||||
if (
|
||||
profile.browser === "tor-browser" ||
|
||||
profile.browser === "mullvad-browser"
|
||||
) {
|
||||
// If another TOR/Mullvad profile is running, this one is not available
|
||||
return "Only 1 instance can run at a time";
|
||||
}
|
||||
|
||||
if (isRunning) {
|
||||
return "URL will open in a new tab in the existing browser window";
|
||||
}
|
||||
|
||||
return "";
|
||||
// Helper function to get tooltip content for profiles - now uses shared hook
|
||||
const getProfileTooltipContent = (profile: BrowserProfile): string | null => {
|
||||
return browserState.getProfileTooltipContent(profile);
|
||||
};
|
||||
|
||||
const handleOpenUrl = useCallback(async () => {
|
||||
@@ -211,16 +150,12 @@ export function ProfileSelectorDialog({
|
||||
// Check if the selected profile can be used for opening links
|
||||
const canOpenWithSelectedProfile = () => {
|
||||
if (!selectedProfileData) return false;
|
||||
return canUseProfileForLinks(
|
||||
selectedProfileData,
|
||||
profiles,
|
||||
runningProfiles,
|
||||
);
|
||||
return browserState.canUseProfileForLinks(selectedProfileData);
|
||||
};
|
||||
|
||||
// Get tooltip content for disabled profiles
|
||||
const getTooltipContent = () => {
|
||||
if (!selectedProfileData) return "";
|
||||
if (!selectedProfileData) return null;
|
||||
return getProfileTooltipContent(selectedProfileData);
|
||||
};
|
||||
|
||||
@@ -285,65 +220,64 @@ export function ProfileSelectorDialog({
|
||||
<SelectContent>
|
||||
{profiles.map((profile) => {
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const canUseForLinks = canUseProfileForLinks(
|
||||
profile,
|
||||
profiles,
|
||||
runningProfiles,
|
||||
);
|
||||
const canUseForLinks =
|
||||
browserState.canUseProfileForLinks(profile);
|
||||
const tooltipContent = getProfileTooltipContent(profile);
|
||||
|
||||
return (
|
||||
<Tooltip key={profile.name}>
|
||||
<TooltipTrigger asChild>
|
||||
<SelectItem
|
||||
value={profile.name}
|
||||
disabled={!canUseForLinks}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
!canUseForLinks ? "opacity-50" : ""
|
||||
}`}
|
||||
<span className="inline-flex">
|
||||
<SelectItem
|
||||
value={profile.name}
|
||||
disabled={!canUseForLinks}
|
||||
>
|
||||
<div className="flex gap-3 items-center px-2 py-1 rounded-lg cursor-pointer hover:bg-accent">
|
||||
<div className="flex gap-2 items-center">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon(
|
||||
profile.browser,
|
||||
);
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-4 h-4" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<div className="font-medium">
|
||||
{profile.name}
|
||||
<div
|
||||
className={`flex items-center gap-2 ${
|
||||
!canUseForLinks ? "opacity-50" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3 items-center px-2 py-1 rounded-lg cursor-pointer hover:bg-accent">
|
||||
<div className="flex gap-2 items-center">
|
||||
{(() => {
|
||||
const IconComponent = getBrowserIcon(
|
||||
profile.browser,
|
||||
);
|
||||
return IconComponent ? (
|
||||
<IconComponent className="w-4 h-4" />
|
||||
) : null;
|
||||
})()}
|
||||
</div>
|
||||
<div className="flex-1 text-right">
|
||||
<div className="font-medium">
|
||||
{profile.name}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getBrowserDisplayName(profile.browser)}
|
||||
</Badge>
|
||||
{hasProxy(profile) && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Proxy
|
||||
</Badge>
|
||||
)}
|
||||
{isRunning && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Running
|
||||
</Badge>
|
||||
)}
|
||||
{!canUseForLinks && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
>
|
||||
Unavailable
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{getBrowserDisplayName(profile.browser)}
|
||||
</Badge>
|
||||
{hasProxy(profile) && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Proxy
|
||||
</Badge>
|
||||
)}
|
||||
{isRunning && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Running
|
||||
</Badge>
|
||||
)}
|
||||
{!canUseForLinks && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="text-xs"
|
||||
>
|
||||
Unavailable
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</SelectItem>
|
||||
</SelectItem>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{tooltipContent && (
|
||||
<TooltipContent>{tooltipContent}</TooltipContent>
|
||||
@@ -363,7 +297,7 @@ export function ProfileSelectorDialog({
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<span className="inline-flex">
|
||||
<LoadingButton
|
||||
isLoading={isLaunching}
|
||||
onClick={() => void handleOpenUrl()}
|
||||
@@ -375,7 +309,7 @@ export function ProfileSelectorDialog({
|
||||
>
|
||||
Open
|
||||
</LoadingButton>
|
||||
</div>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{getTooltipContent() && (
|
||||
<TooltipContent>{getTooltipContent()}</TooltipContent>
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"use client";
|
||||
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group";
|
||||
import * as React from "react";
|
||||
import { LuCircle } from "react-icons/lu";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const RadioGroup = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Root
|
||||
className={cn("grid gap-2", className)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
/>
|
||||
);
|
||||
});
|
||||
RadioGroup.displayName = RadioGroupPrimitive.Root.displayName;
|
||||
|
||||
const RadioGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof RadioGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof RadioGroupPrimitive.Item>
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<RadioGroupPrimitive.Indicator className="flex items-center justify-center">
|
||||
<LuCircle className="h-2.5 w-2.5 fill-current text-current" />
|
||||
</RadioGroupPrimitive.Indicator>
|
||||
</RadioGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
||||
<th
|
||||
data-slot="table-head"
|
||||
className={cn(
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
@@ -82,10 +82,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
||||
return (
|
||||
<td
|
||||
data-slot="table-cell"
|
||||
className={cn(
|
||||
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
|
||||
className,
|
||||
)}
|
||||
className={cn("p-2 align-middle whitespace-nowrap", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { BrowserProfile } from "@/types";
|
||||
|
||||
export function useBrowserSupport() {
|
||||
const [supportedBrowsers, setSupportedBrowsers] = useState<string[]>([]);
|
||||
@@ -51,3 +52,189 @@ export function useBrowserSupport() {
|
||||
checkBrowserSupport,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing browser state and enforcing single-instance rules for Tor and Mullvad browsers
|
||||
*/
|
||||
export function useBrowserState(
|
||||
profiles: BrowserProfile[],
|
||||
runningProfiles: Set<string>,
|
||||
isUpdating?: (browser: string) => boolean,
|
||||
) {
|
||||
const [isClient, setIsClient] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setIsClient(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check if a browser type allows only one instance to run at a time
|
||||
*/
|
||||
const isSingleInstanceBrowser = useCallback(
|
||||
(browserType: string): boolean => {
|
||||
return browserType === "tor-browser" || browserType === "mullvad-browser";
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if any instance of a specific browser type is currently running
|
||||
*/
|
||||
const isAnyInstanceRunning = useCallback(
|
||||
(browserType: string): boolean => {
|
||||
if (!isClient) return false;
|
||||
return profiles.some(
|
||||
(p) => p.browser === browserType && runningProfiles.has(p.name),
|
||||
);
|
||||
},
|
||||
[profiles, runningProfiles, isClient],
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if a profile can be launched (not disabled by single-instance rules)
|
||||
*/
|
||||
const canLaunchProfile = useCallback(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!isClient) return false;
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
|
||||
// If the profile is already running, it can always be stopped
|
||||
if (isRunning) return true;
|
||||
|
||||
// If browser is updating, it cannot be launched
|
||||
if (isBrowserUpdating) return false;
|
||||
|
||||
// For single-instance browsers, check if any instance is running
|
||||
if (isSingleInstanceBrowser(profile.browser)) {
|
||||
return !isAnyInstanceRunning(profile.browser);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
[
|
||||
runningProfiles,
|
||||
isClient,
|
||||
isUpdating,
|
||||
isSingleInstanceBrowser,
|
||||
isAnyInstanceRunning,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Check if a profile can be used for opening links
|
||||
* This is more restrictive than canLaunchProfile as it considers running state
|
||||
*/
|
||||
const canUseProfileForLinks = useCallback(
|
||||
(profile: BrowserProfile): boolean => {
|
||||
if (!isClient) return false;
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
|
||||
// For single-instance browsers (Tor and Mullvad)
|
||||
if (isSingleInstanceBrowser(profile.browser)) {
|
||||
const runningInstancesOfType = profiles.filter(
|
||||
(p) => p.browser === profile.browser && runningProfiles.has(p.name),
|
||||
);
|
||||
|
||||
// If no instances are running, any profile of this type can be used
|
||||
if (runningInstancesOfType.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// If instances are running, only the running ones can be used
|
||||
return isRunning;
|
||||
}
|
||||
|
||||
// For other browsers, any profile can be used
|
||||
return true;
|
||||
},
|
||||
[profiles, runningProfiles, isClient, isSingleInstanceBrowser],
|
||||
);
|
||||
|
||||
/**
|
||||
* Get tooltip content for a profile's launch button
|
||||
*/
|
||||
const getLaunchTooltipContent = useCallback(
|
||||
(profile: BrowserProfile): string => {
|
||||
if (!isClient) return "Loading...";
|
||||
|
||||
const isRunning = runningProfiles.has(profile.name);
|
||||
const isBrowserUpdating = isUpdating?.(profile.browser) ?? false;
|
||||
|
||||
if (isRunning) {
|
||||
return "Click to forcefully stop the browser";
|
||||
}
|
||||
|
||||
if (isBrowserUpdating) {
|
||||
return `${profile.browser} is being updated. Please wait for the update to complete.`;
|
||||
}
|
||||
|
||||
if (
|
||||
isSingleInstanceBrowser(profile.browser) &&
|
||||
!canLaunchProfile(profile)
|
||||
) {
|
||||
const browserDisplayName =
|
||||
profile.browser === "tor-browser" ? "TOR" : "Mullvad";
|
||||
return `Only one ${browserDisplayName} browser instance can run at a time. Stop the running ${browserDisplayName} browser first.`;
|
||||
}
|
||||
|
||||
return "Click to launch the browser";
|
||||
},
|
||||
[
|
||||
runningProfiles,
|
||||
isClient,
|
||||
isUpdating,
|
||||
isSingleInstanceBrowser,
|
||||
canLaunchProfile,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
* Get tooltip content for profile selection (for opening links)
|
||||
*/
|
||||
const getProfileTooltipContent = useCallback(
|
||||
(profile: BrowserProfile): string | null => {
|
||||
if (!isClient) return null;
|
||||
|
||||
const canUseForLinks = canUseProfileForLinks(profile);
|
||||
|
||||
if (canUseForLinks) return null;
|
||||
|
||||
if (isSingleInstanceBrowser(profile.browser)) {
|
||||
const browserDisplayName =
|
||||
profile.browser === "tor-browser" ? "TOR" : "Mullvad";
|
||||
const runningInstancesOfType = profiles.filter(
|
||||
(p) => p.browser === profile.browser && runningProfiles.has(p.name),
|
||||
);
|
||||
|
||||
if (runningInstancesOfType.length > 0) {
|
||||
const runningProfileNames = runningInstancesOfType
|
||||
.map((p) => p.name)
|
||||
.join(", ");
|
||||
return `${browserDisplayName} browser is already running (${runningProfileNames}). Only one instance can run at a time.`;
|
||||
}
|
||||
}
|
||||
|
||||
return "This profile cannot be used for opening links right now.";
|
||||
},
|
||||
[
|
||||
profiles,
|
||||
runningProfiles,
|
||||
isClient,
|
||||
canUseProfileForLinks,
|
||||
isSingleInstanceBrowser,
|
||||
],
|
||||
);
|
||||
|
||||
return {
|
||||
isClient,
|
||||
isSingleInstanceBrowser,
|
||||
isAnyInstanceRunning,
|
||||
canLaunchProfile,
|
||||
canUseProfileForLinks,
|
||||
getLaunchTooltipContent,
|
||||
getProfileTooltipContent,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,7 +100,7 @@ export function showToast(props: ToastProps & { id?: string }) {
|
||||
}
|
||||
|
||||
if (props.type === "success") {
|
||||
sonnerToast.success(React.createElement(UnifiedToast, props), {
|
||||
sonnerToast.custom(() => React.createElement(UnifiedToast, props), {
|
||||
id: toastId,
|
||||
duration,
|
||||
style: {
|
||||
@@ -113,7 +113,7 @@ export function showToast(props: ToastProps & { id?: string }) {
|
||||
},
|
||||
});
|
||||
} else if (props.type === "error") {
|
||||
sonnerToast.error(React.createElement(UnifiedToast, props), {
|
||||
sonnerToast.custom(() => React.createElement(UnifiedToast, props), {
|
||||
id: toastId,
|
||||
duration,
|
||||
style: {
|
||||
|
||||
@@ -21,6 +21,7 @@ export interface BrowserProfile {
|
||||
last_launch?: number;
|
||||
release_type: string; // "stable" or "nightly"
|
||||
camoufox_config?: CamoufoxConfig; // Camoufox configuration
|
||||
group_id?: string; // Reference to profile group
|
||||
}
|
||||
|
||||
export interface StoredProxy {
|
||||
@@ -29,6 +30,17 @@ export interface StoredProxy {
|
||||
proxy_settings: ProxySettings;
|
||||
}
|
||||
|
||||
export interface ProfileGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface GroupWithCount {
|
||||
id: string;
|
||||
name: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface DetectedProfile {
|
||||
browser: string;
|
||||
name: string;
|
||||
|
||||
Reference in New Issue
Block a user