Compare commits

...

27 Commits

Author SHA1 Message Date
zhom adcb20fab9 chore: linting 2025-07-27 03:09:22 +04:00
zhom ff9c633b07 docs: update contribution guidelines 2025-07-27 03:06:35 +04:00
zhom 7ca76b1f78 refactor: warm up nodecar 2025-07-27 02:54:11 +04:00
zhom 4887a3db4d refactor: better unused binary tracking 2025-07-27 02:53:51 +04:00
zhom e38cd2e560 test: remove time requirements for nodecar 2025-07-27 02:32:47 +04:00
zhom 7e7b47cae3 chore: warnings 2025-07-27 02:32:20 +04:00
zhom 13ae170166 test: unused command 2025-07-26 19:03:42 +04:00
zhom df78e22650 refactor: properly cleanup unused binaries and simplify downloaded browser registry 2025-07-26 19:03:42 +04:00
zhom 328e6f16ee feat: ask for confirmation before bulk delete 2025-07-26 19:03:42 +04:00
zhom 40ad32af6d feat: add profile groups 2025-07-26 19:03:41 +04:00
zhom f299eeaea5 Merge pull request #53 from zhom/dependabot/github_actions/github-actions-d4d3ad3fbf
ci(deps): bump the github-actions group with 4 updates
2025-07-26 18:55:54 +04:00
dependabot[bot] 84142caac9 ci(deps): bump the github-actions group with 4 updates
Bumps the github-actions group with 4 updates: [google/osv-scanner-action](https://github.com/google/osv-scanner-action), [actions/first-interaction](https://github.com/actions/first-interaction), [actions/ai-inference](https://github.com/actions/ai-inference) and [actions/setup-python](https://github.com/actions/setup-python).


Updates `google/osv-scanner-action` from 2.0.3 to 2.1.0
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/40a8940a65eab1544a6af759e43d936201a131a2...b00f71e051ddddc6e46a193c31c8c0bf283bf9e6)

