Compare commits

...
Author SHA1 Message Date
zhom e3d487f846 chore: version bump 2025-12-21 15:17:33 +04:00
zhom b4b7609534 fix: cast handle as mutable 2025-12-21 15:16:59 +04:00
zhom 8bf40fbc62 refactor: add missing windows types 2025-12-21 14:50:22 +04:00
zhom 630cf74ab9 chore: add system io feature for windows 2025-12-21 14:16:28 +04:00
zhom b8d8039c80 chore: name contributors workflow 2025-12-21 14:07:09 +04:00
zhom f1c4245c5a chore: linting 2025-12-21 13:52:06 +04:00
zhom 5cc816ecc5 fix: download correct camoufox version on macos x64 2025-12-21 13:51:58 +04:00
zhom 7409cf7851 test: stabilize tests 2025-12-21 13:29:37 +04:00
zhom d36d5430ca refactor: prevent double-counting 2025-12-21 13:23:33 +04:00
zhom 7518ee9e87 fix: prevent duplicate header 2025-12-21 13:23:33 +04:00
zhom ab8db06dfb refactor: more robust proxy connection 2025-12-21 13:23:33 +04:00
zhom 0b43c6776b refactor: animate tabs 2025-12-21 13:23:33 +04:00
zhom 564c57fefc refactor: prevent double counting 2025-12-21 13:23:33 +04:00
zhom d3cf91c5d3 refactor: verify dead process on force kill 2025-12-21 13:23:33 +04:00
zhom 729307be7b refactor: reduce rerenders 2025-12-21 13:23:33 +04:00
zhom c736eb9195 refactor: animate dialog 2025-12-21 13:23:33 +04:00
zhom 68d0741f38 refactor: properly clear lock 2025-12-21 13:23:33 +04:00
zhom ae59ba802e fix: properly kill camoufox on Stop 2025-12-21 13:23:33 +04:00
zhom 73de070478 refactor: dump client bandwidth data when not visible 2025-12-21 13:23:33 +04:00
zhom 187d3414d8 refactor: add snapshots instead of taking taking the max 2025-12-21 13:23:33 +04:00
zhom cc74589243 refactor: reduce disk usage for proxy data sharing 2025-12-21 13:23:33 +04:00
zhom 55974d17be chore: codegen 2025-12-21 13:23:33 +04:00
zhom cbd0312618 chore: update dependencies 2025-12-21 13:23:30 +04:00
zhomandGitHub 41205ab31d Merge pull request #162 from zhom/dependabot/cargo/src-tauri/rust-dependencies-fe1cc8a477
deps(rust)(deps): bump the rust-dependencies group across 1 directory with 26 updates
2025-12-21 11:13:25 +02:00
zhomandGitHub bfec778d19 Merge pull request #161 from zhom/dependabot/npm_and_yarn/frontend-dependencies-2aeffeccfd
deps(deps): bump the frontend-dependencies group across 1 directory with 86 updates
2025-12-21 11:13:11 +02:00
zhomandGitHub 0cb738c5ae Merge pull request #155 from zhom/dependabot/github_actions/github-actions-c3eae18ee6
ci(deps): bump the github-actions group with 2 updates
2025-12-21 11:12:55 +02:00
zhomandGitHub a82a73b3f4 Merge pull request #160 from JorySeverijnse/universal-chromium-download
Fixed not being able to download chromium anymore
2025-12-21 11:12:26 +02:00
dependabot[bot]andGitHub 49eca7271f deps(rust)(deps): bump the rust-dependencies group across 1 directory with 26 updates
Bumps the rust-dependencies group with 20 updates in the /src-tauri directory:

| Package | From | To |
| --- | --- | --- |
| [tauri](https://github.com/tauri-apps/tauri) | `2.9.4` | `2.9.5` |
| [reqwest](https://github.com/seanmonstar/reqwest) | `0.12.24` | `0.12.26` |
| [zip](https://github.com/zip-rs/zip2) | `6.0.0` | `7.0.0` |
| [base64ct](https://github.com/RustCrypto/formats) | `1.8.0` | `1.8.1` |
| [bumpalo](https://github.com/fitzgen/bumpalo) | `3.19.0` | `3.19.1` |
| [camino](https://github.com/camino-rs/camino) | `1.2.1` | `1.2.2` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.48` | `1.2.50` |
| [dlopen2](https://github.com/OpenByteDev/dlopen2) | `0.8.1` | `0.8.2` |
| [dlopen2_derive](https://github.com/OpenByteDev/dlopen2) | `0.4.2` | `0.4.3` |
| [icu_properties](https://github.com/unicode-org/icu4x) | `2.1.1` | `2.1.2` |
| libredox | `0.1.10` | `0.1.11` |
| [ntapi](https://github.com/MSxDOS/ntapi) | `0.4.1` | `0.4.2` |
| [portable-atomic](https://github.com/taiki-e/portable-atomic) | `1.11.1` | `1.12.0` |
| [rustls-pki-types](https://github.com/rustls/pki-types) | `1.13.1` | `1.13.2` |
| [simd-adler32](https://github.com/mcountryman/simd-adler32) | `0.3.7` | `0.3.8` |
| [softbuffer](https://github.com/rust-windowing/softbuffer) | `0.4.6` | `0.4.8` |
| [toml_parser](https://github.com/toml-rs/toml) | `1.0.4` | `1.0.6+spec-1.1.0` |
| [toml_writer](https://github.com/toml-rs/toml) | `1.0.4` | `1.0.6+spec-1.1.0` |
| [tracing](https://github.com/tokio-rs/tracing) | `0.1.43` | `0.1.44` |
| [zlib-rs](https://github.com/trifectatechfoundation/zlib-rs) | `0.5.3` | `0.5.4` |



Updates `tauri` from 2.9.4 to 2.9.5
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.4...tauri-v2.9.5)

Updates `reqwest` from 0.12.24 to 0.12.26
- [Release notes](https://github.com/seanmonstar/reqwest/releases)
- [Changelog](https://github.com/seanmonstar/reqwest/blob/master/CHANGELOG.md)
- [Commits](https://github.com/seanmonstar/reqwest/compare/v0.12.24...v0.12.26)

Updates `zip` from 6.0.0 to 7.0.0
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v6.0.0...v7.0.0)

Updates `tower-http` from 0.6.7 to 0.6.8
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.7...tower-http-0.6.8)

Updates `base64ct` from 1.8.0 to 1.8.1
- [Commits](https://github.com/RustCrypto/formats/compare/base64ct/v1.8.0...base64ct/v1.8.1)

Updates `bumpalo` from 3.19.0 to 3.19.1
- [Changelog](https://github.com/fitzgen/bumpalo/blob/main/CHANGELOG.md)
- [Commits](https://github.com/fitzgen/bumpalo/compare/v3.19.0...v3.19.1)

Updates `camino` from 1.2.1 to 1.2.2
- [Release notes](https://github.com/camino-rs/camino/releases)
- [Changelog](https://github.com/camino-rs/camino/blob/main/CHANGELOG.md)
- [Commits](https://github.com/camino-rs/camino/compare/camino-1.2.1...camino-1.2.2)

Updates `cc` from 1.2.48 to 1.2.50
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.48...cc-v1.2.50)

Updates `crc` from 3.4.0 to 3.3.0
- [Commits](https://github.com/mrhooray/crc-rs/compare/3.4.0...3.3.0)

Updates `dlopen2` from 0.8.1 to 0.8.2
- [Commits](https://github.com/OpenByteDev/dlopen2/commits)

Updates `dlopen2_derive` from 0.4.2 to 0.4.3
- [Commits](https://github.com/OpenByteDev/dlopen2/commits)

Updates `icu_properties` from 2.1.1 to 2.1.2
- [Release notes](https://github.com/unicode-org/icu4x/releases)
- [Changelog](https://github.com/unicode-org/icu4x/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unicode-org/icu4x/commits)

Updates `icu_properties_data` from 2.1.1 to 2.1.2
- [Release notes](https://github.com/unicode-org/icu4x/releases)
- [Changelog](https://github.com/unicode-org/icu4x/blob/main/CHANGELOG.md)
- [Commits](https://github.com/unicode-org/icu4x/commits)

Updates `libredox` from 0.1.10 to 0.1.11

Updates `lzma-rust2` from 0.13.0 to 0.15.4
- [Changelog](https://github.com/hasenbanck/lzma-rust2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hasenbanck/lzma-rust2/commits/v0.15.4)

Updates `ntapi` from 0.4.1 to 0.4.2
- [Commits](https://github.com/MSxDOS/ntapi/commits)

Updates `portable-atomic` from 1.11.1 to 1.12.0
- [Release notes](https://github.com/taiki-e/portable-atomic/releases)
- [Changelog](https://github.com/taiki-e/portable-atomic/blob/main/CHANGELOG.md)
- [Commits](https://github.com/taiki-e/portable-atomic/compare/v1.11.1...v1.12.0)

Updates `rustls-pki-types` from 1.13.1 to 1.13.2
- [Release notes](https://github.com/rustls/pki-types/releases)
- [Commits](https://github.com/rustls/pki-types/compare/v/1.13.1...v/1.13.2)

Updates `simd-adler32` from 0.3.7 to 0.3.8
- [Changelog](https://github.com/mcountryman/simd-adler32/blob/main/CHANGELOG.md)
- [Commits](https://github.com/mcountryman/simd-adler32/commits)

Updates `softbuffer` from 0.4.6 to 0.4.8
- [Release notes](https://github.com/rust-windowing/softbuffer/releases)
- [Changelog](https://github.com/rust-windowing/softbuffer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-windowing/softbuffer/compare/v0.4.6...v0.4.8)

Updates `tauri-runtime-wry` from 2.9.2 to 2.9.3
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-runtime-wry-v2.9.2...tauri-runtime-wry-v2.9.3)

Updates `toml_parser` from 1.0.4 to 1.0.6+spec-1.1.0
- [Commits](https://github.com/toml-rs/toml/compare/toml_parser-v1.0.4...toml_parser-v1.0.6)

Updates `toml_writer` from 1.0.4 to 1.0.6+spec-1.1.0
- [Commits](https://github.com/toml-rs/toml/compare/toml_writer-v1.0.4...toml_writer-v1.0.6)

Updates `tracing` from 0.1.43 to 0.1.44
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-0.1.43...tracing-0.1.44)

Updates `tracing-core` from 0.1.35 to 0.1.36
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-core-0.1.35...tracing-core-0.1.36)

Updates `zlib-rs` from 0.5.3 to 0.5.4
- [Release notes](https://github.com/trifectatechfoundation/zlib-rs/releases)
- [Changelog](https://github.com/trifectatechfoundation/zlib-rs/blob/main/docs/release.md)
- [Commits](https://github.com/trifectatechfoundation/zlib-rs/compare/v0.5.3...v0.5.4)

---
updated-dependencies:
- dependency-name: tauri
  dependency-version: 2.9.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: reqwest
  dependency-version: 0.12.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zip
  dependency-version: 7.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: tower-http
  dependency-version: 0.6.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: base64ct
  dependency-version: 1.8.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bumpalo
  dependency-version: 3.19.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: camino
  dependency-version: 1.2.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.50
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: crc
  dependency-version: 3.3.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: dlopen2
  dependency-version: 0.8.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: dlopen2_derive
  dependency-version: 0.4.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: icu_properties
  dependency-version: 2.1.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: icu_properties_data
  dependency-version: 2.1.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libredox
  dependency-version: 0.1.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: lzma-rust2
  dependency-version: 0.15.4
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: ntapi
  dependency-version: 0.4.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: portable-atomic
  dependency-version: 1.12.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: rustls-pki-types
  dependency-version: 1.13.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: simd-adler32
  dependency-version: 0.3.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: softbuffer
  dependency-version: 0.4.8
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime-wry
  dependency-version: 2.9.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: toml_parser
  dependency-version: 1.0.6+spec-1.1.0
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: toml_writer
  dependency-version: 1.0.6+spec-1.1.0
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tracing
  dependency-version: 0.1.44
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tracing-core
  dependency-version: 0.1.36
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zlib-rs
  dependency-version: 0.5.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-20 09:45:40 +00:00
dependabot[bot]andGitHub 487c72cbb7 deps(deps): bump the frontend-dependencies group across 1 directory with 86 updates
Bumps the frontend-dependencies group with 13 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.556.0` | `0.562.0` |
| [motion](https://github.com/motiondivision/motion) | `12.23.25` | `12.23.26` |
| [next](https://github.com/vercel/next.js) | `16.0.7` | `16.1.0` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.1` | `19.2.3` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.1` | `19.2.3` |
| [recharts](https://github.com/recharts/recharts) | `3.5.1` | `3.6.0` |
| [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) | `2.3.8` | `2.3.10` |
| [@tailwindcss/postcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/@tailwindcss-postcss) | `4.1.17` | `4.1.18` |
| [@tauri-apps/cli](https://github.com/tauri-apps/tauri) | `2.9.5` | `2.9.6` |
| [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) | `24.10.1` | `25.0.3` |
| [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/tree/HEAD/packages/plugin-react) | `5.1.1` | `5.1.2` |
| [tailwindcss](https://github.com/tailwindlabs/tailwindcss/tree/HEAD/packages/tailwindcss) | `4.1.17` | `4.1.18` |
| [proxy-chain](https://github.com/apify/proxy-chain) | `2.6.0` | `2.7.0` |



Updates `lucide-react` from 0.556.0 to 0.562.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.562.0/packages/lucide-react)

Updates `motion` from 12.23.25 to 12.23.26
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.23.25...v12.23.26)

Updates `next` from 16.0.7 to 16.1.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.7...v16.1.0)

Updates `react` from 19.2.1 to 19.2.3
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.3/packages/react)

Updates `react-dom` from 19.2.1 to 19.2.3
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.3/packages/react-dom)

Updates `recharts` from 3.5.1 to 3.6.0
- [Release notes](https://github.com/recharts/recharts/releases)
- [Changelog](https://github.com/recharts/recharts/blob/main/CHANGELOG.md)
- [Commits](https://github.com/recharts/recharts/compare/v3.5.1...v3.6.0)

Updates `@biomejs/biome` from 2.3.8 to 2.3.10
- [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.3.10/packages/@biomejs/biome)

Updates `@tailwindcss/postcss` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/packages/@tailwindcss-postcss)

Updates `@tauri-apps/cli` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/@tauri-apps/cli-v2.9.5...@tauri-apps/cli-v2.9.6)

Updates `@types/node` from 24.10.1 to 25.0.3
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

Updates `@vitejs/plugin-react` from 5.1.1 to 5.1.2
- [Release notes](https://github.com/vitejs/vite-plugin-react/releases)
- [Changelog](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/CHANGELOG.md)
- [Commits](https://github.com/vitejs/vite-plugin-react/commits/plugin-react@5.1.2/packages/plugin-react)

Updates `tailwindcss` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/packages/tailwindcss)

Updates `proxy-chain` from 2.6.0 to 2.7.0
- [Release notes](https://github.com/apify/proxy-chain/releases)
- [Changelog](https://github.com/apify/proxy-chain/blob/master/CHANGELOG.md)
- [Commits](https://github.com/apify/proxy-chain/compare/v2.6.0...v2.7.0)

Updates `@biomejs/cli-darwin-arm64` from 2.3.8 to 2.3.10
- [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.3.10/packages/@biomejs/biome)

Updates `@biomejs/cli-darwin-x64` from 2.3.8 to 2.3.10
- [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.3.10/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-arm64-musl` from 2.3.8 to 2.3.10
- [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.3.10/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-arm64` from 2.3.8 to 2.3.10
- [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.3.10/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-x64-musl` from 2.3.8 to 2.3.10
- [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.3.10/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-x64` from 2.3.8 to 2.3.10
- [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.3.10/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-arm64` from 2.3.8 to 2.3.10
- [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.3.10/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-x64` from 2.3.8 to 2.3.10
- [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.3.10/packages/@biomejs/biome)

Updates `@next/env` from 16.0.7 to 16.1.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.0/packages/next-env)

Updates `@next/swc-darwin-arm64` from 16.0.7 to 16.1.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.0/crates/napi/npm/darwin-arm64)

Updates `@next/swc-darwin-x64` from 16.0.7 to 16.1.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.0/crates/napi/npm/darwin-x64)

Updates `@next/swc-linux-arm64-gnu` from 16.0.7 to 16.1.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.0/crates/napi/npm/linux-arm64-gnu)

Updates `@next/swc-linux-arm64-musl` from 16.0.7 to 16.1.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.0/crates/napi/npm/linux-arm64-musl)

Updates `@next/swc-linux-x64-gnu` from 16.0.7 to 16.1.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.0/crates/napi/npm/linux-x64-gnu)

Updates `@next/swc-linux-x64-musl` from 16.0.7 to 16.1.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.0/crates/napi/npm/linux-x64-musl)

Updates `@next/swc-win32-arm64-msvc` from 16.0.7 to 16.1.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.0/crates/napi/npm/win32-arm64-msvc)

Updates `@next/swc-win32-x64-msvc` from 16.0.7 to 16.1.0
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/commits/v16.1.0/crates/napi/npm/win32-x64-msvc)

Updates `@reduxjs/toolkit` from 2.11.0 to 2.11.2
- [Release notes](https://github.com/reduxjs/redux-toolkit/releases)
- [Commits](https://github.com/reduxjs/redux-toolkit/compare/v2.11.0...v2.11.2)

Updates `@rolldown/pluginutils` from 1.0.0-beta.47 to 1.0.0-beta.53
- [Release notes](https://github.com/rolldown/rolldown/releases)
- [Changelog](https://github.com/rolldown/rolldown/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rolldown/rolldown/commits/v1.0.0-beta.53/packages/pluginutils)

Updates `@rollup/rollup-android-arm-eabi` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-android-arm64` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-darwin-arm64` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-darwin-x64` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-freebsd-arm64` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-freebsd-x64` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-linux-arm-gnueabihf` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-linux-arm-musleabihf` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-linux-arm64-gnu` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-linux-arm64-musl` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-linux-loong64-gnu` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-linux-ppc64-gnu` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-linux-riscv64-gnu` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-linux-riscv64-musl` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-linux-s390x-gnu` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-linux-x64-gnu` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-linux-x64-musl` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-openharmony-arm64` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-win32-arm64-msvc` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-win32-ia32-msvc` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-win32-x64-gnu` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@rollup/rollup-win32-x64-msvc` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

Updates `@standard-schema/spec` from 1.0.0 to 1.1.0
- [Release notes](https://github.com/standard-schema/standard-schema/releases)
- [Commits](https://github.com/standard-schema/standard-schema/compare/v1.0.0...v1.1.0)

Updates `@tailwindcss/node` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/packages/@tailwindcss-node)

Updates `@tailwindcss/oxide-android-arm64` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node/npm/android-arm64)

Updates `@tailwindcss/oxide-darwin-arm64` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node/npm/darwin-arm64)

Updates `@tailwindcss/oxide-darwin-x64` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node/npm/darwin-x64)

Updates `@tailwindcss/oxide-freebsd-x64` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node/npm/freebsd-x64)

Updates `@tailwindcss/oxide-linux-arm-gnueabihf` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node/npm/linux-arm-gnueabihf)

Updates `@tailwindcss/oxide-linux-arm64-gnu` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node/npm/linux-arm64-gnu)

Updates `@tailwindcss/oxide-linux-arm64-musl` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node/npm/linux-arm64-musl)

Updates `@tailwindcss/oxide-linux-x64-gnu` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node/npm/linux-x64-gnu)

Updates `@tailwindcss/oxide-linux-x64-musl` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node/npm/linux-x64-musl)

Updates `@tailwindcss/oxide-wasm32-wasi` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node)

Updates `@tailwindcss/oxide-win32-arm64-msvc` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node/npm/win32-arm64-msvc)

Updates `@tailwindcss/oxide-win32-x64-msvc` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node/npm/win32-x64-msvc)

Updates `@tailwindcss/oxide` from 4.1.17 to 4.1.18
- [Release notes](https://github.com/tailwindlabs/tailwindcss/releases)
- [Changelog](https://github.com/tailwindlabs/tailwindcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tailwindlabs/tailwindcss/commits/v4.1.18/crates/node)

Updates `@tauri-apps/cli-darwin-arm64` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.5...tauri-cli-v2.9.6)

Updates `@tauri-apps/cli-darwin-x64` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.5...tauri-cli-v2.9.6)

Updates `@tauri-apps/cli-linux-arm-gnueabihf` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.5...tauri-cli-v2.9.6)

Updates `@tauri-apps/cli-linux-arm64-gnu` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.5...tauri-cli-v2.9.6)

Updates `@tauri-apps/cli-linux-arm64-musl` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.5...tauri-cli-v2.9.6)

Updates `@tauri-apps/cli-linux-riscv64-gnu` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.5...tauri-cli-v2.9.6)

Updates `@tauri-apps/cli-linux-x64-gnu` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.5...tauri-cli-v2.9.6)

Updates `@tauri-apps/cli-linux-x64-musl` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.5...tauri-cli-v2.9.6)

Updates `@tauri-apps/cli-win32-arm64-msvc` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.5...tauri-cli-v2.9.6)

Updates `@tauri-apps/cli-win32-ia32-msvc` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.5...tauri-cli-v2.9.6)

Updates `@tauri-apps/cli-win32-x64-msvc` from 2.9.5 to 2.9.6
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.5...tauri-cli-v2.9.6)

Updates `baseline-browser-mapping` from 2.9.3 to 2.9.11
- [Release notes](https://github.com/web-platform-dx/baseline-browser-mapping/releases)
- [Commits](https://github.com/web-platform-dx/baseline-browser-mapping/compare/v2.9.3...v2.9.11)

Updates `caniuse-lite` from 1.0.30001759 to 1.0.30001761
- [Commits](https://github.com/browserslist/caniuse-lite/compare/1.0.30001759...1.0.30001761)

Updates `enhanced-resolve` from 5.18.3 to 5.18.4
- [Release notes](https://github.com/webpack/enhanced-resolve/releases)
- [Commits](https://github.com/webpack/enhanced-resolve/compare/v5.18.3...v5.18.4)

Updates `es-toolkit` from 1.42.0 to 1.43.0
- [Release notes](https://github.com/toss/es-toolkit/releases)
- [Changelog](https://github.com/toss/es-toolkit/blob/main/CHANGELOG.md)
- [Commits](https://github.com/toss/es-toolkit/compare/v1.42.0...v1.43.0)

Updates `framer-motion` from 12.23.25 to 12.23.26
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.23.25...v12.23.26)

Updates `rollup` from 4.53.3 to 4.53.5
- [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.53.3...v4.53.5)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.562.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: motion
  dependency-version: 12.23.26
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: next
  dependency-version: 16.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: react
  dependency-version: 19.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: react-dom
  dependency-version: 19.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: recharts
  dependency-version: 3.6.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/biome"
  dependency-version: 2.3.10
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/postcss"
  dependency-version: 4.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli"
  dependency-version: 2.9.6
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@types/node"
  dependency-version: 25.0.3
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: frontend-dependencies
- dependency-name: "@vitejs/plugin-react"
  dependency-version: 5.1.2
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: tailwindcss
  dependency-version: 4.1.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: proxy-chain
  dependency-version: 2.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.3.10
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@next/env"
  dependency-version: 16.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-arm64"
  dependency-version: 16.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-darwin-x64"
  dependency-version: 16.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-gnu"
  dependency-version: 16.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-arm64-musl"
  dependency-version: 16.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-gnu"
  dependency-version: 16.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-linux-x64-musl"
  dependency-version: 16.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-arm64-msvc"
  dependency-version: 16.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@next/swc-win32-x64-msvc"
  dependency-version: 16.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@reduxjs/toolkit"
  dependency-version: 2.11.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rolldown/pluginutils"
  dependency-version: 1.0.0-beta.53
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm-eabi"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-android-arm64"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-arm64"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-darwin-x64"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-arm64"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-freebsd-x64"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-gnueabihf"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm-musleabihf"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-gnu"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-arm64-musl"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-loong64-gnu"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-ppc64-gnu"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-gnu"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-riscv64-musl"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-s390x-gnu"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-gnu"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-linux-x64-musl"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-openharmony-arm64"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-arm64-msvc"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-ia32-msvc"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-gnu"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@rollup/rollup-win32-x64-msvc"
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@standard-schema/spec"
  dependency-version: 1.1.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/node"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-android-arm64"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-darwin-arm64"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-darwin-x64"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-freebsd-x64"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-arm-gnueabihf"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-arm64-gnu"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-arm64-musl"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-x64-gnu"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-linux-x64-musl"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-wasm32-wasi"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-win32-arm64-msvc"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide-win32-x64-msvc"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tailwindcss/oxide"
  dependency-version: 4.1.18
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-darwin-arm64"
  dependency-version: 2.9.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-darwin-x64"
  dependency-version: 2.9.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm-gnueabihf"
  dependency-version: 2.9.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm64-gnu"
  dependency-version: 2.9.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-arm64-musl"
  dependency-version: 2.9.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-riscv64-gnu"
  dependency-version: 2.9.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-x64-gnu"
  dependency-version: 2.9.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-linux-x64-musl"
  dependency-version: 2.9.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-arm64-msvc"
  dependency-version: 2.9.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-ia32-msvc"
  dependency-version: 2.9.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: "@tauri-apps/cli-win32-x64-msvc"
  dependency-version: 2.9.6
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: baseline-browser-mapping
  dependency-version: 2.9.11
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: caniuse-lite
  dependency-version: 1.0.30001761
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: enhanced-resolve
  dependency-version: 5.18.4
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: es-toolkit
  dependency-version: 1.43.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: framer-motion
  dependency-version: 12.23.26
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: rollup
  dependency-version: 4.53.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-20 09:30:01 +00:00
JorySeverijnse aec4a0c3af Fixed not being able to download chromium anymore
Made the code a bit more universal so it also downloads the correct packages based on ones specific platform
2025-12-19 18:25:12 +01:00
dependabot[bot]andGitHub c37675bce2 ci(deps): bump the github-actions group with 2 updates
Bumps the github-actions group with 2 updates: [google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml](https://github.com/google/osv-scanner-action) and [google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml](https://github.com/google/osv-scanner-action).


Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml` from 2.3.0 to 2.3.1
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/b77c075a1235514558f0eb88dbd31e22c45e0cd2...375a0e8ebdc98e99b02ac4338a724f5750f21213)

Updates `google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml` from 2.3.0 to 2.3.1
- [Release notes](https://github.com/google/osv-scanner-action/releases)
- [Commits](https://github.com/google/osv-scanner-action/compare/b77c075a1235514558f0eb88dbd31e22c45e0cd2...375a0e8ebdc98e99b02ac4338a724f5750f21213)

---
updated-dependencies:
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml
  dependency-version: 2.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml
  dependency-version: 2.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-13 09:06:47 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ccdc411e7f deps(rust)(deps): bump the rust-dependencies group (#152)
Bumps the rust-dependencies group in /src-tauri with 19 updates:

| Package | From | To |
| --- | --- | --- |
| [tauri](https://github.com/tauri-apps/tauri) | `2.9.3` | `2.9.4` |
| [log](https://github.com/rust-lang/log) | `0.4.28` | `0.4.29` |
| [libc](https://github.com/rust-lang/libc) | `0.2.177` | `0.2.178` |
| [flate2](https://github.com/rust-lang/flate2-rs) | `1.1.5` | `1.1.7` |
| [uuid](https://github.com/uuid-rs/uuid) | `1.18.1` | `1.19.0` |
| [hyper-util](https://github.com/hyperium/hyper-util) | `0.1.18` | `0.1.19` |
| [tauri-build](https://github.com/tauri-apps/tauri) | `2.5.2` | `2.5.3` |
| [dlopen2](https://github.com/OpenByteDev/dlopen2) | `0.8.0` | `0.8.1` |
| [dlopen2_derive](https://github.com/OpenByteDev/dlopen2) | `0.4.1` | `0.4.2` |
| [mio](https://github.com/tokio-rs/mio) | `1.1.0` | `1.1.1` |
| [tauri-codegen](https://github.com/tauri-apps/tauri) | `2.5.1` | `2.5.2` |
| [tauri-macros](https://github.com/tauri-apps/tauri) | `2.5.1` | `2.5.2` |
| [tauri-plugin](https://github.com/tauri-apps/tauri) | `2.5.1` | `2.5.2` |
| [tauri-runtime](https://github.com/tauri-apps/tauri) | `2.9.1` | `2.9.2` |
| [tauri-runtime-wry](https://github.com/tauri-apps/tauri) | `2.9.1` | `2.9.2` |
| [tauri-utils](https://github.com/tauri-apps/tauri) | `2.8.0` | `2.8.1` |
| [zerocopy](https://github.com/google/zerocopy) | `0.8.30` | `0.8.31` |
| [zerocopy-derive](https://github.com/google/zerocopy) | `0.8.30` | `0.8.31` |
| [zlib-rs](https://github.com/trifectatechfoundation/zlib-rs) | `0.5.2` | `0.5.3` |


Updates `tauri` from 2.9.3 to 2.9.4
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.3...tauri-v2.9.4)

Updates `log` from 0.4.28 to 0.4.29
- [Release notes](https://github.com/rust-lang/log/releases)
- [Changelog](https://github.com/rust-lang/log/blob/master/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/log/compare/0.4.28...0.4.29)

Updates `libc` from 0.2.177 to 0.2.178
- [Release notes](https://github.com/rust-lang/libc/releases)
- [Changelog](https://github.com/rust-lang/libc/blob/0.2.178/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/libc/compare/0.2.177...0.2.178)

Updates `flate2` from 1.1.5 to 1.1.7
- [Release notes](https://github.com/rust-lang/flate2-rs/releases)
- [Commits](https://github.com/rust-lang/flate2-rs/compare/1.1.5...1.1.7)

Updates `uuid` from 1.18.1 to 1.19.0
- [Release notes](https://github.com/uuid-rs/uuid/releases)
- [Commits](https://github.com/uuid-rs/uuid/compare/v1.18.1...v1.19.0)

Updates `hyper-util` from 0.1.18 to 0.1.19
- [Release notes](https://github.com/hyperium/hyper-util/releases)
- [Changelog](https://github.com/hyperium/hyper-util/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper-util/compare/v0.1.18...v0.1.19)

Updates `tauri-build` from 2.5.2 to 2.5.3
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-build-v2.5.2...tauri-build-v2.5.3)

Updates `dlopen2` from 0.8.0 to 0.8.1
- [Commits](https://github.com/OpenByteDev/dlopen2/commits)

Updates `dlopen2_derive` from 0.4.1 to 0.4.2
- [Commits](https://github.com/OpenByteDev/dlopen2/commits)

Updates `mio` from 1.1.0 to 1.1.1
- [Release notes](https://github.com/tokio-rs/mio/releases)
- [Changelog](https://github.com/tokio-rs/mio/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/mio/compare/v1.1.0...v1.1.1)

Updates `tauri-codegen` from 2.5.1 to 2.5.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-codegen-v2.5.1...tauri-codegen-v2.5.2)

Updates `tauri-macros` from 2.5.1 to 2.5.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-macros-v2.5.1...tauri-macros-v2.5.2)

Updates `tauri-plugin` from 2.5.1 to 2.5.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-plugin-v2.5.1...tauri-plugin-v2.5.2)

Updates `tauri-runtime` from 2.9.1 to 2.9.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-runtime-v2.9.1...tauri-runtime-v2.9.2)

Updates `tauri-runtime-wry` from 2.9.1 to 2.9.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-runtime-wry-v2.9.1...tauri-runtime-wry-v2.9.2)

Updates `tauri-utils` from 2.8.0 to 2.8.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-utils-v2.8.0...tauri-utils-v2.8.1)

Updates `zerocopy` from 0.8.30 to 0.8.31
- [Release notes](https://github.com/google/zerocopy/releases)
- [Changelog](https://github.com/google/zerocopy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/zerocopy/compare/v0.8.30...v0.8.31)

Updates `zerocopy-derive` from 0.8.30 to 0.8.31
- [Release notes](https://github.com/google/zerocopy/releases)
- [Changelog](https://github.com/google/zerocopy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/zerocopy/compare/v0.8.30...v0.8.31)

Updates `zlib-rs` from 0.5.2 to 0.5.3
- [Release notes](https://github.com/trifectatechfoundation/zlib-rs/releases)
- [Changelog](https://github.com/trifectatechfoundation/zlib-rs/blob/main/docs/release.md)
- [Commits](https://github.com/trifectatechfoundation/zlib-rs/compare/v0.5.2...v0.5.3)

---
updated-dependencies:
- dependency-name: tauri
  dependency-version: 2.9.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: log
  dependency-version: 0.4.29
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: libc
  dependency-version: 0.2.178
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: flate2
  dependency-version: 1.1.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: uuid
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: hyper-util
  dependency-version: 0.1.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-build
  dependency-version: 2.5.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: dlopen2
  dependency-version: 0.8.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: dlopen2_derive
  dependency-version: 0.4.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: mio
  dependency-version: 1.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-codegen
  dependency-version: 2.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-macros
  dependency-version: 2.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-plugin
  dependency-version: 2.5.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime
  dependency-version: 2.9.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-runtime-wry
  dependency-version: 2.9.2
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-utils
  dependency-version: 2.8.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zerocopy
  dependency-version: 0.8.31
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zerocopy-derive
  dependency-version: 0.8.31
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zlib-rs
  dependency-version: 0.5.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-06 09:54:33 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
bec3fa142c deps(deps): bump the frontend-dependencies group with 12 updates (#151)
Bumps the frontend-dependencies group with 12 updates:

| Package | From | To |
| --- | --- | --- |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `0.555.0` | `0.556.0` |
| [motion](https://github.com/motiondivision/motion) | `12.23.24` | `12.23.25` |
| [react](https://github.com/facebook/react/tree/HEAD/packages/react) | `19.2.0` | `19.2.1` |
| [react-dom](https://github.com/facebook/react/tree/HEAD/packages/react-dom) | `19.2.0` | `19.2.1` |
| [fingerprint-generator](https://github.com/apify/fingerprint-suite) | `2.1.77` | `2.1.78` |
| [baseline-browser-mapping](https://github.com/web-platform-dx/baseline-browser-mapping) | `2.8.26` | `2.9.3` |
| [browserslist](https://github.com/browserslist/browserslist) | `4.28.0` | `4.28.1` |
| [electron-to-chromium](https://github.com/kilian/electron-to-chromium) | `1.5.250` | `1.5.266` |
| [framer-motion](https://github.com/motiondivision/motion) | `12.23.24` | `12.23.25` |
| [generative-bayesian-network](https://github.com/apify/fingerprint-suite) | `2.1.77` | `2.1.78` |
| [header-generator](https://github.com/apify/fingerprint-suite) | `2.1.77` | `2.1.78` |
| [update-browserslist-db](https://github.com/browserslist/update-db) | `1.1.4` | `1.2.2` |


Updates `lucide-react` from 0.555.0 to 0.556.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/0.556.0/packages/lucide-react)

Updates `motion` from 12.23.24 to 12.23.25
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.23.24...v12.23.25)

Updates `react` from 19.2.0 to 19.2.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.1/packages/react)

Updates `react-dom` from 19.2.0 to 19.2.1
- [Release notes](https://github.com/facebook/react/releases)
- [Changelog](https://github.com/facebook/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/facebook/react/commits/v19.2.1/packages/react-dom)

Updates `fingerprint-generator` from 2.1.77 to 2.1.78
- [Release notes](https://github.com/apify/fingerprint-suite/releases)
- [Commits](https://github.com/apify/fingerprint-suite/compare/v2.1.77...v2.1.78)

Updates `baseline-browser-mapping` from 2.8.26 to 2.9.3
- [Release notes](https://github.com/web-platform-dx/baseline-browser-mapping/releases)
- [Commits](https://github.com/web-platform-dx/baseline-browser-mapping/compare/v2.8.26...v2.9.3)

Updates `browserslist` from 4.28.0 to 4.28.1
- [Release notes](https://github.com/browserslist/browserslist/releases)
- [Changelog](https://github.com/browserslist/browserslist/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/browserslist/compare/4.28.0...4.28.1)

Updates `electron-to-chromium` from 1.5.250 to 1.5.266
- [Changelog](https://github.com/Kilian/electron-to-chromium/blob/master/CHANGELOG.md)
- [Commits](https://github.com/kilian/electron-to-chromium/commits)

Updates `framer-motion` from 12.23.24 to 12.23.25
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.23.24...v12.23.25)

Updates `generative-bayesian-network` from 2.1.77 to 2.1.78
- [Release notes](https://github.com/apify/fingerprint-suite/releases)
- [Commits](https://github.com/apify/fingerprint-suite/compare/v2.1.77...v2.1.78)

Updates `header-generator` from 2.1.77 to 2.1.78
- [Release notes](https://github.com/apify/fingerprint-suite/releases)
- [Commits](https://github.com/apify/fingerprint-suite/compare/v2.1.77...v2.1.78)

Updates `update-browserslist-db` from 1.1.4 to 1.2.2
- [Release notes](https://github.com/browserslist/update-db/releases)
- [Changelog](https://github.com/browserslist/update-db/blob/main/CHANGELOG.md)
- [Commits](https://github.com/browserslist/update-db/compare/1.1.4...1.2.2)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 0.556.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: motion
  dependency-version: 12.23.25
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: react
  dependency-version: 19.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: react-dom
  dependency-version: 19.2.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: fingerprint-generator
  dependency-version: 2.1.78
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: baseline-browser-mapping
  dependency-version: 2.9.3
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: browserslist
  dependency-version: 4.28.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: electron-to-chromium
  dependency-version: 1.5.266
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: framer-motion
  dependency-version: 12.23.25
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: generative-bayesian-network
  dependency-version: 2.1.78
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: header-generator
  dependency-version: 2.1.78
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: frontend-dependencies
- dependency-name: update-browserslist-db
  dependency-version: 1.2.2
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-06 09:29:05 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d725040b6e ci(deps): bump the github-actions group with 6 updates (#150)
Bumps the github-actions group with 6 updates:

| Package | From | To |
| --- | --- | --- |
| [actions/checkout](https://github.com/actions/checkout) | `6.0.0` | `6.0.1` |
| [actions/setup-node](https://github.com/actions/setup-node) | `6.0.0` | `6.1.0` |
| [swatinem/rust-cache](https://github.com/swatinem/rust-cache) | `2.8.1` | `2.8.2` |
| [actions/ai-inference](https://github.com/actions/ai-inference) | `2.0.1` | `2.0.4` |
| [crate-ci/typos](https://github.com/crate-ci/typos) | `1.39.2` | `1.40.0` |
| [actions/stale](https://github.com/actions/stale) | `10.1.0` | `10.1.1` |


Updates `actions/checkout` from 6.0.0 to 6.0.1
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/1af3b93b6815bc44a9784bd300feb67ff0d1eeb3...8e8c483db84b4bee98b60c0593521ed34d9990e8)

Updates `actions/setup-node` from 6.0.0 to 6.1.0
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/2028fbc5c25fe9cf00d9f06a71cc4710d4507903...395ad3262231945c25e8478fd5baf05154b1d79f)

Updates `swatinem/rust-cache` from 2.8.1 to 2.8.2
- [Release notes](https://github.com/swatinem/rust-cache/releases)
- [Changelog](https://github.com/Swatinem/rust-cache/blob/master/CHANGELOG.md)
- [Commits](https://github.com/swatinem/rust-cache/compare/f13886b937689c021905a6b90929199931d60db1...779680da715d629ac1d338a641029a2f4372abb5)

Updates `actions/ai-inference` from 2.0.1 to 2.0.4
- [Release notes](https://github.com/actions/ai-inference/releases)
- [Commits](https://github.com/actions/ai-inference/compare/a1c11829223a786afe3b5663db904a3aa1eac3a2...334892bb203895caaed82ec52d23c1ed9385151e)

Updates `crate-ci/typos` from 1.39.2 to 1.40.0
- [Release notes](https://github.com/crate-ci/typos/releases)
- [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md)
- [Commits](https://github.com/crate-ci/typos/compare/626c4bedb751ce0b7f03262ca97ddda9a076ae1c...2d0ce569feab1f8752f1dde43cc2f2aa53236e06)

Updates `actions/stale` from 10.1.0 to 10.1.1
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/5f858e3efba33a5ca4407a664cc011ad407f2008...997185467fa4f803885201cee163a9f38240193d)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: 6.0.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/setup-node
  dependency-version: 6.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: swatinem/rust-cache
  dependency-version: 2.8.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: actions/ai-inference
  dependency-version: 2.0.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
- dependency-name: crate-ci/typos
  dependency-version: 1.40.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: github-actions
- dependency-name: actions/stale
  dependency-version: 10.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: github-actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-06 09:17:35 +00:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
81c00538a9 deps(deps): bump next from 16.0.6 to 16.0.7 (#149)
Bumps [next](https://github.com/vercel/next.js) from 16.0.6 to 16.0.7.
- [Release notes](https://github.com/vercel/next.js/releases)
- [Changelog](https://github.com/vercel/next.js/blob/canary/release.js)
- [Commits](https://github.com/vercel/next.js/compare/v16.0.6...v16.0.7)

---
updated-dependencies:
- dependency-name: next
  dependency-version: 16.0.7
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-03 21:04:21 +00:00
zhom 1c5444928d refactor: flush consistently 2025-12-02 23:06:30 +04:00
zhom 85f8630389 chore: version bump 2025-12-02 22:31:21 +04:00
zhom 57ead61139 refactor: reduce cpu usage for proxies 2025-12-02 22:30:40 +04:00
zhom ef00c59063 docs: add cla 2025-12-02 20:32:00 +04:00
zhom a61f42b645 chore: version bump 2025-12-02 13:00:19 +04:00
zhom 3dd66069b5 build: rerun tauri_build on binaries change 2025-12-02 12:59:50 +04:00
zhom 14c7ded062 chore: fix release notes escape character 2025-12-02 12:52:02 +04:00
zhom d58b68fd50 chore: version bump 2025-12-02 11:01:09 +04:00
zhom 3e69fea338 fix: show progress on macos 2025-12-02 10:01:48 +04:00
zhom fe2125beba chore: fix issue validation workflow 2025-12-02 01:05:21 +04:00
zhom 23cfa84998 chore: prevent code injection 2025-12-02 00:29:12 +04:00
zhom 3e3ec29f58 chore: version bump 2025-12-02 00:07:40 +04:00
zhom b1b91e94c0 chore: add permissions for webview 2025-12-02 00:06:54 +04:00
zhom c624196dbb chore: update release notes triggering logic 2025-12-01 23:55:59 +04:00
zhom b24568043c chore: linting 2025-12-01 15:21:22 +04:00
zhom d201cc90d1 chore: version bump 2025-12-01 13:41:36 +04:00
zhom a118ccc349 chore: linting 2025-12-01 12:29:21 +04:00
zhom effe229067 refactor: remove mullvad and tor browser artifacts 2025-12-01 12:25:21 +04:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
98a8369f60 deps(deps): bump the frontend-dependencies group across 1 directory with 9 updates (#147)
Bumps the frontend-dependencies group with 1 update in the / directory: [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome).


Updates `@biomejs/biome` from 2.2.3 to 2.3.8
- [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.3.8/packages/@biomejs/biome)

Updates `@biomejs/cli-darwin-arm64` from 2.2.3 to 2.3.8
- [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.3.8/packages/@biomejs/biome)

Updates `@biomejs/cli-darwin-x64` from 2.2.3 to 2.3.8
- [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.3.8/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-arm64-musl` from 2.2.3 to 2.3.8
- [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.3.8/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-arm64` from 2.2.3 to 2.3.8
- [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.3.8/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-x64-musl` from 2.2.3 to 2.3.8
- [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.3.8/packages/@biomejs/biome)

Updates `@biomejs/cli-linux-x64` from 2.2.3 to 2.3.8
- [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.3.8/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-arm64` from 2.2.3 to 2.3.8
- [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.3.8/packages/@biomejs/biome)

Updates `@biomejs/cli-win32-x64` from 2.2.3 to 2.3.8
- [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.3.8/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.3.8
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-arm64"
  dependency-version: 2.3.8
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-darwin-x64"
  dependency-version: 2.3.8
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64-musl"
  dependency-version: 2.3.8
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-arm64"
  dependency-version: 2.3.8
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64-musl"
  dependency-version: 2.3.8
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-linux-x64"
  dependency-version: 2.3.8
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-arm64"
  dependency-version: 2.3.8
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
- dependency-name: "@biomejs/cli-win32-x64"
  dependency-version: 2.3.8
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: frontend-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-11-30 22:16:04 +00:00
zhom f7ae299771 chore: version bump 2025-12-01 01:47:32 +04:00
zhom c43f141907 refactor: better error handling 2025-12-01 01:46:28 +04:00
zhom cd33accb1a refactor: show tooltip for truncated text 2025-12-01 00:45:41 +04:00
zhom ca89b917f4 refactor: respect selected timeframe for top domains 2025-12-01 00:15:23 +04:00
zhom 6ad183ab89 style: profile table cleanup 2025-11-30 23:29:07 +04:00
zhom c83950bee7 chore: codegen 2025-11-30 22:43:10 +04:00
zhom 0047c80967 style: make the row chart shorter 2025-11-30 21:28:19 +04:00
zhom 3d7bd2b14c chore: version bump 2025-11-30 21:25:25 +04:00
zhom 8899e58987 chore: simplify tsconfig 2025-11-30 21:18:56 +04:00
zhom acf8651bd1 refactor: fix types after dependency upgrade 2025-11-30 21:16:26 +04:00
zhom ef534ee779 chore: update major dependencies 2025-11-30 21:06:09 +04:00
zhom 75bb10cf61 chore: remove ipecho from domain checkers 2025-11-30 21:03:31 +04:00
zhom 6f9e0de633 chore: update dependencies 2025-11-30 20:59:19 +04:00
zhom 39c2a9f6f0 refactor: disable quit confirmations in browser 2025-11-30 20:59:04 +04:00
zhom 4b6f08fca3 refactor: disable more update-related settings 2025-11-30 20:44:59 +04:00
zhom 24eff75d4e chore: cleanup logs 2025-11-30 20:42:06 +04:00
zhom 11869855e9 build: make permissions more explicit 2025-11-30 20:40:34 +04:00
zhom 0d1f1f1497 refactor: suppress first-run warnings 2025-11-30 20:40:10 +04:00
zhom e8026d817f refactor: clean up old binary after installation 2025-11-30 20:39:34 +04:00
zhom d1ca4273de chore: check tag name instead of ref 2025-11-30 20:08:25 +04:00
zhom e8c382400c chore: version bump 2025-11-30 17:36:31 +04:00
zhom c40f023d41 refactor: improved performance for old profiles 2025-11-30 17:34:04 +04:00
zhom e16512576c refactor: try getting high priority for local proxies 2025-11-30 17:04:07 +04:00
zhom f098128988 refactor: cleanup bandwidth tracking functionality 2025-11-30 16:55:23 +04:00
zhom cdba9aac33 feat: add network overview 2025-11-30 15:04:48 +04:00
zhom 01b3109dc1 feat: add mass-proxy-assign action 2025-11-30 11:03:19 +04:00
zhom 8aa3885240 style: move the body of the profile creation trigger a bit left 2025-11-30 10:46:03 +04:00
zhom 5947ec14e6 feat: add notes 2025-11-30 10:45:39 +04:00
zhom 2c7c07c414 test: fix flackyness 2025-11-30 09:51:28 +04:00
zhom 2e26b53db8 refactor: reuse copy-to-clipboard button 2025-11-30 00:31:42 +04:00
zhom 966a10c045 feat: allow user select system for which to generate fingerprint 2025-11-30 00:27:08 +04:00
zhom f72e3066f3 feat: add catppuccin themes 2025-11-30 00:14:23 +04:00
zhom cd8e1dcf18 refactor: allow the user to view the fingerprint while the profile is running 2025-11-29 23:50:54 +04:00
zhom dfc8cd4c9f chore: cleanup windows config 2025-11-29 23:40:33 +04:00
zhom 5a1726d119 build: disable msi bundler until ice30 issue is resolved 2025-11-29 23:38:54 +04:00
zhom 133ed98df1 chore: update windows config 2025-11-29 23:37:46 +04:00
zhom 4683410a2c feat: add openapi spec generation 2025-11-29 23:32:49 +04:00
zhom 44b5e71593 fix: remove parenthesis 2025-11-29 22:53:42 +04:00
zhomandGitHub a02c16126b Merge pull request #145 from zhom/dependabot/cargo/src-tauri/rust-dependencies-2c3a25eb42
deps(rust)(deps): bump the rust-dependencies group in /src-tauri with 27 updates
2025-11-29 22:45:46 +04:00
zhom fd7edfc332 build: enable all build targets 2025-11-29 22:45:28 +04:00
zhom 1e48caf129 chore: linting 2025-11-29 22:44:55 +04:00
zhom e39047bdfd chore: prevent output truncation in release notes workflow 2025-11-29 22:42:15 +04:00
zhom f3fe0fa0e7 chore: remove unused permissions and variables 2025-11-29 22:41:32 +04:00
zhom a7f523ac4c chore: stop using automerge action 2025-11-29 22:24:07 +04:00
zhom 763d5a5a1b build: disable msi bundler until ice30 issue is resolved 2025-11-29 21:58:51 +04:00
dependabot[bot]andGitHub 65d37d48e2 deps(rust)(deps): bump the rust-dependencies group
Bumps the rust-dependencies group in /src-tauri with 27 updates:

| Package | From | To |
| --- | --- | --- |
| [tauri](https://github.com/tauri-apps/tauri) | `2.9.2` | `2.9.3` |
| [zip](https://github.com/zip-rs/zip2) | `5.1.1` | `6.0.0` |
| [axum](https://github.com/tokio-rs/axum) | `0.8.6` | `0.8.7` |
| [tower-http](https://github.com/tower-rs/tower-http) | `0.6.6` | `0.6.7` |
| [hyper](https://github.com/hyperium/hyper) | `1.8.0` | `1.8.1` |
| [hyper-util](https://github.com/hyperium/hyper-util) | `0.1.17` | `0.1.18` |
| [tauri-build](https://github.com/tauri-apps/tauri) | `2.5.1` | `2.5.2` |
| [bytes](https://github.com/tokio-rs/bytes) | `1.10.1` | `1.11.0` |
| [cc](https://github.com/rust-lang/cc-rs) | `1.2.45` | `1.2.48` |
| [crc](https://github.com/mrhooray/crc-rs) | `3.3.0` | `3.4.0` |
| [crypto-common](https://github.com/RustCrypto/traits) | `0.1.6` | `0.1.7` |
| [endi](https://github.com/zeenix/endi) | `1.1.0` | `1.1.1` |
| [find-msvc-tools](https://github.com/rust-lang/cc-rs) | `0.1.4` | `0.1.5` |
| [generic-array](https://github.com/fizyk20/generic-array) | `0.14.9` | `0.14.7` |
| [http](https://github.com/hyperium/http) | `1.3.1` | `1.4.0` |
| [open](https://github.com/Byron/open-rs) | `5.3.2` | `5.3.3` |
| [rustls-pki-types](https://github.com/rustls/pki-types) | `1.13.0` | `1.13.1` |
| [serde_with](https://github.com/jonasbb/serde_with) | `3.15.1` | `3.16.1` |
| [serde_with_macros](https://github.com/jonasbb/serde_with) | `3.15.1` | `3.16.1` |
| [signal-hook-registry](https://github.com/vorner/signal-hook) | `1.4.6` | `1.4.7` |
| [tauri-codegen](https://github.com/tauri-apps/tauri) | `2.5.0` | `2.5.1` |
| [tauri-macros](https://github.com/tauri-apps/tauri) | `2.5.0` | `2.5.1` |
| [tracing](https://github.com/tokio-rs/tracing) | `0.1.41` | `0.1.43` |
| [tracing-attributes](https://github.com/tokio-rs/tracing) | `0.1.30` | `0.1.31` |
| [tracing-core](https://github.com/tokio-rs/tracing) | `0.1.34` | `0.1.35` |
| [zerocopy](https://github.com/google/zerocopy) | `0.8.27` | `0.8.30` |
| [zerocopy-derive](https://github.com/google/zerocopy) | `0.8.27` | `0.8.30` |


Updates `tauri` from 2.9.2 to 2.9.3
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-v2.9.2...tauri-v2.9.3)

Updates `zip` from 5.1.1 to 6.0.0
- [Release notes](https://github.com/zip-rs/zip2/releases)
- [Changelog](https://github.com/zip-rs/zip2/blob/master/CHANGELOG.md)
- [Commits](https://github.com/zip-rs/zip2/compare/v5.1.1...v6.0.0)

Updates `axum` from 0.8.6 to 0.8.7
- [Release notes](https://github.com/tokio-rs/axum/releases)
- [Changelog](https://github.com/tokio-rs/axum/blob/main/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/axum/compare/axum-v0.8.6...axum-v0.8.7)

Updates `tower-http` from 0.6.6 to 0.6.7
- [Release notes](https://github.com/tower-rs/tower-http/releases)
- [Commits](https://github.com/tower-rs/tower-http/compare/tower-http-0.6.6...tower-http-0.6.7)

Updates `hyper` from 1.8.0 to 1.8.1
- [Release notes](https://github.com/hyperium/hyper/releases)
- [Changelog](https://github.com/hyperium/hyper/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper/compare/v1.8.0...v1.8.1)

Updates `hyper-util` from 0.1.17 to 0.1.18
- [Release notes](https://github.com/hyperium/hyper-util/releases)
- [Changelog](https://github.com/hyperium/hyper-util/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/hyper-util/compare/v0.1.17...v0.1.18)

Updates `tauri-build` from 2.5.1 to 2.5.2
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-build-v2.5.1...tauri-build-v2.5.2)

Updates `bytes` from 1.10.1 to 1.11.0
- [Release notes](https://github.com/tokio-rs/bytes/releases)
- [Changelog](https://github.com/tokio-rs/bytes/blob/master/CHANGELOG.md)
- [Commits](https://github.com/tokio-rs/bytes/compare/v1.10.1...v1.11.0)

Updates `cc` from 1.2.45 to 1.2.48
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/cc-v1.2.45...cc-v1.2.48)

Updates `crc` from 3.3.0 to 3.4.0
- [Commits](https://github.com/mrhooray/crc-rs/compare/3.3.0...3.4.0)

Updates `crypto-common` from 0.1.6 to 0.1.7
- [Commits](https://github.com/RustCrypto/traits/compare/crypto-common-v0.1.6...crypto-common-v0.1.7)

Updates `endi` from 1.1.0 to 1.1.1
- [Release notes](https://github.com/zeenix/endi/releases)
- [Commits](https://github.com/zeenix/endi/compare/1.1.0...1.1.1)

Updates `find-msvc-tools` from 0.1.4 to 0.1.5
- [Release notes](https://github.com/rust-lang/cc-rs/releases)
- [Changelog](https://github.com/rust-lang/cc-rs/blob/main/CHANGELOG.md)
- [Commits](https://github.com/rust-lang/cc-rs/compare/find-msvc-tools-v0.1.4...find-msvc-tools-v0.1.5)

Updates `generic-array` from 0.14.9 to 0.14.7
- [Release notes](https://github.com/fizyk20/generic-array/releases)
- [Changelog](https://github.com/fizyk20/generic-array/blob/master/CHANGELOG.md)
- [Commits](https://github.com/fizyk20/generic-array/commits)

Updates `http` from 1.3.1 to 1.4.0
- [Release notes](https://github.com/hyperium/http/releases)
- [Changelog](https://github.com/hyperium/http/blob/master/CHANGELOG.md)
- [Commits](https://github.com/hyperium/http/compare/v1.3.1...v1.4.0)

Updates `open` from 5.3.2 to 5.3.3
- [Release notes](https://github.com/Byron/open-rs/releases)
- [Changelog](https://github.com/Byron/open-rs/blob/main/changelog.md)
- [Commits](https://github.com/Byron/open-rs/compare/v5.3.2...v5.3.3)

Updates `rustls-pki-types` from 1.13.0 to 1.13.1
- [Release notes](https://github.com/rustls/pki-types/releases)
- [Commits](https://github.com/rustls/pki-types/compare/v/1.13.0...v/1.13.1)

Updates `serde_with` from 3.15.1 to 3.16.1
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.15.1...v3.16.1)

Updates `serde_with_macros` from 3.15.1 to 3.16.1
- [Release notes](https://github.com/jonasbb/serde_with/releases)
- [Commits](https://github.com/jonasbb/serde_with/compare/v3.15.1...v3.16.1)

Updates `signal-hook-registry` from 1.4.6 to 1.4.7
- [Changelog](https://github.com/vorner/signal-hook/blob/master/CHANGELOG.md)
- [Commits](https://github.com/vorner/signal-hook/compare/registry-v1.4.6...registry-v1.4.7)

Updates `tauri-codegen` from 2.5.0 to 2.5.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-codegen-v2.5.0...tauri-codegen-v2.5.1)

Updates `tauri-macros` from 2.5.0 to 2.5.1
- [Release notes](https://github.com/tauri-apps/tauri/releases)
- [Commits](https://github.com/tauri-apps/tauri/compare/tauri-macros-v2.5.0...tauri-macros-v2.5.1)

Updates `tracing` from 0.1.41 to 0.1.43
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-0.1.41...tracing-0.1.43)

Updates `tracing-attributes` from 0.1.30 to 0.1.31
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-attributes-0.1.30...tracing-attributes-0.1.31)

Updates `tracing-core` from 0.1.34 to 0.1.35
- [Release notes](https://github.com/tokio-rs/tracing/releases)
- [Commits](https://github.com/tokio-rs/tracing/compare/tracing-core-0.1.34...tracing-core-0.1.35)

Updates `zerocopy` from 0.8.27 to 0.8.30
- [Release notes](https://github.com/google/zerocopy/releases)
- [Changelog](https://github.com/google/zerocopy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/zerocopy/compare/v0.8.27...v0.8.30)

Updates `zerocopy-derive` from 0.8.27 to 0.8.30
- [Release notes](https://github.com/google/zerocopy/releases)
- [Changelog](https://github.com/google/zerocopy/blob/main/CHANGELOG.md)
- [Commits](https://github.com/google/zerocopy/compare/v0.8.27...v0.8.30)

---
updated-dependencies:
- dependency-name: tauri
  dependency-version: 2.9.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zip
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: rust-dependencies
- dependency-name: axum
  dependency-version: 0.8.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tower-http
  dependency-version: 0.6.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: hyper
  dependency-version: 1.8.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: hyper-util
  dependency-version: 0.1.18
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-build
  dependency-version: 2.5.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: bytes
  dependency-version: 1.11.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: cc
  dependency-version: 1.2.48
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: crc
  dependency-version: 3.4.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: crypto-common
  dependency-version: 0.1.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: endi
  dependency-version: 1.1.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: find-msvc-tools
  dependency-version: 0.1.5
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: generic-array
  dependency-version: 0.14.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: http
  dependency-version: 1.4.0
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: open
  dependency-version: 5.3.3
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: rustls-pki-types
  dependency-version: 1.13.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: serde_with
  dependency-version: 3.16.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: serde_with_macros
  dependency-version: 3.16.1
  dependency-type: indirect
  update-type: version-update:semver-minor
  dependency-group: rust-dependencies
- dependency-name: signal-hook-registry
  dependency-version: 1.4.7
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-codegen
  dependency-version: 2.5.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tauri-macros
  dependency-version: 2.5.1
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tracing
  dependency-version: 0.1.43
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tracing-attributes
  dependency-version: 0.1.31
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: tracing-core
  dependency-version: 0.1.35
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zerocopy
  dependency-version: 0.8.30
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
- dependency-name: zerocopy-derive
  dependency-version: 0.8.30
  dependency-type: indirect
  update-type: version-update:semver-patch
  dependency-group: rust-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-29 17:46:07 +00:00
85 changed files with 9477 additions and 3723 deletions
+3 -3
View File
@@ -31,7 +31,7 @@ jobs:
# build-mode: none
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
@@ -39,7 +39,7 @@ jobs:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
with:
node-version-file: .node-version
cache: "pnpm"
@@ -57,7 +57,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 #v2.8.1
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
with:
workdir: ./src-tauri
+3 -1
View File
@@ -1,3 +1,5 @@
name: Contributors
on:
push:
branches:
@@ -19,7 +21,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Contribute List
uses: akhilmhdh/contributors-readme-action@83ea0b4f1ac928fbfe88b9e8460a932a528eb79f #v2.3.11
env:
+7 -9
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@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@375a0e8ebdc98e99b02ac4338a724f5750f21213" # v2.3.1
with:
scan-args: |-
-r
@@ -70,13 +70,11 @@ jobs:
id: metadata
uses: dependabot/fetch-metadata@08eff52bf64351f401fb50d4972fa95b9f2c2d1b #v2.4.0
with:
compat-lookup: true
github-token: "${{ secrets.GITHUB_TOKEN }}"
- name: Auto-merge minor and patch updates
uses: ridedott/merge-me-action@18dd4f01d259faf0a2d900a56cd6b7e765009209 #v2.10.138
with:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
MERGE_METHOD: SQUASH
PRESET: DEPENDABOT_MINOR
MAXIMUM_RETRIES: 5
- name: Enable auto-merge for minor and patch updates
if: ${{ steps.metadata.outputs.update-type == 'version-update:semver-minor' || steps.metadata.outputs.update-type == 'version-update:semver-patch' }}
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
timeout-minutes: 10
+2 -3
View File
@@ -8,10 +8,9 @@ jobs:
greeting:
runs-on: ubuntu-latest
permissions:
issues: write
pull-requests: write
steps:
- uses: actions/first-interaction@1c4688942c71f71d4f5502a26ea67c331730fa4d # v3.1.0
with:
issue_message: "Thank you for your first issue ❤️ If it's a feature request, please make sure it's clear what you want, why you want it, and how important it is to you. If you posted a bug report, please make sure it includes as much detail as possible."
pr_message: "Welcome to the community and thank you for your first contribution ❤️ A human will review your PR shortly. Make sure that the pipelines are green, so that the PR is considered ready for a review and could be merged."
repo-token: ${{ secrets.GITHUB_TOKEN }}
pr-message: "Welcome to the community and thank you for your first contribution ❤️ A human will review your PR shortly. Make sure that the pipelines are green, so that the PR is considered ready for a review and could be merged."
+5 -4
View File
@@ -15,7 +15,7 @@ jobs:
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Get issue templates
id: get-templates
@@ -49,7 +49,7 @@ jobs:
- name: Validate issue with AI
id: validate
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
with:
prompt-file: issue_analysis.txt
system-prompt: |
@@ -115,13 +115,14 @@ jobs:
- name: Parse validation result and take action
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RESPONSE_FILE: ${{ steps.validate.outputs.response-file }}
RESPONSE: ${{ steps.validate.outputs.response }}
run: |
# Prefer reading from the response file to avoid output truncation
RESPONSE_FILE='${{ steps.validate.outputs.response-file }}'
if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then
RAW_OUTPUT=$(cat "$RESPONSE_FILE")
else
RAW_OUTPUT='${{ steps.validate.outputs.response }}'
RAW_OUTPUT="$RESPONSE"
fi
# Extract JSON if wrapped in markdown code fences; otherwise use raw
+2 -2
View File
@@ -34,7 +34,7 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
@@ -42,7 +42,7 @@ jobs:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
with:
node-version-file: .node-version
cache: "pnpm"
+2 -2
View File
@@ -41,7 +41,7 @@ jobs:
run: git config --global core.autocrlf false
- name: Checkout repository code
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Set up pnpm package manager
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
@@ -49,7 +49,7 @@ jobs:
run_install: false
- name: Set up Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
with:
node-version-file: .node-version
cache: "pnpm"
+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@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@375a0e8ebdc98e99b02ac4338a724f5750f21213" # v2.3.1
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@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@375a0e8ebdc98e99b02ac4338a724f5750f21213" # v2.3.1
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@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable-pr.yml@375a0e8ebdc98e99b02ac4338a724f5750f21213" # v2.3.1
with:
scan-args: |-
-r
+50 -17
View File
@@ -1,8 +1,10 @@
name: Generate Release Notes
on:
release:
types: [published]
workflow_run:
workflows: ["Release"]
types:
- completed
permissions:
contents: write
@@ -11,19 +13,40 @@ permissions:
jobs:
generate-release-notes:
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v') && !github.event.release.prerelease
if: github.event.workflow_run.conclusion == 'success' && startsWith(github.event.workflow_run.head_branch, 'v')
steps:
- name: Checkout repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
with:
fetch-depth: 0 # Fetch full history to compare with previous release
fetch-depth: 0
- name: Get release info
id: get-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
TAG_NAME: ${{ github.event.workflow_run.head_branch }}
run: |
echo "tag-name=$TAG_NAME" >> $GITHUB_OUTPUT
# Get release info by tag
RELEASE_INFO=$(gh api /repos/${{ github.repository }}/releases/tags/$TAG_NAME)
RELEASE_ID=$(echo "$RELEASE_INFO" | jq -r '.id')
IS_PRERELEASE=$(echo "$RELEASE_INFO" | jq -r '.prerelease')
echo "release-id=$RELEASE_ID" >> $GITHUB_OUTPUT
echo "is-prerelease=$IS_PRERELEASE" >> $GITHUB_OUTPUT
if [ "$IS_PRERELEASE" = "true" ]; then
echo "Skipping release notes generation for prerelease"
fi
- name: Get previous release tag
id: get-previous-tag
if: steps.get-release.outputs.is-prerelease == 'false'
env:
CURRENT_TAG: ${{ steps.get-release.outputs.tag-name }}
run: |
# Get the previous release tag (excluding the current one)
CURRENT_TAG="${{ github.ref_name }}"
PREVIOUS_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | grep -v "$CURRENT_TAG" | head -n 1)
if [ -z "$PREVIOUS_TAG" ]; then
@@ -38,16 +61,16 @@ jobs:
- name: Get commit messages between releases
id: get-commits
if: steps.get-release.outputs.is-prerelease == 'false'
env:
PREVIOUS_TAG: ${{ steps.get-previous-tag.outputs.previous-tag }}
CURRENT_TAG: ${{ steps.get-previous-tag.outputs.current-tag }}
run: |
# Get commit messages between previous and current release
PREVIOUS_TAG="${{ steps.get-previous-tag.outputs.previous-tag }}"
CURRENT_TAG="${{ steps.get-previous-tag.outputs.current-tag }}"
# Get commit log with detailed format
COMMIT_LOG=$(git log --pretty=format:"- %s (%h by %an)" $PREVIOUS_TAG..$CURRENT_TAG --no-merges)
COMMIT_LOG=$(git log --pretty=format:"- %s (%h by %an)" "$PREVIOUS_TAG".."$CURRENT_TAG" --no-merges)
# Get changed files summary
CHANGED_FILES=$(git diff --name-status $PREVIOUS_TAG..$CURRENT_TAG | head -20)
CHANGED_FILES=$(git diff --name-status "$PREVIOUS_TAG".."$CURRENT_TAG" | head -20)
# Save to files for AI processing
echo "$COMMIT_LOG" > commits.txt
@@ -58,7 +81,8 @@ jobs:
- name: Generate release notes with AI
id: generate-notes
uses: actions/ai-inference@a1c11829223a786afe3b5663db904a3aa1eac3a2 # v2.0.1
if: steps.get-release.outputs.is-prerelease == 'false'
uses: actions/ai-inference@334892bb203895caaed82ec52d23c1ed9385151e # v2.0.4
with:
prompt-file: commits.txt
system-prompt: |
@@ -101,18 +125,27 @@ jobs:
model: gpt-4o
- name: Update release with generated notes
if: steps.get-release.outputs.is-prerelease == 'false'
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RESPONSE_FILE: ${{ steps.generate-notes.outputs.response-file }}
RESPONSE_OUTPUT: ${{ steps.generate-notes.outputs.response }}
RELEASE_ID: ${{ steps.get-release.outputs.release-id }}
run: |
# Get the generated release notes
RELEASE_NOTES="${{ steps.generate-notes.outputs.response }}"
# Prefer reading from the response file to avoid output truncation
if [ -n "$RESPONSE_FILE" ] && [ -f "$RESPONSE_FILE" ]; then
RELEASE_NOTES=$(cat "$RESPONSE_FILE")
else
RELEASE_NOTES="$RESPONSE_OUTPUT"
fi
# Update the release with the generated notes
gh api --method PATCH /repos/${{ github.repository }}/releases/${{ github.event.release.id }} \
gh api --method PATCH /repos/${{ github.repository }}/releases/"$RELEASE_ID" \
--field body="$RELEASE_NOTES"
echo "✅ Release notes updated successfully!"
- name: Cleanup
if: always()
run: |
rm -f commits.txt changes.txt
+4 -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@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@375a0e8ebdc98e99b02ac4338a724f5750f21213" # v2.3.1
with:
scan-args: |-
-r
@@ -105,7 +105,7 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
@@ -113,7 +113,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
with:
node-version-file: .node-version
cache: "pnpm"
@@ -131,7 +131,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 #v2.8.1
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
with:
workdir: ./src-tauri
+4 -4
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@b77c075a1235514558f0eb88dbd31e22c45e0cd2" # v2.3.0
uses: "google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@375a0e8ebdc98e99b02ac4338a724f5750f21213" # v2.3.1
with:
scan-args: |-
-r
@@ -104,7 +104,7 @@ jobs:
runs-on: ${{ matrix.platform }}
steps:
- uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Setup pnpm
uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 #v4.2.0
@@ -112,7 +112,7 @@ jobs:
run_install: false
- name: Setup Node.js
uses: actions/setup-node@2028fbc5c25fe9cf00d9f06a71cc4710d4507903 #v6.0.0
uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f #v6.1.0
with:
node-version-file: .node-version
cache: "pnpm"
@@ -130,7 +130,7 @@ jobs:
sudo apt-get install -y libwebkit2gtk-4.1-dev libgtk-3-dev libayatana-appindicator3-dev librsvg2-dev pkg-config xdg-utils
- name: Rust cache
uses: swatinem/rust-cache@f13886b937689c021905a6b90929199931d60db1 #v2.8.1
uses: swatinem/rust-cache@779680da715d629ac1d338a641029a2f4372abb5 #v2.8.2
with:
workdir: ./src-tauri
+2 -2
View File
@@ -21,6 +21,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout Actions Repository
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 #v6.0.0
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 #v6.0.1
- name: Spell Check Repo
uses: crate-ci/typos@626c4bedb751ce0b7f03262ca97ddda9a076ae1c #v1.39.2
uses: crate-ci/typos@2d0ce569feab1f8752f1dde43cc2f2aa53236e06 #v1.40.0
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
pull-requests: write
steps:
- uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0
- uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10.1.1
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
stale-issue-message: "This issue has been inactive for 60 days. Please respond to keep it open."
+15 -3
View File
@@ -17,6 +17,7 @@
"busctl",
"CAMOU",
"camoufox",
"catppuccin",
"cdylib",
"certifi",
"CFURL",
@@ -53,6 +54,7 @@
"esac",
"esbuild",
"etree",
"firstrun",
"flate",
"frontmost",
"geoip",
@@ -65,10 +67,12 @@
"hkcu",
"hooksconfig",
"hookspath",
"Hoverable",
"icns",
"idlelib",
"idletime",
"idna",
"infobars",
"Inno",
"kdeglobals",
"keras",
@@ -76,10 +80,12 @@
"killall",
"Kolkata",
"kreadconfig",
"langpack",
"launchservices",
"letterboxing",
"libatk",
"libayatana",
"libc",
"libcairo",
"libgdk",
"libglib",
@@ -91,6 +97,7 @@
"lpdw",
"lxml",
"lzma",
"macchiato",
"Matchalk",
"mmdb",
"mountpoint",
@@ -98,13 +105,12 @@
"mstone",
"msvc",
"msys",
"Mullvad",
"mullvadbrowser",
"mypy",
"noarchive",
"nobrowse",
"noconfirm",
"nodecar",
"NODELAY",
"nodemon",
"norestart",
"NSIS",
@@ -126,6 +132,7 @@
"plasmohq",
"platformdirs",
"prefs",
"PRIO",
"propertylist",
"psutil",
"pycache",
@@ -135,6 +142,7 @@
"pyoxidizer",
"pytest",
"pyyaml",
"reportingpolicy",
"reqwest",
"ridedott",
"rlib",
@@ -145,6 +153,9 @@
"screeninfo",
"selectables",
"serde",
"sessionstore",
"setpriority",
"setsid",
"SETTINGCHANGE",
"setuptools",
"shadcn",
@@ -175,9 +186,9 @@
"timedatectl",
"titlebar",
"tkinter",
"Torbrowser",
"tqdm",
"trackingprotection",
"trailhead",
"turbopack",
"turtledemo",
"udeps",
@@ -186,6 +197,7 @@
"unrs",
"urlencoding",
"urllib",
"utoipa",
"venv",
"vercel",
"VERYSILENT",
+10
View File
@@ -12,6 +12,16 @@ Do keep in mind before you start working on an issue / posting a PR:
- Confirm if other contributors are working on the same issue
- Check if the feature aligns with our roadmap and project goals
## Contributor License Agreement
By contributing to Donut Browser, you agree that your contributions will be licensed under the same terms as the project. You must agree to our [Contributor License Agreement](CONTRIBUTOR_LICENSE_AGREEMENT.md) before your contributions can be accepted. This agreement ensures that:
- Your contributions can be used in the open source version of Donut Browser (licensed under AGPL-3.0)
- Donut Browser can offer commercial licenses for the software, including your contributions
- You retain all rights to use your contributions for any other purpose
When you submit your first pull request, you acknowledge that you agree to the terms of the Contributor License Agreement.
## Tips & Things to Consider
- PRs with tests are highly appreciated
+15
View File
@@ -0,0 +1,15 @@
# Donut Browser Software Grant and Contributor License Agreement ("Agreement")
This agreement is based on the Apache Software Foundation Contributor License Agreement. (v r190612)
Thank you for your interest in the Donut Browser project ("Donut Browser" or "the Project"). In order to clarify the intellectual property license granted with Contributions from any person or entity, Donut Browser must have a Contributor License Agreement (CLA) on file that has been agreed to by each Contributor, indicating agreement to the license terms below. This license is for your protection as a Contributor as well as the protection of Donut Browser and its users; it does not change your rights to use your own Contributions for any other purpose. This Agreement allows an individual to contribute to Donut Browser on that individual's own behalf, or an entity (the "Corporation") to submit Contributions to Donut Browser, to authorize Contributions submitted by its designated employees to Donut Browser, and to grant copyright and patent licenses thereto.
You accept and agree to the following terms and conditions for Your present and future Contributions submitted to Donut Browser. Except for the license granted herein to Donut Browser and recipients of software distributed by Donut Browser, You reserve all right, title, and interest in and to Your Contributions.
1. Definitions. "You" (or "Your") shall mean the copyright owner or legal entity authorized by the copyright owner that is making this Agreement with Donut Browser. For legal entities, the entity making a Contribution and all other entities that control, are controlled by, or are under common control with that entity are considered to be a single Contributor. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "Contribution" shall mean any work, as well as any modifications or additions to an existing work, that is intentionally submitted by You to Donut Browser for inclusion in, or documentation of, any of the products owned or managed by Donut Browser (the "Work"). For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to Donut Browser or its representatives, including but not limited to communication on electronic mailing lists, source code control systems (such as GitHub), and issue tracking systems that are managed by, or on behalf of, Donut Browser for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by You as "Not a Contribution."
2. Grant of Copyright License. Subject to the terms and conditions of this Agreement, You hereby grant to Donut Browser and to recipients of software distributed by Donut Browser a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare derivative works of, publicly display, publicly perform, sublicense, and distribute Your Contributions and such derivative works under any license terms, including but not limited to the GNU Affero General Public License version 3 (AGPL-3.0) and any commercial or proprietary license terms that Donut Browser may choose to offer. This grant includes the right for Donut Browser to offer the Work, including Your Contributions, under multiple licenses simultaneously (dual or multi-licensing), including both open source and commercial licenses.
3. Grant of Patent License. Subject to the terms and conditions of this Agreement, You hereby grant to Donut Browser and to recipients of software distributed by Donut Browser a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by You that are necessarily infringed by Your Contribution(s) alone or by combination of Your Contribution(s) with the Work to which such Contribution(s) were submitted. If any entity institutes patent litigation against You or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that your Contribution, or the Work to which you have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed.
4. You represent that You are legally entitled to grant the above license. If You are an individual, and if Your employer(s) has rights to intellectual property that you create that includes Your Contributions, you represent that You have received permission to make Contributions on behalf of that employer, or that Your employer has waived such rights for your Contributions to Donut Browser. If You are a Corporation, any individual who makes a contribution from an account associated with You will be considered authorized to Contribute on Your behalf.
5. You represent that each of Your Contributions is Your original creation (see section 7 for submissions on behalf of others).
6. You are not expected to provide support for Your Contributions, except to the extent You desire to provide support. You may provide support for free, for a fee, or not at all. Unless required by applicable law or agreed to in writing, You provide Your Contributions on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE.
7. Should You wish to submit work that is not Your original creation, You may submit it to Donut Browser separately from any Contribution, identifying the complete details of its source and of any license or other restriction (including, but not limited to, related patents, trademarks, and license agreements) of which you are personally aware, and conspicuously marking the work as "Submitted on behalf of a third-party: [named here]".
+3
View File
@@ -29,6 +29,9 @@
}
},
"css": {
"parser": {
"tailwindDirectives": true
},
"formatter": {
"quoteStyle": "double"
}
+4 -1
View File
@@ -10,6 +10,7 @@
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "react-icons",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
@@ -17,5 +18,7 @@
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
"registries": {
"@animate-ui": "https://animate-ui.com/r/{name}.json"
}
}
+1 -1
View File
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./dist/types/routes.d.ts" />
import "./dist/dev/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+4 -4
View File
@@ -21,15 +21,15 @@
"author": "",
"license": "AGPL-3.0",
"dependencies": {
"@types/node": "^24.10.0",
"@types/node": "^25.0.3",
"commander": "^14.0.2",
"donutbrowser-camoufox-js": "^0.7.0",
"dotenv": "^17.2.3",
"fingerprint-generator": "^2.1.76",
"fingerprint-generator": "^2.1.78",
"get-port": "^7.1.0",
"nodemon": "^3.1.11",
"playwright-core": "^1.56.1",
"proxy-chain": "^2.5.9",
"playwright-core": "^1.57.0",
"proxy-chain": "^2.7.0",
"tmp": "^0.2.5",
"ts-node": "^10.9.2",
"typescript": "^5.9.3"
+85 -27
View File
@@ -267,6 +267,18 @@ export async function startCamoufoxProcess(
});
}
/**
* Check if a process is running by PID
*/
function isProcessRunning(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch {
return false;
}
}
/**
* Stop a Camoufox process
* @param id The Camoufox ID to stop
@@ -279,45 +291,85 @@ export async function stopCamoufoxProcess(id: string): Promise<boolean> {
return false;
}
const pid = config.processId;
try {
// Method 1: If we have a process ID, kill by PID with proper signal sequence
if (config.processId) {
if (pid && isProcessRunning(pid)) {
try {
// First try SIGTERM for graceful shutdown
process.kill(config.processId, "SIGTERM");
// Give it more time to terminate gracefully (increased from 2s to 5s)
await new Promise((resolve) => setTimeout(resolve, 5000));
process.kill(pid, "SIGTERM");
// Check if process is still running
try {
process.kill(config.processId, 0); // Signal 0 checks if process exists
process.kill(config.processId, "SIGKILL");
} catch {}
} catch {}
// Wait up to 3 seconds for graceful shutdown
for (let i = 0; i < 30; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (!isProcessRunning(pid)) {
break;
}
}
// If still running, force kill
if (isProcessRunning(pid)) {
process.kill(pid, "SIGKILL");
// Wait for SIGKILL to take effect
for (let i = 0; i < 20; i++) {
await new Promise((resolve) => setTimeout(resolve, 100));
if (!isProcessRunning(pid)) {
break;
}
}
}
} catch {
// Process might have already exited
}
}
// Method 2: Pattern-based kill as fallback
const killByPattern = spawn(
"pkill",
["-TERM", "-f", `camoufox-worker.*${id}`],
{
stdio: "ignore",
},
);
// Wait for pattern-based kill command to complete
// Method 2: Pattern-based kill as fallback (kills any child processes)
await new Promise<void>((resolve) => {
const killByPattern = spawn(
"pkill",
["-TERM", "-f", `camoufox-worker.*${id}`],
{ stdio: "ignore" },
);
killByPattern.on("exit", () => resolve());
// Timeout after 3 seconds
setTimeout(() => resolve(), 3000);
setTimeout(() => resolve(), 1000);
});
// Final cleanup with SIGKILL if needed
setTimeout(() => {
spawn("pkill", ["-KILL", "-f", `camoufox-worker.*${id}`], {
stdio: "ignore",
// Wait a moment then force kill any remaining
await new Promise((resolve) => setTimeout(resolve, 500));
await new Promise<void>((resolve) => {
const killByPatternForce = spawn(
"pkill",
["-KILL", "-f", `camoufox-worker.*${id}`],
{ stdio: "ignore" },
);
killByPatternForce.on("exit", () => resolve());
setTimeout(() => resolve(), 1000);
});
// Also kill any Firefox processes associated with this profile
if (config.profilePath) {
await new Promise<void>((resolve) => {
const killFirefox = spawn(
"pkill",
["-KILL", "-f", config.profilePath!],
{ stdio: "ignore" },
);
killFirefox.on("exit", () => resolve());
setTimeout(() => resolve(), 1000);
});
}, 1000);
}
// Verify process is actually dead
if (pid && isProcessRunning(pid)) {
// Last resort: SIGKILL again
try {
process.kill(pid, "SIGKILL");
} catch {
// Ignore
}
}
// Delete the configuration
deleteCamoufoxConfig(id);
@@ -352,6 +404,7 @@ interface GenerateConfigOptions {
blockWebgl?: boolean;
executablePath?: string;
fingerprint?: string;
os?: "windows" | "macos" | "linux";
}
/**
@@ -433,6 +486,11 @@ export async function generateCamoufoxConfig(
launchOpts.allowAddonNewTab = true;
// Add OS option for fingerprint generation
if (options.os) {
launchOpts.os = options.os;
}
// Generate the configuration using launchOptions
const generatedOptions = await launchOptions(launchOpts);
+8
View File
@@ -34,6 +34,10 @@ program
.option("--fingerprint <json>", "fingerprint JSON string")
.option("--headless", "run in headless mode")
.option("--custom-config <json>", "custom config JSON string")
.option(
"--os <os>",
"operating system for fingerprint: windows, macos, linux",
)
.description("manage Camoufox browser instances")
.action(
@@ -284,6 +288,10 @@ program
typeof options.fingerprint === "string"
? options.fingerprint
: undefined,
os:
typeof options.os === "string"
? (options.os as "windows" | "macos" | "linux")
: undefined,
});
console.log(config);
process.exit(0);
+1
View File
@@ -66,6 +66,7 @@ export function parseProxyString(proxyString: LaunchOptions["proxy"] | string) {
// Try parsing as URL first (handles protocol://username:password@host:port)
if (trimmed.includes("://")) {
const url = new URL(trimmed);
// Playwright accepts short form "host:port" for HTTP proxies
server = `${url.hostname}:${url.port}`;
if (url.username) {
+18 -16
View File
@@ -2,7 +2,7 @@
"name": "donutbrowser",
"private": true,
"license": "AGPL-3.0",
"version": "0.12.3",
"version": "0.13.8",
"type": "module",
"scripts": {
"dev": "next dev --turbopack",
@@ -41,7 +41,7 @@
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tanstack/react-table": "^8.21.3",
"@tauri-apps/api": "^2.9.0",
"@tauri-apps/api": "^2.9.1",
"@tauri-apps/plugin-deep-link": "^2.4.5",
"@tauri-apps/plugin-dialog": "^2.4.2",
"@tauri-apps/plugin-fs": "~2.4.4",
@@ -51,31 +51,33 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
"color": "^5.0.2",
"color": "^5.0.3",
"flag-icons": "^7.5.0",
"motion": "^12.23.24",
"next": "^15.5.6",
"lucide-react": "^0.562.0",
"motion": "^12.23.26",
"next": "^16.1.0",
"next-themes": "^0.4.6",
"radix-ui": "^1.4.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-icons": "^5.5.0",
"recharts": "3.6.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tauri-plugin-macos-permissions-api": "^2.3.0"
},
"devDependencies": {
"@biomejs/biome": "2.2.3",
"@tailwindcss/postcss": "^4.1.17",
"@tauri-apps/cli": "^2.9.4",
"@biomejs/biome": "2.3.10",
"@tailwindcss/postcss": "^4.1.18",
"@tauri-apps/cli": "^2.9.6",
"@types/color": "^4.2.0",
"@types/node": "^24.10.0",
"@types/react": "^19.2.3",
"@types/react-dom": "^19.2.2",
"@vitejs/plugin-react": "^5.1.0",
"@types/node": "^25.0.3",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.2",
"husky": "^9.1.7",
"lint-staged": "^16.2.6",
"tailwindcss": "^4.1.17",
"lint-staged": "^16.2.7",
"tailwindcss": "^4.1.18",
"ts-unused-exports": "^11.0.1",
"tw-animate-css": "^1.4.0",
"typescript": "~5.9.3"
+1461 -1150
View File
File diff suppressed because it is too large Load Diff
+282 -244
View File
File diff suppressed because it is too large Load Diff
+10 -6
View File
@@ -1,6 +1,6 @@
[package]
name = "donutbrowser"
version = "0.12.3"
version = "0.13.8"
description = "Simple Yet Powerful Anti-Detect Browser"
authors = ["zhom@github"]
edition = "2021"
@@ -39,6 +39,7 @@ tauri-plugin-dialog = "2"
tauri-plugin-macos-permissions = "2"
tauri-plugin-log = "2"
log = "0.4"
env_logger = "0.11"
directories = "6"
reqwest = { version = "0.12", features = ["json", "stream", "socks"] }
@@ -49,24 +50,26 @@ base64 = "0.22"
libc = "0.2"
async-trait = "0.1"
futures-util = "0.3"
zip = "5"
zip = "7"
tar = "0"
bzip2 = "0"
flate2 = "1"
lzma-rs = "0"
msi-extract = "0"
uuid = { version = "1.18", features = ["v4", "serde"] }
uuid = { version = "1.19", features = ["v4", "serde"] }
url = "2.5"
urlencoding = "2.1"
chrono = { version = "0.4", features = ["serde"] }
axum = "0.8.4"
axum = "0.8.7"
tower = "0.5"
tower-http = { version = "0.6", features = ["cors"] }
rand = "0.9.2"
utoipa = { version = "5", features = ["axum_extras", "chrono"] }
utoipa-axum = "0.2"
argon2 = "0.5"
aes-gcm = "0.10"
hyper = { version = "1.7", features = ["full"] }
hyper = { version = "1.8", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
clap = { version = "4", features = ["derive"] }
@@ -88,6 +91,7 @@ windows = { version = "0.62", features = [
"Win32_System_Threading",
"Win32_System_Diagnostics_Debug",
"Win32_System_SystemInformation",
"Win32_System_IO",
"Win32_Security",
"Win32_Storage_FileSystem",
"Win32_System_Registry",
@@ -97,7 +101,7 @@ windows = { version = "0.62", features = [
[dev-dependencies]
tempfile = "3.21.0"
wiremock = "0.6"
hyper = { version = "1.7", features = ["full"] }
hyper = { version = "1.8", features = ["full"] }
hyper-util = { version = "0.1", features = ["full"] }
http-body-util = "0.1"
tower = "0.5"
+4
View File
@@ -42,6 +42,10 @@ fn main() {
println!("cargo:rerun-if-changed=src/proxy_runner.rs");
println!("cargo:rerun-if-changed=src/proxy_storage.rs");
// Tell Cargo to rebuild when binaries directory contents change
// This ensures tauri_build is re-run after sidecar binaries are copied
println!("cargo:rerun-if-changed=binaries");
// Only run tauri_build if all external binaries exist
// This allows building donut-proxy sidecar without the other binaries present
if external_binaries_exist() {
+10 -1
View File
@@ -3,9 +3,18 @@
"identifier": "default",
"description": "enables the default permissions",
"windows": ["main"],
"webviews": ["main"],
"permissions": [
"core:default",
"core:event:default",
"core:event:allow-listen",
"core:event:allow-emit",
"core:event:allow-emit-to",
"core:event:allow-unlisten",
"core:image:default",
"core:menu:default",
"core:path:default",
"core:tray:default",
"core:webview:default",
"core:window:default",
"core:window:allow-start-dragging",
"core:window:allow-close",
+36 -404
View File
@@ -292,7 +292,6 @@ pub fn is_browser_version_nightly(
// This will be handled in the API parsing, so this fallback is for cached versions
is_nightly_version(version)
}
"mullvad-browser" | "tor-browser" => is_nightly_version(version),
"chromium" => {
// Chromium builds are generally stable snapshots
false
@@ -349,7 +348,6 @@ pub struct ApiClient {
firefox_dev_api_base: String,
github_api_base: String,
chromium_api_base: String,
tor_archive_base: String,
}
impl ApiClient {
@@ -366,7 +364,6 @@ impl ApiClient {
github_api_base: "https://api.github.com".to_string(),
chromium_api_base: "https://commondatastorage.googleapis.com/chromium-browser-snapshots"
.to_string(),
tor_archive_base: "https://archive.torproject.org/tor-package-archive/torbrowser".to_string(),
}
}
@@ -439,7 +436,6 @@ impl ApiClient {
firefox_dev_api_base: String,
github_api_base: String,
chromium_api_base: String,
tor_archive_base: String,
) -> Self {
Self {
client: Client::new(),
@@ -447,7 +443,6 @@ impl ApiClient {
firefox_dev_api_base,
github_api_base,
chromium_api_base,
tor_archive_base,
}
}
@@ -564,7 +559,6 @@ impl ApiClient {
let cached_data: CachedGithubData = serde_json::from_str(&content).ok()?;
// Always use cached GitHub releases - cache never expires, only gets updated with new versions
log::info!("Using cached GitHub releases for {browser}");
Some(cached_data.releases)
}
@@ -724,45 +718,6 @@ impl ApiClient {
Ok(releases)
}
pub async fn fetch_mullvad_releases_with_caching(
&self,
no_caching: bool,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
// Check cache first (unless bypassing)
if !no_caching {
if let Some(cached_releases) = self.load_cached_github_releases("mullvad") {
return Ok(cached_releases);
}
}
log::info!("Fetching Mullvad releases from GitHub API");
let base_url = format!(
"{}/repos/mullvad/mullvad-browser/releases",
self.github_api_base
);
let releases = self.fetch_github_releases_multiple_pages(&base_url).await?;
let mut releases: Vec<GithubRelease> = releases
.into_iter()
.map(|mut release| {
release.is_nightly = release.prerelease;
release
})
.collect();
// Sort releases using the new version sorting system
sort_github_releases(&mut releases);
// Cache the results (unless bypassing cache)
if !no_caching {
if let Err(e) = self.save_cached_github_releases("mullvad", &releases) {
log::error!("Failed to cache Mullvad releases: {e}");
}
}
Ok(releases)
}
pub async fn fetch_zen_releases_with_caching(
&self,
no_caching: bool,
@@ -881,11 +836,11 @@ impl ApiClient {
};
// Look for assets matching the pattern: camoufox-{version}-{release}-{os}.{arch}.zip
// Use ends_with for precise matching to avoid false positives
let pattern = format!(".{os_name}.{arch_name}.zip");
assets.iter().any(|asset| {
let name = asset.name.to_lowercase();
name.starts_with("camoufox-")
&& name.contains(&format!("-{os_name}.{arch_name}.zip"))
&& name.ends_with(".zip")
name.starts_with("camoufox-") && name.ends_with(&pattern)
})
}
@@ -945,13 +900,20 @@ impl ApiClient {
pub async fn fetch_chromium_latest_version(
&self,
) -> Result<String, Box<dyn std::error::Error + Send + Sync>> {
// Use architecture-aware URL for Chromium
let arch = if cfg!(target_arch = "aarch64") {
"Mac_Arm"
} else {
"Mac"
// Use platform-aware URL for Chromium to match download URL generation
let (os, arch) = Self::get_platform_info();
let platform_str = match (&os[..], &arch[..]) {
("windows", "x64") => "Win_x64",
("windows", "arm64") => "Win_Arm64",
("linux", "x64") => "Linux_x64",
("linux", "arm64") => return Err("Chromium doesn't support ARM64 on Linux".into()),
("macos", "x64") => "Mac",
("macos", "arm64") => "Mac_Arm",
_ => {
return Err(format!("Unsupported platform/architecture for Chromium: {os}/{arch}").into())
}
};
let url = format!("{}/{arch}/LAST_CHANGE", self.chromium_api_base);
let url = format!("{}/{platform_str}/LAST_CHANGE", self.chromium_api_base);
let version = self
.client
.get(&url)
@@ -1103,107 +1065,6 @@ impl ApiClient {
Ok(compatible_releases)
}
pub async fn fetch_tor_releases_with_caching(
&self,
no_caching: bool,
) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
// Check cache first (unless bypassing)
if !no_caching {
if let Some(cached_releases) = self.load_cached_versions("tor-browser") {
return Ok(cached_releases);
}
}
log::info!("Fetching TOR releases from archive...");
let url = format!("{}/", self.tor_archive_base);
let html = self
.client
.get(url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.text()
.await?;
// Parse HTML to extract version directories
let mut version_candidates = Vec::new();
// Look for directory links in the HTML
for line in html.lines() {
if line.contains("<a href=\"") && line.contains("/\">") {
// Extract the directory name from the href attribute
if let Some(start) = line.find("<a href=\"") {
let start = start + 9; // Length of "<a href=\""
if let Some(end) = line[start..].find("/\">") {
let version = &line[start..start + end];
// Skip parent directory and non-version entries
if version != ".."
&& !version.is_empty()
&& version.chars().next().unwrap_or('a').is_ascii_digit()
{
version_candidates.push(version.to_string());
}
}
}
}
}
// Sort version candidates using the new version sorting system
sort_versions(&mut version_candidates);
// Only check the first 10 versions to avoid being too slow
let mut version_strings = Vec::new();
for version in version_candidates.into_iter().take(10) {
// Check if this version has a macOS DMG file
if let Ok(has_macos) = self.check_tor_version_has_macos(&version).await {
if has_macos {
version_strings.push(version);
}
}
// Add a small delay to avoid overwhelming the server
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
}
// Convert to BrowserRelease objects
let releases: Vec<BrowserRelease> = version_strings
.into_iter()
.map(|version| BrowserRelease {
version: version.clone(),
date: "".to_string(), // TOR archive doesn't provide structured dates
is_prerelease: false, // Assume all archived versions are stable
})
.collect();
// Cache the results (unless bypassing cache)
if !no_caching {
if let Err(e) = self.save_cached_versions("tor-browser", &releases) {
log::error!("Failed to cache TOR versions: {e}");
}
}
Ok(releases)
}
async fn check_tor_version_has_macos(
&self,
version: &str,
) -> Result<bool, Box<dyn std::error::Error + Send + Sync>> {
let url = format!("{}/{version}/", self.tor_archive_base);
let html = self
.client
.get(&url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await?
.text()
.await?;
// Check if there's a macOS DMG file in this version directory
Ok(html.contains("tor-browser-macos-") && html.contains(".dmg"))
}
/// Check if a Zen twilight release has been updated by comparing file size
pub async fn check_twilight_update(
&self,
@@ -1303,7 +1164,6 @@ mod tests {
base_url.clone(), // firefox_dev_api_base
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
base_url.clone(), // tor_archive_base
)
}
@@ -1527,47 +1387,6 @@ mod tests {
assert_eq!(releases[0].version, "140.0b1");
}
#[tokio::test]
async fn test_mullvad_api() {
let server = setup_mock_server().await;
let client = create_test_client(&server);
let mock_response = r#"[
{
"tag_name": "14.5a6",
"name": "Mullvad Browser 14.5a6",
"prerelease": true,
"published_at": "2024-01-15T10:00:00Z",
"assets": [
{
"name": "mullvad-browser-macos-14.5a6.dmg",
"browser_download_url": "https://example.com/mullvad-14.5a6.dmg",
"size": 100000000
}
]
}
]"#;
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(query_param("per_page", "100"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_response)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let result = client.fetch_mullvad_releases_with_caching(true).await;
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].tag_name, "14.5a6");
assert!(releases[0].is_nightly);
}
#[tokio::test]
async fn test_zen_api() {
let server = setup_mock_server().await;
@@ -1668,14 +1487,19 @@ mod tests {
let server = setup_mock_server().await;
let client = create_test_client(&server);
let arch = if cfg!(target_arch = "aarch64") {
"Mac_Arm"
} else {
"Mac"
let (os, arch) = ApiClient::get_platform_info();
let platform_str = match (&os[..], &arch[..]) {
("windows", "x64") => "Win_x64",
("windows", "arm64") => "Win_Arm64",
("linux", "x64") => "Linux_x64",
("linux", "arm64") => return,
("macos", "x64") => "Mac",
("macos", "arm64") => "Mac_Arm",
_ => return,
};
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(path(format!("/{platform_str}/LAST_CHANGE")))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
@@ -1696,14 +1520,19 @@ mod tests {
let server = setup_mock_server().await;
let client = create_test_client(&server);
let arch = if cfg!(target_arch = "aarch64") {
"Mac_Arm"
} else {
"Mac"
let (os, arch) = ApiClient::get_platform_info();
let platform_str = match (&os[..], &arch[..]) {
("windows", "x64") => "Win_x64",
("windows", "arm64") => "Win_Arm64",
("linux", "x64") => "Linux_x64",
("linux", "arm64") => return,
("macos", "x64") => "Mac",
("macos", "arm64") => "Mac_Arm",
_ => return,
};
Mock::given(method("GET"))
.and(path(format!("/{arch}/LAST_CHANGE")))
.and(path(format!("/{platform_str}/LAST_CHANGE")))
.respond_with(
ResponseTemplate::new(200)
.set_body_string("1465660")
@@ -1721,125 +1550,6 @@ mod tests {
assert!(!releases[0].is_prerelease);
}
#[tokio::test]
async fn test_tor_api() {
let server = setup_mock_server().await;
let client = create_test_client(&server);
let mock_html = r#"
<html>
<body>
<a href="../">../</a>
<a href="14.0.4/">14.0.4/</a>
<a href="14.0.3/">14.0.3/</a>
</body>
</html>
"#;
let version_html = r#"
<html>
<body>
<a href="tor-browser-macos-14.0.4.dmg">tor-browser-macos-14.0.4.dmg</a>
</body>
</html>
"#;
Mock::given(method("GET"))
.and(path("/"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_html)
.insert_header("content-type", "text/html"),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
.insert_header("content-type", "text/html"),
)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/14.0.3/"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html.replace("14.0.4", "14.0.3"))
.insert_header("content-type", "text/html"),
)
.mount(&server)
.await;
let result = client.fetch_tor_releases_with_caching(true).await;
assert!(result.is_ok());
let releases = result.unwrap();
assert!(!releases.is_empty());
assert_eq!(releases[0].version, "14.0.4");
}
#[tokio::test]
async fn test_tor_version_check() {
let server = setup_mock_server().await;
let client = create_test_client(&server);
let version_html = r#"
<html>
<body>
<a href="tor-browser-macos-14.0.4.dmg">tor-browser-macos-14.0.4.dmg</a>
</body>
</html>
"#;
Mock::given(method("GET"))
.and(path("/14.0.4/"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
.insert_header("content-type", "text/html"),
)
.mount(&server)
.await;
let result = client.check_tor_version_has_macos("14.0.4").await;
assert!(result.is_ok());
assert!(result.unwrap());
}
#[tokio::test]
async fn test_tor_version_check_no_macos() {
let server = setup_mock_server().await;
let client = create_test_client(&server);
let version_html = r#"
<html>
<body>
<a href="tor-browser-linux-14.0.4.tar.xz">tor-browser-linux-14.0.4.tar.xz</a>
</body>
</html>
"#;
Mock::given(method("GET"))
.and(path("/14.0.5/"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(version_html)
.insert_header("content-type", "text/html"),
)
.mount(&server)
.await;
let result = client.check_tor_version_has_macos("14.0.5").await;
assert!(result.is_ok());
assert!(!result.unwrap());
}
#[test]
fn test_is_nightly_version() {
assert!(is_nightly_version("1.2.3a1"));
@@ -1911,84 +1621,6 @@ mod tests {
assert!(result.is_err());
}
#[tokio::test]
async fn test_mullvad_pagination_two_pages() {
let server = setup_mock_server().await;
let client = create_test_client(&server);
// Page 1 response with Link: rel="next" header
let mock_page1 = r#"[
{
"tag_name": "100.0",
"name": "Mullvad Browser 100.0",
"prerelease": false,
"published_at": "2024-07-01T00:00:00Z",
"assets": [
{ "name": "mullvad-browser-macos-100.0.dmg", "browser_download_url": "https://example.com/100.0.dmg", "size": 1 }
]
}
]"#;
// Page 2 response
let mock_page2 = r#"[
{
"tag_name": "99.0",
"name": "Mullvad Browser 99.0",
"prerelease": false,
"published_at": "2024-06-01T00:00:00Z",
"assets": [
{ "name": "mullvad-browser-macos-99.0.dmg", "browser_download_url": "https://example.com/99.0.dmg", "size": 1 }
]
}
]"#;
// Mock page 1
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(query_param("per_page", "100"))
.and(query_param("page", "1"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_page1)
.insert_header("content-type", "application/json")
.insert_header(
"link",
format!(
"<{}?per_page=100&page=2>; rel=\"next\", <{}?per_page=100&page=2>; rel=\"last\"",
server.uri().to_string() + "/repos/mullvad/mullvad-browser/releases",
server.uri().to_string() + "/repos/mullvad/mullvad-browser/releases"
),
),
)
.mount(&server)
.await;
// Mock page 2
Mock::given(method("GET"))
.and(path("/repos/mullvad/mullvad-browser/releases"))
.and(query_param("per_page", "100"))
.and(query_param("page", "2"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(mock_page2)
.insert_header("content-type", "application/json"),
)
.mount(&server)
.await;
let result = client.fetch_mullvad_releases_with_caching(true).await;
assert!(result.is_ok());
let releases = result.unwrap();
// We currently only fetch 1 page intentionally; ensure we at least got page 1
assert_eq!(
releases.len(),
1,
"Should fetch only the first page of results"
);
assert_eq!(releases[0].tag_name, "100.0");
}
#[test]
fn test_camoufox_beta_version_parsing() {
// Test specific Camoufox beta versions that are causing issues
+511 -87
View File
@@ -1,3 +1,4 @@
use crate::browser::ProxySettings;
use crate::camoufox_manager::CamoufoxConfig;
use crate::group_manager::GROUP_MANAGER;
use crate::profile::manager::ProfileManager;
@@ -8,7 +9,7 @@ use axum::{
http::{HeaderMap, StatusCode},
middleware::{self, Next},
response::{Json, Response},
routing::{delete, get, post, put},
routing::get,
Router,
};
use lazy_static::lazy_static;
@@ -18,9 +19,11 @@ use tauri::Emitter;
use tokio::net::TcpListener;
use tokio::sync::{mpsc, Mutex};
use tower_http::cors::CorsLayer;
use utoipa::{OpenApi, ToSchema};
use utoipa_axum::{router::OpenApiRouter, routes};
// API Types
#[derive(Debug, Serialize, Deserialize, Clone)]
#[derive(Debug, Serialize, Deserialize, Clone, ToSchema)]
pub struct ApiProfile {
pub id: String,
pub name: String,
@@ -30,42 +33,45 @@ pub struct ApiProfile {
pub process_id: Option<u32>,
pub last_launch: Option<u64>,
pub release_type: String,
#[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Vec<String>,
pub is_running: bool,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiProfilesResponse {
pub profiles: Vec<ApiProfile>,
pub total: usize,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct ApiProfileResponse {
pub profile: ApiProfile,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct CreateProfileRequest {
pub name: String,
pub browser: String,
pub version: String,
pub proxy_id: Option<String>,
pub release_type: Option<String>,
#[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Option<Vec<String>>,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
pub struct UpdateProfileRequest {
pub name: Option<String>,
pub browser: Option<String>,
pub version: Option<String>,
pub proxy_id: Option<String>,
pub release_type: Option<String>,
#[schema(value_type = Object)]
pub camoufox_config: Option<serde_json::Value>,
pub group_id: Option<String>,
pub tags: Option<Vec<String>>,
@@ -76,56 +82,59 @@ struct ApiServerState {
app_handle: tauri::AppHandle,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiGroupResponse {
id: String,
name: String,
profile_count: usize,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct CreateGroupRequest {
name: String,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct UpdateGroupRequest {
name: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct ApiProxyResponse {
id: String,
name: String,
proxy_settings: serde_json::Value,
#[schema(value_type = Object)]
proxy_settings: ProxySettings,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct CreateProxyRequest {
name: String,
proxy_settings: serde_json::Value,
#[schema(value_type = Object)]
proxy_settings: ProxySettings,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct UpdateProxyRequest {
name: Option<String>,
proxy_settings: Option<serde_json::Value>,
#[schema(value_type = Object)]
proxy_settings: Option<ProxySettings>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct DownloadBrowserRequest {
browser: String,
version: String,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
struct DownloadBrowserResponse {
browser: String,
version: String,
status: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ToastPayload {
pub message: String,
pub variant: String,
@@ -133,24 +142,98 @@ pub struct ToastPayload {
pub description: Option<String>,
}
#[derive(Debug, Serialize)]
#[derive(Debug, Serialize, ToSchema)]
struct RunProfileResponse {
profile_id: String,
remote_debugging_port: u16,
headless: bool,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct RunProfileRequest {
url: Option<String>,
headless: Option<bool>,
}
#[derive(Debug, Deserialize)]
#[derive(Debug, Deserialize, ToSchema)]
struct OpenUrlRequest {
url: String,
}
#[derive(OpenApi)]
#[openapi(
paths(
get_profiles,
get_profile,
create_profile,
update_profile,
delete_profile,
run_profile,
open_url_in_profile,
kill_profile,
get_groups,
get_group,
create_group,
update_group,
delete_group,
get_tags,
get_proxies,
get_proxy,
create_proxy,
update_proxy,
delete_proxy,
download_browser_api,
get_browser_versions,
check_browser_downloaded,
),
components(schemas(
ApiProfile,
ApiProfilesResponse,
ApiProfileResponse,
CreateProfileRequest,
UpdateProfileRequest,
ApiGroupResponse,
CreateGroupRequest,
UpdateGroupRequest,
ApiProxyResponse,
CreateProxyRequest,
UpdateProxyRequest,
DownloadBrowserRequest,
DownloadBrowserResponse,
RunProfileResponse,
RunProfileRequest,
OpenUrlRequest,
ProxySettings,
)),
tags(
(name = "profiles", description = "Profile management endpoints"),
(name = "groups", description = "Group management endpoints"),
(name = "tags", description = "Tag management endpoints"),
(name = "proxies", description = "Proxy management endpoints"),
(name = "browsers", description = "Browser management endpoints"),
),
modifiers(&SecurityAddon),
)]
struct ApiDoc;
struct SecurityAddon;
impl utoipa::Modify for SecurityAddon {
fn modify(&self, openapi: &mut utoipa::openapi::OpenApi) {
if let Some(components) = openapi.components.as_mut() {
components.add_security_scheme(
"bearer_auth",
utoipa::openapi::security::SecurityScheme::Http(
utoipa::openapi::security::HttpBuilder::new()
.scheme(utoipa::openapi::security::HttpAuthScheme::Bearer)
.bearer_format("JWT")
.build(),
),
);
}
}
}
pub struct ApiServer {
port: Option<u16>,
shutdown_tx: Option<mpsc::Sender<()>>,
@@ -207,40 +290,44 @@ impl ApiServer {
.map_err(|e| format!("Failed to get local address: {e}"))?
.port();
// Create router with CORS, authentication, and versioning
let v1_routes = Router::new()
.route("/profiles", get(get_profiles))
.route("/profiles", post(create_profile))
.route("/profiles/{id}", get(get_profile))
.route("/profiles/{id}", put(update_profile))
.route("/profiles/{id}", delete(delete_profile))
.route("/profiles/{id}/run", post(run_profile))
.route("/profiles/{id}/open-url", post(open_url_in_profile))
.route("/profiles/{id}/kill", post(kill_profile))
.route("/groups", get(get_groups).post(create_group))
.route(
"/groups/{id}",
get(get_group).put(update_group).delete(delete_group),
)
.route("/tags", get(get_tags))
.route("/proxies", get(get_proxies).post(create_proxy))
.route(
"/proxies/{id}",
get(get_proxy).put(update_proxy).delete(delete_proxy),
)
.route("/browsers/download", post(download_browser_api))
.route("/browsers/{browser}/versions", get(get_browser_versions))
.route(
"/browsers/{browser}/versions/{version}/downloaded",
get(check_browser_downloaded),
)
.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
// Create router with OpenAPI documentation
let (v1_routes, _) = OpenApiRouter::new()
.routes(routes!(
get_profiles,
create_profile,
get_profile,
update_profile,
delete_profile,
run_profile,
open_url_in_profile,
kill_profile,
get_groups,
create_group,
get_group,
update_group,
delete_group,
get_tags,
get_proxies,
create_proxy,
get_proxy,
update_proxy,
delete_proxy,
download_browser_api,
get_browser_versions,
check_browser_downloaded,
))
.split_for_parts();
let api = ApiDoc::openapi();
let v1_routes = v1_routes.layer(middleware::from_fn_with_state(
state.clone(),
auth_middleware,
));
let app = Router::new()
.nest("/v1", v1_routes)
.route("/openapi.json", get(move || async move { Json(api) }))
.layer(CorsLayer::permissive())
.with_state(state);
@@ -346,6 +433,19 @@ pub async fn get_api_server_status() -> Result<Option<u16>, String> {
}
// API Handlers - Profiles
#[utoipa::path(
get,
path = "/v1/profiles",
responses(
(status = 200, description = "List of all profiles", body = ApiProfilesResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
let profile_manager = ProfileManager::instance();
match profile_manager.list_profiles() {
@@ -380,6 +480,23 @@ async fn get_profiles() -> Result<Json<ApiProfilesResponse>, StatusCode> {
}
}
#[utoipa::path(
get,
path = "/v1/profiles/{id}",
params(
("id" = String, Path, description = "Profile ID")
),
responses(
(status = 200, description = "Profile details", body = ApiProfileResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn get_profile(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
@@ -415,6 +532,21 @@ async fn get_profile(
}
}
#[utoipa::path(
post,
path = "/v1/profiles",
request_body = CreateProfileRequest,
responses(
(status = 200, description = "Profile created successfully", body = ApiProfileResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn create_profile(
State(state): State<ApiServerState>,
Json(request): Json<CreateProfileRequest>,
@@ -485,6 +617,25 @@ async fn create_profile(
}
}
#[utoipa::path(
put,
path = "/v1/profiles/{id}",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = UpdateProfileRequest,
responses(
(status = 200, description = "Profile updated successfully", body = ApiProfileResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn update_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -566,6 +717,23 @@ async fn update_profile(
get_profile(Path(id), State(state)).await
}
#[utoipa::path(
delete,
path = "/v1/profiles/{id}",
params(
("id" = String, Path, description = "Profile ID")
),
responses(
(status = 204, description = "Profile deleted successfully"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn delete_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -578,6 +746,19 @@ async fn delete_profile(
}
// API Handlers - Groups
#[utoipa::path(
get,
path = "/v1/groups",
responses(
(status = 200, description = "List of all groups", body = Vec<ApiGroupResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "groups"
)]
async fn get_groups(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<ApiGroupResponse>>, StatusCode> {
@@ -602,6 +783,23 @@ async fn get_groups(
}
}
#[utoipa::path(
get,
path = "/v1/groups/{id}",
params(
("id" = String, Path, description = "Group ID")
),
responses(
(status = 200, description = "Group details", body = ApiGroupResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Group not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "groups"
)]
async fn get_group(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
@@ -625,6 +823,21 @@ async fn get_group(
}
}
#[utoipa::path(
post,
path = "/v1/groups",
request_body = CreateGroupRequest,
responses(
(status = 200, description = "Group created successfully", body = ApiGroupResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "groups"
)]
async fn create_group(
State(state): State<ApiServerState>,
Json(request): Json<CreateGroupRequest>,
@@ -642,6 +855,25 @@ async fn create_group(
}
}
#[utoipa::path(
put,
path = "/v1/groups/{id}",
params(
("id" = String, Path, description = "Group ID")
),
request_body = UpdateGroupRequest,
responses(
(status = 200, description = "Group updated successfully", body = ApiGroupResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Group not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "groups"
)]
async fn update_group(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -660,6 +892,23 @@ async fn update_group(
}
}
#[utoipa::path(
delete,
path = "/v1/groups/{id}",
params(
("id" = String, Path, description = "Group ID")
),
responses(
(status = 204, description = "Group deleted successfully"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "groups"
)]
async fn delete_group(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -674,6 +923,19 @@ async fn delete_group(
}
// API Handlers - Tags
#[utoipa::path(
get,
path = "/v1/tags",
responses(
(status = 200, description = "List of all tags", body = Vec<String>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "tags"
)]
async fn get_tags(State(_state): State<ApiServerState>) -> Result<Json<Vec<String>>, StatusCode> {
match TAG_MANAGER.lock() {
Ok(manager) => match manager.get_all_tags() {
@@ -685,6 +947,19 @@ async fn get_tags(State(_state): State<ApiServerState>) -> Result<Json<Vec<Strin
}
// API Handlers - Proxies
#[utoipa::path(
get,
path = "/v1/proxies",
responses(
(status = 200, description = "List of all proxies", body = Vec<ApiProxyResponse>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "proxies"
)]
async fn get_proxies(
State(_state): State<ApiServerState>,
) -> Result<Json<Vec<ApiProxyResponse>>, StatusCode> {
@@ -695,12 +970,29 @@ async fn get_proxies(
.map(|p| ApiProxyResponse {
id: p.id,
name: p.name,
proxy_settings: serde_json::to_value(p.proxy_settings).unwrap_or_default(),
proxy_settings: p.proxy_settings,
})
.collect(),
))
}
#[utoipa::path(
get,
path = "/v1/proxies/{id}",
params(
("id" = String, Path, description = "Proxy ID")
),
responses(
(status = 200, description = "Proxy details", body = ApiProxyResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Proxy not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "proxies"
)]
async fn get_proxy(
Path(id): Path<String>,
State(_state): State<ApiServerState>,
@@ -710,45 +1002,65 @@ async fn get_proxy(
Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
proxy_settings: serde_json::to_value(proxy.proxy_settings).unwrap_or_default(),
proxy_settings: proxy.proxy_settings,
}))
} else {
Err(StatusCode::NOT_FOUND)
}
}
#[utoipa::path(
post,
path = "/v1/proxies",
request_body = CreateProxyRequest,
responses(
(status = 200, description = "Proxy created successfully", body = ApiProxyResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "proxies"
)]
async fn create_proxy(
State(state): State<ApiServerState>,
Json(request): Json<CreateProxyRequest>,
) -> Result<Json<ApiProxyResponse>, StatusCode> {
// Convert JSON value to ProxySettings
match serde_json::from_value(request.proxy_settings.clone()) {
Ok(proxy_settings) => {
match PROXY_MANAGER.create_stored_proxy(
&state.app_handle,
request.name.clone(),
proxy_settings,
) {
Ok(_) => {
// Find the created proxy to return it
let proxies = PROXY_MANAGER.get_stored_proxies();
if let Some(proxy) = proxies.into_iter().find(|p| p.name == request.name) {
Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
proxy_settings: request.proxy_settings,
}))
} else {
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
match PROXY_MANAGER.create_stored_proxy(
&state.app_handle,
request.name.clone(),
request.proxy_settings,
) {
Ok(proxy) => Ok(Json(ApiProxyResponse {
id: proxy.id,
name: proxy.name,
proxy_settings: proxy.proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
}
}
#[utoipa::path(
put,
path = "/v1/proxies/{id}",
params(
("id" = String, Path, description = "Proxy ID")
),
request_body = UpdateProxyRequest,
responses(
(status = 200, description = "Proxy updated successfully", body = ApiProxyResponse),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Proxy not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "proxies"
)]
async fn update_proxy(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -757,14 +1069,9 @@ async fn update_proxy(
let proxies = PROXY_MANAGER.get_stored_proxies();
if let Some(proxy) = proxies.into_iter().find(|p| p.id == id) {
let new_name = request.name.unwrap_or(proxy.name.clone());
let new_proxy_settings = if let Some(settings_json) = request.proxy_settings {
match serde_json::from_value(settings_json) {
Ok(settings) => settings,
Err(_) => return Err(StatusCode::BAD_REQUEST),
}
} else {
proxy.proxy_settings.clone()
};
let new_proxy_settings = request
.proxy_settings
.unwrap_or(proxy.proxy_settings.clone());
match PROXY_MANAGER.update_stored_proxy(
&state.app_handle,
@@ -775,7 +1082,7 @@ async fn update_proxy(
Ok(_) => Ok(Json(ApiProxyResponse {
id,
name: new_name,
proxy_settings: serde_json::to_value(new_proxy_settings).unwrap_or_default(),
proxy_settings: new_proxy_settings,
})),
Err(_) => Err(StatusCode::BAD_REQUEST),
}
@@ -784,6 +1091,23 @@ async fn update_proxy(
}
}
#[utoipa::path(
delete,
path = "/v1/proxies/{id}",
params(
("id" = String, Path, description = "Proxy ID")
),
responses(
(status = 204, description = "Proxy deleted successfully"),
(status = 400, description = "Bad request"),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "proxies"
)]
async fn delete_proxy(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -795,6 +1119,24 @@ async fn delete_proxy(
}
// API Handler - Run Profile with Remote Debugging
#[utoipa::path(
post,
path = "/v1/profiles/{id}/run",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = RunProfileRequest,
responses(
(status = 200, description = "Profile launched successfully", body = RunProfileResponse),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn run_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -836,6 +1178,24 @@ async fn run_profile(
}
// API Handler - Open URL in existing browser
#[utoipa::path(
post,
path = "/v1/profiles/{id}/open-url",
params(
("id" = String, Path, description = "Profile ID")
),
request_body = OpenUrlRequest,
responses(
(status = 200, description = "URL opened successfully"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn open_url_in_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -852,6 +1212,23 @@ async fn open_url_in_profile(
}
// API Handler - Kill browser process
#[utoipa::path(
post,
path = "/v1/profiles/{id}/kill",
params(
("id" = String, Path, description = "Profile ID")
),
responses(
(status = 204, description = "Browser process killed successfully"),
(status = 401, description = "Unauthorized"),
(status = 404, description = "Profile not found"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "profiles"
)]
async fn kill_profile(
Path(id): Path<String>,
State(state): State<ApiServerState>,
@@ -876,6 +1253,20 @@ async fn kill_profile(
}
// API Handler - Download Browser
#[utoipa::path(
post,
path = "/v1/browsers/download",
request_body = DownloadBrowserRequest,
responses(
(status = 200, description = "Browser download initiated", body = DownloadBrowserResponse),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "browsers"
)]
async fn download_browser_api(
State(state): State<ApiServerState>,
Json(request): Json<DownloadBrowserRequest>,
@@ -897,6 +1288,22 @@ async fn download_browser_api(
}
// API Handler - Get Browser Versions
#[utoipa::path(
get,
path = "/v1/browsers/{browser}/versions",
params(
("browser" = String, Path, description = "Browser name")
),
responses(
(status = 200, description = "List of available browser versions", body = Vec<String>),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "browsers"
)]
async fn get_browser_versions(
Path(browser): Path<String>,
State(_state): State<ApiServerState>,
@@ -913,6 +1320,23 @@ async fn get_browser_versions(
}
// API Handler - Check if Browser is Downloaded
#[utoipa::path(
get,
path = "/v1/browsers/{browser}/versions/{version}/downloaded",
params(
("browser" = String, Path, description = "Browser name"),
("version" = String, Path, description = "Browser version")
),
responses(
(status = 200, description = "Browser download status", body = bool),
(status = 401, description = "Unauthorized"),
(status = 500, description = "Internal server error")
),
security(
("bearer_auth" = [])
),
tag = "browsers"
)]
async fn check_browser_downloaded(
Path((browser, version)): Path<(String, String)>,
State(_state): State<ApiServerState>,
+33 -1
View File
@@ -784,6 +784,20 @@ impl AppAutoUpdater {
) -> Result<PathBuf, Box<dyn std::error::Error + Send + Sync>> {
let file_path = dest_dir.join(filename);
// First, try to get the file size via HEAD request
// This is more reliable than GET content-length for some CDN configurations
// especially when dealing with redirects (like GitHub releases)
let head_size = self
.client
.head(download_url)
.header("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36")
.send()
.await
.ok()
.and_then(|r| r.content_length());
log::info!("HEAD request for download size: {:?} bytes", head_size);
let response = self
.client
.get(download_url)
@@ -795,7 +809,9 @@ impl AppAutoUpdater {
return Err(format!("Download failed with status: {}", response.status()).into());
}
let total_size = response.content_length().unwrap_or(0);
// Use HEAD size if available, otherwise fall back to GET content-length
let total_size = head_size.or(response.content_length()).unwrap_or(0);
log::info!("Final download size: {} bytes", total_size);
let mut file = fs::File::create(&file_path)?;
let mut stream = response.bytes_stream();
let mut downloaded = 0u64;
@@ -999,6 +1015,22 @@ impl AppAutoUpdater {
// Clean up backup after successful installation
let _ = fs::remove_dir_all(&backup_path);
// Clean up old "Donut Browser.app" if it exists (from before the project rename)
if let Some(parent_dir) = current_app_path.parent() {
let old_app_path = parent_dir.join("Donut Browser.app");
if old_app_path.exists() && old_app_path != current_app_path {
log::info!(
"Removing old 'Donut Browser.app' from: {}",
old_app_path.display()
);
if let Err(e) = fs::remove_dir_all(&old_app_path) {
log::warn!("Warning: Failed to remove old 'Donut Browser.app': {e}");
} else {
log::info!("Successfully removed old 'Donut Browser.app'");
}
}
}
Ok(())
}
+1
View File
@@ -517,6 +517,7 @@ mod tests {
camoufox_config: None,
group_id: None,
tags: Vec::new(),
note: None,
}
}
+66 -2
View File
@@ -1,11 +1,60 @@
use clap::{Arg, Command};
use donutbrowser_lib::proxy_runner::{
start_proxy_process, stop_all_proxy_processes, stop_proxy_process,
start_proxy_process_with_profile, stop_all_proxy_processes, stop_proxy_process,
};
use donutbrowser_lib::proxy_server::run_proxy_server;
use donutbrowser_lib::proxy_storage::get_proxy_config;
use std::process;
fn set_high_priority() {
#[cfg(unix)]
{
unsafe {
// Set high priority (negative nice value = higher priority)
// -10 is a reasonably high priority without being too aggressive
// This may fail without elevated privileges, which is fine
let result = libc::setpriority(libc::PRIO_PROCESS, 0, -10);
if result == 0 {
log::info!("Set process priority to -10 (high priority)");
} else {
// Try a less aggressive priority if -10 fails
let result = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
if result == 0 {
log::info!("Set process priority to -5 (above normal)");
}
}
}
}
#[cfg(target_os = "linux")]
{
// Lower OOM score so this process is less likely to be killed under memory pressure
// Valid range is -1000 to 1000, lower = less likely to be killed
// -500 is a reasonable value that makes us less likely to be killed
if let Err(e) = std::fs::write("/proc/self/oom_score_adj", "-500") {
log::debug!("Could not set OOM score adjustment: {}", e);
} else {
log::info!("Set OOM score adjustment to -500");
}
}
#[cfg(windows)]
{
use windows::Win32::System::Threading::{
GetCurrentProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS,
};
unsafe {
let process = GetCurrentProcess();
if SetPriorityClass(process, ABOVE_NORMAL_PRIORITY_CLASS).is_ok() {
log::info!("Set process priority to ABOVE_NORMAL_PRIORITY_CLASS");
} else {
log::debug!("Could not set process priority class");
}
}
}
}
fn build_proxy_url(
proxy_type: &str,
host: &str,
@@ -33,6 +82,12 @@ fn build_proxy_url(
#[tokio::main(flavor = "multi_thread")]
async fn main() {
// Initialize logger to write to stderr (which will be redirected to file)
env_logger::Builder::from_default_env()
.filter_level(log::LevelFilter::Debug)
.format_timestamp_millis()
.init();
// Set up panic handler to log panics before process exits
std::panic::set_hook(Box::new(|panic_info| {
log::error!("PANIC in proxy worker: {:?}", panic_info);
@@ -87,6 +142,11 @@ async fn main() {
.short('u')
.long("upstream")
.help("Upstream proxy URL (protocol://[username:password@]host:port)"),
)
.arg(
Arg::new("profile-id")
.long("profile-id")
.help("ID of the profile this proxy is associated with"),
),
)
.subcommand(
@@ -138,8 +198,9 @@ async fn main() {
}
let port = start_matches.get_one::<u16>("port").copied();
let profile_id = start_matches.get_one::<String>("profile-id").cloned();
match start_proxy_process(upstream_url, port).await {
match start_proxy_process_with_profile(upstream_url, port, profile_id).await {
Ok(config) => {
// Output the configuration as JSON for the Rust side to parse
// Use println! here because this needs to go to stdout for parsing
@@ -224,6 +285,9 @@ async fn main() {
.expect("action is required");
if action == "start" {
// Set high priority so this process is killed last under resource pressure
set_high_priority();
log::error!("Proxy worker starting, looking for config id: {}", id);
log::error!("Process PID: {}", std::process::id());
+120 -100
View File
@@ -1,7 +1,8 @@
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
use utoipa::ToSchema;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct ProxySettings {
pub proxy_type: String, // "http", "https", "socks4", or "socks5"
pub host: String,
@@ -12,39 +13,33 @@ pub struct ProxySettings {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum BrowserType {
MullvadBrowser,
Chromium,
Firefox,
FirefoxDeveloper,
Brave,
Zen,
TorBrowser,
Camoufox,
}
impl BrowserType {
pub fn as_str(&self) -> &'static str {
match self {
BrowserType::MullvadBrowser => "mullvad-browser",
BrowserType::Chromium => "chromium",
BrowserType::Firefox => "firefox",
BrowserType::FirefoxDeveloper => "firefox-developer",
BrowserType::Brave => "brave",
BrowserType::Zen => "zen",
BrowserType::TorBrowser => "tor-browser",
BrowserType::Camoufox => "camoufox",
}
}
pub fn from_str(s: &str) -> Result<Self, String> {
match s {
"mullvad-browser" => Ok(BrowserType::MullvadBrowser),
"chromium" => Ok(BrowserType::Chromium),
"firefox" => Ok(BrowserType::Firefox),
"firefox-developer" => Ok(BrowserType::FirefoxDeveloper),
"brave" => Ok(BrowserType::Brave),
"zen" => Ok(BrowserType::Zen),
"tor-browser" => Ok(BrowserType::TorBrowser),
"camoufox" => Ok(BrowserType::Camoufox),
_ => Err(format!("Unknown browser type: {s}")),
}
@@ -84,21 +79,120 @@ mod macos {
executable_dir.push("Contents");
executable_dir.push("MacOS");
// Find the first executable in the MacOS directory
let executable_path = std::fs::read_dir(&executable_dir)?
// Find executables matching the browser name pattern
let candidates: Vec<_> = std::fs::read_dir(&executable_dir)?
.filter_map(Result::ok)
.find(|entry| {
.filter(|entry| {
let binding = entry.file_name();
let name = binding.to_string_lossy();
name.starts_with("firefox")
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.starts_with("camoufox")
|| name.contains("Browser")
})
.map(|entry| entry.path())
.ok_or("No executable found in MacOS directory")?;
.collect();
if candidates.is_empty() {
return Err("No executable found in MacOS directory".into());
}
// For Camoufox, validate architecture compatibility
let executable_path = if candidates.iter().any(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| n.starts_with("camoufox"))
.unwrap_or(false)
}) {
// Find the executable that matches the current architecture
let current_arch = if cfg!(target_arch = "x86_64") {
"x86_64"
} else if cfg!(target_arch = "aarch64") {
"arm64"
} else {
return Err("Unsupported architecture".into());
};
// Try to find an executable that matches the current architecture
// Use file command to check architecture
let mut found_executable = None;
let mut file_command_available = true;
for candidate in &candidates {
match std::process::Command::new("file").arg(candidate).output() {
Ok(output) => {
if output.status.success() {
if let Ok(output_str) = String::from_utf8(output.stdout) {
let is_compatible = if current_arch == "x86_64" {
output_str.contains("x86_64") || output_str.contains("i386")
} else {
output_str.contains("arm64") || output_str.contains("aarch64")
};
if is_compatible {
found_executable = Some(candidate.clone());
log::info!(
"Found compatible Camoufox executable for {}: {}",
current_arch,
candidate.display()
);
break;
} else {
log::warn!(
"Skipping incompatible Camoufox executable: {} (architecture: {})",
candidate.display(),
output_str.trim()
);
}
}
} else {
log::warn!(
"Failed to check architecture for {}: file command returned non-zero exit code",
candidate.display()
);
}
}
Err(e) => {
log::warn!(
"Failed to check architecture for {} using file command: {}",
candidate.display(),
e
);
file_command_available = false;
// Continue checking other candidates
}
}
}
// If no compatible executable found but we have candidates, use the first one
// (fallback for cases where file command isn't available or failed)
if found_executable.is_none() && !candidates.is_empty() {
if !file_command_available {
log::warn!(
"file command not available, using first candidate: {}",
candidates[0].display()
);
} else {
log::warn!(
"No compatible executable found for architecture {}, using first candidate: {}",
current_arch,
candidates[0].display()
);
}
found_executable = Some(candidates[0].clone());
}
found_executable.ok_or_else(|| {
format!(
"No compatible Camoufox executable found for architecture {}. Available executables: {:?}",
current_arch,
candidates
)
})?
} else {
// For other browsers, use the first matching executable
candidates[0].clone()
};
Ok(executable_path)
}
@@ -189,28 +283,9 @@ mod linux {
browser_subdir.join("firefox"),
browser_subdir.join("firefox-bin"),
],
BrowserType::MullvadBrowser => {
vec![
browser_subdir.join("firefox"),
browser_subdir.join("mullvad-browser"),
browser_subdir.join("firefox-bin"),
]
}
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::TorBrowser => {
vec![
// Common Tor Browser launchers
browser_subdir.join("tor-browser"),
// Firefox-based binaries
browser_subdir.join("firefox"),
browser_subdir.join("firefox-bin"),
// Sometimes packaged similarly to Firefox
install_dir.join("firefox").join("firefox"),
install_dir.join("firefox").join("firefox-bin"),
]
}
BrowserType::Camoufox => {
vec![
install_dir.join("camoufox-bin"),
@@ -302,23 +377,9 @@ mod linux {
install_dir.join("firefox").join("firefox"),
]
}
BrowserType::MullvadBrowser => {
vec![
browser_subdir.join("mullvad-browser"),
browser_subdir.join("firefox-bin"),
browser_subdir.join("firefox"),
]
}
BrowserType::Zen => {
vec![browser_subdir.join("zen"), browser_subdir.join("zen-bin")]
}
BrowserType::TorBrowser => {
vec![
browser_subdir.join("tor-browser"),
browser_subdir.join("firefox-bin"),
browser_subdir.join("firefox"),
]
}
BrowserType::Camoufox => {
vec![
install_dir.join("camoufox-bin"),
@@ -423,9 +484,7 @@ mod windows {
if path.extension().is_some_and(|ext| ext == "exe") {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if name.starts_with("firefox")
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.starts_with("camoufox")
|| name.contains("browser")
{
@@ -509,9 +568,7 @@ mod windows {
if path.extension().is_some_and(|ext| ext == "exe") {
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if name.starts_with("firefox")
|| name.starts_with("mullvad")
|| name.starts_with("zen")
|| name.starts_with("tor")
|| name.starts_with("camoufox")
|| name.contains("browser")
{
@@ -623,22 +680,9 @@ impl Browser for FirefoxBrowser {
args.push("--headless".to_string());
}
// Use -no-remote for browsers that require it for security (Mullvad, Tor) or when remote debugging
match self.browser_type {
BrowserType::MullvadBrowser | BrowserType::TorBrowser => {
args.push("-no-remote".to_string());
}
BrowserType::Firefox
| BrowserType::FirefoxDeveloper
| BrowserType::Zen
| BrowserType::Camoufox => {
// Use -no-remote when remote debugging to avoid conflicts
if remote_debugging_port.is_some() {
args.push("-no-remote".to_string());
}
// Don't use -no-remote for normal launches so we can communicate with existing instances
}
_ => {}
// Use -no-remote when remote debugging to avoid conflicts with existing instances
if remote_debugging_port.is_some() {
args.push("-no-remote".to_string());
}
// Firefox-based browsers use profile directory and user.js for proxy configuration
@@ -736,6 +780,12 @@ impl Browser for ChromiumBrowser {
"--disable-background-timer-throttling".to_string(),
"--crash-server-url=".to_string(),
"--disable-updater".to_string(),
// Disable quit confirmation and session restore prompts
"--disable-session-crashed-bubble".to_string(),
"--hide-crash-restore-bubble".to_string(),
"--disable-infobars".to_string(),
// Disable QUIC/HTTP3 to ensure traffic goes through HTTP proxy
"--disable-quic".to_string(),
];
// Add remote debugging if requested
@@ -909,11 +959,9 @@ impl BrowserFactory {
pub fn create_browser(&self, browser_type: BrowserType) -> Box<dyn Browser> {
match browser_type {
BrowserType::MullvadBrowser
| BrowserType::Firefox
| BrowserType::FirefoxDeveloper
| BrowserType::Zen
| BrowserType::TorBrowser => Box::new(FirefoxBrowser::new(browser_type)),
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen => {
Box::new(FirefoxBrowser::new(browser_type))
}
BrowserType::Chromium | BrowserType::Brave => Box::new(ChromiumBrowser::new(browser_type)),
BrowserType::Camoufox => Box::new(CamoufoxBrowser::new()),
}
@@ -991,20 +1039,14 @@ mod tests {
#[test]
fn test_browser_type_conversions() {
// Test as_str
assert_eq!(BrowserType::MullvadBrowser.as_str(), "mullvad-browser");
assert_eq!(BrowserType::Firefox.as_str(), "firefox");
assert_eq!(BrowserType::FirefoxDeveloper.as_str(), "firefox-developer");
assert_eq!(BrowserType::Chromium.as_str(), "chromium");
assert_eq!(BrowserType::Brave.as_str(), "brave");
assert_eq!(BrowserType::Zen.as_str(), "zen");
assert_eq!(BrowserType::TorBrowser.as_str(), "tor-browser");
assert_eq!(BrowserType::Camoufox.as_str(), "camoufox");
// Test from_str - use expect with descriptive messages instead of unwrap
assert_eq!(
BrowserType::from_str("mullvad-browser").expect("mullvad-browser should be valid"),
BrowserType::MullvadBrowser
);
assert_eq!(
BrowserType::from_str("firefox").expect("firefox should be valid"),
BrowserType::Firefox
@@ -1025,10 +1067,6 @@ mod tests {
BrowserType::from_str("zen").expect("zen should be valid"),
BrowserType::Zen
);
assert_eq!(
BrowserType::from_str("tor-browser").expect("tor-browser should be valid"),
BrowserType::TorBrowser
);
assert_eq!(
BrowserType::from_str("camoufox").expect("camoufox should be valid"),
BrowserType::Camoufox
@@ -1095,30 +1133,12 @@ mod tests {
"Firefox should include debugging port"
);
// Test Mullvad Browser (should always use -no-remote)
let browser = FirefoxBrowser::new(BrowserType::MullvadBrowser);
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Mullvad Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
// Test Tor Browser (should always use -no-remote)
let browser = FirefoxBrowser::new(BrowserType::TorBrowser);
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Tor Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile", "-no-remote"]);
// Test Zen Browser (should not use -no-remote for normal launch)
// Test Zen Browser (no special flags without remote debugging)
let browser = FirefoxBrowser::new(BrowserType::Zen);
let args = browser
.create_launch_args("/path/to/profile", None, None, None, false)
.expect("Failed to create launch args for Zen Browser");
assert_eq!(args, vec!["-profile", "/path/to/profile"]);
assert!(
!args.contains(&"-no-remote".to_string()),
"Zen Browser should not use -no-remote for normal launch"
);
// Test headless mode
let args = browser
+409 -253
View File
@@ -33,29 +33,6 @@ impl BrowserRunner {
&BROWSER_RUNNER
}
// Helper function to check if a process matches TOR/Mullvad browser
fn is_tor_or_mullvad_browser(
&self,
exe_name: &str,
cmd: &[std::ffi::OsString],
browser_type: &str,
) -> bool {
#[cfg(target_os = "macos")]
return platform_browser::macos::is_tor_or_mullvad_browser(exe_name, cmd, browser_type);
#[cfg(target_os = "windows")]
return platform_browser::windows::is_tor_or_mullvad_browser(exe_name, cmd, browser_type);
#[cfg(target_os = "linux")]
return platform_browser::linux::is_tor_or_mullvad_browser(exe_name, cmd, browser_type);
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = (exe_name, cmd, browser_type);
false
}
}
pub fn get_binaries_dir(&self) -> PathBuf {
let mut path = self.base_dirs.data_local_dir().to_path_buf();
path.push(if cfg!(debug_assertions) {
@@ -149,12 +126,13 @@ impl BrowserRunner {
// Start the proxy and get local proxy settings
// If proxy startup fails, DO NOT launch Camoufox - it requires local proxy
let profile_id_str = profile.id.to_string();
let local_proxy = PROXY_MANAGER
.start_proxy(
app_handle.clone(),
upstream_proxy.as_ref(),
0, // Use 0 as temporary PID, will be updated later
Some(&profile.name),
Some(&profile_id_str),
)
.await
.map_err(|e| {
@@ -214,6 +192,10 @@ impl BrowserRunner {
updated_camoufox_config.fingerprint = Some(new_fingerprint);
// Preserve the randomize flag so it persists across launches
updated_camoufox_config.randomize_fingerprint_on_launch = Some(true);
// Preserve the OS setting so it's used for future fingerprint generation
if camoufox_config.os.is_some() {
updated_camoufox_config.os = camoufox_config.os.clone();
}
updated_profile.camoufox_config = Some(updated_camoufox_config.clone());
log::info!(
@@ -390,148 +372,117 @@ impl BrowserRunner {
profile.id
);
// For TOR and Mullvad browsers, we need to find the actual browser process
// because they use launcher scripts that spawn the real browser process
let mut actual_pid = launcher_pid;
if matches!(
browser_type,
BrowserType::TorBrowser | BrowserType::MullvadBrowser
) {
// Wait a moment for the actual browser process to start
tokio::time::sleep(tokio::time::Duration::from_millis(3000)).await;
// Find the actual browser process
let system = System::new_all();
for (pid, process) in system.processes() {
let process_name = process.name().to_str().unwrap_or("");
let process_cmd = process.cmd();
let pid_u32 = pid.as_u32();
// Skip if this is the launcher process itself
if pid_u32 == launcher_pid {
continue;
}
if self.is_tor_or_mullvad_browser(process_name, process_cmd, &profile.browser) {
log::info!(
"Found actual {} browser process: PID {} ({})",
profile.browser,
pid_u32,
process_name
);
actual_pid = pid_u32;
break;
}
}
}
// On macOS, when launching via `open -a`, the child PID is the `open` helper.
// Resolve and store the actual browser PID for all browser types.
#[cfg(target_os = "macos")]
{
// Give the browser a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
let actual_pid = {
#[cfg(target_os = "macos")]
{
// Give the browser a moment to start
tokio::time::sleep(tokio::time::Duration::from_millis(1500)).await;
let system = System::new_all();
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_data_path_str = profile_data_path.to_string_lossy();
let system = System::new_all();
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_data_path = profile.get_profile_data_path(&profiles_dir);
let profile_data_path_str = profile_data_path.to_string_lossy();
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.is_empty() {
continue;
}
let mut resolved_pid = launcher_pid;
// Determine if this process matches the intended browser type
let exe_name_lower = process.name().to_string_lossy().to_lowercase();
let is_correct_browser = match profile.browser.as_str() {
"firefox" => {
exe_name_lower.contains("firefox")
&& !exe_name_lower.contains("developer")
&& !exe_name_lower.contains("tor")
&& !exe_name_lower.contains("mullvad")
&& !exe_name_lower.contains("camoufox")
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.is_empty() {
continue;
}
"firefox-developer" => {
// More flexible detection for Firefox Developer Edition
(exe_name_lower.contains("firefox") && exe_name_lower.contains("developer"))
|| (exe_name_lower.contains("firefox")
&& cmd.iter().any(|arg| {
let arg_str = arg.to_str().unwrap_or("");
arg_str.contains("Developer")
|| arg_str.contains("developer")
|| arg_str.contains("FirefoxDeveloperEdition")
|| arg_str.contains("firefox-developer")
}))
|| exe_name_lower == "firefox" // Firefox Developer might just show as "firefox"
}
"mullvad-browser" => {
self.is_tor_or_mullvad_browser(&exe_name_lower, cmd, "mullvad-browser")
}
"tor-browser" => self.is_tor_or_mullvad_browser(&exe_name_lower, cmd, "tor-browser"),
"zen" => exe_name_lower.contains("zen"),
"chromium" => exe_name_lower.contains("chromium") || exe_name_lower.contains("chrome"),
"brave" => exe_name_lower.contains("brave") || exe_name_lower.contains("Brave"),
_ => false,
};
if !is_correct_browser {
continue;
}
// Determine if this process matches the intended browser type
let exe_name_lower = process.name().to_string_lossy().to_lowercase();
let is_correct_browser = match profile.browser.as_str() {
"firefox" => {
exe_name_lower.contains("firefox")
&& !exe_name_lower.contains("developer")
&& !exe_name_lower.contains("camoufox")
}
"firefox-developer" => {
// More flexible detection for Firefox Developer Edition
(exe_name_lower.contains("firefox") && exe_name_lower.contains("developer"))
|| (exe_name_lower.contains("firefox")
&& cmd.iter().any(|arg| {
let arg_str = arg.to_str().unwrap_or("");
arg_str.contains("Developer")
|| arg_str.contains("developer")
|| arg_str.contains("FirefoxDeveloperEdition")
|| arg_str.contains("firefox-developer")
}))
|| exe_name_lower == "firefox" // Firefox Developer might just show as "firefox"
}
"zen" => exe_name_lower.contains("zen"),
"chromium" => exe_name_lower.contains("chromium") || exe_name_lower.contains("chrome"),
"brave" => exe_name_lower.contains("brave") || exe_name_lower.contains("Brave"),
_ => false,
};
// Check for profile path match
let profile_path_match = if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "tor-browser" | "mullvad-browser" | "zen"
) {
// Firefox-based browsers: look for -profile argument followed by path
let mut found_profile_arg = false;
for (i, arg) in cmd.iter().enumerate() {
if let Some(arg_str) = arg.to_str() {
if arg_str == "-profile" && i + 1 < cmd.len() {
if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) {
if next_arg == profile_data_path_str {
found_profile_arg = true;
break;
if !is_correct_browser {
continue;
}
// Check for profile path match
let profile_path_match = if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "zen"
) {
// Firefox-based browsers: look for -profile argument followed by path
let mut found_profile_arg = false;
for (i, arg) in cmd.iter().enumerate() {
if let Some(arg_str) = arg.to_str() {
if arg_str == "-profile" && i + 1 < cmd.len() {
if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) {
if next_arg == profile_data_path_str {
found_profile_arg = true;
break;
}
}
}
}
// Also check for combined -profile=path format
if arg_str == format!("-profile={profile_data_path_str}") {
found_profile_arg = true;
break;
}
// Check if the argument is the profile path directly
if arg_str == profile_data_path_str {
found_profile_arg = true;
break;
// Also check for combined -profile=path format
if arg_str == format!("-profile={profile_data_path_str}") {
found_profile_arg = true;
break;
}
// Check if the argument is the profile path directly
if arg_str == profile_data_path_str {
found_profile_arg = true;
break;
}
}
}
}
found_profile_arg
} else {
// Chromium-based browsers: look for --user-data-dir argument
cmd.iter().any(|s| {
if let Some(arg) = s.to_str() {
arg == format!("--user-data-dir={profile_data_path_str}")
|| arg == profile_data_path_str
} else {
false
}
})
};
found_profile_arg
} else {
// Chromium-based browsers: look for --user-data-dir argument
cmd.iter().any(|s| {
if let Some(arg) = s.to_str() {
arg == format!("--user-data-dir={profile_data_path_str}")
|| arg == profile_data_path_str
} else {
false
}
})
};
if profile_path_match {
let pid_u32 = pid.as_u32();
if pid_u32 != launcher_pid {
actual_pid = pid_u32;
break;
if profile_path_match {
let pid_u32 = pid.as_u32();
if pid_u32 != launcher_pid {
resolved_pid = pid_u32;
break;
}
}
}
resolved_pid
}
}
#[cfg(not(target_os = "macos"))]
{
launcher_pid
}
};
// Update profile with process info and save
let mut updated_profile = profile.clone();
@@ -547,11 +498,7 @@ impl BrowserRunner {
if profile.proxy_id.is_some()
&& matches!(
browser_type,
BrowserType::Firefox
| BrowserType::FirefoxDeveloper
| BrowserType::Zen
| BrowserType::TorBrowser
| BrowserType::MullvadBrowser
BrowserType::Firefox | BrowserType::FirefoxDeveloper | BrowserType::Zen
)
{
// Proxy settings for Firefox-based browsers are applied via user.js file
@@ -709,48 +656,9 @@ impl BrowserRunner {
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform".into());
}
BrowserType::MullvadBrowser | BrowserType::TorBrowser => {
#[cfg(target_os = "macos")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::macos::open_url_in_existing_browser_tor_mullvad(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "windows")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::windows::open_url_in_existing_browser_tor_mullvad(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(target_os = "linux")]
{
let profiles_dir = self.profile_manager.get_profiles_dir();
return platform_browser::linux::open_url_in_existing_browser_tor_mullvad(
&updated_profile,
url,
browser_type,
&browser_dir,
&profiles_dir,
)
.await;
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform".into());
BrowserType::Camoufox => {
// Camoufox uses nodecar for launching, URL opening is handled differently
Err("URL opening in existing Camoufox instance is not supported".into())
}
BrowserType::Chromium | BrowserType::Brave => {
#[cfg(target_os = "macos")]
@@ -795,10 +703,6 @@ impl BrowserRunner {
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
return Err("Unsupported platform".into());
}
BrowserType::Camoufox => {
// This should never be reached due to the early return above, but handle it just in case
Err("Camoufox URL opening should be handled in the early return above".into())
}
}
}
@@ -819,6 +723,7 @@ impl BrowserRunner {
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
let profile_id_str = profile.id.to_string();
// Start local proxy - if this fails, DO NOT launch browser
let internal_proxy = PROXY_MANAGER
@@ -826,7 +731,7 @@ impl BrowserRunner {
app_handle.clone(),
upstream_proxy.as_ref(),
temp_pid,
Some(&profile.name),
Some(&profile_id_str),
)
.await
.map_err(|e| {
@@ -842,7 +747,7 @@ impl BrowserRunner {
// For Firefox-based browsers, apply PAC/user.js to point to the local proxy
if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "zen" | "tor-browser" | "mullvad-browser"
"firefox" | "firefox-developer" | "zen"
) {
let profiles_dir = self.profile_manager.get_profiles_dir();
let profile_path = profiles_dir.join(profile.id.to_string()).join("profile");
@@ -943,19 +848,6 @@ impl BrowserRunner {
if let Some(url_ref) = url.as_ref() {
log::info!("Opening URL in existing browser: {url_ref}");
// For TOR/Mullvad browsers, add extra verification
if matches!(
final_profile.browser.as_str(),
"tor-browser" | "mullvad-browser"
) {
log::info!("TOR/Mullvad browser detected - ensuring we have correct PID");
if final_profile.process_id.is_none() {
log::info!(
"ERROR: No PID found for running TOR/Mullvad browser - this should not happen"
);
return Err("No PID found for running browser".into());
}
}
match self
.open_url_in_existing_browser(
app_handle.clone(),
@@ -972,18 +864,22 @@ impl BrowserRunner {
Err(e) => {
log::info!("Failed to open URL in existing browser: {e}");
// For Mullvad and Tor browsers, don't fall back to new instance since they use -no-remote
// and can't have multiple instances with the same profile
match final_profile.browser.as_str() {
"mullvad-browser" | "tor-browser" => {
Err(format!("Failed to open URL in existing {} browser. Cannot launch new instance due to profile conflict: {}", final_profile.browser, e).into())
}
_ => {
log::info!("Falling back to new instance for browser: {}", final_profile.browser);
// Fallback to launching a new instance for other browsers
self.launch_browser_internal(app_handle.clone(), &final_profile, url, internal_proxy_settings, None, false).await
}
}
// Fall back to launching a new instance
log::info!(
"Falling back to new instance for browser: {}",
final_profile.browser
);
// Fallback to launching a new instance for other browsers
self
.launch_browser_internal(
app_handle.clone(),
&final_profile,
url,
internal_proxy_settings,
None,
false,
)
.await
}
}
} else {
@@ -1058,6 +954,19 @@ impl BrowserRunner {
profile.id
);
// Stop the proxy associated with this profile first
let profile_id_str = profile.id.to_string();
if let Err(e) = PROXY_MANAGER
.stop_proxy_by_profile_id(app_handle.clone(), &profile_id_str)
.await
{
log::warn!(
"Warning: Failed to stop proxy for profile {}: {e}",
profile_id_str
);
}
let mut process_actually_stopped = false;
match self
.camoufox_manager
.find_camoufox_by_profile(&profile_path_str)
@@ -1076,26 +985,263 @@ impl BrowserRunner {
.await
{
Ok(stopped) => {
if stopped {
log::info!(
"Successfully stopped Camoufox process: {} (PID: {:?})",
camoufox_process.id,
camoufox_process.processId
);
if let Some(pid) = camoufox_process.processId {
if stopped {
// Verify the process actually died by checking after a short delay
use tokio::time::{sleep, Duration};
sleep(Duration::from_millis(500)).await;
use sysinfo::{Pid, System};
let system = System::new_all();
process_actually_stopped = system.process(Pid::from(pid as usize)).is_none();
if process_actually_stopped {
log::info!(
"Successfully stopped Camoufox process: {} (PID: {:?}) - verified process is dead",
camoufox_process.id,
pid
);
} else {
log::warn!(
"Camoufox stop command returned success but process {} (PID: {:?}) is still running - forcing kill",
camoufox_process.id,
pid
);
// Force kill the process
#[cfg(target_os = "macos")]
{
use crate::platform_browser;
if let Err(e) = platform_browser::macos::kill_browser_process_impl(
pid,
Some(&profile_path_str),
)
.await
{
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
} else {
// Verify the process is actually dead after force kill
use tokio::time::{sleep, Duration};
sleep(Duration::from_millis(500)).await;
use sysinfo::{Pid, System};
let system = System::new_all();
process_actually_stopped =
system.process(Pid::from(pid as usize)).is_none();
if process_actually_stopped {
log::info!(
"Successfully force killed Camoufox process {} (PID: {:?})",
camoufox_process.id,
pid
);
}
}
}
#[cfg(target_os = "linux")]
{
use crate::platform_browser;
if let Err(e) = platform_browser::linux::kill_browser_process_impl(pid).await
{
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
} else {
// Verify the process is actually dead after force kill
use tokio::time::{sleep, Duration};
sleep(Duration::from_millis(500)).await;
use sysinfo::{Pid, System};
let system = System::new_all();
process_actually_stopped =
system.process(Pid::from(pid as usize)).is_none();
if process_actually_stopped {
log::info!(
"Successfully force killed Camoufox process {} (PID: {:?})",
camoufox_process.id,
pid
);
}
}
}
#[cfg(target_os = "windows")]
{
use crate::platform_browser;
if let Err(e) =
platform_browser::windows::kill_browser_process_impl(pid).await
{
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
} else {
// Verify the process is actually dead after force kill
use tokio::time::{sleep, Duration};
sleep(Duration::from_millis(500)).await;
use sysinfo::{Pid, System};
let system = System::new_all();
process_actually_stopped =
system.process(Pid::from(pid as usize)).is_none();
if process_actually_stopped {
log::info!(
"Successfully force killed Camoufox process {} (PID: {:?})",
camoufox_process.id,
pid
);
}
}
}
}
} else {
// stop_camoufox returned false, try to force kill the process
log::warn!(
"Camoufox stop command returned false for process {} (PID: {:?}) - attempting force kill",
camoufox_process.id,
pid
);
#[cfg(target_os = "macos")]
{
use crate::platform_browser;
if let Err(e) = platform_browser::macos::kill_browser_process_impl(
pid,
Some(&profile_path_str),
)
.await
{
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
} else {
// Verify the process is actually dead after force kill
use tokio::time::{sleep, Duration};
sleep(Duration::from_millis(500)).await;
use sysinfo::{Pid, System};
let system = System::new_all();
process_actually_stopped = system.process(Pid::from(pid as usize)).is_none();
if process_actually_stopped {
log::info!(
"Successfully force killed Camoufox process {} (PID: {:?})",
camoufox_process.id,
pid
);
}
}
}
#[cfg(target_os = "linux")]
{
use crate::platform_browser;
if let Err(e) = platform_browser::linux::kill_browser_process_impl(pid).await {
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
} else {
// Verify the process is actually dead after force kill
use tokio::time::{sleep, Duration};
sleep(Duration::from_millis(500)).await;
use sysinfo::{Pid, System};
let system = System::new_all();
process_actually_stopped = system.process(Pid::from(pid as usize)).is_none();
if process_actually_stopped {
log::info!(
"Successfully force killed Camoufox process {} (PID: {:?})",
camoufox_process.id,
pid
);
}
}
}
#[cfg(target_os = "windows")]
{
use crate::platform_browser;
if let Err(e) = platform_browser::windows::kill_browser_process_impl(pid).await
{
log::error!("Failed to force kill Camoufox process {}: {}", pid, e);
} else {
// Verify the process is actually dead after force kill
use tokio::time::{sleep, Duration};
sleep(Duration::from_millis(500)).await;
use sysinfo::{Pid, System};
let system = System::new_all();
process_actually_stopped = system.process(Pid::from(pid as usize)).is_none();
if process_actually_stopped {
log::info!(
"Successfully force killed Camoufox process {} (PID: {:?})",
camoufox_process.id,
pid
);
}
}
}
}
} else {
log::info!(
"Failed to stop Camoufox process: {} (PID: {:?})",
camoufox_process.id,
camoufox_process.processId
);
// No PID available, assume stopped if stop_camoufox returned true
process_actually_stopped = stopped;
if !stopped {
log::warn!(
"Failed to stop Camoufox process {} but no PID available for force kill",
camoufox_process.id
);
}
}
}
Err(e) => {
log::info!(
log::error!(
"Error stopping Camoufox process {}: {}",
camoufox_process.id,
e
);
// Try to force kill if we have a PID
if let Some(pid) = camoufox_process.processId {
log::info!(
"Attempting force kill after stop_camoufox error for PID: {}",
pid
);
#[cfg(target_os = "macos")]
{
use crate::platform_browser;
if let Err(kill_err) =
platform_browser::macos::kill_browser_process_impl(pid, Some(&profile_path_str))
.await
{
log::error!(
"Failed to force kill Camoufox process {}: {}",
pid,
kill_err
);
} else {
use tokio::time::{sleep, Duration};
sleep(Duration::from_millis(500)).await;
use sysinfo::{Pid, System};
let system = System::new_all();
process_actually_stopped = system.process(Pid::from(pid as usize)).is_none();
}
}
#[cfg(target_os = "linux")]
{
use crate::platform_browser;
if let Err(kill_err) =
platform_browser::linux::kill_browser_process_impl(pid).await
{
log::error!(
"Failed to force kill Camoufox process {}: {}",
pid,
kill_err
);
} else {
use tokio::time::{sleep, Duration};
sleep(Duration::from_millis(500)).await;
use sysinfo::{Pid, System};
let system = System::new_all();
process_actually_stopped = system.process(Pid::from(pid as usize)).is_none();
}
}
#[cfg(target_os = "windows")]
{
use crate::platform_browser;
if let Err(kill_err) =
platform_browser::windows::kill_browser_process_impl(pid).await
{
log::error!(
"Failed to force kill Camoufox process {}: {}",
pid,
kill_err
);
} else {
use tokio::time::{sleep, Duration};
sleep(Duration::from_millis(500)).await;
use sysinfo::{Pid, System};
let system = System::new_all();
process_actually_stopped = system.process(Pid::from(pid as usize)).is_none();
}
}
}
}
}
}
@@ -1105,9 +1251,10 @@ impl BrowserRunner {
profile.name,
profile.id
);
process_actually_stopped = true; // No process found, consider it stopped
}
Err(e) => {
log::info!(
log::error!(
"Error finding Camoufox process for profile {}: {}",
profile.name,
e
@@ -1115,6 +1262,22 @@ impl BrowserRunner {
}
}
// If process wasn't confirmed stopped, return an error
if !process_actually_stopped {
log::error!(
"Failed to stop Camoufox process for profile: {} (ID: {}) - process may still be running",
profile.name,
profile.id
);
return Err(
format!(
"Failed to stop Camoufox process for profile {} - process may still be running",
profile.name
)
.into(),
);
}
// Clear the process ID from the profile
let mut updated_profile = profile.clone();
updated_profile.process_id = None;
@@ -1235,8 +1398,6 @@ impl BrowserRunner {
"firefox" => {
exe_name.contains("firefox")
&& !exe_name.contains("developer")
&& !exe_name.contains("tor")
&& !exe_name.contains("mullvad")
&& !exe_name.contains("camoufox")
}
"firefox-developer" => {
@@ -1252,8 +1413,6 @@ impl BrowserRunner {
}))
|| exe_name == "firefox" // Firefox Developer might just show as "firefox"
}
"mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser"),
"tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "tor-browser"),
"zen" => exe_name.contains("zen"),
"chromium" => exe_name.contains("chromium") || exe_name.contains("chrome"),
"brave" => exe_name.contains("brave") || exe_name.contains("Brave"),
@@ -1268,7 +1427,7 @@ impl BrowserRunner {
let profile_path_match = if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "tor-browser" | "mullvad-browser" | "zen"
"firefox" | "firefox-developer" | "zen"
) {
// Firefox-based browsers: look for -profile argument followed by path
let mut found_profile_arg = false;
@@ -1517,8 +1676,6 @@ impl BrowserRunner {
"firefox" => {
exe_name.contains("firefox")
&& !exe_name.contains("developer")
&& !exe_name.contains("tor")
&& !exe_name.contains("mullvad")
&& !exe_name.contains("camoufox")
}
"firefox-developer" => {
@@ -1534,8 +1691,6 @@ impl BrowserRunner {
}))
|| exe_name == "firefox" // Firefox Developer might just show as "firefox"
}
"mullvad-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "mullvad-browser"),
"tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "tor-browser"),
"zen" => exe_name.contains("zen"),
"chromium" => exe_name.contains("chromium") || exe_name.contains("chrome"),
"brave" => exe_name.contains("brave") || exe_name.contains("Brave"),
@@ -1549,7 +1704,7 @@ impl BrowserRunner {
// Check for profile path match with improved logic
let profile_path_match = if matches!(
profile.browser.as_str(),
"firefox" | "firefox-developer" | "tor-browser" | "mullvad-browser" | "zen"
"firefox" | "firefox-developer" | "zen"
) {
// Firefox-based browsers: look for -profile argument followed by path
let mut found_profile_arg = false;
@@ -1691,6 +1846,7 @@ pub async fn launch_browser_profile(
// Use a temporary PID (1) to start the proxy, we'll update it after browser launch
let temp_pid = 1u32;
let profile_id_str = profile.id.to_string();
// Always start a local proxy, even if there's no upstream proxy
// This allows for traffic monitoring and future features
@@ -1699,7 +1855,7 @@ pub async fn launch_browser_profile(
app_handle.clone(),
upstream_proxy.as_ref(),
temp_pid,
Some(&profile.name),
Some(&profile_id_str),
)
.await
{
@@ -1710,7 +1866,7 @@ pub async fn launch_browser_profile(
// For Firefox-based browsers, always apply PAC/user.js to point to the local proxy
if matches!(
profile_for_launch.browser.as_str(),
"firefox" | "firefox-developer" | "zen" | "tor-browser" | "mullvad-browser"
"firefox" | "firefox-developer" | "zen"
) {
let profiles_dir = browser_runner.profile_manager.get_profiles_dir();
let profile_path = profiles_dir
-250
View File
@@ -54,14 +54,6 @@ impl BrowserVersionManager {
match browser {
"firefox" | "firefox-developer" => Ok(true),
"mullvad-browser" => {
// Mullvad doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
Ok(false)
} else {
Ok(true)
}
}
"zen" => {
// Zen supports all platforms and architectures
Ok(true)
@@ -78,14 +70,6 @@ impl BrowserVersionManager {
Ok(true)
}
}
"tor-browser" => {
// TOR Browser doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
Ok(false)
} else {
Ok(true)
}
}
"camoufox" => {
// Camoufox supports all platforms and architectures according to the JS code
Ok(true)
@@ -99,11 +83,9 @@ impl BrowserVersionManager {
let all_browsers = vec![
"firefox",
"firefox-developer",
"mullvad-browser",
"zen",
"brave",
"chromium",
"tor-browser",
"camoufox",
];
@@ -238,11 +220,9 @@ impl BrowserVersionManager {
let fresh_versions = match browser {
"firefox" => self.fetch_firefox_versions(true).await?, // Always fetch fresh for merging
"firefox-developer" => self.fetch_firefox_developer_versions(true).await?,
"mullvad-browser" => self.fetch_mullvad_versions(true).await?,
"zen" => self.fetch_zen_versions(true).await?,
"brave" => self.fetch_brave_versions(true).await?,
"chromium" => self.fetch_chromium_versions(true).await?,
"tor-browser" => self.fetch_tor_versions(true).await?,
"camoufox" => self.fetch_camoufox_versions(true).await?,
_ => return Err(format!("Unsupported browser: {browser}").into()),
};
@@ -356,27 +336,6 @@ impl BrowserVersionManager {
})
.collect()
}
"mullvad-browser" => {
let releases = self.fetch_mullvad_releases_detailed(true).await?;
merged_versions
.into_iter()
.map(|version| {
if let Some(release) = releases.iter().find(|r| r.tag_name == version) {
BrowserVersionInfo {
version: release.tag_name.clone(),
is_prerelease: release.is_nightly,
date: release.published_at.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: false, // Mullvad usually stable releases
date: "".to_string(),
}
}
})
.collect()
}
"zen" => {
let releases = self.fetch_zen_releases_detailed(true).await?;
merged_versions
@@ -444,31 +403,6 @@ impl BrowserVersionManager {
})
.collect()
}
"tor-browser" => {
let releases = self.fetch_tor_releases_detailed(true).await?;
merged_versions
.into_iter()
.map(|version| {
if let Some(release) = releases.iter().find(|r| r.version == version) {
BrowserVersionInfo {
version: release.version.clone(),
is_prerelease: crate::api_client::is_browser_version_nightly(
"tor-browser",
&release.version,
None,
),
date: release.date.clone(),
}
} else {
BrowserVersionInfo {
version: version.clone(),
is_prerelease: false, // TOR Browser usually stable releases
date: "".to_string(),
}
}
})
.collect()
}
"camoufox" => {
let releases = self.fetch_camoufox_releases_detailed(true).await?;
merged_versions
@@ -602,50 +536,6 @@ impl BrowserVersionManager {
is_archive,
})
}
"mullvad-browser" => {
// Mullvad Browser doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
return Err(format!("Mullvad Browser doesn't support ARM64 on {os}").into());
}
let (platform_str, filename, is_archive) = match os.as_str() {
"windows" => {
if arch == "arm64" {
return Err("Mullvad Browser doesn't support ARM64 on Windows".into());
}
(
"windows-x86_64",
format!("mullvad-browser-windows-x86_64-{version}.exe"),
false,
)
}
"linux" => {
if arch == "arm64" {
return Err("Mullvad Browser doesn't support ARM64 on Linux".into());
}
(
"x86_64",
format!("mullvad-browser-x86_64-{version}.tar.xz"),
true,
)
}
"macos" => (
"macos",
format!("mullvad-browser-macos-{version}.dmg"),
true,
),
_ => return Err(format!("Unsupported platform for Mullvad Browser: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://github.com/mullvad/mullvad-browser/releases/download/{version}/mullvad-browser-{platform_str}-{version}{}",
if os == "windows" { ".exe" } else if os == "linux" { ".tar.xz" } else { ".dmg" }
),
filename,
is_archive,
})
}
"zen" => {
let (asset_name, filename, is_archive) = match (&os[..], &arch[..]) {
("windows", "x64") => ("zen.installer.exe", format!("zen-{version}.exe"), false),
@@ -731,46 +621,6 @@ impl BrowserVersionManager {
is_archive: true,
})
}
"tor-browser" => {
// TOR Browser doesn't support ARM64 on Windows and Linux
if arch == "arm64" && (os == "windows" || os == "linux") {
return Err(format!("TOR Browser doesn't support ARM64 on {os}").into());
}
let (platform_str, filename, is_archive) = match os.as_str() {
"windows" => {
if arch == "arm64" {
return Err("TOR Browser doesn't support ARM64 on Windows".into());
}
(
"windows-x86_64-portable",
format!("tor-browser-windows-x86_64-portable-{version}.exe"),
false,
)
}
"linux" => {
if arch == "arm64" {
return Err("TOR Browser doesn't support ARM64 on Linux".into());
}
(
"linux-x86_64",
format!("tor-browser-linux-x86_64-{version}.tar.xz"),
true,
)
}
"macos" => ("macos", format!("tor-browser-macos-{version}.dmg"), true),
_ => return Err(format!("Unsupported platform for TOR Browser: {os}").into()),
};
Ok(DownloadInfo {
url: format!(
"https://archive.torproject.org/tor-package-archive/torbrowser/{version}/tor-browser-{platform_str}-{version}{}",
if os == "windows" { ".exe" } else if os == "linux" { ".tar.xz" } else { ".dmg" }
),
filename,
is_archive,
})
}
"camoufox" => {
// Camoufox downloads from GitHub releases with pattern: camoufox-{version}-{release}-{os}.{arch}.zip
let (os_name, arch_name) = match (&os[..], &arch[..]) {
@@ -864,24 +714,6 @@ impl BrowserVersionManager {
.await
}
async fn fetch_mullvad_versions(
&self,
no_caching: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_mullvad_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.tag_name).collect())
}
async fn fetch_mullvad_releases_detailed(
&self,
no_caching: bool,
) -> Result<Vec<GithubRelease>, Box<dyn std::error::Error + Send + Sync>> {
self
.api_client
.fetch_mullvad_releases_with_caching(no_caching)
.await
}
async fn fetch_zen_versions(
&self,
no_caching: bool,
@@ -971,24 +803,6 @@ impl BrowserVersionManager {
.await
}
async fn fetch_tor_versions(
&self,
no_caching: bool,
) -> Result<Vec<String>, Box<dyn std::error::Error + Send + Sync>> {
let releases = self.fetch_tor_releases_detailed(no_caching).await?;
Ok(releases.into_iter().map(|r| r.version).collect())
}
async fn fetch_tor_releases_detailed(
&self,
no_caching: bool,
) -> Result<Vec<BrowserRelease>, Box<dyn std::error::Error + Send + Sync>> {
self
.api_client
.fetch_tor_releases_with_caching(no_caching)
.await
}
async fn fetch_camoufox_versions(
&self,
no_caching: bool,
@@ -1036,7 +850,6 @@ mod tests {
base_url.clone(), // firefox_dev_api_base
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
base_url.clone(), // tor_archive_base
)
}
@@ -1130,40 +943,6 @@ mod tests {
.url
.contains("/pub/devedition/releases/139.0b1/"));
// Test Mullvad Browser
let mullvad_info = service
.get_download_info("mullvad-browser", "14.5a6")
.unwrap();
#[cfg(target_os = "macos")]
{
assert_eq!(mullvad_info.filename, "mullvad-browser-macos-14.5a6.dmg");
assert!(mullvad_info.url.contains("mullvad-browser-macos-14.5a6"));
assert!(mullvad_info.is_archive);
}
#[cfg(target_os = "linux")]
{
assert_eq!(
mullvad_info.filename,
"mullvad-browser-x86_64-14.5a6.tar.xz"
);
assert!(mullvad_info.url.contains("mullvad-browser-x86_64-14.5a6"));
assert!(mullvad_info.is_archive);
}
#[cfg(target_os = "windows")]
{
assert_eq!(
mullvad_info.filename,
"mullvad-browser-windows-x86_64-14.5a6.exe"
);
assert!(mullvad_info
.url
.contains("mullvad-browser-windows-x86_64-14.5a6"));
assert!(!mullvad_info.is_archive);
}
// Test Zen Browser
let zen_info = service.get_download_info("zen", "1.11b").unwrap();
@@ -1188,35 +967,6 @@ mod tests {
assert!(!zen_info.is_archive);
}
// Test Tor Browser
let tor_info = service.get_download_info("tor-browser", "14.0.4").unwrap();
#[cfg(target_os = "macos")]
{
assert_eq!(tor_info.filename, "tor-browser-macos-14.0.4.dmg");
assert!(tor_info.url.contains("tor-browser-macos-14.0.4"));
assert!(tor_info.is_archive);
}
#[cfg(target_os = "linux")]
{
assert_eq!(tor_info.filename, "tor-browser-linux-x86_64-14.0.4.tar.xz");
assert!(tor_info.url.contains("tor-browser-linux-x86_64-14.0.4"));
assert!(tor_info.is_archive);
}
#[cfg(target_os = "windows")]
{
assert_eq!(
tor_info.filename,
"tor-browser-windows-x86_64-portable-14.0.4.exe"
);
assert!(tor_info
.url
.contains("tor-browser-windows-x86_64-portable-14.0.4"));
assert!(!tor_info.is_archive);
}
// Test Chromium
let chromium_info = service.get_download_info("chromium", "1465660").unwrap();
+116 -20
View File
@@ -23,6 +23,7 @@ pub struct CamoufoxConfig {
pub executable_path: Option<String>,
pub fingerprint: Option<String>, // JSON string of the complete fingerprint config
pub randomize_fingerprint_on_launch: Option<bool>, // Generate new fingerprint on every launch
pub os: Option<String>, // Operating system for fingerprint generation: "windows", "macos", or "linux"
}
impl Default for CamoufoxConfig {
@@ -40,6 +41,7 @@ impl Default for CamoufoxConfig {
executable_path: None,
fingerprint: None,
randomize_fingerprint_on_launch: None,
os: None,
}
}
}
@@ -171,6 +173,11 @@ impl CamoufoxManager {
}
}
// Add OS option for fingerprint generation
if let Some(os) = &config.os {
config_args.extend(["--os".to_string(), os.clone()]);
}
// Execute config generation command
let mut config_sidecar = self.get_nodecar_sidecar(app_handle)?;
for arg in &config_args {
@@ -342,6 +349,8 @@ impl CamoufoxManager {
}
/// Find Camoufox server by profile path (for integration with browser_runner)
/// This method first checks in-memory instances, then scans system processes
/// to detect Camoufox instances that may have been started before the app restarted.
pub async fn find_camoufox_by_profile(
&self,
profile_path: &str,
@@ -349,41 +358,127 @@ impl CamoufoxManager {
// First clean up any dead instances
self.cleanup_dead_instances().await?;
let inner = self.inner.lock().await;
// Convert paths to canonical form for comparison
let target_path = std::path::Path::new(profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(profile_path).to_path_buf());
for (id, instance) in inner.instances.iter() {
if let Some(instance_profile_path) = &instance.profile_path {
let instance_path = std::path::Path::new(instance_profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(instance_profile_path).to_path_buf());
// Check in-memory instances first
{
let inner = self.inner.lock().await;
if instance_path == target_path {
// Verify the server is actually running by checking the process
if let Some(process_id) = instance.process_id {
if self.is_server_running(process_id).await {
// Found running Camoufox instance
return Ok(Some(CamoufoxLaunchResult {
id: id.clone(),
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
}));
} else {
// Camoufox instance found but process is not running
for (id, instance) in inner.instances.iter() {
if let Some(instance_profile_path) = &instance.profile_path {
let instance_path = std::path::Path::new(instance_profile_path)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(instance_profile_path).to_path_buf());
if instance_path == target_path {
// Verify the server is actually running by checking the process
if let Some(process_id) = instance.process_id {
if self.is_server_running(process_id).await {
// Found running Camoufox instance
return Ok(Some(CamoufoxLaunchResult {
id: id.clone(),
processId: instance.process_id,
profilePath: instance.profile_path.clone(),
url: instance.url.clone(),
}));
}
}
}
}
}
}
// If not found in in-memory instances, scan system processes
// This handles the case where the app was restarted but Camoufox is still running
if let Some((pid, found_profile_path)) = self.find_camoufox_process_by_profile(&target_path) {
log::info!(
"Found running Camoufox process (PID: {}) for profile path via system scan",
pid
);
// Register this instance in our tracking
let instance_id = format!("recovered_{}", pid);
let mut inner = self.inner.lock().await;
inner.instances.insert(
instance_id.clone(),
CamoufoxInstance {
id: instance_id.clone(),
process_id: Some(pid),
profile_path: Some(found_profile_path.clone()),
url: None,
},
);
return Ok(Some(CamoufoxLaunchResult {
id: instance_id,
processId: Some(pid),
profilePath: Some(found_profile_path),
url: None,
}));
}
Ok(None)
}
/// Scan system processes to find a Camoufox process using a specific profile path
fn find_camoufox_process_by_profile(
&self,
target_path: &std::path::Path,
) -> Option<(u32, String)> {
use sysinfo::{ProcessRefreshKind, RefreshKind, System};
let system = System::new_with_specifics(
RefreshKind::nothing().with_processes(ProcessRefreshKind::everything()),
);
let target_path_str = target_path.to_string_lossy();
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.is_empty() {
continue;
}
// Check if this is a Camoufox/Firefox process
let exe_name = process.name().to_string_lossy().to_lowercase();
let is_firefox_like = exe_name.contains("firefox")
|| exe_name.contains("camoufox")
|| exe_name.contains("firefox-bin");
if !is_firefox_like {
continue;
}
// Check if the command line contains our profile path
for (i, arg) in cmd.iter().enumerate() {
if let Some(arg_str) = arg.to_str() {
// Check for -profile argument followed by our path
if arg_str == "-profile" && i + 1 < cmd.len() {
if let Some(next_arg) = cmd.get(i + 1).and_then(|a| a.to_str()) {
let cmd_path = std::path::Path::new(next_arg)
.canonicalize()
.unwrap_or_else(|_| std::path::Path::new(next_arg).to_path_buf());
if cmd_path == target_path {
return Some((pid.as_u32(), next_arg.to_string()));
}
}
}
// Also check if the argument contains the profile path directly
if arg_str.contains(&*target_path_str) {
return Some((pid.as_u32(), target_path_str.to_string()));
}
}
}
}
None
}
/// Check if servers are still alive and clean up dead instances
pub async fn cleanup_dead_instances(
&self,
@@ -496,6 +591,7 @@ mod tests {
assert_eq!(default_config.proxy, None);
assert_eq!(default_config.fingerprint, None);
assert_eq!(default_config.randomize_fingerprint_on_launch, None);
assert_eq!(default_config.os, None);
}
}
+21 -91
View File
@@ -145,30 +145,6 @@ impl Downloader {
Ok(asset_url)
}
BrowserType::MullvadBrowser => {
// For Mullvad, verify the asset exists
let releases = self
.api_client
.fetch_mullvad_releases_with_caching(true)
.await?;
let release = releases
.iter()
.find(|r| r.tag_name == version)
.ok_or(format!("Mullvad version {version} not found"))?;
// Get platform and architecture info
let (os, arch) = Self::get_platform_info();
// Find the appropriate asset
let asset_url = self
.find_mullvad_asset(&release.assets, &os, &arch)
.ok_or(format!(
"No compatible asset found for Mullvad version {version} on {os}/{arch}"
))?;
Ok(asset_url)
}
BrowserType::Camoufox => {
// For Camoufox, verify the asset exists and find the correct download URL
let releases = self
@@ -327,46 +303,6 @@ impl Downloader {
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Mullvad asset for the current platform and architecture
fn find_mullvad_asset(
&self,
assets: &[crate::browser::GithubAsset],
os: &str,
arch: &str,
) -> Option<String> {
// Mullvad asset naming patterns:
// Windows: mullvad-browser-windows-x86_64-VERSION.exe
// macOS: mullvad-browser-macos-VERSION.dmg
// Linux: mullvad-browser-x86_64-VERSION.tar.xz
let asset = match (os, arch) {
("windows", "x64") => assets.iter().find(|asset| {
asset.name.contains("windows")
&& asset.name.contains("x86_64")
&& asset.name.ends_with(".exe")
}),
("windows", "arm64") => {
// Mullvad doesn't support ARM64 on Windows
None
}
("macos", _) => assets
.iter()
.find(|asset| asset.name.contains("macos") && asset.name.ends_with(".dmg")),
("linux", "x64") => assets.iter().find(|asset| {
asset.name.contains("x86_64")
&& asset.name.ends_with(".tar.xz")
&& !asset.name.contains("windows")
}),
("linux", "arm64") => {
// Mullvad doesn't support ARM64 on Linux
None
}
_ => None,
};
asset.map(|a| a.browser_download_url.clone())
}
/// Find the appropriate Camoufox asset for the current platform and architecture
fn find_camoufox_asset(
&self,
@@ -385,15 +321,31 @@ impl Downloader {
_ => return None,
};
// Look for assets matching the pattern
// Use ends_with for precise matching to avoid false positives
let pattern = format!(".{os_name}.{arch_name}.zip");
let asset = assets.iter().find(|asset| {
let name = asset.name.to_lowercase();
name.starts_with("camoufox-")
&& name.contains(&format!("-{os_name}.{arch_name}.zip"))
&& name.ends_with(".zip")
name.starts_with("camoufox-") && name.ends_with(&pattern)
});
asset.map(|a| a.browser_download_url.clone())
if let Some(asset) = asset {
log::info!(
"Selected Camoufox asset for {}/{}: {}",
os,
arch,
asset.name
);
Some(asset.browser_download_url.clone())
} else {
log::warn!(
"No matching Camoufox asset found for {}/{} with pattern '{}'. Available assets: {:?}",
os,
arch,
pattern,
assets.iter().map(|a| &a.name).collect::<Vec<_>>()
);
None
}
}
pub async fn download_browser<R: tauri::Runtime>(
@@ -937,7 +889,6 @@ mod tests {
base_url.clone(), // firefox_dev_api_base
base_url.clone(), // github_api_base
base_url.clone(), // chromium_api_base
base_url.clone(), // tor_archive_base
)
}
@@ -984,27 +935,6 @@ mod tests {
assert_eq!(url, download_info.url);
}
#[tokio::test]
async fn test_resolve_tor_download_url() {
let server = setup_mock_server().await;
let api_client = create_test_api_client(&server);
let downloader = Downloader::new_with_api_client(api_client);
let download_info = DownloadInfo {
url: "https://archive.torproject.org/tor-package-archive/torbrowser/14.0.4/tor-browser-macos-14.0.4.dmg".to_string(),
filename: "tor-test.dmg".to_string(),
is_archive: true,
};
let result = downloader
.resolve_download_url(BrowserType::TorBrowser, "14.0.4", &download_info)
.await;
assert!(result.is_ok());
let url = result.unwrap();
assert_eq!(url, download_info.url);
}
#[tokio::test]
async fn test_download_browser_with_progress() {
let server = setup_mock_server().await;
+1 -23
View File
@@ -868,9 +868,6 @@ impl Extractor {
"chromium.exe",
"zen.exe",
"brave.exe",
"tor-browser.exe",
"tor.exe",
"mullvad-browser.exe",
];
// First try priority executable names
@@ -937,8 +934,6 @@ impl Extractor {
|| file_name.contains("chromium")
|| file_name.contains("zen")
|| file_name.contains("brave")
|| file_name.contains("tor")
|| file_name.contains("mullvad")
|| file_name.contains("browser")
{
return Ok(path);
@@ -1012,15 +1007,6 @@ impl Extractor {
"brave-browser-beta",
"brave-browser-dev",
"brave-bin",
// Tor Browser variants
"tor-browser",
"torbrowser-launcher",
"tor-browser_en-US",
"start-tor-browser",
"Browser/start-tor-browser",
// Mullvad Browser
"mullvad-browser",
"mullvad-browser-bin",
// Camoufox variants
"camoufox",
"camoufox-bin",
@@ -1049,19 +1035,14 @@ impl Extractor {
"chromium",
"brave",
"zen",
"tor-browser",
"mullvad-browser",
"camoufox",
".",
"./",
"firefox",
"mullvad-browser",
"tor-browser_en-US",
"Browser",
"browser",
"opt/google/chrome",
"opt/brave.com/brave",
"opt/mullvad-browser",
"opt/camoufox",
"usr/lib/firefox",
"usr/lib/chromium",
@@ -1159,8 +1140,7 @@ impl Extractor {
|| name_lower.contains("chrome")
|| name_lower.contains("brave")
|| name_lower.contains("zen")
|| name_lower.contains("tor")
|| name_lower.contains("mullvad")
|| name_lower.contains("camoufox")
|| name_lower.ends_with(".appimage")
|| !name_lower.contains('.')
{
@@ -1215,8 +1195,6 @@ impl Extractor {
|| name_lower.contains("chrome")
|| name_lower.contains("brave")
|| name_lower.contains("zen")
|| name_lower.contains("tor")
|| name_lower.contains("mullvad")
|| name_lower.contains("camoufox")
|| file_name.ends_with(".AppImage")
{
+33 -3
View File
@@ -30,6 +30,7 @@ pub mod proxy_runner;
pub mod proxy_server;
pub mod proxy_storage;
mod settings_manager;
pub mod traffic_stats;
// mod theme_detector; // removed: theme detection handled in webview via CSS prefers-color-scheme
mod tag_manager;
mod version_updater;
@@ -40,7 +41,8 @@ use browser_runner::{
use profile::manager::{
check_browser_status, create_browser_profile_new, delete_profile, list_browser_profiles,
rename_profile, update_camoufox_config, update_profile_proxy, update_profile_tags,
rename_profile, update_camoufox_config, update_profile_note, update_profile_proxy,
update_profile_tags,
};
use browser_version_manager::{
@@ -245,6 +247,29 @@ async fn is_geoip_database_available() -> Result<bool, String> {
Ok(GeoIPDownloader::is_geoip_database_available())
}
#[tauri::command]
async fn get_all_traffic_snapshots() -> Result<Vec<crate::traffic_stats::TrafficSnapshot>, String> {
// Use real-time snapshots that merge in-memory data with disk data
Ok(crate::traffic_stats::get_all_traffic_snapshots_realtime())
}
#[tauri::command]
async fn clear_all_traffic_stats() -> Result<(), String> {
crate::traffic_stats::clear_all_traffic_stats()
.map_err(|e| format!("Failed to clear traffic stats: {e}"))
}
#[tauri::command]
async fn get_traffic_stats_for_period(
profile_id: String,
seconds: u64,
) -> Result<Option<crate::traffic_stats::FilteredTrafficStats>, String> {
Ok(crate::traffic_stats::get_traffic_stats_for_period(
&profile_id,
seconds,
))
}
#[tauri::command]
async fn download_geoip_database(app_handle: tauri::AppHandle) -> Result<(), String> {
let downloader = GeoIPDownloader::instance();
@@ -565,7 +590,8 @@ pub fn run() {
// Periodically broadcast browser running status to the frontend
let app_handle_status = app.handle().clone();
tauri::async_runtime::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_millis(500));
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last_running_states: std::collections::HashMap<String, bool> =
std::collections::HashMap::new();
@@ -710,6 +736,7 @@ pub fn run() {
get_browser_release_types,
update_profile_proxy,
update_profile_tags,
update_profile_note,
check_browser_status,
kill_browser_profile,
rename_profile,
@@ -754,7 +781,10 @@ pub fn run() {
warm_up_nodecar,
start_api_server,
stop_api_server,
get_api_server_status
get_api_server_status,
get_all_traffic_snapshots,
clear_all_traffic_stats,
get_traffic_stats_for_period
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
-248
View File
@@ -1,6 +1,5 @@
use crate::browser::{create_browser, BrowserType};
use crate::profile::BrowserProfile;
use std::ffi::OsString;
use std::path::Path;
use std::process::Command;
@@ -10,40 +9,6 @@ pub mod macos {
use super::*;
use sysinfo::{Pid, System};
pub fn is_tor_or_mullvad_browser(exe_name: &str, cmd: &[OsString], browser_type: &str) -> bool {
match browser_type {
"mullvad-browser" => {
let has_mullvad_in_exe = exe_name.contains("mullvad");
let has_firefox_exe = exe_name == "firefox" || exe_name.contains("firefox-bin");
let has_mullvad_in_cmd = cmd.iter().any(|arg| {
let arg_str = arg.to_str().unwrap_or("");
arg_str.contains("Mullvad Browser.app")
|| arg_str.contains("mullvad")
|| arg_str.contains("Mullvad")
|| arg_str.contains("/Applications/Mullvad Browser.app/")
|| arg_str.contains("MullvadBrowser")
});
has_mullvad_in_exe || (has_firefox_exe && has_mullvad_in_cmd)
}
"tor-browser" => {
let has_tor_in_exe = exe_name.contains("tor");
let has_firefox_exe = exe_name == "firefox" || exe_name.contains("firefox-bin");
let has_tor_in_cmd = cmd.iter().any(|arg| {
let arg_str = arg.to_str().unwrap_or("");
arg_str.contains("Tor Browser.app")
|| arg_str.contains("tor-browser")
|| arg_str.contains("TorBrowser")
|| arg_str.contains("/Applications/Tor Browser.app/")
|| arg_str.contains("TorBrowser-Data")
});
has_tor_in_exe || (has_firefox_exe && has_tor_in_cmd)
}
_ => false,
}
}
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
@@ -375,122 +340,6 @@ end try
descendants
}
pub async fn open_url_in_existing_browser_tor_mullvad(
profile: &BrowserProfile,
url: &str,
browser_type: BrowserType,
browser_dir: &Path,
_profiles_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let pid = profile.process_id.unwrap();
log::info!("Opening URL in TOR/Mullvad browser using file-based approach (PID: {pid})");
// Method 1: Try using a temporary HTML file approach
log::info!("Attempting file-based URL opening for TOR/Mullvad browser");
let temp_dir = std::env::temp_dir();
let temp_file_name = format!("donut_browser_url_{}.html", std::process::id());
let temp_file_path = temp_dir.join(&temp_file_name);
let html_content = format!(
r#"<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta http-equiv="refresh" content="0; url={url}">
<title>Redirecting...</title>
<script>
window.location.href = "{url}";
</script>
</head>
<body>
<p>Redirecting to <a href="{url}">{url}</a>...</p>
</body>
</html>"#
);
match std::fs::write(&temp_file_path, html_content) {
Ok(()) => {
log::info!("Created temporary HTML file: {temp_file_path:?}");
let browser = create_browser(browser_type.clone());
if let Ok(executable_path) = browser.get_executable_path(browser_dir) {
let open_result = Command::new("open")
.args([
"-a",
executable_path.to_str().unwrap(),
temp_file_path.to_str().unwrap(),
])
.output();
// Clean up the temporary file after a short delay
let temp_file_path_clone = temp_file_path.clone();
tokio::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_secs(5)).await;
let _ = std::fs::remove_file(temp_file_path_clone);
});
match open_result {
Ok(output) if output.status.success() => {
log::info!("Successfully opened URL using file-based approach");
return Ok(());
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::info!("File-based approach failed: {stderr}");
}
Err(e) => {
log::info!("File-based approach error: {e}");
}
}
}
let _ = std::fs::remove_file(&temp_file_path);
}
Err(e) => {
log::info!("Failed to create temporary HTML file: {e}");
}
}
// Method 2: Try using the 'open' command directly with the URL
log::info!("Attempting direct URL opening with 'open' command");
let browser = create_browser(browser_type.clone());
if let Ok(executable_path) = browser.get_executable_path(browser_dir) {
let direct_open_result = Command::new("open")
.args(["-a", executable_path.to_str().unwrap(), url])
.output();
match direct_open_result {
Ok(output) if output.status.success() => {
log::info!("Successfully opened URL using direct 'open' command");
return Ok(());
}
Ok(output) => {
let stderr = String::from_utf8_lossy(&output.stderr);
log::info!("Direct 'open' command failed: {stderr}");
}
Err(e) => {
log::info!("Direct 'open' command error: {e}");
}
}
}
// If all methods fail, return a helpful error message
Err(
format!(
"Failed to open URL in existing TOR/Mullvad browser (PID: {pid}). All methods failed:\n\
1. File-based approach failed\n\
2. Direct 'open' command failed\n\
\n\
This may be due to browser security restrictions or the browser process may have changed.\n\
Try closing and reopening the browser, or manually paste the URL: {url}"
)
.into(),
)
}
pub async fn open_url_in_existing_browser_chromium(
profile: &BrowserProfile,
url: &str,
@@ -622,42 +471,6 @@ end try
pub mod windows {
use super::*;
pub fn is_tor_or_mullvad_browser(exe_name: &str, cmd: &[OsString], browser_type: &str) -> bool {
let exe_lower = exe_name.to_lowercase();
// Check for Firefox-based browsers first by executable name
let is_firefox_family = exe_lower.contains("firefox") || exe_lower.contains(".exe");
if !is_firefox_family {
return false;
}
// Check command arguments for profile paths and browser-specific indicators
let cmd_line = cmd
.iter()
.map(|s| s.to_string_lossy().to_lowercase())
.collect::<Vec<_>>()
.join(" ");
match browser_type {
"tor-browser" => {
// Check for TOR browser specific paths and arguments
cmd_line.contains("tor")
|| cmd_line.contains("browser\\torbrowser")
|| cmd_line.contains("tor-browser")
|| cmd_line.contains("profile") && (cmd_line.contains("tor") || cmd_line.contains("tbb"))
}
"mullvad-browser" => {
// Check for Mullvad browser specific paths and arguments
cmd_line.contains("mullvad")
|| cmd_line.contains("browser\\mullvadbrowser")
|| cmd_line.contains("mullvad-browser")
|| cmd_line.contains("profile") && cmd_line.contains("mullvad")
}
_ => false,
}
}
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
@@ -782,48 +595,6 @@ pub mod windows {
Ok(())
}
pub async fn open_url_in_existing_browser_tor_mullvad(
profile: &BrowserProfile,
url: &str,
browser_type: BrowserType,
browser_dir: &Path,
profiles_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
// On Windows, TOR and Mullvad browsers can sometimes accept URLs via command line
// even with -no-remote, by launching a new instance that hands off to existing one
let browser = create_browser(browser_type.clone());
let executable_path = browser
.get_executable_path(browser_dir)
.map_err(|e| format!("Failed to get executable path: {}", e))?;
let mut cmd = Command::new(&executable_path);
let profile_data_path = profile.get_profile_data_path(profiles_dir);
cmd.args(["-profile", &profile_data_path.to_string_lossy(), url]);
// Set working directory
if let Some(parent_dir) = browser_dir
.parent()
.or_else(|| browser_dir.ancestors().nth(1))
{
cmd.current_dir(parent_dir);
}
let output = cmd.output()?;
if !output.status.success() {
return Err(
format!(
"Failed to open URL in existing {}: {}. Note: TOR and Mullvad browsers may require manual URL opening for security reasons.",
browser_type.as_str(),
String::from_utf8_lossy(&output.stderr)
)
.into(),
);
}
Ok(())
}
pub async fn open_url_in_existing_browser_chromium(
profile: &BrowserProfile,
url: &str,
@@ -909,15 +680,6 @@ pub mod windows {
pub mod linux {
use super::*;
pub fn is_tor_or_mullvad_browser(
_exe_name: &str,
_cmd: &[OsString],
_browser_type: &str,
) -> bool {
// Linux implementation would go here
false
}
pub async fn launch_browser_process(
executable_path: &std::path::Path,
args: &[String],
@@ -1074,16 +836,6 @@ pub mod linux {
Ok(())
}
pub async fn open_url_in_existing_browser_tor_mullvad(
_profile: &BrowserProfile,
_url: &str,
_browser_type: BrowserType,
_browser_dir: &Path,
_profiles_dir: &Path,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
Err("Opening URLs in existing Firefox-based browsers is not supported on Linux when using -no-remote".into())
}
pub async fn open_url_in_existing_browser_chromium(
profile: &BrowserProfile,
url: &str,
+167 -173
View File
@@ -165,6 +165,7 @@ impl ProfileManager {
camoufox_config: None,
group_id: group_id.clone(),
tags: Vec::new(),
note: None,
};
match self
@@ -207,6 +208,7 @@ impl ProfileManager {
camoufox_config: final_camoufox_config,
group_id: group_id.clone(),
tags: Vec::new(),
note: None,
};
// Save profile info
@@ -522,6 +524,35 @@ impl ProfileManager {
Ok(profile)
}
pub fn update_profile_note(
&self,
app_handle: &tauri::AppHandle,
profile_id: &str,
note: Option<String>,
) -> Result<BrowserProfile, Box<dyn std::error::Error>> {
// Find the profile by ID
let profile_uuid =
uuid::Uuid::parse_str(profile_id).map_err(|_| format!("Invalid profile ID: {profile_id}"))?;
let profiles = self.list_profiles()?;
let mut profile = profiles
.into_iter()
.find(|p| p.id == profile_uuid)
.ok_or_else(|| format!("Profile with ID '{profile_id}' not found"))?;
// Update note (trim whitespace, set to None if empty)
profile.note = note.map(|n| n.trim().to_string()).filter(|n| !n.is_empty());
// Save profile
self.save_profile(&profile)?;
// Emit profile note update event
if let Err(e) = app_handle.emit("profiles-changed", ()) {
log::warn!("Warning: Failed to emit profiles-changed event: {e}");
}
Ok(profile)
}
pub fn delete_multiple_profiles(
&self,
app_handle: &tauri::AppHandle,
@@ -719,7 +750,7 @@ impl ProfileManager {
// For non-camoufox browsers, use the existing PID-based logic
let inner_profile = profile.clone();
let system = System::new_all();
let mut system = System::new();
let mut is_running = false;
let mut found_pid: Option<u32> = None;
@@ -734,10 +765,8 @@ impl ProfileManager {
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
if profile.browser == "tor-browser"
|| profile.browser == "firefox"
if profile.browser == "firefox"
|| profile.browser == "firefox-developer"
|| profile.browser == "mullvad-browser"
|| profile.browser == "zen"
{
arg == profile_data_path_str
@@ -763,6 +792,8 @@ impl ProfileManager {
// If we didn't find the browser with the stored PID, search all processes
if !is_running {
// Refresh all processes only when we need to search (expensive but necessary)
system.refresh_all();
for (pid, process) in system.processes() {
let cmd = process.cmd();
if cmd.len() >= 2 {
@@ -772,13 +803,9 @@ impl ProfileManager {
"firefox" => {
exe_name.contains("firefox")
&& !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"),
"tor-browser" => self.is_tor_or_mullvad_browser(&exe_name, cmd, "tor-browser"),
"zen" => exe_name.contains("zen"),
"chromium" => exe_name.contains("chromium"),
"brave" => exe_name.contains("brave"),
@@ -801,10 +828,8 @@ impl ProfileManager {
// Camoufox uses user_data_dir like Chromium browsers
arg.contains(&format!("--user-data-dir={profile_data_path_str}"))
|| arg == profile_data_path_str
} else if profile.browser == "tor-browser"
|| profile.browser == "firefox"
} else if profile.browser == "firefox"
|| profile.browser == "firefox-developer"
|| profile.browser == "mullvad-browser"
|| profile.browser == "zen"
{
arg == profile_data_path_str
@@ -851,7 +876,6 @@ impl ProfileManager {
None => inner_profile.clone(),
};
let previous_pid = latest_profile.process_id;
let mut merged = latest_profile.clone();
if let Some(pid) = found_pid {
@@ -867,13 +891,6 @@ impl ProfileManager {
if let Err(e) = self.save_profile(&merged) {
log::warn!("Warning: Failed to clear profile PID: {e}");
}
// Stop any associated proxy immediately when the browser stops
if let Some(old_pid) = previous_pid {
let _ = crate::proxy_manager::PROXY_MANAGER
.stop_proxy(app_handle.clone(), old_pid)
.await;
}
}
// Emit profile update event to frontend
@@ -951,18 +968,12 @@ impl ProfileManager {
None => profile.clone(),
};
if let Some(old_pid) = latest.process_id {
if latest.process_id.is_some() {
latest.process_id = None;
if let Err(e) = self.save_profile(&latest) {
log::warn!("Warning: Failed to clear Camoufox profile process info: {e}");
}
// Stop any proxy tied to this old PID immediately
let _ = crate::proxy_manager::PROXY_MANAGER
.stop_proxy(app_handle.clone(), old_pid)
.await;
// Emit profile update event to frontend
if let Err(e) = app_handle.emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e}");
}
@@ -987,7 +998,7 @@ impl ProfileManager {
None => profile.clone(),
};
if let Some(old_pid) = latest.process_id {
if latest.process_id.is_some() {
latest.process_id = None;
if let Err(e2) = self.save_profile(&latest) {
log::warn!(
@@ -995,11 +1006,6 @@ impl ProfileManager {
);
}
// Best-effort stop of proxy tied to old PID
let _ = crate::proxy_manager::PROXY_MANAGER
.stop_proxy(app_handle.clone(), old_pid)
.await;
// Emit profile update event to frontend
if let Err(e3) = app_handle.emit("profile-updated", &latest) {
log::warn!("Warning: Failed to emit profile update event: {e3}");
@@ -1011,36 +1017,9 @@ impl ProfileManager {
}
}
// Helper function to check if a process matches TOR/Mullvad browser
fn is_tor_or_mullvad_browser(
&self,
exe_name: &str,
cmd: &[std::ffi::OsString],
browser_type: &str,
) -> bool {
#[cfg(target_os = "macos")]
return crate::platform_browser::macos::is_tor_or_mullvad_browser(exe_name, cmd, browser_type);
#[cfg(target_os = "windows")]
return crate::platform_browser::windows::is_tor_or_mullvad_browser(
exe_name,
cmd,
browser_type,
);
#[cfg(target_os = "linux")]
return crate::platform_browser::linux::is_tor_or_mullvad_browser(exe_name, cmd, browser_type);
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
let _ = (exe_name, cmd, browser_type);
false
}
}
fn get_common_firefox_preferences(&self) -> Vec<String> {
vec![
// Disable default browser updates
// Disable default browser check
"user_pref(\"browser.shell.checkDefaultBrowser\", false);".to_string(),
"user_pref(\"browser.shell.skipDefaultBrowserCheckOnFirstRun\", true);".to_string(),
"user_pref(\"browser.preferences.moreFromMozilla\", false);".to_string(),
@@ -1055,27 +1034,58 @@ impl ProfileManager {
// Keep extension updates enabled
"user_pref(\"extensions.update.enabled\", true);".to_string(),
"user_pref(\"extensions.update.autoUpdateDefault\", true);".to_string(),
// Completely disable browser update checking
"user_pref(\"app.update.enabled\", false);".to_string(),
"user_pref(\"app.update.staging.enabled\", false);".to_string(),
"user_pref(\"app.update.timerFirstInterval\", -1);".to_string(),
"user_pref(\"app.update.download.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.elevate.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.disabledForTesting\", true);".to_string(),
"user_pref(\"app.update.auto\", false);".to_string(),
"user_pref(\"app.update.mode\", 0);".to_string(),
"user_pref(\"app.update.promptWaitTime\", -1);".to_string(),
"user_pref(\"app.update.service.enabled\", false);".to_string(),
"user_pref(\"app.update.staging.enabled\", false);".to_string(),
"user_pref(\"app.update.silent\", true);".to_string(),
"user_pref(\"app.update.disabledForTesting\", true);".to_string(),
// Prevent update URL access entirely
"user_pref(\"app.update.url\", \"\");".to_string(),
"user_pref(\"app.update.url.manual\", \"\");".to_string(),
"user_pref(\"app.update.url.details\", \"\");".to_string(),
// Disable update timing/scheduling
"user_pref(\"app.update.timerFirstInterval\", 999999999);".to_string(),
"user_pref(\"app.update.interval\", 999999999);".to_string(),
"user_pref(\"app.update.background.interval\", 999999999);".to_string(),
"user_pref(\"app.update.idletime\", 999999999);".to_string(),
"user_pref(\"app.update.promptWaitTime\", 999999999);".to_string(),
// Disable update attempts
"user_pref(\"app.update.download.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.elevate.maxAttempts\", 0);".to_string(),
"user_pref(\"app.update.checkInstallTime\", false);".to_string(),
"user_pref(\"app.update.interval\", -1);".to_string(),
"user_pref(\"app.update.background.interval\", -1);".to_string(),
"user_pref(\"app.update.idletime\", -1);".to_string(),
// Suppress additional update UI/prompts
// Suppress update UI/prompts/notifications
"user_pref(\"app.update.doorhanger\", false);".to_string(),
"user_pref(\"app.update.badge\", false);".to_string(),
"user_pref(\"app.update.notifyDuringDownload\", false);".to_string(),
"user_pref(\"app.update.background.scheduling.enabled\", false);".to_string(),
"user_pref(\"app.update.background.enabled\", false);".to_string(),
// Disable BITS (Windows Background Intelligent Transfer Service) updates
"user_pref(\"app.update.BITS.enabled\", false);".to_string(),
// Disable language pack updates
"user_pref(\"app.update.langpack.enabled\", false);".to_string(),
// Suppress upgrade dialogs on startup
"user_pref(\"browser.startup.upgradeDialog.enabled\", false);".to_string(),
// Disable update ping telemetry
"user_pref(\"toolkit.telemetry.updatePing.enabled\", false);".to_string(),
// Zen browser specific - disable welcome screen and updates
"user_pref(\"zen.welcome-screen.seen\", true);".to_string(),
"user_pref(\"zen.updates.enabled\", false);".to_string(),
"user_pref(\"zen.updates.check-for-updates\", false);".to_string(),
// Additional first-run suppressions
"user_pref(\"app.normandy.first_run\", false);".to_string(),
"user_pref(\"trailhead.firstrun.didSeeAboutWelcome\", true);".to_string(),
"user_pref(\"datareporting.policy.dataSubmissionPolicyBypassNotification\", true);"
.to_string(),
"user_pref(\"toolkit.telemetry.reportingpolicy.firstRun\", false);".to_string(),
// Disable quit confirmation dialogs
"user_pref(\"browser.warnOnQuit\", false);".to_string(),
"user_pref(\"browser.showQuitWarning\", false);".to_string(),
"user_pref(\"browser.tabs.warnOnClose\", false);".to_string(),
"user_pref(\"browser.tabs.warnOnCloseOtherTabs\", false);".to_string(),
"user_pref(\"browser.sessionstore.warnOnQuit\", false);".to_string(),
]
}
@@ -1097,114 +1107,76 @@ impl ProfileManager {
let mut preferences = Vec::new();
// Get the UUID directory (parent of profile data directory)
let uuid_dir = profile_data_path
.parent()
.ok_or("Invalid profile path - cannot find UUID directory")?;
// Add common Firefox preferences (like disabling default browser check)
preferences.extend(self.get_common_firefox_preferences());
// Use embedded PAC template instead of reading from file
const PAC_TEMPLATE: &str = r#"function FindProxyForURL(url, host) {
return "{{proxy_url}}";
}"#;
// Determine which proxy settings to use
let effective_proxy = internal_proxy.unwrap_or(proxy);
let proxy_host = &effective_proxy.host;
let proxy_port = effective_proxy.port;
// Format proxy URL based on type and whether we have an internal proxy
let proxy_url = if let Some(internal) = internal_proxy {
// Use internal proxy (local proxy) as the primary proxy
// This is the local proxy that forwards to the upstream proxy
log::info!(
"Applying local proxy settings to Firefox profile: {}:{}",
internal.host,
internal.port
);
format!("HTTP {}:{}", internal.host, internal.port)
} else {
// Use user-configured proxy directly (upstream proxy)
log::info!(
"Applying upstream proxy settings to Firefox profile: {}:{} ({})",
proxy.host,
proxy.port,
proxy.proxy_type
);
match proxy.proxy_type.as_str() {
"http" => format!("HTTP {}:{}", proxy.host, proxy.port),
"https" => format!("HTTPS {}:{}", proxy.host, proxy.port),
"socks4" => format!("SOCKS4 {}:{}", proxy.host, proxy.port),
"socks5" => format!("SOCKS5 {}:{}", proxy.host, proxy.port),
_ => return Err(format!("Unsupported proxy type: {}", proxy.proxy_type).into()),
}
};
// Check if this is a SOCKS proxy (only possible when using upstream directly)
let is_socks =
internal_proxy.is_none() && (proxy.proxy_type == "socks4" || proxy.proxy_type == "socks5");
// Replace placeholders in PAC file
let pac_content = PAC_TEMPLATE
.replace("{{proxy_url}}", &proxy_url)
.replace("{{proxy_credentials}}", ""); // Credentials are now handled by the PAC file
// Save PAC file in UUID directory
let pac_path = uuid_dir.join("proxy.pac");
log::info!(
"Creating PAC file at: {} with proxy: {}",
pac_path.display(),
proxy_url
);
fs::write(&pac_path, &pac_content)?;
log::info!(
"Created PAC file at: {} with content: {}",
pac_path.display(),
pac_content
"Applying manual proxy settings to Firefox profile: {}:{} (is_internal: {}, is_socks: {})",
proxy_host,
proxy_port,
internal_proxy.is_some(),
is_socks
);
// Configure Firefox to use the PAC file
// Convert path to absolute and properly format for file:// URL
let pac_path_absolute = pac_path.canonicalize().unwrap_or_else(|_| pac_path.clone());
let pac_url = if cfg!(windows) {
// Windows: file:///C:/path/to/file.pac
format!(
"file:///{}",
pac_path_absolute.to_string_lossy().replace('\\', "/")
)
// Use MANUAL proxy configuration (type 1) instead of PAC file (type 2)
// PAC files with file:// URLs are blocked by privacy-focused browsers like Zen
// Manual proxy configuration works reliably across all Firefox variants
preferences.push("user_pref(\"network.proxy.type\", 1);".to_string());
if is_socks {
// SOCKS proxy configuration
preferences.extend([
format!("user_pref(\"network.proxy.socks\", \"{}\");", proxy_host),
format!("user_pref(\"network.proxy.socks_port\", {});", proxy_port),
format!(
"user_pref(\"network.proxy.socks_version\", {});",
if proxy.proxy_type == "socks5" { 5 } else { 4 }
),
"user_pref(\"network.proxy.http\", \"\");".to_string(),
"user_pref(\"network.proxy.http_port\", 0);".to_string(),
"user_pref(\"network.proxy.ssl\", \"\");".to_string(),
"user_pref(\"network.proxy.ssl_port\", 0);".to_string(),
]);
} else {
// Unix/macOS: file:///absolute/path/to/file.pac (three slashes for absolute path)
format!("file://{}", pac_path_absolute.to_string_lossy())
};
log::info!("PAC file path (absolute): {}", pac_path_absolute.display());
log::info!("PAC file URL for Firefox: {}", pac_url);
// HTTP/HTTPS proxy configuration (including our internal local proxy)
preferences.extend([
format!("user_pref(\"network.proxy.http\", \"{}\");", proxy_host),
format!("user_pref(\"network.proxy.http_port\", {});", proxy_port),
format!("user_pref(\"network.proxy.ssl\", \"{}\");", proxy_host),
format!("user_pref(\"network.proxy.ssl_port\", {});", proxy_port),
format!("user_pref(\"network.proxy.ftp\", \"{}\");", proxy_host),
format!("user_pref(\"network.proxy.ftp_port\", {});", proxy_port),
"user_pref(\"network.proxy.socks\", \"\");".to_string(),
"user_pref(\"network.proxy.socks_port\", 0);".to_string(),
]);
}
// Common proxy settings - keep it simple like proxy-chain expected
preferences.extend([
"user_pref(\"network.proxy.type\", 2);".to_string(),
format!(
"user_pref(\"network.proxy.autoconfig_url\", \"{}\");",
pac_url
),
"user_pref(\"network.proxy.failover_direct\", false);".to_string(),
"user_pref(\"network.proxy.socks_remote_dns\", true);".to_string(),
"user_pref(\"network.proxy.no_proxies_on\", \"\");".to_string(),
"user_pref(\"signon.autologin.proxy\", true);".to_string(),
"user_pref(\"network.proxy.share_proxy_settings\", false);".to_string(),
"user_pref(\"network.automatic-ntlm-auth.allow-proxies\", false);".to_string(),
"user_pref(\"network.auth-use-sspi\", false);".to_string(),
"user_pref(\"network.proxy.autoconfig_url\", \"\");".to_string(),
// Disable QUIC/HTTP3 - it bypasses HTTP proxy
"user_pref(\"network.http.http3.enable\", false);".to_string(),
"user_pref(\"network.http.http3.enabled\", false);".to_string(),
]);
// Write settings to user.js file
let user_js_content = preferences.join("\n");
fs::write(user_js_path, &user_js_content)?;
log::info!("Updated user.js with proxy settings. PAC URL: {}", pac_url);
if let Some(internal) = internal_proxy {
log::info!(
"Firefox will use LOCAL proxy: {}:{} (which forwards to upstream)",
internal.host,
internal.port
);
} else {
log::info!(
"Firefox will use UPSTREAM proxy directly: {}:{}",
proxy.host,
proxy.port
);
}
log::info!(
"Updated user.js with manual proxy settings: {}:{}",
proxy_host,
proxy_port
);
Ok(())
}
@@ -1252,7 +1224,9 @@ mod tests {
let temp_dir = TempDir::new().unwrap();
// Mock the base directories by setting environment variables
std::env::set_var("HOME", temp_dir.path());
unsafe {
std::env::set_var("HOME", temp_dir.path());
}
let profile_manager = ProfileManager::instance();
(profile_manager, temp_dir)
@@ -1366,20 +1340,28 @@ mod tests {
assert!(user_js_path.exists(), "user.js should be created");
let content = fs::read_to_string(&user_js_path).expect("Should read user.js");
// Check for manual proxy configuration (type 1) instead of PAC (type 2)
// Manual proxy is used because PAC file:// URLs are blocked by privacy browsers like Zen
assert!(
content.contains("network.proxy.type"),
"Should contain proxy type setting"
content.contains("network.proxy.type\", 1"),
"Should set proxy type to 1 (manual)"
);
assert!(content.contains("2"), "Should set proxy type to 2 (PAC)");
// Check that PAC file was created
let pac_path = uuid_dir.join("proxy.pac");
assert!(pac_path.exists(), "proxy.pac should be created");
let pac_content = fs::read_to_string(&pac_path).expect("Should read proxy.pac");
assert!(
pac_content.contains("FindProxyForURL"),
"PAC file should contain FindProxyForURL function"
content.contains("network.proxy.http\", \"proxy.example.com\""),
"Should set HTTP proxy host"
);
assert!(
content.contains("network.proxy.http_port\", 8080"),
"Should set HTTP proxy port"
);
assert!(
content.contains("network.proxy.ssl\", \"proxy.example.com\""),
"Should set SSL proxy host"
);
assert!(
content.contains("network.proxy.ssl_port\", 8080"),
"Should set SSL proxy port"
);
}
}
@@ -1445,6 +1427,18 @@ pub fn update_profile_tags(
.map_err(|e| format!("Failed to update profile tags: {e}"))
}
#[tauri::command]
pub fn update_profile_note(
app_handle: tauri::AppHandle,
profile_id: String,
note: Option<String>,
) -> Result<BrowserProfile, String> {
let profile_manager = ProfileManager::instance();
profile_manager
.update_profile_note(&app_handle, &profile_id, note)
.map_err(|e| format!("Failed to update profile note: {e}"))
}
#[tauri::command]
pub async fn check_browser_status(
app_handle: tauri::AppHandle,
+2
View File
@@ -22,6 +22,8 @@ pub struct BrowserProfile {
pub group_id: Option<String>, // Reference to profile group
#[serde(default)]
pub tags: Vec<String>, // Free-form tags
#[serde(default)]
pub note: Option<String>, // User note
}
pub fn default_release_type() -> String {
+1 -18
View File
@@ -59,9 +59,6 @@ impl ProfileImporter {
// Detect Zen Browser profiles
detected_profiles.extend(self.detect_zen_browser_profiles()?);
// NOTE: Mullvad and Tor Browser profile imports are no longer supported.
// We intentionally do not detect these profiles to avoid offering them in the UI.
// Remove duplicates based on path
let mut seen_paths = HashSet::new();
let unique_profiles: Vec<DetectedProfile> = detected_profiles
@@ -495,9 +492,7 @@ impl ProfileImporter {
"firefox-developer" => "Firefox Developer",
"chromium" => "Chrome/Chromium",
"brave" => "Brave",
"mullvad-browser" => "Mullvad Browser",
"zen" => "Zen Browser",
"tor-browser" => "Tor Browser",
_ => "Unknown Browser",
}
}
@@ -509,11 +504,6 @@ impl ProfileImporter {
browser_type: &str,
new_profile_name: &str,
) -> Result<(), Box<dyn std::error::Error>> {
// Disable imports for Mullvad and Tor browsers
if browser_type == "mullvad-browser" || browser_type == "tor-browser" {
return Err("Importing Mullvad Browser or Tor Browser profiles is not supported".into());
}
// Validate that source path exists
let source_path = Path::new(source_path);
if !source_path.exists() {
@@ -561,6 +551,7 @@ impl ProfileImporter {
camoufox_config: None,
group_id: None,
tags: Vec::new(),
note: None,
};
// Save the profile metadata
@@ -684,15 +675,7 @@ mod tests {
"Chrome/Chromium"
);
assert_eq!(importer.get_browser_display_name("brave"), "Brave");
assert_eq!(
importer.get_browser_display_name("mullvad-browser"),
"Mullvad Browser"
);
assert_eq!(importer.get_browser_display_name("zen"), "Zen Browser");
assert_eq!(
importer.get_browser_display_name("tor-browser"),
"Tor Browser"
);
assert_eq!(
importer.get_browser_display_name("unknown"),
"Unknown Browser"
+217 -73
View File
@@ -20,8 +20,8 @@ pub struct ProxyInfo {
pub upstream_port: u16,
pub upstream_type: String,
pub local_port: u16,
// Optional profile name to which this proxy instance is logically tied
pub profile_name: Option<String>,
// Optional profile ID to which this proxy instance is logically tied
pub profile_id: Option<String>,
}
// Proxy check result cache
@@ -491,7 +491,6 @@ impl ProxyManager {
"https://ipinfo.io/ip",
"https://icanhazip.com",
"https://ifconfig.co/ip",
"https://ipecho.net/plain",
];
// Create HTTP client with proxy
@@ -594,14 +593,9 @@ impl ProxyManager {
app_handle: tauri::AppHandle,
proxy_settings: Option<&ProxySettings>,
browser_pid: u32,
profile_name: Option<&str>,
profile_id: Option<&str>,
) -> Result<ProxySettings, String> {
// First, proactively cleanup any dead proxies so we don't accidentally reuse stale ones
let _ = self.cleanup_dead_proxies(app_handle.clone()).await;
// If we have a previous proxy tied to this profile, and the upstream settings are changing,
// stop it before starting a new one so the change takes effect immediately.
if let Some(name) = profile_name {
if let Some(name) = profile_id {
// Check if we have an active proxy recorded for this profile
let maybe_existing_id = {
let map = self.profile_active_proxy_ids.lock().unwrap();
@@ -626,30 +620,29 @@ impl ProxyManager {
&& existing.upstream_host == desired_host
&& existing.upstream_port == desired_port;
if !is_same_upstream {
// Stop the previous proxy tied to this profile (best effort)
// We don't know the original PID mapping that created it; iterate to find its key
let pid_to_stop = {
let proxies = self.active_proxies.lock().unwrap();
proxies.iter().find_map(|(pid, info)| {
if info.id == existing_id {
Some(*pid)
} else {
None
}
})
};
if let Some(pid) = pid_to_stop {
let _ = self.stop_proxy(app_handle.clone(), pid).await;
if is_same_upstream {
// Settings match - can reuse existing proxy
// Just update the PID mapping if needed
let proxies = self.active_proxies.lock().unwrap();
if proxies.contains_key(&browser_pid) {
// Already mapped, reuse it
return Ok(ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: existing.local_port,
username: None,
password: None,
});
}
// Need to add this PID to the mapping - we'll do that after starting
}
// Settings differ - we'll create a new proxy, but don't stop the old one
// It will be cleaned up by periodic cleanup if it becomes dead
}
}
}
// Check if we already have a proxy for this browser PID. If it exists but the upstream
// settings don't match the newly requested ones, stop it and create a new proxy so that
// changes take effect immediately.
let mut needs_restart = false;
// Check if we already have a proxy for this browser PID
// If settings match, reuse it; otherwise create a new one (don't stop the old one)
{
let proxies = self.active_proxies.lock().unwrap();
if let Some(existing) = proxies.get(&browser_pid) {
@@ -664,24 +657,29 @@ impl ProxyManager {
&& existing.upstream_port == desired_port;
if is_same_upstream {
// Reuse existing local proxy
return Ok(ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: existing.local_port,
username: None,
password: None,
});
} else {
// Upstream changed; we must restart the local proxy so that traffic is routed correctly
needs_restart = true;
}
}
}
// Check if profile_id matches
let profile_id_matches = match (profile_id, &existing.profile_id) {
(Some(ref new_id), Some(ref old_id)) => new_id == old_id,
(None, None) => true,
_ => false,
};
if needs_restart {
// Best-effort stop of the old proxy for this PID before starting a new one
let _ = self.stop_proxy(app_handle.clone(), browser_pid).await;
if profile_id_matches {
// Reuse existing local proxy (settings and profile_id match)
return Ok(ProxySettings {
proxy_type: "http".to_string(),
host: "127.0.0.1".to_string(),
port: existing.local_port,
username: None,
password: None,
});
}
// Profile ID changed - we'll create a new proxy but don't stop the old one
// It will be cleaned up by periodic cleanup if it becomes dead
}
// Upstream changed - we'll create a new proxy but don't stop the old one
// It will be cleaned up by periodic cleanup if it becomes dead
}
}
// Start a new proxy using the donut-proxy binary with the correct CLI interface
@@ -711,6 +709,11 @@ impl ProxyManager {
}
}
// Add profile ID if provided for traffic tracking
if let Some(id) = profile_id {
proxy_cmd = proxy_cmd.arg("--profile-id").arg(id);
}
// Execute the command and wait for it to complete
// The donut-proxy binary should start the worker and then exit
let output = proxy_cmd
@@ -755,7 +758,7 @@ impl ProxyManager {
.map(|p| p.proxy_type.clone())
.unwrap_or_else(|| "DIRECT".to_string()),
local_port,
profile_name: profile_name.map(|s| s.to_string()),
profile_id: profile_id.map(|s| s.to_string()),
};
// Wait for the local proxy port to be ready to accept connections
@@ -789,14 +792,14 @@ impl ProxyManager {
}
// Store the profile proxy info for persistence
if let Some(name) = profile_name {
if let Some(id) = profile_id {
if let Some(proxy_settings) = proxy_settings {
let mut profile_proxies = self.profile_proxies.lock().unwrap();
profile_proxies.insert(name.to_string(), proxy_settings.clone());
profile_proxies.insert(id.to_string(), proxy_settings.clone());
}
// Also record the active proxy id for this profile for quick cleanup on changes
let mut map = self.profile_active_proxy_ids.lock().unwrap();
map.insert(name.to_string(), proxy_info.id.clone());
map.insert(id.to_string(), proxy_info.id.clone());
}
// Return proxy settings for the browser
@@ -815,10 +818,10 @@ impl ProxyManager {
app_handle: tauri::AppHandle,
browser_pid: u32,
) -> Result<(), String> {
let (proxy_id, profile_name): (String, Option<String>) = {
let (proxy_id, profile_id): (String, Option<String>) = {
let mut proxies = self.active_proxies.lock().unwrap();
match proxies.remove(&browser_pid) {
Some(proxy) => (proxy.id, proxy.profile_name.clone()),
Some(proxy) => (proxy.id, proxy.profile_id.clone()),
None => return Ok(()), // No proxy to stop
}
};
@@ -842,11 +845,11 @@ impl ProxyManager {
}
// Clear profile-to-proxy mapping if it references this proxy
if let Some(name) = profile_name {
if let Some(id) = profile_id {
let mut map = self.profile_active_proxy_ids.lock().unwrap();
if let Some(current_id) = map.get(&name) {
if let Some(current_id) = map.get(&id) {
if current_id == &proxy_id {
map.remove(&name);
map.remove(&id);
}
}
}
@@ -859,6 +862,69 @@ impl ProxyManager {
Ok(())
}
// Stop the proxy associated with a profile ID
pub async fn stop_proxy_by_profile_id(
&self,
app_handle: tauri::AppHandle,
profile_id: &str,
) -> Result<(), String> {
// Find the proxy ID for this profile
let proxy_id = {
let map = self.profile_active_proxy_ids.lock().unwrap();
map.get(profile_id).cloned()
};
if let Some(proxy_id) = proxy_id {
// Find the PID for this proxy
let pid = {
let proxies = self.active_proxies.lock().unwrap();
proxies.iter().find_map(|(pid, proxy)| {
if proxy.id == proxy_id {
Some(*pid)
} else {
None
}
})
};
if let Some(pid) = pid {
// Use the existing stop_proxy method
self.stop_proxy(app_handle, pid).await
} else {
// Proxy not found in active_proxies, try to stop it directly by ID
let proxy_cmd = app_handle
.shell()
.sidecar("donut-proxy")
.map_err(|e| format!("Failed to create sidecar: {e}"))?
.arg("proxy")
.arg("stop")
.arg("--id")
.arg(&proxy_id);
let output = proxy_cmd.output().await.unwrap();
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
log::warn!("Proxy stop error: {stderr}");
}
// Clear profile-to-proxy mapping
let mut map = self.profile_active_proxy_ids.lock().unwrap();
map.remove(profile_id);
// Emit event for reactive UI updates
if let Err(e) = app_handle.emit("proxies-changed", ()) {
log::error!("Failed to emit proxies-changed event: {e}");
}
Ok(())
}
} else {
// No proxy found for this profile
Ok(())
}
}
// 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();
@@ -870,30 +936,108 @@ impl ProxyManager {
}
}
// Check if a process is still running
fn is_process_running(&self, pid: u32) -> bool {
use sysinfo::{Pid, System};
let system = System::new_all();
system.process(Pid::from(pid as usize)).is_some()
}
// Clean up proxies for dead browser processes
// Only clean up orphaned config files where the proxy process itself is dead
pub async fn cleanup_dead_proxies(
&self,
app_handle: tauri::AppHandle,
) -> Result<Vec<u32>, String> {
let dead_pids = {
let proxies = self.active_proxies.lock().unwrap();
proxies
.keys()
.filter(|&&pid| pid != 0 && !self.is_process_running(pid)) // Skip temporary PID 0
.copied()
.collect::<Vec<u32>>()
// Don't stop proxies for dead browser processes - let them run indefinitely
// The proxy processes are idle and don't consume CPU when not in use
// Only clean up config files where the proxy process itself is dead (see below)
let dead_pids: Vec<u32> = Vec::new();
// Clean up orphaned proxy configs (only where proxy process is definitely dead)
// IMPORTANT: Only clean up configs where the proxy process itself is dead
// If the proxy process is running (even if idle), leave it alone
// The user doesn't care if proxy processes run indefinitely as long as they're not consuming CPU
let orphaned_configs = {
use crate::proxy_storage::{is_process_running, list_proxy_configs};
use std::time::{SystemTime, UNIX_EPOCH};
let all_configs = list_proxy_configs();
let tracked_proxy_ids: std::collections::HashSet<String> = {
let proxies = self.active_proxies.lock().unwrap();
proxies.values().map(|p| p.id.clone()).collect()
};
// Get current time for grace period check
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
all_configs
.into_iter()
.filter(|config| {
// If proxy is tracked in active_proxies, it's definitely not orphaned
if tracked_proxy_ids.contains(&config.id) {
return false;
}
// Extract creation time from proxy ID (format: proxy_{timestamp}_{random})
// This gives us a grace period for newly created proxies
let proxy_age = config
.id
.strip_prefix("proxy_")
.and_then(|s| s.split('_').next())
.and_then(|s| s.parse::<u64>().ok())
.map(|created_at| now.saturating_sub(created_at))
.unwrap_or(0);
// Grace period: don't clean up proxies created in the last 120 seconds
// This prevents race conditions during startup (increased from 60 to 120 for safety)
if proxy_age < 120 {
log::debug!(
"Skipping cleanup of proxy {} - too new (age: {}s)",
config.id,
proxy_age
);
return false;
}
// ONLY clean up if we can verify the proxy process is dead
// If proxy process is running, leave it alone (even if idle)
if let Some(proxy_pid) = config.pid {
// Check if proxy process is actually dead
if !is_process_running(proxy_pid) {
// Proxy process is dead, clean up the config file
log::info!(
"Proxy {} process (PID {}) is dead, will clean up config",
config.id,
proxy_pid
);
return true;
}
// Proxy process is running - leave it alone
log::debug!(
"Skipping cleanup of proxy {} - process (PID {}) is still running",
config.id,
proxy_pid
);
return false;
}
// No PID in config - can't verify if process is dead
// Be conservative: don't clean up (might be starting up or PID not set yet)
log::debug!(
"Skipping cleanup of proxy {} - no PID in config (might be starting up)",
config.id
);
false
})
.collect::<Vec<_>>()
};
for dead_pid in &dead_pids {
log::info!("Cleaning up proxy for dead browser process PID: {dead_pid}");
let _ = self.stop_proxy(app_handle.clone(), *dead_pid).await;
// Clean up orphaned config files (proxy process is dead)
for config in orphaned_configs {
log::info!(
"Cleaning up orphaned proxy config: {} (proxy process is dead)",
config.id
);
// Just delete the config file - the process is already dead
use crate::proxy_storage::delete_proxy_config;
delete_proxy_config(&config.id);
}
// Emit event for reactive UI updates
@@ -1035,7 +1179,7 @@ mod tests {
upstream_port: 3128,
upstream_type: "http".to_string(),
local_port: (8000 + i) as u16,
profile_name: None,
profile_id: None,
};
// Add proxy
+42 -13
View File
@@ -11,6 +11,14 @@ lazy_static::lazy_static! {
pub async fn start_proxy_process(
upstream_url: Option<String>,
port: Option<u16>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
start_proxy_process_with_profile(upstream_url, port, None).await
}
pub async fn start_proxy_process_with_profile(
upstream_url: Option<String>,
port: Option<u16>,
profile_id: Option<String>,
) -> Result<ProxyConfig, Box<dyn std::error::Error>> {
let id = generate_proxy_id();
let upstream = upstream_url.unwrap_or_else(|| "DIRECT".to_string());
@@ -22,9 +30,17 @@ pub async fn start_proxy_process(
listener.local_addr().unwrap().port()
});
let config = ProxyConfig::new(id.clone(), upstream, Some(local_port));
let config =
ProxyConfig::new(id.clone(), upstream, Some(local_port)).with_profile_id(profile_id.clone());
save_proxy_config(&config)?;
// Log profile_id for debugging
if let Some(ref pid) = profile_id {
log::info!("Saved proxy config {} with profile_id: {}", id, pid);
} else {
log::info!("Saved proxy config {} without profile_id", id);
}
// Spawn proxy worker process in the background using std::process::Command
// This ensures proper process detachment on Unix systems
let exe = std::env::current_exe()?;
@@ -43,18 +59,12 @@ pub async fn start_proxy_process(
cmd.stdin(Stdio::null());
cmd.stdout(Stdio::null());
#[cfg(debug_assertions)]
{
let log_path = std::path::PathBuf::from("/tmp").join(format!("donut-proxy-{}.log", id));
if let Ok(file) = std::fs::File::create(&log_path) {
log::error!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
cmd.stderr(Stdio::null());
}
}
#[cfg(not(debug_assertions))]
{
// Always log to file for diagnostics (both debug and release builds)
let log_path = std::path::PathBuf::from("/tmp").join(format!("donut-proxy-{}.log", id));
if let Ok(file) = std::fs::File::create(&log_path) {
log::info!("Proxy worker stderr will be logged to: {:?}", log_path);
cmd.stderr(Stdio::from(file));
} else {
cmd.stderr(Stdio::null());
}
@@ -63,6 +73,13 @@ pub async fn start_proxy_process(
cmd.pre_exec(|| {
// Create a new process group so the process survives parent exit
libc::setsid();
// Set high priority so the proxy is killed last under resource pressure
// Negative nice value = higher priority. Try -10, fall back to -5 if it fails.
if libc::setpriority(libc::PRIO_PROCESS, 0, -10) != 0 {
let _ = libc::setpriority(libc::PRIO_PROCESS, 0, -5);
}
Ok(())
});
}
@@ -90,6 +107,10 @@ pub async fn start_proxy_process(
{
use std::os::windows::process::CommandExt;
use std::process::Command as StdCommand;
use windows::Win32::Foundation::CloseHandle;
use windows::Win32::System::Threading::{
OpenProcess, SetPriorityClass, ABOVE_NORMAL_PRIORITY_CLASS, PROCESS_SET_INFORMATION,
};
let mut cmd = StdCommand::new(&exe);
cmd.arg("proxy-worker");
@@ -108,6 +129,14 @@ pub async fn start_proxy_process(
let child = cmd.spawn()?;
let pid = child.id();
// Set high priority so the proxy is killed last under resource pressure
unsafe {
if let Ok(handle) = OpenProcess(PROCESS_SET_INFORMATION, false, pid) {
let _ = SetPriorityClass(handle, ABOVE_NORMAL_PRIORITY_CLASS);
let _ = CloseHandle(handle);
}
}
// Store PID
{
let mut processes = PROXY_PROCESSES.lock().unwrap();
+719 -98
View File
@@ -1,4 +1,5 @@
use crate::proxy_storage::ProxyConfig;
use crate::traffic_stats::{get_traffic_tracker, init_traffic_tracker};
use http_body_util::{BodyExt, Full};
use hyper::body::Bytes;
use hyper::server::conn::http1;
@@ -9,12 +10,81 @@ use std::convert::Infallible;
use std::io;
use std::net::SocketAddr;
use std::pin::Pin;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::task::{Context, Poll};
use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf};
use tokio::net::TcpListener;
use tokio::net::TcpStream;
use url::Url;
/// Wrapper stream that counts bytes read and written
struct CountingStream<S> {
inner: S,
bytes_read: Arc<AtomicU64>,
bytes_written: Arc<AtomicU64>,
}
impl<S> CountingStream<S> {
fn new(inner: S) -> Self {
Self {
inner,
bytes_read: Arc::new(AtomicU64::new(0)),
bytes_written: Arc::new(AtomicU64::new(0)),
}
}
}
impl<S: AsyncRead + Unpin> AsyncRead for CountingStream<S> {
fn poll_read(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
let filled_before = buf.filled().len();
let result = Pin::new(&mut self.inner).poll_read(cx, buf);
if let Poll::Ready(Ok(())) = &result {
let bytes_read = buf.filled().len() - filled_before;
if bytes_read > 0 {
self
.bytes_read
.fetch_add(bytes_read as u64, Ordering::Relaxed);
// Update global tracker - count as received (data coming into proxy)
if let Some(tracker) = get_traffic_tracker() {
tracker.add_bytes_received(bytes_read as u64);
}
}
}
result
}
}
impl<S: AsyncWrite + Unpin> AsyncWrite for CountingStream<S> {
fn poll_write(
mut self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
let result = Pin::new(&mut self.inner).poll_write(cx, buf);
if let Poll::Ready(Ok(n)) = &result {
self.bytes_written.fetch_add(*n as u64, Ordering::Relaxed);
// Update global tracker - count as sent (data going out of proxy)
if let Some(tracker) = get_traffic_tracker() {
tracker.add_bytes_sent(*n as u64);
}
}
result
}
fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_flush(cx)
}
fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
Pin::new(&mut self.inner).poll_shutdown(cx)
}
}
// Wrapper to prepend consumed bytes to a stream
struct PrependReader {
prepended: Vec<u8>,
@@ -289,12 +359,349 @@ async fn connect_via_socks(
}
}
async fn handle_http_via_socks4(
req: Request<hyper::body::Incoming>,
upstream_url: &str,
) -> Result<Response<Full<Bytes>>, Infallible> {
// Extract domain for traffic tracking
let domain = req
.uri()
.host()
.map(|h| h.to_string())
.unwrap_or_else(|| "unknown".to_string());
// Parse upstream SOCKS4 proxy URL
let upstream = match Url::parse(upstream_url) {
Ok(url) => url,
Err(e) => {
log::error!("Failed to parse SOCKS4 proxy URL: {}", e);
let mut response = Response::new(Full::new(Bytes::from("Invalid proxy URL")));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
};
let socks_host = upstream.host_str().unwrap_or("127.0.0.1");
let socks_port = upstream.port().unwrap_or(1080);
let socks_addr = format!("{}:{}", socks_host, socks_port);
// Parse target from request URI
let target_uri = req.uri();
let target_host = target_uri.host().unwrap_or("localhost");
let target_port = target_uri.port_u16().unwrap_or(80);
// Connect to SOCKS4 proxy
let mut socks_stream = match TcpStream::connect(&socks_addr).await {
Ok(stream) => stream,
Err(e) => {
log::error!("Failed to connect to SOCKS4 proxy {}: {}", socks_addr, e);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to connect to SOCKS4 proxy: {}",
e
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
};
// Resolve target host to IP (SOCKS4 requires IP addresses)
let target_ip = match tokio::net::lookup_host((target_host, target_port)).await {
Ok(mut addrs) => {
if let Some(addr) = addrs.next() {
match addr.ip() {
std::net::IpAddr::V4(ipv4) => ipv4.octets(),
std::net::IpAddr::V6(_) => {
log::error!("SOCKS4 does not support IPv6");
let mut response = Response::new(Full::new(Bytes::from(
"SOCKS4 does not support IPv6 addresses",
)));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
}
} else {
log::error!("Failed to resolve target host: {}", target_host);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to resolve target host: {}",
target_host
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
}
Err(e) => {
log::error!("Failed to resolve target host {}: {}", target_host, e);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to resolve target host: {}",
e
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
};
// Build SOCKS4 CONNECT request
let mut socks_request = vec![0x04, 0x01]; // SOCKS4, CONNECT
socks_request.extend_from_slice(&target_port.to_be_bytes());
socks_request.extend_from_slice(&target_ip);
socks_request.push(0); // NULL terminator for userid
// Send SOCKS4 CONNECT request
if let Err(e) = socks_stream.write_all(&socks_request).await {
log::error!("Failed to send SOCKS4 CONNECT request: {}", e);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to send SOCKS4 request: {}",
e
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
// Read SOCKS4 response
let mut socks_response = [0u8; 8];
if let Err(e) = socks_stream.read_exact(&mut socks_response).await {
log::error!("Failed to read SOCKS4 response: {}", e);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to read SOCKS4 response: {}",
e
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
// Check SOCKS4 response (second byte should be 0x5A for success)
if socks_response[1] != 0x5A {
log::error!(
"SOCKS4 connection failed, response code: {}",
socks_response[1]
);
let mut response = Response::new(Full::new(Bytes::from("SOCKS4 connection failed")));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
// Now send the HTTP request through the SOCKS4 connection
// Build HTTP request line
let method = req.method().as_str();
let path = target_uri
.path_and_query()
.map(|pq| pq.as_str())
.unwrap_or("/");
let http_version = if req.version() == hyper::Version::HTTP_11 {
"HTTP/1.1"
} else {
"HTTP/1.0"
};
let mut http_request = format!("{} {} {}\r\n", method, path, http_version);
// Add Host header if not present
let mut has_host = false;
for (name, value) in req.headers().iter() {
if name.as_str().eq_ignore_ascii_case("host") {
has_host = true;
}
// Skip proxy-specific headers
if name.as_str().eq_ignore_ascii_case("proxy-authorization")
|| name.as_str().eq_ignore_ascii_case("proxy-connection")
|| name.as_str().eq_ignore_ascii_case("proxy-authenticate")
{
continue;
}
// Skip Content-Length and Transfer-Encoding - we'll add our own Content-Length
// based on the collected body size. Having both violates HTTP/1.1 (RFC 7230).
if name.as_str().eq_ignore_ascii_case("content-length")
|| name.as_str().eq_ignore_ascii_case("transfer-encoding")
{
continue;
}
if let Ok(val) = value.to_str() {
http_request.push_str(&format!("{}: {}\r\n", name.as_str(), val));
}
}
if !has_host {
http_request.push_str(&format!("Host: {}:{}\r\n", target_host, target_port));
}
// Get body
let body_bytes = match req.collect().await {
Ok(collected) => collected.to_bytes(),
Err(_) => Bytes::new(),
};
// Add Content-Length if there's a body
if !body_bytes.is_empty() {
http_request.push_str(&format!("Content-Length: {}\r\n", body_bytes.len()));
}
http_request.push_str("\r\n");
// Send HTTP request
if let Err(e) = socks_stream.write_all(http_request.as_bytes()).await {
log::error!("Failed to send HTTP request through SOCKS4: {}", e);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to send HTTP request: {}",
e
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
// Send body if present
if !body_bytes.is_empty() {
if let Err(e) = socks_stream.write_all(&body_bytes).await {
log::error!("Failed to send HTTP body through SOCKS4: {}", e);
let mut response = Response::new(Full::new(Bytes::from(format!(
"Failed to send HTTP body: {}",
e
))));
*response.status_mut() = StatusCode::BAD_GATEWAY;
return Ok(response);
}
}
// Read HTTP response
let mut response_buffer = Vec::with_capacity(8192);
let mut temp_buf = [0u8; 4096];
let mut content_length: Option<usize> = None;
let mut is_chunked = false;
// Read until we have complete headers
loop {
match socks_stream.read(&mut temp_buf).await {
Ok(0) => break, // Connection closed
Ok(n) => {
response_buffer.extend_from_slice(&temp_buf[..n]);
// Check for end of headers (\r\n\r\n)
if let Some(pos) = response_buffer.windows(4).position(|w| w == b"\r\n\r\n") {
// Parse headers
let headers_str = String::from_utf8_lossy(&response_buffer[..pos + 4]);
for line in headers_str.lines() {
let line_lower = line.to_lowercase();
if line_lower.starts_with("content-length:") {
if let Some(len_str) = line.split(':').nth(1) {
if let Ok(len) = len_str.trim().parse::<usize>() {
content_length = Some(len);
}
}
} else if line_lower.starts_with("transfer-encoding:") && line_lower.contains("chunked")
{
is_chunked = true;
}
}
// Read body if Content-Length is specified and we don't have it all
if let Some(cl) = content_length {
let body_start = pos + 4;
let body_received = response_buffer.len() - body_start;
if body_received < cl {
// Read remaining body (but don't use read_exact as connection might close)
let remaining = cl - body_received;
let mut read_so_far = 0;
while read_so_far < remaining {
match socks_stream.read(&mut temp_buf).await {
Ok(0) => break, // Connection closed
Ok(m) => {
let to_read = (remaining - read_so_far).min(m);
response_buffer.extend_from_slice(&temp_buf[..to_read]);
read_so_far += to_read;
if to_read < m {
// More data than needed, might be next response - stop here
break;
}
}
Err(_) => break,
}
}
}
} else if !is_chunked {
// No Content-Length and not chunked - read until connection closes
// But limit to reasonable size to avoid memory issues
let max_body_size = 10 * 1024 * 1024; // 10MB max
while response_buffer.len() < max_body_size {
match socks_stream.read(&mut temp_buf).await {
Ok(0) => break, // Connection closed
Ok(n) => {
response_buffer.extend_from_slice(&temp_buf[..n]);
}
Err(_) => break,
}
}
}
// Note: Chunked encoding is complex to parse manually, so we'll read what we can
// For full chunked support, we'd need a proper HTTP parser
break;
}
}
Err(e) => {
log::error!("Error reading HTTP response from SOCKS4: {}", e);
break;
}
}
}
// Parse HTTP response
let response_str = String::from_utf8_lossy(&response_buffer);
let mut lines = response_str.lines();
let status_line = lines.next().unwrap_or("HTTP/1.1 500 Internal Server Error");
let status_parts: Vec<&str> = status_line.split_whitespace().collect();
let status_code = status_parts
.get(1)
.and_then(|s| s.parse::<u16>().ok())
.unwrap_or(500);
// Find header/body boundary
let header_end = response_buffer
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|p| p + 4)
.unwrap_or(response_buffer.len());
let body = response_buffer[header_end..].to_vec();
// Record request in traffic tracker
let response_size = body.len() as u64;
if let Some(tracker) = get_traffic_tracker() {
tracker.record_request(&domain, body_bytes.len() as u64, response_size);
}
let mut hyper_response = Response::new(Full::new(Bytes::from(body)));
*hyper_response.status_mut() = StatusCode::from_u16(status_code).unwrap();
Ok(hyper_response)
}
async fn handle_http(
req: Request<hyper::body::Incoming>,
upstream_url: Option<String>,
) -> Result<Response<Full<Bytes>>, Infallible> {
// Use reqwest for all HTTP requests as it handles proxies better
// This is faster and more reliable than trying to use hyper-proxy with version conflicts
// Extract domain for traffic tracking
let domain = req
.uri()
.host()
.map(|h| h.to_string())
.unwrap_or_else(|| "unknown".to_string());
log::error!(
"DEBUG: Handling HTTP request: {} {} (host: {:?})",
req.method(),
req.uri(),
req.uri().host()
);
// Check if we need to handle SOCKS4 manually (reqwest doesn't support it)
if let Some(ref upstream) = upstream_url {
if upstream != "DIRECT" {
if let Ok(url) = Url::parse(upstream) {
if url.scheme() == "socks4" {
// Handle SOCKS4 manually for HTTP requests
return handle_http_via_socks4(req, upstream).await;
}
}
}
}
// Use reqwest for HTTP/HTTPS/SOCKS5 proxies
use reqwest::Client;
let client_builder = Client::builder();
@@ -370,6 +777,12 @@ async fn handle_http(
let headers = response.headers().clone();
let body = response.bytes().await.unwrap_or_default();
// Record request in traffic tracker
let response_size = body.len() as u64;
if let Some(tracker) = get_traffic_tracker() {
tracker.record_request(&domain, body_bytes.len() as u64, response_size);
}
let mut hyper_response = Response::new(Full::new(body));
*hyper_response.status_mut() = StatusCode::from_u16(status.as_u16()).unwrap();
@@ -407,6 +820,7 @@ fn build_reqwest_client_with_proxy(
let proxy = match scheme {
"http" | "https" => {
// For HTTP/HTTPS proxies, reqwest handles them directly
// Note: HTTPS proxy URLs still use HTTP CONNECT method, reqwest handles TLS automatically
Proxy::http(upstream_url)?
}
"socks5" => {
@@ -414,8 +828,9 @@ fn build_reqwest_client_with_proxy(
Proxy::all(upstream_url)?
}
"socks4" => {
// SOCKS4 is not directly supported by reqwest, would need custom handling
return Err("SOCKS4 not supported for HTTP requests via reqwest".into());
// SOCKS4 is handled manually in handle_http_via_socks4
// This should not be reached, but return error as fallback
return Err("SOCKS4 should be handled manually".into());
}
_ => {
return Err(format!("Unsupported proxy scheme: {}", scheme).into());
@@ -441,14 +856,35 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
};
log::error!(
"Found config: id={}, port={:?}, upstream={}",
"Found config: id={}, port={:?}, upstream={}, profile_id={:?}",
config.id,
config.local_port,
config.upstream_url
config.upstream_url,
config.profile_id
);
log::error!("Starting proxy server for config id: {}", config.id);
// Initialize traffic tracker with profile ID if available
// This can now be called multiple times to update the tracker
init_traffic_tracker(config.id.clone(), config.profile_id.clone());
log::error!(
"Traffic tracker initialized for proxy: {} (profile_id: {:?})",
config.id,
config.profile_id
);
// Verify tracker was initialized correctly
if let Some(tracker) = crate::traffic_stats::get_traffic_tracker() {
log::error!(
"Tracker verified: proxy_id={}, profile_id={:?}",
tracker.proxy_id,
tracker.profile_id
);
} else {
log::error!("WARNING: Tracker was not initialized!");
}
// Determine the bind address
let bind_addr = SocketAddr::from(([127, 0, 0, 1], config.local_port.unwrap_or(0)));
@@ -488,44 +924,189 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
);
log::error!("Proxy server entering accept loop - process should stay alive");
// Start a background task to write lightweight session snapshots for real-time updates
// These are much smaller than full stats and can be written frequently (~100 bytes every 2 seconds)
if let Some(tracker) = get_traffic_tracker() {
let tracker_clone = tracker.clone();
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(2));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
loop {
interval.tick().await;
// Write lightweight session snapshot (only current counters, ~100 bytes)
if let Err(e) = tracker_clone.write_session_snapshot() {
log::debug!("Failed to write session snapshot: {}", e);
}
}
});
}
// Start a background task to periodically flush traffic stats to disk
// Use adaptive flush frequency: every 5 seconds when active, every 30 seconds when idle
tokio::spawn(async move {
let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(5));
interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
let mut last_activity_time = std::time::Instant::now();
let mut last_flush_time = std::time::Instant::now();
let mut current_interval_secs = 5u64;
loop {
interval.tick().await;
if let Some(tracker) = get_traffic_tracker() {
let (sent, recv, requests) = tracker.get_snapshot();
let current_bytes = sent + recv;
let time_since_activity = last_activity_time.elapsed();
let time_since_flush = last_flush_time.elapsed();
let has_traffic = current_bytes > 0 || requests > 0;
// Determine flush frequency based on activity
// When active: flush every 5 seconds
// When idle: flush every 30 seconds
let desired_interval_secs =
if has_traffic || time_since_activity < std::time::Duration::from_secs(30) {
5u64
} else {
30u64
};
// Update interval if needed
if desired_interval_secs != current_interval_secs {
current_interval_secs = desired_interval_secs;
interval = tokio::time::interval(tokio::time::Duration::from_secs(desired_interval_secs));
}
// Only flush if enough time has passed since last flush
let flush_interval = std::time::Duration::from_secs(desired_interval_secs);
let should_flush = time_since_flush >= flush_interval;
if should_flush {
match tracker.flush_to_disk() {
Ok(Some((sent, recv))) => {
// Successful flush with data
last_flush_time = std::time::Instant::now();
if sent > 0 || recv > 0 {
last_activity_time = std::time::Instant::now();
}
}
Ok(None) => {
// No data to flush - this is normal
last_flush_time = std::time::Instant::now();
}
Err(e) => {
log::error!("Failed to flush traffic stats: {}", e);
// Don't update flush time on error - retry sooner
}
}
}
}
}
});
// Keep the runtime alive with an infinite loop
// This ensures the process doesn't exit even if there are no active connections
loop {
match listener.accept().await {
Ok((mut stream, _)) => {
Ok((mut stream, peer_addr)) => {
// Enable TCP_NODELAY to ensure small packets are sent immediately
// This is critical for CONNECT responses to be sent before tunneling begins
let _ = stream.set_nodelay(true);
log::error!("DEBUG: Accepted connection from {:?}", peer_addr);
let upstream = upstream_url.clone();
tokio::task::spawn(async move {
// Read first bytes to detect CONNECT requests
// CONNECT requests need special handling for tunneling
let mut peek_buffer = [0u8; 8];
// Use a larger buffer to ensure we can detect CONNECT even with partial reads
let mut peek_buffer = [0u8; 16];
match stream.read(&mut peek_buffer).await {
Ok(n) if n >= 7 => {
let request_start = String::from_utf8_lossy(&peek_buffer[..n.min(7)]);
if request_start.starts_with("CONNECT") {
Ok(0) => {
log::error!("DEBUG: Connection closed immediately (0 bytes read)");
}
Ok(n) => {
// Check if this looks like a CONNECT request
// Be more lenient - check if the first bytes match "CONNECT" (case-insensitive)
let request_start_upper =
String::from_utf8_lossy(&peek_buffer[..n.min(7)]).to_uppercase();
let is_connect = request_start_upper.starts_with("CONNECT");
log::error!(
"DEBUG: Read {} bytes, starts with: {:?}, is_connect: {}",
n,
String::from_utf8_lossy(&peek_buffer[..n.min(20)]),
is_connect
);
if is_connect {
// Handle CONNECT request manually for tunneling
let mut full_request = Vec::with_capacity(4096);
full_request.extend_from_slice(&peek_buffer[..n]);
// Read the rest of the CONNECT request
// Read the rest of the CONNECT request until we have the full headers
// CONNECT requests end with \r\n\r\n (or \n\n)
let mut remaining = [0u8; 4096];
let mut total_read = n;
let max_reads = 100; // Prevent infinite loop
let mut reads = 0;
loop {
if reads >= max_reads {
log::error!("DEBUG: Max reads reached, breaking");
break;
}
match stream.read(&mut remaining).await {
Ok(0) => break,
Ok(m) => {
full_request.extend_from_slice(&remaining[..m]);
Ok(0) => {
// Connection closed, but we might have a complete request
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
break;
}
// If we have some data, try to process it anyway
if total_read > 0 {
break;
}
return; // No data at all
}
Ok(m) => {
reads += 1;
total_read += m;
full_request.extend_from_slice(&remaining[..m]);
// Check if we have complete headers
if full_request.ends_with(b"\r\n\r\n") || full_request.ends_with(b"\n\n") {
break;
}
// Also check if we have enough to parse (at least "CONNECT host:port HTTP/1.x")
if total_read >= 20 {
// Check if we have a newline that might indicate end of request line
if let Some(pos) = full_request.iter().position(|&b| b == b'\n') {
if pos < full_request.len() - 1 {
// We have at least the request line, check if we have headers
let request_str = String::from_utf8_lossy(&full_request);
if request_str.contains("\r\n\r\n") || request_str.contains("\n\n") {
break;
}
}
}
}
}
Err(e) => {
log::error!("DEBUG: Error reading CONNECT request: {:?}", e);
// If we have some data, try to process it
if total_read > 0 {
break;
}
return;
}
Err(_) => break,
}
}
// Handle CONNECT manually
log::error!(
"DEBUG: Handling CONNECT manually for: {}",
String::from_utf8_lossy(&full_request[..full_request.len().min(100)])
String::from_utf8_lossy(&full_request[..full_request.len().min(200)])
);
if let Err(e) = handle_connect_from_buffer(stream, full_request, upstream).await {
log::error!("Error handling CONNECT request: {:?}", e);
@@ -534,7 +1115,14 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
}
return;
}
// Not CONNECT - reconstruct stream with consumed bytes prepended
// Not CONNECT (or partial read) - reconstruct stream with consumed bytes prepended
// This is critical: we MUST prepend any bytes we consumed, even if < 7 bytes
log::error!(
"DEBUG: Non-CONNECT request, first {} bytes: {:?}",
n,
String::from_utf8_lossy(&peek_buffer[..n.min(50)])
);
let prepended_bytes = peek_buffer[..n].to_vec();
let prepended_reader = PrependReader {
prepended: prepended_bytes,
@@ -547,17 +1135,10 @@ pub async fn run_proxy_server(config: ProxyConfig) -> Result<(), Box<dyn std::er
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
log::error!("Error serving connection: {:?}", err);
}
return;
}
_ => {}
}
// For non-CONNECT requests, use hyper's HTTP handling
let io = TokioIo::new(stream);
let service = service_fn(move |req| handle_request(req, upstream.clone()));
if let Err(err) = http1::Builder::new().serve_connection(io, service).await {
log::error!("Error serving connection: {:?}", err);
Err(e) => {
log::error!("Error reading from connection: {:?}", e);
}
}
});
}
@@ -605,85 +1186,99 @@ async fn handle_connect_from_buffer(
(target, 443)
};
// Record domain access in traffic tracker
let domain = target_host.to_string();
if let Some(tracker) = get_traffic_tracker() {
tracker.record_request(&domain, 0, 0);
}
// Connect to target (directly or via upstream proxy)
let target_stream = if upstream_url.is_none()
|| upstream_url
.as_ref()
.map(|s| s == "DIRECT")
.unwrap_or(false)
{
// Direct connection
TcpStream::connect((target_host, target_port)).await?
} else {
// Connect via upstream proxy
let upstream = Url::parse(upstream_url.as_ref().unwrap())?;
let scheme = upstream.scheme();
let target_stream = match upstream_url.as_ref() {
None => {
// Direct connection
TcpStream::connect((target_host, target_port)).await?
}
Some(url) if url == "DIRECT" => {
// Direct connection
TcpStream::connect((target_host, target_port)).await?
}
Some(upstream_url_str) => {
// Connect via upstream proxy
let upstream = Url::parse(upstream_url_str)?;
let scheme = upstream.scheme();
match scheme {
"http" | "https" => {
// Connect via HTTP proxy CONNECT
let proxy_host = upstream.host_str().unwrap_or("127.0.0.1");
let proxy_port = upstream.port().unwrap_or(8080);
let mut proxy_stream = TcpStream::connect((proxy_host, proxy_port)).await?;
match scheme {
"http" | "https" => {
// Connect via HTTP/HTTPS proxy CONNECT
// Note: HTTPS proxy URLs still use HTTP CONNECT method (CONNECT is always HTTP-based)
// For HTTPS proxies, reqwest handles TLS automatically in handle_http
// For manual CONNECT here, we use plain TCP - HTTPS proxy CONNECT typically works over plain TCP
let proxy_host = upstream.host_str().unwrap_or("127.0.0.1");
let proxy_port = upstream.port().unwrap_or(8080);
let mut proxy_stream = TcpStream::connect((proxy_host, proxy_port)).await?;
// Add authentication if provided
let mut connect_req = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
target_host, target_port, target_host, target_port
);
// Add authentication if provided
let mut connect_req = format!(
"CONNECT {}:{} HTTP/1.1\r\nHost: {}:{}\r\n",
target_host, target_port, target_host, target_port
);
if !upstream.username().is_empty() {
use base64::{engine::general_purpose, Engine as _};
let username = upstream.username();
let password = upstream.password().unwrap_or("");
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
}
connect_req.push_str("\r\n");
// Send CONNECT request to upstream proxy
proxy_stream.write_all(connect_req.as_bytes()).await?;
// Read response
let mut buffer = [0u8; 4096];
let n = proxy_stream.read(&mut buffer).await?;
let response = String::from_utf8_lossy(&buffer[..n]);
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
return Err(format!("Upstream proxy CONNECT failed: {}", response).into());
}
proxy_stream
}
"socks4" | "socks5" => {
// Connect via SOCKS proxy
let socks_host = upstream.host_str().unwrap_or("127.0.0.1");
let socks_port = upstream.port().unwrap_or(1080);
let socks_addr = format!("{}:{}", socks_host, socks_port);
if !upstream.username().is_empty() {
use base64::{engine::general_purpose, Engine as _};
let username = upstream.username();
let password = upstream.password().unwrap_or("");
let auth = general_purpose::STANDARD.encode(format!("{}:{}", username, password));
connect_req.push_str(&format!("Proxy-Authorization: Basic {}\r\n", auth));
connect_via_socks(
&socks_addr,
target_host,
target_port,
scheme == "socks5",
if !username.is_empty() {
Some((username, password))
} else {
None
},
)
.await?
}
connect_req.push_str("\r\n");
// Send CONNECT request to upstream proxy
proxy_stream.write_all(connect_req.as_bytes()).await?;
// Read response
let mut buffer = [0u8; 4096];
let n = proxy_stream.read(&mut buffer).await?;
let response = String::from_utf8_lossy(&buffer[..n]);
if !response.starts_with("HTTP/1.1 200") && !response.starts_with("HTTP/1.0 200") {
return Err(format!("Upstream proxy CONNECT failed: {}", response).into());
_ => {
return Err(format!("Unsupported upstream proxy scheme: {}", scheme).into());
}
proxy_stream
}
"socks4" | "socks5" => {
// Connect via SOCKS proxy
let socks_host = upstream.host_str().unwrap_or("127.0.0.1");
let socks_port = upstream.port().unwrap_or(1080);
let socks_addr = format!("{}:{}", socks_host, socks_port);
let username = upstream.username();
let password = upstream.password().unwrap_or("");
connect_via_socks(
&socks_addr,
target_host,
target_port,
scheme == "socks5",
if !username.is_empty() {
Some((username, password))
} else {
None
},
)
.await?
}
_ => {
return Err(format!("Unsupported upstream proxy scheme: {}", scheme).into());
}
}
};
// Enable TCP_NODELAY on target stream for immediate data transfer
let _ = target_stream.set_nodelay(true);
// Send 200 Connection Established response to client
// CRITICAL: Must flush after writing to ensure response is sent before tunneling
client_stream
@@ -693,10 +1288,20 @@ async fn handle_connect_from_buffer(
log::error!("DEBUG: Sent 200 Connection Established response, starting tunnel");
// Now tunnel data bidirectionally
// Now tunnel data bidirectionally with counting
// Wrap streams to count bytes transferred
let counting_client = CountingStream::new(client_stream);
let counting_target = CountingStream::new(target_stream);
// Get references for final stats
let client_read_counter = counting_client.bytes_read.clone();
let client_write_counter = counting_client.bytes_written.clone();
let target_read_counter = counting_target.bytes_read.clone();
let target_write_counter = counting_target.bytes_written.clone();
// Split streams for bidirectional copying
let (mut client_read, mut client_write) = tokio::io::split(client_stream);
let (mut target_read, mut target_write) = tokio::io::split(target_stream);
let (mut client_read, mut client_write) = tokio::io::split(counting_client);
let (mut target_read, mut target_write) = tokio::io::split(counting_target);
log::error!("DEBUG: Starting bidirectional tunnel");
@@ -735,5 +1340,21 @@ async fn handle_connect_from_buffer(
}
}
// Log final byte counts and update domain stats
let final_sent =
client_read_counter.load(Ordering::Relaxed) + target_write_counter.load(Ordering::Relaxed);
let final_recv =
target_read_counter.load(Ordering::Relaxed) + client_write_counter.load(Ordering::Relaxed);
log::error!(
"DEBUG: Tunnel closed - sent: {} bytes, received: {} bytes",
final_sent,
final_recv
);
// Update domain-specific byte counts now that tunnel is complete
if let Some(tracker) = get_traffic_tracker() {
tracker.update_domain_bytes(&domain, final_sent, final_recv);
}
Ok(())
}
+9 -1
View File
@@ -11,6 +11,8 @@ pub struct ProxyConfig {
pub ignore_proxy_certificate: Option<bool>,
pub local_url: Option<String>,
pub pid: Option<u32>,
#[serde(default)]
pub profile_id: Option<String>,
}
impl ProxyConfig {
@@ -22,8 +24,14 @@ impl ProxyConfig {
ignore_proxy_certificate: None,
local_url: None,
pid: None,
profile_id: None,
}
}
pub fn with_profile_id(mut self, profile_id: Option<String>) -> Self {
self.profile_id = profile_id;
self
}
}
pub fn get_storage_dir() -> PathBuf {
@@ -125,6 +133,6 @@ pub fn generate_proxy_id() -> String {
pub fn is_process_running(pid: u32) -> bool {
use sysinfo::{Pid, System};
let system = System::new_all();
let system = System::new();
system.process(Pid::from(pid as usize)).is_some()
}
File diff suppressed because it is too large Load Diff
+53 -47
View File
@@ -32,7 +32,7 @@ pub struct BackgroundUpdateResult {
}
#[derive(Debug, Serialize, Deserialize)]
struct BackgroundUpdateState {
pub(crate) struct BackgroundUpdateState {
last_update_time: u64,
update_interval_hours: u64,
}
@@ -78,12 +78,12 @@ impl VersionUpdater {
Ok(cache_dir)
}
fn get_background_update_state_file() -> Result<PathBuf, Box<dyn std::error::Error>> {
pub(crate) fn get_background_update_state_file() -> Result<PathBuf, Box<dyn std::error::Error>> {
let cache_dir = Self::get_cache_dir()?;
Ok(cache_dir.join("background_update_state.json"))
}
fn load_background_update_state() -> BackgroundUpdateState {
pub(crate) fn load_background_update_state() -> BackgroundUpdateState {
let state_file = match Self::get_background_update_state_file() {
Ok(file) => file,
Err(_) => return BackgroundUpdateState::default(),
@@ -101,7 +101,7 @@ impl VersionUpdater {
serde_json::from_str(&content).unwrap_or_default()
}
fn save_background_update_state(
pub(crate) fn save_background_update_state(
state: &BackgroundUpdateState,
) -> Result<(), Box<dyn std::error::Error>> {
let state_file = Self::get_background_update_state_file()?;
@@ -516,50 +516,31 @@ pub async fn clear_all_version_cache_and_refetch(
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
use tempfile::TempDir;
// Helper function to create a unique test state file
fn get_test_state_file(test_name: &str) -> PathBuf {
let cache_dir = VersionUpdater::get_cache_dir().unwrap();
cache_dir.join(format!("test_{test_name}_state.json"))
fn setup_test_env() -> TempDir {
let temp_dir = TempDir::new().expect("Failed to create temp directory");
env::set_var("HOME", temp_dir.path());
temp_dir
}
fn save_test_state(
test_name: &str,
state: &BackgroundUpdateState,
) -> Result<(), Box<dyn std::error::Error>> {
let state_file = get_test_state_file(test_name);
let content = serde_json::to_string_pretty(state)?;
fs::write(&state_file, content)?;
Ok(())
}
fn load_test_state(test_name: &str) -> BackgroundUpdateState {
let state_file = get_test_state_file(test_name);
if !state_file.exists() {
return BackgroundUpdateState::default();
}
let content = match fs::read_to_string(&state_file) {
Ok(content) => content,
Err(_) => return BackgroundUpdateState::default(),
};
match serde_json::from_str(&content) {
Ok(state) => state,
Err(e) => {
eprintln!("Failed to parse test state file {:?}: {}", state_file, e);
BackgroundUpdateState::default()
}
fn cleanup_state_file() {
if let Ok(state_file) = VersionUpdater::get_background_update_state_file() {
let _ = fs::remove_file(&state_file);
}
}
#[test]
#[serial]
fn test_background_update_state_persistence() {
let test_name = "persistence";
let _temp_dir = setup_test_env();
// Clean up any existing test file first
let _ = fs::remove_file(get_test_state_file(test_name));
// Clean up any existing state file first
if let Ok(state_file) = VersionUpdater::get_background_update_state_file() {
let _ = fs::remove_file(&state_file);
}
// Create a test state
let test_state = BackgroundUpdateState {
@@ -568,33 +549,55 @@ mod tests {
};
// Save the state
save_test_state(test_name, &test_state).unwrap();
let save_result = VersionUpdater::save_background_update_state(&test_state);
assert!(save_result.is_ok(), "Should save state successfully");
// Verify file was created
let state_file = get_test_state_file(test_name);
let state_file = VersionUpdater::get_background_update_state_file().unwrap();
assert!(state_file.exists(), "State file should exist after saving");
// Load the state back
let loaded_state = load_test_state(test_name);
// Read the file directly to verify contents
let file_content = fs::read_to_string(&state_file).expect("Should read state file");
let file_state: BackgroundUpdateState =
serde_json::from_str(&file_content).expect("Should parse state file");
// Verify the file contents match what we saved
assert_eq!(
file_state.last_update_time, test_state.last_update_time,
"File last_update_time should match. Expected: {}, Got: {}",
test_state.last_update_time, file_state.last_update_time
);
assert_eq!(
file_state.update_interval_hours, test_state.update_interval_hours,
"File update_interval_hours should match"
);
// Load the state back using the method
let loaded_state = VersionUpdater::load_background_update_state();
// Verify the values match
assert_eq!(
loaded_state.last_update_time, test_state.last_update_time,
"last_update_time should match. Expected: {}, Got: {}",
"Loaded last_update_time should match. Expected: {}, Got: {}",
test_state.last_update_time, loaded_state.last_update_time
);
assert_eq!(
loaded_state.update_interval_hours, test_state.update_interval_hours,
"update_interval_hours should match"
"Loaded update_interval_hours should match"
);
// Clean up
let _ = fs::remove_file(get_test_state_file(test_name));
cleanup_state_file();
}
#[test]
#[serial]
fn test_should_run_background_update_logic() {
// Create isolated test states to avoid interference
let _temp_dir = setup_test_env();
// Clean up any existing state file first
cleanup_state_file();
let current_time = VersionUpdater::get_current_timestamp();
// Test with recent update (should not update)
@@ -643,6 +646,9 @@ mod tests {
should_update_never,
"Should update when never updated before"
);
// Clean up
cleanup_state_file();
}
#[test]
+2 -2
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "Donut",
"version": "0.12.3",
"version": "0.13.8",
"identifier": "com.donutbrowser",
"build": {
"beforeDevCommand": "pnpm copy-proxy-binary && pnpm dev",
@@ -17,7 +17,7 @@
},
"bundle": {
"active": true,
"targets": "all",
"targets": ["app", "dmg", "nsis", "deb", "rpm", "appimage"],
"category": "Productivity",
"externalBin": ["binaries/nodecar", "binaries/donut-proxy"],
"icon": [
+161 -7
View File
@@ -351,7 +351,8 @@ async fn test_multiple_proxies_simultaneously(
let mut proxy_ports = Vec::new();
// Start 3 proxies with a small delay between each to avoid race conditions
// Start 3 proxies, waiting for each to be ready before starting the next
// This avoids race conditions on macOS where processes need time to initialize
for i in 0..3 {
let output = TestUtils::execute_command(&binary_path, &["proxy", "start"]).await?;
if !output.status.success() {
@@ -376,14 +377,36 @@ async fn test_multiple_proxies_simultaneously(
println!("Proxy {} started on port {}", i + 1, local_port);
// Small delay between starting proxies to avoid resource contention
sleep(Duration::from_millis(100)).await;
// Wait for this proxy to be ready before starting the next one
// This prevents race conditions on macOS where processes need time to initialize
let mut attempts = 0;
let max_attempts = 50; // 5 seconds max (50 * 100ms)
loop {
sleep(Duration::from_millis(100)).await;
match TcpStream::connect(("127.0.0.1", local_port)).await {
Ok(_) => {
println!("Proxy {} is ready on port {}", i + 1, local_port);
break;
}
Err(_) => {
attempts += 1;
if attempts >= max_attempts {
return Err(
format!(
"Proxy {} on port {} failed to become ready after {} attempts",
i + 1,
local_port,
max_attempts
)
.into(),
);
}
}
}
}
}
// Wait for all proxies to be ready
sleep(Duration::from_millis(1000)).await;
// Verify all proxies are listening
// Verify all proxies are still listening
for (i, port) in proxy_ports.iter().enumerate() {
match TcpStream::connect(("127.0.0.1", *port)).await {
Ok(_) => {
@@ -439,6 +462,137 @@ async fn test_proxy_list() -> Result<(), Box<dyn std::error::Error + Send + Sync
Ok(())
}
/// Test traffic tracking through proxy
#[tokio::test]
#[serial]
async fn test_traffic_tracking() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let binary_path = setup_test().await?;
let mut tracker = ProxyTestTracker::new(binary_path.clone());
println!("Testing traffic tracking through proxy...");
// Start a proxy
let output = TestUtils::execute_command(&binary_path, &["proxy", "start"]).await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let stdout = String::from_utf8_lossy(&output.stdout);
return Err(format!("Failed to start proxy - stdout: {stdout}, stderr: {stderr}").into());
}
let config: Value = serde_json::from_str(&String::from_utf8(output.stdout)?)?;
let proxy_id = config["id"].as_str().unwrap().to_string();
let local_port = config["localPort"].as_u64().unwrap() as u16;
tracker.track_proxy(proxy_id.clone());
println!("Proxy started on port {}", local_port);
// Wait for proxy to be ready
sleep(Duration::from_millis(500)).await;
// Make an HTTP request through the proxy
let mut stream = TcpStream::connect(("127.0.0.1", local_port)).await?;
let request =
b"GET http://httpbin.org/ip HTTP/1.1\r\nHost: httpbin.org\r\nConnection: close\r\n\r\n";
// Track bytes sent
let bytes_sent = request.len();
stream.write_all(request).await?;
// Read response
let mut response = Vec::new();
stream.read_to_end(&mut response).await?;
let bytes_received = response.len();
println!(
"HTTP request completed: sent {} bytes, received {} bytes",
bytes_sent, bytes_received
);
// Wait for traffic stats to be flushed (happens every second)
sleep(Duration::from_secs(2)).await;
// Verify traffic was tracked by checking traffic stats file exists
// Note: Traffic stats are stored in the cache directory
let cache_dir = directories::BaseDirs::new()
.expect("Failed to get base directories")
.cache_dir()
.to_path_buf();
let traffic_stats_dir = cache_dir.join("DonutBrowserDev").join("traffic_stats");
let stats_file = traffic_stats_dir.join(format!("{}.json", proxy_id));
if stats_file.exists() {
let content = std::fs::read_to_string(&stats_file)?;
let stats: Value = serde_json::from_str(&content)?;
let total_sent = stats["total_bytes_sent"].as_u64().unwrap_or(0);
let total_received = stats["total_bytes_received"].as_u64().unwrap_or(0);
let total_requests = stats["total_requests"].as_u64().unwrap_or(0);
println!(
"Traffic stats recorded: sent {} bytes, received {} bytes, {} requests",
total_sent, total_received, total_requests
);
// Check if domains are being tracked
let mut domain_traffic = false;
if let Some(domains) = stats.get("domains") {
if let Some(domain_map) = domains.as_object() {
println!("Domains tracked: {}", domain_map.len());
for (domain, domain_stats) in domain_map {
println!(" - {}", domain);
// Check if any domain has traffic
if let Some(domain_obj) = domain_stats.as_object() {
let domain_sent = domain_obj
.get("bytes_sent")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let domain_recv = domain_obj
.get("bytes_received")
.and_then(|v| v.as_u64())
.unwrap_or(0);
let domain_reqs = domain_obj
.get("request_count")
.and_then(|v| v.as_u64())
.unwrap_or(0);
println!(
" sent: {}, received: {}, requests: {}",
domain_sent, domain_recv, domain_reqs
);
if domain_sent > 0 || domain_recv > 0 || domain_reqs > 0 {
domain_traffic = true;
}
}
}
}
}
// Verify that some traffic was recorded - check either total bytes or domain traffic
assert!(
total_sent > 0 || total_received > 0 || total_requests > 0 || domain_traffic,
"Traffic stats should record some activity (sent: {}, received: {}, requests: {})",
total_sent,
total_received,
total_requests
);
println!("Traffic tracking test passed!");
} else {
println!("Warning: Traffic stats file not found at {:?}", stats_file);
// This is not necessarily a failure - the file may not have been created yet
// The important thing is that the proxy is working
}
// Cleanup
tracker.cleanup_all().await;
// Clean up the traffic stats file
if stats_file.exists() {
let _ = std::fs::remove_file(&stats_file);
}
Ok(())
}
/// Test proxy stop
#[tokio::test]
#[serial]
+49 -9
View File
@@ -15,6 +15,7 @@ import { ImportProfileDialog } from "@/components/import-profile-dialog";
import { PermissionDialog } from "@/components/permission-dialog";
import { ProfilesDataTable } from "@/components/profile-data-table";
import { ProfileSelectorDialog } from "@/components/profile-selector-dialog";
import { ProxyAssignmentDialog } from "@/components/proxy-assignment-dialog";
import { ProxyManagementDialog } from "@/components/proxy-management-dialog";
import { SettingsDialog } from "@/components/settings-dialog";
import { useAppUpdateNotifications } from "@/hooks/use-app-update-notifications";
@@ -29,13 +30,11 @@ import { showErrorToast, showToast } from "@/lib/toast-utils";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
type BrowserTypeString =
| "mullvad-browser"
| "firefox"
| "firefox-developer"
| "chromium"
| "brave"
| "zen"
| "tor-browser"
| "camoufox";
interface PendingUrl {
@@ -62,7 +61,11 @@ export default function Home() {
error: groupsError,
} = useGroupEvents();
const { isLoading: proxiesLoading, error: proxiesError } = useProxyEvents();
const {
storedProxies,
isLoading: proxiesLoading,
error: proxiesError,
} = useProxyEvents();
const [createProfileDialogOpen, setCreateProfileDialogOpen] = useState(false);
const [settingsDialogOpen, setSettingsDialogOpen] = useState(false);
@@ -75,10 +78,15 @@ export default function Home() {
useState(false);
const [groupAssignmentDialogOpen, setGroupAssignmentDialogOpen] =
useState(false);
const [proxyAssignmentDialogOpen, setProxyAssignmentDialogOpen] =
useState(false);
const [selectedGroupId, setSelectedGroupId] = useState<string>("default");
const [selectedProfilesForGroup, setSelectedProfilesForGroup] = useState<
string[]
>([]);
const [selectedProfilesForProxy, setSelectedProfilesForProxy] = useState<
string[]
>([]);
const [selectedProfiles, setSelectedProfiles] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState<string>("");
const [pendingUrls, setPendingUrls] = useState<PendingUrl[]>([]);
@@ -559,12 +567,29 @@ export default function Home() {
setSelectedProfiles([]);
}, [selectedProfiles, handleAssignProfilesToGroup]);
const handleAssignProfilesToProxy = useCallback((profileIds: string[]) => {
setSelectedProfilesForProxy(profileIds);
setProxyAssignmentDialogOpen(true);
}, []);
const handleBulkProxyAssignment = useCallback(() => {
if (selectedProfiles.length === 0) return;
handleAssignProfilesToProxy(selectedProfiles);
setSelectedProfiles([]);
}, [selectedProfiles, handleAssignProfilesToProxy]);
const handleGroupAssignmentComplete = useCallback(async () => {
// No need to manually reload - useProfileEvents will handle the update
setGroupAssignmentDialogOpen(false);
setSelectedProfilesForGroup([]);
}, []);
const handleProxyAssignmentComplete = useCallback(async () => {
// No need to manually reload - useProfileEvents will handle the update
setProxyAssignmentDialogOpen(false);
setSelectedProfilesForProxy([]);
}, []);
const handleGroupManagementComplete = useCallback(async () => {
// No need to manually reload - useProfileEvents will handle the update
}, []);
@@ -621,9 +646,7 @@ export default function Home() {
if (profiles.length === 0) return;
const deprecatedProfiles = profiles.filter(
(p) =>
["tor-browser", "mullvad-browser"].includes(p.browser) ||
(p.release_type === "nightly" && p.browser !== "firefox-developer"),
(p) => p.release_type === "nightly" && p.browser !== "firefox-developer",
);
if (deprecatedProfiles.length > 0) {
@@ -634,7 +657,7 @@ export default function Home() {
id: "deprecated-profiles-warning",
type: "error",
title: "Some profiles will be deprecated soon",
description: `The following profiles will be deprecated soon: ${deprecatedNames}. Tor Browser, Mullvad Browser, and nightly profiles (except Firefox Developers Edition) will be removed in upcoming versions. Please check GitHub for migration instructions.`,
description: `The following profiles will be deprecated soon: ${deprecatedNames}. Nightly profiles (except Firefox Developers Edition) will be removed in upcoming versions. Please check GitHub for migration instructions.`,
duration: 15000,
action: {
label: "Learn more",
@@ -676,8 +699,8 @@ export default function Home() {
// Search in profile name
if (profile.name.toLowerCase().includes(query)) return true;
// Search in browser name
if (profile.browser.toLowerCase().includes(query)) return true;
// Search in note
if (profile.note?.toLowerCase().includes(query)) return true;
// Search in tags
if (profile.tags?.some((tag) => tag.toLowerCase().includes(query)))
@@ -730,6 +753,7 @@ export default function Home() {
onSelectedProfilesChange={setSelectedProfiles}
onBulkDelete={handleBulkDelete}
onBulkGroupAssignment={handleBulkGroupAssignment}
onBulkProxyAssignment={handleBulkProxyAssignment}
/>
</div>
</main>
@@ -807,6 +831,11 @@ export default function Home() {
}}
profile={currentProfileForCamoufoxConfig}
onSave={handleSaveCamoufoxConfig}
isRunning={
currentProfileForCamoufoxConfig
? runningProfiles.has(currentProfileForCamoufoxConfig.id)
: false
}
/>
<GroupManagementDialog
@@ -827,6 +856,17 @@ export default function Home() {
profiles={profiles}
/>
<ProxyAssignmentDialog
isOpen={proxyAssignmentDialogOpen}
onClose={() => {
setProxyAssignmentDialogOpen(false);
}}
selectedProfiles={selectedProfilesForProxy}
onAssignmentComplete={handleProxyAssignmentComplete}
profiles={profiles}
storedProxies={storedProxies}
/>
<DeleteConfirmationDialog
isOpen={showBulkDeleteConfirmation}
onClose={() => setShowBulkDeleteConfirmation(false)}
+120
View File
@@ -0,0 +1,120 @@
"use client";
import * as React from "react";
import { Area, AreaChart, ResponsiveContainer } from "recharts";
import { cn } from "@/lib/utils";
import type { BandwidthDataPoint } from "@/types";
interface BandwidthMiniChartProps {
data: BandwidthDataPoint[];
currentBandwidth?: number;
onClick?: () => void;
className?: string;
}
export function BandwidthMiniChart({
data,
currentBandwidth: externalBandwidth,
onClick,
className,
}: BandwidthMiniChartProps) {
// Transform data for the chart - combine sent and received for total bandwidth
const chartData = React.useMemo(() => {
// Fill in missing seconds with zeros for smooth chart
if (data.length === 0) {
// Create 60 seconds of zero data for the past minute
const now = Math.floor(Date.now() / 1000);
return Array.from({ length: 60 }, (_, i) => ({
time: now - (59 - i),
bandwidth: 0,
}));
}
const now = Math.floor(Date.now() / 1000);
const result: { time: number; bandwidth: number }[] = [];
// Get the last 60 seconds
for (let i = 59; i >= 0; i--) {
const targetTime = now - i;
const point = data.find((d) => d.timestamp === targetTime);
result.push({
time: targetTime,
bandwidth: point ? point.bytes_sent + point.bytes_received : 0,
});
}
return result;
}, [data]);
// Find max value for scaling
const _maxBandwidth = React.useMemo(() => {
const max = Math.max(...chartData.map((d) => d.bandwidth), 1);
return max;
}, [chartData]);
// Use external bandwidth if provided, otherwise calculate from last data point
const currentBandwidth =
externalBandwidth ?? chartData[chartData.length - 1]?.bandwidth ?? 0;
// Format bytes to human readable
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B/s";
if (bytes < 1024) return `${bytes} B/s`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB/s`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB/s`;
};
return (
<button
type="button"
onClick={onClick}
className={cn(
"relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors min-w-[120px] border-none bg-transparent",
className,
)}
>
<div className="flex-1 h-3 pointer-events-none">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 0, right: 0, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="bandwidthGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-1)"
stopOpacity={0.6}
/>
<stop
offset="100%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<Area
type="monotone"
dataKey="bandwidth"
stroke="var(--chart-1)"
strokeWidth={1}
fill="url(#bandwidthGradient)"
isAnimationActive={false}
dot={false}
activeDot={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<span className="text-xs text-muted-foreground whitespace-nowrap min-w-[60px] text-right">
{formatBytes(currentBandwidth)}
</span>
</button>
);
}
+33 -15
View File
@@ -10,7 +10,16 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
import type { BrowserProfile, CamoufoxConfig, CamoufoxOS } from "@/types";
const getCurrentOS = (): CamoufoxOS => {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
if (platform.includes("win")) return "windows";
if (platform.includes("mac")) return "macos";
return "linux";
};
import { LoadingButton } from "./loading-button";
import { RippleButton } from "./ui/ripple";
@@ -19,6 +28,7 @@ interface CamoufoxConfigDialogProps {
onClose: () => void;
profile: BrowserProfile | null;
onSave: (profile: BrowserProfile, config: CamoufoxConfig) => Promise<void>;
isRunning?: boolean;
}
export function CamoufoxConfigDialog({
@@ -26,10 +36,12 @@ export function CamoufoxConfigDialog({
onClose,
profile,
onSave,
isRunning = false,
}: CamoufoxConfigDialogProps) {
const [config, setConfig] = useState<CamoufoxConfig>({
const [config, setConfig] = useState<CamoufoxConfig>(() => ({
geoip: true,
});
os: getCurrentOS(),
}));
const [isSaving, setIsSaving] = useState(false);
// Initialize config when profile changes
@@ -38,6 +50,7 @@ export function CamoufoxConfigDialog({
setConfig(
profile.camoufox_config || {
geoip: true,
os: getCurrentOS(),
},
);
}
@@ -86,6 +99,7 @@ export function CamoufoxConfigDialog({
setConfig(
profile.camoufox_config || {
geoip: true,
os: getCurrentOS(),
},
);
}
@@ -101,33 +115,37 @@ export function CamoufoxConfigDialog({
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-3xl max-h-[90vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogHeader className="shrink-0">
<DialogTitle>
Configure Fingerprint Settings - {profile.name}
{isRunning ? "View" : "Configure"} Fingerprint Settings -{" "}
{profile.name}
</DialogTitle>
</DialogHeader>
<ScrollArea className="flex-1 h-[320px]">
<ScrollArea className="flex-1 h-[300px]">
<div className="py-4">
<SharedCamoufoxConfigForm
config={config}
onConfigChange={updateConfig}
forceAdvanced={true}
readOnly={isRunning}
/>
</div>
</ScrollArea>
<DialogFooter className="flex-shrink-0 pt-4 border-t">
<DialogFooter className="shrink-0 pt-4 border-t">
<RippleButton variant="outline" onClick={handleClose}>
Cancel
{isRunning ? "Close" : "Cancel"}
</RippleButton>
<LoadingButton
isLoading={isSaving}
onClick={handleSave}
disabled={isSaving}
>
Save
</LoadingButton>
{!isRunning && (
<LoadingButton
isLoading={isSaving}
onClick={handleSave}
disabled={isSaving}
>
Save
</LoadingButton>
)}
</DialogFooter>
</DialogContent>
</Dialog>
+17 -19
View File
@@ -29,17 +29,24 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserReleaseTypes, CamoufoxConfig } from "@/types";
import type { BrowserReleaseTypes, CamoufoxConfig, CamoufoxOS } from "@/types";
const getCurrentOS = (): CamoufoxOS => {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
if (platform.includes("win")) return "windows";
if (platform.includes("mac")) return "macos";
return "linux";
};
import { RippleButton } from "./ui/ripple";
type BrowserTypeString =
| "mullvad-browser"
| "firefox"
| "firefox-developer"
| "chromium"
| "brave"
| "zen"
| "tor-browser"
| "camoufox";
interface CreateProfileDialogProps {
@@ -83,14 +90,6 @@ const browserOptions: BrowserOption[] = [
value: "zen",
label: "Zen Browser",
},
{
value: "mullvad-browser",
label: "Mullvad Browser",
},
{
value: "tor-browser",
label: "Tor Browser",
},
];
export function CreateProfileDialog({
@@ -111,9 +110,10 @@ export function CreateProfileDialog({
const [selectedProxyId, setSelectedProxyId] = useState<string>();
// Camoufox anti-detect states
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>({
const [camoufoxConfig, setCamoufoxConfig] = useState<CamoufoxConfig>(() => ({
geoip: true, // Default to automatic geoip
});
os: getCurrentOS(), // Default to current OS
}));
// Handle browser selection from the initial screen
const handleBrowserSelect = (browser: BrowserTypeString) => {
@@ -379,6 +379,7 @@ export function CreateProfileDialog({
setReleaseTypes({});
setCamoufoxConfig({
geoip: true, // Reset to automatic geoip
os: getCurrentOS(), // Reset to current OS
});
onClose();
};
@@ -418,12 +419,9 @@ export function CreateProfileDialog({
isBrowserVersionAvailable,
]);
// Filter supported browsers for regular browsers (excluding mullvad and tor)
const regularBrowsers = browserOptions.filter(
(browser) =>
supportedBrowsers.includes(browser.value) &&
browser.value !== "mullvad-browser" &&
browser.value !== "tor-browser",
// Filter supported browsers for regular browsers
const regularBrowsers = browserOptions.filter((browser) =>
supportedBrowsers.includes(browser.value),
);
return (
+6 -1
View File
@@ -142,7 +142,12 @@ const HomeHeader = ({
</Button>
</span>
</TooltipTrigger>
<TooltipContent>Create a new profile</TooltipContent>
<TooltipContent
arrowOffset={-8}
style={{ transform: "translateX(-8px)" }}
>
Create a new profile
</TooltipContent>
</Tooltip>
</div>
</div>
+1 -4
View File
@@ -62,10 +62,7 @@ export function ImportProfileDialog({
const { supportedBrowsers, isLoading: isLoadingSupport } =
useBrowserSupport();
// Exclude browsers that are no longer supported for import
const importableBrowsers = supportedBrowsers.filter(
(b) => b !== "mullvad-browser" && b !== "tor-browser",
);
const importableBrowsers = supportedBrowsers;
const loadDetectedProfiles = useCallback(async () => {
setIsLoading(true);
-1
View File
@@ -287,7 +287,6 @@ const MultipleSelector = React.forwardRef<
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [debouncedSearchTerm, groupBy, open, triggerSearchOnFocus, onSearch]);
// biome-ignore lint/correctness/noNestedComponentDefinitions: public code, TODO: fix
const CreatableItem = () => {
if (!creatable) return undefined;
if (
+484 -120
View File
@@ -13,6 +13,7 @@ import { invoke } from "@tauri-apps/api/core";
import { emit, listen } from "@tauri-apps/api/event";
import type { Dispatch, SetStateAction } from "react";
import * as React from "react";
import { FiWifi } from "react-icons/fi";
import { IoEllipsisHorizontal } from "react-icons/io5";
import {
LuCheck,
@@ -68,15 +69,21 @@ import {
} from "@/lib/browser-utils";
import { trimName } from "@/lib/name-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, ProxyCheckResult, StoredProxy } from "@/types";
import type {
BrowserProfile,
ProxyCheckResult,
StoredProxy,
TrafficSnapshot,
} from "@/types";
import { BandwidthMiniChart } from "./bandwidth-mini-chart";
import {
DataTableActionBar,
DataTableActionBarAction,
DataTableActionBarSelection,
} from "./data-table-action-bar";
import { LoadingButton } from "./loading-button";
import MultipleSelector, { type Option } from "./multiple-selector";
import { ProxyCheckButton } from "./proxy-check-button";
import { TrafficDetailsDialog } from "./traffic-details-dialog";
import { Input } from "./ui/input";
import { RippleButton } from "./ui/ripple";
@@ -103,6 +110,14 @@ type TableMeta = {
React.SetStateAction<Record<string, string[]>>
>;
// Note editor state
noteOverrides: Record<string, string | null>;
openNoteEditorFor: string | null;
setOpenNoteEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
setNoteOverrides: React.Dispatch<
React.SetStateAction<Record<string, string | null>>
>;
// Proxy selector state
openProxySelectorFor: string | null;
setOpenProxySelectorFor: React.Dispatch<React.SetStateAction<string | null>>;
@@ -142,6 +157,10 @@ type TableMeta = {
// Overflow actions
onAssignProfilesToGroup?: (profileIds: string[]) => void;
onConfigureCamoufox?: (profile: BrowserProfile) => void;
// Traffic snapshots (lightweight real-time data)
trafficSnapshots: Record<string, TrafficSnapshot>;
onOpenTrafficDialog?: (profileId: string) => void;
};
const TagsCell = React.memo<{
@@ -312,7 +331,9 @@ const TagsCell = React.memo<{
ref={containerRef as unknown as React.RefObject<HTMLButtonElement>}
className={cn(
"flex overflow-hidden gap-1 items-center px-2 py-1 h-6 w-full bg-transparent rounded border-none cursor-pointer",
isDisabled ? "opacity-60" : "cursor-pointer hover:bg-accent/50",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
)}
onClick={() => {
if (!isDisabled) setOpenTagsEditorFor(profile.id);
@@ -335,7 +356,7 @@ const TagsCell = React.memo<{
);
return (
<div className="w-48 h-6 cursor-pointer">
<div className="w-40 h-6 cursor-pointer">
<Tooltip>
<TooltipTrigger asChild>{ButtonContent}</TooltipTrigger>
{hiddenCount > 0 && (
@@ -361,13 +382,13 @@ const TagsCell = React.memo<{
return (
<div
className={cn(
"w-48 h-6 relative",
"w-40 h-6 relative",
isDisabled && "opacity-60 pointer-events-none",
)}
>
<div
ref={editorRef}
className="absolute top-0 left-0 z-50 w-48 min-h-6 bg-popover rounded-md shadow-md"
className="absolute top-0 left-0 z-50 w-40 min-h-6 bg-popover rounded-md shadow-md"
>
<MultipleSelector
value={valueOptions}
@@ -402,6 +423,243 @@ const TagsCell = React.memo<{
TagsCell.displayName = "TagsCell";
const NonHoverableTooltip = React.memo<{
children: React.ReactNode;
content: React.ReactNode;
sideOffset?: number;
alignOffset?: number;
horizontalOffset?: number;
}>(
({
children,
content,
sideOffset = 4,
alignOffset = 0,
horizontalOffset = 0,
}) => {
const [isOpen, setIsOpen] = React.useState(false);
return (
<Tooltip open={isOpen} onOpenChange={setIsOpen}>
<TooltipTrigger
asChild
onMouseEnter={() => setIsOpen(true)}
onMouseLeave={() => setIsOpen(false)}
>
{children}
</TooltipTrigger>
<TooltipContent
sideOffset={sideOffset}
alignOffset={alignOffset}
arrowOffset={horizontalOffset}
onPointerEnter={(e) => e.preventDefault()}
onPointerLeave={() => setIsOpen(false)}
className="pointer-events-none"
style={
horizontalOffset !== 0
? { transform: `translateX(${horizontalOffset}px)` }
: undefined
}
>
{content}
</TooltipContent>
</Tooltip>
);
},
);
NonHoverableTooltip.displayName = "NonHoverableTooltip";
const NoteCell = React.memo<{
profile: BrowserProfile;
isDisabled: boolean;
noteOverrides: Record<string, string | null>;
openNoteEditorFor: string | null;
setOpenNoteEditorFor: React.Dispatch<React.SetStateAction<string | null>>;
setNoteOverrides: React.Dispatch<
React.SetStateAction<Record<string, string | null>>
>;
}>(
({
profile,
isDisabled,
noteOverrides,
openNoteEditorFor,
setOpenNoteEditorFor,
setNoteOverrides,
}) => {
const effectiveNote: string | null = Object.hasOwn(
noteOverrides,
profile.id,
)
? noteOverrides[profile.id]
: (profile.note ?? null);
const onNoteChange = React.useCallback(
async (newNote: string | null) => {
const trimmedNote = newNote?.trim() || null;
setNoteOverrides((prev) => ({ ...prev, [profile.id]: trimmedNote }));
try {
await invoke<BrowserProfile>("update_profile_note", {
profileId: profile.id,
note: trimmedNote,
});
} catch (error) {
console.error("Failed to update note:", error);
}
},
[profile.id, setNoteOverrides],
);
const editorRef = React.useRef<HTMLDivElement | null>(null);
const textareaRef = React.useRef<HTMLTextAreaElement | null>(null);
const [noteValue, setNoteValue] = React.useState(effectiveNote || "");
// Update local state when effective note changes (from outside)
React.useEffect(() => {
if (openNoteEditorFor !== profile.id) {
setNoteValue(effectiveNote || "");
}
}, [effectiveNote, openNoteEditorFor, profile.id]);
// Auto-resize textarea on open
React.useEffect(() => {
if (openNoteEditorFor === profile.id && textareaRef.current) {
const textarea = textareaRef.current;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
}
}, [openNoteEditorFor, profile.id]);
const handleTextareaChange = React.useCallback(
(e: React.ChangeEvent<HTMLTextAreaElement>) => {
const newValue = e.target.value;
setNoteValue(newValue);
// Auto-resize
const textarea = e.target;
textarea.style.height = "auto";
textarea.style.height = `${Math.min(textarea.scrollHeight, 200)}px`;
},
[],
);
React.useEffect(() => {
if (openNoteEditorFor !== profile.id) return;
const handleClick = (e: MouseEvent) => {
const target = e.target as Node | null;
if (
editorRef.current &&
target &&
!editorRef.current.contains(target)
) {
const currentValue = textareaRef.current?.value || "";
void onNoteChange(currentValue);
setOpenNoteEditorFor(null);
}
};
document.addEventListener("mousedown", handleClick);
return () => document.removeEventListener("mousedown", handleClick);
}, [openNoteEditorFor, profile.id, setOpenNoteEditorFor, onNoteChange]);
React.useEffect(() => {
if (openNoteEditorFor === profile.id && textareaRef.current) {
textareaRef.current.focus();
// Move cursor to end
const len = textareaRef.current.value.length;
textareaRef.current.setSelectionRange(len, len);
}
}, [openNoteEditorFor, profile.id]);
const displayNote = effectiveNote || "";
const trimmedNote =
displayNote.length > 12 ? `${displayNote.slice(0, 12)}...` : displayNote;
const showTooltip = displayNote.length > 12 || displayNote.length > 0;
if (openNoteEditorFor !== profile.id) {
return (
<div className="w-24 min-h-6">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className={cn(
"flex items-start px-2 py-1 min-h-6 w-full bg-transparent rounded border-none text-left",
isDisabled
? "opacity-60 cursor-not-allowed"
: "cursor-pointer hover:bg-accent/50",
)}
onClick={() => {
if (!isDisabled) {
setNoteValue(effectiveNote || "");
setOpenNoteEditorFor(profile.id);
}
}}
>
<span
className={cn(
"text-sm wrap-break-word",
!effectiveNote && "text-muted-foreground",
)}
>
{effectiveNote ? trimmedNote : "No Note"}
</span>
</button>
</TooltipTrigger>
{showTooltip && (
<TooltipContent className="max-w-[320px]">
<p className="whitespace-pre-wrap wrap-break-word">
{effectiveNote || "No Note"}
</p>
</TooltipContent>
)}
</Tooltip>
</div>
);
}
return (
<div
className={cn(
"w-24 relative",
isDisabled && "opacity-60 pointer-events-none",
)}
>
<div
ref={editorRef}
className="absolute -top-[15px] -left-px z-50 w-60 min-h-6 bg-popover rounded-md shadow-md border"
>
<textarea
ref={textareaRef}
value={noteValue}
onChange={handleTextareaChange}
onKeyDown={(e) => {
if (e.key === "Escape") {
setNoteValue(effectiveNote || "");
setOpenNoteEditorFor(null);
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
void onNoteChange(noteValue);
setOpenNoteEditorFor(null);
}
}}
onBlur={() => {
void onNoteChange(noteValue);
setOpenNoteEditorFor(null);
}}
placeholder="Add a note..."
className="w-full min-h-6 max-h-[200px] px-2 py-1 text-sm bg-transparent border-0 resize-none focus:outline-none focus:ring-0"
style={{
overflow: "auto",
}}
rows={1}
/>
</div>
</div>
);
},
);
NoteCell.displayName = "NoteCell";
interface ProfilesDataTableProps {
profiles: BrowserProfile[];
onLaunchProfile: (profile: BrowserProfile) => void | Promise<void>;
@@ -418,6 +676,7 @@ interface ProfilesDataTableProps {
onSelectedProfilesChange: Dispatch<SetStateAction<string[]>>;
onBulkDelete?: () => void;
onBulkGroupAssignment?: () => void;
onBulkProxyAssignment?: () => void;
}
export function ProfilesDataTable({
@@ -434,6 +693,7 @@ export function ProfilesDataTable({
onSelectedProfilesChange,
onBulkDelete,
onBulkGroupAssignment,
onBulkProxyAssignment,
}: ProfilesDataTableProps) {
const { getTableSorting, updateSorting, isLoaded } = useTableSorting();
const [sorting, setSorting] = React.useState<SortingState>([]);
@@ -526,6 +786,19 @@ export function ProfilesDataTable({
const [proxyCheckResults, setProxyCheckResults] = React.useState<
Record<string, ProxyCheckResult>
>({});
const [noteOverrides, setNoteOverrides] = React.useState<
Record<string, string | null>
>({});
const [openNoteEditorFor, setOpenNoteEditorFor] = React.useState<
string | null
>(null);
const [trafficSnapshots, setTrafficSnapshots] = React.useState<
Record<string, TrafficSnapshot>
>({});
const [trafficDialogProfile, setTrafficDialogProfile] = React.useState<{
id: string;
name?: string;
} | null>(null);
// Load cached check results for proxies
React.useEffect(() => {
@@ -594,6 +867,69 @@ export function ProfilesDataTable({
stoppingProfiles,
);
// Fetch traffic snapshots for running profiles (lightweight, real-time data)
// Convert Set to sorted array to avoid Set reference comparison issues in dependencies
const runningProfileIds = React.useMemo(
() => Array.from(runningProfiles).sort(),
[runningProfiles],
);
const runningCount = runningProfileIds.length;
React.useEffect(() => {
if (!browserState.isClient) return;
if (runningCount === 0) {
setTrafficSnapshots({});
return;
}
const fetchTrafficSnapshots = async () => {
try {
const allSnapshots = await invoke<TrafficSnapshot[]>(
"get_all_traffic_snapshots",
);
const newSnapshots: Record<string, TrafficSnapshot> = {};
for (const snapshot of allSnapshots) {
if (snapshot.profile_id) {
// Only keep snapshots for profiles that are currently running
if (runningProfileIds.includes(snapshot.profile_id)) {
const existing = newSnapshots[snapshot.profile_id];
if (!existing || snapshot.last_update > existing.last_update) {
newSnapshots[snapshot.profile_id] = snapshot;
}
}
}
}
setTrafficSnapshots(newSnapshots);
} catch (error) {
console.error("Failed to fetch traffic snapshots:", error);
}
};
void fetchTrafficSnapshots();
const interval = setInterval(fetchTrafficSnapshots, 1000);
return () => clearInterval(interval);
}, [browserState.isClient, runningCount, runningProfileIds]);
// Clean up snapshots for profiles that are no longer running
React.useEffect(() => {
if (!browserState.isClient) return;
setTrafficSnapshots((prev) => {
const cleaned: Record<string, TrafficSnapshot> = {};
for (const [profileId, snapshot] of Object.entries(prev)) {
// Only keep snapshots for profiles that are currently running
if (runningProfileIds.includes(profileId)) {
cleaned[profileId] = snapshot;
}
}
// Only update if something was removed
if (Object.keys(cleaned).length !== Object.keys(prev).length) {
return cleaned;
}
return prev;
});
}, [browserState.isClient, runningProfileIds]);
// Clear launching/stopping spinners when backend reports running status changes
React.useEffect(() => {
if (!browserState.isClient) return;
@@ -892,6 +1228,12 @@ export function ProfilesDataTable({
setOpenTagsEditorFor,
setTagsOverrides,
// Note editor state
noteOverrides,
openNoteEditorFor,
setOpenNoteEditorFor,
setNoteOverrides,
// Proxy selector state
openProxySelectorFor,
setOpenProxySelectorFor,
@@ -926,6 +1268,13 @@ export function ProfilesDataTable({
// Overflow actions
onAssignProfilesToGroup,
onConfigureCamoufox,
// Traffic snapshots (lightweight real-time data)
trafficSnapshots,
onOpenTrafficDialog: (profileId: string) => {
const profile = profiles.find((p) => p.id === profileId);
setTrafficDialogProfile({ id: profileId, name: profile?.name });
},
}),
[
selectedProfiles,
@@ -940,6 +1289,8 @@ export function ProfilesDataTable({
tagsOverrides,
allTags,
openTagsEditorFor,
noteOverrides,
openNoteEditorFor,
openProxySelectorFor,
proxyOverrides,
storedProxies,
@@ -953,6 +1304,8 @@ export function ProfilesDataTable({
profileToRename,
newProfileName,
isRenamingSaving,
trafficSnapshots,
profiles,
renameError,
onKillProfile,
onLaunchProfile,
@@ -1021,37 +1374,51 @@ export function ProfilesDataTable({
);
}
const browserName = getBrowserDisplayName(browser);
if (meta.showCheckboxes || isSelected) {
return (
<span className="flex justify-center items-center w-4 h-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) =>
meta.handleCheckboxChange(profile.id, !!value)
}
aria-label="Select row"
className="w-4 h-4"
/>
</span>
<NonHoverableTooltip
content={<p>{browserName}</p>}
sideOffset={4}
horizontalOffset={8}
>
<span className="flex justify-center items-center w-4 h-4">
<Checkbox
checked={isSelected}
onCheckedChange={(value) =>
meta.handleCheckboxChange(profile.id, !!value)
}
aria-label="Select row"
className="w-4 h-4"
/>
</span>
</NonHoverableTooltip>
);
}
return (
<span className="flex relative justify-center items-center w-4 h-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
onClick={() => meta.handleIconClick(profile.id)}
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>
<NonHoverableTooltip
content={<p>{browserName}</p>}
sideOffset={4}
horizontalOffset={8}
>
<span className="flex relative justify-center items-center w-4 h-4">
<button
type="button"
className="flex justify-center items-center p-0 border-none cursor-pointer"
onClick={() => meta.handleIconClick(profile.id)}
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>
</NonHoverableTooltip>
);
},
enableSorting: false,
@@ -1113,8 +1480,9 @@ export function ProfilesDataTable({
size="sm"
disabled={!canLaunch || isLaunching || isStopping}
className={cn(
"cursor-pointer min-w-[70px] h-7",
!canLaunch && "opacity-50",
"min-w-[70px] h-7",
!canLaunch && "opacity-50 cursor-not-allowed",
canLaunch && "cursor-pointer",
)}
onClick={() =>
isRunning
@@ -1172,11 +1540,6 @@ export function ProfilesDataTable({
const isEditing = meta.profileToRename?.id === profile.id;
if (isEditing) {
const isSaveDisabled =
meta.isRenamingSaving ||
meta.newProfileName.trim().length === 0 ||
meta.newProfileName.trim() === profile.name;
return (
<div
ref={renameContainerRef}
@@ -1190,7 +1553,9 @@ export function ProfilesDataTable({
if (meta.renameError) meta.setRenameError(null);
}}
onKeyDown={(e) => {
if (e.key === "Enter") {
if (e.key === "Enter" && !(e.metaKey || e.ctrlKey)) {
void meta.handleRename();
} else if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
void meta.handleRename();
} else if (e.key === "Escape") {
meta.setProfileToRename(null);
@@ -1198,20 +1563,20 @@ export function ProfilesDataTable({
meta.setRenameError(null);
}
}}
onBlur={() => {
if (
meta.newProfileName.trim().length > 0 &&
meta.newProfileName.trim() !== profile.name
) {
void meta.handleRename();
} else {
meta.setProfileToRename(null);
meta.setNewProfileName("");
meta.setRenameError(null);
}
}}
className="w-30 h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0"
/>
<div className="flex absolute right-0 top-full z-50 gap-1 translate-y-[30%] opacity-100 bg-black rounded-md">
<LoadingButton
isLoading={meta.isRenamingSaving}
size="sm"
variant="default"
disabled={isSaveDisabled}
className="cursor-pointer [[disabled]]:bg-primary/80"
onClick={() => void meta.handleRename()}
>
Save
</LoadingButton>
</div>
</div>
);
}
@@ -1295,51 +1660,28 @@ export function ProfilesDataTable({
},
},
{
accessorKey: "browser",
header: ({ column }) => {
return (
<Button
variant="ghost"
onClick={() =>
column.toggleSorting(column.getIsSorted() === "asc")
}
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
>
Browser
{column.getIsSorted() === "asc" ? (
<LuChevronUp className="ml-2 w-4 h-4" />
) : column.getIsSorted() === "desc" ? (
<LuChevronDown className="ml-2 w-4 h-4" />
) : null}
</Button>
);
},
cell: ({ row }) => {
const browser: string = row.getValue("browser");
const name = getBrowserDisplayName(browser);
if (name.length < 14) {
return (
<div className="flex items-center">
<span>{name}</span>
</div>
);
}
id: "note",
header: "Note",
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
const profile = row.original;
const isRunning =
meta.isClient && meta.runningProfiles.has(profile.id);
const isLaunching = meta.launchingProfiles.has(profile.id);
const isStopping = meta.stoppingProfiles.has(profile.id);
const isBrowserUpdating = meta.isUpdating(profile.browser);
const isDisabled =
isRunning || isLaunching || isStopping || isBrowserUpdating;
return (
<Tooltip>
<TooltipTrigger asChild>
<span>{trimName(name, 14)}</span>
</TooltipTrigger>
<TooltipContent>{name}</TooltipContent>
</Tooltip>
);
},
enableSorting: true,
sortingFn: (rowA, rowB, columnId) => {
const browserA: string = rowA.getValue(columnId);
const browserB: string = rowB.getValue(columnId);
return getBrowserDisplayName(browserA).localeCompare(
getBrowserDisplayName(browserB),
<NoteCell
profile={profile}
isDisabled={isDisabled}
noteOverrides={meta.noteOverrides || {}}
openNoteEditorFor={meta.openNoteEditorFor || null}
setOpenNoteEditorFor={meta.setOpenNoteEditorFor}
setNoteOverrides={meta.setNoteOverrides}
/>
);
},
},
@@ -1365,35 +1707,34 @@ export function ProfilesDataTable({
? (meta.storedProxies.find((p) => p.id === effectiveProxyId) ??
null)
: null;
const displayName =
profile.browser === "tor-browser"
? "Not supported"
: effectiveProxy
? effectiveProxy.name
: "Not Selected";
const displayName = effectiveProxy
? effectiveProxy.name
: "Not Selected";
const profileHasProxy = Boolean(effectiveProxy);
const tooltipText =
profile.browser === "tor-browser"
? "Proxies are not supported for TOR browser"
: profileHasProxy && effectiveProxy
? effectiveProxy.name
: null;
profileHasProxy && effectiveProxy ? effectiveProxy.name : null;
const isSelectorOpen = meta.openProxySelectorFor === profile.id;
if (profile.browser === "tor-browser") {
// When profile is running, show bandwidth chart instead of proxy selector
if (isRunning && meta.trafficSnapshots) {
// Find the traffic snapshot for this profile by matching profile_id
const snapshot = meta.trafficSnapshots[profile.id];
// Only use recent_bandwidth (last 60 seconds) - minimal data needed for mini chart
// Create a new array reference to ensure React detects changes
const bandwidthData = snapshot?.recent_bandwidth
? [...snapshot.recent_bandwidth]
: [];
const currentBandwidth =
(snapshot?.current_bytes_sent || 0) +
(snapshot?.current_bytes_received || 0);
return (
<Tooltip>
<TooltipTrigger asChild>
<span className="flex gap-2 items-center">
<span className="text-sm text-muted-foreground">
Not supported
</span>
</span>
</TooltipTrigger>
{(tooltipText || displayName.length > 10) && (
<TooltipContent>{tooltipText || displayName}</TooltipContent>
)}
</Tooltip>
<BandwidthMiniChart
key={`${profile.id}-${snapshot?.last_update || 0}-${bandwidthData.length}`}
data={bandwidthData}
currentBandwidth={currentBandwidth}
onClick={() => meta.onOpenTrafficDialog?.(profile.id)}
/>
);
}
@@ -1542,6 +1883,13 @@ export function ProfilesDataTable({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => {
meta.onOpenTrafficDialog?.(profile.id);
}}
>
View Network
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
meta.onAssignProfilesToGroup?.([profile.id]);
@@ -1556,9 +1904,8 @@ export function ProfilesDataTable({
onClick={() => {
meta.onConfigureCamoufox?.(profile);
}}
disabled={isDisabled}
>
Configure Fingerprint
Change Fingerprint
</DropdownMenuItem>
)}
<DropdownMenuItem
@@ -1683,6 +2030,15 @@ export function ProfilesDataTable({
<LuUsers />
</DataTableActionBarAction>
)}
{onBulkProxyAssignment && (
<DataTableActionBarAction
tooltip="Assign Proxy"
onClick={onBulkProxyAssignment}
size="icon"
>
<FiWifi />
</DataTableActionBarAction>
)}
{onBulkDelete && (
<DataTableActionBarAction
tooltip="Delete"
@@ -1695,6 +2051,14 @@ export function ProfilesDataTable({
</DataTableActionBarAction>
)}
</DataTableActionBar>
{trafficDialogProfile && (
<TrafficDetailsDialog
isOpen={trafficDialogProfile !== null}
onClose={() => setTrafficDialogProfile(null)}
profileId={trafficDialogProfile.id}
profileName={trafficDialogProfile.name}
/>
)}
</>
);
}
+6 -28
View File
@@ -2,8 +2,6 @@
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useEffect, useState } from "react";
import { LuCopy } from "react-icons/lu";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-button";
import { Badge } from "@/components/ui/badge";
import {
@@ -31,6 +29,7 @@ import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserProfile } from "@/types";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
import { RippleButton } from "./ui/ripple";
interface ProfileSelectorDialogProps {
@@ -122,18 +121,6 @@ export function ProfileSelectorDialog({
onClose();
}, [onClose]);
const handleCopyUrl = useCallback(async () => {
if (!url) return;
try {
await navigator.clipboard.writeText(url);
toast.success("URL copied to clipboard!");
} catch (error) {
console.error("Failed to copy URL:", error);
toast.error("Failed to copy URL to clipboard");
}
}, [url]);
const selectedProfileData = profiles.find((p) => p.name === selectedProfile);
// Check if the selected profile can be used for opening links
@@ -155,11 +142,7 @@ export function ProfileSelectorDialog({
const runningAvailableProfile = profiles.find((profile) => {
const isRunning = runningProfiles.has(profile.id);
// Simple check without browserState dependency
return (
isRunning &&
profile.browser !== "tor-browser" &&
profile.browser !== "mullvad-browser"
);
return isRunning;
});
if (runningAvailableProfile) {
@@ -186,15 +169,10 @@ export function ProfileSelectorDialog({
<div className="space-y-2">
<div className="flex justify-between items-center">
<Label className="text-sm font-medium">Opening URL:</Label>
<RippleButton
variant="outline"
size="sm"
onClick={() => void handleCopyUrl()}
className="flex gap-2 items-center"
>
<LuCopy className="w-3 h-3" />
Copy
</RippleButton>
<CopyToClipboard
text={url}
successMessage="URL copied to clipboard!"
/>
</div>
<div className="p-2 text-sm break-all rounded bg-muted">
{url}
+178
View File
@@ -0,0 +1,178 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { emit } from "@tauri-apps/api/event";
import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { LoadingButton } from "@/components/loading-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 { BrowserProfile, StoredProxy } from "@/types";
import { RippleButton } from "./ui/ripple";
interface ProxyAssignmentDialogProps {
isOpen: boolean;
onClose: () => void;
selectedProfiles: string[];
onAssignmentComplete: () => void;
profiles?: BrowserProfile[];
storedProxies?: StoredProxy[];
}
export function ProxyAssignmentDialog({
isOpen,
onClose,
selectedProfiles,
onAssignmentComplete,
profiles = [],
storedProxies = [],
}: ProxyAssignmentDialogProps) {
const [selectedProxyId, setSelectedProxyId] = useState<string | null>(null);
const [isAssigning, setIsAssigning] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleAssign = useCallback(async () => {
setIsAssigning(true);
setError(null);
try {
const validProfiles = selectedProfiles.filter((profileId) => {
const profile = profiles.find((p) => p.id === profileId);
return profile;
});
if (validProfiles.length === 0) {
setError("No valid profiles selected.");
setIsAssigning(false);
return;
}
// Update each profile's proxy sequentially to avoid file locking issues
for (const profileId of validProfiles) {
await invoke("update_profile_proxy", {
profileId,
proxyId: selectedProxyId,
});
}
// Notify other parts of the app so usage counts and lists refresh
await emit("profile-updated");
onAssignmentComplete();
onClose();
} catch (err) {
console.error("Failed to assign proxies to profiles:", err);
const errorMessage =
err instanceof Error
? err.message
: "Failed to assign proxies to profiles";
setError(errorMessage);
toast.error(errorMessage);
} finally {
setIsAssigning(false);
}
}, [
selectedProfiles,
selectedProxyId,
profiles,
onAssignmentComplete,
onClose,
]);
useEffect(() => {
if (isOpen) {
setSelectedProxyId(null);
setError(null);
}
}, [isOpen]);
return (
<Dialog open={isOpen} onOpenChange={onClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Assign Proxy</DialogTitle>
<DialogDescription>
Assign a proxy to {selectedProfiles.length} selected profile(s).
</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((profileId) => {
const profile = profiles.find(
(p: BrowserProfile) => p.id === profileId,
);
const displayName = profile ? profile.name : profileId;
return (
<li key={profileId} className="truncate">
{displayName}
</li>
);
})}
</ul>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="proxy-select">Assign Proxy:</Label>
<Select
value={selectedProxyId || "none"}
onValueChange={(value) => {
setSelectedProxyId(value === "none" ? null : value);
}}
>
<SelectTrigger>
<SelectValue placeholder="Select a proxy" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">No Proxy</SelectItem>
{storedProxies.map((proxy) => (
<SelectItem key={proxy.id} value={proxy.id}>
{proxy.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{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>
<RippleButton
variant="outline"
onClick={onClose}
disabled={isAssigning}
>
Cancel
</RippleButton>
<LoadingButton
isLoading={isAssigning}
onClick={() => void handleAssign()}
>
Assign
</LoadingButton>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
+9 -12
View File
@@ -46,6 +46,7 @@ import {
THEMES,
} from "@/lib/themes";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { CopyToClipboard } from "./ui/copy-to-clipboard";
import { RippleButton } from "./ui/ripple";
interface AppSettings {
@@ -267,6 +268,8 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
setIsClearingCache(true);
try {
await invoke("clear_all_version_cache_and_refetch");
// Also clear traffic stats cache
await invoke("clear_all_traffic_stats");
// Don't show immediate success toast - let the version update progress events handle it
} catch (error) {
console.error("Failed to clear cache:", error);
@@ -520,7 +523,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent className="max-w-md max-h-[80vh] my-8 flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogHeader className="shrink-0">
<DialogTitle>Settings</DialogTitle>
</DialogHeader>
@@ -839,16 +842,10 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
readOnly
className="flex-1 px-3 py-2 font-mono text-sm rounded-md border bg-muted"
/>
<RippleButton
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(settings.api_token || "");
showSuccessToast("API token copied to clipboard");
}}
>
Copy
</RippleButton>
<CopyToClipboard
text={settings.api_token || ""}
successMessage="API token copied to clipboard"
/>
</div>
<p className="text-xs text-muted-foreground">
Include this token in the Authorization header as "Bearer{" "}
@@ -1073,7 +1070,7 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
</div>
</div>
<DialogFooter className="flex-shrink-0">
<DialogFooter className="shrink-0">
<RippleButton variant="outline" onClick={handleClose}>
Cancel
</RippleButton>
+120 -10
View File
@@ -15,7 +15,11 @@ import {
} from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea";
import type { CamoufoxConfig, CamoufoxFingerprintConfig } from "@/types";
import type {
CamoufoxConfig,
CamoufoxFingerprintConfig,
CamoufoxOS,
} from "@/types";
interface SharedCamoufoxConfigFormProps {
config: CamoufoxConfig;
@@ -23,6 +27,7 @@ interface SharedCamoufoxConfigFormProps {
className?: string;
isCreating?: boolean; // Flag to indicate if this is for creating a new profile
forceAdvanced?: boolean; // Force advanced mode (for editing)
readOnly?: boolean; // Flag to indicate if the form should be read-only
}
// Determine if fingerprint editing should be disabled
@@ -30,14 +35,36 @@ const isFingerprintEditingDisabled = (config: CamoufoxConfig): boolean => {
return config.randomize_fingerprint_on_launch === true;
};
// Detect the current operating system
const getCurrentOS = (): CamoufoxOS => {
if (typeof navigator === "undefined") return "linux";
const platform = navigator.platform.toLowerCase();
if (platform.includes("win")) return "windows";
if (platform.includes("mac")) return "macos";
return "linux";
};
// OS display labels
const osLabels: Record<CamoufoxOS, string> = {
windows: "Windows",
macos: "macOS",
linux: "Linux",
};
// Component for editing nested objects like webGl:parameters
interface ObjectEditorProps {
value: Record<string, unknown> | undefined;
onChange: (value: Record<string, unknown> | undefined) => void;
title: string;
readOnly?: boolean;
}
function ObjectEditor({ value, onChange, title }: ObjectEditorProps) {
function ObjectEditor({
value,
onChange,
title,
readOnly = false,
}: ObjectEditorProps) {
const [jsonString, setJsonString] = useState("");
useEffect(() => {
@@ -45,6 +72,7 @@ function ObjectEditor({ value, onChange, title }: ObjectEditorProps) {
}, [value]);
const handleChange = (newValue: string) => {
if (readOnly) return;
setJsonString(newValue);
try {
if (newValue.trim() === "" || newValue.trim() === "{}") {
@@ -75,6 +103,7 @@ function ObjectEditor({ value, onChange, title }: ObjectEditorProps) {
placeholder={`Enter ${title} as JSON`}
className="font-mono text-sm"
rows={6}
disabled={readOnly}
/>
</div>
);
@@ -86,12 +115,18 @@ export function SharedCamoufoxConfigForm({
className = "",
isCreating = false,
forceAdvanced = false,
readOnly = false,
}: SharedCamoufoxConfigFormProps) {
const [activeTab, setActiveTab] = useState(
forceAdvanced ? "manual" : "automatic",
);
const [fingerprintConfig, setFingerprintConfig] =
useState<CamoufoxFingerprintConfig>({});
const [currentOS] = useState<CamoufoxOS>(getCurrentOS);
// Get selected OS (defaults to current OS)
const selectedOS = config.os || currentOS;
const isOSDifferent = selectedOS !== currentOS;
// Set screen resolution to user's screen size when creating a new profile
useEffect(() => {
@@ -174,10 +209,39 @@ export function SharedCamoufoxConfigForm({
}
};
const isEditingDisabled = isFingerprintEditingDisabled(config);
const isEditingDisabled = isFingerprintEditingDisabled(config) || readOnly;
const renderAdvancedForm = () => (
<div className="space-y-6">
{/* Operating System Selection */}
<div className="space-y-3">
<Label>Operating System Fingerprint</Label>
<Select
value={selectedOS}
onValueChange={(value: CamoufoxOS) => onConfigChange("os", value)}
disabled={readOnly}
>
<SelectTrigger>
<SelectValue placeholder="Select operating system" />
</SelectTrigger>
<SelectContent>
<SelectItem value="windows">{osLabels.windows}</SelectItem>
<SelectItem value="macos">{osLabels.macos}</SelectItem>
<SelectItem value="linux">{osLabels.linux}</SelectItem>
</SelectContent>
</Select>
{isOSDifferent && (
<Alert className="border-yellow-500/50 bg-yellow-500/10">
<AlertDescription className="text-yellow-600 dark:text-yellow-400">
Warning: Selecting an OS different from your current system (
{osLabels[currentOS]}) increases the risk of detection. Websites
can detect mismatches between your fingerprint and actual system
behavior.
</AlertDescription>
</Alert>
)}
</div>
{/* Randomize Fingerprint Option */}
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
@@ -187,6 +251,7 @@ export function SharedCamoufoxConfigForm({
onCheckedChange={(checked) =>
onConfigChange("randomize_fingerprint_on_launch", checked)
}
disabled={readOnly}
/>
<Label htmlFor="randomize-fingerprint" className="font-medium">
Generate random fingerprint on every launch
@@ -201,9 +266,9 @@ export function SharedCamoufoxConfigForm({
{isEditingDisabled ? (
<Alert>
<AlertDescription>
Fingerprint editing is disabled because random fingerprint
generation is enabled. Disable the option above to manually edit the
fingerprint configuration.
{readOnly
? "Fingerprint editing is disabled because the profile is currently running. Stop the profile to make changes."
: "Fingerprint editing is disabled because random fingerprint generation is enabled. Disable the option above to manually edit the fingerprint configuration."}
</AlertDescription>
</Alert>
) : (
@@ -727,6 +792,7 @@ export function SharedCamoufoxConfigForm({
updateFingerprintConfig("webGl:parameters", value)
}
title="WebGL Parameters"
readOnly={readOnly}
/>
</div>
@@ -743,6 +809,7 @@ export function SharedCamoufoxConfigForm({
updateFingerprintConfig("webGl2:parameters", value)
}
title="WebGL2 Parameters"
readOnly={readOnly}
/>
</div>
@@ -759,6 +826,7 @@ export function SharedCamoufoxConfigForm({
updateFingerprintConfig("webGl:shaderPrecisionFormats", value)
}
title="WebGL Shader Precision Formats"
readOnly={readOnly}
/>
</div>
@@ -775,6 +843,7 @@ export function SharedCamoufoxConfigForm({
updateFingerprintConfig("webGl2:shaderPrecisionFormats", value)
}
title="WebGL2 Shader Precision Formats"
readOnly={readOnly}
/>
</div>
@@ -876,15 +945,55 @@ export function SharedCamoufoxConfigForm({
// Advanced mode only (for editing)
renderAdvancedForm()
) : (
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<Tabs
value={activeTab}
onValueChange={readOnly ? undefined : setActiveTab}
className="w-full"
>
<TabsList className="grid grid-cols-2 w-full">
<TabsTrigger value="automatic">Automatic</TabsTrigger>
<TabsTrigger value="manual">Manual</TabsTrigger>
<TabsTrigger value="automatic" disabled={readOnly}>
Automatic
</TabsTrigger>
<TabsTrigger value="manual" disabled={readOnly}>
Manual
</TabsTrigger>
</TabsList>
<TabsContent value="automatic" className="space-y-6">
{/* Operating System Selection */}
<div className="mt-4 space-y-3">
<Label>Operating System Fingerprint</Label>
<Select
value={selectedOS}
onValueChange={(value: CamoufoxOS) =>
onConfigChange("os", value)
}
disabled={readOnly}
>
<SelectTrigger>
<SelectValue placeholder="Select operating system" />
</SelectTrigger>
<SelectContent>
<SelectItem value="windows">{osLabels.windows}</SelectItem>
<SelectItem value="macos">{osLabels.macos}</SelectItem>
<SelectItem value="linux">{osLabels.linux}</SelectItem>
</SelectContent>
</Select>
{isOSDifferent && (
<Alert className="border-yellow-500/50 bg-yellow-500/10">
<AlertDescription className="text-yellow-600 dark:text-yellow-400">
Warning: Selecting an OS different from your current
system ({osLabels[currentOS]}) increases the risk of
detection. Websites with advanced protections can detect
mismatches between your fingerprint and actual system
behavior.
</AlertDescription>
</Alert>
)}
</div>
{/* Randomize Fingerprint Option */}
<div className="mt-4 space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="space-y-3 p-4 border rounded-lg bg-muted/30">
<div className="flex items-center space-x-2">
<Checkbox
id="randomize-fingerprint-auto"
@@ -892,6 +1001,7 @@ export function SharedCamoufoxConfigForm({
onCheckedChange={(checked) =>
onConfigChange("randomize_fingerprint_on_launch", checked)
}
disabled={readOnly}
/>
<Label
htmlFor="randomize-fingerprint-auto"
+559
View File
@@ -0,0 +1,559 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import {
Area,
AreaChart,
CartesianGrid,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
TooltipContent,
TooltipTrigger,
Tooltip as UITooltip,
} from "@/components/ui/tooltip";
import type { FilteredTrafficStats } from "@/types";
type TimePeriod =
| "1m"
| "5m"
| "30m"
| "1h"
| "2h"
| "4h"
| "1d"
| "7d"
| "30d"
| "all";
interface TrafficDetailsDialogProps {
isOpen: boolean;
onClose: () => void;
profileId?: string;
profileName?: string;
}
const formatBytes = (bytes: number): string => {
if (bytes === 0) return "0 B";
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024)
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
};
const formatBytesPerSecond = (bytes: number): string => {
if (bytes === 0) return "0 B/s";
if (bytes < 1024) return `${bytes} B/s`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB/s`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB/s`;
};
function getSecondsForPeriod(period: TimePeriod): number {
switch (period) {
case "1m":
return 60;
case "5m":
return 300;
case "30m":
return 1800;
case "1h":
return 3600;
case "2h":
return 7200;
case "4h":
return 14400;
case "1d":
return 86400;
case "7d":
return 604800;
case "30d":
return 2592000;
case "all":
return 0; // 0 means all time
default:
return 300;
}
}
const TruncatedDomain = React.memo<{ domain: string }>(({ domain }) => {
const ref = React.useRef<HTMLSpanElement>(null);
const [isTruncated, setIsTruncated] = React.useState(false);
const checkTruncation = React.useCallback(() => {
if (ref.current) {
setIsTruncated(ref.current.scrollWidth > ref.current.clientWidth);
}
}, []);
React.useLayoutEffect(() => {
checkTruncation();
});
React.useEffect(() => {
const resizeObserver = new ResizeObserver(checkTruncation);
if (ref.current) {
resizeObserver.observe(ref.current);
}
return () => {
resizeObserver.disconnect();
};
}, [checkTruncation]);
const content = (
<span ref={ref} className="truncate max-w-[200px] block">
{domain}
</span>
);
if (!isTruncated) {
return content;
}
return (
<UITooltip>
<TooltipTrigger asChild>{content}</TooltipTrigger>
<TooltipContent>
<p>{domain}</p>
</TooltipContent>
</UITooltip>
);
});
TruncatedDomain.displayName = "TruncatedDomain";
export function TrafficDetailsDialog({
isOpen,
onClose,
profileId,
profileName,
}: TrafficDetailsDialogProps) {
const [stats, setStats] = React.useState<FilteredTrafficStats | null>(null);
const [timePeriod, setTimePeriod] = React.useState<TimePeriod>("5m");
// Fetch stats periodically - now uses filtered API
React.useEffect(() => {
if (!isOpen || !profileId) return;
const fetchStats = async () => {
try {
const seconds = getSecondsForPeriod(timePeriod);
const filteredStats = await invoke<FilteredTrafficStats | null>(
"get_traffic_stats_for_period",
{ profileId, seconds },
);
setStats(filteredStats);
} catch (error) {
console.error("Failed to fetch traffic stats:", error);
}
};
void fetchStats();
const interval = setInterval(fetchStats, 2000);
return () => {
clearInterval(interval);
// Clear stats from memory when dialog closes to free up memory
setStats(null);
};
}, [isOpen, profileId, timePeriod]);
// Transform data for chart (already filtered by backend)
const chartData = React.useMemo(() => {
if (!stats?.bandwidth_history) return [];
return stats.bandwidth_history.map((d) => ({
time: d.timestamp,
sent: d.bytes_sent,
received: d.bytes_received,
total: d.bytes_sent + d.bytes_received,
}));
}, [stats]);
// Tooltip render function
const renderTooltip = React.useCallback(
(props: TooltipContentProps<number, string>) => {
const { active, payload, label } = props;
if (!active || !payload?.length) return null;
const time = new Date((typeof label === "number" ? label : 0) * 1000);
const formattedTime = time.toLocaleTimeString();
return (
<div className="bg-popover border rounded-lg px-3 py-2 shadow-lg">
<p className="text-xs text-muted-foreground mb-1">{formattedTime}</p>
{payload.map((entry) => (
<p key={String(entry.dataKey)} className="text-sm">
<span className="text-muted-foreground">
{entry.dataKey === "sent" ? "↑ Sent: " : "↓ Received: "}
</span>
<span className="font-medium">
{formatBytesPerSecond(
typeof entry.value === "number" ? entry.value : 0,
)}
</span>
</p>
))}
</div>
);
},
[],
);
// Top domains sorted by total traffic
const topDomainsByTraffic = React.useMemo(() => {
if (!stats?.domains) return [];
return Object.values(stats.domains)
.sort(
(a, b) =>
b.bytes_sent + b.bytes_received - (a.bytes_sent + a.bytes_received),
)
.slice(0, 10);
}, [stats]);
// Top domains sorted by request count
const topDomainsByRequests = React.useMemo(() => {
if (!stats?.domains) return [];
return Object.values(stats.domains)
.sort((a, b) => b.request_count - a.request_count)
.slice(0, 10);
}, [stats]);
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
Traffic Details
{profileName && (
<span className="text-muted-foreground font-normal ml-2">
{profileName}
</span>
)}
</DialogTitle>
</DialogHeader>
<ScrollArea className="h-[60vh]">
<div className="space-y-6 pr-4">
{/* Chart with Period Selector */}
<div>
<div className="flex items-center justify-between mb-2">
<h3 className="text-sm font-medium">Bandwidth Over Time</h3>
<Select
value={timePeriod}
onValueChange={(v) => setTimePeriod(v as TimePeriod)}
>
<SelectTrigger className="w-[120px] h-8">
<SelectValue placeholder="Time period" />
</SelectTrigger>
<SelectContent>
<SelectItem value="1m">Last 1 min</SelectItem>
<SelectItem value="5m">Last 5 min</SelectItem>
<SelectItem value="30m">Last 30 min</SelectItem>
<SelectItem value="1h">Last 1 hour</SelectItem>
<SelectItem value="2h">Last 2 hours</SelectItem>
<SelectItem value="4h">Last 4 hours</SelectItem>
<SelectItem value="1d">Last 1 day</SelectItem>
<SelectItem value="7d">Last 7 days</SelectItem>
<SelectItem value="30d">Last 30 days</SelectItem>
<SelectItem value="all">All time</SelectItem>
</SelectContent>
</Select>
</div>
<div className="h-[200px] w-full">
<ResponsiveContainer width="100%" height="100%">
<AreaChart
data={chartData}
margin={{ top: 10, right: 10, bottom: 0, left: 0 }}
>
<defs>
<linearGradient
id="sentGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-1)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-1)"
stopOpacity={0.1}
/>
</linearGradient>
<linearGradient
id="receivedGradient"
x1="0"
y1="0"
x2="0"
y2="1"
>
<stop
offset="0%"
stopColor="var(--chart-2)"
stopOpacity={0.5}
/>
<stop
offset="100%"
stopColor="var(--chart-2)"
stopOpacity={0.1}
/>
</linearGradient>
</defs>
<CartesianGrid
strokeDasharray="3 3"
className="stroke-muted"
/>
<XAxis
dataKey="time"
tickFormatter={(t) =>
new Date(t * 1000).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})
}
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
/>
<YAxis
tickFormatter={(v) => formatBytesPerSecond(v)}
className="text-xs"
tick={{ fill: "var(--muted-foreground)" }}
width={60}
/>
<Tooltip content={renderTooltip} />
<Area
type="monotone"
dataKey="sent"
stackId="1"
stroke="var(--chart-1)"
fill="url(#sentGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
<Area
type="monotone"
dataKey="received"
stackId="1"
stroke="var(--chart-2)"
fill="url(#receivedGradient)"
strokeWidth={1.5}
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
</div>
<div className="flex items-center justify-center gap-6 mt-2">
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: "var(--chart-1)" }}
/>
<span className="text-xs text-muted-foreground">Sent</span>
</div>
<div className="flex items-center gap-2">
<div
className="w-3 h-3 rounded"
style={{ backgroundColor: "var(--chart-2)" }}
/>
<span className="text-xs text-muted-foreground">
Received
</span>
</div>
</div>
</div>
{/* Period Stats - now uses backend-computed values */}
<div className="grid grid-cols-3 gap-4">
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">
Sent ({timePeriod === "all" ? "total" : timePeriod})
</p>
<p className="text-lg font-semibold text-chart-1">
{formatBytes(stats?.period_bytes_sent || 0)}
</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">
Received ({timePeriod === "all" ? "total" : timePeriod})
</p>
<p className="text-lg font-semibold text-chart-2">
{formatBytes(stats?.period_bytes_received || 0)}
</p>
</div>
<div className="bg-muted/50 rounded-lg p-3">
<p className="text-xs text-muted-foreground">
Requests ({timePeriod === "all" ? "total" : timePeriod})
</p>
<p className="text-lg font-semibold">
{(stats?.period_requests || 0).toLocaleString()}
</p>
</div>
</div>
{/* Total Stats (smaller, under period stats) */}
<div className="flex items-center gap-6 text-sm text-muted-foreground border-t pt-4">
<div>
<span className="font-medium">All-time traffic:</span>{" "}
{formatBytes(
(stats?.total_bytes_sent || 0) +
(stats?.total_bytes_received || 0),
)}
</div>
<div>
<span className="font-medium">All-time requests:</span>{" "}
{stats?.total_requests?.toLocaleString() || 0}
</div>
</div>
{/* Disclaimer about proxy/VPN traffic calculation */}
<p className="text-xs text-muted-foreground italic">
Note: If you are using a proxy, VPN, or similar service, your
provider may calculate traffic differently due to encryption
overhead and protocol differences.
</p>
{/* Top Domains by Traffic */}
{topDomainsByTraffic.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2">
Top Domains by Traffic (
{timePeriod === "all" ? "all time" : timePeriod})
</h3>
<div className="border rounded-md">
<div className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
<span>Domain</span>
<span className="text-right">Requests</span>
<span className="text-right">Sent</span>
<span className="text-right">Received</span>
</div>
<div className="max-h-[180px] overflow-y-auto">
{topDomainsByTraffic.map((domain, index) => (
<div
key={domain.domain}
className="grid grid-cols-[1fr_80px_80px_80px] gap-2 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-muted/30"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-muted-foreground w-4 shrink-0">
{index + 1}
</span>
<TruncatedDomain domain={domain.domain} />
</div>
<span className="text-right text-muted-foreground">
{domain.request_count.toLocaleString()}
</span>
<span className="text-right text-chart-1">
{formatBytes(domain.bytes_sent)}
</span>
<span className="text-right text-chart-2">
{formatBytes(domain.bytes_received)}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Top Domains by Requests */}
{topDomainsByRequests.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2">
Top Domains by Requests (
{timePeriod === "all" ? "all time" : timePeriod})
</h3>
<div className="border rounded-md">
<div className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-xs font-medium text-muted-foreground border-b bg-muted/30">
<span>Domain</span>
<span className="text-right">Requests</span>
<span className="text-right">Total Traffic</span>
</div>
<div className="max-h-[180px] overflow-y-auto">
{topDomainsByRequests.map((domain, index) => (
<div
key={domain.domain}
className="grid grid-cols-[1fr_80px_100px] gap-2 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-muted/30"
>
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-muted-foreground w-4 shrink-0">
{index + 1}
</span>
<TruncatedDomain domain={domain.domain} />
</div>
<span className="text-right text-muted-foreground">
{domain.request_count.toLocaleString()}
</span>
<span className="text-right">
{formatBytes(
domain.bytes_sent + domain.bytes_received,
)}
</span>
</div>
))}
</div>
</div>
</div>
)}
{/* Unique IPs */}
{stats?.unique_ips && stats.unique_ips.length > 0 && (
<div>
<h3 className="text-sm font-medium mb-2">
Unique IPs ({stats.unique_ips.length})
</h3>
<div className="border rounded-md p-3 max-h-[120px] overflow-y-auto">
<div className="flex flex-wrap gap-1.5">
{stats.unique_ips.map((ip) => (
<span
key={ip}
className="text-xs bg-muted px-2 py-1 rounded font-mono"
>
{ip}
</span>
))}
</div>
</div>
</div>
)}
{/* No data state */}
{!stats && (
<div className="text-center py-8 text-muted-foreground">
<p>No traffic data available for this profile.</p>
<p className="text-sm mt-1">
Traffic data will appear after you launch the profile.
</p>
</div>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
+55
View File
@@ -0,0 +1,55 @@
"use client";
import {
type HTMLMotionProps,
type LegacyAnimationControls,
motion,
type TargetAndTransition,
type Transition,
} from "motion/react";
import type * as React from "react";
import { useAutoHeight } from "@/hooks/use-auto-height";
import { Slot, type WithAsChild } from "@/lib/slot";
type AutoHeightProps = WithAsChild<
{
children: React.ReactNode;
deps?: React.DependencyList;
animate?: TargetAndTransition | LegacyAnimationControls;
transition?: Transition;
} & Omit<HTMLMotionProps<"div">, "animate">
>;
function AutoHeight({
children,
deps = [],
transition = {
type: "spring",
stiffness: 300,
damping: 30,
bounce: 0,
restDelta: 0.01,
},
style,
animate,
asChild = false,
...props
}: AutoHeightProps) {
const { ref, height } = useAutoHeight<HTMLDivElement>(deps);
const Comp = asChild ? Slot : motion.div;
return (
<Comp
style={{ overflow: "hidden", ...style }}
animate={{ height, ...animate }}
transition={transition}
{...props}
>
<div ref={ref}>{children}</div>
</Comp>
);
}
export { AutoHeight, type AutoHeightProps };
+1 -3
View File
@@ -1,5 +1,3 @@
import type * as React from "react";
import { cn } from "@/lib/utils";
function Card({ className, ...props }: React.ComponentProps<"div">) {
@@ -20,7 +18,7 @@ function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-1.5 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className,
)}
{...props}
+378
View File
@@ -0,0 +1,378 @@
"use client";
import * as React from "react";
import * as RechartsPrimitive from "recharts";
import type {
Props as DefaultLegendContentProps,
LegendPayload,
} from "recharts/types/component/DefaultLegendContent";
import type { Payload } from "recharts/types/component/DefaultTooltipContent";
import type { TooltipContentProps } from "recharts/types/component/Tooltip";
import { cn } from "@/lib/utils";
// Format: { THEME_NAME: CSS_SELECTOR }
const THEMES = { light: "", dark: ".dark" } as const;
export type ChartConfig = {
[k in string]: {
label?: React.ReactNode;
icon?: React.ComponentType;
} & (
| { color?: string; theme?: never }
| { color?: never; theme: Record<keyof typeof THEMES, string> }
);
};
type ChartContextProps = {
config: ChartConfig;
};
const ChartContext = React.createContext<ChartContextProps | null>(null);
function useChart() {
const context = React.useContext(ChartContext);
if (!context) {
throw new Error("useChart must be used within a <ChartContainer />");
}
return context;
}
const ChartContainer = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> & {
config: ChartConfig;
children: React.ComponentProps<
typeof RechartsPrimitive.ResponsiveContainer
>["children"];
}
>(({ id, className, children, config, ...props }, ref) => {
const uniqueId = React.useId();
const chartId = `chart-${id || uniqueId.replace(/:/g, "")}`;
return (
<ChartContext.Provider value={{ config }}>
<div
data-chart={chartId}
ref={ref}
className={cn(
"flex aspect-video justify-center text-xs [&_.recharts-cartesian-axis-tick_text]:fill-muted-foreground [&_.recharts-cartesian-grid_line[stroke='#ccc']]:stroke-border/50 [&_.recharts-curve.recharts-tooltip-cursor]:stroke-border [&_.recharts-dot[stroke='#fff']]:stroke-transparent [&_.recharts-layer]:outline-none [&_.recharts-polar-grid_[stroke='#ccc']]:stroke-border [&_.recharts-radial-bar-background-sector]:fill-muted [&_.recharts-rectangle.recharts-tooltip-cursor]:fill-muted [&_.recharts-reference-line_[stroke='#ccc']]:stroke-border [&_.recharts-sector[stroke='#fff']]:stroke-transparent [&_.recharts-sector]:outline-none [&_.recharts-surface]:outline-none",
className,
)}
{...props}
>
<ChartStyle id={chartId} config={config} />
<RechartsPrimitive.ResponsiveContainer>
{children}
</RechartsPrimitive.ResponsiveContainer>
</div>
</ChartContext.Provider>
);
});
ChartContainer.displayName = "Chart";
const ChartStyle = ({ id, config }: { id: string; config: ChartConfig }) => {
const colorConfig = Object.entries(config).filter(
([, config]) => config.theme || config.color,
);
if (!colorConfig.length) {
return null;
}
return (
<style
// biome-ignore lint/security/noDangerouslySetInnerHtml: Safe usage for CSS variables from chart config
dangerouslySetInnerHTML={{
__html: Object.entries(THEMES)
.map(
([theme, prefix]) => `
${prefix} [data-chart=${id}] {
${colorConfig
.map(([key, itemConfig]) => {
const color =
itemConfig.theme?.[theme as keyof typeof itemConfig.theme] ||
itemConfig.color;
return color ? ` --color-${key}: ${color};` : null;
})
.join("\n")}
}
`,
)
.join("\n"),
}}
/>
);
};
const ChartTooltip = RechartsPrimitive.Tooltip;
const ChartTooltipContent = React.forwardRef<
HTMLDivElement,
TooltipContentProps<number, string> &
React.ComponentProps<"div"> & {
hideLabel?: boolean;
hideIndicator?: boolean;
indicator?: "line" | "dot" | "dashed";
nameKey?: string;
labelKey?: string;
labelClassName?: string;
color?: string;
}
>(
(
{
active,
payload,
className,
indicator = "dot",
hideLabel = false,
hideIndicator = false,
label,
labelFormatter,
labelClassName,
formatter,
color,
nameKey,
labelKey,
},
ref,
) => {
const { config } = useChart();
const tooltipLabel = React.useMemo(() => {
if (hideLabel || !payload?.length) {
return null;
}
const [item] = payload;
const key = `${labelKey || item?.dataKey || item?.name || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const value =
!labelKey && typeof label === "string"
? config[label as keyof typeof config]?.label || label
: itemConfig?.label;
if (labelFormatter) {
return (
<div className={cn("font-medium", labelClassName)}>
{labelFormatter(value, payload)}
</div>
);
}
if (!value) {
return null;
}
return <div className={cn("font-medium", labelClassName)}>{value}</div>;
}, [
label,
labelFormatter,
payload,
hideLabel,
labelClassName,
config,
labelKey,
]);
if (!active || !payload?.length) {
return null;
}
const nestLabel = payload.length === 1 && indicator !== "dot";
return (
<div
ref={ref}
className={cn(
"grid min-w-[8rem] items-start gap-1.5 rounded-lg border border-border/50 bg-background px-2.5 py-1.5 text-xs shadow-xl",
className,
)}
>
{!nestLabel ? tooltipLabel : null}
<div className="grid gap-1.5">
{payload
.filter((item: Payload<number, string>) => item.type !== "none")
.map((item: Payload<number, string>, index: number) => {
const key = `${nameKey || item.name || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
const indicatorColor = color || item.payload?.fill || item.color;
return (
<div
key={String(item.dataKey ?? index)}
className={cn(
"flex w-full flex-wrap items-stretch gap-2 [&>svg]:h-2.5 [&>svg]:w-2.5 [&>svg]:text-muted-foreground",
indicator === "dot" && "items-center",
)}
>
{formatter && item?.value !== undefined && item.name ? (
formatter(item.value, item.name, item, index, item.payload)
) : (
<>
{itemConfig?.icon ? (
<itemConfig.icon />
) : (
!hideIndicator && (
<div
className={cn(
"shrink-0 rounded-[2px] border-[--color-border] bg-[--color-bg]",
{
"h-2.5 w-2.5": indicator === "dot",
"w-1": indicator === "line",
"w-0 border-[1.5px] border-dashed bg-transparent":
indicator === "dashed",
"my-0.5": nestLabel && indicator === "dashed",
},
)}
style={
{
"--color-bg": indicatorColor,
"--color-border": indicatorColor,
} as React.CSSProperties
}
/>
)
)}
<div
className={cn(
"flex flex-1 justify-between leading-none",
nestLabel ? "items-end" : "items-center",
)}
>
<div className="grid gap-1.5">
{nestLabel ? tooltipLabel : null}
<span className="text-muted-foreground">
{itemConfig?.label || item.name}
</span>
</div>
{item.value && (
<span className="font-mono font-medium tabular-nums text-foreground">
{item.value.toLocaleString()}
</span>
)}
</div>
</>
)}
</div>
);
})}
</div>
</div>
);
},
);
ChartTooltipContent.displayName = "ChartTooltip";
const ChartLegend = RechartsPrimitive.Legend;
const ChartLegendContent = React.forwardRef<
HTMLDivElement,
React.ComponentProps<"div"> &
Pick<DefaultLegendContentProps, "payload" | "verticalAlign"> & {
hideIcon?: boolean;
nameKey?: string;
}
>(
(
{ className, hideIcon = false, payload, verticalAlign = "bottom", nameKey },
ref,
) => {
const { config } = useChart();
if (!payload?.length) {
return null;
}
return (
<div
ref={ref}
className={cn(
"flex items-center justify-center gap-4",
verticalAlign === "top" ? "pb-3" : "pt-3",
className,
)}
>
{payload
.filter((item: LegendPayload) => item.type !== "none")
.map((item: LegendPayload) => {
const key = `${nameKey || item.dataKey || "value"}`;
const itemConfig = getPayloadConfigFromPayload(config, item, key);
return (
<div
key={item.value}
className={cn(
"flex items-center gap-1.5 [&>svg]:h-3 [&>svg]:w-3 [&>svg]:text-muted-foreground",
)}
>
{itemConfig?.icon && !hideIcon ? (
<itemConfig.icon />
) : (
<div
className="h-2 w-2 shrink-0 rounded-[2px]"
style={{
backgroundColor: item.color,
}}
/>
)}
{itemConfig?.label}
</div>
);
})}
</div>
);
},
);
ChartLegendContent.displayName = "ChartLegend";
// Helper to extract item config from a payload.
function getPayloadConfigFromPayload(
config: ChartConfig,
payload: unknown,
key: string,
) {
if (typeof payload !== "object" || payload === null) {
return undefined;
}
const payloadPayload =
"payload" in payload &&
typeof payload.payload === "object" &&
payload.payload !== null
? payload.payload
: undefined;
let configLabelKey: string = key;
if (
key in payload &&
typeof payload[key as keyof typeof payload] === "string"
) {
configLabelKey = payload[key as keyof typeof payload] as string;
} else if (
payloadPayload &&
key in payloadPayload &&
typeof payloadPayload[key as keyof typeof payloadPayload] === "string"
) {
configLabelKey = payloadPayload[
key as keyof typeof payloadPayload
] as string;
}
return configLabelKey in config
? config[configLabelKey]
: config[key as keyof typeof config];
}
export {
ChartContainer,
ChartTooltip,
ChartTooltipContent,
ChartLegend,
ChartLegendContent,
ChartStyle,
};
+65
View File
@@ -0,0 +1,65 @@
"use client";
import { useCallback, useState } from "react";
import { LuCheck, LuCopy } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { showSuccessToast } from "@/lib/toast-utils";
interface CopyToClipboardProps {
text: string;
variant?:
| "default"
| "destructive"
| "outline"
| "secondary"
| "ghost"
| "link";
size?: "default" | "sm" | "lg" | "icon";
className?: string;
successMessage?: string;
}
export function CopyToClipboard({
text,
variant = "outline",
size = "icon",
className,
successMessage = "Copied to clipboard",
}: CopyToClipboardProps) {
const [copied, setCopied] = useState(false);
const copyToClipboard = useCallback(async () => {
try {
await navigator.clipboard.writeText(text);
setCopied(true);
showSuccessToast(successMessage);
setTimeout(() => {
setCopied(false);
}, 2000);
} catch (error) {
console.error("Failed to copy to clipboard:", error);
}
}, [text, successMessage]);
return (
<Button
variant={variant}
size={size}
className={`relative ${className || ""}`}
onClick={copyToClipboard}
aria-label={copied ? "Copied" : "Copy to clipboard"}
>
<span className="sr-only">{copied ? "Copied" : "Copy"}</span>
<LuCopy
className={`h-4 w-4 transition-all duration-300 ${
copied ? "scale-0" : "scale-100"
}`}
/>
<LuCheck
className={`absolute inset-0 m-auto h-4 w-4 transition-all duration-300 ${
copied ? "scale-100" : "scale-0"
}`}
/>
</Button>
);
}
+138 -39
View File
@@ -1,86 +1,185 @@
"use client";
import * as DialogPrimitive from "@radix-ui/react-dialog";
import { AnimatePresence, type HTMLMotionProps, motion } from "motion/react";
import { Dialog as DialogPrimitive } from "radix-ui";
import type * as React from "react";
import { RxCross2 } from "react-icons/rx";
import { useControlledState } from "@/hooks/use-controlled-state";
import { getStrictContext } from "@/lib/get-strict-context";
import { cn } from "@/lib/utils";
import { WindowDragArea } from "../window-drag-area";
function Dialog({
...props
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
type DialogContextType = {
isOpen: boolean;
setIsOpen: DialogProps["onOpenChange"];
};
const [DialogProvider, useDialog] =
getStrictContext<DialogContextType>("DialogContext");
type DialogProps = React.ComponentProps<typeof DialogPrimitive.Root>;
function Dialog(props: DialogProps) {
const [isOpen, setIsOpen] = useControlledState({
value: props?.open,
defaultValue: props?.defaultOpen,
onChange: props?.onOpenChange,
});
return (
<DialogProvider value={{ isOpen, setIsOpen }}>
<DialogPrimitive.Root
data-slot="dialog"
{...props}
onOpenChange={setIsOpen}
/>
</DialogProvider>
);
}
function DialogTrigger({
...props
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
type DialogTriggerProps = React.ComponentProps<typeof DialogPrimitive.Trigger>;
function DialogTrigger(props: DialogTriggerProps) {
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
}
function DialogPortal({
...props
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
type DialogPortalProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Portal>,
"forceMount"
>;
function DialogPortal(props: DialogPortalProps) {
const { isOpen } = useDialog();
return (
<AnimatePresence>
{isOpen && (
<DialogPrimitive.Portal
data-slot="dialog-portal"
forceMount
{...props}
/>
)}
</AnimatePresence>
);
}
function DialogClose({
...props
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
type DialogOverlayProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Overlay>,
"forceMount" | "asChild"
> &
HTMLMotionProps<"div">;
function DialogOverlay({
className,
transition = { duration: 0.2, ease: "easeInOut" },
...props
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
}: DialogOverlayProps) {
return (
<DialogPrimitive.Overlay
data-slot="dialog-overlay"
className={cn(
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-[9999] bg-background/50",
className,
)}
{...props}
>
<WindowDragArea />
<DialogPrimitive.Overlay data-slot="dialog-overlay" asChild forceMount>
<motion.div
key="dialog-overlay"
initial={{ opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(4px)" }}
transition={transition}
className={cn("fixed inset-0 z-9999 bg-background/50", className)}
{...props}
>
<WindowDragArea />
</motion.div>
</DialogPrimitive.Overlay>
);
}
type DialogFlipDirection = "top" | "bottom" | "left" | "right";
type DialogContentProps = Omit<
React.ComponentProps<typeof DialogPrimitive.Content>,
"forceMount" | "asChild"
> &
HTMLMotionProps<"div"> & {
from?: DialogFlipDirection;
};
function DialogContent({
className,
children,
from = "top",
onOpenAutoFocus,
onCloseAutoFocus,
onEscapeKeyDown,
onPointerDownOutside,
onInteractOutside,
transition = { type: "spring", stiffness: 150, damping: 25 },
...props
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
}: DialogContentProps) {
const initialRotation =
from === "bottom" || from === "left" ? "20deg" : "-20deg";
const isVertical = from === "top" || from === "bottom";
const rotateAxis = isVertical ? "rotateX" : "rotateY";
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
<DialogPrimitive.Content
data-slot="dialog-content"
className={cn(
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-[10000] grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
className,
)}
asChild
forceMount
onOpenAutoFocus={onOpenAutoFocus}
onCloseAutoFocus={onCloseAutoFocus}
onEscapeKeyDown={onEscapeKeyDown}
onPointerDownOutside={onPointerDownOutside}
onInteractOutside={(event) => {
const target = event.target as HTMLElement | null;
if (target?.closest('[data-window-drag-area="true"]')) {
event.preventDefault();
}
onInteractOutside?.(event);
}}
{...props}
>
{children}
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
<motion.div
key="dialog-content"
data-slot="dialog-content"
initial={{
opacity: 0,
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
animate={{
opacity: 1,
filter: "blur(0px)",
transform: `perspective(500px) ${rotateAxis}(0deg) scale(1)`,
}}
exit={{
opacity: 0,
filter: "blur(4px)",
transform: `perspective(500px) ${rotateAxis}(${initialRotation}) scale(0.8)`,
}}
transition={transition}
className={cn(
"bg-background fixed top-[50%] left-[50%] z-10000 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg sm:max-w-lg",
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="cursor-pointer ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<RxCross2 />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</motion.div>
</DialogPrimitive.Content>
</DialogPortal>
);
}
type DialogCloseProps = React.ComponentProps<typeof DialogPrimitive.Close>;
function DialogClose(props: DialogCloseProps) {
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
}
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
+640
View File
@@ -0,0 +1,640 @@
"use client";
import { AnimatePresence, motion, type Transition } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
type HighlightMode = "children" | "parent";
type Bounds = {
top: number;
left: number;
width: number;
height: number;
};
const DEFAULT_BOUNDS_OFFSET: Bounds = {
top: 0,
left: 0,
width: 0,
height: 0,
};
type HighlightContextType<T extends string> = {
as?: keyof HTMLElementTagNameMap;
mode: HighlightMode;
activeValue: T | null;
setActiveValue: (value: T | null) => void;
setBounds: (bounds: DOMRect) => void;
clearBounds: () => void;
id: string;
hover: boolean;
click: boolean;
className?: string;
style?: React.CSSProperties;
activeClassName?: string;
setActiveClassName: (className: string) => void;
transition?: Transition;
disabled?: boolean;
enabled?: boolean;
exitDelay?: number;
forceUpdateBounds?: boolean;
};
const HighlightContext = React.createContext<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
HighlightContextType<any> | undefined
>(undefined);
function useHighlight<T extends string>(): HighlightContextType<T> {
const context = React.useContext(HighlightContext);
if (!context) {
throw new Error("useHighlight must be used within a HighlightProvider");
}
return context as unknown as HighlightContextType<T>;
}
type BaseHighlightProps<T extends React.ElementType = "div"> = {
as?: T;
ref?: React.Ref<HTMLDivElement>;
mode?: HighlightMode;
value?: string | null;
defaultValue?: string | null;
onValueChange?: (value: string | null) => void;
className?: string;
style?: React.CSSProperties;
transition?: Transition;
hover?: boolean;
click?: boolean;
disabled?: boolean;
enabled?: boolean;
exitDelay?: number;
};
type ParentModeHighlightProps = {
boundsOffset?: Partial<Bounds>;
containerClassName?: string;
forceUpdateBounds?: boolean;
};
type ControlledParentModeHighlightProps<T extends React.ElementType = "div"> =
BaseHighlightProps<T> &
ParentModeHighlightProps & {
mode: "parent";
controlledItems: true;
children: React.ReactNode;
};
type ControlledChildrenModeHighlightProps<T extends React.ElementType = "div"> =
BaseHighlightProps<T> & {
mode?: "children" | undefined;
controlledItems: true;
children: React.ReactNode;
};
type UncontrolledParentModeHighlightProps<T extends React.ElementType = "div"> =
BaseHighlightProps<T> &
ParentModeHighlightProps & {
mode: "parent";
controlledItems?: false;
itemsClassName?: string;
children: React.ReactElement | React.ReactElement[];
};
type UncontrolledChildrenModeHighlightProps<
T extends React.ElementType = "div",
> = BaseHighlightProps<T> & {
mode?: "children";
controlledItems?: false;
itemsClassName?: string;
children: React.ReactElement | React.ReactElement[];
};
type HighlightProps<T extends React.ElementType = "div"> =
| ControlledParentModeHighlightProps<T>
| ControlledChildrenModeHighlightProps<T>
| UncontrolledParentModeHighlightProps<T>
| UncontrolledChildrenModeHighlightProps<T>;
function Highlight<T extends React.ElementType = "div">({
ref,
...props
}: HighlightProps<T>) {
const {
as: Component = "div",
children,
value,
defaultValue,
onValueChange,
className,
style,
transition = { type: "spring", stiffness: 350, damping: 35 },
hover = false,
click = true,
enabled = true,
controlledItems,
disabled = false,
exitDelay = 200,
mode = "children",
} = props;
const localRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
const propsBoundsOffset = (props as ParentModeHighlightProps)?.boundsOffset;
const boundsOffset = propsBoundsOffset ?? DEFAULT_BOUNDS_OFFSET;
const boundsOffsetTop = boundsOffset.top ?? 0;
const boundsOffsetLeft = boundsOffset.left ?? 0;
const boundsOffsetWidth = boundsOffset.width ?? 0;
const boundsOffsetHeight = boundsOffset.height ?? 0;
const boundsOffsetRef = React.useRef({
top: boundsOffsetTop,
left: boundsOffsetLeft,
width: boundsOffsetWidth,
height: boundsOffsetHeight,
});
React.useEffect(() => {
boundsOffsetRef.current = {
top: boundsOffsetTop,
left: boundsOffsetLeft,
width: boundsOffsetWidth,
height: boundsOffsetHeight,
};
}, [
boundsOffsetTop,
boundsOffsetLeft,
boundsOffsetWidth,
boundsOffsetHeight,
]);
const [activeValue, setActiveValue] = React.useState<string | null>(
value ?? defaultValue ?? null,
);
const [boundsState, setBoundsState] = React.useState<Bounds | null>(null);
const [activeClassNameState, setActiveClassNameState] =
React.useState<string>("");
const safeSetActiveValue = (id: string | null) => {
setActiveValue((prev) => {
if (prev !== id) {
onValueChange?.(id);
return id;
}
return prev;
});
};
const safeSetBoundsRef = React.useRef<
((bounds: DOMRect) => void) | undefined
>(undefined);
React.useEffect(() => {
safeSetBoundsRef.current = (bounds: DOMRect) => {
if (!localRef.current) return;
const containerRect = localRef.current.getBoundingClientRect();
const offset = boundsOffsetRef.current;
const newBounds: Bounds = {
top: bounds.top - containerRect.top + offset.top,
left: bounds.left - containerRect.left + offset.left,
width: bounds.width + offset.width,
height: bounds.height + offset.height,
};
setBoundsState((prev) => {
if (
prev &&
prev.top === newBounds.top &&
prev.left === newBounds.left &&
prev.width === newBounds.width &&
prev.height === newBounds.height
) {
return prev;
}
return newBounds;
});
};
});
const safeSetBounds = (bounds: DOMRect) => {
safeSetBoundsRef.current?.(bounds);
};
const clearBounds = React.useCallback(() => {
setBoundsState((prev) => (prev === null ? prev : null));
}, []);
React.useEffect(() => {
if (value !== undefined) setActiveValue(value);
else if (defaultValue !== undefined) setActiveValue(defaultValue);
}, [value, defaultValue]);
const id = React.useId();
React.useEffect(() => {
if (mode !== "parent") return;
const container = localRef.current;
if (!container) return;
const onScroll = () => {
if (!activeValue) return;
const activeEl = container.querySelector<HTMLElement>(
`[data-value="${activeValue}"][data-highlight="true"]`,
);
if (activeEl)
safeSetBoundsRef.current?.(activeEl.getBoundingClientRect());
};
container.addEventListener("scroll", onScroll, { passive: true });
return () => container.removeEventListener("scroll", onScroll);
}, [mode, activeValue]);
const render = (children: React.ReactNode) => {
if (mode === "parent") {
return (
<Component
ref={localRef}
data-slot="motion-highlight-container"
style={{ position: "relative", zIndex: 1 }}
className={(props as ParentModeHighlightProps)?.containerClassName}
>
<AnimatePresence initial={false} mode="wait">
{boundsState && (
<motion.div
data-slot="motion-highlight"
animate={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 1,
}}
initial={{
top: boundsState.top,
left: boundsState.left,
width: boundsState.width,
height: boundsState.height,
opacity: 0,
}}
exit={{
opacity: 0,
transition: {
...transition,
delay: (transition?.delay ?? 0) + (exitDelay ?? 0) / 1000,
},
}}
transition={transition}
style={{ position: "absolute", zIndex: 0, ...style }}
className={cn(className, activeClassNameState)}
/>
)}
</AnimatePresence>
{children}
</Component>
);
}
return children;
};
return (
<HighlightContext.Provider
value={{
mode,
activeValue,
setActiveValue: safeSetActiveValue,
id,
hover,
click,
className,
style,
transition,
disabled,
enabled,
exitDelay,
setBounds: safeSetBounds,
clearBounds,
activeClassName: activeClassNameState,
setActiveClassName: setActiveClassNameState,
forceUpdateBounds: (props as ParentModeHighlightProps)
?.forceUpdateBounds,
}}
>
{enabled
? controlledItems
? render(children)
: render(
React.Children.map(children, (child, index) => (
<HighlightItem key={index} className={props?.itemsClassName}>
{child}
</HighlightItem>
)),
)
: children}
</HighlightContext.Provider>
);
}
function getNonOverridingDataAttributes(
element: React.ReactElement,
dataAttributes: Record<string, unknown>,
): Record<string, unknown> {
return Object.keys(dataAttributes).reduce<Record<string, unknown>>(
(acc, key) => {
if ((element.props as Record<string, unknown>)[key] === undefined) {
acc[key] = dataAttributes[key];
}
return acc;
},
{},
);
}
type ExtendedChildProps = React.ComponentProps<"div"> & {
id?: string;
ref?: React.Ref<HTMLElement>;
"data-active"?: string;
"data-value"?: string;
"data-disabled"?: boolean;
"data-highlight"?: boolean;
"data-slot"?: string;
};
type HighlightItemProps<T extends React.ElementType = "div"> =
React.ComponentProps<T> & {
as?: T;
children: React.ReactElement;
id?: string;
value?: string;
className?: string;
style?: React.CSSProperties;
transition?: Transition;
activeClassName?: string;
disabled?: boolean;
exitDelay?: number;
asChild?: boolean;
forceUpdateBounds?: boolean;
};
function HighlightItem<T extends React.ElementType>({
ref,
as,
children,
id,
value,
className,
style,
transition,
disabled = false,
activeClassName,
exitDelay,
asChild = false,
forceUpdateBounds,
...props
}: HighlightItemProps<T>) {
const itemId = React.useId();
const {
activeValue,
setActiveValue,
mode,
setBounds,
clearBounds,
hover,
click,
enabled,
className: contextClassName,
style: contextStyle,
transition: contextTransition,
id: contextId,
disabled: contextDisabled,
exitDelay: contextExitDelay,
forceUpdateBounds: contextForceUpdateBounds,
setActiveClassName,
} = useHighlight();
const Component = as ?? "div";
const element = children as React.ReactElement<ExtendedChildProps>;
const childValue =
id ?? value ?? element.props?.["data-value"] ?? element.props?.id ?? itemId;
const isActive = activeValue === childValue;
const isDisabled = disabled === undefined ? contextDisabled : disabled;
const itemTransition = transition ?? contextTransition;
const localRef = React.useRef<HTMLDivElement>(null);
React.useImperativeHandle(ref, () => localRef.current as HTMLDivElement);
const refCallback = React.useCallback((node: HTMLElement | null) => {
localRef.current = node as HTMLDivElement;
}, []);
React.useEffect(() => {
if (mode !== "parent") return;
let rafId: number;
let previousBounds: Bounds | null = null;
const shouldUpdateBounds =
forceUpdateBounds === true ||
(contextForceUpdateBounds && forceUpdateBounds !== false);
const updateBounds = () => {
if (!localRef.current) return;
const bounds = localRef.current.getBoundingClientRect();
if (shouldUpdateBounds) {
if (
previousBounds &&
previousBounds.top === bounds.top &&
previousBounds.left === bounds.left &&
previousBounds.width === bounds.width &&
previousBounds.height === bounds.height
) {
rafId = requestAnimationFrame(updateBounds);
return;
}
previousBounds = bounds;
rafId = requestAnimationFrame(updateBounds);
}
setBounds(bounds);
};
if (isActive) {
updateBounds();
setActiveClassName(activeClassName ?? "");
} else if (!activeValue) clearBounds();
if (shouldUpdateBounds) return () => cancelAnimationFrame(rafId);
}, [
mode,
isActive,
activeValue,
setBounds,
clearBounds,
activeClassName,
setActiveClassName,
forceUpdateBounds,
contextForceUpdateBounds,
]);
if (!React.isValidElement(children)) return children;
const dataAttributes = {
"data-active": isActive ? "true" : "false",
"aria-selected": isActive,
"data-disabled": isDisabled,
"data-value": childValue,
"data-highlight": true,
};
const commonHandlers = hover
? {
onMouseEnter: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue);
element.props.onMouseEnter?.(e);
},
onMouseLeave: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(null);
element.props.onMouseLeave?.(e);
},
}
: click
? {
onClick: (e: React.MouseEvent<HTMLDivElement>) => {
setActiveValue(childValue);
element.props.onClick?.(e);
},
}
: {};
if (asChild) {
if (mode === "children") {
return React.cloneElement(
element,
{
key: childValue,
ref: refCallback,
className: cn("relative", element.props.className),
...getNonOverridingDataAttributes(element, {
...dataAttributes,
"data-slot": "motion-highlight-item-container",
}),
...commonHandlers,
...props,
},
<>
<AnimatePresence initial={false} mode="wait">
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot="motion-highlight"
style={{
position: "absolute",
zIndex: 0,
...contextStyle,
...style,
}}
className={cn(contextClassName, activeClassName)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay:
(itemTransition?.delay ?? 0) +
(exitDelay ?? contextExitDelay ?? 0) / 1000,
},
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
<Component
data-slot="motion-highlight-item"
style={{ position: "relative", zIndex: 1 }}
className={className}
{...dataAttributes}
>
{children}
</Component>
</>,
);
}
return React.cloneElement(element, {
ref: refCallback,
...getNonOverridingDataAttributes(element, {
...dataAttributes,
"data-slot": "motion-highlight-item",
}),
...commonHandlers,
});
}
return enabled ? (
<Component
key={childValue}
ref={localRef}
data-slot="motion-highlight-item-container"
className={cn(mode === "children" && "relative", className)}
{...dataAttributes}
{...props}
{...commonHandlers}
>
{mode === "children" && (
<AnimatePresence initial={false} mode="wait">
{isActive && !isDisabled && (
<motion.div
layoutId={`transition-background-${contextId}`}
data-slot="motion-highlight"
style={{
position: "absolute",
zIndex: 0,
...contextStyle,
...style,
}}
className={cn(contextClassName, activeClassName)}
transition={itemTransition}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{
opacity: 0,
transition: {
...itemTransition,
delay:
(itemTransition?.delay ?? 0) +
(exitDelay ?? contextExitDelay ?? 0) / 1000,
},
}}
{...dataAttributes}
/>
)}
</AnimatePresence>
)}
{React.cloneElement(element, {
style: { position: "relative", zIndex: 1 },
className: element.props.className,
...getNonOverridingDataAttributes(element, {
...dataAttributes,
"data-slot": "motion-highlight-item",
}),
})}
</Component>
) : (
children
);
}
export {
Highlight,
HighlightItem,
useHighlight,
type HighlightProps,
type HighlightItemProps,
};
+179 -18
View File
@@ -1,18 +1,82 @@
"use client";
import * as TabsPrimitive from "@radix-ui/react-tabs";
import {
AnimatePresence,
type HTMLMotionProps,
motion,
type Transition,
} from "motion/react";
import * as React from "react";
import { AutoHeight } from "@/components/ui/auto-height";
import {
Highlight,
HighlightItem,
type HighlightItemProps,
type HighlightProps,
} from "@/components/ui/highlight";
import { useControlledState } from "@/hooks/use-controlled-state";
import { getStrictContext } from "@/lib/get-strict-context";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
type TabsContextType = {
value: string | undefined;
setValue: TabsProps["onValueChange"];
};
const [TabsProvider, useTabs] =
getStrictContext<TabsContextType>("TabsContext");
type TabsProps = React.ComponentProps<typeof TabsPrimitive.Root>;
function Tabs(props: TabsProps) {
const [value, setValue] = useControlledState({
value: props.value,
defaultValue: props.defaultValue,
onChange: props.onValueChange,
});
return (
<TabsProvider value={{ value, setValue }}>
<TabsPrimitive.Root
data-slot="tabs"
{...props}
onValueChange={setValue}
/>
</TabsProvider>
);
}
type TabsHighlightProps = Omit<HighlightProps, "controlledItems" | "value">;
function TabsHighlight({
transition = { type: "spring", stiffness: 200, damping: 25 },
...props
}: TabsHighlightProps) {
const { value } = useTabs();
return (
<Highlight
data-slot="tabs-highlight"
controlledItems
value={value}
transition={transition}
click={false}
{...props}
/>
);
}
type TabsListProps = React.ComponentProps<typeof TabsPrimitive.List>;
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
TabsListProps
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
data-slot="tabs-list"
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className,
@@ -22,12 +86,23 @@ const TabsList = React.forwardRef<
));
TabsList.displayName = TabsPrimitive.List.displayName;
type TabsHighlightItemProps = HighlightItemProps & {
value: string;
};
function TabsHighlightItem(props: TabsHighlightItemProps) {
return <HighlightItem data-slot="tabs-highlight-item" {...props} />;
}
type TabsTriggerProps = React.ComponentProps<typeof TabsPrimitive.Trigger>;
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
TabsTriggerProps
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
data-slot="tabs-trigger"
className={cn(
"cursor-pointer inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className,
@@ -37,19 +112,105 @@ const TabsTrigger = React.forwardRef<
));
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName;
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
));
TabsContent.displayName = TabsPrimitive.Content.displayName;
type TabsContentProps = React.ComponentProps<typeof TabsPrimitive.Content> &
HTMLMotionProps<"div">;
export { Tabs, TabsList, TabsTrigger, TabsContent };
function TabsContent({
value,
forceMount,
transition = { duration: 0.5, ease: "easeInOut" },
className,
...props
}: TabsContentProps) {
return (
<AnimatePresence mode="wait">
<TabsPrimitive.Content asChild forceMount={forceMount} value={value}>
<motion.div
data-slot="tabs-content"
layout
layoutDependency={value}
initial={{ opacity: 0, filter: "blur(4px)" }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: "blur(4px)" }}
transition={transition}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className,
)}
{...props}
/>
</TabsPrimitive.Content>
</AnimatePresence>
);
}
type TabsContentsAutoProps = React.ComponentProps<typeof AutoHeight> & {
mode?: "auto-height";
children: React.ReactNode;
transition?: Transition;
};
type TabsContentsLayoutProps = Omit<HTMLMotionProps<"div">, "transition"> & {
mode: "layout";
children: React.ReactNode;
transition?: Transition;
};
type TabsContentsProps = TabsContentsAutoProps | TabsContentsLayoutProps;
const defaultTransition: Transition = {
type: "spring",
stiffness: 200,
damping: 30,
};
function isAutoMode(props: TabsContentsProps): props is TabsContentsAutoProps {
return !("mode" in props) || props.mode === "auto-height";
}
function TabsContents(props: TabsContentsProps) {
const { value } = useTabs();
if (isAutoMode(props)) {
const { transition = defaultTransition, ...autoProps } = props;
return (
<AutoHeight
data-slot="tabs-contents"
deps={[value]}
transition={transition}
{...autoProps}
/>
);
}
const { transition = defaultTransition, style, ...layoutProps } = props;
return (
<motion.div
data-slot="tabs-contents"
layout="size"
layoutDependency={value}
style={{ overflow: "hidden", ...style }}
transition={{ layout: transition }}
{...layoutProps}
/>
);
}
export {
Tabs,
TabsHighlight,
TabsHighlightItem,
TabsList,
TabsTrigger,
TabsContent,
TabsContents,
type TabsProps,
type TabsHighlightProps,
type TabsHighlightItemProps,
type TabsListProps,
type TabsTriggerProps,
type TabsContentProps,
type TabsContentsProps,
};
+14 -2
View File
@@ -37,14 +37,19 @@ function TooltipTrigger({
function TooltipContent({
className,
sideOffset = 0,
alignOffset,
arrowOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
}: React.ComponentProps<typeof TooltipPrimitive.Content> & {
arrowOffset?: number;
}) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
alignOffset={alignOffset}
className={cn(
"bg-primary text-primary-foreground animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-[50000] w-fit origin-(--radix-tooltip-content-transform-origin) rounded-md px-3 py-1.5 text-xs text-balance",
className,
@@ -52,7 +57,14 @@ function TooltipContent({
{...props}
>
{children}
<TooltipPrimitive.Arrow className="bg-primary fill-primary z-[50000] size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px]" />
<TooltipPrimitive.Arrow
className="fill-primary z-[50000]"
style={
arrowOffset !== 0
? { transform: `translateX(${-arrowOffset}px)` }
: undefined
}
/>
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
);
+101
View File
@@ -0,0 +1,101 @@
"use client";
import * as React from "react";
type AutoHeightOptions = {
includeParentBox?: boolean;
includeSelfBox?: boolean;
};
export function useAutoHeight<T extends HTMLElement = HTMLDivElement>(
deps: React.DependencyList = [],
options: AutoHeightOptions = {
includeParentBox: true,
includeSelfBox: false,
},
) {
const ref = React.useRef<T | null>(null);
const roRef = React.useRef<ResizeObserver | null>(null);
const [height, setHeight] = React.useState(0);
const measure = React.useCallback(() => {
const el = ref.current;
if (!el) return 0;
const base = el.getBoundingClientRect().height || 0;
let extra = 0;
if (options.includeParentBox && el.parentElement) {
const cs = getComputedStyle(el.parentElement);
const paddingY =
(parseFloat(cs.paddingTop || "0") || 0) +
(parseFloat(cs.paddingBottom || "0") || 0);
const borderY =
(parseFloat(cs.borderTopWidth || "0") || 0) +
(parseFloat(cs.borderBottomWidth || "0") || 0);
const isBorderBox = cs.boxSizing === "border-box";
if (isBorderBox) {
extra += paddingY + borderY;
}
}
if (options.includeSelfBox) {
const cs = getComputedStyle(el);
const paddingY =
(parseFloat(cs.paddingTop || "0") || 0) +
(parseFloat(cs.paddingBottom || "0") || 0);
const borderY =
(parseFloat(cs.borderTopWidth || "0") || 0) +
(parseFloat(cs.borderBottomWidth || "0") || 0);
const isBorderBox = cs.boxSizing === "border-box";
if (isBorderBox) {
extra += paddingY + borderY;
}
}
const dpr =
typeof window !== "undefined" ? window.devicePixelRatio || 1 : 1;
const total = Math.ceil((base + extra) * dpr) / dpr;
return total;
}, [options.includeParentBox, options.includeSelfBox]);
React.useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
setHeight(measure());
if (roRef.current) {
roRef.current.disconnect();
roRef.current = null;
}
const ro = new ResizeObserver(() => {
const next = measure();
requestAnimationFrame(() => setHeight(next));
});
ro.observe(el);
if (options.includeParentBox && el.parentElement) {
ro.observe(el.parentElement);
}
roRef.current = ro;
return () => {
ro.disconnect();
roRef.current = null;
};
}, [...deps, measure, options.includeParentBox]);
React.useLayoutEffect(() => {
if (height === 0) {
const next = measure();
if (next !== 0) setHeight(next);
}
}, [height, measure]);
return { ref, height } as const;
}
+6 -10
View File
@@ -3,7 +3,7 @@ import { getBrowserDisplayName } from "@/lib/browser-utils";
import type { BrowserProfile } from "@/types";
/**
* Hook for managing browser state and enforcing single-instance rules for Tor and Mullvad browsers
* Hook for managing browser state
*/
export function useBrowserState(
profiles: BrowserProfile[],
@@ -22,8 +22,8 @@ export function useBrowserState(
* 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";
(_browserType: string): boolean => {
return false; // No browsers currently require single instance
},
[],
);
@@ -102,7 +102,7 @@ export function useBrowserState(
return false;
}
// For single-instance browsers (Tor and Mullvad)
// For single-instance browsers
if (isSingleInstanceBrowser(profile.browser)) {
const runningInstancesOfType = profiles.filter(
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
@@ -195,9 +195,7 @@ export function useBrowserState(
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 `Only one instance of this browser can run at a time. Stop the running browser first.`;
}
return "";
@@ -242,8 +240,6 @@ export function useBrowserState(
}
if (isSingleInstanceBrowser(profile.browser)) {
const browserDisplayName =
profile.browser === "tor-browser" ? "TOR" : "Mullvad";
const runningInstancesOfType = profiles.filter(
(p) => p.browser === profile.browser && runningProfiles.has(p.id),
);
@@ -252,7 +248,7 @@ export function useBrowserState(
const runningProfileNames = runningInstancesOfType
.map((p) => p.name)
.join(", ");
return `${browserDisplayName} browser is already running (${runningProfileNames}). Only one instance can run at a time.`;
return `${getBrowserDisplayName(profile.browser)} browser is already running (${runningProfileNames}). Only one instance can run at a time.`;
}
}
+33
View File
@@ -0,0 +1,33 @@
import * as React from "react";
interface CommonControlledStateProps<T> {
value?: T;
defaultValue?: T;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function useControlledState<T, Rest extends any[] = []>(
props: CommonControlledStateProps<T> & {
onChange?: (value: T, ...args: Rest) => void;
},
): readonly [T, (next: T, ...args: Rest) => void] {
const { value, defaultValue, onChange } = props;
const [state, setInternalState] = React.useState<T>(
value !== undefined ? value : (defaultValue as T),
);
React.useEffect(() => {
if (value !== undefined) setInternalState(value);
}, [value]);
const setState = React.useCallback(
(next: T, ...args: Rest) => {
setInternalState(next);
onChange?.(next, ...args);
},
[onChange],
);
return [state, setState] as const;
}
+1 -7
View File
@@ -4,7 +4,7 @@
*/
import { FaChrome, FaFirefox, FaShieldAlt } from "react-icons/fa";
import { SiBrave, SiMullvad, SiTorbrowser } from "react-icons/si";
import { SiBrave } from "react-icons/si";
import { ZenBrowser } from "@/components/icons/zen-browser";
/**
@@ -14,11 +14,9 @@ export function getBrowserDisplayName(browserType: string): string {
const browserNames: Record<string, string> = {
firefox: "Firefox",
"firefox-developer": "Firefox Developer Edition",
"mullvad-browser": "Mullvad Browser",
zen: "Zen Browser",
brave: "Brave",
chromium: "Chromium",
"tor-browser": "Tor Browser",
camoufox: "Anti-Detect",
};
@@ -30,8 +28,6 @@ export function getBrowserDisplayName(browserType: string): string {
*/
export function getBrowserIcon(browserType: string) {
switch (browserType) {
case "mullvad-browser":
return SiMullvad;
case "chromium":
return FaChrome;
case "brave":
@@ -41,8 +37,6 @@ export function getBrowserIcon(browserType: string) {
return FaFirefox;
case "zen":
return ZenBrowser;
case "tor-browser":
return SiTorbrowser;
case "camoufox":
return FaShieldAlt;
default:
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react";
function getStrictContext<T>(
name?: string,
): readonly [
({
value,
children,
}: {
value: T;
children?: React.ReactNode;
}) => React.JSX.Element,
() => T,
] {
const Context = React.createContext<T | undefined>(undefined);
const Provider = ({
value,
children,
}: {
value: T;
children?: React.ReactNode;
}) => <Context.Provider value={value}>{children}</Context.Provider>;
const useSafeContext = () => {
const ctx = React.useContext(Context);
if (ctx === undefined) {
throw new Error(`useContext must be used within ${name ?? "a Provider"}`);
}
return ctx;
};
return [Provider, useSafeContext] as const;
}
export { getStrictContext };
+98
View File
@@ -0,0 +1,98 @@
"use client";
import { type HTMLMotionProps, isMotionComponent, motion } from "motion/react";
import * as React from "react";
import { cn } from "@/lib/utils";
type AnyProps = Record<string, unknown>;
type DOMMotionProps<T extends HTMLElement = HTMLElement> = Omit<
HTMLMotionProps<keyof HTMLElementTagNameMap>,
"ref"
> & { ref?: React.Ref<T> };
type WithAsChild<Base extends object> =
| (Base & { asChild: true; children: React.ReactElement })
| (Base & { asChild?: false | undefined });
type SlotProps<T extends HTMLElement = HTMLElement> = {
children?: React.ReactElement;
} & DOMMotionProps<T>;
function mergeRefs<T>(
...refs: (React.Ref<T> | undefined)[]
): React.RefCallback<T> {
return (node) => {
refs.forEach((ref) => {
if (!ref) return;
if (typeof ref === "function") {
ref(node);
} else {
(ref as React.RefObject<T | null>).current = node;
}
});
};
}
function mergeProps<T extends HTMLElement>(
childProps: AnyProps,
slotProps: DOMMotionProps<T>,
): AnyProps {
const merged: AnyProps = { ...childProps, ...slotProps };
if (childProps.className || slotProps.className) {
merged.className = cn(
childProps.className as string,
slotProps.className as string,
);
}
if (childProps.style || slotProps.style) {
merged.style = {
...(childProps.style as React.CSSProperties),
...(slotProps.style as React.CSSProperties),
};
}
return merged;
}
function Slot<T extends HTMLElement = HTMLElement>({
children,
ref,
...props
}: SlotProps<T>) {
const isAlreadyMotion = React.useMemo(() => {
if (!React.isValidElement(children)) return false;
return (
typeof children.type === "object" &&
children.type !== null &&
isMotionComponent(children.type)
);
}, [children]);
const Base = React.useMemo(() => {
if (!React.isValidElement(children)) return motion.div;
return isAlreadyMotion
? (children.type as React.ElementType)
: motion.create(children.type as React.ElementType);
}, [isAlreadyMotion, children]);
if (!React.isValidElement(children)) return null;
const { ref: childRef, ...childProps } = children.props as AnyProps;
const mergedProps = mergeProps(childProps, props);
return (
<Base {...mergedProps} ref={mergeRefs(childRef as React.Ref<T>, ref)} />
);
}
export {
Slot,
type SlotProps,
type WithAsChild,
type DOMMotionProps,
type AnyProps,
};
+152
View File
@@ -16,6 +16,11 @@ export interface ThemeColors extends Record<string, string> {
"--destructive": string;
"--destructive-foreground": string;
"--border": string;
"--chart-1": string;
"--chart-2": string;
"--chart-3": string;
"--chart-4": string;
"--chart-5": string;
}
export interface Theme {
@@ -46,6 +51,11 @@ export const THEMES: Theme[] = [
"--destructive": "#f7768e",
"--destructive-foreground": "#1a1b26",
"--border": "#3b4261",
"--chart-1": "#7aa2f7",
"--chart-2": "#9ece6a",
"--chart-3": "#bb9af7",
"--chart-4": "#2ac3de",
"--chart-5": "#ff9e64",
},
},
{
@@ -69,6 +79,11 @@ export const THEMES: Theme[] = [
"--destructive": "#ff5555",
"--destructive-foreground": "#f8f8f2",
"--border": "#6272a4",
"--chart-1": "#bd93f9",
"--chart-2": "#50fa7b",
"--chart-3": "#ff79c6",
"--chart-4": "#8be9fd",
"--chart-5": "#ffb86c",
},
},
{
@@ -92,6 +107,11 @@ export const THEMES: Theme[] = [
"--destructive": "#ff819f",
"--destructive-foreground": "#273136",
"--border": "#304e37",
"--chart-1": "#7eb08a",
"--chart-2": "#d2b48c",
"--chart-3": "#7ea4b0",
"--chart-4": "#a8c97f",
"--chart-5": "#e6c07b",
},
},
{
@@ -115,6 +135,11 @@ export const THEMES: Theme[] = [
"--destructive": "#ef4444",
"--destructive-foreground": "#f7f7f8",
"--border": "#2a2e39",
"--chart-1": "#5755d9",
"--chart-2": "#0ea5e9",
"--chart-3": "#f25f4c",
"--chart-4": "#22c55e",
"--chart-5": "#f59e0b",
},
},
{
@@ -138,6 +163,11 @@ export const THEMES: Theme[] = [
"--destructive": "#f07178",
"--destructive-foreground": "#b3b1ad",
"--border": "#1f2430",
"--chart-1": "#39bae6",
"--chart-2": "#c2d94c",
"--chart-3": "#d2a6ff",
"--chart-4": "#ffb454",
"--chart-5": "#f07178",
},
},
{
@@ -161,6 +191,123 @@ export const THEMES: Theme[] = [
"--destructive": "#f07178",
"--destructive-foreground": "#fafafa",
"--border": "#e7eaed",
"--chart-1": "#399ee6",
"--chart-2": "#86b300",
"--chart-3": "#a37acc",
"--chart-4": "#fa8d3e",
"--chart-5": "#f07178",
},
},
{
id: "catppuccin-latte",
name: "Catppuccin Latte",
colors: {
"--background": "#eff1f5",
"--foreground": "#4c4f69",
"--card": "#ccd0da",
"--card-foreground": "#4c4f69",
"--popover": "#ccd0da",
"--popover-foreground": "#4c4f69",
"--primary": "#1e66f5",
"--primary-foreground": "#eff1f5",
"--secondary": "#04a5e5",
"--secondary-foreground": "#eff1f5",
"--muted": "#bcc0cc",
"--muted-foreground": "#5c5f77",
"--accent": "#8839ef",
"--accent-foreground": "#eff1f5",
"--destructive": "#d20f39",
"--destructive-foreground": "#eff1f5",
"--border": "#9ca0b0",
"--chart-1": "#1e66f5",
"--chart-2": "#40a02b",
"--chart-3": "#8839ef",
"--chart-4": "#04a5e5",
"--chart-5": "#df8e1d",
},
},
{
id: "catppuccin-frappe",
name: "Catppuccin Frappe",
colors: {
"--background": "#303446",
"--foreground": "#c6d0f5",
"--card": "#414559",
"--card-foreground": "#c6d0f5",
"--popover": "#414559",
"--popover-foreground": "#c6d0f5",
"--primary": "#8caaee",
"--primary-foreground": "#303446",
"--secondary": "#99d1db",
"--secondary-foreground": "#303446",
"--muted": "#51576d",
"--muted-foreground": "#b5bfe2",
"--accent": "#ca9ee6",
"--accent-foreground": "#303446",
"--destructive": "#e78284",
"--destructive-foreground": "#303446",
"--border": "#737994",
"--chart-1": "#8caaee",
"--chart-2": "#a6d189",
"--chart-3": "#ca9ee6",
"--chart-4": "#99d1db",
"--chart-5": "#e5c890",
},
},
{
id: "catppuccin-macchiato",
name: "Catppuccin Macchiato",
colors: {
"--background": "#24273a",
"--foreground": "#cad3f5",
"--card": "#363a4f",
"--card-foreground": "#cad3f5",
"--popover": "#363a4f",
"--popover-foreground": "#cad3f5",
"--primary": "#8aadf4",
"--primary-foreground": "#24273a",
"--secondary": "#91d7e3",
"--secondary-foreground": "#24273a",
"--muted": "#494d64",
"--muted-foreground": "#b8c0e0",
"--accent": "#c6a0f6",
"--accent-foreground": "#24273a",
"--destructive": "#ed8796",
"--destructive-foreground": "#24273a",
"--border": "#6e738d",
"--chart-1": "#8aadf4",
"--chart-2": "#a6da95",
"--chart-3": "#c6a0f6",
"--chart-4": "#91d7e3",
"--chart-5": "#eed49f",
},
},
{
id: "catppuccin-mocha",
name: "Catppuccin Mocha",
colors: {
"--background": "#1e1e2e",
"--foreground": "#cdd6f4",
"--card": "#313244",
"--card-foreground": "#cdd6f4",
"--popover": "#313244",
"--popover-foreground": "#cdd6f4",
"--primary": "#89b4fa",
"--primary-foreground": "#1e1e2e",
"--secondary": "#89dceb",
"--secondary-foreground": "#1e1e2e",
"--muted": "#45475a",
"--muted-foreground": "#bac2de",
"--accent": "#cba6f7",
"--accent-foreground": "#1e1e2e",
"--destructive": "#f38ba8",
"--destructive-foreground": "#1e1e2e",
"--border": "#585b70",
"--chart-1": "#89b4fa",
"--chart-2": "#a6e3a1",
"--chart-3": "#cba6f7",
"--chart-4": "#89dceb",
"--chart-5": "#f9e2af",
},
},
];
@@ -184,6 +331,11 @@ export const THEME_VARIABLES: Array<{ key: keyof ThemeColors; label: string }> =
{ key: "--destructive", label: "Destructive" },
{ key: "--destructive-foreground", label: "Destructive FG" },
{ key: "--border", label: "Border" },
{ key: "--chart-1", label: "Chart 1" },
{ key: "--chart-2", label: "Chart 2" },
{ key: "--chart-3", label: "Chart 3" },
{ key: "--chart-4", label: "Chart 4" },
{ key: "--chart-5", label: "Chart 5" },
];
export function getThemeById(id: string): Theme | undefined {
+10
View File
@@ -79,6 +79,11 @@
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
--sidebar-border: oklch(0.92 0.004 286.32);
--sidebar-ring: oklch(0.705 0.015 286.067);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
}
.dark {
@@ -113,6 +118,11 @@
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.552 0.016 285.938);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
}
@layer base {
+61 -1
View File
@@ -7,7 +7,7 @@ export interface ProxySettings {
}
export interface TableSortingSettings {
column: string; // "name", "browser", "status"
column: string; // "name", "note", "status"
direction: string; // "asc" or "desc"
}
@@ -23,6 +23,7 @@ export interface BrowserProfile {
camoufox_config?: CamoufoxConfig; // Camoufox configuration
group_id?: string; // Reference to profile group
tags?: string[];
note?: string; // User note
}
export interface ProxyCheckResult {
@@ -82,6 +83,8 @@ export interface AppUpdateProgress {
message: string;
}
export type CamoufoxOS = "windows" | "macos" | "linux";
export interface CamoufoxConfig {
proxy?: string;
screen_max_width?: number;
@@ -95,6 +98,7 @@ export interface CamoufoxConfig {
executable_path?: string;
fingerprint?: string; // JSON string of the complete fingerprint config
randomize_fingerprint_on_launch?: boolean; // Generate new fingerprint on every launch
os?: CamoufoxOS; // Operating system for fingerprint generation
}
// Extended interface for the advanced fingerprint configuration
@@ -264,3 +268,59 @@ export interface CamoufoxLaunchResult {
profilePath?: string;
url?: string;
}
// Traffic stats types
export interface BandwidthDataPoint {
timestamp: number;
bytes_sent: number;
bytes_received: number;
}
export interface DomainAccess {
domain: string;
request_count: number;
bytes_sent: number;
bytes_received: number;
first_access: number;
last_access: number;
}
export interface TrafficStats {
proxy_id: string;
profile_id?: string;
session_start: number;
last_update: number;
total_bytes_sent: number;
total_bytes_received: number;
total_requests: number;
bandwidth_history: BandwidthDataPoint[];
domains: Record<string, DomainAccess>;
unique_ips: string[];
}
export interface TrafficSnapshot {
profile_id?: string;
session_start: number;
last_update: number;
total_bytes_sent: number;
total_bytes_received: number;
total_requests: number;
current_bytes_sent: number;
current_bytes_received: number;
recent_bandwidth: BandwidthDataPoint[];
}
export interface FilteredTrafficStats {
profile_id?: string;
session_start: number;
last_update: number;
total_bytes_sent: number;
total_bytes_received: number;
total_requests: number;
bandwidth_history: BandwidthDataPoint[];
period_bytes_sent: number;
period_bytes_received: number;
period_requests: number;
domains: Record<string, DomainAccess>;
unique_ips: string[];
}
+4 -2
View File
@@ -13,7 +13,7 @@
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
@@ -29,7 +29,9 @@
"**/*.tsx",
".next/types/**/*.ts",
"next-env.d.ts",
"dist/types/**/*.ts"
"dist/types/**/*.ts",
".next/dev/types/**/*.ts",
"dist/dev/types/**/*.ts"
],
"exclude": ["node_modules", "nodecar", "src-tauri/target"]
}