Updates `actions/first-interaction` from 1.3.0 to 2.0.0
- [Release notes](https://github.com/actions/first-interaction/releases)
- [Commits](https://github.com/actions/first-interaction/compare/34f15e814fe48ac9312ccf29db4e74fa767cbab7...2d4393e6bc0e2efb2e48fba7e06819c3bf61ffc9)

Updates `actions/ai-inference` from 1.1.0 to 1.2.3
- [Release notes](https://github.com/actions/ai-inference/releases)
- [Commits](https://github.com/actions/ai-inference/compare/d645f067d89ee1d5d736a5990e327e504d1c5a4a...9693b137b6566bb66055a713613bf4f0493701eb)

Updates `actions/setup-python` from 5.3.0 to 5.6.0
- [Release notes](https://github.com/actions/setup-python/releases)
- [Commits](https://github.com/actions/setup-python/compare/0b93645e9fea7318ecaed2b359559ac225c90a2b...a26af69be951a213d495a4c3e4e4022e16d87065)

---
updated-dependencies:
- dependency-name: google/osv-scanner-action
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/first-interaction
  dependency-version: 2.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: github-actions
- dependency-name: actions/ai-inference
  dependency-version: 1.2.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/setup-python
  dependency-version: 5.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-26 09:23:42 +00:00
zhom d06dbb6c70 chore: add banderole to ci 2025-07-25 11:59:17 +04:00
zhom cf5b498bd6 chore: reset pnpm lock 2025-07-25 11:36:24 +04:00
zhom 3c28a169bd test: increase timeout for nodecar initial launch 2025-07-25 11:26:41 +04:00
zhom 25653e166b refactor: share browser state logic across data table and selector dialog 2025-07-25 11:21:26 +04:00
zhom 0b4263140d refactor: switch to banderole from pkg 2025-07-25 11:18:32 +04:00
zhom b500c28b96 fix: allow user download browser if there is only nightly version available 2025-07-25 09:19:31 +04:00
zhom 7c2be81531 refactor: do not allow changing camoufox config if it is running 2025-07-25 09:18:49 +04:00
zhom b55ef469ed refactor: only check for startup urls using js tauri api 2025-07-25 09:18:02 +04:00
zhom 76a206093d chore: rename agents file 2025-07-25 09:04:30 +04:00
zhom 3e88dbc30e refactor: increase delay for ui update after profile deletion 2025-07-25 08:55:36 +04:00
zhom 031823587e refactor: camoufox rust implementation 2025-07-25 08:52:13 +04:00
zhom c7a1ac228c Merge pull request #45 from zhom/dependabot/cargo/src-tauri/rust-dependencies-1dd9e3ae47
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 16 updates
2025-07-12 10:08:23 +00:00
zhom 8ede335bed Merge pull request #44 from zhom/dependabot/npm_and_yarn/frontend-dependencies-dd443c2936
deps(deps): bump the frontend-dependencies group with 30 updates
2025-07-12 10:08:10 +00:00
dependabot[bot] b170b8846d deps(rust)(deps): bump the rust-dependencies group
---
updated-dependencies:
- dependency-name: sysinfo
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zip
  dependency-version: 4.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: hyper-util
  dependency-version: 0.1.15
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: async-channel
  dependency-version: 2.5.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: blocking
  dependency-version: 1.6.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bzip2
  dependency-version: 0.6.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.29
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: embed-resource
  dependency-version: 3.0.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: plist
  dependency-version: 1.7.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: quick-xml
  dependency-version: 0.38.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: rustls
  dependency-version: 0.23.29
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rustls-webpki
  dependency-version: 0.103.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zbus
  dependency-version: 5.8.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zbus_macros
  dependency-version: 5.8.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zvariant
  dependency-version: 5.6.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: zvariant_derive
  dependency-version: 5.6.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-12 10:01:32 +00:00
dependabot[bot] 632d90a022 deps(deps): bump the frontend-dependencies group with 30 updates
Bumps the frontend-dependencies group with 30 updates:

| Package | From | To |
| --- | --- | --- |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.0.6` | `2.1.1` |
| [@biomejs/cli-darwin-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.0.6` | `2.1.1` |
| [@biomejs/cli-darwin-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.0.6` | `2.1.1` |
| [@biomejs/cli-linux-arm64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.0.6` | `2.1.1` |
| [@biomejs/cli-linux-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.0.6` | `2.1.1` |
| [@biomejs/cli-linux-x64-musl](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.0.6` | `2.1.1` |
| [@biomejs/cli-linux-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.0.6` | `2.1.1` |
| [@biomejs/cli-win32-arm64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.0.6` | `2.1.1` |
| [@biomejs/cli-win32-x64](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.0.6` | `2.1.1` |
| [@rollup/rollup-android-arm-eabi](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-android-arm64](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-darwin-arm64](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-darwin-x64](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-freebsd-arm64](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-freebsd-x64](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-linux-arm-gnueabihf](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-linux-arm-musleabihf](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-linux-arm64-gnu](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-linux-arm64-musl](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-linux-loongarch64-gnu](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-linux-powerpc64le-gnu](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-linux-riscv64-gnu](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-linux-riscv64-musl](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-linux-s390x-gnu](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-linux-x64-gnu](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-linux-x64-musl](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-win32-arm64-msvc](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-win32-ia32-msvc](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [@rollup/rollup-win32-x64-msvc](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |
| [rollup](https://github.com/rollup/rollup) | `4.44.2` | `4.45.0` |


Updates `@biomejs/biome` from 2.0.6 to 2.1.1
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.1.1/packages/@biomejs/biome)

Updates `@biomejs/cli-darwin-arm64` from 2.0.6 to 2.1.1
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.1.1/packages/@biomejs/biome)

Updates `@biomejs/cli-darwin-x64` from 2.0.6 to 2.1.1
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.1.1/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-arm64-musl` from 2.0.6 to 2.1.1
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.1.1/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-arm64` from 2.0.6 to 2.1.1
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.1.1/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-x64-musl` from 2.0.6 to 2.1.1
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.1.1/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-x64` from 2.0.6 to 2.1.1
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.1.1/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-arm64` from 2.0.6 to 2.1.1
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.1.1/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-x64` from 2.0.6 to 2.1.1
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.1.1/packages/@biomejs/biome)

Updates `@rollup/rollup-android-arm-eabi` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-android-arm64` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-darwin-arm64` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-darwin-x64` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-freebsd-arm64` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-freebsd-x64` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-linux-arm-gnueabihf` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-linux-arm-musleabihf` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-linux-arm64-gnu` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-linux-arm64-musl` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-linux-loongarch64-gnu` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-linux-powerpc64le-gnu` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-linux-riscv64-gnu` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-linux-riscv64-musl` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-linux-s390x-gnu` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-linux-x64-gnu` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-linux-x64-musl` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-win32-arm64-msvc` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-win32-ia32-msvc` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `@rollup/rollup-win32-x64-msvc` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

Updates `rollup` from 4.44.2 to 4.45.0
- [Release notes](https://github.com/rollup/rollup/releases)
- [Changelog](https://github.com/rollup/rollup/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rollup/rollup/compare/v4.44.2...v4.45.0)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.1.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.1.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.1.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.1.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.1.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.1.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.1.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.1.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.1.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loongarch64-gnu"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-powerpc64le-gnu"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: rollup
  dependency-version: 4.45.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-12 09:34:29 +00:00
50 changed files with 4988 additions and 2987 deletions
+4 -1
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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."
+1 -1
View File
@@ -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: |
+6 -3
View File
@@ -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
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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: |
+15 -4
View File
@@ -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
+14 -3
View File
@@ -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
View File
@@ -46,4 +46,7 @@ yarn-error.log*
# typescript
*.tsbuildinfo
!**/.gitkeep
!**/.gitkeep
# nodecar
nodecar/nodecar-bin
+53
View File
@@ -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"
]
View File
+6 -5
View File
@@ -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.
+2 -2
View File
@@ -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
View File
@@ -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",
+71 -411
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+531 -772
View File
File diff suppressed because it is too large Load Diff
+116 -63
View File
@@ -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",
+5 -2
View File
@@ -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"] }
+7 -3
View File
@@ -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,
}
}
+1 -1
View File
@@ -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
View File
@@ -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 {
-607
View File
@@ -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
+174 -92
View File
@@ -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 = &registry.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"));
}
}
+257
View File
@@ -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
View File
@@ -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");
+1
View File
@@ -690,6 +690,7 @@ impl ProfileImporter {
last_launch: None,
release_type: "stable".to_string(),
camoufox_config: None,
group_id: None,
};
// Save the profile metadata
+6 -112
View File
@@ -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
View File
@@ -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>
);
}
+115
View File
@@ -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>
);
}
+90 -68
View File
@@ -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>
);
}
+209
View File
@@ -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>
);
}
+125
View File
@@ -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>
);
}
+178
View File
@@ -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>
);
}
+51
View File
@@ -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>
);
}
+207
View File
@@ -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}
/>
</>
);
}
+139
View File
@@ -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
+315 -239
View File
@@ -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 &quot;{profileToDelete?.name}&quot; 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}
/>
</>
);
}
+68 -134
View File
@@ -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>
+44
View File
@@ -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 };
+2 -5
View File
@@ -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}
/>
);
+188 -1
View File
@@ -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,
};
}
+2 -2
View File
@@ -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: {
+12
View File
@@ -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;